| 1 | use askama::Template; |
| 2 | use axum::{ |
| 3 | extract::{Path, Query, State}, |
| 4 | http::StatusCode, |
| 5 | response::{Html, IntoResponse}, |
| 6 | }; |
| 7 | use serde::Deserialize; |
| 8 | use sqlx::SqlitePool; |
| 9 | use std::sync::Arc; |
| 10 | |
| 11 | use crate::config::Config; |
| 12 | use crate::git; |
| 13 | use crate::repos; |
| 14 | |
| 15 | #[derive(Clone)] |
| 16 | pub struct WebState { |
| 17 | pub cfg: Arc<Config>, |
| 18 | pub pool: SqlitePool, |
| 19 | } |
| 20 | |
| 21 | // --- Template structs --- |
| 22 | |
| 23 | #[derive(Debug, Clone)] |
| 24 | pub struct RepoSummary { |
| 25 | pub name: String, |
| 26 | pub hash: Option<String>, |
| 27 | } |
| 28 | |
| 29 | #[derive(Debug, Clone)] |
| 30 | pub struct Breadcrumb { |
| 31 | pub name: String, |
| 32 | pub path: String, |
| 33 | } |
| 34 | |
| 35 | #[derive(Debug, Clone)] |
| 36 | pub struct RepoWithHash { |
| 37 | pub owner: String, |
| 38 | pub name: String, |
| 39 | pub hash: Option<String>, |
| 40 | } |
| 41 | |
| 42 | #[derive(Template)] |
| 43 | #[template(path = "home.html")] |
| 44 | struct HomeTemplate { |
| 45 | site_name: String, |
| 46 | ssh_host: String, |
| 47 | ssh_port: String, |
| 48 | user_count: i64, |
| 49 | repo_count: i64, |
| 50 | repositories: Vec<RepoWithHash>, |
| 51 | } |
| 52 | |
| 53 | #[derive(Template)] |
| 54 | #[template(path = "user.html")] |
| 55 | struct UserTemplate { |
| 56 | site_name: String, |
| 57 | username: String, |
| 58 | joined: String, |
| 59 | repos: Vec<RepoSummary>, |
| 60 | } |
| 61 | |
| 62 | #[derive(Template)] |
| 63 | #[template(path = "repo.html")] |
| 64 | struct RepoTemplate { |
| 65 | site_name: String, |
| 66 | ssh_port: String, |
| 67 | owner: String, |
| 68 | repo: String, |
| 69 | branch: String, |
| 70 | subpath: String, |
| 71 | entries: Vec<git::TreeEntry>, |
| 72 | readme: Option<String>, |
| 73 | breadcrumbs: Vec<Breadcrumb>, |
| 74 | } |
| 75 | |
| 76 | #[derive(Template)] |
| 77 | #[template(path = "blob.html")] |
| 78 | struct BlobTemplate { |
| 79 | site_name: String, |
| 80 | owner: String, |
| 81 | repo: String, |
| 82 | branch: String, |
| 83 | filename: String, |
| 84 | lines: Vec<String>, |
| 85 | line_count: usize, |
| 86 | size: usize, |
| 87 | is_binary: bool, |
| 88 | breadcrumbs: Vec<Breadcrumb>, |
| 89 | } |
| 90 | |
| 91 | #[derive(Template)] |
| 92 | #[template(path = "commits.html")] |
| 93 | struct CommitsTemplate { |
| 94 | site_name: String, |
| 95 | owner: String, |
| 96 | repo: String, |
| 97 | branch: String, |
| 98 | commits: Vec<git::CommitInfo>, |
| 99 | page: usize, |
| 100 | has_next: bool, |
| 101 | } |
| 102 | |
| 103 | #[derive(Template)] |
| 104 | #[template(path = "not_found.html")] |
| 105 | struct NotFoundTemplate { |
| 106 | site_name: String, |
| 107 | message: String, |
| 108 | } |
| 109 | |
| 110 | fn render_404(st: &WebState, msg: &str) -> axum::response::Response { |
| 111 | let t = NotFoundTemplate { |
| 112 | site_name: st.cfg.site_name.clone(), |
| 113 | message: msg.to_string(), |
| 114 | }; |
| 115 | (StatusCode::NOT_FOUND, Html(t.render().unwrap_or_else(|_| msg.to_string()))).into_response() |
| 116 | } |
| 117 | |
| 118 | fn build_breadcrumbs(subpath: &str) -> Vec<Breadcrumb> { |
| 119 | if subpath.is_empty() { |
| 120 | return Vec::new(); |
| 121 | } |
| 122 | let mut crumbs = Vec::new(); |
| 123 | let mut accumulated = String::new(); |
| 124 | for seg in subpath.split('/') { |
| 125 | if seg.is_empty() { continue; } |
| 126 | if !accumulated.is_empty() { |
| 127 | accumulated.push('/'); |
| 128 | } |
| 129 | accumulated.push_str(seg); |
| 130 | crumbs.push(Breadcrumb { |
| 131 | name: seg.to_string(), |
| 132 | path: accumulated.clone(), |
| 133 | }); |
| 134 | } |
| 135 | crumbs |
| 136 | } |
| 137 | |
| 138 | /// Homepage: `/` |
| 139 | pub async fn homepage(State(st): State<WebState>) -> axum::response::Response { |
| 140 | let user_count = sqlx::query_scalar!(r#"SELECT COUNT(*) as "c!: i64" FROM users"#) |
| 141 | .fetch_one(&st.pool).await.unwrap_or(0); |
| 142 | let repo_count = sqlx::query_scalar!(r#"SELECT COUNT(*) as "c!: i64" FROM repos"#) |
| 143 | .fetch_one(&st.pool).await.unwrap_or(0); |
| 144 | |
| 145 | let rows = sqlx::query!( |
| 146 | r#"SELECT repos.name as "name!: String", users.username as "owner!: String" |
| 147 | FROM repos JOIN users ON users.id = repos.owner_id |
| 148 | ORDER BY repos.created_at DESC LIMIT 50"# |
| 149 | ).fetch_all(&st.pool).await.unwrap_or_default(); |
| 150 | |
| 151 | let repos_root = st.cfg.repos_root.clone(); |
| 152 | let repositories: Vec<RepoWithHash> = rows.into_iter().map(|r| { |
| 153 | let hash = repos::head_hash(&repos_root, &r.owner, &r.name) |
| 154 | .map(|h| h[..7.min(h.len())].to_string()); |
| 155 | RepoWithHash { owner: r.owner, name: r.name, hash } |
| 156 | }).collect(); |
| 157 | |
| 158 | // Extract SSH port from bind address (e.g. "0.0.0.0:2222" -> "2222") |
| 159 | let ssh_port = st.cfg.ssh_bind.rsplit(':').next().unwrap_or("22").to_string(); |
| 160 | |
| 161 | let t = HomeTemplate { |
| 162 | site_name: st.cfg.site_name.clone(), |
| 163 | ssh_host: st.cfg.site_name.clone(), |
| 164 | ssh_port, |
| 165 | user_count, |
| 166 | repo_count, |
| 167 | repositories, |
| 168 | }; |
| 169 | Html(t.render().unwrap_or_default()).into_response() |
| 170 | } |
| 171 | |
| 172 | /// User profile page: `/:username` |
| 173 | pub async fn user_page( |
| 174 | State(st): State<WebState>, |
| 175 | Path(username): Path<String>, |
| 176 | ) -> axum::response::Response { |
| 177 | let user = sqlx::query!( |
| 178 | r#"SELECT id as "id!: i64", username as "username!: String", created_at as "created_at!: String" |
| 179 | FROM users WHERE username = ?"#, |
| 180 | username |
| 181 | ) |
| 182 | .fetch_optional(&st.pool) |
| 183 | .await; |
| 184 | |
| 185 | let user = match user { |
| 186 | Ok(Some(u)) => u, |
| 187 | _ => return render_404(&st, "user not found"), |
| 188 | }; |
| 189 | |
| 190 | let rows = sqlx::query!( |
| 191 | r#"SELECT name as "name!: String" FROM repos WHERE owner_id = ? ORDER BY name"#, |
| 192 | user.id |
| 193 | ) |
| 194 | .fetch_all(&st.pool) |
| 195 | .await |
| 196 | .unwrap_or_default(); |
| 197 | |
| 198 | let repos_root = st.cfg.repos_root.clone(); |
| 199 | let uname = user.username.clone(); |
| 200 | let repo_list: Vec<RepoSummary> = rows |
| 201 | .into_iter() |
| 202 | .map(|r| { |
| 203 | let hash = repos::head_hash(&repos_root, &uname, &r.name) |
| 204 | .map(|h| h[..7.min(h.len())].to_string()); |
| 205 | RepoSummary { name: r.name, hash } |
| 206 | }) |
| 207 | .collect(); |
| 208 | |
| 209 | let joined = user.created_at.split('T').next().unwrap_or(&user.created_at).to_string(); |
| 210 | |
| 211 | let t = UserTemplate { |
| 212 | site_name: st.cfg.site_name.clone(), |
| 213 | username: user.username, |
| 214 | joined, |
| 215 | repos: repo_list, |
| 216 | }; |
| 217 | Html(t.render().unwrap_or_default()).into_response() |
| 218 | } |
| 219 | |
| 220 | /// Repo tree page: `/:owner/:repo` or `/:owner/:repo/tree/:branch/*path` |
| 221 | pub async fn repo_page( |
| 222 | State(st): State<WebState>, |
| 223 | Path(params): Path<Vec<(String, String)>>, |
| 224 | ) -> axum::response::Response { |
| 225 | // Extract params from the path |
| 226 | let owner = params.get(0).map(|p| p.1.as_str()).unwrap_or(""); |
| 227 | let repo = params.get(1).map(|p| p.1.as_str()).unwrap_or(""); |
| 228 | |
| 229 | if !repos::repo_exists(&st.pool, owner, repo).await { |
| 230 | return render_404(&st, "repository not found"); |
| 231 | } |
| 232 | |
| 233 | let repos_root = st.cfg.repos_root.clone(); |
| 234 | let branch = git::get_default_branch(&repos_root, owner, repo) |
| 235 | .unwrap_or_else(|| "main".to_string()); |
| 236 | |
| 237 | render_tree(&st, owner, repo, &branch, "").await |
| 238 | } |
| 239 | |
| 240 | pub async fn repo_tree_page( |
| 241 | State(st): State<WebState>, |
| 242 | Path((owner, repo, branch, path)): Path<(String, String, String, String)>, |
| 243 | ) -> axum::response::Response { |
| 244 | if !repos::repo_exists(&st.pool, &owner, &repo).await { |
| 245 | return render_404(&st, "repository not found"); |
| 246 | } |
| 247 | render_tree(&st, &owner, &repo, &branch, &path).await |
| 248 | } |
| 249 | |
| 250 | async fn render_tree( |
| 251 | st: &WebState, |
| 252 | owner: &str, |
| 253 | repo: &str, |
| 254 | branch: &str, |
| 255 | subpath: &str, |
| 256 | ) -> axum::response::Response { |
| 257 | let repos_root = st.cfg.repos_root.clone(); |
| 258 | let o = owner.to_string(); |
| 259 | let r = repo.to_string(); |
| 260 | let b = branch.to_string(); |
| 261 | let sp = subpath.to_string(); |
| 262 | |
| 263 | let result = tokio::task::spawn_blocking(move || { |
| 264 | let entries = git::list_tree(&repos_root, &o, &r, &b, &sp).unwrap_or_default(); |
| 265 | let readme = if entries.is_empty() { |
| 266 | None |
| 267 | } else { |
| 268 | git::get_readme(&repos_root, &o, &r, &b, &sp) |
| 269 | }; |
| 270 | (entries, readme) |
| 271 | }) |
| 272 | .await; |
| 273 | |
| 274 | let (entries, readme) = result.unwrap_or_default(); |
| 275 | |
| 276 | let ssh_port = st.cfg.ssh_bind.rsplit(':').next().unwrap_or("22").to_string(); |
| 277 | |
| 278 | let t = RepoTemplate { |
| 279 | site_name: st.cfg.site_name.clone(), |
| 280 | ssh_port, |
| 281 | owner: owner.to_string(), |
| 282 | repo: repo.to_string(), |
| 283 | branch: branch.to_string(), |
| 284 | subpath: subpath.to_string(), |
| 285 | entries, |
| 286 | readme, |
| 287 | breadcrumbs: build_breadcrumbs(subpath), |
| 288 | }; |
| 289 | Html(t.render().unwrap_or_default()).into_response() |
| 290 | } |
| 291 | |
| 292 | /// Blob page: `/:owner/:repo/blob/:branch/*path` |
| 293 | pub async fn blob_page( |
| 294 | State(st): State<WebState>, |
| 295 | Path((owner, repo, branch, path)): Path<(String, String, String, String)>, |
| 296 | ) -> axum::response::Response { |
| 297 | if !repos::repo_exists(&st.pool, &owner, &repo).await { |
| 298 | return render_404(&st, "repository not found"); |
| 299 | } |
| 300 | |
| 301 | let repos_root = st.cfg.repos_root.clone(); |
| 302 | let o = owner.clone(); |
| 303 | let r = repo.clone(); |
| 304 | let b = branch.clone(); |
| 305 | let p = path.clone(); |
| 306 | |
| 307 | let result = tokio::task::spawn_blocking(move || { |
| 308 | git::get_blob(&repos_root, &o, &r, &b, &p) |
| 309 | }) |
| 310 | .await; |
| 311 | |
| 312 | let bytes = match result { |
| 313 | Ok(Ok(b)) => b, |
| 314 | _ => return render_404(&st, "file not found"), |
| 315 | }; |
| 316 | |
| 317 | let filename = path.rsplit('/').next().unwrap_or(&path).to_string(); |
| 318 | let size = bytes.len(); |
| 319 | |
| 320 | // Detect binary: check for null bytes in first 8KB |
| 321 | let check_len = size.min(8192); |
| 322 | let is_binary = bytes[..check_len].contains(&0); |
| 323 | |
| 324 | let (lines, line_count) = if is_binary { |
| 325 | (Vec::new(), 0) |
| 326 | } else { |
| 327 | let text = String::from_utf8_lossy(&bytes); |
| 328 | let ls: Vec<String> = text.lines().map(|l| l.to_string()).collect(); |
| 329 | let count = ls.len(); |
| 330 | (ls, count) |
| 331 | }; |
| 332 | |
| 333 | let t = BlobTemplate { |
| 334 | site_name: st.cfg.site_name.clone(), |
| 335 | owner, |
| 336 | repo, |
| 337 | branch, |
| 338 | filename, |
| 339 | lines, |
| 340 | line_count, |
| 341 | size, |
| 342 | is_binary, |
| 343 | breadcrumbs: build_breadcrumbs(&path), |
| 344 | }; |
| 345 | Html(t.render().unwrap_or_default()).into_response() |
| 346 | } |
| 347 | |
| 348 | /// Commits page: `/:owner/:repo/commits/:branch` |
| 349 | #[derive(Deserialize)] |
| 350 | pub struct CommitsQuery { |
| 351 | pub page: Option<usize>, |
| 352 | } |
| 353 | |
| 354 | pub async fn commits_page( |
| 355 | State(st): State<WebState>, |
| 356 | Path((owner, repo, branch)): Path<(String, String, String)>, |
| 357 | Query(q): Query<CommitsQuery>, |
| 358 | ) -> axum::response::Response { |
| 359 | if !repos::repo_exists(&st.pool, &owner, &repo).await { |
| 360 | return render_404(&st, "repository not found"); |
| 361 | } |
| 362 | |
| 363 | let page = q.page.unwrap_or(1).max(1); |
| 364 | let per_page = 30; |
| 365 | let skip = (page - 1) * per_page; |
| 366 | |
| 367 | let repos_root = st.cfg.repos_root.clone(); |
| 368 | let o = owner.clone(); |
| 369 | let r = repo.clone(); |
| 370 | let b = branch.clone(); |
| 371 | |
| 372 | let result = tokio::task::spawn_blocking(move || { |
| 373 | git::list_commits(&repos_root, &o, &r, &b, per_page + 1, skip) |
| 374 | }) |
| 375 | .await; |
| 376 | |
| 377 | let mut commits = match result { |
| 378 | Ok(Ok(c)) => c, |
| 379 | _ => Vec::new(), |
| 380 | }; |
| 381 | |
| 382 | let has_next = commits.len() > per_page; |
| 383 | commits.truncate(per_page); |
| 384 | |
| 385 | let t = CommitsTemplate { |
| 386 | site_name: st.cfg.site_name.clone(), |
| 387 | owner, |
| 388 | repo, |
| 389 | branch, |
| 390 | commits, |
| 391 | page, |
| 392 | has_next, |
| 393 | }; |
| 394 | Html(t.render().unwrap_or_default()).into_response() |
| 395 | } |