docs/specs/2026-09-11-snippets-design.md

v1.38.0
gitbay/docs/specs/2026-09-11-snippets-design.md rendered · source · history · blame · raw

238 lines · 11462 bytes

  1# Snippets
  2
  3Closes #195 (ref #185). A snippet is a small set of named text files a
  4user owns, shares by URL, and edits in place: paste.sr.ht's paste with a
  5gist's mutability, without the git repository underneath.
  6
  7## Problem
  8
  9#185 walked a sourcehut user's day and found that sharing a log or a
 10fragment several times a week has no object here. Neither a repository
 11(too heavy for a log) nor an issue (wrong shape) covers it. #195 asks for
 12the feature rather than a FAQ entry declining it.
 13
 14## Decision
 15
 16A snippet is rows in SQLite: an owner, a description, a visibility, and
 17one or more named files with their content. It is created and edited
 18over SSH and the API, rendered and edited on the web through the same
 19control commands, and identified by an opaque id in a URL under the
 20owner.
 21
 22Decisions taken on the way, with the alternatives rejected:
 23
 24- **Store-backed, not a git repository.** A gist is a bare repository
 25  with a flag, cloneable and versioned, which would drag in a namespace
 26  decision, a receive-pack path that skips CI, issues and merge
 27  requests, and the whole repository policy surface. The use case is a
 28  log pasted from a terminal. If history is ever wanted the id and URL
 29  scheme below do not have to change.
 30- **Mutable, opaque id.** paste.sr.ht keys a paste on a hash of its
 31  content, so a typo fix changes the URL already shared. A random id
 32  keeps the URL; updating a file replaces it and no history is kept.
 33- **Three visibilities, default unlisted.** `public` is listed on the
 34  owner's page; `unlisted` is readable by anyone with the URL and listed
 35  nowhere; `private` is the owner's alone and answers 404 to everyone
 36  else, as a private repository does. Sharing a log wants unlisted,
 37  so that is the default when `snippet create` names none.
 38- **Users only.** Nothing in #195 or #185 asks for an org to own a
 39  snippet, and an org snippet would need a membership rule for writes.
 40- **Web writes in the same merge request.** The Parity rule lands only
 41  the triage loop on the web at once; the request here was parity in one
 42  go. Every form dispatches a control command through `runControlStdin`,
 43  so no rule lives in a handler.
 44- **Text only.** Content must be valid UTF-8. A snippet is read on a
 45  page and served raw as `text/plain`; a binary belongs in a release
 46  asset.
 47- **One file per command.** Stock OpenSSH carries one stdin stream, so
 48  `snippet create` takes one file and `snippet file set` adds the rest.
 49  A packed multi-file format on stdin would fail the stock-ssh
 50  constraint.
 51- **No `--yes` on delete.** `release delete --yes` guards assets that
 52  are gone for good; a snippet has nothing hanging off it and is a
 53  paste, not a release.
 54- **No events, audit rows, notifications, comments, search, Atom feed
 55  or explore listing.** Events are keyed on a repository. None of the
 56  rest was asked for.
 57
 58## Data
 59
 60Migration 0054, no rebuild:
 61
 62```sql
 63CREATE TABLE snippets (
 64    id          INTEGER PRIMARY KEY,
 65    public_id   TEXT NOT NULL UNIQUE,
 66    owner_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
 67    description TEXT NOT NULL DEFAULT '',
 68    visibility  TEXT NOT NULL CHECK (visibility IN ('public','unlisted','private')),
 69    created_at  TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
 70    updated_at  TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
 71);
 72CREATE INDEX snippets_owner ON snippets(owner_id, id);
 73
 74CREATE TABLE snippet_files (
 75    snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
 76    name       TEXT NOT NULL,
 77    content    BLOB NOT NULL,
 78    size       INTEGER NOT NULL,
 79    PRIMARY KEY (snippet_id, name)
 80);
 81```
 82
 83`public_id` is 12 lowercase hex characters from `crypto/rand` (48 bits),
 84generated on create and retried on a unique violation. It is global: the
 85CLI takes `<id>` alone, and the owner in the URL is for reading, not for
 86lookup.
 87
 88`updated_at` moves on every file or metadata write. Deleting a user
 89deletes their snippets through the cascade, as it does their keys.
 90
 91Limits:
 92
 93- `limits.max_snippet_bytes` in `config.toml`, per file, default
 94  1 MiB. Enforced on create and `file set` with the same
 95  `io.LimitReader(n+1)` shape as `release asset add`.
 96- 64 files per snippet, a constant in `internal/control/snippet.go`.
 97- `limits.max_snippets_per_user`, default unlimited, like
 98  `max_repos_per_user`. Enforced on `snippet create`; admins are not
 99  exempt.
