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
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 @@ | ||
| 288 | 288 | #admin-shell[data-collapsed="true"] aside { |
| 289 | 289 | width: 44px; |
| 290 | 290 | } |
| 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 | +} |