collin/anvil · 256654bc
feat: web file editing, image attachments, and admin disk-usage view
Collin Richards · 2026-06-10 13:39 UTC · 256654bc5528698cc4e824f45d89854ecb46e01e · parent a9984bbc · browse files
modifiedTODO.md+45 −7
| ⋯ 11 unchanged lines | |||
| 12 | 12 | no working tree. The new commit just advances the branch tip, so anyone who | |
| 13 | 13 | pushed earlier can fast-forward pull it. | |
| 14 | 14 | ||
| 15 | - | - start minimal: an "Edit" button on the blob page → textarea → commit; commits | |
| 16 | - | build the tree/commit objects via gix and move the ref (reject if the branch | |
| 17 | - | moved under us — no non-fast-forward clobber) | |
| 18 | - | - then richer editing: a real markdown editor with a live render preview | |
| 15 | + | - [x] start minimal: an "Edit" button on the blob page → textarea → commit; | |
| 16 | + | commits build the tree/commit objects via gix and move the ref (reject if the | |
| 17 | + | branch moved under us — no non-fast-forward clobber). `anvil-git/src/edit.rs` | |
| 18 | + | does the CAS commit; `ui.rs` `edit_form`/`edit_submit` wire the page. | |
| 19 | + | - [x] a structured way to add items to `TODO.md` — an "Add task" form that | |
| 20 | + | appends a ticket (`## title`, the richer card style) to the right section per | |
| 21 | + | the todo-md round-trip rules (`todomd::add_task` / `task_sections`), rather | |
| 22 | + | than hand-editing the raw file | |
| 23 | + | - [ ] then richer editing: a real markdown editor with a live render preview | |
| 19 | 24 | (reuse `render_markdown`) before committing | |
| 20 | - | - and a structured way to add items to `TODO.md` from the web UI — an "add task" | |
| 21 | - | form that appends a `- [ ]` to the right section per the todo-md spec's | |
| 22 | - | round-trip rules, rather than hand-editing the raw file | |
| 25 | + | ||
| 26 | + | ## Image uploads (attachments stored outside git) | |
| 27 | + | ||
| 28 | + | Upload an image in the web editor and link to it from the markdown without the | |
| 29 | + | blob ever entering git history. Stored content-addressed per repo and served | |
| 30 | + | back; the file only carries the URL. | |
| 31 | + | ||
| 32 | + | - [x] store: content-addressed blobs at `data/attachments/{repo_id}/{sha256}`, | |
| 33 | + | deduped per repo; `Attachment` model maps repo_id/hash → content-type, size, | |
| 34 | + | uploader, created-at. Kept out of `repositories/` so it's never a git object. | |
| 35 | + | (`anvil-core`: `attachments`, `storage::attachment_path`, schema shim.) | |
| 36 | + | - [x] serve: `GET /{owner}/{repo}/-/attachments/{hash}`, read-access gated | |
| 37 | + | (private repos stay private), immutable cache + `nosniff` + locked-down CSP. | |
| 38 | + | - [x] upload: `POST /{owner}/{repo}/-/attachments` behind write-access + CSRF | |
| 39 | + | (`X-CSRF-Token` header), magic-byte sniffed to png/jpeg/gif/webp (SVG | |
| 40 | + | rejected), capped by `http.attachment_max_mb`, returns the markdown to splice. | |
| 41 | + | - [x] editor UX: paste or drop an image in the file editor → background upload → | |
| 42 | + | `` inserted at the cursor. | |
| 43 | + | - [x] caps: per-repo attachment quota (`http.attachment_quota_mb`, 0 = | |
| 44 | + | unlimited) — a new upload over the cap is rejected; deduped re-uploads are | |
| 45 | + | always free. (Reject, not evict: evicting would break live Markdown links.) | |
| 46 | + | - [ ] within-repo reclaim: an orphan sweep (delete attachments no committed file | |
| 47 | + | references) and/or a per-attachment delete action — the recourse once a repo | |
| 48 | + | hits its quota. Deferred: deletion is destructive and "orphaned" is fuzzy | |
| 49 | + | (tip-only vs any-ref), so it wants its own design pass. | |
| 50 | + | - [ ] remove a repo's attachment + artifact dirs on repo delete — blocked: there | |
| 51 | + | is no repo-delete path yet (only the create-rollback uses it). | |
| 52 | + | ||
| 53 | + | ## Admin: site disk-usage dashboard | |
| 54 | + | ||
| 55 | + | - [x] `/-/admin/usage` (admin-only; 404 for everyone else, nav link for admins): | |
| 56 | + | actual on-disk bytes per user, broken down by content type (repositories / CI | |
| 57 | + | artifacts / attachments) with column + grand totals. `anvil-core::usage` | |
| 58 | + | walks the stores; `storage::dir_size` sums them. | |
| 59 | + | - [ ] maybe: per-repo drill-down, and a cheap cached/periodic variant if the | |
| 60 | + | on-demand disk walk gets slow on large instances. | |
modifiedanvil.example.toml+7 −0
| ⋯ 10 unchanged lines | |||
| 11 | 11 | # Highlighting large files is CPU-heavy, so repeat views are served from this | |
| 12 | 12 | # cache. Set to 0 to disable it entirely on RAM-constrained hosts. | |
| 13 | 13 | highlight_cache_mb = 16 | |
| 14 | + | # Maximum size (MiB) of a single uploaded attachment (e.g. an image pasted | |
| 15 | + | # into the web file editor). Larger uploads are rejected. | |
| 16 | + | attachment_max_mb = 16 | |
| 17 | + | # Per-repository cap (MiB) on total stored attachments. An upload that would | |
| 18 | + | # exceed it is rejected; re-uploading existing (deduped) content is free. | |
| 19 | + | # 0 means unlimited. | |
| 20 | + | attachment_quota_mb = 0 | |
| 14 | 21 | ||
| 15 | 22 | [ssh] | |
| 16 | 23 | enabled = false | |
| ⋯ 39 unchanged lines | |||
addedcrates/anvil-core/src/attachments.rs+79 −0
| 1 | + | //! Uploaded attachments: content-addressed files stored outside git. | |
| 2 | + | //! | |
| 3 | + | //! The bytes live on disk under `storage::attachment_path` (never in a git | |
| 4 | + | //! repository); this module owns the database rows that index them per repo | |
| 5 | + | //! and the content hashing used as their address. See [`crate::models::Attachment`]. | |
| 6 | + | ||
| 7 | + | use sha2::{ | |
| 8 | + | Digest, | |
| 9 | + | Sha256, | |
| 10 | + | }; | |
| 11 | + | ||
| 12 | + | use crate::{ | |
| 13 | + | error::Result, | |
| 14 | + | models::Attachment, | |
| 15 | + | }; | |
| 16 | + | ||
| 17 | + | /// Lowercase hex SHA-256 of `bytes` — the content address used as both the | |
| 18 | + | /// dedup key and the on-disk filename. | |
| 19 | + | pub fn content_hash(bytes: &[u8]) -> String { | |
| 20 | + | Sha256::digest(bytes) | |
| 21 | + | .iter() | |
| 22 | + | .map(|b| format!("{b:02x}")) | |
| 23 | + | .collect() | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// The attachment row for `(repo_id, hash)`, if one exists. | |
| 27 | + | pub async fn find(db: &toasty::Db, repo_id: i64, hash: &str) -> Result<Option<Attachment>> { | |
| 28 | + | let mut conn = db.clone(); | |
| 29 | + | let row = Attachment::filter(Attachment::fields().repo_id().eq(repo_id)) | |
| 30 | + | .filter(Attachment::fields().hash().eq(hash)) | |
| 31 | + | .first() | |
| 32 | + | .exec(&mut conn) | |
| 33 | + | .await?; | |
| 34 | + | Ok(row) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// Record an attachment for a repo, deduping on content: if `hash` is already | |
| 38 | + | /// recorded for `repo_id` the existing row is returned and no new row is made. | |
| 39 | + | /// The caller writes the bytes to [`crate::storage::attachment_path`] itself. | |
| 40 | + | pub async fn add( | |
| 41 | + | db: &toasty::Db, | |
| 42 | + | repo_id: i64, | |
| 43 | + | hash: &str, | |
| 44 | + | content_type: &str, | |
| 45 | + | size: i64, | |
| 46 | + | uploader_id: i64, | |
| 47 | + | ) -> Result<Attachment> { | |
| 48 | + | if let Some(existing) = find(db, repo_id, hash).await? { | |
| 49 | + | return Ok(existing); | |
| 50 | + | } | |
| 51 | + | let mut conn = db.clone(); | |
| 52 | + | let row = toasty::create!(Attachment { | |
| 53 | + | repo_id: repo_id, | |
| 54 | + | hash: hash, | |
| 55 | + | content_type: content_type, | |
| 56 | + | size: size, | |
| 57 | + | uploader_id: uploader_id, | |
| 58 | + | created_at: crate::now(), | |
| 59 | + | }) | |
| 60 | + | .exec(&mut conn) | |
| 61 | + | .await?; | |
| 62 | + | Ok(row) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | #[cfg(test)] | |
| 66 | + | mod tests { | |
| 67 | + | use super::*; | |
| 68 | + | ||
| 69 | + | #[test] | |
| 70 | + | fn content_hash_is_stable_lowercase_hex_sha256() { | |
| 71 | + | // Known SHA-256 of "hello". | |
| 72 | + | assert_eq!( | |
| 73 | + | content_hash(b"hello"), | |
| 74 | + | "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" | |
| 75 | + | ); | |
| 76 | + | assert_eq!(content_hash(b"a"), content_hash(b"a")); | |
| 77 | + | assert_ne!(content_hash(b"a"), content_hash(b"b")); | |
| 78 | + | } | |
| 79 | + | } |
modifiedcrates/anvil-core/src/config.rs+17 −0
| ⋯ 103 unchanged lines | |||
| 104 | 104 | /// `0` disables it — lowest memory, every view re-highlights. Defaults | |
| 105 | 105 | /// to 16. | |
| 106 | 106 | pub highlight_cache_mb: usize, | |
| 107 | + | /// Maximum size, in MiB, of a single uploaded attachment (e.g. an image | |
| 108 | + | /// pasted into the file editor). Uploads over this are rejected. Defaults | |
| 109 | + | /// to 16. | |
| 110 | + | pub attachment_max_mb: usize, | |
| 111 | + | /// Per-repository cap, in MiB, on total stored attachments. A new upload | |
| 112 | + | /// that would push a repo over this is rejected (re-uploading existing, | |
| 113 | + | /// deduped content is always free). `0` means unlimited. Defaults to 0. | |
| 114 | + | pub attachment_quota_mb: usize, | |
| 107 | 115 | } | |
| 108 | 116 | ||
| 109 | 117 | #[derive(Clone, Debug, Deserialize, Serialize)] | |
| ⋯ 76 unchanged lines | |||
| 186 | 194 | listen: "127.0.0.1:3000".to_string(), | |
| 187 | 195 | base_url: "http://localhost:3000".to_string(), | |
| 188 | 196 | highlight_cache_mb: 16, | |
| 197 | + | attachment_max_mb: 16, | |
| 198 | + | attachment_quota_mb: 0, | |
| 189 | 199 | } | |
| 190 | 200 | } | |
| 191 | 201 | } | |
| ⋯ 45 unchanged lines | |||
| 237 | 247 | self.data_dir.join("artifacts") | |
| 238 | 248 | } | |
| 239 | 249 | ||
| 250 | + | /// Root directory under which uploaded attachments are stored | |
| 251 | + | /// (`attachments/{repo_id}/{hash}`). Kept out of `repositories/` so the | |
| 252 | + | /// files are never git objects. | |
| 253 | + | pub fn attachments_dir(&self) -> PathBuf { | |
| 254 | + | self.data_dir.join("attachments") | |
| 255 | + | } | |
| 256 | + | ||
| 240 | 257 | /// Whether session cookies should carry the `Secure` attribute (HTTPS-only). | |
| 241 | 258 | /// Derived from the public base URL's scheme, so local plaintext dev still | |
| 242 | 259 | /// works while production behind TLS gets `Secure` automatically. | |
| ⋯ 57 unchanged lines | |||
modifiedcrates/anvil-core/src/db.rs+15 −2
| ⋯ 4 unchanged lines | |||
| 5 | 5 | use crate::{ | |
| 6 | 6 | error::Result, | |
| 7 | 7 | models::{ | |
| 8 | + | Attachment, | |
| 8 | 9 | CiArtifact, | |
| 9 | 10 | CiRun, | |
| 10 | 11 | Issue, | |
| ⋯ 28 unchanged lines | |||
| 39 | 40 | CiRun, | |
| 40 | 41 | CiArtifact, | |
| 41 | 42 | Issue, | |
| 42 | - | IssueComment | |
| 43 | + | IssueComment, | |
| 44 | + | Attachment | |
| 43 | 45 | )) | |
| 44 | 46 | .connect(&url) | |
| 45 | 47 | .await?; | |
| ⋯ 18 unchanged lines | |||
| 64 | 66 | r#"CREATE INDEX IF NOT EXISTS "index_issues_by_repo_id" ON "issues" ("repo_id")"#, | |
| 65 | 67 | ISSUE_COMMENTS_DDL, | |
| 66 | 68 | r#"CREATE INDEX IF NOT EXISTS "index_issue_comments_by_issue_id" ON "issue_comments" ("issue_id")"#, | |
| 69 | + | ATTACHMENTS_DDL, | |
| 70 | + | r#"CREATE INDEX IF NOT EXISTS "index_attachments_by_repo_id" ON "attachments" ("repo_id")"#, | |
| 67 | 71 | ]; | |
| 68 | 72 | ||
| 69 | 73 | const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" ( | |
| ⋯ 26 unchanged lines | |||
| 96 | 100 | "body" TEXT NOT NULL, | |
| 97 | 101 | "created_at" BIGINT NOT NULL )"#; | |
| 98 | 102 | ||
| 103 | + | const ATTACHMENTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "attachments" ( | |
| 104 | + | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |
| 105 | + | "repo_id" BIGINT NOT NULL, | |
| 106 | + | "hash" TEXT NOT NULL, | |
| 107 | + | "content_type" TEXT NOT NULL, | |
| 108 | + | "size" BIGINT NOT NULL, | |
| 109 | + | "uploader_id" BIGINT NOT NULL, | |
| 110 | + | "created_at" BIGINT NOT NULL )"#; | |
| 111 | + | ||
| 99 | 112 | /// Columns added to existing tables after deployment, applied as | |
| 100 | 113 | /// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS` | |
| 101 | 114 | /// for columns, so presence is checked via `pragma_table_info`). The model | |
| ⋯ 36 unchanged lines | |||
| 138 | 151 | use super::*; | |
| 139 | 152 | ||
| 140 | 153 | /// Tables created by shims (i.e. added after the first deployment). | |
| 141 | - | const SHIMMED_TABLES: &[&str] = &["ci_artifacts", "issues", "issue_comments"]; | |
| 154 | + | const SHIMMED_TABLES: &[&str] = &["ci_artifacts", "issues", "issue_comments", "attachments"]; | |
| 142 | 155 | ||
| 143 | 156 | /// Every schema object (table + indexes) for `table`, normalized. | |
| 144 | 157 | fn schema_objects(path: &Path, table: &str) -> Vec<String> { | |
| ⋯ 117 unchanged lines | |||
modifiedcrates/anvil-core/src/lib.rs+3 −0
| ⋯ 5 unchanged lines | |||
| 6 | 6 | //! `anvil-git`) build on top of it. | |
| 7 | 7 | ||
| 8 | 8 | pub mod access; | |
| 9 | + | pub mod attachments; | |
| 9 | 10 | pub mod ci; | |
| 10 | 11 | pub mod config; | |
| 11 | 12 | pub mod db; | |
| ⋯ 4 unchanged lines | |||
| 16 | 17 | pub mod sessions; | |
| 17 | 18 | pub mod ssh_keys; | |
| 18 | 19 | pub mod storage; | |
| 20 | + | pub mod usage; | |
| 19 | 21 | pub mod users; | |
| 20 | 22 | ||
| 21 | 23 | pub use config::Config; | |
| ⋯ 2 unchanged lines | |||
| 24 | 26 | Result, | |
| 25 | 27 | }; | |
| 26 | 28 | pub use models::{ | |
| 29 | + | Attachment, | |
| 27 | 30 | CiArtifact, | |
| 28 | 31 | CiRun, | |
| 29 | 32 | Issue, | |
| ⋯ 100 unchanged lines | |||
modifiedcrates/anvil-core/src/models.rs+25 −0
| ⋯ 143 unchanged lines | |||
| 144 | 144 | pub expires_at: i64, | |
| 145 | 145 | } | |
| 146 | 146 | ||
| 147 | + | /// An uploaded file (e.g. an image pasted into the file editor), stored | |
| 148 | + | /// outside git at `data_dir/attachments/{repo_id}/{hash}` so large binaries | |
| 149 | + | /// never enter the repository's history. Markdown carries only the serve URL. | |
| 150 | + | /// | |
| 151 | + | /// Content-addressed: `hash` is the lowercase hex SHA-256 of the bytes, so the | |
| 152 | + | /// same content uploaded twice to a repo dedupes to one file. Lookups and GC | |
| 153 | + | /// scope by `repo_id`, which also gates serving by the repo's read access. | |
| 154 | + | #[derive(Clone, Debug, toasty::Model)] | |
| 155 | + | pub struct Attachment { | |
| 156 | + | #[key] | |
| 157 | + | #[auto] | |
| 158 | + | pub id: i64, | |
| 159 | + | #[index] | |
| 160 | + | pub repo_id: i64, | |
| 161 | + | /// Lowercase hex SHA-256 of the content — both the dedup key and the path | |
| 162 | + | /// component under the repo's attachment directory. | |
| 163 | + | pub hash: String, | |
| 164 | + | /// MIME type to serve the bytes with (e.g. `image/png`). | |
| 165 | + | pub content_type: String, | |
| 166 | + | pub size: i64, | |
| 167 | + | /// The user who first uploaded this content to the repo. | |
| 168 | + | pub uploader_id: i64, | |
| 169 | + | pub created_at: i64, | |
| 170 | + | } | |
| 171 | + | ||
| 147 | 172 | /// A registered SSH public key, used to authenticate git-over-SSH connections. | |
| 148 | 173 | #[derive(Debug, toasty::Model)] | |
| 149 | 174 | pub struct SshKey { | |
| ⋯ 13 unchanged lines | |||
modifiedcrates/anvil-core/src/storage.rs+28 −0
| ⋯ 25 unchanged lines | |||
| 26 | 26 | artifacts_dir.join(repo_id.to_string()).join(commit) | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | + | /// On-disk path of an uploaded attachment | |
| 30 | + | /// (`<attachments_dir>/<repo_id>/<hash>`). Content-addressed, so the file is | |
| 31 | + | /// immutable once written. `hash` is validated hex by the caller. | |
| 32 | + | pub fn attachment_path(attachments_dir: &Path, repo_id: i64, hash: &str) -> PathBuf { | |
| 33 | + | attachments_dir.join(repo_id.to_string()).join(hash) | |
| 34 | + | } | |
| 35 | + | ||
| 29 | 36 | /// Create a new bare repository on disk, returning the opened handle. | |
| 30 | 37 | /// | |
| 31 | 38 | /// `HEAD` is pointed at `refs/heads/<default_branch>` so the on-disk repository | |
| ⋯ 56 unchanged lines | |||
| 88 | 95 | Ok(()) | |
| 89 | 96 | } | |
| 90 | 97 | ||
| 98 | + | /// Total size in bytes of all regular files under `path`, recursively. A | |
| 99 | + | /// missing or unreadable directory counts as 0, and symlinks are not followed | |
| 100 | + | /// (so cycles can't trap the walk). Used for disk-usage accounting. | |
| 101 | + | pub fn dir_size(path: &Path) -> u64 { | |
| 102 | + | let Ok(entries) = std::fs::read_dir(path) else { | |
| 103 | + | return 0; | |
| 104 | + | }; | |
| 105 | + | let mut total = 0; | |
| 106 | + | for entry in entries.flatten() { | |
| 107 | + | let Ok(file_type) = entry.file_type() else { | |
| 108 | + | continue; | |
| 109 | + | }; | |
| 110 | + | if file_type.is_dir() { | |
| 111 | + | total += dir_size(&entry.path()); | |
| 112 | + | } else if file_type.is_file() { | |
| 113 | + | total += entry.metadata().map(|m| m.len()).unwrap_or(0); | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | total | |
| 117 | + | } | |
| 118 | + | ||
| 91 | 119 | /// Open an existing bare repository. | |
| 92 | 120 | pub fn open(repositories_dir: &Path, owner: &str, name: &str) -> Result<gix::Repository> { | |
| 93 | 121 | let path = repo_path(repositories_dir, owner, name); | |
| ⋯ 2 unchanged lines | |||
addedcrates/anvil-core/src/usage.rs+98 −0
| 1 | + | //! Disk-usage accounting for the admin dashboard. | |
| 2 | + | //! | |
| 3 | + | //! Walks the on-disk stores (bare repositories, CI artifacts, uploaded | |
| 4 | + | //! attachments) and attributes each repository's bytes to its owner, broken | |
| 5 | + | //! down by content type. Sizes are actual on-disk bytes, so they include git | |
| 6 | + | //! packs, artifact tarballs, etc. — not just logical row sizes. | |
| 7 | + | ||
| 8 | + | use std::collections::BTreeMap; | |
| 9 | + | ||
| 10 | + | use crate::{ | |
| 11 | + | App, | |
| 12 | + | error::Result, | |
| 13 | + | models::{ | |
| 14 | + | Repository, | |
| 15 | + | User, | |
| 16 | + | }, | |
| 17 | + | storage, | |
| 18 | + | }; | |
| 19 | + | ||
| 20 | + | /// One user's disk usage, split by content type (bytes). | |
| 21 | + | #[derive(Clone, Debug, Default)] | |
| 22 | + | pub struct UserUsage { | |
| 23 | + | pub username: String, | |
| 24 | + | pub git: u64, | |
| 25 | + | pub artifacts: u64, | |
| 26 | + | pub attachments: u64, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | impl UserUsage { | |
| 30 | + | pub fn total(&self) -> u64 { | |
| 31 | + | self.git + self.artifacts + self.attachments | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// Site-wide disk usage: per-user rows (largest first) plus column totals. | |
| 36 | + | #[derive(Clone, Debug, Default)] | |
| 37 | + | pub struct Usage { | |
| 38 | + | pub per_user: Vec<UserUsage>, | |
| 39 | + | pub git: u64, | |
| 40 | + | pub artifacts: u64, | |
| 41 | + | pub attachments: u64, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | impl Usage { | |
| 45 | + | pub fn total(&self) -> u64 { | |
| 46 | + | self.git + self.artifacts + self.attachments | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// Compute site-wide disk usage by walking every repository's on-disk stores. | |
| 51 | + | /// O(files on disk) — intended for an on-demand admin page, not a hot path. | |
| 52 | + | pub async fn compute(app: &App) -> Result<Usage> { | |
| 53 | + | let repos_dir = app.config.repositories_dir(); | |
| 54 | + | let artifacts_dir = app.config.artifacts_dir(); | |
| 55 | + | let attachments_dir = app.config.attachments_dir(); | |
| 56 | + | ||
| 57 | + | let mut conn = app.db.clone(); | |
| 58 | + | let users = User::all().exec(&mut conn).await?; | |
| 59 | + | // Seed a row per user so accounts with no repos still appear (at zero). | |
| 60 | + | let mut by_user: BTreeMap<i64, UserUsage> = users | |
| 61 | + | .iter() | |
| 62 | + | .map(|u| { | |
| 63 | + | ( | |
| 64 | + | u.id, | |
| 65 | + | UserUsage { | |
| 66 | + | username: u.username.clone(), | |
| 67 | + | ..Default::default() | |
| 68 | + | }, | |
| 69 | + | ) | |
| 70 | + | }) | |
| 71 | + | .collect(); | |
| 72 | + | ||
| 73 | + | let mut conn = app.db.clone(); | |
| 74 | + | let repos = Repository::all().exec(&mut conn).await?; | |
| 75 | + | let mut usage = Usage::default(); | |
| 76 | + | for repo in repos { | |
| 77 | + | // Skip repos whose owner row is gone (shouldn't happen); their bytes | |
| 78 | + | // can't be attributed to a user. | |
| 79 | + | let Some(entry) = by_user.get_mut(&repo.owner_id) else { | |
| 80 | + | continue; | |
| 81 | + | }; | |
| 82 | + | let git = storage::dir_size(&storage::repo_path(&repos_dir, &entry.username, &repo.name)); | |
| 83 | + | let artifacts = storage::dir_size(&artifacts_dir.join(repo.id.to_string())); | |
| 84 | + | let attachments = storage::dir_size(&attachments_dir.join(repo.id.to_string())); | |
| 85 | + | ||
| 86 | + | entry.git += git; | |
| 87 | + | entry.artifacts += artifacts; | |
| 88 | + | entry.attachments += attachments; | |
| 89 | + | usage.git += git; | |
| 90 | + | usage.artifacts += artifacts; | |
| 91 | + | usage.attachments += attachments; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | let mut per_user: Vec<UserUsage> = by_user.into_values().collect(); | |
| 95 | + | per_user.sort_by(|a, b| b.total().cmp(&a.total()).then(a.username.cmp(&b.username))); | |
| 96 | + | usage.per_user = per_user; | |
| 97 | + | Ok(usage) | |
| 98 | + | } |
addedcrates/anvil-git/src/edit.rs+292 −0
| 1 | + | //! Web-driven file edits: write a new commit directly onto a branch of a | |
| 2 | + | //! bare repository with gix — no working tree, no index. | |
| 3 | + | //! | |
| 4 | + | //! The new blob, the rebuilt trees along the file's path, and the commit | |
| 5 | + | //! object are written to the object database, then the branch ref is | |
| 6 | + | //! advanced with a compare-and-swap (the transaction insists the tip still | |
| 7 | + | //! matches what the editor saw — a concurrent push loses nobody's work, the | |
| 8 | + | //! web edit is simply rejected and re-offered). The commit is a plain child | |
| 9 | + | //! of the old tip, so clients that pushed earlier can fast-forward pull. | |
| 10 | + | ||
| 11 | + | use std::path::Path; | |
| 12 | + | ||
| 13 | + | use gix::objs::tree; | |
| 14 | + | ||
| 15 | + | /// Why an edit didn't commit. `BranchMoved` and `NoChanges` are normal | |
| 16 | + | /// outcomes the UI explains; `Other` is a real failure. | |
| 17 | + | #[derive(Debug)] | |
| 18 | + | pub enum EditError { | |
| 19 | + | /// The branch tip no longer matches what the editor was looking at. | |
| 20 | + | BranchMoved { | |
| 21 | + | current: String, | |
| 22 | + | }, | |
| 23 | + | /// The new content is identical to what's already committed. | |
| 24 | + | NoChanges, | |
| 25 | + | Other(String), | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | impl std::fmt::Display for EditError { | |
| 29 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 30 | + | match self { | |
| 31 | + | EditError::BranchMoved { current } => { | |
| 32 | + | write!(f, "branch moved (now at {current})") | |
| 33 | + | } | |
| 34 | + | EditError::NoChanges => write!(f, "no changes"), | |
| 35 | + | EditError::Other(m) => write!(f, "{m}"), | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | fn other(e: impl std::fmt::Display) -> EditError { | |
| 41 | + | EditError::Other(e.to_string()) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// Replace `file_path`'s content on `branch` with a new commit authored by | |
| 45 | + | /// `author_name`/`author_email`, expecting the branch tip to be | |
| 46 | + | /// `expected_tip` (full hex). Returns the new commit id. | |
| 47 | + | /// | |
| 48 | + | /// Only existing files can be edited (no creation/deletion here); the | |
| 49 | + | /// entry's mode is preserved, so editing an executable keeps it executable. | |
| 50 | + | #[allow(clippy::too_many_arguments)] | |
| 51 | + | pub fn commit_file_change( | |
| 52 | + | repo_path: &Path, | |
| 53 | + | branch: &str, | |
| 54 | + | expected_tip: &str, | |
| 55 | + | file_path: &str, | |
| 56 | + | content: &[u8], | |
| 57 | + | author_name: &str, | |
| 58 | + | author_email: &str, | |
| 59 | + | message: &str, | |
| 60 | + | ) -> Result<String, EditError> { | |
| 61 | + | let repo = gix::open(repo_path).map_err(other)?; | |
| 62 | + | ||
| 63 | + | let tip = crate::browse::resolve_commit(repo_path, &format!("refs/heads/{branch}")) | |
| 64 | + | .map_err(|_| EditError::Other(format!("no such branch: {branch}")))?; | |
| 65 | + | if tip != expected_tip { | |
| 66 | + | return Err(EditError::BranchMoved { current: tip }); | |
| 67 | + | } | |
| 68 | + | let tip_id = gix::ObjectId::from_hex(tip.as_bytes()).map_err(other)?; | |
| 69 | + | ||
| 70 | + | let root_tree = repo | |
| 71 | + | .find_object(tip_id) | |
| 72 | + | .map_err(other)? | |
| 73 | + | .peel_to_commit() | |
| 74 | + | .map_err(other)? | |
| 75 | + | .tree_id() | |
| 76 | + | .map_err(other)? | |
| 77 | + | .detach(); | |
| 78 | + | ||
| 79 | + | let blob_id = repo.write_blob(content).map_err(other)?.detach(); | |
| 80 | + | let components: Vec<&str> = file_path.split('/').filter(|c| !c.is_empty()).collect(); | |
| 81 | + | if components.is_empty() { | |
| 82 | + | return Err(EditError::Other("empty path".into())); | |
| 83 | + | } | |
| 84 | + | let new_root = replace_in_tree(&repo, root_tree, &components, blob_id)?; | |
| 85 | + | if new_root == root_tree { | |
| 86 | + | return Err(EditError::NoChanges); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | let author = gix::actor::Signature { | |
| 90 | + | name: author_name.into(), | |
| 91 | + | email: author_email.into(), | |
| 92 | + | time: gix::date::Time::now_local_or_utc(), | |
| 93 | + | }; | |
| 94 | + | let commit = gix::objs::Commit { | |
| 95 | + | tree: new_root, | |
| 96 | + | parents: [tip_id].into_iter().collect(), | |
| 97 | + | author: author.clone(), | |
| 98 | + | committer: author, | |
| 99 | + | encoding: None, | |
| 100 | + | message: message.into(), | |
| 101 | + | extra_headers: Vec::new(), | |
| 102 | + | }; | |
| 103 | + | let commit_id = repo.write_object(&commit).map_err(other)?.detach(); | |
| 104 | + | ||
| 105 | + | // The compare-and-swap: the update only lands if the tip is still the | |
| 106 | + | // one the editor saw. A racing push makes this fail cleanly. | |
| 107 | + | use gix::refs::{ | |
| 108 | + | Target, | |
| 109 | + | transaction::{ | |
| 110 | + | Change, | |
| 111 | + | LogChange, | |
| 112 | + | PreviousValue, | |
| 113 | + | RefEdit, | |
| 114 | + | RefLog, | |
| 115 | + | }, | |
| 116 | + | }; | |
| 117 | + | let name: gix::refs::FullName = format!("refs/heads/{branch}") | |
| 118 | + | .try_into() | |
| 119 | + | .map_err(|e: gix::validate::reference::name::Error| other(e))?; | |
| 120 | + | repo.edit_reference(RefEdit { | |
| 121 | + | change: Change::Update { | |
| 122 | + | log: LogChange { | |
| 123 | + | mode: RefLog::AndReference, | |
| 124 | + | force_create_reflog: false, | |
| 125 | + | message: "web edit".into(), | |
| 126 | + | }, | |
| 127 | + | expected: PreviousValue::MustExistAndMatch(Target::Object(tip_id)), | |
| 128 | + | new: Target::Object(commit_id), | |
| 129 | + | }, | |
| 130 | + | name, | |
| 131 | + | deref: false, | |
| 132 | + | }) | |
| 133 | + | .map_err(|e| { | |
| 134 | + | // Re-read the tip for a friendlier conflict message; the transaction | |
| 135 | + | // error already implies it moved. | |
| 136 | + | match crate::browse::resolve_commit(repo_path, &format!("refs/heads/{branch}")) { | |
| 137 | + | Ok(current) if current != expected_tip => EditError::BranchMoved { current }, | |
| 138 | + | _ => other(e), | |
| 139 | + | } | |
| 140 | + | })?; | |
| 141 | + | ||
| 142 | + | Ok(commit_id.to_string()) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// Rebuild the trees along `components`, swapping the final entry's oid for | |
| 146 | + | /// `blob_id`. The file must already exist; its mode is preserved. | |
| 147 | + | fn replace_in_tree( | |
| 148 | + | repo: &gix::Repository, | |
| 149 | + | tree_id: gix::ObjectId, | |
| 150 | + | components: &[&str], | |
| 151 | + | blob_id: gix::ObjectId, | |
| 152 | + | ) -> Result<gix::ObjectId, EditError> { | |
| 153 | + | let obj = repo.find_object(tree_id).map_err(other)?; | |
| 154 | + | let tree_ref = | |
| 155 | + | gix::objs::TreeRef::from_bytes(&obj.data, gix::hash::Kind::Sha1).map_err(other)?; | |
| 156 | + | let mut tree: gix::objs::Tree = tree_ref.into(); | |
| 157 | + | ||
| 158 | + | let (name, rest) = components.split_first().expect("non-empty components"); | |
| 159 | + | let entry = tree | |
| 160 | + | .entries | |
| 161 | + | .iter_mut() | |
| 162 | + | .find(|e| e.filename == *name) | |
| 163 | + | .ok_or_else(|| EditError::Other(format!("no such file in tree: {name}")))?; | |
| 164 | + | ||
| 165 | + | if rest.is_empty() { | |
| 166 | + | if !entry.mode.is_blob() { | |
| 167 | + | return Err(EditError::Other(format!("{name} is not a file"))); | |
| 168 | + | } | |
| 169 | + | entry.oid = blob_id; | |
| 170 | + | } else { | |
| 171 | + | if entry.mode != tree::EntryKind::Tree.into() { | |
| 172 | + | return Err(EditError::Other(format!("{name} is not a directory"))); | |
| 173 | + | } | |
| 174 | + | entry.oid = replace_in_tree(repo, entry.oid, rest, blob_id)?; | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | Ok(repo.write_object(&tree).map_err(other)?.detach()) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[cfg(test)] | |
| 181 | + | mod tests { | |
| 182 | + | use super::*; | |
| 183 | + | ||
| 184 | + | fn git(dir: &Path, args: &[&str]) { | |
| 185 | + | let out = std::process::Command::new("git") | |
| 186 | + | .args(args) | |
| 187 | + | .current_dir(dir) | |
| 188 | + | .env("GIT_AUTHOR_NAME", "t") | |
| 189 | + | .env("GIT_AUTHOR_EMAIL", "t@example.com") | |
| 190 | + | .env("GIT_COMMITTER_NAME", "t") | |
| 191 | + | .env("GIT_COMMITTER_EMAIL", "t@example.com") | |
| 192 | + | .output() | |
| 193 | + | .expect("run git"); | |
| 194 | + | assert!(out.status.success(), "git {args:?}: {out:?}"); | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | fn fixture(dir: &Path) { | |
| 198 | + | git(dir, &["init", "-q", "-b", "main"]); | |
| 199 | + | std::fs::create_dir(dir.join("sub")).unwrap(); | |
| 200 | + | std::fs::write(dir.join("top.txt"), "top\n").unwrap(); | |
| 201 | + | std::fs::write(dir.join("sub/inner.txt"), "inner\n").unwrap(); | |
| 202 | + | std::fs::write(dir.join("run.sh"), "#!/bin/sh\n").unwrap(); | |
| 203 | + | git(dir, &["add", "."]); | |
| 204 | + | git(dir, &["update-index", "--chmod=+x", "run.sh"]); | |
| 205 | + | git(dir, &["commit", "-qm", "init"]); | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | #[test] | |
| 209 | + | fn commits_edits_with_cas_and_preserved_modes() { | |
| 210 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 211 | + | let dir = tmp.path(); | |
| 212 | + | fixture(dir); | |
| 213 | + | let tip = crate::browse::resolve_commit(dir, "main").unwrap(); | |
| 214 | + | ||
| 215 | + | // Nested edit advances the branch by exactly one commit. | |
| 216 | + | let new = commit_file_change( | |
| 217 | + | dir, | |
| 218 | + | "main", | |
| 219 | + | &tip, | |
| 220 | + | "sub/inner.txt", | |
| 221 | + | b"changed\n", | |
| 222 | + | "alice", | |
| 223 | + | "a@anvil", | |
| 224 | + | "Update inner", | |
| 225 | + | ) | |
| 226 | + | .unwrap(); | |
| 227 | + | assert_eq!(crate::browse::resolve_commit(dir, "main").unwrap(), new); | |
| 228 | + | let detail = crate::browse::commit_detail(dir, &new).unwrap(); | |
| 229 | + | assert_eq!(detail.parent.as_deref(), Some(tip.as_str())); | |
| 230 | + | assert_eq!(detail.info.author, "alice"); | |
| 231 | + | assert_eq!(detail.changes.len(), 1, "only the edited file changed"); | |
| 232 | + | assert_eq!(detail.changes[0].path, "sub/inner.txt"); | |
| 233 | + | let content = crate::browse::read_blob(dir, "main", "sub/inner.txt") | |
| 234 | + | .unwrap() | |
| 235 | + | .unwrap(); | |
| 236 | + | assert_eq!(content, b"changed\n"); | |
| 237 | + | ||
| 238 | + | // The stale tip is rejected (CAS), the branch is untouched. | |
| 239 | + | let conflict = commit_file_change( | |
| 240 | + | dir, "main", &tip, "top.txt", b"x\n", "alice", "a@anvil", "stale", | |
| 241 | + | ); | |
| 242 | + | match conflict { | |
| 243 | + | Err(EditError::BranchMoved { current }) => assert_eq!(current, new), | |
| 244 | + | other => panic!("expected BranchMoved, got {other:?}"), | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // Identical content is reported, not committed. | |
| 248 | + | let tip2 = crate::browse::resolve_commit(dir, "main").unwrap(); | |
| 249 | + | assert!(matches!( | |
| 250 | + | commit_file_change( | |
| 251 | + | dir, "main", &tip2, "top.txt", b"top\n", "alice", "a@anvil", "noop", | |
| 252 | + | ), | |
| 253 | + | Err(EditError::NoChanges) | |
| 254 | + | )); | |
| 255 | + | ||
| 256 | + | // An executable stays executable after an edit (mode preserved): | |
| 257 | + | // verify with git itself. | |
| 258 | + | let tip3 = crate::browse::resolve_commit(dir, "main").unwrap(); | |
| 259 | + | commit_file_change( | |
| 260 | + | dir, | |
| 261 | + | "main", | |
| 262 | + | &tip3, | |
| 263 | + | "run.sh", | |
| 264 | + | b"#!/bin/sh\necho hi\n", | |
| 265 | + | "alice", | |
| 266 | + | "a@anvil", | |
| 267 | + | "edit sh", | |
| 268 | + | ) | |
| 269 | + | .unwrap(); | |
| 270 | + | let out = std::process::Command::new("git") | |
| 271 | + | .args(["ls-tree", "main", "run.sh"]) | |
| 272 | + | .current_dir(dir) | |
| 273 | + | .output() | |
| 274 | + | .unwrap(); | |
| 275 | + | assert!(String::from_utf8_lossy(&out.stdout).starts_with("100755")); | |
| 276 | + | ||
| 277 | + | // Editing a missing file fails cleanly. | |
| 278 | + | let tip4 = crate::browse::resolve_commit(dir, "main").unwrap(); | |
| 279 | + | assert!(matches!( | |
| 280 | + | commit_file_change(dir, "main", &tip4, "nope.txt", b"x", "a", "a@a", "m"), | |
| 281 | + | Err(EditError::Other(_)) | |
| 282 | + | )); | |
| 283 | + | ||
| 284 | + | // The repository stays consistent for real git after all of this. | |
| 285 | + | let out = std::process::Command::new("git") | |
| 286 | + | .args(["fsck", "--strict"]) | |
| 287 | + | .current_dir(dir) | |
| 288 | + | .output() | |
| 289 | + | .unwrap(); | |
| 290 | + | assert!(out.status.success(), "git fsck: {out:?}"); | |
| 291 | + | } | |
| 292 | + | } |
modifiedcrates/anvil-git/src/lib.rs+1 −0
| ⋯ 13 unchanged lines | |||
| 14 | 14 | //! it can be replaced incrementally with an upstream-shaped implementation. | |
| 15 | 15 | ||
| 16 | 16 | pub mod browse; | |
| 17 | + | pub mod edit; | |
| 17 | 18 | pub mod error; | |
| 18 | 19 | pub mod mirror; | |
| 19 | 20 | pub mod push; | |
| ⋯ 12 unchanged lines | |||
addedcrates/anvil-web/src/admin.rs+84 −0
| 1 | + | //! Admin-only dashboard pages (site-level), gated by `User.is_admin`. | |
| 2 | + | ||
| 3 | + | use anvil_core::{ | |
| 4 | + | App, | |
| 5 | + | usage, | |
| 6 | + | }; | |
| 7 | + | use axum::{ | |
| 8 | + | Router, | |
| 9 | + | extract::State, | |
| 10 | + | response::{ | |
| 11 | + | IntoResponse, | |
| 12 | + | Response, | |
| 13 | + | }, | |
| 14 | + | routing::get, | |
| 15 | + | }; | |
| 16 | + | use maud::{ | |
| 17 | + | Markup, | |
| 18 | + | html, | |
| 19 | + | }; | |
| 20 | + | ||
| 21 | + | use crate::{ | |
| 22 | + | auth::CurrentUser, | |
| 23 | + | ui::{ | |
| 24 | + | fmt_size, | |
| 25 | + | layout, | |
| 26 | + | not_found, | |
| 27 | + | server_error, | |
| 28 | + | }, | |
| 29 | + | }; | |
| 30 | + | ||
| 31 | + | pub fn routes(router: Router<App>) -> Router<App> { | |
| 32 | + | router.route("/-/admin/usage", get(usage_page)) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// `GET /-/admin/usage` — site-wide disk usage by user and content type. | |
| 36 | + | /// Non-admins (including anonymous) get a 404, so the page's existence isn't | |
| 37 | + | /// leaked. | |
| 38 | + | async fn usage_page(State(app): State<App>, CurrentUser(user): CurrentUser) -> Response { | |
| 39 | + | if !user.as_ref().is_some_and(|u| u.is_admin) { | |
| 40 | + | return not_found("not found"); | |
| 41 | + | } | |
| 42 | + | let data = match usage::compute(&app).await { | |
| 43 | + | Ok(d) => d, | |
| 44 | + | Err(e) => return server_error(e), | |
| 45 | + | }; | |
| 46 | + | layout("Disk usage", user.as_ref(), render(&data)).into_response() | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | fn render(u: &usage::Usage) -> Markup { | |
| 50 | + | html! { | |
| 51 | + | h1 { "Disk usage" } | |
| 52 | + | p.muted { | |
| 53 | + | "Actual on-disk bytes per user, by content type — " | |
| 54 | + | (fmt_size(u.total() as i64)) " total across the instance." | |
| 55 | + | } | |
| 56 | + | table.usage { | |
| 57 | + | thead { tr { | |
| 58 | + | th { "User" } | |
| 59 | + | th.num { "Repositories" } | |
| 60 | + | th.num { "CI artifacts" } | |
| 61 | + | th.num { "Attachments" } | |
| 62 | + | th.num { "Total" } | |
| 63 | + | } } | |
| 64 | + | tbody { | |
| 65 | + | @for row in &u.per_user { | |
| 66 | + | tr { | |
| 67 | + | td { (row.username) } | |
| 68 | + | td.num { (fmt_size(row.git as i64)) } | |
| 69 | + | td.num { (fmt_size(row.artifacts as i64)) } | |
| 70 | + | td.num { (fmt_size(row.attachments as i64)) } | |
| 71 | + | td.num { (fmt_size(row.total() as i64)) } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | tfoot { tr { | |
| 76 | + | td { "All users" } | |
| 77 | + | td.num { (fmt_size(u.git as i64)) } | |
| 78 | + | td.num { (fmt_size(u.artifacts as i64)) } | |
| 79 | + | td.num { (fmt_size(u.attachments as i64)) } | |
| 80 | + | td.num { (fmt_size(u.total() as i64)) } | |
| 81 | + | } } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | } |
addedcrates/anvil-web/src/attachments.rs+246 −0
| 1 | + | //! Attachment endpoints: upload (write-gated) and serve (read-gated). | |
| 2 | + | //! | |
| 3 | + | //! Images pasted/dropped into the file editor are POSTed here as a raw body, | |
| 4 | + | //! stored content-addressed outside git (see [`anvil_core::attachments`]), and | |
| 5 | + | //! served back so the Markdown only carries a URL. The serve route is gated by | |
| 6 | + | //! the repo's read access, so private repos stay private. | |
| 7 | + | ||
| 8 | + | use anvil_core::{ | |
| 9 | + | App, | |
| 10 | + | access, | |
| 11 | + | attachments, | |
| 12 | + | storage, | |
| 13 | + | }; | |
| 14 | + | use axum::{ | |
| 15 | + | Router, | |
| 16 | + | body::Bytes, | |
| 17 | + | extract::{ | |
| 18 | + | DefaultBodyLimit, | |
| 19 | + | Path, | |
| 20 | + | State, | |
| 21 | + | }, | |
| 22 | + | http::{ | |
| 23 | + | HeaderMap, | |
| 24 | + | StatusCode, | |
| 25 | + | header, | |
| 26 | + | }, | |
| 27 | + | response::{ | |
| 28 | + | IntoResponse, | |
| 29 | + | Response, | |
| 30 | + | }, | |
| 31 | + | routing::{ | |
| 32 | + | get, | |
| 33 | + | post, | |
| 34 | + | }, | |
| 35 | + | }; | |
| 36 | + | ||
| 37 | + | use crate::{ | |
| 38 | + | auth::{ | |
| 39 | + | CSRF_FIELD, | |
| 40 | + | Csrf, | |
| 41 | + | CurrentUser, | |
| 42 | + | verify_csrf, | |
| 43 | + | }, | |
| 44 | + | ui::{ | |
| 45 | + | forbidden, | |
| 46 | + | not_found, | |
| 47 | + | resolve_repo, | |
| 48 | + | server_error, | |
| 49 | + | }, | |
| 50 | + | }; | |
| 51 | + | ||
| 52 | + | /// Register the attachment routes. `max_upload_bytes` bounds a single upload | |
| 53 | + | /// (from `http.attachment_max_mb`); the body-limit layer rejects anything | |
| 54 | + | /// larger before it is buffered. | |
| 55 | + | pub fn routes(router: Router<App>, max_upload_bytes: usize) -> Router<App> { | |
| 56 | + | router | |
| 57 | + | .route( | |
| 58 | + | "/{owner}/{repo}/-/attachments/{hash}", | |
| 59 | + | get(serve_attachment), | |
| 60 | + | ) | |
| 61 | + | .route( | |
| 62 | + | "/{owner}/{repo}/-/attachments", | |
| 63 | + | post(upload_attachment).layer(DefaultBodyLimit::max(max_upload_bytes)), | |
| 64 | + | ) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// A 64-char lowercase hex SHA-256 — the only shape a stored attachment name | |
| 68 | + | /// can take. Guards the path component before it touches the filesystem. | |
| 69 | + | fn is_valid_hash(hash: &str) -> bool { | |
| 70 | + | hash.len() == 64 | |
| 71 | + | && hash | |
| 72 | + | .bytes() | |
| 73 | + | .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// Detect a supported raster image type from leading magic bytes, returning its | |
| 77 | + | /// MIME type. SVG is deliberately excluded — it can carry script, and we serve | |
| 78 | + | /// attachments from our own origin. The client's `Content-Type` is ignored. | |
| 79 | + | fn sniff_image(bytes: &[u8]) -> Option<&'static str> { | |
| 80 | + | if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { | |
| 81 | + | Some("image/png") | |
| 82 | + | } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { | |
| 83 | + | Some("image/jpeg") | |
| 84 | + | } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { | |
| 85 | + | Some("image/gif") | |
| 86 | + | } else if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { | |
| 87 | + | Some("image/webp") | |
| 88 | + | } else { | |
| 89 | + | None | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// `GET /{owner}/{repo}/-/attachments/{hash}` — serve an attachment's bytes, | |
| 94 | + | /// gated by the repo's read access. Content is immutable (addressed by hash), | |
| 95 | + | /// so it is cached aggressively and served with active content neutralized. | |
| 96 | + | async fn serve_attachment( | |
| 97 | + | State(app): State<App>, | |
| 98 | + | CurrentUser(user): CurrentUser, | |
| 99 | + | Path((owner, repo, hash)): Path<(String, String, String)>, | |
| 100 | + | ) -> Response { | |
| 101 | + | let (_, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await { | |
| 102 | + | Ok(v) => v, | |
| 103 | + | Err(resp) => return resp, | |
| 104 | + | }; | |
| 105 | + | if !is_valid_hash(&hash) { | |
| 106 | + | return not_found("no such attachment"); | |
| 107 | + | } | |
| 108 | + | let row = match attachments::find(&app.db, meta.id, &hash).await { | |
| 109 | + | Ok(Some(r)) => r, | |
| 110 | + | Ok(None) => return not_found("no such attachment"), | |
| 111 | + | Err(e) => return server_error(e), | |
| 112 | + | }; | |
| 113 | + | let path = storage::attachment_path(&app.config.attachments_dir(), meta.id, &hash); | |
| 114 | + | let bytes = match std::fs::read(&path) { | |
| 115 | + | Ok(b) => b, | |
| 116 | + | Err(_) => return not_found("attachment data missing on disk"), | |
| 117 | + | }; | |
| 118 | + | ( | |
| 119 | + | [ | |
| 120 | + | (header::CONTENT_TYPE, row.content_type), | |
| 121 | + | (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()), | |
| 122 | + | // Content-addressed ⇒ immutable: cache for a year. | |
| 123 | + | ( | |
| 124 | + | header::CACHE_CONTROL, | |
| 125 | + | "public, max-age=31536000, immutable".to_string(), | |
| 126 | + | ), | |
| 127 | + | (header::CONTENT_DISPOSITION, "inline".to_string()), | |
| 128 | + | // Neutralize any active content even if a type slips through. | |
| 129 | + | ( | |
| 130 | + | header::CONTENT_SECURITY_POLICY, | |
| 131 | + | "default-src 'none'; sandbox".to_string(), | |
| 132 | + | ), | |
| 133 | + | ], | |
| 134 | + | bytes, | |
| 135 | + | ) | |
| 136 | + | .into_response() | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /// `POST /{owner}/{repo}/-/attachments` — store a raw image body and return its | |
| 140 | + | /// serve URL. Requires write access and a valid CSRF token (sent as the | |
| 141 | + | /// `X-CSRF-Token` header, since the body is the raw file, not a form). | |
| 142 | + | async fn upload_attachment( | |
| 143 | + | State(app): State<App>, | |
| 144 | + | CurrentUser(user): CurrentUser, | |
| 145 | + | csrf: Csrf, | |
| 146 | + | Path((owner, repo)): Path<(String, String)>, | |
| 147 | + | headers: HeaderMap, | |
| 148 | + | body: Bytes, | |
| 149 | + | ) -> Response { | |
| 150 | + | let (_, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await { | |
| 151 | + | Ok(v) => v, | |
| 152 | + | Err(resp) => return resp, | |
| 153 | + | }; | |
| 154 | + | let Some(user) = user else { | |
| 155 | + | return (StatusCode::UNAUTHORIZED, "sign in to upload").into_response(); | |
| 156 | + | }; | |
| 157 | + | if !access::can_write(&meta, Some(&user)) { | |
| 158 | + | return forbidden(); | |
| 159 | + | } | |
| 160 | + | let submitted = headers | |
| 161 | + | .get("x-csrf-token") | |
| 162 | + | .and_then(|v| v.to_str().ok()) | |
| 163 | + | .or_else(|| headers.get(CSRF_FIELD).and_then(|v| v.to_str().ok())) | |
| 164 | + | .unwrap_or_default(); | |
| 165 | + | if let Err(resp) = verify_csrf(&csrf, submitted) { | |
| 166 | + | return resp; | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | let cap = app.config.http.attachment_max_mb.saturating_mul(1 << 20); | |
| 170 | + | if cap != 0 && body.len() > cap { | |
| 171 | + | return (StatusCode::PAYLOAD_TOO_LARGE, "attachment too large").into_response(); | |
| 172 | + | } | |
| 173 | + | let Some(content_type) = sniff_image(&body) else { | |
| 174 | + | return ( | |
| 175 | + | StatusCode::BAD_REQUEST, | |
| 176 | + | "unsupported file type (png, jpeg, gif, webp only)", | |
| 177 | + | ) | |
| 178 | + | .into_response(); | |
| 179 | + | }; | |
| 180 | + | ||
| 181 | + | let hash = attachments::content_hash(&body); | |
| 182 | + | let dir = app.config.attachments_dir().join(meta.id.to_string()); | |
| 183 | + | if let Err(e) = std::fs::create_dir_all(&dir) { | |
| 184 | + | return server_error(e); | |
| 185 | + | } | |
| 186 | + | // Content-addressed: the file is immutable, so only write if it's new. | |
| 187 | + | let path = dir.join(&hash); | |
| 188 | + | let is_new = !path.exists(); | |
| 189 | + | // Per-repo quota: only new content adds bytes, so deduped re-uploads are | |
| 190 | + | // always allowed even at the cap. | |
| 191 | + | let quota = app.config.http.attachment_quota_mb.saturating_mul(1 << 20); | |
| 192 | + | if quota != 0 && is_new && storage::dir_size(&dir) + body.len() as u64 > quota as u64 { | |
| 193 | + | return ( | |
| 194 | + | StatusCode::PAYLOAD_TOO_LARGE, | |
| 195 | + | "repository attachment quota exceeded", | |
| 196 | + | ) | |
| 197 | + | .into_response(); | |
| 198 | + | } | |
| 199 | + | if is_new && let Err(e) = std::fs::write(&path, &body) { | |
| 200 | + | return server_error(e); | |
| 201 | + | } | |
| 202 | + | if let Err(e) = attachments::add( | |
| 203 | + | &app.db, | |
| 204 | + | meta.id, | |
| 205 | + | &hash, | |
| 206 | + | content_type, | |
| 207 | + | body.len() as i64, | |
| 208 | + | user.id, | |
| 209 | + | ) | |
| 210 | + | .await | |
| 211 | + | { | |
| 212 | + | return server_error(e); | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | let url = format!("/{owner}/{repo}/-/attachments/{hash}"); | |
| 216 | + | let markdown = format!(""); | |
| 217 | + | axum::Json(serde_json::json!({ "url": url, "markdown": markdown })).into_response() | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | #[cfg(test)] | |
| 221 | + | mod tests { | |
| 222 | + | use super::*; | |
| 223 | + | ||
| 224 | + | #[test] | |
| 225 | + | fn hash_shape_is_strict() { | |
| 226 | + | assert!(is_valid_hash(&"a".repeat(64))); | |
| 227 | + | assert!(!is_valid_hash(&"a".repeat(63))); | |
| 228 | + | assert!(!is_valid_hash(&"A".repeat(64))); // uppercase rejected | |
| 229 | + | assert!(!is_valid_hash("../etc/passwd")); | |
| 230 | + | assert!(!is_valid_hash(&"g".repeat(64))); // non-hex | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | #[test] | |
| 234 | + | fn sniffs_supported_images_only() { | |
| 235 | + | assert_eq!( | |
| 236 | + | sniff_image(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]), | |
| 237 | + | Some("image/png") | |
| 238 | + | ); | |
| 239 | + | assert_eq!(sniff_image(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg")); | |
| 240 | + | assert_eq!(sniff_image(b"GIF89a..."), Some("image/gif")); | |
| 241 | + | assert_eq!(sniff_image(b"RIFF\0\0\0\0WEBPVP8 "), Some("image/webp")); | |
| 242 | + | assert_eq!(sniff_image(b"<svg></svg>"), None); | |
| 243 | + | assert_eq!(sniff_image(b"<!doctype html>"), None); | |
| 244 | + | assert_eq!(sniff_image(b""), None); | |
| 245 | + | } | |
| 246 | + | } |
modifiedcrates/anvil-web/src/lib.rs+7 −0
| ⋯ 19 unchanged lines | |||
| 20 | 20 | }, | |
| 21 | 21 | }; | |
| 22 | 22 | ||
| 23 | + | pub mod admin; | |
| 23 | 24 | pub mod artifacts; | |
| 25 | + | pub mod attachments; | |
| 24 | 26 | pub mod auth; | |
| 25 | 27 | pub mod git_http; | |
| 26 | 28 | pub mod issues; | |
| ⋯ 8 unchanged lines | |||
| 35 | 37 | .route("/-/login", get(auth::login_form).post(auth::login_submit)) | |
| 36 | 38 | .route("/-/logout", post(auth::logout)); | |
| 37 | 39 | router = ui::routes(router); // web UI, including `/` | |
| 40 | + | router = admin::routes(router); // admin-only dashboard | |
| 38 | 41 | router = pages::routes(router); // static sites from `pages` branches | |
| 39 | 42 | router = artifacts::routes(router); // CI artifact downloads + sites | |
| 43 | + | router = attachments::routes( | |
| 44 | + | router, | |
| 45 | + | app.config.http.attachment_max_mb.saturating_mul(1 << 20), | |
| 46 | + | ); // uploaded image attachments | |
| 40 | 47 | router = issues::routes(router); // per-repo issue tracker | |
| 41 | 48 | router = git_http::routes(router); // smart-HTTP git endpoints | |
| 42 | 49 | router | |
| ⋯ 23 unchanged lines | |||
modifiedcrates/anvil-web/src/todomd.rs+157 −0
| ⋯ 231 unchanged lines | |||
| 232 | 232 | } | |
| 233 | 233 | } | |
| 234 | 234 | ||
| 235 | + | /// The **add** operation: append a ticket — a nested heading (`## <title>`, | |
| 236 | + | /// one level below the column headings) — to the end of the section named | |
| 237 | + | /// `section`, touching no other byte of the document (the todo-md round-trip | |
| 238 | + | /// rule). A ticket is the richer card style: it inherits done-ness from its | |
| 239 | + | /// column, so it carries no checkbox. Returns `None` when the title is blank, | |
| 240 | + | /// the document has no column headings to nest under, or no such section exists. | |
| 241 | + | pub fn add_task(text: &str, section: &str, title: &str) -> Option<String> { | |
| 242 | + | let title = title.split_whitespace().collect::<Vec<_>>().join(" "); | |
| 243 | + | if title.is_empty() { | |
| 244 | + | return None; | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // `split('\n')` (not `lines()`) so reconstruction is byte-exact. | |
| 248 | + | let lines: Vec<&str> = text.split('\n').collect(); | |
| 249 | + | let col = column_level(text); | |
| 250 | + | // A ticket nests one level below the columns; with no headings at all | |
| 251 | + | // there's no column to nest it under. | |
| 252 | + | if col == 0 { | |
| 253 | + | return None; | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // The target span: [start, end) of the section's body lines. | |
| 257 | + | let mut start = None; | |
| 258 | + | let mut end = lines.len(); | |
| 259 | + | let mut in_fence = false; | |
| 260 | + | for (i, raw) in lines.iter().enumerate() { | |
| 261 | + | let line = raw.trim_end_matches('\r'); | |
| 262 | + | if line.trim_start().starts_with("```") { | |
| 263 | + | in_fence = !in_fence; | |
| 264 | + | continue; | |
| 265 | + | } | |
| 266 | + | if in_fence { | |
| 267 | + | continue; | |
| 268 | + | } | |
| 269 | + | if let Some((level, rest)) = heading(line) | |
| 270 | + | && level == col | |
| 271 | + | { | |
| 272 | + | match start { | |
| 273 | + | None if strip_marker(rest) == section => start = Some(i + 1), | |
| 274 | + | Some(_) => { | |
| 275 | + | end = i; | |
| 276 | + | break; | |
| 277 | + | } | |
| 278 | + | None => {} | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | let start = start?; | |
| 283 | + | ||
| 284 | + | let ticket = format!("{} {title}", "#".repeat(col as usize + 1)); | |
| 285 | + | ||
| 286 | + | let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect(); | |
| 287 | + | match (start..end).rev().find(|&i| !lines[i].trim().is_empty()) { | |
| 288 | + | // After the section's last non-blank line, with a blank line before it | |
| 289 | + | // so the heading stands on its own. | |
| 290 | + | Some(i) => { | |
| 291 | + | out.insert(i + 1, ticket); | |
| 292 | + | out.insert(i + 1, String::new()); | |
| 293 | + | } | |
| 294 | + | // Empty section: a blank line, then the ticket, right after the heading. | |
| 295 | + | None => { | |
| 296 | + | out.insert(start, ticket); | |
| 297 | + | out.insert(start, String::new()); | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | Some(out.join("\n")) | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | /// The section names a task can be added to: the column-level headings, in | |
| 304 | + | /// document order, stripped of any done marker. These are exactly the names | |
| 305 | + | /// [`add_task`] accepts. Empty when the document has no headings. | |
| 306 | + | pub fn task_sections(text: &str) -> Vec<String> { | |
| 307 | + | let col = column_level(text); | |
| 308 | + | if col == 0 { | |
| 309 | + | return Vec::new(); | |
| 310 | + | } | |
| 311 | + | let mut in_fence = false; | |
| 312 | + | let mut out = Vec::new(); | |
| 313 | + | for line in text.lines() { | |
| 314 | + | if line.trim_start().starts_with("```") { | |
| 315 | + | in_fence = !in_fence; | |
| 316 | + | continue; | |
| 317 | + | } | |
| 318 | + | if in_fence { | |
| 319 | + | continue; | |
| 320 | + | } | |
| 321 | + | if let Some((level, rest)) = heading(line) | |
| 322 | + | && level == col | |
| 323 | + | { | |
| 324 | + | out.push(strip_marker(rest)); | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | out | |
| 328 | + | } | |
| 329 | + | ||
| 235 | 330 | /// Render a todo-md document as a kanban board (columns = task-bearing | |
| 236 | 331 | /// sections) with prose sections as a notes area below. `None` if the file | |
| 237 | 332 | /// contains no tasks at all — callers fall back to plain markdown. | |
| ⋯ 165 unchanged lines | |||
| 403 | 498 | assert!(board.contains("just prose"), "notes area kept: {board}"); | |
| 404 | 499 | assert!(render_board("# readme\n\nonly prose\n").is_none()); | |
| 405 | 500 | } | |
| 501 | + | ||
| 502 | + | #[test] | |
| 503 | + | fn add_task_appends_ticket_within_section_byte_exactly() { | |
| 504 | + | let out = add_task(DOC, "Now", "new ticket").unwrap(); | |
| 505 | + | // Lands as a nested heading after the section's last non-blank line, | |
| 506 | + | // padded by a blank line, before the next column heading. | |
| 507 | + | assert!(out.contains("- [x] finished task\n\n## new ticket\n\n# Done")); | |
| 508 | + | // Round-trip rule: removing the inserted ticket restores the original. | |
| 509 | + | assert_eq!(out.replacen("## new ticket\n\n", "", 1), DOC); | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | #[test] | |
| 513 | + | fn add_task_ticket_level_tracks_the_column_level() { | |
| 514 | + | // Columns at `##` ⇒ tickets nest at `###`. | |
| 515 | + | let doc = "## Backlog\n\n### Existing\n\nbody\n"; | |
| 516 | + | let out = add_task(doc, "Backlog", "New one").unwrap(); | |
| 517 | + | assert!(out.contains("body\n\n### New one")); | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | #[test] | |
| 521 | + | fn add_task_to_done_section_inherits_done_no_checkbox() { | |
| 522 | + | let out = add_task(DOC, "Done", "tidy up").unwrap(); | |
| 523 | + | assert!(out.contains("## tidy up")); | |
| 524 | + | assert!(!out.contains("- [ ] tidy up") && !out.contains("- [x] tidy up")); | |
| 525 | + | // Parses as a ticket under the done column, so it reads as done. | |
| 526 | + | let done = parse(&out).into_iter().find(|s| s.title == "Done").unwrap(); | |
| 527 | + | assert!( | |
| 528 | + | done.tasks | |
| 529 | + | .iter() | |
| 530 | + | .any(|t| t.ticket && t.title == "tidy up" && t.done) | |
| 531 | + | ); | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | #[test] | |
| 535 | + | fn add_task_into_empty_section_inserts_blank_then_ticket() { | |
| 536 | + | let doc = "# Now\n# Done\n"; | |
| 537 | + | assert_eq!( | |
| 538 | + | add_task(doc, "Now", "first").unwrap(), | |
| 539 | + | "# Now\n\n## first\n# Done\n" | |
| 540 | + | ); | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | #[test] | |
| 544 | + | fn add_task_rejects_missing_section_blank_title_and_headingless() { | |
| 545 | + | assert!(add_task(DOC, "Nonexistent", "x").is_none()); | |
| 546 | + | assert!(add_task(DOC, "Now", " ").is_none()); | |
| 547 | + | assert!(add_task("no headings here\n", "Whatever", "x").is_none()); | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | #[test] | |
| 551 | + | fn added_ticket_renders_as_a_board_card() { | |
| 552 | + | let out = add_task(DOC, "Now", "Wire uploads").unwrap(); | |
| 553 | + | let board = render_board(&out).expect("has tasks").into_string(); | |
| 554 | + | assert!(board.contains("Wire uploads")); | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | #[test] | |
| 558 | + | fn task_sections_lists_column_headings_stripped() { | |
| 559 | + | assert_eq!(task_sections(DOC), ["Now", "Done", "Notes"]); | |
| 560 | + | assert!(task_sections("# readme\n\nonly prose\n") == ["readme"]); | |
| 561 | + | assert!(task_sections("no headings at all\n").is_empty()); | |
| 562 | + | } | |
| 406 | 563 | } | |
modifiedcrates/anvil-web/src/ui.rs+433 −2
| ⋯ 166 unchanged lines | |||
| 167 | 167 | form.stack p { margin:10px 0; } form.stack label { font-size:13px; color:var(--muted); } | |
| 168 | 168 | form.stack input[type=text], form.stack textarea { width:100%; max-width:480px; padding:6px 8px; border:1px solid var(--border); border-radius:6px; font:inherit; } | |
| 169 | 169 | form.stack .check { display:flex; gap:8px; align-items:flex-start; max-width:480px; } | |
| 170 | + | form.stack select { padding:6px 8px; border:1px solid var(--border); border-radius:6px; font:inherit; } | |
| 171 | + | form.stack textarea.editor { max-width:none; font:13px/1.5 ui-monospace,monospace; tab-size:4; resize:vertical; } | |
| 172 | + | p.file-actions { margin:8px 0; } | |
| 173 | + | table.usage { border-collapse:collapse; width:100%; max-width:680px; margin-top:12px; } | |
| 174 | + | table.usage th, table.usage td { padding:6px 10px; border-bottom:1px solid var(--border); text-align:left; } | |
| 175 | + | table.usage .num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; } | |
| 176 | + | table.usage tfoot td { font-weight:600; border-top:2px solid var(--border); border-bottom:none; } | |
| 170 | 177 | .issue-dot { width:10px; height:10px; border-radius:50%; flex:none; } | |
| 171 | 178 | .issue-dot.open { background:#1a7f37; } | |
| 172 | 179 | .issue-dot.closed { background:#8250df; } | |
| ⋯ 124 unchanged lines | |||
| 297 | 304 | .route("/{owner}/{repo}/tree/{rev}", get(tree_root)) | |
| 298 | 305 | .route("/{owner}/{repo}/tree/{rev}/{*path}", get(tree_path)) | |
| 299 | 306 | .route("/{owner}/{repo}/blob/{rev}/{*path}", get(blob)) | |
| 307 | + | .route( | |
| 308 | + | "/{owner}/{repo}/edit/{rev}/{*path}", | |
| 309 | + | get(edit_form).post(edit_submit), | |
| 310 | + | ) | |
| 311 | + | .route( | |
| 312 | + | "/{owner}/{repo}/add-task/{rev}/{*path}", | |
| 313 | + | get(add_task_form).post(add_task_submit), | |
| 314 | + | ) | |
| 300 | 315 | .route("/{owner}/{repo}/commits/{rev}", get(commits)) | |
| 301 | 316 | .route("/{owner}/{repo}/commit/{id}", get(commit)) | |
| 302 | 317 | .route("/{owner}/{repo}/ci", get(ci_runs)) | |
| ⋯ 38 unchanged lines | |||
| 341 | 356 | summary { (u.username) } | |
| 342 | 357 | div.nav-dropdown { | |
| 343 | 358 | a href="/-/settings" { "Settings" } | |
| 359 | + | @if u.is_admin { a href="/-/admin/usage" { "Disk usage" } } | |
| 344 | 360 | form method="post" action="/-/logout" { | |
| 345 | 361 | button type="submit" { "Sign out" } | |
| 346 | 362 | } | |
| ⋯ 284 unchanged lines | |||
| 631 | 647 | ) | |
| 632 | 648 | } | |
| 633 | 649 | ||
| 634 | - | fn forbidden() -> Response { | |
| 650 | + | pub(crate) fn forbidden() -> Response { | |
| 635 | 651 | ( | |
| 636 | 652 | StatusCode::FORBIDDEN, | |
| 637 | 653 | layout( | |
| ⋯ 436 unchanged lines | |||
| 1074 | 1090 | Path((owner, repo, rev, path)): Path<(String, String, String, String)>, | |
| 1075 | 1091 | Query(query): Query<HashMap<String, String>>, | |
| 1076 | 1092 | ) -> Result<Markup, Response> { | |
| 1077 | - | let (repo_path, _) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?; | |
| 1093 | + | let (repo_path, meta) = resolve_repo(&app, user.as_ref(), &owner, &repo).await?; | |
| 1078 | 1094 | let (oid, bytes) = browse::read_blob_with_id(&repo_path, &rev, &path) | |
| 1079 | 1095 | .map_err(server_error)? | |
| 1080 | 1096 | .ok_or_else(|| not_found("file not found"))?; | |
| 1081 | 1097 | ||
| 1098 | + | // Editing writes a commit onto a branch, so it's offered only to writers | |
| 1099 | + | // viewing a text file at a branch tip (not a tag or detached commit). | |
| 1100 | + | let can_edit = !is_binary(&bytes) | |
| 1101 | + | && access::can_write(&meta, user.as_ref()) | |
| 1102 | + | && browse::resolve_commit(&repo_path, &format!("refs/heads/{rev}")).is_ok(); | |
| 1103 | + | ||
| 1082 | 1104 | let markdown = is_markdown(&path) && !is_binary(&bytes); | |
| 1083 | 1105 | // Custom renderers for well-known filenames (the plugin point — add new | |
| 1084 | 1106 | // filename → renderer pairs here). TODO.md defaults to a kanban board. | |
| ⋯ 33 unchanged lines | |||
| 1118 | 1140 | html! { | |
| 1119 | 1141 | h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } } | |
| 1120 | 1142 | (breadcrumbs(&owner, &repo, &rev, &path, true)) | |
| 1143 | + | @if can_edit { | |
| 1144 | + | p.file-actions { | |
| 1145 | + | a.btn.btn-secondary href=(format!("/{owner}/{repo}/edit/{}/{path}", enc_ref(&rev))) { "Edit" } | |
| 1146 | + | @if is_todo { | |
| 1147 | + | " " | |
| 1148 | + | a.btn.btn-secondary href=(format!("/{owner}/{repo}/add-task/{}/{path}", enc_ref(&rev))) { "Add task" } | |
| 1149 | + | } | |
| 1150 | + | } | |
| 1151 | + | } | |
| 1121 | 1152 | @if markdown { | |
| 1122 | 1153 | p.view-toggle { | |
| 1123 | 1154 | span.pill-group { | |
| ⋯ 26 unchanged lines | |||
| 1150 | 1181 | )) | |
| 1151 | 1182 | } | |
| 1152 | 1183 | ||
| 1184 | + | #[derive(serde::Deserialize)] | |
| 1185 | + | struct EditFileForm { | |
| 1186 | + | csrf: String, | |
| 1187 | + | /// Expected branch tip the editor saw — the compare-and-swap guard. | |
| 1188 | + | expected_tip: String, | |
| 1189 | + | message: String, | |
| 1190 | + | content: String, | |
| 1191 | + | } | |
| 1192 | + | ||
| 1193 | + | /// Resolve a repo for a web edit, enforcing read+write access and that `rev` | |
| 1194 | + | /// names a branch (editing advances a branch ref). Returns the repo path and | |
| 1195 | + | /// the branch tip the editor is working from. | |
| 1196 | + | async fn resolve_for_edit( | |
| 1197 | + | app: &App, | |
| 1198 | + | user: Option<&User>, | |
| 1199 | + | owner: &str, | |
| 1200 | + | repo: &str, | |
| 1201 | + | rev: &str, | |
| 1202 | + | ) -> Result<(PathBuf, String), Response> { | |
| 1203 | + | let (repo_path, meta) = resolve_repo(app, user, owner, repo).await?; | |
| 1204 | + | if user.is_none() { | |
| 1205 | + | return Err(Redirect::to("/-/login").into_response()); | |
| 1206 | + | } | |
| 1207 | + | if !access::can_write(&meta, user) { | |
| 1208 | + | return Err(forbidden()); | |
| 1209 | + | } | |
| 1210 | + | let tip = browse::resolve_commit(&repo_path, &format!("refs/heads/{rev}")) | |
| 1211 | + | .map_err(|_| not_found("not an editable branch"))?; | |
| 1212 | + | Ok((repo_path, tip)) | |
| 1213 | + | } | |
| 1214 | + | ||
| 1215 | + | /// `GET /{owner}/{repo}/edit/{rev}/{*path}` — textarea editor for an existing | |
| 1216 | + | /// text file on a branch. | |
| 1217 | + | async fn edit_form( | |
| 1218 | + | State(app): State<App>, | |
| 1219 | + | CurrentUser(user): CurrentUser, | |
| 1220 | + | csrf: Csrf, | |
| 1221 | + | Path((owner, repo, rev, path)): Path<(String, String, String, String)>, | |
| 1222 | + | ) -> Response { | |
| 1223 | + | let (repo_path, tip) = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await { | |
| 1224 | + | Ok(v) => v, | |
| 1225 | + | Err(resp) => return resp, | |
| 1226 | + | }; | |
| 1227 | + | let bytes = match browse::read_blob(&repo_path, &rev, &path) { | |
| 1228 | + | Ok(Some(b)) => b, | |
| 1229 | + | Ok(None) => return not_found("file not found"), | |
| 1230 | + | Err(e) => return server_error(e), | |
| 1231 | + | }; | |
| 1232 | + | if is_binary(&bytes) { | |
| 1233 | + | return bad_request_page( | |
| 1234 | + | user.as_ref(), | |
| 1235 | + | "Binary files can't be edited in the browser.", | |
| 1236 | + | ); | |
| 1237 | + | } | |
| 1238 | + | let content = String::from_utf8_lossy(&bytes).into_owned(); | |
| 1239 | + | edit_page( | |
| 1240 | + | &owner, | |
| 1241 | + | &repo, | |
| 1242 | + | &rev, | |
| 1243 | + | &path, | |
| 1244 | + | &content, | |
| 1245 | + | &format!("Update {path}"), | |
| 1246 | + | &tip, | |
| 1247 | + | None, | |
| 1248 | + | user.as_ref(), | |
| 1249 | + | &csrf.0, | |
| 1250 | + | ) | |
| 1251 | + | .into_response() | |
| 1252 | + | } | |
| 1253 | + | ||
| 1254 | + | /// `POST /{owner}/{repo}/edit/{rev}/{*path}` — commit the edited content. | |
| 1255 | + | async fn edit_submit( | |
| 1256 | + | State(app): State<App>, | |
| 1257 | + | CurrentUser(user): CurrentUser, | |
| 1258 | + | csrf: Csrf, | |
| 1259 | + | Path((owner, repo, rev, path)): Path<(String, String, String, String)>, | |
| 1260 | + | Form(form): Form<EditFileForm>, | |
| 1261 | + | ) -> Response { | |
| 1262 | + | let repo_path = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await { | |
| 1263 | + | Ok((p, _)) => p, | |
| 1264 | + | Err(resp) => return resp, | |
| 1265 | + | }; | |
| 1266 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 1267 | + | return resp; | |
| 1268 | + | } | |
| 1269 | + | let user = user.expect("resolve_for_edit requires a logged-in user"); | |
| 1270 | + | ||
| 1271 | + | // Browsers serialize textarea newlines as CRLF; normalize so an edit | |
| 1272 | + | // doesn't rewrite every line ending. | |
| 1273 | + | let content = form.content.replace("\r\n", "\n"); | |
| 1274 | + | let message = if form.message.trim().is_empty() { | |
| 1275 | + | format!("Update {path}") | |
| 1276 | + | } else { | |
| 1277 | + | form.message.clone() | |
| 1278 | + | }; | |
| 1279 | + | ||
| 1280 | + | match anvil_git::edit::commit_file_change( | |
| 1281 | + | &repo_path, | |
| 1282 | + | &rev, | |
| 1283 | + | &form.expected_tip, | |
| 1284 | + | &path, | |
| 1285 | + | content.as_bytes(), | |
| 1286 | + | &user.username, | |
| 1287 | + | &user.email, | |
| 1288 | + | &message, | |
| 1289 | + | ) { | |
| 1290 | + | Ok(_) => { | |
| 1291 | + | Redirect::to(&format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev))).into_response() | |
| 1292 | + | } | |
| 1293 | + | Err(e) => edit_page( | |
| 1294 | + | &owner, | |
| 1295 | + | &repo, | |
| 1296 | + | &rev, | |
| 1297 | + | &path, | |
| 1298 | + | &content, | |
| 1299 | + | &message, | |
| 1300 | + | &form.expected_tip, | |
| 1301 | + | Some(&e.to_string()), | |
| 1302 | + | Some(&user), | |
| 1303 | + | &csrf.0, | |
| 1304 | + | ) | |
| 1305 | + | .into_response(), | |
| 1306 | + | } | |
| 1307 | + | } | |
| 1308 | + | ||
| 1309 | + | /// The file-editor page: a textarea, a commit-message field, and the | |
| 1310 | + | /// compare-and-swap tip carried in a hidden field. | |
| 1311 | + | #[allow(clippy::too_many_arguments)] | |
| 1312 | + | fn edit_page( | |
| 1313 | + | owner: &str, | |
| 1314 | + | repo: &str, | |
| 1315 | + | rev: &str, | |
| 1316 | + | path: &str, | |
| 1317 | + | content: &str, | |
| 1318 | + | message: &str, | |
| 1319 | + | expected_tip: &str, | |
| 1320 | + | error: Option<&str>, | |
| 1321 | + | user: Option<&User>, | |
| 1322 | + | csrf: &str, | |
| 1323 | + | ) -> Markup { | |
| 1324 | + | let action = format!("/{owner}/{repo}/edit/{}/{path}", enc_ref(rev)); | |
| 1325 | + | let cancel = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(rev)); | |
| 1326 | + | let upload_url = format!("/{owner}/{repo}/-/attachments"); | |
| 1327 | + | layout( | |
| 1328 | + | &format!("Edit {path}"), | |
| 1329 | + | user, | |
| 1330 | + | html! { | |
| 1331 | + | h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } } | |
| 1332 | + | (breadcrumbs(owner, repo, rev, path, true)) | |
| 1333 | + | p.muted { "Editing on branch " code { (rev) } " — commits as you." } | |
| 1334 | + | @if let Some(error) = error { p style="color:#cf222e" { (error) } } | |
| 1335 | + | form.stack method="post" action=(action) { | |
| 1336 | + | (csrf_input(csrf)) | |
| 1337 | + | input type="hidden" name="expected_tip" value=(expected_tip); | |
| 1338 | + | p { | |
| 1339 | + | textarea.editor name="content" rows="24" spellcheck="false" autofocus | |
| 1340 | + | data-upload-url=(upload_url) data-csrf=(csrf) { (content) } | |
| 1341 | + | } | |
| 1342 | + | p.muted.upload-hint { "Paste or drop an image to upload it — a Markdown link is inserted and the file is stored outside git." } | |
| 1343 | + | p { label { "Commit message" br; input type="text" name="message" value=(message); } } | |
| 1344 | + | p { | |
| 1345 | + | button.btn type="submit" { "Commit changes" } | |
| 1346 | + | " " | |
| 1347 | + | a.btn.btn-secondary href=(cancel) { "Cancel" } | |
| 1348 | + | } | |
| 1349 | + | } | |
| 1350 | + | script { (PreEscaped(EDITOR_JS)) } | |
| 1351 | + | }, | |
| 1352 | + | ) | |
| 1353 | + | } | |
| 1354 | + | ||
| 1355 | + | /// Paste/drop-to-upload for the file editor: image clipboard items and dropped | |
| 1356 | + | /// image files are POSTed to the repo's attachment endpoint as a raw body, and | |
| 1357 | + | /// the returned Markdown is spliced into the textarea at the cursor. The blob | |
| 1358 | + | /// is stored outside git; only the URL lands in the file. | |
| 1359 | + | const EDITOR_JS: &str = r#" | |
| 1360 | + | (function(){ | |
| 1361 | + | var ta = document.querySelector('textarea.editor'); | |
| 1362 | + | if (!ta || !ta.dataset.uploadUrl) return; | |
| 1363 | + | var url = ta.dataset.uploadUrl, csrf = ta.dataset.csrf; | |
| 1364 | + | function insertAtCursor(text){ | |
| 1365 | + | var s = ta.selectionStart, e = ta.selectionEnd; | |
| 1366 | + | ta.value = ta.value.slice(0, s) + text + ta.value.slice(e); | |
| 1367 | + | ta.selectionStart = ta.selectionEnd = s + text.length; | |
| 1368 | + | ta.focus(); | |
| 1369 | + | } | |
| 1370 | + | function replaceFirst(find, repl){ | |
| 1371 | + | var i = ta.value.indexOf(find); | |
| 1372 | + | if (i >= 0) ta.value = ta.value.slice(0, i) + repl + ta.value.slice(i + find.length); | |
| 1373 | + | } | |
| 1374 | + | function upload(file){ | |
| 1375 | + | var token = '![uploading ' + (file.name || 'image') + '…]()'; | |
| 1376 | + | insertAtCursor(token + '\n'); | |
| 1377 | + | fetch(url, { | |
| 1378 | + | method: 'POST', | |
| 1379 | + | headers: {'X-CSRF-Token': csrf, 'Content-Type': file.type || 'application/octet-stream'}, | |
| 1380 | + | body: file | |
| 1381 | + | }).then(function(r){ | |
| 1382 | + | if (!r.ok) throw new Error('upload failed (' + r.status + ')'); | |
| 1383 | + | return r.json(); | |
| 1384 | + | }).then(function(d){ | |
| 1385 | + | replaceFirst(token, d.markdown); | |
| 1386 | + | }).catch(function(err){ | |
| 1387 | + | replaceFirst(token, '![upload failed]()'); | |
| 1388 | + | console.error(err); | |
| 1389 | + | }); | |
| 1390 | + | } | |
| 1391 | + | ta.addEventListener('paste', function(ev){ | |
| 1392 | + | var items = (ev.clipboardData || {}).items || []; | |
| 1393 | + | for (var i = 0; i < items.length; i++){ | |
| 1394 | + | if (items[i].kind === 'file' && items[i].type.indexOf('image/') === 0){ | |
| 1395 | + | ev.preventDefault(); | |
| 1396 | + | upload(items[i].getAsFile()); | |
| 1397 | + | } | |
| 1398 | + | } | |
| 1399 | + | }); | |
| 1400 | + | ta.addEventListener('dragover', function(ev){ ev.preventDefault(); }); | |
| 1401 | + | ta.addEventListener('drop', function(ev){ | |
| 1402 | + | var files = (ev.dataTransfer || {}).files || [], imgs = []; | |
| 1403 | + | for (var i = 0; i < files.length; i++){ | |
| 1404 | + | if (files[i].type.indexOf('image/') === 0) imgs.push(files[i]); | |
| 1405 | + | } | |
| 1406 | + | if (imgs.length){ ev.preventDefault(); imgs.forEach(upload); } | |
| 1407 | + | }); | |
| 1408 | + | })(); | |
| 1409 | + | "#; | |
| 1410 | + | ||
| 1411 | + | #[derive(serde::Deserialize)] | |
| 1412 | + | struct AddTaskForm { | |
| 1413 | + | csrf: String, | |
| 1414 | + | expected_tip: String, | |
| 1415 | + | section: String, | |
| 1416 | + | title: String, | |
| 1417 | + | } | |
| 1418 | + | ||
| 1419 | + | /// `GET /{owner}/{repo}/add-task/{rev}/{*path}` — structured "add a task" form | |
| 1420 | + | /// for a `TODO.md`, appending a `- [ ]` item per the todo-md round-trip rules. | |
| 1421 | + | async fn add_task_form( | |
| 1422 | + | State(app): State<App>, | |
| 1423 | + | CurrentUser(user): CurrentUser, | |
| 1424 | + | csrf: Csrf, | |
| 1425 | + | Path((owner, repo, rev, path)): Path<(String, String, String, String)>, | |
| 1426 | + | ) -> Response { | |
| 1427 | + | let (repo_path, tip) = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await { | |
| 1428 | + | Ok(v) => v, | |
| 1429 | + | Err(resp) => return resp, | |
| 1430 | + | }; | |
| 1431 | + | if !todomd::is_todo_md(&path) { | |
| 1432 | + | return not_found("not a TODO.md"); | |
| 1433 | + | } | |
| 1434 | + | let bytes = match browse::read_blob(&repo_path, &rev, &path) { | |
| 1435 | + | Ok(Some(b)) => b, | |
| 1436 | + | Ok(None) => return not_found("file not found"), | |
| 1437 | + | Err(e) => return server_error(e), | |
| 1438 | + | }; | |
| 1439 | + | let sections = todomd::task_sections(&String::from_utf8_lossy(&bytes)); | |
| 1440 | + | if sections.is_empty() { | |
| 1441 | + | return bad_request_page(user.as_ref(), "This TODO.md has no sections to add to."); | |
| 1442 | + | } | |
| 1443 | + | add_task_page( | |
| 1444 | + | &owner, | |
| 1445 | + | &repo, | |
| 1446 | + | &rev, | |
| 1447 | + | &path, | |
| 1448 | + | §ions, | |
| 1449 | + | "", | |
| 1450 | + | &tip, | |
| 1451 | + | None, | |
| 1452 | + | user.as_ref(), | |
| 1453 | + | &csrf.0, | |
| 1454 | + | ) | |
| 1455 | + | .into_response() | |
| 1456 | + | } | |
| 1457 | + | ||
| 1458 | + | /// `POST /{owner}/{repo}/add-task/{rev}/{*path}` — append the task and commit. | |
| 1459 | + | async fn add_task_submit( | |
| 1460 | + | State(app): State<App>, | |
| 1461 | + | CurrentUser(user): CurrentUser, | |
| 1462 | + | csrf: Csrf, | |
| 1463 | + | Path((owner, repo, rev, path)): Path<(String, String, String, String)>, | |
| 1464 | + | Form(form): Form<AddTaskForm>, | |
| 1465 | + | ) -> Response { | |
| 1466 | + | let repo_path = match resolve_for_edit(&app, user.as_ref(), &owner, &repo, &rev).await { | |
| 1467 | + | Ok((p, _)) => p, | |
| 1468 | + | Err(resp) => return resp, | |
| 1469 | + | }; | |
| 1470 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 1471 | + | return resp; | |
| 1472 | + | } | |
| 1473 | + | let user = user.expect("resolve_for_edit requires a logged-in user"); | |
| 1474 | + | if !todomd::is_todo_md(&path) { | |
| 1475 | + | return not_found("not a TODO.md"); | |
| 1476 | + | } | |
| 1477 | + | let bytes = match browse::read_blob(&repo_path, &rev, &path) { | |
| 1478 | + | Ok(Some(b)) => b, | |
| 1479 | + | Ok(None) => return not_found("file not found"), | |
| 1480 | + | Err(e) => return server_error(e), | |
| 1481 | + | }; | |
| 1482 | + | let text = String::from_utf8_lossy(&bytes); | |
| 1483 | + | let sections = todomd::task_sections(&text); | |
| 1484 | + | ||
| 1485 | + | let render_err = |msg: &str, csrf: &Csrf| { | |
| 1486 | + | add_task_page( | |
| 1487 | + | &owner, | |
| 1488 | + | &repo, | |
| 1489 | + | &rev, | |
| 1490 | + | &path, | |
| 1491 | + | §ions, | |
| 1492 | + | &form.title, | |
| 1493 | + | &form.expected_tip, | |
| 1494 | + | Some(msg), | |
| 1495 | + | Some(&user), | |
| 1496 | + | &csrf.0, | |
| 1497 | + | ) | |
| 1498 | + | .into_response() | |
| 1499 | + | }; | |
| 1500 | + | ||
| 1501 | + | let Some(updated) = todomd::add_task(&text, &form.section, &form.title) else { | |
| 1502 | + | return render_err( | |
| 1503 | + | "Couldn't add the task — check the title isn't empty and the section exists.", | |
| 1504 | + | &csrf, | |
| 1505 | + | ); | |
| 1506 | + | }; | |
| 1507 | + | ||
| 1508 | + | let message = format!("Add task to {}", form.section); | |
| 1509 | + | match anvil_git::edit::commit_file_change( | |
| 1510 | + | &repo_path, | |
| 1511 | + | &rev, | |
| 1512 | + | &form.expected_tip, | |
| 1513 | + | &path, | |
| 1514 | + | updated.as_bytes(), | |
| 1515 | + | &user.username, | |
| 1516 | + | &user.email, | |
| 1517 | + | &message, | |
| 1518 | + | ) { | |
| 1519 | + | Ok(_) => { | |
| 1520 | + | Redirect::to(&format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(&rev))).into_response() | |
| 1521 | + | } | |
| 1522 | + | Err(e) => render_err(&e.to_string(), &csrf), | |
| 1523 | + | } | |
| 1524 | + | } | |
| 1525 | + | ||
| 1526 | + | /// The add-task form: a section dropdown and a title field. | |
| 1527 | + | #[allow(clippy::too_many_arguments)] | |
| 1528 | + | fn add_task_page( | |
| 1529 | + | owner: &str, | |
| 1530 | + | repo: &str, | |
| 1531 | + | rev: &str, | |
| 1532 | + | path: &str, | |
| 1533 | + | sections: &[String], | |
| 1534 | + | title: &str, | |
| 1535 | + | expected_tip: &str, | |
| 1536 | + | error: Option<&str>, | |
| 1537 | + | user: Option<&User>, | |
| 1538 | + | csrf: &str, | |
| 1539 | + | ) -> Markup { | |
| 1540 | + | let action = format!("/{owner}/{repo}/add-task/{}/{path}", enc_ref(rev)); | |
| 1541 | + | let cancel = format!("/{owner}/{repo}/blob/{}/{path}", enc_ref(rev)); | |
| 1542 | + | layout( | |
| 1543 | + | &format!("Add task · {path}"), | |
| 1544 | + | user, | |
| 1545 | + | html! { | |
| 1546 | + | h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } } | |
| 1547 | + | (breadcrumbs(owner, repo, rev, path, true)) | |
| 1548 | + | h2 { "Add a task" } | |
| 1549 | + | @if let Some(error) = error { p style="color:#cf222e" { (error) } } | |
| 1550 | + | form.stack method="post" action=(action) { | |
| 1551 | + | (csrf_input(csrf)) | |
| 1552 | + | input type="hidden" name="expected_tip" value=(expected_tip); | |
| 1553 | + | p { label { "Section" br; | |
| 1554 | + | select name="section" { | |
| 1555 | + | @for s in sections { option value=(s) { (s) } } | |
| 1556 | + | } | |
| 1557 | + | } } | |
| 1558 | + | p { label { "Task" br; | |
| 1559 | + | input type="text" name="title" value=(title) placeholder="Describe the task" autofocus; | |
| 1560 | + | } } | |
| 1561 | + | p { | |
| 1562 | + | button.btn type="submit" { "Add task" } | |
| 1563 | + | " " | |
| 1564 | + | a.btn.btn-secondary href=(cancel) { "Cancel" } | |
| 1565 | + | } | |
| 1566 | + | } | |
| 1567 | + | }, | |
| 1568 | + | ) | |
| 1569 | + | } | |
| 1570 | + | ||
| 1571 | + | /// A 400 page for malformed edit requests (binary file, no sections, …). | |
| 1572 | + | fn bad_request_page(user: Option<&User>, message: &str) -> Response { | |
| 1573 | + | ( | |
| 1574 | + | StatusCode::BAD_REQUEST, | |
| 1575 | + | layout( | |
| 1576 | + | "Can't edit", | |
| 1577 | + | user, | |
| 1578 | + | html! { h1 { "Can't edit" } p.muted { (message) } }, | |
| 1579 | + | ), | |
| 1580 | + | ) | |
| 1581 | + | .into_response() | |
| 1582 | + | } | |
| 1583 | + | ||
| 1153 | 1584 | /// Pick the singular or plural noun for a count (`1 branch` / `2 branches`). | |
| 1154 | 1585 | fn plural<'a>(n: usize, one: &'a str, many: &'a str) -> &'a str { | |
| 1155 | 1586 | if n == 1 { one } else { many } | |
| ⋯ 741 unchanged lines | |||