irongit

Git hosting and a container registry in one Rust binary (axum + Astro)

Admin panel: collapsible sidebar, lazy-loaded sections for overview, users, orgs, repos, packages, storage, backups, audit log and logs, with audited actions

huncholanehuncholaneauthored
parent 62c3e49commit a4cbace58795aa7291877ca1fad298ab8d9f3f7fBrowse files

12 files changed, +2446 -9

+0-9backend/src/web/admin.rs
@@ -1,9 +0,0 @@
1-//! Site admin panel at /admin. Stub; filled in by the admin work.
2-
3-use axum::Router;
4-
5-use crate::state::AppState;
6-
7-pub fn router() -> Router<AppState> {
8- Router::new()
9-}
+129-0backend/src/web/admin/audit.rs
@@ -0,0 +1,129 @@
1+//! /admin/audit: who did what, filterable by action prefix and actor.
2+
3+use axum::{
4+ extract::{Query, State},
5+ response::Response,
6+};
7+use chrono::{DateTime, Utc};
8+use maud::html;
9+use serde::Deserialize;
10+
11+use super::{Admin, PAGE_SIZE, Section, TD, page_number, pager, query, render, table, th};
12+use crate::{
13+ error::AppResult,
14+ state::AppState,
15+ web::{
16+ layout::{self, Ctx},
17+ ui,
18+ },
19+};
20+
21+#[derive(Deserialize, Default)]
22+pub struct ListQuery {
23+ #[serde(default)]
24+ action: String,
25+ #[serde(default)]
26+ actor: String,
27+ page: Option<i64>,
28+}
29+
30+impl ListQuery {
31+ fn base(&self, path: &str) -> String {
32+ format!("{path}{}", query(&[("action", self.action.trim()), ("actor", self.actor.trim())]))
33+ }
34+}
35+
36+fn with_page(base: &str, page: i64) -> String {
37+ format!("{base}{}page={page}", if base.contains('?') { "&" } else { "?" })
38+}
39+
40+#[derive(sqlx::FromRow)]
41+pub struct AuditRow {
42+ pub id: i64,
43+ pub action: String,
44+ pub target: String,
45+ pub meta: serde_json::Value,
46+ pub ip: Option<String>,
47+ pub actor: Option<String>,
48+ pub actor_avatar: Option<String>,
49+ pub created_at: DateTime<Utc>,
50+}
51+
52+/// Audit entries newest first. `action` matches as a prefix ("repo." finds
53+/// every repo event); `actor` is an exact account name.
54+pub async fn load(state: &AppState, action: &str, actor: &str, offset: i64, limit: i64) -> AppResult<Vec<AuditRow>> {
55+ let prefix = format!("{}%", action.trim().replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"));
56+ Ok(sqlx::query_as(
57+ "select l.id, l.action, l.target, l.meta, l.ip, a.name::text as actor, a.avatar_key as actor_avatar, l.created_at
58+ from audit_log l left join accounts a on a.id = l.actor_id
59+ where l.action like $1 and ($2 = '' or a.name = $2)
60+ order by l.created_at desc, l.id desc
61+ limit $3 offset $4",
62+ )
63+ .bind(prefix)
64+ .bind(actor.trim())
65+ .bind(limit)
66+ .bind(offset)
67+ .fetch_all(&state.db)
68+ .await?)
69+}
70+
71+pub async fn page(Admin(_admin): Admin, ctx: Ctx, Query(q): Query<ListQuery>) -> Response {
72+ let page = page_number(q.page);
73+ let fragment_url = with_page(&q.base("/admin/f/audit"), page);
74+ let actions: Vec<String> = sqlx::query_scalar("select distinct action from audit_log order by action limit 200")
75+ .fetch_all(&ctx.state.db)
76+ .await
77+ .unwrap_or_default();
78+ let filters = html! {
79+ form method="get" action="/admin/audit" class="flex flex-wrap gap-1" {
80+ input class="input w-56 py-0.5 font-mono" name="action" value=(q.action) placeholder="Action prefix, e.g. admin." list="audit-actions";
81+ datalist id="audit-actions" {
82+ @for action in &actions { option value=(action) {} }
83+ }
84+ input class="input w-40 py-0.5" name="actor" value=(q.actor) placeholder="Actor username";
85+ button class="btn btn-sm" type="submit" { "Filter" }
86+ @if !q.action.is_empty() || !q.actor.is_empty() { a class="btn btn-sm" href="/admin/audit" { "Clear" } }
87+ }
88+ };
89+ let body = html! { div class="box" { (ui::lazy(&fragment_url, 14)) } };
90+ render(&ctx, Section::Audit, "Audit log", filters, body)
91+}
92+
93+pub async fn fragment(Admin(_admin): Admin, State(state): State<AppState>, Query(q): Query<ListQuery>) -> AppResult<Response> {
94+ let page = page_number(q.page);
95+ let rows = load(&state, &q.action, &q.actor, (page - 1) * PAGE_SIZE, PAGE_SIZE + 1).await?;
96+ let has_next = rows.len() as i64 > PAGE_SIZE;
97+ let rows = &rows[..rows.len().min(PAGE_SIZE as usize)];
98+ let head = html! { (th("When")) (th("Actor")) (th("Action")) (th("Target")) (th("Details")) (th("IP")) };
99+ let body = html! {
100+ @for row in rows {
101+ tr class="hover:bg-surface-raised" {
102+ td class={ (TD) " text-xs text-ink-dim" } { (ui::time(row.created_at)) }
103+ td class=(TD) {
104+ @match &row.actor {
105+ Some(actor) => div class="flex items-center gap-1.5" {
106+ (ui::avatar(actor, row.actor_avatar.as_deref(), 18))
107+ a href={ "/admin/audit" (query(&[("actor", actor)])) } { (actor) }
108+ },
109+ None => span class="text-ink-faint" { "system" },
110+ }
111+ }
112+ td class=(TD) { a href={ "/admin/audit" (query(&[("action", &row.action)])) } class="font-mono text-xs text-ember" { (row.action) } }
113+ td class=(TD) { span class="font-mono text-xs" { (row.target) } }
114+ td class={ (TD) " max-w-[32rem] truncate font-mono text-[11px] text-ink-dim" } title=(row.meta.to_string()) {
115+ @if row.meta.as_object().is_some_and(|m| m.is_empty()) { span class="text-ink-faint" { "-" } } @else { (row.meta.to_string()) }
116+ }
117+ td class={ (TD) " font-mono text-[11px] text-ink-faint" } { (row.ip.clone().unwrap_or_default()) }
118+ }
119+ }
120+ };
121+ Ok(layout::fragment(html! {
122+ @if rows.is_empty() {
123+ p class="px-3 py-6 text-center text-ink-faint" { "No audit entries match." }
124+ } @else {
125+ (table(head, body))
126+ (pager(&q.base("/admin/audit"), page, has_next, rows.len()))
127+ }
128+ }))
129+}
+147-0backend/src/web/admin/backups.rs
@@ -0,0 +1,147 @@
1+//! /admin/backups: backup history and a "Back up now" button.
2+
3+use std::time::Duration;
4+
5+use axum::{
6+ Form,
7+ extract::{Query, State},
8+ http::HeaderMap,
9+ response::Response,
10+};
11+use chrono::{DateTime, Utc};
12+use maud::html;
13+use serde::Deserialize;
14+use serde_json::json;
15+
16+use super::{Admin, Notice, Section, TD, back_with, check_origin, notice, record, render, table, th};
17+use crate::{
18+ error::AppResult,
19+ state::AppState,
20+ web::{
21+ layout::{self, Ctx},
22+ ui,
23+ },
24+};
25+
26+/// How long the button waits for a backup before letting it finish in the
27+/// background.
28+const WAIT: Duration = Duration::from_secs(20);
29+
30+#[derive(Deserialize, Default)]
31+pub struct PageQuery {
32+ msg: Option<String>,
33+ err: Option<String>,
34+}
35+
36+pub async fn page(Admin(_admin): Admin, ctx: Ctx, Query(q): Query<PageQuery>) -> Response {
37+ let hours = ctx.state.config.backup_interval_hours;
38+ let actions = html! {
39+ span class="text-xs text-ink-faint" { "Automatic every " (hours) " hour" @if hours != 1 { "s" } " to R2 under " code { "backups/" } }
40+ form method="post" action="/admin/backups/run" {
41+ input type="hidden" name="back" value="/admin/backups";
42+ button class="btn btn-sm btn-primary" type="submit" { "Back up now" }
43+ }
44+ };
45+ let body = html! {
46+ (notice(&Notice { msg: q.msg, err: q.err }))
47+ div class="box" { (ui::lazy("/admin/f/backups", 8)) }
48+ };
49+ render(&ctx, Section::Backups, "Backups", actions, body)
50+}
51+
52+#[derive(sqlx::FromRow)]
53+struct Run {
54+ id: i64,
55+ started_at: DateTime<Utc>,
56+ finished_at: Option<DateTime<Utc>>,
57+ status: String,
58+ repo_count: i32,
59+ bytes: i64,
60+ error: Option<String>,
61+}
62+
63+pub async fn fragment(Admin(_admin): Admin, State(state): State<AppState>) -> AppResult<Response> {
64+ let runs: Vec<Run> = sqlx::query_as(
65+ "select id, started_at, finished_at, status, repo_count, bytes, error from backup_runs order by started_at desc limit 100",
66+ )
67+ .fetch_all(&state.db)
68+ .await?;
69+ let head = html! { (th("#")) (th("Started")) (th("Duration")) (th("Status")) (th("Repos")) (th("Size")) (th("Error")) };
70+ let body = html! {
71+ @for run in &runs {
72+ tr class="hover:bg-surface-raised" {
73+ td class={ (TD) " font-mono text-xs text-ink-faint" } { (run.id) }
74+ td class={ (TD) " text-xs" } { (ui::time(run.started_at)) }
75+ td class={ (TD) " font-mono text-xs" } {
76+ @match run.finished_at {
77+ Some(done) => (format_duration((done - run.started_at).num_seconds())),
78+ None => span class="text-ink-faint" { "running" },
79+ }
80+ }
81+ td class=(TD) {
82+ @match run.status.as_str() {
83+ "ok" => span class="tag border-ok/50 text-ok" { "OK" },
84+ "failed" => span class="tag border-danger/50 text-danger" { "Failed" },
85+ _ => span class="tag" { "Running" },
86+ }
87+ }
88+ td class={ (TD) " text-right font-mono text-xs" } { (run.repo_count) }
89+ td class={ (TD) " text-right font-mono text-xs" } { (ui::bytes(run.bytes.max(0) as u64)) }
90+ td class={ (TD) " max-w-[40rem] truncate text-xs text-danger" } title=(run.error.clone().unwrap_or_default()) { (run.error.clone().unwrap_or_default()) }
91+ }
92+ }
93+ };
94+ Ok(layout::fragment(html! {
95+ @if runs.is_empty() {
96+ p class="px-3 py-6 text-center text-ink-faint" { "No backups have run yet." }
97+ } @else {
98+ (table(head, body))
99+ }
100+ }))
101+}
102+
103+fn format_duration(seconds: i64) -> String {
104+ match seconds.max(0) {
105+ s if s < 60 => format!("{s}s"),
106+ s if s < 3600 => format!("{}m {}s", s / 60, s % 60),
107+ s => format!("{}h {}m", s / 3600, (s % 3600) / 60),
108+ }
109+}
110+
111+#[derive(Deserialize)]
112+pub struct RunForm {
113+ back: Option<String>,
114+}
115+
116+pub async fn run(Admin(admin): Admin, State(state): State<AppState>, headers: HeaderMap, Form(form): Form<RunForm>) -> Response {
117+ if let Err(response) = check_origin(&headers, &state) {
118+ return response;
119+ }
120+ record(&state, &admin, &headers, "admin.backup.start", "backups", json!({})).await;
121+ let task_state = state.clone();
122+ let handle = tokio::spawn(async move { crate::backup::run_now(&task_state).await });
123+ let result = match tokio::time::timeout(WAIT, handle).await {
124+ Ok(Ok(Ok(summary))) => Ok(format!("Backup finished: {summary}")),
125+ Ok(Ok(Err(error))) => {
126+ tracing::warn!(?error, "manual backup failed");
127+ record(&state, &admin, &headers, "admin.backup.failed", "backups", json!({ "error": format!("{error:#}") })).await;
128+ Err(format!("Backup failed: {error:#}"))
129+ }
130+ Ok(Err(join_error)) => {
131+ tracing::error!(%join_error, "manual backup task panicked");
132+ Err("The backup task crashed; see the server log.".to_string())
133+ }
134+ Err(_) => Ok("The backup is still running in the background. Refresh this page to see it finish.".to_string()),
135+ };
136+ back_with(form.back.as_deref(), "/admin/backups", result)
137+}
138+
139+#[cfg(test)]
140+mod tests {
141+ #[test]
142+ fn durations() {
143+ assert_eq!(super::format_duration(5), "5s");
144+ assert_eq!(super::format_duration(125), "2m 5s");
145+ assert_eq!(super::format_duration(7300), "2h 1m");
146+ }
147+}
+307-0backend/src/web/admin/logs.rs
@@ -0,0 +1,307 @@
1+//! /admin/logs: tail the server, frontend and hook logs in LOG_DIR.
2+//!
3+//! Only files listed from LOG_DIR can be opened: the `file` parameter is
4+//! matched against that listing by name, never joined onto a path.
5+
6+use std::{
7+ io::{Read, Seek, SeekFrom},
8+ path::{Path, PathBuf},
9+ time::SystemTime,
10+};
11+
12+use axum::{
13+ extract::{Query, State},
14+ response::Response,
15+};
16+use maud::{PreEscaped, html};
17+use serde::Deserialize;
18+
19+use super::{Admin, Section, query, render};
20+use crate::{
21+ error::{AppError, AppResult},
22+ state::AppState,
23+ web::{
24+ layout::{self, Ctx},
25+ ui,
26+ },
27+};
28+
29+/// The newest bytes of a file that are searched; enough for days of logs
30+/// without reading gigabytes.
31+const WINDOW: u64 = 8 * 1024 * 1024;
32+const LINE_CHOICES: &[usize] = &[100, 300, 1000, 3000];
33+
34+#[derive(Deserialize, Default, Clone)]
35+pub struct LogQuery {
36+ #[serde(default)]
37+ file: String,
38+ lines: Option<usize>,
39+ #[serde(default)]
40+ q: String,
41+ #[serde(default)]
42+ level: String,
43+}
44+
45+impl LogQuery {
46+ fn lines(&self) -> usize {
47+ self.lines.filter(|n| LINE_CHOICES.contains(n)).unwrap_or(300)
48+ }
49+
50+ fn level(&self) -> &str {
51+ match self.level.as_str() {
52+ "error" | "warn" | "info" | "debug" => self.level.as_str(),
53+ _ => "",
54+ }
55+ }
56+}
57+
58+struct LogFile {
59+ name: String,
60+ path: PathBuf,
61+ size: u64,
62+ modified: SystemTime,
63+}
64+
65+/// Log files in LOG_DIR, newest first.
66+fn list_files(dir: &Path) -> Vec<LogFile> {
67+ let mut files: Vec<LogFile> = std::fs::read_dir(dir)
68+ .into_iter()
69+ .flatten()
70+ .flatten()
71+ .filter_map(|entry| {
72+ let meta = entry.metadata().ok()?;
73+ let name = entry.file_name().into_string().ok()?;
74+ (meta.is_file() && name.ends_with(".log")).then(|| LogFile {
75+ path: entry.path(),
76+ size: meta.len(),
77+ modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
78+ name,
79+ })
80+ })
81+ .collect();
82+ files.sort_by(|a, b| b.modified.cmp(&a.modified).then_with(|| a.name.cmp(&b.name)));
83+ files
84+}
85+
86+pub async fn page(Admin(_admin): Admin, ctx: Ctx, Query(q): Query<LogQuery>) -> Response {
87+ let dir = ctx.state.config.log_dir.clone();
88+ let files = tokio::task::spawn_blocking(move || list_files(&dir)).await.unwrap_or_default();
89+ let selected = files.iter().find(|f| f.name == q.file).or_else(|| files.iter().find(|f| f.name.starts_with("server"))).or(files.first());
90+ let selected_name = selected.map(|f| f.name.clone()).unwrap_or_default();
91+ let level = q.level();
92+ let fragment_url = format!(
93+ "/admin/f/logs{}",
94+ query(&[("file", &selected_name), ("lines", &q.lines().to_string()), ("q", &q.q), ("level", level)])
95+ );
96+ let filters = html! {
97+ form method="get" action="/admin/logs" class="flex flex-wrap items-center gap-1" {
98+ select class="input w-auto max-w-72 py-0.5 font-mono text-xs" name="file" data-autosubmit {
99+ @for f in &files {
100+ option value=(f.name) selected[f.name == selected_name] { (f.name) " (" (ui::bytes(f.size)) ")" }
101+ }
102+ }
103+ select class="input w-auto py-0.5" name="level" data-autosubmit {
104+ option value="" selected[level.is_empty()] { "All levels" }
105+ option value="error" selected[level == "error"] { "Errors" }
106+ option value="warn" selected[level == "warn"] { "Warnings and errors" }
107+ option value="info" selected[level == "info"] { "Info and above" }
108+ option value="debug" selected[level == "debug"] { "Debug and above" }
109+ }
110+ select class="input w-auto py-0.5" name="lines" data-autosubmit {
111+ @for n in LINE_CHOICES {
112+ option value=(n) selected[*n == q.lines()] { "Last " (n) }
113+ }
114+ }
115+ input class="input w-56 py-0.5" type="search" name="q" value=(q.q) placeholder="Contains text";
116+ button class="btn btn-sm" type="submit" { "Apply" }
117+ label class="ml-1 flex items-center gap-1 text-xs text-ink-dim" {
118+ input type="checkbox" id="logs-auto";
119+ "Auto-refresh"
120+ }
121+ }
122+ };
123+ let body = html! {
124+ @if files.is_empty() {
125+ (ui::empty_state("No log files yet", html! { "Logs are written to " code { (ctx.state.config.log_dir.display()) } "." }))
126+ } @else {
127+ div class="box" id="logs-box" { (ui::lazy(&fragment_url, 18)) }
128+ p class="mt-1 text-[11px] break-all text-ink-faint" { "Newest lines first. Directory: " code { (ctx.state.config.log_dir.display()) } }
129+ script {
130+ (PreEscaped(format!(r#"
131+(() => {{
132+ const url = {url};
133+ const box = document.getElementById("logs-box");
134+ const auto = document.getElementById("logs-auto");
135+ let timer;
136+ async function refresh() {{
137+ try {{
138+ const response = await fetch(url, {{ headers: {{ "x-requested-with": "fetch" }} }});
139+ if (response.ok) box.innerHTML = await response.text();
140+ }} catch (error) {{ console.warn("log refresh failed", error); }}
141+ }}
142+ auto?.addEventListener("change", () => {{
143+ clearInterval(timer);
144+ if (auto.checked) {{ refresh(); timer = setInterval(refresh, 5000); }}
145+ }});
146+}})();
147+"#, url = serde_json::to_string(&fragment_url).unwrap_or_default())))
148+ }
149+ }
150+ };
151+ render(&ctx, Section::Logs, "Logs", filters, body)
152+}
153+
154+pub async fn fragment(Admin(_admin): Admin, State(state): State<AppState>, Query(q): Query<LogQuery>) -> AppResult<Response> {
155+ let dir = state.config.log_dir.clone();
156+ let wanted = q.clone();
157+ let result = tokio::task::spawn_blocking(move || -> anyhow::Result<Option<(String, Vec<String>, u64, u64, bool)>> {
158+ let files = list_files(&dir);
159+ let Some(file) = files.into_iter().find(|f| f.name == wanted.file) else { return Ok(None) };
160+ let (lines, scanned, truncated) = tail(&file.path, wanted.lines(), wanted.level(), wanted.q.trim())?;
161+ Ok(Some((file.name, lines, scanned, file.size, truncated)))
162+ })
163+ .await
164+ .map_err(|e| AppError::Internal(e.into()))??;
165+ let Some((name, lines, scanned, size, truncated)) = result else {
166+ return Ok(layout::fragment(html! { p class="px-3 py-6 text-center text-ink-faint" { "That log file does not exist." } }));
167+ };
168+ Ok(layout::fragment(html! {
169+ div class="box-head text-xs text-ink-dim" {
170+ code class="text-ink" { (name) }
171+ span { (lines.len()) " line" @if lines.len() != 1 { "s" } }
172+ span class="ml-auto" {
173+ "searched " (ui::bytes(scanned)) " of " (ui::bytes(size))
174+ @if truncated { " (older lines not searched)" }
175+ }
176+ }
177+ @if lines.is_empty() {
178+ p class="px-3 py-6 text-center text-ink-faint" { "No lines match." }
179+ } @else {
180+ div class="max-h-[calc(100vh-170px)] overflow-x-hidden overflow-y-auto bg-surface-sunken" {
181+ pre class="px-2 py-1 font-mono text-[11.5px] leading-[17px]" {
182+ @for line in &lines {
183+ div class={ "whitespace-pre-wrap break-all " (line_class(line)) } { (line) }
184+ }
185+ }
186+ }
187+ }
188+ }))
189+}
190+
191+/// Newest-first matching lines from the last `WINDOW` bytes of `path`:
192+/// (lines, bytes searched, whether older content was skipped).
193+fn tail(path: &Path, limit: usize, level: &str, needle: &str) -> anyhow::Result<(Vec<String>, u64, bool)> {
194+ let mut file = std::fs::File::open(path)?;
195+ let size = file.metadata()?.len();
196+ let start = size.saturating_sub(WINDOW);
197+ file.seek(SeekFrom::Start(start))?;
198+ let mut buffer = Vec::with_capacity((size - start) as usize);
199+ file.read_to_end(&mut buffer)?;
200+ let text = String::from_utf8_lossy(&buffer);
201+ // A window that starts mid-line drops that partial first line.
202+ let text = if start > 0 { text.split_once('\n').map(|(_, rest)| rest).unwrap_or("") } else { &text };
203+ let needle = needle.to_lowercase();
204+ let lines: Vec<String> = text
205+ .lines()
206+ .rev()
207+ .map(strip_ansi)
208+ .filter(|line| !line.trim().is_empty())
209+ .filter(|line| level_matches(line, level))
210+ .filter(|line| needle.is_empty() || line.to_lowercase().contains(&needle))
211+ .take(limit)
212+ .collect();
213+ Ok((lines, size - start, start > 0))
214+}
215+
216+fn strip_ansi(line: &str) -> String {
217+ let mut out = String::with_capacity(line.len());
218+ let mut chars = line.chars().peekable();
219+ while let Some(c) = chars.next() {
220+ if c == '\u{1b}' {
221+ // CSI: ESC [ params final-byte
222+ if chars.peek() == Some(&'[') {
223+ chars.next();
224+ for c in chars.by_ref() {
225+ if c.is_ascii_alphabetic() {
226+ break;
227+ }
228+ }
229+ }
230+ continue;
231+ }
232+ out.push(c);
233+ }
234+ out
235+}
236+
237+/// The level token tracing writes after the timestamp.
238+fn line_level(line: &str) -> Option<&'static str> {
239+ let head = line.char_indices().nth(48).map(|(i, _)| &line[..i]).unwrap_or(line);
240+ for (token, level) in [(" ERROR ", "error"), (" WARN ", "warn"), (" INFO ", "info"), (" DEBUG ", "debug"), (" TRACE ", "trace")] {
241+ if head.contains(token) {
242+ return Some(level);
243+ }
244+ }
245+ None
246+}
247+
248+fn level_matches(line: &str, wanted: &str) -> bool {
249+ let rank = |l: &str| match l {
250+ "error" => 4,
251+ "warn" => 3,
252+ "info" => 2,
253+ "debug" => 1,
254+ _ => 0,
255+ };
256+ match wanted {
257+ "" => true,
258+ // Continuation lines (stack traces) have no level; keep them only
259+ // when showing everything.
260+ wanted => line_level(line).is_some_and(|l| rank(l) >= rank(wanted)),
261+ }
262+}
263+
264+fn line_class(line: &str) -> &'static str {
265+ match line_level(line) {
266+ Some("error") => "text-danger",
267+ Some("warn") => "text-warn",
268+ Some("debug" | "trace") => "text-ink-faint",
269+ _ => "text-ink",
270+ }
271+}
272+
273+#[cfg(test)]
274+mod tests {
275+ use super::*;
276+
277+ #[test]
278+ fn strips_color_codes() {
279+ assert_eq!(strip_ansi("\u{1b}[2m2026\u{1b}[0m \u{1b}[32m INFO\u{1b}[0m x"), "2026 INFO x");
280+ }
281+
282+ #[test]
283+ fn level_filtering() {
284+ let error = "2026-10-08T17:41:30.4Z ERROR irongit: boom";
285+ let info = "2026-10-08T17:41:30.4Z INFO irongit: ok";
286+ assert!(level_matches(error, "warn"));
287+ assert!(!level_matches(info, "warn"));
288+ assert!(level_matches(info, ""));
289+ assert!(level_matches(info, "debug"));
290+ }
291+
292+ #[test]
293+ fn tails_newest_first_with_filters() {
294+ let dir = std::env::temp_dir().join(format!("ig-logs-{}", std::process::id()));
295+ std::fs::create_dir_all(&dir).unwrap();
296+ let path = dir.join("server.test.log");
297+ std::fs::write(&path, "t INFO a: one\nt ERROR a: two\nt INFO a: three\n").unwrap();
298+ let (lines, _, truncated) = tail(&path, 10, "", "").unwrap();
299+ assert_eq!(lines, vec!["t INFO a: three", "t ERROR a: two", "t INFO a: one"]);
300+ assert!(!truncated);
301+ let (lines, _, _) = tail(&path, 10, "error", "").unwrap();
302+ assert_eq!(lines, vec!["t ERROR a: two"]);
303+ let (lines, _, _) = tail(&path, 1, "", "o").unwrap();
304+ assert_eq!(lines.len(), 1);
305+ std::fs::remove_dir_all(dir).ok();
306+ }
307+}
+398-0backend/src/web/admin/mod.rs
@@ -0,0 +1,398 @@
1+//! Site admin panel at /admin.
2+//!
3+//! Every page renders its frame (sidebar, header, filters) immediately and
4+//! loads each data section as an HTML fragment through `data-lazy`, so a slow
5+//! query or an R2 listing never blocks the page. Everything here is behind
6+//! `Admin`, which answers 404 to anyone who is not a site admin (signed out
7+//! included) so the panel is not discoverable.
8+
9+mod audit;
10+mod backups;
11+mod logs;
12+mod orgs;
13+mod overview;
14+mod packages;
15+mod repos;
16+mod storage;
17+mod users;
18+
19+use axum::{
20+ Router,
21+ extract::FromRequestParts,
22+ http::{HeaderMap, StatusCode, header, request::Parts},
23+ response::{IntoResponse, Redirect, Response},
24+ routing::{get, post},
25+};
26+use maud::{Markup, PreEscaped, html};
27+
28+use crate::{
29+ auth::{MaybeViewer, Viewer, safe_next, urlencode},
30+ error::AppError,
31+ state::AppState,
32+ web::layout::{Ctx, Page},
33+};
34+
35+pub fn router() -> Router<AppState> {
36+ Router::new()
37+ .route("/admin", get(overview::page))
38+ .route("/admin/f/overview/counts", get(overview::counts))
39+ .route("/admin/f/overview/activity", get(overview::activity))
40+ .route("/admin/f/overview/signups", get(overview::signups))
41+ .route("/admin/f/overview/pushes", get(overview::pushes))
42+ .route("/admin/f/overview/audit", get(overview::recent_audit))
43+ .route("/admin/users", get(users::page))
44+ .route("/admin/f/users", get(users::fragment))
45+ .route("/admin/users/{id}/action", post(users::action))
46+ .route("/admin/orgs", get(orgs::page))
47+ .route("/admin/f/orgs", get(orgs::fragment))
48+ .route("/admin/orgs/{id}/action", post(orgs::action))
49+ .route("/admin/repos", get(repos::page))
50+ .route("/admin/f/repos", get(repos::fragment))
51+ .route("/admin/repos/{id}/action", post(repos::action))
52+ .route("/admin/packages", get(packages::page))
53+ .route("/admin/f/packages", get(packages::fragment))
54+ .route("/admin/packages/{id}/action", post(packages::action))
55+ .route("/admin/packages/gc", post(packages::gc))
56+ .route("/admin/storage", get(storage::page))
57+ .route("/admin/f/storage/db", get(storage::db_totals))
58+ .route("/admin/f/storage/r2", get(storage::r2_prefix))
59+ .route("/admin/f/storage/disk", get(storage::disk))
60+ .route("/admin/backups", get(backups::page))
61+ .route("/admin/f/backups", get(backups::fragment))
62+ .route("/admin/backups/run", post(backups::run))
63+ .route("/admin/audit", get(audit::page))
64+ .route("/admin/f/audit", get(audit::fragment))
65+ .route("/admin/logs", get(logs::page))
66+ .route("/admin/f/logs", get(logs::fragment))
67+}
68+
69+// ---------------------------------------------------------------------------
70+// Access
71+
72+/// A signed-in site admin. Anyone else, including signed-out visitors, gets
73+/// a plain 404, so the panel's existence is not advertised.
74+pub struct Admin(pub Viewer);
75+
76+impl FromRequestParts<AppState> for Admin {
77+ type Rejection = Response;
78+
79+ async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
80+ let MaybeViewer(viewer) = MaybeViewer::from_request_parts(parts, state).await.map_err(IntoResponse::into_response)?;
81+ match viewer {
82+ Some(viewer) if viewer.site_admin() => Ok(Admin(viewer)),
83+ Some(viewer) => {
84+ tracing::warn!(user = %viewer.name, path = %parts.uri.path(), "non-admin tried the admin panel");
85+ Err(AppError::NotFound.into_response())
86+ }
87+ None => Err(AppError::NotFound.into_response()),
88+ }
89+ }
90+}
91+
92+/// Rejects form posts whose Origin (or Referer) is another site. SameSite
93+/// cookies already stop most cross-site posts; this closes the rest.
94+pub fn check_origin(headers: &HeaderMap, state: &AppState) -> Result<(), Response> {
95+ let base = state.config.base_url();
96+ let origin = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok());
97+ let referer = headers.get(header::REFERER).and_then(|v| v.to_str().ok());
98+ let ok = match (origin, referer) {
99+ (Some(origin), _) => origin.trim_end_matches('/') == base,
100+ (None, Some(referer)) => referer == base || referer.starts_with(&format!("{base}/")),
101+ (None, None) => true,
102+ };
103+ if ok {
104+ Ok(())
105+ } else {
106+ tracing::warn!(?origin, ?referer, "admin form post from another origin rejected");
107+ Err((StatusCode::FORBIDDEN, "cross-origin request rejected").into_response())
108+ }
109+}
110+
111+// ---------------------------------------------------------------------------
112+// Redirects with a one-line result message
113+
114+/// Sends the admin back to `back` (an /admin path) with `msg` or `err` set.
115+pub fn back_with(back: Option<&str>, fallback: &str, result: Result<String, String>) -> Response {
116+ let target = match back {
117+ Some(b) if b.starts_with("/admin") => safe_next(Some(b)),
118+ _ => fallback.to_string(),
119+ };
120+ let mut url = url::Url::parse(&format!("http://local{target}")).unwrap_or_else(|_| url::Url::parse("http://local/admin").expect("valid"));
121+ let kept: Vec<(String, String)> =
122+ url.query_pairs().filter(|(k, _)| k != "msg" && k != "err").map(|(k, v)| (k.into_owned(), v.into_owned())).collect();
123+ {
124+ let mut pairs = url.query_pairs_mut();
125+ pairs.clear();
126+ for (k, v) in &kept {
127+ pairs.append_pair(k, v);
128+ }
129+ match &result {
130+ Ok(message) => pairs.append_pair("msg", message),
131+ Err(message) => pairs.append_pair("err", message),
132+ };
133+ }
134+ let location = match url.query() {
135+ Some(q) if !q.is_empty() => format!("{}?{q}", url.path()),
136+ _ => url.path().to_string(),
137+ };
138+ Redirect::to(&location).into_response()
139+}
140+
141+#[derive(serde::Deserialize, Default)]
142+pub struct Notice {
143+ pub msg: Option<String>,
144+ pub err: Option<String>,
145+}
146+
147+pub fn notice(notice: &Notice) -> Markup {
148+ html! {
149+ @if let Some(msg) = notice.msg.as_deref().filter(|m| !m.is_empty()) {
150+ div class="alert alert-ok mb-2 whitespace-pre-wrap py-1" role="status" { (msg) }
151+ }
152+ @if let Some(err) = notice.err.as_deref().filter(|m| !m.is_empty()) {
153+ div class="alert alert-error mb-2 whitespace-pre-wrap py-1" role="alert" { (err) }
154+ }
155+ }
156+}
157+
158+// ---------------------------------------------------------------------------
159+// Frame
160+
161+#[derive(Clone, Copy, PartialEq, Eq)]
162+pub enum Section {
163+ Overview,
164+ Users,
165+ Orgs,
166+ Repos,
167+ Packages,
168+ Storage,
169+ Backups,
170+ Audit,
171+ Logs,
172+}
173+
174+const SECTIONS: &[(Section, &str, &str)] = &[
175+ (Section::Overview, "/admin", "Overview"),
176+ (Section::Users, "/admin/users", "Users"),
177+ (Section::Orgs, "/admin/orgs", "Organizations"),
178+ (Section::Repos, "/admin/repos", "Repositories"),
179+ (Section::Packages, "/admin/packages", "Packages"),
180+ (Section::Storage, "/admin/storage", "Storage"),
181+ (Section::Backups, "/admin/backups", "Backups"),
182+ (Section::Audit, "/admin/audit", "Audit log"),
183+ (Section::Logs, "/admin/logs", "Logs"),
184+];
185+
186+/// The admin frame: collapsible sidebar on the left, the section filling
187+/// everything else edge to edge.
188+pub fn render(ctx: &Ctx, section: Section, title: &str, actions: Markup, body: Markup) -> Response {
189+ let markup = html! {
190+ div id="admin-shell" class="relative flex w-full" style="min-height:calc(100vh - 44px)" {
191+ aside class="admin-aside z-20 flex w-44 shrink-0 flex-col border-r border-edge bg-surface-raised" {
192+ div class="sticky top-0 flex flex-col" {
193+ button type="button" data-sidebar-toggle class="flex h-8 w-full cursor-pointer items-center gap-2.5 border-b border-edge px-3.5 text-left text-xs text-ink-faint hover:bg-surface-hover hover:text-ink" title="Collapse or expand the sidebar" aria-label="Toggle sidebar" {
194+ (icon("toggle"))
195+ span class="sidebar-label font-semibold tracking-wide uppercase" { "Site admin" }
196+ }
197+ nav class="flex flex-col py-1 text-[13px]" {
198+ @for (s, href, label) in SECTIONS {
199+ @let current = *s == section;
200+ a href=(href) title=(label) aria-current=[current.then_some("page")]
201+ class={ "flex h-7 items-center gap-2.5 border-l-2 px-3 no-underline hover:bg-surface-hover hover:no-underline "
202+ @if current { "border-ember bg-surface-hover font-semibold text-ink" } @else { "border-transparent text-ink-dim" } } {
203+ (icon(label))
204+ span class="sidebar-label truncate" { (label) }
205+ }
206+ }
207+ }
208+ }
209+ }
210+ section class="min-w-0 flex-1 px-3 py-2" {
211+ div class="mb-2 flex min-h-7 flex-wrap items-center gap-2" {
212+ h1 class="mr-auto text-[15px] font-semibold" { (title) }
213+ (actions)
214+ }
215+ (body)
216+ }
217+ }
218+ // Delete buttons start disabled and unlock when the name is typed.
219+ // Delegated, because they arrive in lazy fragments after page load.
220+ script {
221+ (PreEscaped(r#"(() => {
222+ // Phones start with the icon rail, so the first tap expands it.
223+ const shell = document.getElementById("admin-shell");
224+ if (shell && !shell.dataset.collapsed && matchMedia("(max-width: 767px)").matches) shell.dataset.collapsed = "true";
225+})();
226+document.addEventListener("input", (event) => {
227+ const input = event.target;
228+ if (!input.matches || !input.matches("input[data-confirm-value]")) return;
229+ const button = input.form && input.form.querySelector("button[type=submit]");
230+ if (button) button.disabled = input.value.trim() !== input.dataset.confirmValue;
231+});"#))
232+ }
233+ };
234+ ctx.render(Page::new(format!("{title} · Admin"), markup).noindex())
235+}
236+
237+fn icon(name: &str) -> Markup {
238+ let path = match name {
239+ "Overview" => "M2 2h5v5H2zM9 2h5v5H9zM2 9h5v5H2zM9 9h5v5H9z",
240+ "Users" => "M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM2.5 14c.5-3 2.7-4.5 5.5-4.5s5 1.5 5.5 4.5",
241+ "Organizations" => "M2 14V4l5-2v12M7 14V6h7v8M9.5 8.5h2M9.5 11h2M4 6h1M4 9h1M1 14h14",
242+ "Repositories" => "M3 2.5A1.5 1.5 0 0 1 4.5 1H13v11H4.5A1.5 1.5 0 0 0 3 13.5zM3 13.5A1.5 1.5 0 0 0 4.5 15H13v-3",
243+ "Packages" => "M8 1.5 14 4.75v6.5L8 14.5 2 11.25v-6.5zM2 4.75 8 8l6-3.25M8 8v6.5",
244+ "Storage" => "M2.5 3.5c0-1.1 2.5-2 5.5-2s5.5.9 5.5 2v9c0 1.1-2.5 2-5.5 2s-5.5-.9-5.5-2zM2.5 3.5c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2M2.5 8c0 1.1 2.5 2 5.5 2s5.5-.9 5.5-2",
245+ "Backups" => "M1.5 2.5h13v3h-13zM2.5 5.5v8h11v-8M6 8.5h4",
246+ "Audit log" => "M3 2h10v12H3zM5.5 5h5M5.5 8h5M5.5 11h3",
247+ "Logs" => "M1.5 2.5h13v11h-13zM4 6l2.5 2L4 10M8 10.5h4",
248+ // Sidebar toggle: a panel with its left column.
249+ _ => "M1.5 2.5h13v11h-13zM5.5 2.5v11",
250+ };
251+ html! {
252+ svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round" stroke-linecap="round" class="shrink-0" aria-hidden="true" {
253+ path d=(path) {}
254+ }
255+ }
256+}
257+
258+// ---------------------------------------------------------------------------
259+// Shared bits
260+
261+pub const PAGE_SIZE: i64 = 50;
262+
263+/// `%term%` for ILIKE with the wildcard characters in `term` escaped.
264+pub fn like_pattern(term: &str) -> String {
265+ let escaped = term.trim().replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
266+ format!("%{escaped}%")
267+}
268+
269+pub fn page_number(page: Option<i64>) -> i64 {
270+ page.unwrap_or(1).clamp(1, 100_000)
271+}
272+
273+/// Previous/next links for a list. `base` is the page URL (not the fragment)
274+/// so the address bar reflects what is shown.
275+pub fn pager(base: &str, page: i64, has_next: bool, shown: usize) -> Markup {
276+ let sep = if base.contains('?') { '&' } else { '?' };
277+ html! {
278+ div class="flex items-center gap-2 px-2 py-1 text-xs text-ink-faint" {
279+ span { "Page " (page) ", " (shown) " shown" }
280+ div class="ml-auto flex gap-1" {
281+ @if page > 1 {
282+ a class="btn btn-sm" href={ (base) (sep) "page=" (page - 1) } { "Previous" }
283+ }
284+ @if has_next {
285+ a class="btn btn-sm" href={ (base) (sep) "page=" (page + 1) } { "Next" }
286+ }
287+ }
288+ }
289+ }
290+}
291+
292+/// A query string from (key, value) pairs, skipping empty values.
293+pub fn query(pairs: &[(&str, &str)]) -> String {
294+ let parts: Vec<String> = pairs.iter().filter(|(_, v)| !v.is_empty()).map(|(k, v)| format!("{k}={}", urlencode(v))).collect();
295+ if parts.is_empty() { String::new() } else { format!("?{}", parts.join("&")) }
296+}
297+
298+/// The page being viewed, carried in forms so actions return to it.
299+pub fn back_field(back: &str) -> Markup {
300+ html! { input type="hidden" name="back" value=(back); }
301+}
302+
303+/// A compact inline POST form with one button.
304+pub fn action_button(url: &str, back: &str, action: &str, label: &str, class: &str) -> Markup {
305+ html! {
306+ form method="post" action=(url) class="inline" {
307+ (back_field(back))
308+ input type="hidden" name="do" value=(action);
309+ button type="submit" class={ "btn btn-sm " (class) } { (label) }
310+ }
311+ }
312+}
313+
314+/// Delete behind a disclosure that asks for the exact name first.
315+pub fn delete_form(url: &str, back: &str, confirm: &str, what: &str) -> Markup {
316+ html! {
317+ details class="inline-block align-top" {
318+ summary class="btn btn-sm btn-danger list-none [&::-webkit-details-marker]:hidden" { "Delete" }
319+ form method="post" action=(url) class="mt-1 flex w-60 flex-col gap-1 rounded-[4px] border border-danger/40 bg-surface p-2 text-left whitespace-normal" {
320+ (back_field(back))
321+ input type="hidden" name="do" value="delete";
322+ label class="text-[11px] text-ink-dim" { "Type " code class="text-ink" { (confirm) } " to delete this " (what) "." }
323+ input class="input py-0.5 font-mono text-xs" name="confirm" autocomplete="off" data-confirm-value=(confirm);
324+ button type="submit" class="btn btn-sm btn-danger" disabled { "Delete permanently" }
325+ }
326+ }
327+ }
328+}
329+
330+/// Header cell of a dense table.
331+pub fn th(label: &str) -> Markup {
332+ html! { th class="whitespace-nowrap border-b border-edge bg-surface-raised px-2 py-1 text-left text-[11px] font-semibold tracking-wide text-ink-dim uppercase" { (label) } }
333+}
334+
335+/// Body cell classes of a dense table.
336+pub const TD: &str = "border-b border-edge px-2 py-1 align-middle whitespace-nowrap";
337+
338+/// Wraps a table so it scrolls sideways on narrow screens instead of
339+/// squeezing columns.
340+pub fn table(head: Markup, rows: Markup) -> Markup {
341+ html! {
342+ div class="overflow-x-auto" {
343+ table class="w-full border-collapse text-[13px]" {
344+ thead { tr { (head) } }
345+ tbody { (rows) }
346+ }
347+ }
348+ }
349+}
350+
351+/// A labelled number for summary grids.
352+pub fn stat(label: &str, value: String, sub: Option<String>) -> Markup {
353+ html! {
354+ div class="min-w-0 border-r border-b border-edge px-3 py-2" {
355+ div class="truncate text-[11px] font-semibold tracking-wide text-ink-dim uppercase" { (label) }
356+ div class="mt-0.5 font-mono text-lg leading-tight" { (value) }
357+ @if let Some(sub) = sub {
358+ div class="truncate text-[11px] text-ink-faint" { (sub) }
359+ }
360+ }
361+ }
362+}
363+
364+/// Writes the audit entry and analytics event every admin action records.
365+pub async fn record(state: &AppState, admin: &Viewer, headers: &HeaderMap, action: &str, target: &str, meta: serde_json::Value) {
366+ let ip = crate::auth::client_ip(headers);
367+ crate::models::audit(&state.db, Some(admin.id), action, target, meta.clone(), ip.as_deref()).await;
368+ let event = format!("admin_{}", action.trim_start_matches("admin.").replace('.', "_"));
369+ crate::analytics::track(state, &event, Some(&admin.name), "/admin", serde_json::json!({ "target": target, "meta": meta }));
370+ tracing::info!(admin = %admin.name, action, target, "admin action");
371+}
372+
373+#[cfg(test)]
374+mod tests {
375+ use super::*;
376+
377+ #[test]
378+ fn like_patterns_escape_wildcards() {
379+ assert_eq!(like_pattern("a_b%c"), "%a\\_b\\%c%");
380+ assert_eq!(like_pattern(" x "), "%x%");
381+ }
382+
383+ #[test]
384+ fn query_strings_skip_empty_values() {
385+ assert_eq!(query(&[("q", "a b"), ("sort", "")]), "?q=a+b");
386+ assert_eq!(query(&[("q", "")]), "");
387+ }
388+
389+ #[test]
390+ fn back_redirect_replaces_messages_and_stays_in_admin() {
391+ let response = back_with(Some("/admin/users?q=x&msg=old"), "/admin", Ok("Done".into()));
392+ assert_eq!(response.headers()[header::LOCATION], "/admin/users?q=x&msg=Done");
393+ let response = back_with(Some("https://evil.example"), "/admin/repos", Err("No".into()));
394+ assert_eq!(response.headers()[header::LOCATION], "/admin/repos?err=No");
395+ let response = back_with(Some("/somewhere-else"), "/admin", Ok("x".into()));
396+ assert_eq!(response.headers()[header::LOCATION], "/admin?msg=x");
397+ }
398+}
+203-0backend/src/web/admin/orgs.rs
@@ -0,0 +1,203 @@
1+//! /admin/orgs: organizations with member and repo counts, quotas, deletion.
2+
3+use axum::{
4+ Form,
5+ extract::{Path, Query, State},
6+ http::HeaderMap,
7+ response::Response,
8+};
9+use chrono::{DateTime, Utc};
10+use maud::html;
11+use serde::Deserialize;
12+use serde_json::json;
13+
14+use super::{
15+ Admin, Notice, PAGE_SIZE, Section, TD, back_field, back_with, check_origin, delete_form, like_pattern, notice, page_number, pager,
16+ query, record, render, table, th,
17+ users::{delete_account_repos, parse_quota},
18+};
19+use crate::{
20+ error::{AppError, AppResult},
21+ state::AppState,
22+ web::{
23+ layout::{self, Ctx},
24+ ui,
25+ },
26+};
27+
28+#[derive(Deserialize, Default)]
29+pub struct ListQuery {
30+ #[serde(default)]
31+ q: String,
32+ page: Option<i64>,
33+ msg: Option<String>,
34+ err: Option<String>,
35+}
36+
37+fn with_page(base: &str, page: i64) -> String {
38+ format!("{base}{}page={page}", if base.contains('?') { "&" } else { "?" })
39+}
40+
41+pub async fn page(Admin(_admin): Admin, ctx: Ctx, Query(q): Query<ListQuery>) -> Response {
42+ let page = page_number(q.page);
43+ let fragment_url = with_page(&format!("/admin/f/orgs{}", query(&[("q", &q.q)])), page);
44+ let actions = html! {
45+ form method="get" action="/admin/orgs" class="flex gap-1" {
46+ input class="input w-64 py-0.5" type="search" name="q" value=(q.q) placeholder="Organization name";
47+ button class="btn btn-sm" type="submit" { "Search" }
48+ @if !q.q.is_empty() { a class="btn btn-sm" href="/admin/orgs" { "Clear" } }
49+ }
50+ };
51+ let body = html! {
52+ (notice(&Notice { msg: q.msg.clone(), err: q.err.clone() }))
53+ div class="box" { (ui::lazy(&fragment_url, 8)) }
54+ };
55+ render(&ctx, Section::Orgs, "Organizations", actions, body)
56+}
57+
58+#[derive(sqlx::FromRow)]
59+struct Row {
60+ id: i64,
61+ name: String,
62+ display_name: String,
63+ avatar_key: Option<String>,
64+ quota_bytes: Option<i64>,
65+ created_at: DateTime<Utc>,
66+ owners: i64,
67+ members: i64,
68+ repo_count: i64,
69+ package_count: i64,
70+ usage: i64,
71+}
72+
73+pub async fn fragment(Admin(_admin): Admin, State(state): State<AppState>, Query(q): Query<ListQuery>) -> AppResult<Response> {
74+ let page = page_number(q.page);
75+ let rows: Vec<Row> = sqlx::query_as(
76+ "select a.id, a.name::text as name, a.display_name, a.avatar_key, a.quota_bytes, a.created_at,
77+ (select count(*) from org_members m where m.org_id = a.id and m.role = 'owner') as owners,
78+ (select count(*) from org_members m where m.org_id = a.id) as members,
79+ (select count(*) from repos r where r.owner_id = a.id) as repo_count,
80+ (select count(*) from packages p where p.owner_id = a.id) as package_count,
81+ (select coalesce(sum(r.size_bytes), 0)::bigint from repos r where r.owner_id = a.id) as usage
82+ from accounts a
83+ where a.kind = 'org' and ($1 = '%%' or a.name::text ilike $1 or a.display_name ilike $1)
84+ order by a.created_at desc, a.id desc
85+ limit $2 offset $3",
86+ )
87+ .bind(like_pattern(&q.q))
88+ .bind(PAGE_SIZE + 1)
89+ .bind((page - 1) * PAGE_SIZE)
90+ .fetch_all(&state.db)
91+ .await?;
92+ let has_next = rows.len() as i64 > PAGE_SIZE;
93+ let rows = &rows[..rows.len().min(PAGE_SIZE as usize)];
94+ let base = format!("/admin/orgs{}", query(&[("q", &q.q)]));
95+ let back = with_page(&base, page);
96+ let default_quota = state.config.limits.account_quota_bytes;
97+
98+ let head = html! {
99+ (th("Organization")) (th("Members")) (th("Repos")) (th("Packages")) (th("Storage")) (th("Created")) (th("Quota (MB)")) (th("Actions"))
100+ };
101+ let body = html! {
102+ @for r in rows {
103+ @let url = format!("/admin/orgs/{}/action", r.id);
104+ @let quota = r.quota_bytes.map(|q| q.max(0) as u64).unwrap_or(default_quota);
105+ tr class="hover:bg-surface-raised" {
106+ td class=(TD) {
107+ div class="flex items-center gap-2" {
108+ (ui::avatar(&r.name, r.avatar_key.as_deref(), 22))
109+ div class="min-w-0 leading-tight" {
110+ a href={ "/" (r.name) } class="font-medium" { (r.name) }
111+ @if !r.display_name.is_empty() { div class="max-w-48 truncate text-[11px] text-ink-faint" { (r.display_name) } }
112+ }
113+ }
114+ }
115+ td class={ (TD) " text-xs" } {
116+ span class="font-mono" { (r.members) }
117+ span class="text-ink-faint" { " (" (r.owners) " owner" @if r.owners != 1 { "s" } ")" }
118+ @if r.owners == 0 { " " span class="tag border-warn/50 text-warn" { "No owner" } }
119+ }
120+ td class={ (TD) " font-mono text-xs" } { (r.repo_count) }
121+ td class={ (TD) " font-mono text-xs" } { (r.package_count) }
122+ td class={ (TD) " font-mono text-xs" } { (ui::bytes(r.usage.max(0) as u64)) " / " (ui::bytes(quota)) }
123+ td class={ (TD) " text-xs text-ink-dim" } { (ui::time(r.created_at)) }
124+ td class=(TD) {
125+ form method="post" action=(url) class="flex gap-1" {
126+ (back_field(&back))
127+ input type="hidden" name="do" value="quota";
128+ input class="input w-20 py-0 font-mono text-xs" name="quota_mb" inputmode="numeric" placeholder="default"
129+ value=[r.quota_bytes.map(|q| (q.max(0) as u64 / (1024 * 1024)).to_string())];
130+ button class="btn btn-sm" type="submit" { "Set" }
131+ }
132+ }
133+ td class=(TD) { (delete_form(&url, &back, &r.name, "organization and all its repositories")) }
134+ }
135+ }
136+ };
137+ Ok(layout::fragment(html! {
138+ @if rows.is_empty() {
139+ p class="px-3 py-6 text-center text-ink-faint" { @if q.q.is_empty() { "No organizations yet." } @else { "No organizations match " code { (q.q) } "." } }
140+ } @else {
141+ (table(head, body))
142+ (pager(&base, page, has_next, rows.len()))
143+ }
144+ }))
145+}
146+
147+#[derive(Deserialize)]
148+pub struct ActionForm {
149+ #[serde(rename = "do")]
150+ action: String,
151+ back: Option<String>,
152+ quota_mb: Option<String>,
153+ confirm: Option<String>,
154+}
155+
156+pub async fn action(
157+ Admin(admin): Admin,
158+ State(state): State<AppState>,
159+ headers: HeaderMap,
160+ Path(id): Path<i64>,
161+ Form(form): Form<ActionForm>,
162+) -> Response {
163+ if let Err(response) = check_origin(&headers, &state) {
164+ return response;
165+ }
166+ let result = run(&state, &admin, &headers, id, &form).await.map_err(|e| match e {
167+ AppError::Internal(error) => {
168+ tracing::error!(?error, org_id = id, action = %form.action, "admin org action failed");
169+ "Something went wrong; the error is in the server log.".to_string()
170+ }
171+ other => other.public_message(),
172+ });
173+ back_with(form.back.as_deref(), "/admin/orgs", result)
174+}
175+
176+async fn run(state: &AppState, admin: &crate::auth::Viewer, headers: &HeaderMap, id: i64, form: &ActionForm) -> AppResult<String> {
177+ let name: Option<String> = sqlx::query_scalar("select name::text from accounts where id = $1 and kind = 'org'")
178+ .bind(id)
179+ .fetch_optional(&state.db)
180+ .await?;
181+ let Some(name) = name else { return Err(AppError::NotFound) };
182+ match form.action.as_str() {
183+ "quota" => {
184+ let quota = parse_quota(form.quota_mb.as_deref())?;
185+ sqlx::query("update accounts set quota_bytes = $2, updated_at = now() where id = $1").bind(id).bind(quota).execute(&state.db).await?;
186+ record(state, admin, headers, "admin.org.quota", &name, json!({ "quota_bytes": quota })).await;
187+ Ok(match quota {
188+ Some(q) => format!("{name}'s quota is now {}.", ui::bytes(q as u64)),
189+ None => format!("{name} uses the default quota."),
190+ })
191+ }
192+ "delete" => {
193+ if form.confirm.as_deref().map(str::trim) != Some(name.as_str()) {
194+ return Err(AppError::bad("The confirmation did not match the organization name."));
195+ }
196+ let removed = delete_account_repos(state, admin, id).await?;
197+ sqlx::query("delete from accounts where id = $1").bind(id).execute(&state.db).await?;
198+ record(state, admin, headers, "admin.org.delete", &name, json!({ "repos_deleted": removed })).await;
199+ Ok(format!("Deleted {name} and {removed} repositor{}.", if removed == 1 { "y" } else { "ies" }))
200+ }
201+ other => Err(AppError::bad(format!("Unknown action {other}."))),
202+ }
203+}
+234-0backend/src/web/admin/overview.rs
@@ -0,0 +1,234 @@
1+//! /admin: counts, storage totals, 30-day activity and recent events.
2+
3+use axum::{extract::State, response::Response};
4+use chrono::{DateTime, NaiveDate, Utc};
5+use maud::{Markup, html};
6+
7+use super::{Admin, Section, render, stat};
8+use crate::{
9+ error::AppResult,
10+ state::AppState,
11+ web::{
12+ layout::{Ctx, fragment},
13+ ui,
14+ },
15+};
16+
17+pub async fn page(Admin(_admin): Admin, ctx: Ctx) -> Response {
18+ let body = html! {
19+ div class="box mb-2" { (ui::lazy("/admin/f/overview/counts", 3)) }
20+ div class="box mb-2" {
21+ div class="box-head" { span class="font-semibold" { "Last 30 days" } }
22+ (ui::lazy("/admin/f/overview/activity", 4))
23+ }
24+ div class="grid gap-2 xl:grid-cols-3" {
25+ div class="box min-w-0" {
26+ div class="box-head" { span class="font-semibold" { "Recent signups" } a class="ml-auto text-xs" href="/admin/users" { "All users" } }
27+ (ui::lazy("/admin/f/overview/signups", 6))
28+ }
29+ div class="box min-w-0" {
30+ div class="box-head" { span class="font-semibold" { "Recent pushes" } a class="ml-auto text-xs" href="/admin/repos?sort=pushed" { "All repositories" } }
31+ (ui::lazy("/admin/f/overview/pushes", 6))
32+ }
33+ div class="box min-w-0" {
34+ div class="box-head" { span class="font-semibold" { "Recent admin and account events" } a class="ml-auto text-xs" href="/admin/audit" { "Audit log" } }
35+ (ui::lazy("/admin/f/overview/audit", 6))
36+ }
37+ }
38+ };
39+ render(&ctx, Section::Overview, "Overview", html! {}, body)
40+}
41+
42+#[derive(sqlx::FromRow)]
43+struct Counts {
44+ users: i64,
45+ admins: i64,
46+ suspended: i64,
47+ orgs: i64,
48+ repos_public: i64,
49+ repos_private: i64,
50+ packages_public: i64,
51+ packages_private: i64,
52+ tags: i64,
53+ lfs_objects: i64,
54+ blobs: i64,
55+ repo_bytes: i64,
56+ blob_bytes: i64,
57+ lfs_bytes: i64,
58+ unverified_emails: i64,
59+ sessions: i64,
60+}
61+
62+pub async fn counts(Admin(_admin): Admin, State(state): State<AppState>) -> AppResult<Response> {
63+ let c: Counts = sqlx::query_as(
64+ "select
65+ (select count(*) from users) as users,
66+ (select count(*) from users where is_admin) as admins,
67+ (select count(*) from users where suspended_at is not null) as suspended,
68+ (select count(*) from accounts where kind = 'org') as orgs,
69+ (select count(*) from repos where visibility = 'public') as repos_public,
70+ (select count(*) from repos where visibility = 'private') as repos_private,
71+ (select count(*) from packages where visibility = 'public') as packages_public,
72+ (select count(*) from packages where visibility = 'private') as packages_private,
73+ (select count(*) from tags) as tags,
74+ (select count(*) from lfs_objects) as lfs_objects,
75+ (select count(*) from blobs) as blobs,
76+ (select coalesce(sum(size_bytes), 0)::bigint from repos) as repo_bytes,
77+ (select coalesce(sum(size), 0)::bigint from blobs) as blob_bytes,
78+ (select coalesce(sum(size), 0)::bigint from lfs_objects) as lfs_bytes,
79+ (select count(*) from emails where verified_at is null) as unverified_emails,
80+ (select count(*) from sessions where expires_at > now()) as sessions",
81+ )
82+ .fetch_one(&state.db)
83+ .await?;
84+ let b = |n: i64| ui::bytes(n.max(0) as u64);
85+ Ok(fragment(html! {
86+ div class="grid grid-cols-2 sm:grid-cols-5 2xl:grid-cols-10" style="margin:0 -1px -1px 0" {
87+ (stat("Users", c.users.to_string(), Some(format!("{} admin, {} suspended", c.admins, c.suspended))))
88+ (stat("Organizations", c.orgs.to_string(), None))
89+ (stat("Repositories", (c.repos_public + c.repos_private).to_string(), Some(format!("{} public, {} private", c.repos_public, c.repos_private))))
90+ (stat("Packages", (c.packages_public + c.packages_private).to_string(), Some(format!("{} public, {} private, {} tags", c.packages_public, c.packages_private, c.tags))))
91+ (stat("Git on disk", b(c.repo_bytes), None))
92+ (stat("Image layers", b(c.blob_bytes), Some(ui::plural(c.blobs, "blob", "blobs"))))
93+ (stat("LFS objects", b(c.lfs_bytes), Some(ui::plural(c.lfs_objects, "object", "objects"))))
94+ (stat("Total stored", b(c.repo_bytes + c.blob_bytes + c.lfs_bytes), None))
95+ (stat("Active sessions", c.sessions.to_string(), None))
96+ (stat("Unverified emails", c.unverified_emails.to_string(), None))
97+ }
98+ }))
99+}
100+
101+pub async fn activity(Admin(_admin): Admin, State(state): State<AppState>) -> AppResult<Response> {
102+ let rows: Vec<(NaiveDate, i64, i64, i64)> = sqlx::query_as(
103+ "select d::date,
104+ (select count(*) from accounts a where a.kind = 'user' and a.created_at::date = d::date),
105+ (select count(*) from push_events p where p.created_at::date = d::date),
106+ (select count(*) from contribution_commits c where c.day = d::date)
107+ from generate_series(current_date - 29, current_date, interval '1 day') d
108+ order by d",
109+ )
110+ .fetch_all(&state.db)
111+ .await?;
112+ let signups: Vec<(NaiveDate, i64)> = rows.iter().map(|r| (r.0, r.1)).collect();
113+ let pushes: Vec<(NaiveDate, i64)> = rows.iter().map(|r| (r.0, r.2)).collect();
114+ let commits: Vec<(NaiveDate, i64)> = rows.iter().map(|r| (r.0, r.3)).collect();
115+ Ok(fragment(html! {
116+ div class="grid gap-x-4 gap-y-2 p-2 md:grid-cols-3" {
117+ (bar_chart("Signups", &signups, "var(--color-ember)"))
118+ (bar_chart("Pushes", &pushes, "var(--color-accent)"))
119+ (bar_chart("Commits credited", &commits, "var(--color-ok)"))
120+ }
121+ }))
122+}
123+
124+/// A 30-bar chart with a tooltip per day; scales to its column.
125+fn bar_chart(label: &str, data: &[(NaiveDate, i64)], color: &str) -> Markup {
126+ let max = data.iter().map(|d| d.1).max().unwrap_or(0).max(1);
127+ let total: i64 = data.iter().map(|d| d.1).sum();
128+ let width = data.len().max(1) as f64 * 10.0;
129+ html! {
130+ div class="min-w-0" {
131+ div class="mb-1 flex items-baseline gap-2 text-xs" {
132+ span class="font-semibold text-ink-dim" { (label) }
133+ span class="font-mono text-ink" { (total) }
134+ span class="ml-auto text-ink-faint" { "max " (max) "/day" }
135+ }
136+ svg viewBox={ "0 0 " (width) " 60" } preserveAspectRatio="none" class="block h-16 w-full" role="img" aria-label={ (label) ", last 30 days" } {
137+ line x1="0" y1="59.5" x2=(width) y2="59.5" stroke="var(--color-edge)" stroke-width="1" {}
138+ @for (i, (day, n)) in data.iter().enumerate() {
139+ @let h = if *n == 0 { 0.0 } else { (*n as f64 / max as f64 * 56.0).max(2.0) };
140+ g {
141+ title { (day.format("%b %e")) ": " (n) }
142+ rect x=(i as f64 * 10.0) y="0" width="10" height="60" fill="transparent" {}
143+ rect x={ (i as f64 * 10.0 + 1.5) } y={ (59.0 - h) } width="7" height=(h) fill=(color) {}
144+ }
145+ }
146+ }
147+ div class="mt-0.5 flex justify-between text-[10px] text-ink-faint" {
148+ span { (data.first().map(|d| d.0.format("%b %e").to_string()).unwrap_or_default()) }
149+ span { "today" }
150+ }
151+ }
152+ }
153+}
154+
155+pub async fn signups(Admin(_admin): Admin, State(state): State<AppState>) -> AppResult<Response> {
156+ let rows: Vec<(String, Option<String>, String, DateTime<Utc>, bool, Option<String>)> = sqlx::query_as(
157+ "select a.name::text, a.avatar_key, a.display_name, a.created_at, u.is_admin, e.email::text
158+ from accounts a join users u on u.account_id = a.id
159+ left join emails e on e.user_id = a.id and e.is_primary
160+ order by a.created_at desc limit 10",
161+ )
162+ .fetch_all(&state.db)
163+ .await?;
164+ Ok(fragment(html! {
165+ @if rows.is_empty() {
166+ p class="px-3 py-4 text-center text-xs text-ink-faint" { "No users yet." }
167+ }
168+ @for (name, avatar, display, created, admin, email) in &rows {
169+ div class="flex items-center gap-2 border-b border-edge px-2 py-1 last:border-b-0" {
170+ (ui::avatar(name, avatar.as_deref(), 20))
171+ a href={ "/" (name) } class="truncate font-medium" { (name) }
172+ @if !display.is_empty() { span class="truncate text-xs text-ink-faint" { (display) } }
173+ @if *admin { span class="tag" { "Admin" } }
174+ span class="ml-auto truncate text-xs text-ink-faint" title=(email.clone().unwrap_or_default()) { (email.clone().unwrap_or_default()) }
175+ span class="shrink-0 text-xs text-ink-faint" { (ui::time(*created)) }
176+ }
177+ }
178+ }))
179+}
180+
181+pub async fn pushes(Admin(_admin): Admin, State(state): State<AppState>) -> AppResult<Response> {
182+ let rows: Vec<(Option<String>, Option<String>, String, String, String, i32, String, String, DateTime<Utc>)> = sqlx::query_as(
183+ "select pa.name::text, pa.avatar_key, o.name::text, r.name::text, p.ref_name, p.commit_count, p.head_message, p.via, p.created_at
184+ from push_events p
185+ join repos r on r.id = p.repo_id join accounts o on o.id = r.owner_id
186+ left join accounts pa on pa.id = p.pusher_id
187+ order by p.created_at desc limit 10",
188+ )
189+ .fetch_all(&state.db)
190+ .await?;
191+ Ok(fragment(html! {
192+ @if rows.is_empty() {
193+ p class="px-3 py-4 text-center text-xs text-ink-faint" { "No pushes yet." }
194+ }
195+ @for (pusher, avatar, owner, repo, ref_name, count, message, via, at) in &rows {
196+ div class="flex items-center gap-2 border-b border-edge px-2 py-1 last:border-b-0" {
197+ @match pusher {
198+ Some(p) => (ui::avatar(p, avatar.as_deref(), 20)),
199+ None => span class="inline-block h-5 w-5 rounded-[4px] bg-surface-hover" {},
200+ }
201+ div class="min-w-0 flex-1" {
202+ div class="truncate text-[13px]" {
203+ span class="font-medium" { (pusher.clone().unwrap_or_else(|| "deleted user".into())) }
204+ " to "
205+ a href={ "/" (owner) "/" (repo) } { (owner) "/" (repo) }
206+ " " code class="text-xs text-ink-dim" { (ref_name.trim_start_matches("refs/heads/").trim_start_matches("refs/tags/")) }
207+ }
208+ div class="truncate text-xs text-ink-faint" {
209+ (ui::plural(*count as i64, "commit", "commits")) " via " (via)
210+ @if !message.is_empty() { ": " (message) }
211+ }
212+ }
213+ span class="shrink-0 text-xs text-ink-faint" { (ui::time(*at)) }
214+ }
215+ }
216+ }))
217+}
218+
219+pub async fn recent_audit(Admin(_admin): Admin, State(state): State<AppState>) -> AppResult<Response> {
220+ let rows = super::audit::load(&state, "", "", 0, 10).await?;
221+ Ok(fragment(html! {
222+ @if rows.is_empty() {
223+ p class="px-3 py-4 text-center text-xs text-ink-faint" { "Nothing recorded yet." }
224+ }
225+ @for row in &rows {
226+ div class="flex items-center gap-2 border-b border-edge px-2 py-1 text-[13px] last:border-b-0" {
227+ code class="shrink-0 text-xs text-ember" { (row.action) }
228+ span class="min-w-0 flex-1 truncate" { (row.target) }
229+ span class="shrink-0 text-xs text-ink-dim" { (row.actor.clone().unwrap_or_else(|| "system".into())) }
230+ span class="shrink-0 text-xs text-ink-faint" { (ui::time(row.created_at)) }
231+ }
232+ }
233+ }))
234+}
+247-0backend/src/web/admin/packages.rs
@@ -0,0 +1,247 @@
1+//! /admin/packages: container images, visibility, deletion and registry GC.
2+
3+use axum::{
4+ Form,
5+ extract::{Path, Query, State},
6+ http::HeaderMap,
7+ response::Response,
8+};
9+use chrono::{DateTime, Utc};
10+use maud::html;
11+use serde::Deserialize;
12+use serde_json::json;
13+
14+use super::{
15+ Admin, Notice, PAGE_SIZE, Section, TD, action_button, back_field, back_with, check_origin, delete_form, like_pattern, notice,
16+ page_number, pager, query, record, render, table, th,
17+};
18+use crate::{
19+ error::{AppError, AppResult},
20+ models::Package,
21+ state::AppState,
22+ web::{
23+ layout::{self, Ctx},
24+ ui,
25+ },
26+};
27+
28+#[derive(Deserialize, Default)]
29+pub struct ListQuery {
30+ #[serde(default)]
31+ q: String,
32+ #[serde(default)]
33+ visibility: String,
34+ page: Option<i64>,
35+ msg: Option<String>,
36+ err: Option<String>,
37+}
38+
39+impl ListQuery {
40+ fn visibility(&self) -> &str {
41+ match self.visibility.as_str() {
42+ "public" | "private" => self.visibility.as_str(),
43+ _ => "",
44+ }
45+ }
46+
47+ fn base(&self, path: &str) -> String {
48+ format!("{path}{}", query(&[("q", &self.q), ("visibility", self.visibility())]))
49+ }
50+}
51+
52+fn with_page(base: &str, page: i64) -> String {
53+ format!("{base}{}page={page}", if base.contains('?') { "&" } else { "?" })
54+}
55+
56+pub async fn page(Admin(_admin): Admin, ctx: Ctx, Query(q): Query<ListQuery>) -> Response {
57+ let page = page_number(q.page);
58+ let fragment_url = with_page(&q.base("/admin/f/packages"), page);
59+ let back = with_page(&q.base("/admin/packages"), page);
60+ let visibility = q.visibility();
61+ let actions = html! {
62+ form method="get" action="/admin/packages" class="flex flex-wrap gap-1" {
63+ input class="input w-56 py-0.5" type="search" name="q" value=(q.q) placeholder="owner/image";
64+ select class="input w-auto py-0.5" name="visibility" data-autosubmit {
65+ option value="" selected[visibility.is_empty()] { "All" }
66+ option value="public" selected[visibility == "public"] { "Public" }
67+ option value="private" selected[visibility == "private"] { "Private" }
68+ }
69+ button class="btn btn-sm" type="submit" { "Filter" }
70+ }
71+ form method="post" action="/admin/packages/gc" title="Delete layers no image references and stale uploads" {
72+ (back_field(&back))
73+ button class="btn btn-sm" type="submit" data-track="admin_registry_gc_clicked" { "Run registry cleanup" }
74+ }
75+ };
76+ let body = html! {
77+ (notice(&Notice { msg: q.msg.clone(), err: q.err.clone() }))
78+ div class="box" { (ui::lazy(&fragment_url, 10)) }
79+ };
80+ render(&ctx, Section::Packages, "Packages", actions, body)
81+}
82+
83+#[derive(sqlx::FromRow)]
84+struct Row {
85+ id: i64,
86+ owner_name: String,
87+ name: String,
88+ visibility: String,
89+ pull_count: i64,
90+ updated_at: DateTime<Utc>,
91+ repo: Option<String>,
92+ tag_count: i64,
93+ manifest_count: i64,
94+ size: i64,
95+}
96+
97+pub async fn fragment(Admin(_admin): Admin, State(state): State<AppState>, Query(q): Query<ListQuery>) -> AppResult<Response> {
98+ let page = page_number(q.page);
99+ let rows: Vec<Row> = sqlx::query_as(
100+ "select p.id, a.name::text as owner_name, p.name, p.visibility, p.pull_count, p.updated_at,
101+ (select ra.name::text || '/' || r.name::text from repos r join accounts ra on ra.id = r.owner_id where r.id = p.repo_id) as repo,
102+ (select count(*) from tags t where t.package_id = p.id) as tag_count,
103+ (select count(*) from manifests m where m.package_id = p.id) as manifest_count,
104+ (select coalesce(sum(b.size), 0)::bigint from package_blobs pb join blobs b on b.digest = pb.digest where pb.package_id = p.id) as size
105+ from packages p join accounts a on a.id = p.owner_id
106+ where ($1 = '%%' or (a.name::text || '/' || p.name) ilike $1)
107+ and ($2 = '' or p.visibility = $2)
108+ order by p.updated_at desc, p.id desc
109+ limit $3 offset $4",
110+ )
111+ .bind(like_pattern(&q.q))
112+ .bind(q.visibility())
113+ .bind(PAGE_SIZE + 1)
114+ .bind((page - 1) * PAGE_SIZE)
115+ .fetch_all(&state.db)
116+ .await?;
117+ let has_next = rows.len() as i64 > PAGE_SIZE;
118+ let rows = &rows[..rows.len().min(PAGE_SIZE as usize)];
119+ let base = q.base("/admin/packages");
120+ let back = with_page(&base, page);
121+ let registry = state.config.registry_host();
122+
123+ let head = html! {
124+ (th("Image")) (th("Visibility")) (th("Tags")) (th("Manifests")) (th("Layers size")) (th("Pulls")) (th("Linked repo")) (th("Updated")) (th("Actions"))
125+ };
126+ let body = html! {
127+ @for r in rows {
128+ @let url = format!("/admin/packages/{}/action", r.id);
129+ @let full = format!("{}/{}", r.owner_name, r.name);
130+ tr class="hover:bg-surface-raised" {
131+ td class=(TD) {
132+ div class="flex items-center gap-1.5" {
133+ (ui::icon_package())
134+ a href={ "/" (r.owner_name) "/-/packages/" (r.name) } class="font-medium" { (full) }
135+ }
136+ div class="font-mono text-[11px] text-ink-faint" { (registry) "/" (full) }
137+ }
138+ td class=(TD) { (ui::visibility_tag(&r.visibility)) }
139+ td class={ (TD) " text-right font-mono text-xs" } { (r.tag_count) }
140+ td class={ (TD) " text-right font-mono text-xs" } { (r.manifest_count) }
141+ td class={ (TD) " text-right font-mono text-xs" } { (ui::bytes(r.size.max(0) as u64)) }
142+ td class={ (TD) " text-right font-mono text-xs" } { (r.pull_count) }
143+ td class={ (TD) " text-xs" } {
144+ @match &r.repo { Some(repo) => a href={ "/" (repo) } { (repo) }, None => span class="text-ink-faint" { "none" } }
145+ }
146+ td class={ (TD) " text-xs text-ink-dim" } { (ui::time(r.updated_at)) }
147+ td class=(TD) {
148+ div class="flex flex-wrap items-start gap-1" {
149+ @if r.visibility == "public" {
150+ (action_button(&url, &back, "private", "Make private", ""))
151+ } @else {
152+ (action_button(&url, &back, "public", "Make public", ""))
153+ }
154+ (delete_form(&url, &back, &full, "image and all its tags"))
155+ }
156+ }
157+ }
158+ }
159+ };
160+ Ok(layout::fragment(html! {
161+ @if rows.is_empty() {
162+ p class="px-3 py-6 text-center text-ink-faint" { "No images match." }
163+ } @else {
164+ (table(head, body))
165+ (pager(&base, page, has_next, rows.len()))
166+ }
167+ }))
168+}
169+
170+#[derive(Deserialize)]
171+pub struct ActionForm {
172+ #[serde(rename = "do")]
173+ action: String,
174+ back: Option<String>,
175+ confirm: Option<String>,
176+}
177+
178+pub async fn action(
179+ Admin(admin): Admin,
180+ State(state): State<AppState>,
181+ headers: HeaderMap,
182+ Path(id): Path<i64>,
183+ Form(form): Form<ActionForm>,
184+) -> Response {
185+ if let Err(response) = check_origin(&headers, &state) {
186+ return response;
187+ }
188+ let result = run(&state, &admin, &headers, id, &form).await.map_err(|e| match e {
189+ AppError::Internal(error) => {
190+ tracing::error!(?error, package_id = id, action = %form.action, "admin package action failed");
191+ "Something went wrong; the error is in the server log.".to_string()
192+ }
193+ other => other.public_message(),
194+ });
195+ back_with(form.back.as_deref(), "/admin/packages", result)
196+}
197+
198+async fn run(state: &AppState, admin: &crate::auth::Viewer, headers: &HeaderMap, id: i64, form: &ActionForm) -> AppResult<String> {
199+ let package = Package::by_id(&state.db, id).await?.ok_or(AppError::NotFound)?;
200+ let name = package.full_name();
201+ match form.action.as_str() {
202+ visibility @ ("public" | "private") => {
203+ sqlx::query("update packages set visibility = $2, updated_at = now() where id = $1")
204+ .bind(id)
205+ .bind(visibility)
206+ .execute(&state.db)
207+ .await?;
208+ record(state, admin, headers, "admin.package.visibility", &name, json!({ "visibility": visibility })).await;
209+ Ok(format!("{name} is now {visibility}."))
210+ }
211+ "delete" => {
212+ if form.confirm.as_deref().map(str::trim) != Some(name.as_str()) {
213+ return Err(AppError::bad("The confirmation did not match owner/image."));
214+ }
215+ // Manifests, tags and blob links cascade; the layers themselves
216+ // are removed from R2 by the next registry cleanup.
217+ sqlx::query("delete from packages where id = $1").bind(id).execute(&state.db).await?;
218+ record(state, admin, headers, "admin.package.delete", &name, json!({})).await;
219+ Ok(format!("Deleted {name}. Its layers are freed on the next registry cleanup."))
220+ }
221+ other => Err(AppError::bad(format!("Unknown action {other}."))),
222+ }
223+}
224+
225+#[derive(Deserialize)]
226+pub struct GcForm {
227+ back: Option<String>,
228+}
229+
230+pub async fn gc(Admin(admin): Admin, State(state): State<AppState>, headers: HeaderMap, Form(form): Form<GcForm>) -> Response {
231+ if let Err(response) = check_origin(&headers, &state) {
232+ return response;
233+ }
234+ let started = std::time::Instant::now();
235+ let result = match crate::registry::gc_once(&state).await {
236+ Ok(summary) => {
237+ record(&state, &admin, &headers, "admin.registry.gc", "registry", json!({ "summary": summary, "ms": started.elapsed().as_millis() as u64 })).await;
238+ Ok(format!("Registry cleanup finished: {summary}"))
239+ }
240+ Err(error) => {
241+ tracing::warn!(?error, "registry cleanup failed");
242+ record(&state, &admin, &headers, "admin.registry.gc_failed", "registry", json!({ "error": format!("{error:#}") })).await;
243+ Err(format!("Registry cleanup failed: {error:#}"))
244+ }
245+ };
246+ back_with(form.back.as_deref(), "/admin/packages", result)
247+}
+228-0backend/src/web/admin/repos.rs
@@ -0,0 +1,228 @@
1+//! /admin/repos: every repository, with visibility, archive and delete.
2+
3+use axum::{
4+ Form,
5+ extract::{Path, Query, State},
6+ http::HeaderMap,
7+ response::Response,
8+};
9+use maud::html;
10+use serde::Deserialize;
11+use serde_json::json;
12+
13+use super::{
14+ Admin, Notice, PAGE_SIZE, Section, TD, action_button, back_with, check_origin, delete_form, like_pattern, notice, page_number, pager,
15+ query, record, render, table, th,
16+};
17+use crate::{
18+ error::{AppError, AppResult},
19+ models::Repo,
20+ ops,
21+ state::AppState,
22+ web::{
23+ layout::{self, Ctx},
24+ ui,
25+ },
26+};
27+
28+#[derive(Deserialize, Default)]
29+pub struct ListQuery {
30+ #[serde(default)]
31+ q: String,
32+ #[serde(default)]
33+ visibility: String,
34+ #[serde(default)]
35+ sort: String,
36+ page: Option<i64>,
37+ msg: Option<String>,
38+ err: Option<String>,
39+}
40+
41+impl ListQuery {
42+ fn normalized(&self) -> (&str, &str) {
43+ let visibility = match self.visibility.as_str() {
44+ "public" | "private" | "archived" => self.visibility.as_str(),
45+ _ => "",
46+ };
47+ let sort = match self.sort.as_str() {
48+ "size" | "pushed" | "name" => self.sort.as_str(),
49+ _ => "created",
50+ };
51+ (visibility, sort)
52+ }
53+
54+ fn base(&self, path: &str) -> String {
55+ let (visibility, sort) = self.normalized();
56+ format!("{path}{}", query(&[("q", &self.q), ("visibility", visibility), ("sort", if sort == "created" { "" } else { sort })]))
57+ }
58+}
59+
60+fn with_page(base: &str, page: i64) -> String {
61+ format!("{base}{}page={page}", if base.contains('?') { "&" } else { "?" })
62+}
63+
64+pub async fn page(Admin(_admin): Admin, ctx: Ctx, Query(q): Query<ListQuery>) -> Response {
65+ let page = page_number(q.page);
66+ let (visibility, sort) = q.normalized();
67+ let fragment_url = with_page(&q.base("/admin/f/repos"), page);
68+ let actions = html! {
69+ form method="get" action="/admin/repos" class="flex flex-wrap gap-1" {
70+ input class="input w-56 py-0.5" type="search" name="q" value=(q.q) placeholder="owner/name";
71+ select class="input w-auto py-0.5" name="visibility" data-autosubmit {
72+ option value="" selected[visibility.is_empty()] { "All" }
73+ option value="public" selected[visibility == "public"] { "Public" }
74+ option value="private" selected[visibility == "private"] { "Private" }
75+ option value="archived" selected[visibility == "archived"] { "Archived" }
76+ }
77+ select class="input w-auto py-0.5" name="sort" data-autosubmit {
78+ option value="created" selected[sort == "created"] { "Newest" }
79+ option value="pushed" selected[sort == "pushed"] { "Recently pushed" }
80+ option value="size" selected[sort == "size"] { "Largest" }
81+ option value="name" selected[sort == "name"] { "Name" }
82+ }
83+ button class="btn btn-sm" type="submit" { "Filter" }
84+ }
85+ };
86+ let body = html! {
87+ (notice(&Notice { msg: q.msg.clone(), err: q.err.clone() }))
88+ div class="box" { (ui::lazy(&fragment_url, 12)) }
89+ };
90+ render(&ctx, Section::Repos, "Repositories", actions, body)
91+}
92+
93+pub async fn fragment(Admin(_admin): Admin, State(state): State<AppState>, Query(q): Query<ListQuery>) -> AppResult<Response> {
94+ let page = page_number(q.page);
95+ let (visibility, sort) = q.normalized();
96+ // Sorting stays a bound parameter: each CASE is null unless chosen.
97+ let rows: Vec<Repo> = sqlx::query_as(concat!(
98+ crate::repo_select!(),
99+ " where ($1 = '%%' or (a.name::text || '/' || r.name::text) ilike $1 or r.description ilike $1)
100+ and ($2 = '' or ($2 = 'archived' and r.archived) or r.visibility = $2)
101+ order by case when $3 = 'size' then r.size_bytes end desc nulls last,
102+ case when $3 = 'pushed' then r.pushed_at end desc nulls last,
103+ case when $3 = 'name' then a.name::text || '/' || r.name::text end asc,
104+ r.created_at desc, r.id desc
105+ limit $4 offset $5"
106+ ))
107+ .bind(like_pattern(&q.q))
108+ .bind(visibility)
109+ .bind(sort)
110+ .bind(PAGE_SIZE + 1)
111+ .bind((page - 1) * PAGE_SIZE)
112+ .fetch_all(&state.db)
113+ .await?;
114+ let has_next = rows.len() as i64 > PAGE_SIZE;
115+ let rows = &rows[..rows.len().min(PAGE_SIZE as usize)];
116+ let base = q.base("/admin/repos");
117+ let back = with_page(&base, page);
118+
119+ let head = html! {
120+ (th("Repository")) (th("Visibility")) (th("Size")) (th("Default branch")) (th("Pushed")) (th("Created")) (th("Actions"))
121+ };
122+ let body = html! {
123+ @for r in rows {
124+ @let url = format!("/admin/repos/{}/action", r.id);
125+ tr class="hover:bg-surface-raised" {
126+ td class=(TD) {
127+ div class="flex items-center gap-1.5" {
128+ (ui::icon_repo())
129+ a href=(r.url()) class="font-medium" { (r.owner_name) "/" (r.name) }
130+ @if r.owner_kind == "org" { span class="tag" { "Org" } }
131+ @if r.is_empty { span class="tag" { "Empty" } }
132+ }
133+ @if !r.description.is_empty() { div class="max-w-md truncate text-[11px] text-ink-faint" { (r.description) } }
134+ }
135+ td class=(TD) {
136+ div class="flex gap-1" {
137+ (ui::visibility_tag(&r.visibility))
138+ @if r.archived { span class="tag border-warn/50 text-warn" { "Archived" } }
139+ }
140+ }
141+ td class={ (TD) " text-right font-mono text-xs" } { (ui::bytes(r.size_bytes.max(0) as u64)) }
142+ td class={ (TD) " font-mono text-xs text-ink-dim" } { (r.default_branch) }
143+ td class={ (TD) " text-xs text-ink-dim" } {
144+ @match r.pushed_at { Some(t) => (ui::time(t)), None => span class="text-ink-faint" { "never" } }
145+ }
146+ td class={ (TD) " text-xs text-ink-dim" } { (ui::time(r.created_at)) }
147+ td class=(TD) {
148+ div class="flex flex-wrap items-start gap-1" {
149+ @if r.is_public() {
150+ (action_button(&url, &back, "private", "Make private", ""))
151+ } @else {
152+ (action_button(&url, &back, "public", "Make public", ""))
153+ }
154+ @if r.archived {
155+ (action_button(&url, &back, "unarchive", "Unarchive", ""))
156+ } @else {
157+ (action_button(&url, &back, "archive", "Archive", ""))
158+ }
159+ (delete_form(&url, &back, &r.full_name(), "repository"))
160+ }
161+ }
162+ }
163+ }
164+ };
165+ Ok(layout::fragment(html! {
166+ @if rows.is_empty() {
167+ p class="px-3 py-6 text-center text-ink-faint" { "No repositories match." }
168+ } @else {
169+ (table(head, body))
170+ (pager(&base, page, has_next, rows.len()))
171+ }
172+ }))
173+}
174+
175+#[derive(Deserialize)]
176+pub struct ActionForm {
177+ #[serde(rename = "do")]
178+ action: String,
179+ back: Option<String>,
180+ confirm: Option<String>,
181+}
182+
183+pub async fn action(
184+ Admin(admin): Admin,
185+ State(state): State<AppState>,
186+ headers: HeaderMap,
187+ Path(id): Path<i64>,
188+ Form(form): Form<ActionForm>,
189+) -> Response {
190+ if let Err(response) = check_origin(&headers, &state) {
191+ return response;
192+ }
193+ let result = run(&state, &admin, &headers, id, &form).await.map_err(|e| match e {
194+ AppError::Internal(error) => {
195+ tracing::error!(?error, repo_id = id, action = %form.action, "admin repo action failed");
196+ "Something went wrong; the error is in the server log.".to_string()
197+ }
198+ other => other.public_message(),
199+ });
200+ back_with(form.back.as_deref(), "/admin/repos", result)
201+}
202+
203+async fn run(state: &AppState, admin: &crate::auth::Viewer, headers: &HeaderMap, id: i64, form: &ActionForm) -> AppResult<String> {
204+ let repo = Repo::by_id(&state.db, id).await?.ok_or(AppError::NotFound)?;
205+ let name = repo.full_name();
206+ match form.action.as_str() {
207+ visibility @ ("public" | "private") => {
208+ ops::set_repo_visibility(state, admin, &repo, visibility).await?;
209+ record(state, admin, headers, "admin.repo.visibility", &name, json!({ "visibility": visibility })).await;
210+ Ok(format!("{name} is now {visibility}."))
211+ }
212+ action @ ("archive" | "unarchive") => {
213+ let archived = action == "archive";
214+ sqlx::query("update repos set archived = $2, updated_at = now() where id = $1").bind(id).bind(archived).execute(&state.db).await?;
215+ record(state, admin, headers, &format!("admin.repo.{action}"), &name, json!({})).await;
216+ Ok(if archived { format!("{name} is archived and read-only.") } else { format!("{name} accepts pushes again.") })
217+ }
218+ "delete" => {
219+ if form.confirm.as_deref().map(str::trim) != Some(name.as_str()) {
220+ return Err(AppError::bad("The confirmation did not match owner/name."));
221+ }
222+ ops::delete_repo(state, admin, &repo).await?;
223+ record(state, admin, headers, "admin.repo.delete", &name, json!({ "size_bytes": repo.size_bytes })).await;
224+ Ok(format!("Deleted {name}."))
225+ }
226+ other => Err(AppError::bad(format!("Unknown action {other}."))),
227+ }
228+}
+192-0backend/src/web/admin/storage.rs
@@ -0,0 +1,192 @@
1+//! /admin/storage: what is stored where. Database totals are quick; the R2
2+//! listing and the disk walk can be slow, so each is its own lazy fragment.
3+
4+use std::path::{Path, PathBuf};
5+
6+use axum::{
7+ extract::{Query, State},
8+ response::Response,
9+};
10+use maud::html;
11+use serde::Deserialize;
12+
13+use super::{Admin, Section, TD, render, table, th};
14+use crate::{
15+ error::{AppError, AppResult},
16+ state::AppState,
17+ web::{
18+ layout::{Ctx, fragment},
19+ ui,
20+ },
21+};
22+
23+/// R2 prefixes the server writes under (see storage::keys).
24+const PREFIXES: &[(&str, &str)] = &[
25+ ("lfs/", "Git LFS objects"),
26+ ("registry/", "Image layers and configs"),
27+ ("avatars/", "Uploaded avatars"),
28+ ("releases/", "CLI releases"),
29+ ("backups/", "Repository and database backups"),
30+];
31+
32+pub async fn page(Admin(_admin): Admin, ctx: Ctx) -> Response {
33+ let body = html! {
34+ div class="grid gap-2 xl:grid-cols-2" {
35+ div class="box min-w-0" {
36+ div class="box-head" { span class="font-semibold" { "Recorded in the database" } }
37+ (ui::lazy("/admin/f/storage/db", 6))
38+ }
39+ div class="box min-w-0" {
40+ div class="box-head" {
41+ span class="font-semibold" { "Server disk" }
42+ span class="ml-auto min-w-0 truncate font-mono text-xs text-ink-faint" title=(ctx.state.config.data_dir.display()) { (ctx.state.config.data_dir.display()) }
43+ }
44+ (ui::lazy("/admin/f/storage/disk", 6))
45+ }
46+ }
47+ div class="box mt-2" {
48+ div class="box-head" {
49+ span class="font-semibold" { "R2 bucket " code { (ctx.state.storage.bucket_name()) } }
50+ span class="ml-auto text-xs text-ink-faint" { "Listed live from R2, one request per 1000 objects" }
51+ }
52+ div class="overflow-x-auto" {
53+ table class="w-full border-collapse text-[13px]" {
54+ thead { tr { (th("Prefix")) (th("Holds")) (th("Objects")) (th("Size")) (th("Newest")) } }
55+ @for (prefix, label) in PREFIXES {
56+ tbody data-lazy={ "/admin/f/storage/r2?prefix=" (prefix) } aria-busy="true" {
57+ tr {
58+ td class=(TD) { code { (prefix) } }
59+ td class={ (TD) " text-ink-dim" } { (label) }
60+ td class=(TD) colspan="3" { div class="skeleton h-3.5 w-48" {} }
61+ }
62+ }
63+ }
64+ }
65+ }
66+ }
67+ };
68+ render(&ctx, Section::Storage, "Storage", html! {}, body)
69+}
70+
71+pub async fn db_totals(Admin(_admin): Admin, State(state): State<AppState>) -> AppResult<Response> {
72+ let rows: Vec<(String, i64, i64)> = sqlx::query_as(
73+ "select 'Git repositories (on disk)', count(*), coalesce(sum(size_bytes), 0)::bigint from repos
74+ union all select 'Image layers and configs (R2)', count(*), coalesce(sum(size), 0)::bigint from blobs
75+ union all select 'Image manifests (Postgres)', count(*), coalesce(sum(octet_length(content)), 0)::bigint from manifests
76+ union all select 'LFS objects (R2)', count(*), coalesce(sum(size), 0)::bigint from lfs_objects
77+ union all select 'CLI releases (R2)', count(*), coalesce(sum(size), 0)::bigint from cli_releases
78+ union all select 'Uploads in progress (disk)', count(*), coalesce(sum(size), 0)::bigint from blob_uploads
79+ union all select 'Avatars (R2)', count(*), 0::bigint from accounts where avatar_key is not null
80+ union all (select 'Last good backup (R2)', repo_count::bigint, bytes from backup_runs where status = 'ok' order by started_at desc limit 1)",
81+ )
82+ .fetch_all(&state.db)
83+ .await?;
84+ let database_size: i64 = sqlx::query_scalar("select pg_database_size(current_database())").fetch_one(&state.db).await?;
85+ let head = html! { (th("Kind")) (th("Count")) (th("Size")) };
86+ let body = html! {
87+ @for (kind, count, bytes) in &rows {
88+ tr {
89+ td class=(TD) { (kind) }
90+ td class={ (TD) " text-right font-mono text-xs" } { (count) }
91+ td class={ (TD) " text-right font-mono text-xs" } { @if *bytes > 0 { (ui::bytes(*bytes as u64)) } @else { span class="text-ink-faint" { "-" } } }
92+ }
93+ }
94+ tr {
95+ td class=(TD) { "Postgres database" }
96+ td class=(TD) {}
97+ td class={ (TD) " text-right font-mono text-xs" } { (ui::bytes(database_size.max(0) as u64)) }
98+ }
99+ };
100+ Ok(fragment(table(head, body)))
101+}
102+
103+#[derive(Deserialize)]
104+pub struct PrefixQuery {
105+ prefix: String,
106+}
107+
108+pub async fn r2_prefix(Admin(_admin): Admin, State(state): State<AppState>, Query(q): Query<PrefixQuery>) -> AppResult<Response> {
109+ let Some((prefix, label)) = PREFIXES.iter().find(|(p, _)| *p == q.prefix) else {
110+ return Err(AppError::bad("Unknown prefix."));
111+ };
112+ let started = std::time::Instant::now();
113+ // This fragment fills a <tbody>, so failures render as a row too.
114+ let objects = match state.storage.list(prefix).await {
115+ Ok(objects) => objects,
116+ Err(error) => {
117+ tracing::error!(?error, prefix, "listing R2 prefix failed");
118+ return Ok(fragment(html! {
119+ tr {
120+ td class=(TD) { code { (prefix) } }
121+ td class={ (TD) " text-ink-dim" } { (label) }
122+ td class={ (TD) " text-danger" } colspan="3" { "Could not list R2: " (format!("{error:#}")) }
123+ }
124+ }));
125+ }
126+ };
127+ let total: u64 = objects.iter().map(|o| o.size).sum();
128+ let newest = objects.iter().map(|o| o.last_modified.as_str()).max().unwrap_or("");
129+ tracing::debug!(prefix, objects = objects.len(), ms = started.elapsed().as_millis() as u64, "listed R2 prefix");
130+ Ok(fragment(html! {
131+ tr class="hover:bg-surface-raised" {
132+ td class=(TD) { code { (prefix) } }
133+ td class={ (TD) " text-ink-dim" } { (label) }
134+ td class={ (TD) " text-right font-mono text-xs" } { (objects.len()) }
135+ td class={ (TD) " text-right font-mono text-xs" } { (ui::bytes(total)) }
136+ td class={ (TD) " font-mono text-xs text-ink-faint" } { @if newest.is_empty() { "-" } @else { (newest) } }
137+ }
138+ }))
139+}
140+
141+pub async fn disk(Admin(_admin): Admin, State(state): State<AppState>) -> AppResult<Response> {
142+ let data_dir = state.config.data_dir.clone();
143+ let log_dir = state.config.log_dir.clone();
144+ let mut dirs: Vec<(String, PathBuf)> = ["repos", "uploads", "trash", "hooks"].iter().map(|d| (d.to_string(), data_dir.join(d))).collect();
145+ dirs.push(("logs".into(), log_dir));
146+ let sizes = tokio::task::spawn_blocking(move || dirs.into_iter().map(|(name, path)| (name, path.exists(), dir_size(&path))).collect::<Vec<_>>())
147+ .await
148+ .map_err(|e| AppError::Internal(e.into()))?;
149+ let filesystem = df(&data_dir).await;
150+ let head = html! { (th("Directory")) (th("Size")) };
151+ let body = html! {
152+ @for (name, exists, size) in &sizes {
153+ tr {
154+ td class=(TD) { code { (name) "/" } }
155+ td class={ (TD) " text-right font-mono text-xs" } { @if *exists { (ui::bytes(*size)) } @else { span class="text-ink-faint" { "not created" } } }
156+ }
157+ }
158+ };
159+ Ok(fragment(html! {
160+ (table(head, body))
161+ @if let Some((total, used, available)) = filesystem {
162+ @let pct = if total == 0 { 0.0 } else { used as f64 / total as f64 * 100.0 };
163+ div class="flex items-center gap-2 border-t border-edge px-2 py-1.5 text-xs" {
164+ span class="text-ink-dim" { "Filesystem" }
165+ div class="h-1.5 w-40 overflow-hidden rounded-[2px] bg-surface-hover" {
166+ div class={ "h-full " @if pct >= 90.0 { "bg-danger" } @else { "bg-accent" } } style={ "width:" (format!("{pct:.1}")) "%" } {}
167+ }
168+ span class="font-mono" { (ui::bytes(used)) " used of " (ui::bytes(total)) ", " (ui::bytes(available)) " free" }
169+ }
170+ }
171+ }))
172+}
173+
174+fn dir_size(path: &Path) -> u64 {
175+ let Ok(entries) = std::fs::read_dir(path) else { return 0 };
176+ entries
177+ .flatten()
178+ .map(|entry| match entry.metadata() {
179+ Ok(meta) if meta.is_dir() => dir_size(&entry.path()),
180+ Ok(meta) => meta.len(),
181+ Err(_) => 0,
182+ })
183+ .sum()
184+}
185+
186+/// (total, used, available) bytes of the filesystem holding `path`.
187+async fn df(path: &Path) -> Option<(u64, u64, u64)> {
188+ let output = tokio::process::Command::new("df").args(["-B1", "--output=size,used,avail"]).arg(path).output().await.ok()?;
189+ let text = String::from_utf8_lossy(&output.stdout);
190+ let mut fields = text.lines().nth(1)?.split_whitespace().map(|v| v.parse::<u64>().ok());
191+ Some((fields.next()??, fields.next()??, fields.next()??))
192+}
+340-0backend/src/web/admin/users.rs
@@ -0,0 +1,340 @@
1+//! /admin/users: search, quotas, admin and suspension flags, deletion.
2+
3+use axum::{
4+ Form,
5+ extract::{Path, Query, State},
6+ http::HeaderMap,
7+ response::Response,
8+};
9+use chrono::{DateTime, Utc};
10+use maud::html;
11+use serde::Deserialize;
12+use serde_json::json;
13+
14+use super::{
15+ Admin, Notice, PAGE_SIZE, Section, TD, action_button, back_field, back_with, check_origin, delete_form, like_pattern, notice,
16+ page_number, pager, query, record, render, table, th,
17+};
18+use crate::{
19+ error::{AppError, AppResult},
20+ models::Repo,
21+ ops,
22+ state::AppState,
23+ web::{
24+ layout::{self, Ctx},
25+ ui,
26+ },
27+};
28+
29+#[derive(Deserialize, Default)]
30+pub struct ListQuery {
31+ #[serde(default)]
32+ q: String,
33+ page: Option<i64>,
34+ msg: Option<String>,
35+ err: Option<String>,
36+}
37+
38+fn list_url(base: &str, q: &ListQuery) -> String {
39+ format!("{base}{}", query(&[("q", &q.q)]))
40+}
41+
42+pub async fn page(Admin(_admin): Admin, ctx: Ctx, Query(q): Query<ListQuery>) -> Response {
43+ let page = page_number(q.page);
44+ let fragment_url = format!("{}{}page={page}", list_url("/admin/f/users", &q), if q.q.is_empty() { "?" } else { "&" });
45+ let actions = html! {
46+ form method="get" action="/admin/users" class="flex gap-1" {
47+ input class="input w-64 py-0.5" type="search" name="q" value=(q.q) placeholder="Name, display name or email" autofocus[q.q.is_empty()];
48+ button class="btn btn-sm" type="submit" { "Search" }
49+ @if !q.q.is_empty() { a class="btn btn-sm" href="/admin/users" { "Clear" } }
50+ }
51+ };
52+ let body = html! {
53+ (notice(&Notice { msg: q.msg.clone(), err: q.err.clone() }))
54+ div class="box" { (ui::lazy(&fragment_url, 12)) }
55+ };
56+ render(&ctx, Section::Users, "Users", actions, body)
57+}
58+
59+#[derive(sqlx::FromRow)]
60+struct Row {
61+ id: i64,
62+ name: String,
63+ display_name: String,
64+ avatar_key: Option<String>,
65+ quota_bytes: Option<i64>,
66+ created_at: DateTime<Utc>,
67+ is_admin: bool,
68+ suspended_at: Option<DateTime<Utc>>,
69+ last_login_at: Option<DateTime<Utc>>,
70+ has_password: bool,
71+ google: bool,
72+ email: Option<String>,
73+ verified: Option<bool>,
74+ repo_count: i64,
75+ usage: i64,
76+}
77+
78+pub async fn fragment(Admin(admin): Admin, State(state): State<AppState>, Query(q): Query<ListQuery>) -> AppResult<Response> {
79+ let page = page_number(q.page);
80+ let rows: Vec<Row> = sqlx::query_as(
81+ "select a.id, a.name::text as name, a.display_name, a.avatar_key, a.quota_bytes, a.created_at,
82+ u.is_admin, u.suspended_at, u.last_login_at, (u.password_hash is not null) as has_password,
83+ exists (select 1 from oauth_identities o where o.user_id = a.id) as google,
84+ e.email::text as email, (e.verified_at is not null) as verified,
85+ (select count(*) from repos r where r.owner_id = a.id) as repo_count,
86+ (select coalesce(sum(r.size_bytes), 0)::bigint from repos r where r.owner_id = a.id) as usage
87+ from accounts a
88+ join users u on u.account_id = a.id
89+ left join emails e on e.user_id = a.id and e.is_primary
90+ where $1 = '%%' or a.name::text ilike $1 or a.display_name ilike $1
91+ or exists (select 1 from emails e2 where e2.user_id = a.id and e2.email::text ilike $1)
92+ order by a.created_at desc, a.id desc
93+ limit $2 offset $3",
94+ )
95+ .bind(like_pattern(&q.q))
96+ .bind(PAGE_SIZE + 1)
97+ .bind((page - 1) * PAGE_SIZE)
98+ .fetch_all(&state.db)
99+ .await?;
100+ let has_next = rows.len() as i64 > PAGE_SIZE;
101+ let rows = &rows[..rows.len().min(PAGE_SIZE as usize)];
102+ let base = list_url("/admin/users", &q);
103+ let back = format!("{base}{}page={page}", if q.q.is_empty() { "?" } else { "&" });
104+ let default_quota = state.config.limits.account_quota_bytes;
105+
106+ let head = html! {
107+ (th("User")) (th("Email")) (th("Flags")) (th("Created")) (th("Last sign-in")) (th("Repos")) (th("Storage")) (th("Quota (MB)")) (th("Actions"))
108+ };
109+ let body = html! {
110+ @for r in rows {
111+ @let url = format!("/admin/users/{}/action", r.id);
112+ @let quota = r.quota_bytes.map(|q| q.max(0) as u64).unwrap_or(default_quota);
113+ @let pct = if quota == 0 { 100.0 } else { (r.usage.max(0) as f64 / quota as f64 * 100.0).min(100.0) };
114+ tr class="hover:bg-surface-raised" {
115+ td class=(TD) {
116+ div class="flex items-center gap-2" {
117+ (ui::avatar(&r.name, r.avatar_key.as_deref(), 22))
118+ div class="min-w-0 leading-tight" {
119+ a href={ "/" (r.name) } class="font-medium" { (r.name) }
120+ @if !r.display_name.is_empty() { div class="max-w-48 truncate text-[11px] text-ink-faint" { (r.display_name) } }
121+ }
122+ }
123+ }
124+ td class=(TD) {
125+ @if let Some(email) = &r.email {
126+ span class="font-mono text-xs" { (email) }
127+ @if r.verified != Some(true) { " " span class="tag border-warn/50 text-warn" { "Unverified" } }
128+ } @else { span class="text-ink-faint" { "none" } }
129+ }
130+ td class=(TD) {
131+ div class="flex gap-1" {
132+ @if r.is_admin { span class="tag border-ember/60 text-ember" { "Admin" } }
133+ @if r.suspended_at.is_some() { span class="tag border-danger/60 text-danger" { "Suspended" } }
134+ @if r.google { span class="tag" { "Google" } }
135+ @if !r.has_password { span class="tag" title="Signs in with Google only" { "No password" } }
136+ @if r.id == admin.id { span class="tag" { "You" } }
137+ }
138+ }
139+ td class={ (TD) " text-xs text-ink-dim" } { (ui::time(r.created_at)) }
140+ td class={ (TD) " text-xs text-ink-dim" } {
141+ @match r.last_login_at { Some(t) => (ui::time(t)), None => span class="text-ink-faint" { "never" } }
142+ }
143+ td class={ (TD) " text-right font-mono text-xs" } { (r.repo_count) }
144+ td class={ (TD) " text-xs" } {
145+ div class="flex items-center gap-1.5" title={ (format!("{pct:.1}")) "% of quota" } {
146+ div class="h-1.5 w-14 overflow-hidden rounded-[2px] bg-surface-hover" {
147+ div class={ "h-full " @if pct >= 90.0 { "bg-danger" } @else { "bg-accent" } } style={ "width:" (format!("{pct:.1}")) "%" } {}
148+ }
149+ span class="font-mono" { (ui::bytes(r.usage.max(0) as u64)) " / " (ui::bytes(quota)) }
150+ }
151+ }
152+ td class=(TD) {
153+ form method="post" action=(url) class="flex gap-1" {
154+ (back_field(&back))
155+ input type="hidden" name="do" value="quota";
156+ input class="input w-20 py-0 font-mono text-xs" name="quota_mb" inputmode="numeric" placeholder="default"
157+ value=[r.quota_bytes.map(|q| (q.max(0) as u64 / (1024 * 1024)).to_string())] title="Blank uses the default quota";
158+ button class="btn btn-sm" type="submit" { "Set" }
159+ }
160+ }
161+ td class=(TD) {
162+ div class="flex items-start gap-1" {
163+ @if r.is_admin {
164+ (action_button(&url, &back, "demote", "Remove admin", ""))
165+ } @else {
166+ (action_button(&url, &back, "promote", "Make admin", ""))
167+ }
168+ @if r.suspended_at.is_some() {
169+ (action_button(&url, &back, "unsuspend", "Unsuspend", ""))
170+ } @else if r.id != admin.id {
171+ (action_button(&url, &back, "suspend", "Suspend", "btn-danger"))
172+ }
173+ @if r.email.is_some() && r.verified != Some(true) {
174+ (action_button(&url, &back, "verify", "Resend verification", ""))
175+ }
176+ @if r.id != admin.id {
177+ (delete_form(&url, &back, &r.name, "user and all their repositories"))
178+ }
179+ }
180+ }
181+ }
182+ }
183+ };
184+ Ok(layout::fragment(html! {
185+ @if rows.is_empty() {
186+ p class="px-3 py-6 text-center text-ink-faint" { @if q.q.is_empty() { "No users yet." } @else { "No users match " code { (q.q) } "." } }
187+ } @else {
188+ (table(head, body))
189+ (pager(&base, page, has_next, rows.len()))
190+ }
191+ }))
192+}
193+
194+#[derive(Deserialize)]
195+pub struct ActionForm {
196+ #[serde(rename = "do")]
197+ action: String,
198+ back: Option<String>,
199+ quota_mb: Option<String>,
200+ confirm: Option<String>,
201+}
202+
203+pub async fn action(
204+ Admin(admin): Admin,
205+ State(state): State<AppState>,
206+ headers: HeaderMap,
207+ Path(id): Path<i64>,
208+ Form(form): Form<ActionForm>,
209+) -> Response {
210+ if let Err(response) = check_origin(&headers, &state) {
211+ return response;
212+ }
213+ let result = run(&state, &admin, &headers, id, &form).await.map_err(|e| match e {
214+ AppError::Internal(error) => {
215+ tracing::error!(?error, user_id = id, action = %form.action, "admin user action failed");
216+ "Something went wrong; the error is in the server log.".to_string()
217+ }
218+ other => other.public_message(),
219+ });
220+ back_with(form.back.as_deref(), "/admin/users", result)
221+}
222+
223+async fn run(state: &AppState, admin: &crate::auth::Viewer, headers: &HeaderMap, id: i64, form: &ActionForm) -> AppResult<String> {
224+ let target: Option<(String, bool, Option<DateTime<Utc>>)> = sqlx::query_as(
225+ "select a.name::text, u.is_admin, u.suspended_at from accounts a join users u on u.account_id = a.id where a.id = $1",
226+ )
227+ .bind(id)
228+ .fetch_optional(&state.db)
229+ .await?;
230+ let Some((name, is_admin, _suspended)) = target else { return Err(AppError::NotFound) };
231+ let admins: i64 = sqlx::query_scalar("select count(*) from users where is_admin").fetch_one(&state.db).await?;
232+
233+ match form.action.as_str() {
234+ "promote" => {
235+ sqlx::query("update users set is_admin = true where account_id = $1").bind(id).execute(&state.db).await?;
236+ record(state, admin, headers, "admin.user.promote", &name, json!({})).await;
237+ Ok(format!("{name} is now a site admin."))
238+ }
239+ "demote" => {
240+ if is_admin && admins <= 1 {
241+ return Err(AppError::bad("That is the last site admin. Make someone else an admin first."));
242+ }
243+ sqlx::query("update users set is_admin = false where account_id = $1").bind(id).execute(&state.db).await?;
244+ record(state, admin, headers, "admin.user.demote", &name, json!({})).await;
245+ Ok(format!("{name} is no longer a site admin."))
246+ }
247+ "suspend" => {
248+ if id == admin.id {
249+ return Err(AppError::bad("You cannot suspend yourself."));
250+ }
251+ if is_admin {
252+ return Err(AppError::bad("Remove admin from this user before suspending them."));
253+ }
254+ sqlx::query("update users set suspended_at = now() where account_id = $1").bind(id).execute(&state.db).await?;
255+ let sessions = sqlx::query("delete from sessions where user_id = $1").bind(id).execute(&state.db).await?.rows_affected();
256+ record(state, admin, headers, "admin.user.suspend", &name, json!({ "sessions_ended": sessions })).await;
257+ Ok(format!("{name} is suspended; {sessions} session(s) ended and their tokens stop working."))
258+ }
259+ "unsuspend" => {
260+ sqlx::query("update users set suspended_at = null where account_id = $1").bind(id).execute(&state.db).await?;
261+ record(state, admin, headers, "admin.user.unsuspend", &name, json!({})).await;
262+ Ok(format!("{name} can sign in again."))
263+ }
264+ "quota" => {
265+ let quota = parse_quota(form.quota_mb.as_deref())?;
266+ sqlx::query("update accounts set quota_bytes = $2, updated_at = now() where id = $1").bind(id).bind(quota).execute(&state.db).await?;
267+ record(state, admin, headers, "admin.user.quota", &name, json!({ "quota_bytes": quota })).await;
268+ Ok(match quota {
269+ Some(q) => format!("{name}'s quota is now {}.", ui::bytes(q as u64)),
270+ None => format!("{name} uses the default quota ({}).", ui::bytes(state.config.limits.account_quota_bytes)),
271+ })
272+ }
273+ "verify" => {
274+ let email: Option<String> =
275+ sqlx::query_scalar("select email::text from emails where user_id = $1 and is_primary and verified_at is null")
276+ .bind(id)
277+ .fetch_optional(&state.db)
278+ .await?;
279+ let Some(email) = email else { return Err(AppError::bad("Their primary email is already verified.")) };
280+ ops::send_verification(state, id, &email).await?;
281+ record(state, admin, headers, "admin.user.resend_verification", &name, json!({ "email": email })).await;
282+ Ok(format!("Verification link sent to {email}."))
283+ }
284+ "delete" => {
285+ if id == admin.id {
286+ return Err(AppError::bad("You cannot delete your own account here."));
287+ }
288+ if is_admin {
289+ return Err(AppError::bad("Remove admin from this user before deleting them."));
290+ }
291+ if form.confirm.as_deref().map(str::trim) != Some(name.as_str()) {
292+ return Err(AppError::bad("The confirmation did not match the username."));
293+ }
294+ let removed = delete_account_repos(state, admin, id).await?;
295+ sqlx::query("delete from accounts where id = $1").bind(id).execute(&state.db).await?;
296+ record(state, admin, headers, "admin.user.delete", &name, json!({ "repos_deleted": removed })).await;
297+ Ok(format!("Deleted {name} and {removed} repositor{}.", if removed == 1 { "y" } else { "ies" }))
298+ }
299+ other => Err(AppError::bad(format!("Unknown action {other}."))),
300+ }
301+}
302+
303+/// Deletes every repository an account owns, files included, so deleting the
304+/// account leaves nothing behind on disk.
305+pub async fn delete_account_repos(state: &AppState, admin: &crate::auth::Viewer, owner_id: i64) -> AppResult<usize> {
306+ let ids: Vec<i64> = sqlx::query_scalar("select id from repos where owner_id = $1").bind(owner_id).fetch_all(&state.db).await?;
307+ for id in &ids {
308+ if let Some(repo) = Repo::by_id(&state.db, *id).await? {
309+ ops::delete_repo(state, admin, &repo).await?;
310+ }
311+ }
312+ Ok(ids.len())
313+}
314+
315+/// Megabytes from a form field; blank means "use the default" (None).
316+pub fn parse_quota(raw: Option<&str>) -> AppResult<Option<i64>> {
317+ let raw = raw.map(str::trim).unwrap_or("");
318+ if raw.is_empty() {
319+ return Ok(None);
320+ }
321+ let mb: u64 = raw.parse().map_err(|_| AppError::bad("Quota must be a whole number of megabytes, or blank for the default."))?;
322+ if mb > 100 * 1024 * 1024 {
323+ return Err(AppError::bad("That quota is unreasonably large."));
324+ }
325+ Ok(Some((mb * 1024 * 1024) as i64))
326+}
327+
328+#[cfg(test)]
329+mod tests {
330+ use super::*;
331+
332+ #[test]
333+ fn quota_parsing() {
334+ assert_eq!(parse_quota(Some("")).unwrap(), None);
335+ assert_eq!(parse_quota(None).unwrap(), None);
336+ assert_eq!(parse_quota(Some(" 10 ")).unwrap(), Some(10 * 1024 * 1024));
337+ assert!(parse_quota(Some("ten")).is_err());
338+ assert!(parse_quota(Some("-1")).is_err());
339+ }
340+}
+21-0frontend/src/styles/global.css
@@ -288,3 +288,24 @@
288288 #admin-shell[data-collapsed="true"] aside {
289289 width: 44px;
290290 }
291+
292+/* Admin panel (backend/src/web/admin): on phones the sidebar starts as
293+ icons and, when expanded, slides over the content instead of squeezing it. */
294+@media (max-width: 767px) {
295+ #admin-shell:not([data-collapsed="false"]) aside {
296+ width: 44px;
297+ }
298+ #admin-shell:not([data-collapsed="false"]) .sidebar-label {
299+ display: none;
300+ }
301+ #admin-shell[data-collapsed="false"] aside {
302+ position: absolute;
303+ top: 0;
304+ bottom: 0;
305+ left: 0;
306+ box-shadow: 4px 0 16px rgb(0 0 0 / 0.35);
307+ }
308+ #admin-shell[data-collapsed="false"] > section {
309+ margin-left: 44px;
310+ }
311+}