395 lines · 10732 bytes
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 }