anvilsign in

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
1212 no working tree. The new commit just advances the branch tip, so anyone who
1313 pushed earlier can fast-forward pull it.
1414
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
1924 (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+ `![image](url)` 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
1111 # Highlighting large files is CPU-heavy, so repeat views are served from this
1212 # cache. Set to 0 to disable it entirely on RAM-constrained hosts.
1313 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
1421
1522 [ssh]
1623 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
104104 /// `0` disables it — lowest memory, every view re-highlights. Defaults
105105 /// to 16.
106106 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,
107115 }
108116
109117 #[derive(Clone, Debug, Deserialize, Serialize)]
⋯ 76 unchanged lines
186194 listen: "127.0.0.1:3000".to_string(),
187195 base_url: "http://localhost:3000".to_string(),
188196 highlight_cache_mb: 16,
197+ attachment_max_mb: 16,
198+ attachment_quota_mb: 0,
189199 }
190200 }
191201 }
⋯ 45 unchanged lines
237247 self.data_dir.join("artifacts")
238248 }
239249
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+
240257 /// Whether session cookies should carry the `Secure` attribute (HTTPS-only).
241258 /// Derived from the public base URL's scheme, so local plaintext dev still
242259 /// works while production behind TLS gets `Secure` automatically.
⋯ 57 unchanged lines
modifiedcrates/anvil-core/src/db.rs+15 −2
⋯ 4 unchanged lines
55 use crate::{
66 error::Result,
77 models::{
8+ Attachment,
89 CiArtifact,
910 CiRun,
1011 Issue,
⋯ 28 unchanged lines
3940 CiRun,
4041 CiArtifact,
4142 Issue,
42- IssueComment
43+ IssueComment,
44+ Attachment
4345 ))
4446 .connect(&url)
4547 .await?;
⋯ 18 unchanged lines
6466 r#"CREATE INDEX IF NOT EXISTS "index_issues_by_repo_id" ON "issues" ("repo_id")"#,
6567 ISSUE_COMMENTS_DDL,
6668 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")"#,
6771 ];
6872
6973 const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" (
⋯ 26 unchanged lines
96100 "body" TEXT NOT NULL,
97101 "created_at" BIGINT NOT NULL )"#;
98102
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+
99112 /// Columns added to existing tables after deployment, applied as
100113 /// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS`
101114 /// for columns, so presence is checked via `pragma_table_info`). The model
⋯ 36 unchanged lines
138151 use super::*;
139152
140153 /// 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"];
142155
143156 /// Every schema object (table + indexes) for `table`, normalized.
144157 fn schema_objects(path: &Path, table: &str) -> Vec<String> {
⋯ 117 unchanged lines
modifiedcrates/anvil-core/src/lib.rs+3 −0
⋯ 5 unchanged lines
66 //! `anvil-git`) build on top of it.
77
88 pub mod access;
9+pub mod attachments;
910 pub mod ci;
1011 pub mod config;
1112 pub mod db;
⋯ 4 unchanged lines
1617 pub mod sessions;
1718 pub mod ssh_keys;
1819 pub mod storage;
20+pub mod usage;
1921 pub mod users;
2022
2123 pub use config::Config;
⋯ 2 unchanged lines
2426 Result,
2527 };
2628 pub use models::{
29+ Attachment,
2730 CiArtifact,
2831 CiRun,
2932 Issue,
⋯ 100 unchanged lines
modifiedcrates/anvil-core/src/models.rs+25 −0
⋯ 143 unchanged lines
144144 pub expires_at: i64,
145145 }
146146
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+
147172 /// A registered SSH public key, used to authenticate git-over-SSH connections.
148173 #[derive(Debug, toasty::Model)]
149174 pub struct SshKey {
⋯ 13 unchanged lines
modifiedcrates/anvil-core/src/storage.rs+28 −0
⋯ 25 unchanged lines
2626 artifacts_dir.join(repo_id.to_string()).join(commit)
2727 }
2828
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+
2936 /// Create a new bare repository on disk, returning the opened handle.
3037 ///
3138 /// `HEAD` is pointed at `refs/heads/<default_branch>` so the on-disk repository
⋯ 56 unchanged lines
8895 Ok(())
8996 }
9097
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+
91119 /// Open an existing bare repository.
92120 pub fn open(repositories_dir: &Path, owner: &str, name: &str) -> Result<gix::Repository> {
93121 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
1414 //! it can be replaced incrementally with an upstream-shaped implementation.
1515
1616 pub mod browse;
17+pub mod edit;
1718 pub mod error;
1819 pub mod mirror;
1920 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!("![image]({url})");
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
2020 },
2121 };
2222
23+pub mod admin;
2324 pub mod artifacts;
25+pub mod attachments;
2426 pub mod auth;
2527 pub mod git_http;
2628 pub mod issues;
⋯ 8 unchanged lines
3537 .route("/-/login", get(auth::login_form).post(auth::login_submit))
3638 .route("/-/logout", post(auth::logout));
3739 router = ui::routes(router); // web UI, including `/`
40+ router = admin::routes(router); // admin-only dashboard
3841 router = pages::routes(router); // static sites from `pages` branches
3942 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
4047 router = issues::routes(router); // per-repo issue tracker
4148 router = git_http::routes(router); // smart-HTTP git endpoints
4249 router
⋯ 23 unchanged lines
modifiedcrates/anvil-web/src/todomd.rs+157 −0
⋯ 231 unchanged lines
232232 }
233233 }
234234
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+
235330 /// Render a todo-md document as a kanban board (columns = task-bearing
236331 /// sections) with prose sections as a notes area below. `None` if the file
237332 /// contains no tasks at all — callers fall back to plain markdown.
⋯ 165 unchanged lines
403498 assert!(board.contains("just prose"), "notes area kept: {board}");
404499 assert!(render_board("# readme\n\nonly prose\n").is_none());
405500 }
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+ }
406563 }
modifiedcrates/anvil-web/src/ui.rs+433 −2
⋯ 166 unchanged lines
167167 form.stack p { margin:10px 0; } form.stack label { font-size:13px; color:var(--muted); }
168168 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; }
169169 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; }
170177 .issue-dot { width:10px; height:10px; border-radius:50%; flex:none; }
171178 .issue-dot.open { background:#1a7f37; }
172179 .issue-dot.closed { background:#8250df; }
⋯ 124 unchanged lines
297304 .route("/{owner}/{repo}/tree/{rev}", get(tree_root))
298305 .route("/{owner}/{repo}/tree/{rev}/{*path}", get(tree_path))
299306 .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+ )
300315 .route("/{owner}/{repo}/commits/{rev}", get(commits))
301316 .route("/{owner}/{repo}/commit/{id}", get(commit))
302317 .route("/{owner}/{repo}/ci", get(ci_runs))
⋯ 38 unchanged lines
341356 summary { (u.username) }
342357 div.nav-dropdown {
343358 a href="/-/settings" { "Settings" }
359+ @if u.is_admin { a href="/-/admin/usage" { "Disk usage" } }
344360 form method="post" action="/-/logout" {
345361 button type="submit" { "Sign out" }
346362 }
⋯ 284 unchanged lines
631647 )
632648 }
633649
634-fn forbidden() -> Response {
650+pub(crate) fn forbidden() -> Response {
635651 (
636652 StatusCode::FORBIDDEN,
637653 layout(
⋯ 436 unchanged lines
10741090 Path((owner, repo, rev, path)): Path<(String, String, String, String)>,
10751091 Query(query): Query<HashMap<String, String>>,
10761092 ) -> 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?;
10781094 let (oid, bytes) = browse::read_blob_with_id(&repo_path, &rev, &path)
10791095 .map_err(server_error)?
10801096 .ok_or_else(|| not_found("file not found"))?;
10811097
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+
10821104 let markdown = is_markdown(&path) && !is_binary(&bytes);
10831105 // Custom renderers for well-known filenames (the plugin point — add new
10841106 // filename → renderer pairs here). TODO.md defaults to a kanban board.
⋯ 33 unchanged lines
11181140 html! {
11191141 h1 { a href=(format!("/{owner}/{repo}")) { (owner) "/" (repo) } }
11201142 (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+ }
11211152 @if markdown {
11221153 p.view-toggle {
11231154 span.pill-group {
⋯ 26 unchanged lines
11501181 ))
11511182 }
11521183
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+ &sections,
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+ &sections,
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+
11531584 /// Pick the singular or plural noun for a count (`1 branch` / `2 branches`).
11541585 fn plural<'a>(n: usize, one: &'a str, many: &'a str) -> &'a str {
11551586 if n == 1 { one } else { many }
⋯ 741 unchanged lines