100- Snippet bytes do not count toward `max_bytes_per_user`; that quota
101  measures repositories and LFS.
102
103File names match the release asset name pattern:
104`^[A-Za-z0-9][A-Za-z0-9._+-]{0,199}$`, so no slashes and no leading dot.
105
106`store.Snippet` carries the row plus `OwnerName`; `store.SnippetFile`
107carries `Name`, `Size` and `Content`. Store functions: `CreateSnippet`,
108`SnippetByPublicID`, `ListSnippets(ownerID, all bool, limit, cursor)`,
109`UpdateSnippet`, `DeleteSnippet`, `SetSnippetFile`, `RemoveSnippetFile`,
110`SnippetFiles`, `SnippetFile`. Hand-written SQL, as everywhere.
111
112## Commands
113
114All in `internal/control/snippet.go`, registered like every other noun.
115Reads set `ReadOnly`; the two commands that take a body set
116`ReadsStdin`; nothing is `SSHOnly`, so the JSON API reaches all of it.
117
118| command | usage |
119|---|---|
120| `snippet create` | `snippet create <filename> [--description <d>] [--visibility public\|unlisted\|private] < file` |
121| `snippet show` | `snippet show <id>` |
122| `snippet list` | `snippet list [<owner>] [--limit n] [--cursor c]` |
123| `snippet edit` | `snippet edit <id> [--description <d>] [--visibility <v>]` |
124| `snippet delete` | `snippet delete <id>` |
125| `snippet file set` | `snippet file set <id> <filename> < file` |
126| `snippet file get` | `snippet file get <id> <filename> > file` |
127| `snippet file remove` | `snippet file remove <id> <filename>` |
128
129Behaviour:
130
131- `create` reads stdin as the first file, refuses empty stdin, content
132  that is not valid UTF-8, or content over the limit (exit 2 with the
133  reason), and prints the id and the web URL. JSON: `{id, url, owner,
134  description, visibility, created_at, updated_at, files:[{name,size}]}`.
135- `show` prints the metadata and the file list with sizes. `--json`
136  includes each file's `content` so one API read returns the whole
137  snippet.
138- `list` with no argument lists the caller's snippets at every
139  visibility, newest first. With `<owner>` it lists that owner's public
140  snippets, or everything when the owner is the caller or an admin. An
141  unknown owner is exit 3. Paged with keyset cursors like `repo list`;
142  the JSON is `{items, next}`.
143- `edit` changes the description or the visibility, or both; neither
144  given is exit 2.
145- `file set` adds a file or replaces one by name, under the same checks
146  as `create`, and refuses the 65th file. `file remove` refuses to
147  remove the last file: a snippet always has one. `file get` writes the
148  content to stdout unchanged.
149- `delete` removes the snippet and its files.
150
151Access, in `internal/policy` beside the repository rules. Key scope needs
152no rule of its own: the dispatcher already refuses every control command
153to a key that is not `full`.
154
155- Read: the owner and admins for `private`; anyone, anonymous included,
156  for `unlisted` and `public`.
157- Write: the owner and admins.
158- A snippet the caller may not read is exit 3, never 4, so a private id
159  cannot be confirmed. A snippet the caller may read but not write is
160  exit 4.
161
162CLI: one `pass()` per command in `cmd/gitbay/main.go`. `snippet create`
163and `snippet file set` use `alwaysStdin` with a `stdinWhat` naming the
164file's bytes, as `release asset add` does. No `needsRepo`: the noun is
165not repository-scoped, and the repo argument is never inferred.
166
167## Web
168
169Routes live under `/{owner}/-/`, the pattern `/{owner}/-/labels` set: a
170hyphen cannot start a repository name, so nothing is shadowed and no
171word joins `internal/policy/names.go`.
172
173| method | path | handler |
174|---|---|---|
175| GET | `/{owner}/-/snippets` | list: the owner's public snippets; everything, marked by visibility, when the viewer is the owner or an admin |
176| GET | `/{owner}/-/snippets/new` | create form, `requireUser`, owner must be the viewer |
177| POST | `/{owner}/-/snippets/new` | dispatch `snippet create`, redirect to the snippet |
178| GET | `/{owner}/-/snippets/{id}` | the snippet: description, visibility, each file highlighted with a raw link |
179| GET | `/{owner}/-/snippets/{id}/raw/{name}` | `text/plain; charset=utf-8`, `X-Content-Type-Options: nosniff` |
180| POST | `/{owner}/-/snippets/{id}/edit` | dispatch `snippet edit` |
181| POST | `/{owner}/-/snippets/{id}/delete` | dispatch `snippet delete`, redirect to the list |
182| POST | `/{owner}/-/snippets/{id}/file` | dispatch `snippet file set` with the textarea on stdin |
183| POST | `/{owner}/-/snippets/{id}/file/remove` | dispatch `snippet file remove` |
184
185An id under the wrong owner is 404. Private snippets are 404 to anyone
186but the owner and admins, unlisted ones render for anyone with the URL.
187Files are rendered through the existing `highlight(path, data)` by
188extension; `.md` and `.org` are highlighted as source, not rendered as
189markup, since a snippet is a paste rather than a document. Each file
190heading links to its raw route.
191
192Forms are on the snippet page for the owner: a textarea per file posting
193`file`, a remove button per file, an add-file form (name and textarea),
194a description and visibility form, and delete. All POSTs go through
195`checkOrigin` and `requireUser`, and answer through `done`, so a refusal
196returns to the page with the message and a missing snippet is the 404
197page.
198
199The owner page shows a `snippets` link below the repository list
200when the owner has a public snippet, or when the viewer is the owner.
201
202Templates: `snippets.html`, `snippet.html`, `snippetnew.html`. Stylesheet
203additions in `static/style.css` only where an existing class does not
204fit; the file blocks reuse the blob page's classes.
205
206## Testing
207
208`e2e/snippet_test.go`, over ssh with the real binary:
209
210- create prints an id of 12 hex characters and a URL; `show` and `file
211  get` round-trip the content byte for byte; `--json` on `show` carries
212  `content`.
213- visibility, from a second account and from anonymous HTTP: private is
214  exit 3 and 404 to the other account, unlisted is readable by id and
215  absent from `snippet list <owner>`, public is listed; the owner's own
216  `snippet list` shows all three.
217- `file set` replaces, `file remove` refuses the last file, the 65th
218  file is refused, non-UTF-8 and oversize bodies are refused with exit 2.
219- `edit` moves visibility and the listing follows.
220- `delete` then `show` is exit 3; deleting the user cascades the rows.
221- the other account cannot `edit`, `file set` or `delete` (exit 4 on an
222  unlisted snippet, exit 3 on a private one).
223
224`e2e/snippetweb_test.go`: the list and snippet pages render, raw is
225`text/plain` with nosniff, the create form makes a snippet, the file and
226edit forms change it, delete removes it, and the owner page carries the
227link. The existing coverage tests hold the rest: the CLI table, the
228`ReadsStdin` flag, and that every `ReadOnly` command writes nothing.
229
230## Documentation
231
232- `.gitbay/wiki/Users.org`: a `* Snippets` section after Pages.
233- `.gitbay/wiki/Parity.org`: rows for `snippet create, edit, delete`,
234  `snippet show, list`, `snippet file set, get, remove`; `cli` yes, `web`
235  yes, `ios` no.
236- `CHANGELOG.org`: the v1.20.0 entry.
237- `.gitbay/wiki/Admin.org`: `max_snippet_bytes` beside `max_asset_bytes`
238  in the limits list.