Skip to main content

max / makenotwork

server: write git notes from the commit page Two CSRF-protected routes under the commit, add-or-edit and delete, with the forms beside the panel P1 already renders. Whoever could push may annotate: a note is a commit on a ref in the repository, so anyone able to push one over SSH may write one here. A signed-in stranger cannot, and does not see the form either — whether a fan may annotate a creator's public commit is a moderation question with its own decision. The committer is `<display name> <username@users.makenot.work>`, never the account address. An email in a public repository's object graph is permanent and clonable by anyone who fetches the notes ref, so it is not a default that a later setting could correct. Both halves are scrubbed of the characters git's commit format uses as delimiters, since a display name holding an angle bracket would otherwise write an object that parses as something other than what was meant. Opting in to a real address is a repo setting that does not exist yet and changes only the value passed in. `refs/notes/mnw/*` is refused now rather than when P6 starts writing build results there. Reserving a prefix is cheap while it is empty and means deciding what happens to somebody's notes once it is not. A namespace becomes a ref path, so it is validated against git's own rules rather than normalized: empty components, leading dots, `..` and a `.lock` suffix are all rejected. A note that is empty after trimming is refused too — that is almost always a mistake, and removing one has its own button. The write also checks the target resolves to a commit in this repository. Without it a typo annotates an id nothing here resolves, leaving the note invisible in the only place it would have been read. CommitNote carries the note as written alongside the rendered HTML, because HTML cannot be turned back into the markdown it came from and an edit box showing anything else would silently rewrite the note on save. A merged write redirects with a marker so the page can say that what is on screen is not what was typed; nothing else needs the query string. Seven integration tests read the repository with gitoxide rather than trusting the page, including one that pins the committer address against the object on disk.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-08 21:10 UTC
Signed with PGP, not checked
Commit: ee4e871f629cf1d843f2096d39a3c71864b2168e
Parent: 0dbd330
14 files changed, +774 insertions, -4 deletions
@@ -91,8 +91,8 @@
91 91 use payments::PaymentProvider;
92 92 use routes::{
93 93 admin_routes, api_routes, auth_routes, build_routes, git_issue_routes, git_routes,
94 - oauth_routes, ota_routes, page_routes, postmark_routes, sso_routes, storage_routes,
95 - stripe_routes, synckit_routes,
94 + git_write_routes, oauth_routes, ota_routes, page_routes, postmark_routes, sso_routes,
95 + storage_routes, stripe_routes, synckit_routes,
96 96 };
97 97 use scanning::ScanPipeline;
98 98 use storage::StorageBackend;
@@ -547,6 +547,7 @@
547 547 .merge(oauth_routes())
548 548 .merge(postmark_routes())
549 549 .merge(git_issue_routes())
550 + .merge(git_write_routes())
550 551 .merge(ota_routes())
551 552 .merge(build_routes())
552 553 .finalize();
@@ -7210,6 +7210,21 @@
7210 7210 .git-note-body { padding: var(--gap-section); font-size: var(--text-note); }
7211 7211 .git-note-body > :first-child { margin-top: 0; }
7212 7212 .git-note-body > :last-child { margin-bottom: 0; }
7213 + /* Writing a note. Only rendered for a reader who can push. */
7214 + .git-notes-edit {
7215 + border-top: 1px solid var(--border);
7216 + padding-top: var(--gap-section);
7217 + margin-bottom: var(--gap-pane);
7218 + }
7219 + .git-notes-edit textarea { width: 100%; font-family: var(--font-mono); font-size: var(--text-note); }
7220 + .git-note-form { margin-bottom: var(--gap-section); }
7221 + .git-note-delete { margin-bottom: var(--gap-pane); }
7222 + .git-note-merged {
7223 + border-left: 2px solid var(--content);
7224 + padding: var(--gap-peer) var(--gap-section);
7225 + margin-bottom: var(--gap-section);
7226 + font-size: var(--text-note);
7227 + }
7213 7228 .git-diff-stats {
7214 7229 font-size: var(--text-note);
7215 7230 padding: var(--gap-section) 0;
@@ -23,6 +23,7 @@
23 23 pub use auth::auth_routes;
24 24 pub use builds::build_routes;
25 25 pub use git::git_routes;
26 + pub use git::git_write_routes;
26 27 pub use git_issues::git_issue_routes;
27 28 pub use oauth::oauth_routes;
28 29 pub use ota::ota_routes;
@@ -91,6 +91,11 @@
91 91 pub const ISSUE_LABEL_NAME_MAX: usize = 50;
92 92 // Git repo settings
93 93 pub const REPO_DESCRIPTION_MAX: usize = 500;
94 + // Git notes. A note is prose about one commit, so the cap is generous
95 + // rather than tight; the reason to have one at all is that every note in a
96 + // namespace is read into memory whenever the tree is flattened.
97 + pub const NOTE_CONTENT_MAX: usize = 50_000;
98 + pub const NOTE_NAMESPACE_MAX: usize = 64;
94 99 // Collections
95 100 pub const COLLECTION_TITLE_MAX: usize = 100;
96 101 pub const COLLECTION_DESCRIPTION_MAX: usize = 500;
@@ -148,10 +148,122 @@
148 148 Ok(())
149 149 }
150 150
151 + /// Namespaces MNW writes itself and nobody else may.
152 + ///
153 + /// `refs/notes/mnw/*` is where build results, issue links and scan verdicts go
154 + /// once P6 mirrors them out of Postgres. Reserving it before anyone can write
155 + /// there is the cheap order: taking a prefix back after repositories carry
156 + /// user notes under it means deciding what happens to those notes.
157 + const RESERVED_NOTE_NAMESPACE: &str = "mnw";
158 +
159 + /// Validate a git notes namespace: the part after `refs/notes/`.
160 + ///
161 + /// It becomes a ref path, so it has to survive git's own rules — and it is
162 + /// user input that gets concatenated into a ref name, so anything clever with
163 + /// dots, slashes or control characters is rejected rather than normalized.
164 + pub fn validate_note_namespace(namespace: &str) -> Result<(), AppError> {
165 + if namespace.is_empty() || namespace.len() > limits::NOTE_NAMESPACE_MAX {
166 + return Err(AppError::validation(format!(
167 + "Notes namespace must be 1-{} characters",
168 + limits::NOTE_NAMESPACE_MAX
169 + )));
170 + }
171 + if !namespace
172 + .chars()
173 + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/')
174 + {
175 + return Err(AppError::validation(
176 + "Notes namespace can only contain letters, numbers, hyphens, underscores, dots and slashes".to_string(),
177 + ));
178 + }
179 +
180 + // Git's ref rules, the subset the character set above leaves reachable.
181 + for component in namespace.split('/') {
182 + if component.is_empty() {
183 + return Err(AppError::validation(
184 + "Notes namespace cannot have an empty path component".to_string(),
185 + ));
186 + }
187 + // Compared as bytes: git's `.lock` rule is a literal, case-sensitive
188 + // suffix on the ref name rather than a file extension, and the string
189 + // form reads to clippy as the latter.
190 + let lock_suffix = component.as_bytes().ends_with(b".lock");
191 + if component.starts_with('.') || lock_suffix || component.contains("..") {
192 + return Err(AppError::validation(
193 + "Notes namespace components cannot start with a dot, contain '..', or end in '.lock'"
194 + .to_string(),
195 + ));
196 + }
197 + }
198 +
199 + let reserved = namespace == RESERVED_NOTE_NAMESPACE
200 + || namespace.starts_with(&format!("{RESERVED_NOTE_NAMESPACE}/"));
201 + if reserved {
202 + return Err(AppError::validation(format!(
203 + "The {RESERVED_NOTE_NAMESPACE} namespace is written by makenot.work and cannot be edited here"
204 + )));
205 + }
206 + Ok(())
207 + }
208 +
209 + /// Validate the body of a git note.
210 + pub fn validate_note_content(content: &str) -> Result<(), AppError> {
211 + if content.trim().is_empty() {
212 + return Err(AppError::validation(
213 + "A note cannot be empty. Remove it instead.".to_string(),
214 + ));
215 + }
216 + if content.chars().count() > limits::NOTE_CONTENT_MAX {
217 + return Err(AppError::validation(format!(
218 + "A note must be {} characters or less",
219 + limits::NOTE_CONTENT_MAX
220 + )));
221 + }
222 + super::reject_control_chars_multiline("Note", content)?;
223 + Ok(())
224 + }
225 +
151 226 #[cfg(test)]
152 227 mod tests {
153 228 use super::*;
154 229
230 + #[test]
231 + fn a_notes_namespace_has_to_survive_being_a_ref_path() {
232 + assert!(validate_note_namespace("commits").is_ok());
233 + assert!(validate_note_namespace("review/security").is_ok());
234 +
235 + assert!(validate_note_namespace("").is_err());
236 + assert!(validate_note_namespace("has space").is_err());
237 + assert!(validate_note_namespace("trailing/").is_err());
238 + assert!(validate_note_namespace("double//slash").is_err());
239 + assert!(validate_note_namespace(".hidden").is_err());
240 + assert!(validate_note_namespace("up/../out").is_err());
241 + assert!(validate_note_namespace("branch.lock").is_err());
242 + assert!(validate_note_namespace(&"a".repeat(65)).is_err());
243 + }
244 +
245 + #[test]
246 + fn the_server_owned_namespace_is_not_writable_from_the_browser() {
247 + // Reserved before anything can be written there, because taking the
248 + // prefix back afterwards would mean deciding what happens to the notes
249 + // already under it.
250 + assert!(validate_note_namespace("mnw").is_err());
251 + assert!(validate_note_namespace("mnw/builds").is_err());
252 + // Not a blanket prefix match: only the namespace and what is under it.
253 + assert!(validate_note_namespace("mnwish").is_ok());
254 + }
255 +
256 + #[test]
257 + fn note_content_is_bounded_and_never_empty() {
258 + assert!(validate_note_content("a real note").is_ok());
259 + assert!(validate_note_content("").is_err());
260 + assert!(validate_note_content(" \n ").is_err());
261 + assert!(validate_note_content(&"a".repeat(50_001)).is_err());
262 + assert!(validate_note_content("null\0byte").is_err());
263 + // Newlines are the point of a note body.
264 + assert!(validate_note_content("two\nlines").is_ok());
265 + }
266 +
155 267 #[test]
156 268 fn test_validate_project_slug_valid() {
157 269 assert!(validate_project_slug("my-project").is_ok());
@@ -61,6 +61,7 @@
61 61 mod gallery;
62 62 mod git_browser;
63 63 mod git_issues;
64 + mod git_notes;
64 65 mod git_project;
65 66 mod guest_checkout;
66 67 mod htmx;