Snippets (#195) !382

merged merged by cmc on 2026-09-12 01:59 UTC · krz/gitbay:snippets into main

27 files changed, +3723 −6

Layout: unified · split

.gitbay/wiki/Admin.org +2
@@ -114,6 +114,8 @@ default, is right when gitbayd terminates TLS itself.
114- =clone_timeout= (3600s) — cap on =repo import= fetches. 114- =clone_timeout= (3600s) — cap on =repo import= fetches.
115- =max_blob_bytes= (100MB) — cap on raw file serving over the web. 115- =max_blob_bytes= (100MB) — cap on raw file serving over the web.
116- =max_asset_bytes= (512MB) — cap per uploaded release asset. 116- =max_asset_bytes= (512MB) — cap per uploaded release asset.
117- =max_snippet_bytes= (1MB) — cap per snippet file.
118- =max_snippets_per_user= (0, unlimited) — snippets an account may own.
117- =max_repos_per_user= (0, unlimited) — repositories an account may own 119- =max_repos_per_user= (0, unlimited) — repositories an account may own
118 directly; =repo create=, =fork= and =import= refuse past it. 120 directly; =repo create=, =fork= and =import= refuse past it.
119 Organizations are not capped. 121 Organizations are not capped.
.gitbay/wiki/Parity.org +3
@@ -192,6 +192,9 @@ always markdown.
192| release delete | yes | yes | yes | 192| release delete | yes | yes | yes |
193| release asset add | yes | no | n/a | 193| release asset add | yes | no | n/a |
194| release asset remove | yes | no | yes | 194| release asset remove | yes | no | yes |
195| snippet create, edit, delete | yes | yes | no |
196| snippet show, list | yes | yes | no |
197| snippet file set, get, remove | yes | yes | no |
195 198
196No row is web-only any more. Blame, file editing, log at a ref, 199No row is web-only any more. Blame, file editing, log at a ref,
197archive, the public listing and the wiki were all in that state — a 200archive, the public listing and the wiki were all in that state — a
.gitbay/wiki/Users.org +26
@@ -601,6 +601,32 @@ instance issues its own certificates). Claims are exclusive per
601instance; unverified claims serve nothing and expire after 7 days. 601instance; unverified claims serve nothing and expire after 7 days.
602=repo domain list= reports pending/verified/expired. 602=repo domain list= reports pending/verified/expired.
603 603
604* Snippets
605
606A snippet is one or more named text files you own outside any
607repository, for a log or a fragment shared by URL. Create one from a
608file on stdin; the reply is the id and the URL:
609
610#+begin_src sh
611gitbay snippet create build.log --description "failing build" < build.log
612gitbay snippet file set <id> notes.txt < notes.txt # add or replace a file
613gitbay snippet file get <id> build.log > build.log
614gitbay snippet edit <id> --visibility public
615gitbay snippet list # yours
616gitbay snippet list <owner> # their public ones
617gitbay snippet delete <id>
618#+end_src
619
620Visibility is =public= (listed on your page), =unlisted= (anyone with
621the URL, listed nowhere; the default) or =private= (you alone; not
622found to everyone else). Files are text, valid UTF-8, each under the
623instance's =max_snippet_bytes=, at most 64 per snippet. A snippet keeps
624at least one file. There is no history: setting a file replaces it.
625
626On the web, =/<you>/-/snippets= lists yours, each snippet page renders
627its files with a raw link per file, and the same page creates, edits
628and deletes through the commands above.
629
604* Browser sessions 630* Browser sessions
605 631
606=gitbay web login= mints a one-time URL; the session it opens lasts 632=gitbay web login= mints a one-time URL; the session it opens lasts
CHANGELOG.org +18
@@ -4,6 +4,24 @@ Versioning follows semver from v0.1.0. Database migrations run
4automatically on daemon start; upgrade notes appear per release when 4automatically on daemon start; upgrade notes appear per release when
5anything beyond "replace the binary and restart" is needed. 5anything beyond "replace the binary and restart" is needed.
6 6
7* v1.20.0 — unreleased
8
9Snippets (#195): named text files a user owns outside any repository,
10shared by URL and edited in place.
11
12- Migration 0054: =snippets= and =snippet_files=.
13- =snippet create|show|list|edit|delete= and =snippet file
14 set|get|remove=. Files are UTF-8 under =limits.max_snippet_bytes=
15 (1MB), at most 64 per snippet; a snippet keeps at least one.
16 =limits.max_snippets_per_user= (0, unlimited) caps how many an
17 account may own, admins included. Visibility =public=, =unlisted=
18 (default) or =private=; a private snippet is not found to everyone
19 but its owner and admins.
20- Web: =/<owner>/-/snippets= lists, each snippet page renders its
21 files with a raw route per file, and the owner creates, edits and
22 deletes from the page through the same commands. The owner page
23 links to the list.
24
7* v1.19.0 — 2026-09-11 25* v1.19.0 — 2026-09-11
8 26
9Labels and milestones an org defines once for every repository under 27Labels and milestones an org defines once for every repository under
cmd/gitbay/main.go +16
@@ -76,6 +76,22 @@ func newRoot() *cobra.Command {
76 pass("list", "list pages: [<owner/name>]", passOpts{server: []string{"wiki", "list"}, needsRepo: true}), 76 pass("list", "list pages: [<owner/name>]", passOpts{server: []string{"wiki", "list"}, needsRepo: true}),
77 pass("show", "print a page: [<owner/name>] [<page>]", passOpts{server: []string{"wiki", "show"}, needsRepo: true}), 77 pass("show", "print a page: [<owner/name>] [<page>]", passOpts{server: []string{"wiki", "show"}, needsRepo: true}),
78 ), 78 ),
79 group("snippet", "shared text files, outside any repository",
80 pass("create", "create from one file on stdin: <filename> [--description d] [--visibility public|unlisted|private] < file",
81 passOpts{server: []string{"snippet", "create"}, alwaysStdin: true, stdinWhat: "the file's text"}),
82 pass("show", "metadata and files: <id>", passOpts{server: []string{"snippet", "show"}}),
83 pass("list", "your snippets, or an owner's public ones: [<owner>] [--limit n] [--cursor c]",
84 passOpts{server: []string{"snippet", "list"}}),
85 pass("edit", "change description or visibility: <id> [--description d] [--visibility v]",
86 passOpts{server: []string{"snippet", "edit"}}),
87 pass("delete", "delete a snippet: <id>", passOpts{server: []string{"snippet", "delete"}}),
88 group("file", "the files in a snippet",
89 pass("set", "add or replace a file from stdin: <id> <filename> < file",
90 passOpts{server: []string{"snippet", "file", "set"}, alwaysStdin: true, stdinWhat: "the file's text"}),
91 pass("get", "print a file: <id> <filename> > file", passOpts{server: []string{"snippet", "file", "get"}}),
92 pass("remove", "remove a file: <id> <filename>", passOpts{server: []string{"snippet", "file", "remove"}}),
93 ),
94 ),
79 repoCmd(), 95 repoCmd(),
80 issueCmd(), 96 issueCmd(),
81 milestoneCmd(), 97 milestoneCmd(),
docs/plans/2026-09-11-snippets.md added +1910
@@ -0,0 +1,1910 @@
1# Snippets: implementation plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** A snippet a user owns, shares by URL, and edits in place: one or more named text files, a description, and a visibility. Closes #195.
6
7**Architecture:** Two new tables (migration 0054) hold snippets and their files; content sits in SQLite as a BLOB. Eight `snippet` control commands in `internal/control/snippet.go` are the only write path; the CLI, the JSON API and the web forms dispatch into them. Read rules live in `internal/policy/snippets.go`. The web renders under `/{owner}/-/snippets`, the pattern `/{owner}/-/labels` set.
8
9**Tech Stack:** Go, SQLite via modernc (hand-written SQL, no ORM), Go `html/template`, chroma through the existing `highlight`, the control registry in `internal/control`, the e2e harness in `e2e/`.
10
11**Spec:** `docs/specs/2026-09-11-snippets-design.md`
12
13## Global Constraints
14
15- Every capability lands as a control command first; the CLI, web and API dispatch into it. New commands need a `pass()` row in `cmd/gitbay/main.go` (`TestCLI` in `e2e/cli_test.go` enforces this) and, if `ReadOnly`, a row in `readArgs` in `e2e/readonly_test.go` (`TestReadOnlyCommandsWriteNothing` enforces that).
16- A command that reads stdin sets `ReadsStdin: true`, or `control.go` swaps in an empty reader and `--file -` stores nothing without an error.
17- Hand-written SQL only. Migrations are `internal/store/migrations/NNNN_name.up.sql` and `.down.sql`, embedded, run one per transaction; `TestMigrateUpDown` runs both directions.
18- Private things return not-found (exit 3, HTTP 404), never a denial that confirms they exist.
19- Every `<input>` and `<textarea>` a person types into carries an `aria-label` (`internal/httpd/inputlabels_test.go`). One `<h1>` per page.
20- Never mention an assistant or model anywhere: commit messages, comments, docs.
21- Commit messages reference the issue: `Ref #195` on each task, `Closes #195` on the last.
22- Run locally: `go build ./... && go vet ./...` and the unit tests of the touched packages. Run at most the one e2e test you write (`go test ./e2e -run 'TestSnippets$'`); CI on bay1 runs the full suite.
23- Style: plain sentences in comments, no dramatic framing. Match the surrounding code. Comments in stores and handlers are one or two lines saying why, as the neighbours do.
24- Work on branch `snippets` in the worktree `../gitbay-snippets`, which already holds the spec.
25
26Names used across tasks, fixed here so the tasks agree:
27
28- Store: `store.Snippet`, `store.SnippetFile`, `CreateSnippet`, `SnippetByPublicID`, `SnippetFiles`, `SnippetFile`, `ListSnippets`, `CountSnippets`, `UpdateSnippet`, `DeleteSnippet`, `SetSnippetFile`, `RemoveSnippetFile`.
29- Policy: `policy.CanReadSnippet`, `policy.CanWriteSnippet`.
30- Config: `Limits.MaxSnippetBytes` (`max_snippet_bytes`, default `1 << 20`).
31- Control: exported `control.SnippetOut`, `control.SnippetFileOut`; the constant `maxSnippetFiles = 64`.
32- Web: handlers `snippetsPage`, `snippetPage`, `snippetRaw`, `snippetNewForm`, `snippetNewSubmit`, `snippetEditSubmit`, `snippetDeleteSubmit`, `snippetFileSubmit`, `snippetFileRemoveSubmit`; templates `snippets.html`, `snippet.html`, `snippetnew.html`.
33
34---
35
36### Task 1: Migration 0054 and the store
37
38**Files:**
39- Create: `internal/store/migrations/0054_snippets.up.sql`
40- Create: `internal/store/migrations/0054_snippets.down.sql`
41- Create: `internal/store/snippets.go`
42- Test: `internal/store/snippets_test.go`
43
44**Interfaces:**
45- Produces the tables `snippets` and `snippet_files` as in the spec.
46- Produces:
47
48```go
49type Snippet struct {
50 ID int64
51 PublicID string
52 OwnerID int64
53 OwnerName string
54 Description string
55 Visibility string // public | unlisted | private
56 CreatedAt string
57 UpdatedAt string
58 Files []SnippetFile // names and sizes; Content is filled by SnippetFiles and SnippetFile only
59}
60type SnippetFile struct {
61 Name string
62 Size int64
63 Content []byte
64}
65func (s *Store) CreateSnippet(ownerID int64, publicID, description, visibility, name string, content []byte) (int64, error) // ErrExists on a public_id collision
66func (s *Store) SnippetByPublicID(publicID string) (Snippet, error) // ErrNotFound; Files carry Name and Size
67func (s *Store) SnippetFiles(id int64) ([]SnippetFile, error) // with Content, by name
68func (s *Store) SnippetFile(id int64, name string) (SnippetFile, error) // ErrNotFound
69func (s *Store) ListSnippets(ownerID int64, all bool, limit int, afterID int64) ([]Snippet, error) // newest first; all=false is public only; afterID=0 from the start
70func (s *Store) CountSnippets(ownerID int64, all bool) (int, error)
71func (s *Store) UpdateSnippet(id int64, description, visibility string) error
72func (s *Store) DeleteSnippet(id int64) error
73func (s *Store) SetSnippetFile(id int64, name string, content []byte) error // insert or replace; touches updated_at
74func (s *Store) RemoveSnippetFile(id int64, name string) error // ErrNotFound when absent; touches updated_at
75```
76
77- [ ] **Step 1: Write the failing store test**
78
79Create `internal/store/snippets_test.go`:
80
81```go
82package store
83
84import (
85 "errors"
86 "testing"
87)
88
89func TestSnippets(t *testing.T) {
90 s := open(t)
91 alice, err := s.CreateUser("alice", false)
92 if err != nil {
93 t.Fatal(err)
94 }
95 id, err := s.CreateSnippet(alice, "abcdef012345", "a log", "unlisted", "build.log", []byte("ok\n"))
96 if err != nil {
97 t.Fatal(err)
98 }
99 if _, err := s.CreateSnippet(alice, "abcdef012345", "", "public", "x", []byte("x")); !errors.Is(err, ErrExists) {
100 t.Fatalf("duplicate public id: %v", err)
101 }
102 sn, err := s.SnippetByPublicID("abcdef012345")
103 if err != nil {
104 t.Fatal(err)
105 }
106 if sn.ID != id || sn.OwnerName != "alice" || sn.Visibility != "unlisted" || sn.Description != "a log" {
107 t.Fatalf("snippet: %+v", sn)
108 }
109 if len(sn.Files) != 1 || sn.Files[0].Name != "build.log" || sn.Files[0].Size != 3 || sn.Files[0].Content != nil {
110 t.Fatalf("files on lookup: %+v", sn.Files)
111 }
112
113 // Set adds, then replaces; remove drops; the file read carries content.
114 if err := s.SetSnippetFile(id, "notes.txt", []byte("one\n")); err != nil {
115 t.Fatal(err)
116 }
117 if err := s.SetSnippetFile(id, "notes.txt", []byte("two\n")); err != nil {
118 t.Fatal(err)
119 }
120 f, err := s.SnippetFile(id, "notes.txt")
121 if err != nil || string(f.Content) != "two\n" || f.Size != 4 {
122 t.Fatalf("file after replace: %+v %v", f, err)
123 }
124 files, err := s.SnippetFiles(id)
125 if err != nil || len(files) != 2 || files[0].Name != "build.log" || string(files[1].Content) != "two\n" {
126 t.Fatalf("files: %+v %v", files, err)
127 }
128 if err := s.RemoveSnippetFile(id, "notes.txt"); err != nil {
129 t.Fatal(err)
130 }
131 if err := s.RemoveSnippetFile(id, "notes.txt"); !errors.Is(err, ErrNotFound) {
132 t.Fatalf("remove missing file: %v", err)
133 }
134 if _, err := s.SnippetFile(id, "notes.txt"); !errors.Is(err, ErrNotFound) {
135 t.Fatalf("read removed file: %v", err)
136 }
137
138 // Listing: public only unless all; newest first; keyset by id.
139 pub, err := s.CreateSnippet(alice, "000000000001", "", "public", "a", []byte("a"))
140 if err != nil {
141 t.Fatal(err)
142 }
143 if _, err := s.CreateSnippet(alice, "000000000002", "", "private", "b", []byte("b")); err != nil {
144 t.Fatal(err)
145 }
146 got, err := s.ListSnippets(alice, false, 0, 0)
147 if err != nil || len(got) != 1 || got[0].ID != pub {
148 t.Fatalf("public list: %+v %v", got, err)
149 }
150 got, err = s.ListSnippets(alice, true, 0, 0)
151 if err != nil || len(got) != 3 || got[0].PublicID != "000000000002" || got[2].ID != id {
152 t.Fatalf("all list: %+v %v", got, err)
153 }
154 got, err = s.ListSnippets(alice, true, 2, got[0].ID)
155 if err != nil || len(got) != 2 || got[0].ID != pub {
156 t.Fatalf("paged list: %+v %v", got, err)
157 }
158 if n, err := s.CountSnippets(alice, false); err != nil || n != 1 {
159 t.Fatalf("public count: %d %v", n, err)
160 }
161 if n, err := s.CountSnippets(alice, true); err != nil || n != 3 {
162 t.Fatalf("all count: %d %v", n, err)
163 }
164
165 // Update, delete, and the owner cascade.
166 if err := s.UpdateSnippet(id, "renamed", "public"); err != nil {
167 t.Fatal(err)
168 }
169 sn, _ = s.SnippetByPublicID("abcdef012345")
170 if sn.Description != "renamed" || sn.Visibility != "public" {
171 t.Fatalf("after update: %+v", sn)
172 }
173 if err := s.DeleteSnippet(id); err != nil {
174 t.Fatal(err)
175 }
176 if _, err := s.SnippetByPublicID("abcdef012345"); !errors.Is(err, ErrNotFound) {
177 t.Fatalf("after delete: %v", err)
178 }
179 if err := s.DeleteUser(alice); err != nil {
180 t.Fatal(err)
181 }
182 var n int
183 if err := s.DB.QueryRow("SELECT COUNT(*) FROM snippet_files").Scan(&n); err != nil || n != 0 {
184 t.Fatalf("files after user delete: %d %v", n, err)
185 }
186}
187```
188
189- [ ] **Step 2: Run it to see it fail**
190
191Run: `go test ./internal/store -run TestSnippets`
192Expected: compile error, `s.CreateSnippet undefined`.
193
194- [ ] **Step 3: Write the migration**
195
196`internal/store/migrations/0054_snippets.up.sql`:
197
198```sql
199-- Snippets: named text files a user owns and shares by URL, outside any
200-- repository. public_id is the opaque id in URLs and commands.
201CREATE TABLE snippets (
202 id INTEGER PRIMARY KEY,
203 public_id TEXT NOT NULL UNIQUE,
204 owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
205 description TEXT NOT NULL DEFAULT '',
206 visibility TEXT NOT NULL CHECK (visibility IN ('public','unlisted','private')),
207 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
208 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
209);
210CREATE INDEX snippets_owner ON snippets(owner_id, id);
211
212CREATE TABLE snippet_files (
213 snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
214 name TEXT NOT NULL,
215 content BLOB NOT NULL,
216 size INTEGER NOT NULL,
217 PRIMARY KEY (snippet_id, name)
218);
219```
220
221`internal/store/migrations/0054_snippets.down.sql`:
222
223```sql
224DROP TABLE snippet_files;
225DROP TABLE snippets;
226```
227
228- [ ] **Step 4: Write the store**
229
230Create `internal/store/snippets.go`:
231
232```go
233package store
234
235import (
236 "database/sql"
237 "errors"
238)
239
240type Snippet struct {
241 ID int64
242 PublicID string
243 OwnerID int64
244 OwnerName string
245 Description string
246 Visibility string // public | unlisted | private
247 CreatedAt string
248 UpdatedAt string
249 // Files carries names and sizes. Content is filled by SnippetFiles and
250 // SnippetFile only, so a listing does not read every body.
251 Files []SnippetFile
252}
253
254type SnippetFile struct {
255 Name string
256 Size int64
257 Content []byte
258}
259
260const snippetSelect = `
261 SELECT s.id, s.public_id, s.owner_id, u.username, s.description, s.visibility, s.created_at, s.updated_at
262 FROM snippets s JOIN users u ON u.id = s.owner_id`
263
264func scanSnippet(row interface{ Scan(...any) error }) (Snippet, error) {
265 var sn Snippet
266 err := row.Scan(&sn.ID, &sn.PublicID, &sn.OwnerID, &sn.OwnerName, &sn.Description, &sn.Visibility, &sn.CreatedAt, &sn.UpdatedAt)
267 return sn, err
268}
269
270// CreateSnippet inserts the snippet and its first file in one transaction.
271// A public_id collision is ErrExists so the caller can draw another.
272func (s *Store) CreateSnippet(ownerID int64, publicID, description, visibility, name string, content []byte) (int64, error) {
273 tx, err := s.DB.Begin()
274 if err != nil {
275 return 0, err
276 }
277 defer tx.Rollback()
278 res, err := tx.Exec(
279 "INSERT INTO snippets (public_id, owner_id, description, visibility) VALUES (?, ?, ?, ?)",
280 publicID, ownerID, description, visibility)
281 if err != nil {
282 if isUniqueErr(err) {
283 return 0, ErrExists
284 }
285 return 0, err
286 }
287 id, err := res.LastInsertId()
288 if err != nil {
289 return 0, err
290 }
291 if _, err := tx.Exec("INSERT INTO snippet_files (snippet_id, name, content, size) VALUES (?, ?, ?, ?)",
292 id, name, content, len(content)); err != nil {
293 return 0, err
294 }
295 return id, tx.Commit()
296}
297
298func (s *Store) SnippetByPublicID(publicID string) (Snippet, error) {
299 sn, err := scanSnippet(s.DB.QueryRow(snippetSelect+" WHERE s.public_id = ?", publicID))
300 if errors.Is(err, sql.ErrNoRows) {
301 return sn, ErrNotFound
302 }
303 if err != nil {
304 return sn, err
305 }
306 sn.Files, err = s.snippetFileNames(sn.ID)
307 return sn, err
308}
309
310func (s *Store) snippetFileNames(id int64) ([]SnippetFile, error) {
311 rows, err := s.DB.Query("SELECT name, size FROM snippet_files WHERE snippet_id = ? ORDER BY name", id)
312 if err != nil {
313 return nil, err
314 }
315 defer rows.Close()
316 var out []SnippetFile
317 for rows.Next() {
318 var f SnippetFile
319 if err := rows.Scan(&f.Name, &f.Size); err != nil {
320 return nil, err
321 }
322 out = append(out, f)
323 }
324 return out, rows.Err()
325}
326
327// SnippetFiles returns every file with its content, by name.
328func (s *Store) SnippetFiles(id int64) ([]SnippetFile, error) {
329 rows, err := s.DB.Query("SELECT name, size, content FROM snippet_files WHERE snippet_id = ? ORDER BY name", id)
330 if err != nil {
331 return nil, err
332 }
333 defer rows.Close()
334 var out []SnippetFile
335 for rows.Next() {
336 var f SnippetFile
337 if err := rows.Scan(&f.Name, &f.Size, &f.Content); err != nil {
338 return nil, err
339 }
340 out = append(out, f)
341 }
342 return out, rows.Err()
343}
344
345func (s *Store) SnippetFile(id int64, name string) (SnippetFile, error) {
346 var f SnippetFile
347 err := s.DB.QueryRow("SELECT name, size, content FROM snippet_files WHERE snippet_id = ? AND name = ?", id, name).
348 Scan(&f.Name, &f.Size, &f.Content)
349 if errors.Is(err, sql.ErrNoRows) {
350 return f, ErrNotFound
351 }
352 return f, err
353}
354
355// ListSnippets lists an owner's snippets newest first. all=false keeps
356// public ones only. afterID is the keyset cursor: rows older than it.
357// Ids grow with creation, so ordering by id is creation order.
358func (s *Store) ListSnippets(ownerID int64, all bool, limit int, afterID int64) ([]Snippet, error) {
359 q := snippetSelect + " WHERE s.owner_id = ?"
360 args := []any{ownerID}
361 if !all {
362 q += " AND s.visibility = 'public'"
363 }
364 if afterID > 0 {
365 q += " AND s.id < ?"
366 args = append(args, afterID)
367 }
368 q += " ORDER BY s.id DESC"
369 if limit > 0 {
370 q += " LIMIT ?"
371 args = append(args, limit)
372 }
373 rows, err := s.DB.Query(q, args...)
374 if err != nil {
375 return nil, err
376 }
377 defer rows.Close()
378 var out []Snippet
379 for rows.Next() {
380 sn, err := scanSnippet(rows)
381 if err != nil {
382 return nil, err
383 }
384 out = append(out, sn)
385 }
386 if err := rows.Err(); err != nil {
387 return nil, err
388 }
389 // One query per row for the names; pages are at most 200 rows.
390 for i := range out {
391 if out[i].Files, err = s.snippetFileNames(out[i].ID); err != nil {
392 return nil, err
393 }
394 }
395 return out, nil
396}
397
398func (s *Store) CountSnippets(ownerID int64, all bool) (int, error) {
399 q := "SELECT COUNT(*) FROM snippets WHERE owner_id = ?"
400 if !all {
401 q += " AND visibility = 'public'"
402 }
403 var n int
404 err := s.DB.QueryRow(q, ownerID).Scan(&n)
405 return n, err
406}
407
408func (s *Store) UpdateSnippet(id int64, description, visibility string) error {
409 _, err := s.DB.Exec(
410 "UPDATE snippets SET description = ?, visibility = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
411 description, visibility, id)
412 return err
413}
414
415func (s *Store) DeleteSnippet(id int64) error {
416 _, err := s.DB.Exec("DELETE FROM snippets WHERE id = ?", id)
417 return err
418}
419
420// SetSnippetFile adds the file or replaces one of the same name.
421func (s *Store) SetSnippetFile(id int64, name string, content []byte) error {
422 tx, err := s.DB.Begin()
423 if err != nil {
424 return err
425 }
426 defer tx.Rollback()
427 if _, err := tx.Exec(`INSERT INTO snippet_files (snippet_id, name, content, size) VALUES (?, ?, ?, ?)
428 ON CONFLICT (snippet_id, name) DO UPDATE SET content = excluded.content, size = excluded.size`,
429 id, name, content, len(content)); err != nil {
430 return err
431 }
432 if _, err := tx.Exec("UPDATE snippets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id); err != nil {
433 return err
434 }
435 return tx.Commit()
436}
437
438func (s *Store) RemoveSnippetFile(id int64, name string) error {
439 tx, err := s.DB.Begin()
440 if err != nil {
441 return err
442 }
443 defer tx.Rollback()
444 res, err := tx.Exec("DELETE FROM snippet_files WHERE snippet_id = ? AND name = ?", id, name)
445 if err != nil {
446 return err
447 }
448 if n, _ := res.RowsAffected(); n == 0 {
449 return ErrNotFound
450 }
451 if _, err := tx.Exec("UPDATE snippets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id); err != nil {
452 return err
453 }
454 return tx.Commit()
455}
456```
457
458- [ ] **Step 5: Run the store tests**
459
460Run: `go test ./internal/store`
461Expected: PASS, including `TestMigrateUpDown` (the down file drops both tables) and `TestSnippets`.
462
463- [ ] **Step 6: Commit**
464
465```bash
466git add internal/store/migrations/0054_snippets.up.sql internal/store/migrations/0054_snippets.down.sql internal/store/snippets.go internal/store/snippets_test.go
467git commit -m "store: snippets and snippet_files (migration 0054)
468
469Ref #195"
470```
471
472---
473
474### Task 2: Policy, config limit, and the snippet commands
475
476**Files:**
477- Create: `internal/policy/snippets.go`
478- Modify: `internal/config/config.go:175-193` (the `Limits` struct) and the defaults near line 217
479- Create: `internal/control/snippet.go`
480- Modify: `cmd/gitbay/main.go:74-77` (after the `wiki` group)
481- Modify: `e2e/readonly_test.go:72-73` (fixtures) and `:146-158` (`readArgs`)
482- Test: `e2e/snippet_test.go`
483
484**Interfaces:**
485- Consumes the store API from Task 1.
486- Produces:
487
488```go
489// internal/policy/snippets.go
490func CanReadSnippet(user store.User, sn store.Snippet) bool
491func CanWriteSnippet(user store.User, sn store.Snippet) bool
492
493// internal/config/config.go
494Limits.MaxSnippetBytes int64 `toml:"max_snippet_bytes"` // default 1 << 20
495
496// internal/control/snippet.go
497type SnippetFileOut struct {
498 Name string `json:"name"`
499 Size int64 `json:"size"`
500 Content string `json:"content,omitempty"`
501}
502type SnippetOut struct {
503 ID string `json:"id"`
504 URL string `json:"url"`
505 Owner string `json:"owner"`
506 Description string `json:"description"`
507 Visibility string `json:"visibility"`
508 CreatedAt string `json:"created_at"`
509 UpdatedAt string `json:"updated_at"`
510 Files []SnippetFileOut `json:"files"`
511}
512```
513
514Commands registered: `snippet create`, `snippet show`, `snippet list`, `snippet edit`, `snippet delete`, `snippet file set`, `snippet file get`, `snippet file remove`.
515
516- [ ] **Step 1: Write the failing e2e test**
517
518Create `e2e/snippet_test.go`:
519
520```go
521package e2e
522
523import (
524 "encoding/json"
525 "regexp"
526 "strings"
527 "testing"
528)
529
530// Snippets over SSH: create from stdin, read back, list by visibility,
531// edit files and metadata, and the not-found rule for private ones.
532func TestSnippets(t *testing.T) {
533 inst := startInstance(t)
534 aliceKey := inst.newKey(t, "alice")
535 bobKey := inst.newKey(t, "bob")
536 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
537 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
538 must := func(key, stdin string, args ...string) string {
539 t.Helper()
540 out, errOut, code := inst.ssh(t, key, stdin, args...)
541 if code != 0 {
542 t.Fatalf("%v: exit %d %s", args, code, errOut)
543 }
544 return out
545 }
546 fails := func(key, stdin string, want int, args ...string) string {
547 t.Helper()
548 _, errOut, code := inst.ssh(t, key, stdin, args...)
549 if code != want {
550 t.Fatalf("%v: exit %d, want %d: %s", args, code, want, errOut)
551 }
552 return errOut
553 }
554 idOf := func(out string) string {
555 t.Helper()
556 var env struct {
557 Data struct {
558 ID string `json:"id"`
559 URL string `json:"url"`
560 } `json:"data"`
561 }
562 if err := json.Unmarshal([]byte(out), &env); err != nil || !regexp.MustCompile(`^[0-9a-f]{12}$`).MatchString(env.Data.ID) {
563 t.Fatalf("create output: %s", out)
564 }
565 if !strings.HasSuffix(env.Data.URL, "/alice/-/snippets/"+env.Data.ID) {
566 t.Fatalf("url: %s", env.Data.URL)
567 }
568 return env.Data.ID
569 }
570
571 // Create with the default visibility, read back byte for byte.
572 body := "line one\nline two\n"
573 unlisted := idOf(must(aliceKey, body, "snippet", "create", "build.log", "--description", "'a log'", "--json"))
574 if got := must(aliceKey, "", "snippet", "file", "get", unlisted, "build.log"); got != body {
575 t.Fatalf("file get: %q", got)
576 }
577 out := must(aliceKey, "", "snippet", "show", unlisted, "--json")
578 if !strings.Contains(out, `"visibility":"unlisted"`) || !strings.Contains(out, `"content":"line one\nline two\n"`) {
579 t.Fatalf("show: %s", out)
580 }
581 public := idOf(must(aliceKey, "pub\n", "snippet", "create", "a.txt", "--visibility", "public", "--json"))
582 private := idOf(must(aliceKey, "sec\n", "snippet", "create", "b.txt", "--visibility", "private", "--json"))
583
584 // Refusals on create: empty, not text, over the limit, bad name.
585 fails(aliceKey, "", 2, "snippet", "create", "x.txt")
586 fails(aliceKey, "\xff\xfe\n", 2, "snippet", "create", "x.bin")
587 fails(aliceKey, strings.Repeat("x", 1<<20+1), 2, "snippet", "create", "big.txt")
588 fails(aliceKey, "x\n", 2, "snippet", "create", "../x")
589 fails(aliceKey, "x\n", 2, "snippet", "create", "x.txt", "--visibility", "secret")
590
591 // Visibility from the other side. Private is not-found, never denied.
592 fails(bobKey, "", 3, "snippet", "show", private)
593 fails(bobKey, "", 3, "snippet", "file", "get", private, "b.txt")
594 must(bobKey, "", "snippet", "show", unlisted)
595 out = must(bobKey, "", "snippet", "list", "alice", "--json")
596 if !strings.Contains(out, public) || strings.Contains(out, unlisted) || strings.Contains(out, private) {
597 t.Fatalf("bob's view of alice's list: %s", out)
598 }
599 out = must(aliceKey, "", "snippet", "list", "--json")
600 for _, id := range []string{public, unlisted, private} {
601 if !strings.Contains(out, id) {
602 t.Fatalf("alice's own list lacks %s: %s", id, out)
603 }
604 }
605 fails(bobKey, "", 3, "snippet", "list", "nobody")
606
607 // Paging: two pages of one, the second reached by cursor.
608 out = must(aliceKey, "", "snippet", "list", "--limit", "1", "--json")
609 var page struct {
610 Data struct {
611 Items []struct{ ID string `json:"id"` } `json:"items"`
612 Next string `json:"next"`
613 } `json:"data"`
614 }
615 json.Unmarshal([]byte(out), &page)
616 if len(page.Data.Items) != 1 || page.Data.Items[0].ID != private || page.Data.Next == "" {
617 t.Fatalf("first page: %s", out)
618 }
619 out = must(aliceKey, "", "snippet", "list", "--limit", "1", "--cursor", page.Data.Next, "--json")
620 if !strings.Contains(out, public) {
621 t.Fatalf("second page: %s", out)
622 }
623
624 // Files: set adds, set replaces, remove drops, the last one stays.
625 must(aliceKey, "notes\n", "snippet", "file", "set", unlisted, "notes.txt")
626 must(aliceKey, "changed\n", "snippet", "file", "set", unlisted, "build.log")
627 if got := must(aliceKey, "", "snippet", "file", "get", unlisted, "build.log"); got != "changed\n" {
628 t.Fatalf("after replace: %q", got)
629 }
630 must(aliceKey, "", "snippet", "file", "remove", unlisted, "notes.txt")
631 fails(aliceKey, "", 3, "snippet", "file", "remove", unlisted, "notes.txt")
632 if msg := fails(aliceKey, "", 2, "snippet", "file", "remove", unlisted, "build.log"); !strings.Contains(msg, "at least one file") {
633 t.Fatalf("last file removal: %s", msg)
634 }
635
636 // Only the owner writes: denied on a readable one, not-found on a private one.
637 fails(bobKey, "x\n", 4, "snippet", "file", "set", unlisted, "x.txt")
638 fails(bobKey, "", 4, "snippet", "edit", unlisted, "--description", "mine")
639 fails(bobKey, "", 4, "snippet", "delete", unlisted)
640 fails(bobKey, "", 3, "snippet", "delete", private)
641
642 // Edit moves visibility and the listing follows.
643 fails(aliceKey, "", 2, "snippet", "edit", unlisted)
644 must(aliceKey, "", "snippet", "edit", unlisted, "--visibility", "public", "--description", "shared")
645 out = must(bobKey, "", "snippet", "list", "alice", "--json")
646 if !strings.Contains(out, unlisted) || !strings.Contains(out, `"description":"shared"`) {
647 t.Fatalf("list after edit: %s", out)
648 }
649
650 // Delete, then gone; deleting the user takes the rest.
651 must(aliceKey, "", "snippet", "delete", unlisted)
652 fails(aliceKey, "", 3, "snippet", "show", unlisted)
653 inst.admin(t, "admin", "user", "delete", "alice", "--yes")
654 fails(bobKey, "", 3, "snippet", "show", public)
655}
656```
657
658- [ ] **Step 2: Run it to see it fail**
659
660Run: `go test ./e2e -run 'TestSnippets$'`
661Expected: FAIL at the first `must`: `unknown command "snippet"` (or similar) with exit 2.
662
663- [ ] **Step 3: Policy**
664
665Create `internal/policy/snippets.go`:
666
667```go
668package policy
669
670import "gitbay.org/gitbay/internal/store"
671
672// CanReadSnippet: anyone for public and unlisted, the owner and admins
673// for private. Anonymous readers have user.ID 0.
674func CanReadSnippet(user store.User, sn store.Snippet) bool {
675 if sn.Visibility != "private" {
676 return true
677 }
678 return user.ID != 0 && (user.ID == sn.OwnerID || user.IsAdmin)
679}
680
681// CanWriteSnippet: the owner and admins.
682func CanWriteSnippet(user store.User, sn store.Snippet) bool {
683 return user.ID != 0 && (user.ID == sn.OwnerID || user.IsAdmin)
684}
685```
686
687- [ ] **Step 4: Config limit**
688
689In `internal/config/config.go`, add to `Limits` after `MaxAssetBytes`:
690
691```go
692 MaxSnippetBytes int64 `toml:"max_snippet_bytes"` // per snippet file
693```
694
695and in the defaults block that sets `MaxAssetBytes: 512 << 20`, add:
696
697```go
698 MaxSnippetBytes: 1 << 20,
699```
700
701- [ ] **Step 5: The commands**
702
703Create `internal/control/snippet.go`:
704
705```go
706package control
707
708import (
709 "crypto/rand"
710 "encoding/hex"
711 "errors"
712 "fmt"
713 "io"
714 "strconv"
715 "unicode/utf8"
716
717 "gitbay.org/gitbay/internal/policy"
718 "gitbay.org/gitbay/internal/protocol"
719 "gitbay.org/gitbay/internal/store"
720)
721
722// A snippet keeps at most this many files; a paste is not a repository.
723const maxSnippetFiles = 64
724
725func init() {
726 register(Command{Path: []string{"snippet", "create"},
727 Summary: "create a snippet from one file on stdin",
728 Usage: "snippet create <filename> [--description <d>] [--visibility public|unlisted|private] < file",
729 ReadsStdin: true, Run: runSnippetCreate})
730 register(Command{Path: []string{"snippet", "show"},
731 Summary: "show a snippet's metadata and files",
732 Usage: "snippet show <id>", ReadOnly: true, Run: runSnippetShow})
733 register(Command{Path: []string{"snippet", "list"},
734 Summary: "list your snippets, or an owner's public ones",
735 Usage: "snippet list [<owner>] [--limit n] [--cursor c]", ReadOnly: true, Run: runSnippetList})
736 register(Command{Path: []string{"snippet", "edit"},
737 Summary: "change a snippet's description or visibility",
738 Usage: "snippet edit <id> [--description <d>] [--visibility public|unlisted|private]", Run: runSnippetEdit})
739 register(Command{Path: []string{"snippet", "delete"},
740 Summary: "delete a snippet and its files",
741 Usage: "snippet delete <id>", Run: runSnippetDelete})
742 register(Command{Path: []string{"snippet", "file", "set"},
743 Summary: "add a file to a snippet, or replace one, from stdin",
744 Usage: "snippet file set <id> <filename> < file",
745 ReadsStdin: true, Run: runSnippetFileSet})
746 register(Command{Path: []string{"snippet", "file", "get"},
747 Summary: "write a snippet file to stdout",
748 Usage: "snippet file get <id> <filename> > file", ReadOnly: true, Run: runSnippetFileGet})
749 register(Command{Path: []string{"snippet", "file", "remove"},
750 Summary: "remove a file from a snippet",
751 Usage: "snippet file remove <id> <filename>", Run: runSnippetFileRemove})
752}
753
754type SnippetFileOut struct {
755 Name string `json:"name"`
756 Size int64 `json:"size"`
757 Content string `json:"content,omitempty"`
758}
759
760type SnippetOut struct {
761 ID string `json:"id"`
762 URL string `json:"url"`
763 Owner string `json:"owner"`
764 Description string `json:"description"`
765 Visibility string `json:"visibility"`
766 CreatedAt string `json:"created_at"`
767 UpdatedAt string `json:"updated_at"`
768 Files []SnippetFileOut `json:"files"`
769}
770
771func snippetURL(c *Ctx, sn store.Snippet) string {
772 return c.Cfg.Server.SiteURL + "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
773}
774
775func snippetOut(c *Ctx, sn store.Snippet) SnippetOut {
776 o := SnippetOut{ID: sn.PublicID, URL: snippetURL(c, sn), Owner: sn.OwnerName,
777 Description: sn.Description, Visibility: sn.Visibility,
778 CreatedAt: sn.CreatedAt, UpdatedAt: sn.UpdatedAt, Files: []SnippetFileOut{}}
779 for _, f := range sn.Files {
780 o.Files = append(o.Files, SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)})
781 }
782 return o
783}
784
785func validSnippetVisibility(v string) bool {
786 return v == "public" || v == "unlisted" || v == "private"
787}
788
789// snippetRef loads a snippet the caller may read; with write, one they
790// may change. Unreadable and missing are the same not-found, so a
791// private id cannot be confirmed by probing.
792func snippetRef(c *Ctx, id string, write bool) (store.Snippet, int) {
793 sn, err := c.Store.SnippetByPublicID(id)
794 if err != nil && !errors.Is(err, store.ErrNotFound) {
795 return sn, c.fail(protocol.ExitFailure, "%v", err)
796 }
797 if err != nil || !policy.CanReadSnippet(c.User, sn) {
798 return sn, c.fail(protocol.ExitNotFound, "no snippet %q", id)
799 }
800 if write && !policy.CanWriteSnippet(c.User, sn) {
801 return sn, c.fail(protocol.ExitDenied, "snippet %s belongs to %s", id, sn.OwnerName)
802 }
803 return sn, -1
804}
805
806// readSnippetBody reads one file from stdin under the limit, and insists
807// on text: the page highlights it and the raw route serves text/plain.
808func readSnippetBody(c *Ctx) ([]byte, int) {
809 limit := c.Cfg.Limits.MaxSnippetBytes
810 data, err := io.ReadAll(io.LimitReader(c.Stdin, limit+1))
811 if err != nil {
812 return nil, c.fail(protocol.ExitFailure, "reading stdin: %v", err)
813 }
814 if int64(len(data)) > limit {
815 return nil, c.fail(protocol.ExitUsage, "file exceeds max_snippet_bytes (%d)", limit)
816 }
817 if len(data) == 0 {
818 return nil, c.fail(protocol.ExitUsage, "empty file: pipe it on stdin")
819 }
820 if !utf8.Valid(data) {
821 return nil, c.fail(protocol.ExitUsage, "snippets hold text: the file is not valid UTF-8")
822 }
823 return data, -1
824}
825
826func checkSnippetFileName(c *Ctx, name string) int {
827 if !assetNamePat.MatchString(name) {
828 return c.fail(protocol.ExitUsage, "invalid file name %q: letters, digits, '._+-'; must not start with '.'", name)
829 }
830 return -1
831}
832
833func newSnippetID() string {
834 buf := make([]byte, 6)
835 rand.Read(buf)
836 return hex.EncodeToString(buf)
837}
838
839func runSnippetCreate(c *Ctx, args []string) int {
840 const usage = "usage: snippet create <filename> [--description <d>] [--visibility public|unlisted|private] < file"
841 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: usage})
842 if err != nil {
843 return c.fail(protocol.ExitUsage, "%v", err)
844 }
845 name := f.pos(0)
846 if name == "" {
847 return c.fail(protocol.ExitUsage, usage)
848 }
849 if code := checkSnippetFileName(c, name); code >= 0 {
850 return code
851 }
852 visibility := f.Value("--visibility")
853 if visibility == "" {
854 visibility = "unlisted"
855 }
856 if !validSnippetVisibility(visibility) {
857 return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
858 }
859 data, code := readSnippetBody(c)
860 if code >= 0 {
861 return code
862 }
863 var pid string
864 for try := 0; ; try++ {
865 pid = newSnippetID()
866 _, err = c.Store.CreateSnippet(c.User.ID, pid, f.Value("--description"), visibility, name, data)
867 if !errors.Is(err, store.ErrExists) || try == 4 {
868 break
869 }
870 }
871 if err != nil {
872 return c.failErr(err)
873 }
874 sn, err := c.Store.SnippetByPublicID(pid)
875 if err != nil {
876 return c.fail(protocol.ExitFailure, "%v", err)
877 }
878 return c.emit(snippetOut(c, sn), func(w io.Writer) {
879 fmt.Fprintf(w, "created snippet %s\n%s\n", sn.PublicID, snippetURL(c, sn))
880 })
881}
882
883func runSnippetShow(c *Ctx, args []string) int {
884 if len(args) != 1 {
885 return c.fail(protocol.ExitUsage, "usage: snippet show <id>")
886 }
887 sn, code := snippetRef(c, args[0], false)
888 if code >= 0 {
889 return code
890 }
891 files, err := c.Store.SnippetFiles(sn.ID)
892 if err != nil {
893 return c.fail(protocol.ExitFailure, "%v", err)
894 }
895 sn.Files = files
896 return c.emit(snippetOut(c, sn), func(w io.Writer) {
897 fmt.Fprintf(w, "snippet %s by %s (%s)\n", sn.PublicID, sn.OwnerName, sn.Visibility)
898 if sn.Description != "" {
899 fmt.Fprintf(w, "%s\n", sn.Description)
900 }
901 fmt.Fprintf(w, "%s\nupdated %s\n", snippetURL(c, sn), sn.UpdatedAt)
902 for _, f := range files {
903 fmt.Fprintf(w, " %s\t%d bytes\n", f.Name, f.Size)
904 }
905 })
906}
907
908func runSnippetList(c *Ctx, args []string) int {
909 rest, p, code := parsePageFlags(c, args, "snippet", true)
910 if code >= 0 {
911 return code
912 }
913 if len(rest) > 1 {
914 return c.fail(protocol.ExitUsage, "usage: snippet list [<owner>] [--limit n] [--cursor c]")
915 }
916 owner := c.User
917 if len(rest) == 1 {
918 u, err := c.Store.UserByUsername(rest[0])
919 if errors.Is(err, store.ErrNotFound) {
920 return c.fail(protocol.ExitNotFound, "no user %q", rest[0])
921 }
922 if err != nil {
923 return c.fail(protocol.ExitFailure, "%v", err)
924 }
925 owner = u
926 }
927 all := owner.ID == c.User.ID || c.User.IsAdmin
928 rows, err := c.Store.ListSnippets(owner.ID, all, p.queryLimit(), p.keyInt())
929 if err != nil {
930 return c.fail(protocol.ExitFailure, "%v", err)
931 }
932 rows, next := trimPage(p, rows, "snippet", func(sn store.Snippet) string { return strconv.FormatInt(sn.ID, 10) })
933 items := make([]SnippetOut, 0, len(rows))
934 for _, sn := range rows {
935 items = append(items, snippetOut(c, sn))
936 }
937 return c.emitPage(p, items, next, func(w io.Writer) {
938 for _, sn := range rows {
939 names := ""
940 for i, f := range sn.Files {
941 if i > 0 {
942 names += ", "
943 }
944 names += f.Name
945 }
946 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", sn.PublicID, sn.Visibility, names, sn.Description)
947 }
948 })
949}
950
951func runSnippetEdit(c *Ctx, args []string) int {
952 const usage = "usage: snippet edit <id> [--description <d>] [--visibility public|unlisted|private]"
953 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: usage})
954 if err != nil {
955 return c.fail(protocol.ExitUsage, "%v", err)
956 }
957 if f.pos(0) == "" || (!f.Has("--description") && !f.Has("--visibility")) {
958 return c.fail(protocol.ExitUsage, usage)
959 }
960 sn, code := snippetRef(c, f.pos(0), true)
961 if code >= 0 {
962 return code
963 }
964 description, visibility := sn.Description, sn.Visibility
965 if f.Has("--description") {
966 description = f.Value("--description")
967 }
968 if f.Has("--visibility") {
969 visibility = f.Value("--visibility")
970 if !validSnippetVisibility(visibility) {
971 return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
972 }
973 }
974 if err := c.Store.UpdateSnippet(sn.ID, description, visibility); err != nil {
975 return c.failErr(err)
976 }
977 sn, err = c.Store.SnippetByPublicID(sn.PublicID)
978 if err != nil {
979 return c.fail(protocol.ExitFailure, "%v", err)
980 }
981 return c.emit(snippetOut(c, sn), func(w io.Writer) {
982 fmt.Fprintf(w, "updated snippet %s (%s)\n", sn.PublicID, sn.Visibility)
983 })
984}
985
986func runSnippetDelete(c *Ctx, args []string) int {
987 if len(args) != 1 {
988 return c.fail(protocol.ExitUsage, "usage: snippet delete <id>")
989 }
990 sn, code := snippetRef(c, args[0], true)
991 if code >= 0 {
992 return code
993 }
994 if err := c.Store.DeleteSnippet(sn.ID); err != nil {
995 return c.failErr(err)
996 }
997 return c.emit(map[string]string{"id": sn.PublicID}, func(w io.Writer) {
998 fmt.Fprintf(w, "deleted snippet %s\n", sn.PublicID)
999 })
1000}
1001
1002func runSnippetFileSet(c *Ctx, args []string) int {
1003 if len(args) != 2 {
1004 return c.fail(protocol.ExitUsage, "usage: snippet file set <id> <filename> < file")
1005 }
1006 sn, code := snippetRef(c, args[0], true)
1007 if code >= 0 {
1008 return code
1009 }
1010 name := args[1]
1011 if code := checkSnippetFileName(c, name); code >= 0 {
1012 return code
1013 }
1014 exists := false
1015 for _, f := range sn.Files {
1016 exists = exists || f.Name == name
1017 }
1018 if !exists && len(sn.Files) >= maxSnippetFiles {
1019 return c.fail(protocol.ExitUsage, "a snippet holds at most %d files", maxSnippetFiles)
1020 }
1021 data, code := readSnippetBody(c)
1022 if code >= 0 {
1023 return code
1024 }
1025 if err := c.Store.SetSnippetFile(sn.ID, name, data); err != nil {
1026 return c.failErr(err)
1027 }
1028 return c.emit(SnippetFileOut{Name: name, Size: int64(len(data))}, func(w io.Writer) {
1029 fmt.Fprintf(w, "set %s (%d bytes) on snippet %s\n", name, len(data), sn.PublicID)
1030 })
1031}
1032
1033func runSnippetFileGet(c *Ctx, args []string) int {
1034 if len(args) != 2 {
1035 return c.fail(protocol.ExitUsage, "usage: snippet file get <id> <filename> > file")
1036 }
1037 sn, code := snippetRef(c, args[0], false)
1038 if code >= 0 {
1039 return code
1040 }
1041 f, err := c.Store.SnippetFile(sn.ID, args[1])
1042 if errors.Is(err, store.ErrNotFound) {
1043 return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
1044 }
1045 if err != nil {
1046 return c.fail(protocol.ExitFailure, "%v", err)
1047 }
1048 if c.JSON {
1049 return c.emit(SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)}, nil)
1050 }
1051 if _, err := c.Stdout.Write(f.Content); err != nil {
1052 return protocol.ExitFailure
1053 }
1054 return protocol.ExitOK
1055}
1056
1057func runSnippetFileRemove(c *Ctx, args []string) int {
1058 if len(args) != 2 {
1059 return c.fail(protocol.ExitUsage, "usage: snippet file remove <id> <filename>")
1060 }
1061 sn, code := snippetRef(c, args[0], true)
1062 if code >= 0 {
1063 return code
1064 }
1065 if len(sn.Files) == 1 && sn.Files[0].Name == args[1] {
1066 return c.fail(protocol.ExitUsage, "a snippet keeps at least one file; delete the snippet instead")
1067 }
1068 err := c.Store.RemoveSnippetFile(sn.ID, args[1])
1069 if errors.Is(err, store.ErrNotFound) {
1070 return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
1071 }
1072 if err != nil {
1073 return c.failErr(err)
1074 }
1075 return c.emit(map[string]string{"id": sn.PublicID, "name": args[1]}, func(w io.Writer) {
1076 fmt.Fprintf(w, "removed %s from snippet %s\n", args[1], sn.PublicID)
1077 })
1078}
1079```
1080
1081One note for the implementer: `c.emit(..., nil)` in `runSnippetFileGet` is only reached under `c.JSON`, where `emit` never calls the plain function; keep the `if c.JSON` guard.
1082
1083- [ ] **Step 6: CLI rows**
1084
1085In `cmd/gitbay/main.go`, directly after the `group("wiki", ...)` entry (around line 77), add:
1086
1087```go
1088 group("snippet", "shared text files, outside any repository",
1089 pass("create", "create from one file on stdin: <filename> [--description d] [--visibility public|unlisted|private] < file",
1090 passOpts{server: []string{"snippet", "create"}, alwaysStdin: true, stdinWhat: "the file's text"}),
1091 pass("show", "metadata and files: <id>", passOpts{server: []string{"snippet", "show"}}),
1092 pass("list", "your snippets, or an owner's public ones: [<owner>] [--limit n] [--cursor c]",
1093 passOpts{server: []string{"snippet", "list"}}),
1094 pass("edit", "change description or visibility: <id> [--description d] [--visibility v]",
1095 passOpts{server: []string{"snippet", "edit"}}),
1096 pass("delete", "delete a snippet: <id>", passOpts{server: []string{"snippet", "delete"}}),
1097 group("file", "the files in a snippet",
1098 pass("set", "add or replace a file from stdin: <id> <filename> < file",
1099 passOpts{server: []string{"snippet", "file", "set"}, alwaysStdin: true, stdinWhat: "the file's text"}),
1100 pass("get", "print a file: <id> <filename> > file", passOpts{server: []string{"snippet", "file", "get"}}),
1101 pass("remove", "remove a file: <id> <filename>", passOpts{server: []string{"snippet", "file", "remove"}}),
1102 ),
1103 ),
1104```
1105
1106- [ ] **Step 7: Read-only coverage entries**
1107
1108In `e2e/readonly_test.go`, after the line `must("data\n", "release", "asset", "add", "alice/app", "v1", "a.txt")` add:
1109
1110```go
1111 snippetOut := must("hello\n", "snippet", "create", "a.txt", "--json")
1112 snippetID := regexp.MustCompile(`"id":"([0-9a-f]{12})"`).FindStringSubmatch(snippetOut)[1]
1113```
1114
1115Add `"regexp"` to the file's imports if it is not there. In the `readArgs` map add, beside the `release` rows:
1116
1117```go
1118 "snippet show": {snippetID},
1119 "snippet list": {},
1120 "snippet file get": {snippetID, "a.txt"},
1121```
1122
1123- [ ] **Step 8: Build, vet, run the tests**
1124
1125Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/policy ./internal/config && go test ./e2e -run 'TestSnippets$|TestCLI$|TestReadOnlyCommandsWriteNothing$'`
1126Expected: all PASS. `TestCLI` proves every `snippet` command has a CLI row with a `stdinWhat`; `TestReadOnlyCommandsWriteNothing` proves the three reads write nothing.
1127
1128If `TestSnippets` fails on the paged `next` cursor, check that `parsePageFlags` was called with `numeric=true` and that `trimPage` keys on `sn.ID`, not `sn.PublicID`.
1129
1130- [ ] **Step 9: Commit**
1131
1132```bash
1133git add internal/policy/snippets.go internal/config/config.go internal/control/snippet.go cmd/gitbay/main.go e2e/readonly_test.go e2e/snippet_test.go
1134git commit -m "control: snippet commands
1135
1136create, show, list, edit, delete, and file set|get|remove. Content is
1137UTF-8 under limits.max_snippet_bytes per file, 64 files per snippet.
1138Private snippets are not-found to everyone but the owner and admins.
1139
1140Ref #195"
1141```
1142
1143---
1144
1145### Task 3: Web read pages and the owner-page link
1146
1147**Files:**
1148- Create: `internal/httpd/snippets.go`
1149- Create: `internal/web/templates/snippets.html`
1150- Create: `internal/web/templates/snippet.html`
1151- Modify: `internal/httpd/routes.go:74-75` (beside the `/{owner}/-/labels` routes)
1152- Modify: `internal/control/profile.go:165-185` (`ProfileOut`) and the `profile show` body near line 296
1153- Modify: `internal/httpd/web.go:436-466` (the `owner.html` page struct and its literal)
1154- Modify: `internal/web/templates/owner.html:21-25`
1155- Test: `e2e/snippetweb_test.go`
1156
1157**Interfaces:**
1158- Consumes `store.SnippetByPublicID`, `store.SnippetFiles`, `store.SnippetFile`, `store.ListSnippets`, `store.CountSnippets`, `policy.CanReadSnippet`, `policy.CanWriteSnippet`, `highlight(path string, data []byte) template.HTML` in `internal/httpd/web.go`.
1159- Produces `ProfileOut.Snippets int` (`json:"snippets"`): the owner's snippets the caller may list (public, or all for the owner and admins). Produces the handlers `snippetsPage`, `snippetPage`, `snippetRaw` and the helper `snippetScope`, which Task 4's write handlers reuse.
1160
1161- [ ] **Step 1: Write the failing e2e test (read half)**
1162
1163Create `e2e/snippetweb_test.go`:
1164
1165```go
1166package e2e
1167
1168import (
1169 "encoding/json"
1170 "net/http"
1171 "net/url"
1172 "strings"
1173 "testing"
1174)
1175
1176func snippetIDFrom(t *testing.T, out string) string {
1177 t.Helper()
1178 var env struct {
1179 Data struct {
1180 ID string `json:"id"`
1181 } `json:"data"`
1182 }
1183 if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.ID == "" {
1184 t.Fatalf("snippet create: %s", out)
1185 }
1186 return env.Data.ID
1187}
1188
1189// Snippet pages: the owner's list, one snippet with highlighted files, the
1190// raw route, the owner-page link, and 404 for what the viewer may not see.
1191func TestSnippetsWeb(t *testing.T) {
1192 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
1193 aliceKey := inst.newKey(t, "alice")
1194 bobKey := inst.newKey(t, "bob")
1195 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
1196 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
1197 must := func(key, stdin string, args ...string) string {
1198 t.Helper()
1199 out, errOut, code := inst.ssh(t, key, stdin, args...)
1200 if code != 0 {
1201 t.Fatalf("%v: exit %d %s", args, code, errOut)
1202 }
1203 return out
1204 }
1205 public := snippetIDFrom(t, must(aliceKey, "package main\n", "snippet", "create", "main.go", "--visibility", "public", "--description", "'hello world'", "--json"))
1206 unlisted := snippetIDFrom(t, must(aliceKey, "quiet\n", "snippet", "create", "q.txt", "--json"))
1207 private := snippetIDFrom(t, must(aliceKey, "secret\n", "snippet", "create", "s.txt", "--visibility", "private", "--json"))
1208
1209 // Anonymous: the public list, the unlisted page by URL, 404 for private.
1210 status, body := inst.get(t, "/alice/-/snippets")
1211 if status != 200 || !strings.Contains(body, public) || strings.Contains(body, unlisted) || strings.Contains(body, private) {
1212 t.Fatalf("anonymous list: %d\n%s", status, body)
1213 }
1214 status, body = inst.get(t, "/alice/-/snippets/"+public)
1215 if status != 200 || !strings.Contains(body, "hello world") || !strings.Contains(body, `class="chroma"`) || !strings.Contains(body, "/raw/main.go") {
1216 t.Fatalf("public page: %d\n%s", status, body)
1217 }
1218 if status, _ := inst.get(t, "/alice/-/snippets/"+unlisted); status != 200 {
1219 t.Fatalf("unlisted page: %d", status)
1220 }
1221 if status, _ := inst.get(t, "/alice/-/snippets/"+private); status != 404 {
1222 t.Fatalf("private page for anonymous: %d", status)
1223 }
1224 if status, _ := inst.get(t, "/bob/-/snippets/"+public); status != 404 {
1225 t.Fatalf("id under the wrong owner: %d", status)
1226 }
1227 if status, _ := inst.get(t, "/nobody/-/snippets"); status != 404 {
1228 t.Fatalf("list for a missing owner: %d", status)
1229 }
1230
1231 // Raw is text/plain with nosniff, whatever the extension.
1232 resp, err := http.Get(inst.base() + "/alice/-/snippets/" + public + "/raw/main.go")
1233 if err != nil {
1234 t.Fatal(err)
1235 }
1236 resp.Body.Close()
1237 if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") || resp.Header.Get("X-Content-Type-Options") != "nosniff" {
1238 t.Fatalf("raw headers: %d %v", resp.StatusCode, resp.Header)
1239 }
1240 if status, _ := inst.get(t, "/alice/-/snippets/" + public + "/raw/other.go"); status != 404 {
1241 t.Fatalf("raw for a missing file: %d", status)
1242 }
1243
1244 // The owner sees everything with visibility marks; the owner page links.
1245 alice := inst.login(t, aliceKey)
1246 status, body = browserGet(t, alice, inst.base()+"/alice/-/snippets")
1247 if status != 200 || !strings.Contains(body, private) || !strings.Contains(body, ">private<") {
1248 t.Fatalf("owner list: %d\n%s", status, body)
1249 }
1250 if status, body := browserGet(t, alice, inst.base()+"/alice/-/snippets/"+private); status != 200 || !strings.Contains(body, "secret") {
1251 t.Fatalf("owner's private page: %d", status)
1252 }
1253 if status, body := inst.get(t, "/alice"); status != 200 || !strings.Contains(body, `href="/alice/-/snippets"`) {
1254 t.Fatalf("owner page lacks the snippets link: %d", status)
1255 }
1256 // bob has no public snippets and is not the viewer: no link.
1257 if status, body := inst.get(t, "/bob"); status != 200 || strings.Contains(body, `href="/bob/-/snippets"`) {
1258 t.Fatalf("bob's page shows a snippets link with nothing to list: %d", status)
1259 }
1260
1261 _ = url.Values{}
1262 _ = bobKey
1263}
1264```
1265
1266The last two lines keep the imports and `bobKey` used until Task 4 extends the test; Task 4 removes them.
1267
1268- [ ] **Step 2: Run it to see it fail**
1269
1270Run: `go test ./e2e -run 'TestSnippetsWeb$'`
1271Expected: FAIL at "anonymous list", status 404.
1272
1273- [ ] **Step 3: Profile count**
1274
1275In `internal/control/profile.go`, add to `ProfileOut` after `Repos`:
1276
1277```go
1278 // Snippets counts the owner's snippets the caller may list: public
1279 // ones, or all of them for the owner and admins. Orgs own none.
1280 Snippets int `json:"snippets"`
1281```
1282
1283In the `profile show` body, after the loop that fills `d.Repos` and before the activity counts, add:
1284
1285```go
1286 if kind == "user" {
1287 all := id == c.User.ID || c.User.IsAdmin
1288 if d.Snippets, err = c.Store.CountSnippets(id, all); err != nil {
1289 return c.fail(protocol.ExitFailure, "%v", err)
1290 }
1291 }
1292```
1293
1294(`kind` and `id` are the variables the surrounding code already uses for the owner's kind and row id; read the function and use its names.)
1295
1296- [ ] **Step 4: Routes**
1297
1298In `internal/httpd/routes.go`, after the `/{owner}/-/milestones` route add:
1299
1300```go
1301 Route{Method: "GET", Pattern: "/{owner}/-/snippets", Handler: s.snippetsPage},
1302 Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}", Handler: s.snippetPage},
1303 Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}/raw/{name}", Handler: s.snippetRaw},
1304```
1305
1306These are read routes, registered in every web mode; the literal `-` and `snippets` segments keep them from overlapping any `/{owner}/{repo}/...` pattern.
1307
1308- [ ] **Step 5: Handlers**
1309
1310Create `internal/httpd/snippets.go`:
1311
1312```go
1313package httpd
1314
1315import (
1316 "bytes"
1317 "html/template"
1318 "net/http"
1319
1320 "gitbay.org/gitbay/internal/policy"
1321 "gitbay.org/gitbay/internal/store"
1322)
1323
1324// snippetScope resolves the owner and id in the URL for the viewer. A
1325// missing owner, an id under another owner, and a private snippet the
1326// viewer may not read are all the same 404.
1327func (s *Server) snippetScope(w http.ResponseWriter, r *http.Request) (store.Snippet, store.User, bool) {
1328 viewer := s.viewer(r)
1329 sn, err := s.st.SnippetByPublicID(r.PathValue("id"))
1330 if err != nil || sn.OwnerName != r.PathValue("owner") || !policy.CanReadSnippet(viewer, sn) {
1331 s.notFound(w, r)
1332 return sn, viewer, false
1333 }
1334 return sn, viewer, true
1335}
1336
1337type snippetRow struct {
1338 store.Snippet
1339 Names string
1340}
1341
1342func (s *Server) snippetsPage(w http.ResponseWriter, r *http.Request) {
1343 viewer := s.viewer(r)
1344 owner, err := s.st.UserByUsername(r.PathValue("owner"))
1345 if err != nil {
1346 s.notFound(w, r)
1347 return
1348 }
1349 self := viewer.ID != 0 && viewer.ID == owner.ID
1350 all := self || viewer.IsAdmin
1351 list, err := s.st.ListSnippets(owner.ID, all, 0, 0)
1352 if err != nil {
1353 http.Error(w, "internal error", http.StatusInternalServerError)
1354 return
1355 }
1356 rows := make([]snippetRow, 0, len(list))
1357 for _, sn := range list {
1358 var names bytes.Buffer
1359 for i, f := range sn.Files {
1360 if i > 0 {
1361 names.WriteString(", ")
1362 }
1363 names.WriteString(f.Name)
1364 }
1365 rows = append(rows, snippetRow{sn, names.String()})
1366 }
1367 s.render(w, "snippets.html", struct {
1368 basePage
1369 Owner string
1370 Self bool
1371 All bool
1372 Snippets []snippetRow
1373 Notice string
1374 }{s.baseFor(viewer), owner.Username, self, all, rows, s.takeFlash(w, r)})
1375}
1376
1377type snippetFileView struct {
1378 Name string
1379 Size int64
1380 Lines int
1381 Content string
1382 HTML template.HTML
1383}
1384
1385func (s *Server) snippetPage(w http.ResponseWriter, r *http.Request) {
1386 sn, viewer, ok := s.snippetScope(w, r)
1387 if !ok {
1388 return
1389 }
1390 files, err := s.st.SnippetFiles(sn.ID)
1391 if err != nil {
1392 http.Error(w, "internal error", http.StatusInternalServerError)
1393 return
1394 }
1395 views := make([]snippetFileView, 0, len(files))
1396 for _, f := range files {
1397 lines := bytes.Count(f.Content, []byte("\n"))
1398 if len(f.Content) > 0 && f.Content[len(f.Content)-1] != '\n' {
1399 lines++
1400 }
1401 views = append(views, snippetFileView{f.Name, f.Size, lines, string(f.Content), highlight(f.Name, f.Content)})
1402 }
1403 s.render(w, "snippet.html", struct {
1404 basePage
1405 Owner string
1406 Snippet store.Snippet
1407 Files []snippetFileView
1408 CanWrite bool
1409 Notice string
1410 }{s.baseFor(viewer), sn.OwnerName, sn, views, policy.CanWriteSnippet(viewer, sn), s.takeFlash(w, r)})
1411}
1412
1413// snippetRaw serves one file as text, inert on the forge's origin.
1414func (s *Server) snippetRaw(w http.ResponseWriter, r *http.Request) {
1415 sn, _, ok := s.snippetScope(w, r)
1416 if !ok {
1417 return
1418 }
1419 f, err := s.st.SnippetFile(sn.ID, r.PathValue("name"))
1420 if err != nil {
1421 s.notFound(w, r)
1422 return
1423 }
1424 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
1425 w.Header().Set("X-Content-Type-Options", "nosniff")
1426 w.Write(f.Content)
1427}
1428```
1429
1430`s.viewer` reads the session cookie; in `view_only` mode there is never one, so the viewer is anonymous there without a mode check.
1431
1432- [ ] **Step 6: Templates**
1433
1434Create `internal/web/templates/snippets.html`:
1435
1436```html
1437{{define "title"}}snippets · {{.Owner}}{{end}}
1438{{define "content"}}
1439<h1><a href="/{{.Owner}}">{{.Owner}}</a> snippets</h1>
1440{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
1441{{if .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets/new">new snippet</a> · or <code>gitbay snippet create &lt;file&gt; &lt; file</code></p>{{end}}
1442{{if .Snippets}}<div class="tablewrap"><table class="keys">
1443<tr class="cols"><th scope="col">snippet</th><th scope="col">files</th>{{if .All}}<th scope="col">visibility</th>{{end}}<th scope="col">updated</th></tr>
1444{{range .Snippets}}<tr>
1445 <td><a href="/{{$.Owner}}/-/snippets/{{.PublicID}}">{{if .Description}}{{.Description}}{{else}}{{.PublicID}}{{end}}</a></td>
1446 <td><span class="mono">{{.Names}}</span></td>
1447 {{if $.All}}<td><span class="chip chip-neutral">{{.Visibility}}</span></td>{{end}}
1448 <td>{{.UpdatedAt}}</td>
1449</tr>
1450{{end}}</table></div>
1451{{else}}<p class="none">No snippets yet.</p>{{end}}
1452{{end}}
1453```
1454
1455Create `internal/web/templates/snippet.html` (the write forms come in Task 4; leave the `{{if .CanWrite}}` block out for now):
1456
1457```html
1458{{define "title"}}{{if .Snippet.Description}}{{.Snippet.Description}}{{else}}{{.Snippet.PublicID}}{{end}} · {{.Owner}}{{end}}
1459{{define "content"}}
1460<h1><a href="/{{.Owner}}">{{.Owner}}</a> / <a href="/{{.Owner}}/-/snippets">snippets</a> / {{.Snippet.PublicID}}</h1>
1461{{if .Snippet.Description}}<p class="desc lede">{{.Snippet.Description}}</p>{{end}}
1462<p class="meta"><span class="chip chip-neutral">{{.Snippet.Visibility}}</span> · updated {{.Snippet.UpdatedAt}} · <code>gitbay snippet show {{.Snippet.PublicID}}</code></p>
1463{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
1464{{range .Files}}
1465<section class="snippetfile" id="file-{{.Name}}">
1466<div class="pathbar">
1467 <span class="crumbs"><strong>{{.Name}}</strong></span>
1468 <span class="spacer"></span>
1469 <span class="actions"><a href="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/raw/{{.Name}}">raw</a></span>
1470</div>
1471<p class="filefacts">{{.Lines}} lines · {{.Size}} bytes</p>
1472<div class="code">{{.HTML}}</div>
1473</section>
1474{{end}}
1475{{end}}
1476```
1477
1478- [ ] **Step 7: Owner page link**
1479
1480In `internal/httpd/web.go`, in the `owner.html` page struct add `Snippets int` after `Self bool`, and in the literal pass `d.Snippets` after the `Self` expression (the `d.Kind == "user" && ...` line). In `internal/web/templates/owner.html`, after the `</ul>` that closes the repository list, add:
1481
1482```html
1483{{if or .Snippets .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets">snippets{{if .Snippets}} <span class="count">{{.Snippets}}</span>{{end}}</a></p>{{end}}
1484```
1485
1486- [ ] **Step 8: Build and run the tests**
1487
1488Run: `go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestSnippetsWeb$'`
1489Expected: PASS. `internal/httpd`'s template tests check the new templates for unlabeled inputs (none yet) and route/reserved-name agreement (nothing new at the top level).
1490
1491- [ ] **Step 9: Commit**
1492
1493```bash
1494git add internal/httpd/snippets.go internal/httpd/routes.go internal/httpd/web.go internal/control/profile.go internal/web/templates/snippets.html internal/web/templates/snippet.html internal/web/templates/owner.html e2e/snippetweb_test.go
1495git commit -m "web: snippet pages
1496
1497The owner's list, one snippet with highlighted files, and a raw route
1498under /{owner}/-/snippets. The owner page links when there is
1499something to list.
1500
1501Ref #195"
1502```
1503
1504---
1505
1506### Task 4: Web writes
1507
1508**Files:**
1509- Modify: `internal/httpd/snippets.go` (append the write handlers)
1510- Modify: `internal/httpd/routes.go` (the account-mode block, beside the `/bookmarks` routes)
1511- Modify: `internal/web/templates/snippet.html` (the `{{if .CanWrite}}` forms)
1512- Create: `internal/web/templates/snippetnew.html`
1513- Test: `e2e/snippetweb_test.go` (extend)
1514
1515**Interfaces:**
1516- Consumes `snippetScope`, `control.SnippetOut`, `s.dispatchIntoStdin`, `s.runControlCode`, `s.runControlStdinCode`, `s.done`, `s.setFlash`, `statusForExit`.
1517- Produces the five POST handlers and the GET form named in the constraints.
1518
1519- [ ] **Step 1: Extend the e2e test**
1520
1521In `e2e/snippetweb_test.go`, replace the two placeholder lines (`_ = url.Values{}` and `_ = bobKey`) with:
1522
1523```go
1524 // The create form makes a snippet through snippet create.
1525 status, body = browserPost(t, alice, inst.base()+"/alice/-/snippets/new", url.Values{
1526 "name": {"notes.md"}, "description": {"from the browser"}, "visibility": {"public"}, "content": {"# notes\n"}})
1527 if status != 200 || !strings.Contains(body, "from the browser") || !strings.Contains(body, "notes.md") {
1528 t.Fatalf("create form: %d\n%s", status, body)
1529 }
1530 var listed struct {
1531 Data []struct {
1532 ID string `json:"id"`
1533 Description string `json:"description"`
1534 } `json:"data"`
1535 }
1536 json.Unmarshal([]byte(must(aliceKey, "", "snippet", "list", "--json")), &listed)
1537 created := ""
1538 for _, sn := range listed.Data {
1539 if sn.Description == "from the browser" {
1540 created = sn.ID
1541 }
1542 }
1543 if created == "" {
1544 t.Fatalf("created from the web, not listed: %+v", listed.Data)
1545 }
1546 if status, _ := browserGet(t, alice, inst.base()+"/bob/-/snippets/new"); status != 404 {
1547 t.Fatalf("new form under another owner: %d", status)
1548 }
1549
1550 // The file form replaces a file and adds one; remove drops it.
1551 page := inst.base() + "/alice/-/snippets/" + created
1552 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"notes.md"}, "content": {"# changed\n"}}); status != 200 {
1553 t.Fatal("file replace failed")
1554 }
1555 if got := must(aliceKey, "", "snippet", "file", "get", created, "notes.md"); got != "# changed\n" {
1556 t.Fatalf("after web replace: %q", got)
1557 }
1558 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"b.txt"}, "content": {"b\n"}}); status != 200 {
1559 t.Fatal("file add failed")
1560 }
1561 if status, _ := browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}}); status != 200 {
1562 t.Fatal("file remove failed")
1563 }
1564 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 3 {
1565 t.Fatalf("b.txt after web remove: exit %d", code)
1566 }
1567 // A refusal comes back on the page as a message, not a bare error.
1568 _, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"notes.md"}})
1569 if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "at least one file") {
1570 t.Fatalf("last-file refusal on the page:\n%s", body)
1571 }
1572
1573 // Edit changes visibility; delete removes.
1574 if status, _ := browserPost(t, alice, page+"/edit", url.Values{"description": {"renamed"}, "visibility": {"private"}}); status != 200 {
1575 t.Fatal("edit failed")
1576 }
1577 if status, _ := inst.get(t, "/alice/-/snippets/"+created); status != 404 {
1578 t.Fatalf("private after web edit, anonymous: %d", status)
1579 }
1580 // bob cannot write alice's snippet from the browser either.
1581 bob := inst.login(t, bobKey)
1582 if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+public+"/edit", url.Values{"description": {"x"}, "visibility": {"public"}}); status != 403 {
1583 t.Fatalf("bob editing alice's snippet: %d", status)
1584 }
1585 if status, _ := browserPost(t, alice, page+"/delete", nil); status != 200 {
1586 t.Fatal("delete failed")
1587 }
1588 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 3 {
1589 t.Fatalf("after web delete: exit %d", code)
1590 }
1591```
1592
1593`browserPost` follows redirects, so a successful form lands on the page it redirects to with status 200 and the page's body. The id of the snippet the form made comes from `snippet list --json`, matched by its description.
1594
1595- [ ] **Step 2: Run it to see it fail**
1596
1597Run: `go test ./e2e -run 'TestSnippetsWeb$'`
1598Expected: FAIL at "create form" with 404 or 405.
1599
1600- [ ] **Step 3: Routes**
1601
1602In `internal/httpd/routes.go`, inside the `web.mode == "accounts"` block, after the `/bookmarks` GET route add:
1603
1604```go
1605 Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)},
1606 Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true,
1607 Handler: s.checkOrigin(s.requireUser(s.snippetNewSubmit))},
1608 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/edit", Mutating: true,
1609 Handler: s.checkOrigin(s.requireUser(s.snippetEditSubmit))},
1610 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/delete", Mutating: true,
1611 Handler: s.checkOrigin(s.requireUser(s.snippetDeleteSubmit))},
1612 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/file", Mutating: true,
1613 Handler: s.checkOrigin(s.requireUser(s.snippetFileSubmit))},
1614 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/file/remove", Mutating: true,
1615 Handler: s.checkOrigin(s.requireUser(s.snippetFileRemoveSubmit))},
1616```
1617
1618`GET /{owner}/-/snippets/new` and `GET /{owner}/-/snippets/{id}` both match `/x/-/snippets/new`; the literal segment is more specific, so the mux picks the form.
1619
1620- [ ] **Step 4: Handlers**
1621
1622Append to `internal/httpd/snippets.go` (add `"strings"`, `"gitbay.org/gitbay/internal/control"` and `"gitbay.org/gitbay/internal/protocol"` to its imports):
1623
1624```go
1625// snippetNewForm is the owner's own page only: the URL names the owner
1626// and a snippet cannot be created for someone else.
1627func (s *Server) snippetNewForm(w http.ResponseWriter, r *http.Request, u store.User) {
1628 if r.PathValue("owner") != u.Username {
1629 s.notFound(w, r)
1630 return
1631 }
1632 s.render(w, "snippetnew.html", struct {
1633 basePage
1634 Owner string
1635 }{s.baseFor(u), u.Username})
1636}
1637
1638func (s *Server) snippetNewSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
1639 if r.PathValue("owner") != u.Username {
1640 s.notFound(w, r)
1641 return
1642 }
1643 argv := []string{"snippet", "create", strings.TrimSpace(r.FormValue("name")),
1644 "--description", strings.TrimSpace(r.FormValue("description")),
1645 "--visibility", r.FormValue("visibility")}
1646 var out control.SnippetOut
1647 code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("content"), &out)
1648 if code != protocol.ExitOK {
1649 http.Error(w, msg, statusForExit(code))
1650 return
1651 }
1652 http.Redirect(w, r, "/"+u.Username+"/-/snippets/"+out.ID, http.StatusSeeOther)
1653}
1654
1655// snippetAction runs a write on the snippet in the URL and returns to
1656// its page with the message, or to the list after a delete. A snippet
1657// the viewer may not read is the 404 page, as on every read.
1658func (s *Server) snippetAction(w http.ResponseWriter, r *http.Request, u store.User, argv []string, stdin string, dest string) {
1659 sn, _, ok := s.snippetScope(w, r)
1660 if !ok {
1661 return
1662 }
1663 if dest == "" {
1664 dest = "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
1665 }
1666 back := func(w http.ResponseWriter, r *http.Request, msg string) {
1667 s.setFlash(w, msg)
1668 http.Redirect(w, r, dest, http.StatusSeeOther)
1669 }
1670 var msg string
1671 var code int
1672 if stdin == "" {
1673 _, msg, code = s.runControlCode(u, argv)
1674 } else {
1675 msg, code = s.runControlStdinCode(u, argv, stdin)
1676 }
1677 if code == protocol.ExitDenied {
1678 http.Error(w, msg, http.StatusForbidden)
1679 return
1680 }
1681 s.done(w, r, code, msg, back)
1682}
1683
1684func (s *Server) snippetEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
1685 s.snippetAction(w, r, u, []string{"snippet", "edit", r.PathValue("id"),
1686 "--description", strings.TrimSpace(r.FormValue("description")),
1687 "--visibility", r.FormValue("visibility")}, "", "")
1688}
1689
1690func (s *Server) snippetDeleteSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
1691 s.snippetAction(w, r, u, []string{"snippet", "delete", r.PathValue("id")}, "",
1692 "/"+r.PathValue("owner")+"/-/snippets")
1693}
1694
1695// An empty textarea reaches the command as empty stdin, which it refuses;
1696// the message lands on the page like any other.
1697func (s *Server) snippetFileSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
1698 s.snippetAction(w, r, u, []string{"snippet", "file", "set", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))},
1699 r.FormValue("content"), "")
1700}
1701
1702func (s *Server) snippetFileRemoveSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
1703 s.snippetAction(w, r, u, []string{"snippet", "file", "remove", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))}, "", "")
1704}
1705```
1706
1707The denied branch answers 403 rather than a redirect because the viewer can read the page but not change it; the e2e test asserts it. Browsers send `\r\n` from a textarea; the command stores what it receives, which is what the raw route serves back. Do not normalise.
1708
1709- [ ] **Step 5: Templates**
1710
1711Create `internal/web/templates/snippetnew.html`:
1712
1713```html
1714{{define "title"}}new snippet · {{.Owner}}{{end}}
1715{{define "content"}}
1716<h1>New snippet</h1>
1717<form method="post" action="/{{.Owner}}/-/snippets/new" class="commentform">
1718<p><input type="text" name="name" aria-label="File name" placeholder="filename" required></p>
1719<p><input type="text" name="description" aria-label="Description" placeholder="description"></p>
1720<p><select name="visibility" aria-label="Visibility"><option value="unlisted">unlisted</option><option value="public">public</option><option value="private">private</option></select></p>
1721<p><textarea name="content" aria-label="Content" rows="16" required></textarea></p>
1722<p><button type="submit">Create snippet</button></p>
1723</form>
1724{{end}}
1725```
1726
1727In `internal/web/templates/snippet.html`, inside the `{{range .Files}}` section after `<div class="code">{{.HTML}}</div>`, add:
1728
1729```html
1730{{if $.CanWrite}}<details class="editbox"><summary>edit {{.Name}}</summary>
1731<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file" class="commentform">
1732<input type="hidden" name="name" value="{{.Name}}">
1733<p><textarea name="content" aria-label="Content of {{.Name}}" rows="12">{{.Content}}</textarea></p>
1734<p><button type="submit">Save</button></p>
1735</form>
1736<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file/remove">
1737<input type="hidden" name="name" value="{{.Name}}">
1738<p><button type="submit">Remove {{.Name}}</button></p>
1739</form>
1740</details>{{end}}
1741```
1742
1743and after the `{{end}}` that closes the range, before the final `{{end}}`:
1744
1745```html
1746{{if .CanWrite}}
1747<details class="editbox"><summary>add a file</summary>
1748<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/file" class="commentform">
1749<p><input type="text" name="name" aria-label="File name" placeholder="filename" required></p>
1750<p><textarea name="content" aria-label="Content" rows="12" required></textarea></p>
1751<p><button type="submit">Add file</button></p>
1752</form></details>
1753<details class="editbox"><summary>settings</summary>
1754<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/edit">
1755<p><input type="text" name="description" aria-label="Description" value="{{.Snippet.Description}}" placeholder="description"></p>
1756<p><select name="visibility" aria-label="Visibility">
1757<option value="public"{{if eq .Snippet.Visibility "public"}} selected{{end}}>public</option>
1758<option value="unlisted"{{if eq .Snippet.Visibility "unlisted"}} selected{{end}}>unlisted</option>
1759<option value="private"{{if eq .Snippet.Visibility "private"}} selected{{end}}>private</option>
1760</select></p>
1761<p><button type="submit">Save</button></p>
1762</form>
1763<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/delete">
1764<p><button type="submit">Delete snippet</button></p>
1765</form>
1766</details>
1767{{end}}
1768```
1769
1770If `.editbox` or `.commentform` render poorly beside `.code`, add a `.snippetfile { margin-bottom: 1.5rem }` rule to `internal/web/static/style.css`; nothing more.
1771
1772- [ ] **Step 6: Build and run the tests**
1773
1774Run: `go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestSnippetsWeb$'`
1775Expected: PASS. `internal/httpd` carries the structural checks: `TestMutatingRoutesRequireCheckOrigin` (every `Mutating` route is wrapped in `checkOrigin`), `TestViewOnlyHasNoMutatingRoutes` (the POST routes sit inside the accounts block), and the input-label test on `snippetnew.html` and the new forms.
1776
1777- [ ] **Step 7: Commit**
1778
1779```bash
1780git add internal/httpd/snippets.go internal/httpd/routes.go internal/web/templates/snippet.html internal/web/templates/snippetnew.html e2e/snippetweb_test.go
1781git commit -m "web: create, edit and delete snippets
1782
1783Every form dispatches the snippet command the CLI runs.
1784
1785Ref #195"
1786```
1787
1788---
1789
1790### Task 5: Documentation and changelog
1791
1792**Files:**
1793- Modify: `.gitbay/wiki/Users.org` (a `* Snippets` section after `* Pages`, before `* Browser sessions`)
1794- Modify: `.gitbay/wiki/Parity.org` (rows after `release asset remove`)
1795- Modify: `.gitbay/wiki/Admin.org:116` (the `[limits]` list)
1796- Modify: `CHANGELOG.org` (a new top entry)
1797
1798- [ ] **Step 1: Users.org**
1799
1800Insert before `* Browser sessions`:
1801
1802```org
1803* Snippets
1804
1805A snippet is one or more named text files you own outside any
1806repository, for a log or a fragment shared by URL. Create one from a
1807file on stdin; the reply is the id and the URL:
1808
1809#+begin_src sh
1810gitbay snippet create build.log --description "failing build" < build.log
1811gitbay snippet file set <id> notes.txt < notes.txt # add or replace a file
1812gitbay snippet file get <id> build.log > build.log
1813gitbay snippet edit <id> --visibility public
1814gitbay snippet list # yours
1815gitbay snippet list <owner> # their public ones
1816gitbay snippet delete <id>
1817#+end_src
1818
1819Visibility is =public= (listed on your page), =unlisted= (anyone with
1820the URL, listed nowhere; the default) or =private= (you alone; not
1821found to everyone else). Files are text, valid UTF-8, each under the
1822instance's =max_snippet_bytes=, at most 64 per snippet. A snippet keeps
1823at least one file. There is no history: setting a file replaces it.
1824
1825On the web, =/<you>/-/snippets= lists yours, each snippet page renders
1826its files with a raw link per file, and the same page creates, edits
1827and deletes through the commands above.
1828```
1829
1830- [ ] **Step 2: Parity.org**
1831
1832After the `release asset remove` row add:
1833
1834```org
1835| snippet create, edit, delete | yes | yes | no |
1836| snippet show, list | yes | yes | no |
1837| snippet file set, get, remove | yes | yes | no |
1838```
1839
1840Match the table's column alignment by hand; org tables tolerate ragged cells but the page is read raw too.
1841
1842- [ ] **Step 3: Admin.org**
1843
1844After the `max_asset_bytes` line in `** [limits]` add:
1845
1846```org
1847- =max_snippet_bytes= (1MB) — cap per snippet file.
1848```
1849
1850- [ ] **Step 4: CHANGELOG.org**
1851
1852Before `* v1.19.0 — 2026-09-11` add:
1853
1854```org
1855* v1.20.0 — unreleased
1856
1857Snippets (#195): named text files a user owns outside any repository,
1858shared by URL and edited in place.
1859
1860- Migration 0054: =snippets= and =snippet_files=.
1861- =snippet create|show|list|edit|delete= and =snippet file
1862 set|get|remove=. Files are UTF-8 under =limits.max_snippet_bytes=
1863 (1MB), at most 64 per snippet; a snippet keeps at least one.
1864 Visibility =public=, =unlisted= (default) or =private=; a private
1865 snippet is not found to everyone but its owner and admins.
1866- Web: =/<owner>/-/snippets= lists, each snippet page renders its
1867 files with a raw route per file, and the owner creates, edits and
1868 deletes from the page through the same commands. The owner page
1869 links to the list.
1870```
1871
1872- [ ] **Step 5: Commit**
1873
1874```bash
1875git add .gitbay/wiki/Users.org .gitbay/wiki/Parity.org .gitbay/wiki/Admin.org CHANGELOG.org
1876git commit -m "wiki, CHANGELOG: snippets
1877
1878Closes #195"
1879```
1880
1881---
1882
1883### Task 6: Merge request
1884
1885- [ ] **Step 1: Push and open the MR**
1886
1887```bash
1888git push -u origin snippets
1889gitbay mr create --source snippets --target main --title "Snippets (#195)" --file - <<'EOF'
1890Named text files a user owns outside any repository, shared by URL and
1891edited in place. Spec: docs/specs/2026-09-11-snippets-design.md.
1892
1893Migration 0054. Commands snippet create|show|list|edit|delete and
1894snippet file set|get|remove; web under /{owner}/-/snippets with forms
1895dispatching the same commands. New limit max_snippet_bytes (1MB).
1896
1897Closes #195
1898EOF
1899```
1900
1901- [ ] **Step 2: Wait for CI, then merge**
1902
1903Check `gitbay build list --json` until the build for the branch head succeeds; read `gitbay build log <n>` on failure and fix on the branch. Then:
1904
1905```bash
1906gitbay mr merge <n> --strategy ff
1907git push origin --delete snippets
1908```
1909
1910and remove the worktree and branch locally after the merge lands.
docs/specs/2026-09-11-snippets-design.md added +238
@@ -0,0 +1,238 @@
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.
e2e/readonly_test.go +6
@@ -7,6 +7,7 @@ import (
7 "fmt" 7 "fmt"
8 "os" 8 "os"
9 "path/filepath" 9 "path/filepath"
10 "regexp"
10 "strings" 11 "strings"
11 "testing" 12 "testing"
12 13
@@ -71,6 +72,8 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
71 must("", "status", "set", "alice/app", sha, "--context", "ci/x", "--state", "success") 72 must("", "status", "set", "alice/app", sha, "--context", "ci/x", "--state", "success")
72 must("", "release", "create", "alice/app", "v1", "--title", "first") 73 must("", "release", "create", "alice/app", "v1", "--title", "first")
73 must("data\n", "release", "asset", "add", "alice/app", "v1", "a.txt") 74 must("data\n", "release", "asset", "add", "alice/app", "v1", "a.txt")
75 snippetOut := must("hello\n", "snippet", "create", "a.txt", "--json")
76 snippetID := regexp.MustCompile(`"id":"([0-9a-f]{12})"`).FindStringSubmatch(snippetOut)[1]
74 must("", "org", "create", "theorg") 77 must("", "org", "create", "theorg")
75 must("", "org", "team", "create", "theorg", "core") 78 must("", "org", "team", "create", "theorg", "core")
76 must("", "token", "create", "--name", "t") 79 must("", "token", "create", "--name", "t")
@@ -146,6 +149,9 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
146 "release list": {"alice/app"}, 149 "release list": {"alice/app"},
147 "release show": {"alice/app", "v1"}, 150 "release show": {"alice/app", "v1"},
148 "release asset get": {"alice/app", "v1", "a.txt"}, 151 "release asset get": {"alice/app", "v1", "a.txt"},
152 "snippet show": {snippetID},
153 "snippet list": {},
154 "snippet file get": {snippetID, "a.txt"},
149 "notifications list": nil, 155 "notifications list": nil,
150 "notifications settings show": nil, 156 "notifications settings show": nil,
151 "repo bookmarks": nil, 157 "repo bookmarks": nil,
e2e/snippet_test.go added +142
@@ -0,0 +1,142 @@
1package e2e
2
3import (
4 "encoding/json"
5 "regexp"
6 "strings"
7 "testing"
8)
9
10// Snippets over SSH: create from stdin, read back, list by visibility,
11// edit files and metadata, and the not-found rule for private ones.
12func TestSnippets(t *testing.T) {
13 inst := startInstanceWith(t, "[limits]\nmax_snippets_per_user = 3\n")
14 aliceKey := inst.newKey(t, "alice")
15 bobKey := inst.newKey(t, "bob")
16 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
17 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
18 must := func(key, stdin string, args ...string) string {
19 t.Helper()
20 out, errOut, code := inst.ssh(t, key, stdin, args...)
21 if code != 0 {
22 t.Fatalf("%v: exit %d %s", args, code, errOut)
23 }
24 return out
25 }
26 fails := func(key, stdin string, want int, args ...string) string {
27 t.Helper()
28 _, errOut, code := inst.ssh(t, key, stdin, args...)
29 if code != want {
30 t.Fatalf("%v: exit %d, want %d: %s", args, code, want, errOut)
31 }
32 return errOut
33 }
34 idOf := func(out string) string {
35 t.Helper()
36 var env struct {
37 Data struct {
38 ID string `json:"id"`
39 URL string `json:"url"`
40 } `json:"data"`
41 }
42 if err := json.Unmarshal([]byte(out), &env); err != nil || !regexp.MustCompile(`^[0-9a-f]{12}$`).MatchString(env.Data.ID) {
43 t.Fatalf("create output: %s", out)
44 }
45 if !strings.HasSuffix(env.Data.URL, "/alice/-/snippets/"+env.Data.ID) {
46 t.Fatalf("url: %s", env.Data.URL)
47 }
48 return env.Data.ID
49 }
50
51 // Create with the default visibility, read back byte for byte.
52 body := "line one\nline two\n"
53 unlisted := idOf(must(aliceKey, body, "snippet", "create", "build.log", "--description", "'a log'", "--json"))
54 if got := must(aliceKey, "", "snippet", "file", "get", unlisted, "build.log"); got != body {
55 t.Fatalf("file get: %q", got)
56 }
57 out := must(aliceKey, "", "snippet", "show", unlisted, "--json")
58 if !strings.Contains(out, `"visibility":"unlisted"`) || !strings.Contains(out, `"content":"line one\nline two\n"`) {
59 t.Fatalf("show: %s", out)
60 }
61 public := idOf(must(aliceKey, "pub\n", "snippet", "create", "a.txt", "--visibility", "public", "--json"))
62 private := idOf(must(aliceKey, "sec\n", "snippet", "create", "b.txt", "--visibility", "private", "--json"))
63
64 // The per-account cap: three snippets exist, a fourth is refused.
65 if msg := fails(aliceKey, "x\n", 2, "snippet", "create", "c.txt"); !strings.Contains(msg, "snippet limit reached") {
66 t.Fatalf("cap refusal: %s", msg)
67 }
68
69 // Refusals on create: empty, not text, over the limit, bad name.
70 fails(aliceKey, "", 2, "snippet", "create", "x.txt")
71 fails(aliceKey, "\xff\xfe\n", 2, "snippet", "create", "x.bin")
72 fails(aliceKey, strings.Repeat("x", 1<<20+1), 2, "snippet", "create", "big.txt")
73 fails(aliceKey, "x\n", 2, "snippet", "create", "../x")
74 fails(aliceKey, "x\n", 2, "snippet", "create", "x.txt", "--visibility", "secret")
75
76 // Visibility from the other side. Private is not-found, never denied.
77 fails(bobKey, "", 3, "snippet", "show", private)
78 fails(bobKey, "", 3, "snippet", "file", "get", private, "b.txt")
79 must(bobKey, "", "snippet", "show", unlisted)
80 out = must(bobKey, "", "snippet", "list", "alice", "--json")
81 if !strings.Contains(out, public) || strings.Contains(out, unlisted) || strings.Contains(out, private) {
82 t.Fatalf("bob's view of alice's list: %s", out)
83 }
84 out = must(aliceKey, "", "snippet", "list", "--json")
85 for _, id := range []string{public, unlisted, private} {
86 if !strings.Contains(out, id) {
87 t.Fatalf("alice's own list lacks %s: %s", id, out)
88 }
89 }
90 fails(bobKey, "", 3, "snippet", "list", "nobody")
91
92 // Paging: two pages of one, the second reached by cursor.
93 out = must(aliceKey, "", "snippet", "list", "--limit", "1", "--json")
94 var page struct {
95 Data struct {
96 Items []struct {
97 ID string `json:"id"`
98 } `json:"items"`
99 Next string `json:"next"`
100 } `json:"data"`
101 }
102 json.Unmarshal([]byte(out), &page)
103 if len(page.Data.Items) != 1 || page.Data.Items[0].ID != private || page.Data.Next == "" {
104 t.Fatalf("first page: %s", out)
105 }
106 out = must(aliceKey, "", "snippet", "list", "--limit", "1", "--cursor", page.Data.Next, "--json")
107 if !strings.Contains(out, public) {
108 t.Fatalf("second page: %s", out)
109 }
110
111 // Files: set adds, set replaces, remove drops, the last one stays.
112 must(aliceKey, "notes\n", "snippet", "file", "set", unlisted, "notes.txt")
113 must(aliceKey, "changed\n", "snippet", "file", "set", unlisted, "build.log")
114 if got := must(aliceKey, "", "snippet", "file", "get", unlisted, "build.log"); got != "changed\n" {
115 t.Fatalf("after replace: %q", got)
116 }
117 must(aliceKey, "", "snippet", "file", "remove", unlisted, "notes.txt")
118 fails(aliceKey, "", 3, "snippet", "file", "remove", unlisted, "notes.txt")
119 if msg := fails(aliceKey, "", 2, "snippet", "file", "remove", unlisted, "build.log"); !strings.Contains(msg, "at least one file") {
120 t.Fatalf("last file removal: %s", msg)
121 }
122
123 // Only the owner writes: denied on a readable one, not-found on a private one.
124 fails(bobKey, "x\n", 4, "snippet", "file", "set", unlisted, "x.txt")
125 fails(bobKey, "", 4, "snippet", "edit", unlisted, "--description", "mine")
126 fails(bobKey, "", 4, "snippet", "delete", unlisted)
127 fails(bobKey, "", 3, "snippet", "delete", private)
128
129 // Edit moves visibility and the listing follows.
130 fails(aliceKey, "", 2, "snippet", "edit", unlisted)
131 must(aliceKey, "", "snippet", "edit", unlisted, "--visibility", "public", "--description", "shared")
132 out = must(bobKey, "", "snippet", "list", "alice", "--json")
133 if !strings.Contains(out, unlisted) || !strings.Contains(out, `"description":"shared"`) {
134 t.Fatalf("list after edit: %s", out)
135 }
136
137 // Delete, then gone; deleting the user takes the rest.
138 must(aliceKey, "", "snippet", "delete", unlisted)
139 fails(aliceKey, "", 3, "snippet", "show", unlisted)
140 inst.admin(t, "admin", "user", "delete", "alice", "--yes")
141 fails(bobKey, "", 3, "snippet", "show", public)
142}
e2e/snippetweb_test.go added +179
@@ -0,0 +1,179 @@
1package e2e
2
3import (
4 "encoding/json"
5 "net/http"
6 "net/url"
7 "strings"
8 "testing"
9)
10
11func snippetIDFrom(t *testing.T, out string) string {
12 t.Helper()
13 var env struct {
14 Data struct {
15 ID string `json:"id"`
16 } `json:"data"`
17 }
18 if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.ID == "" {
19 t.Fatalf("snippet create: %s", out)
20 }
21 return env.Data.ID
22}
23
24// Snippet pages: the owner's list, one snippet with highlighted files, the
25// raw route, the owner-page link, and 404 for what the viewer may not see.
26func TestSnippetsWeb(t *testing.T) {
27 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
28 aliceKey := inst.newKey(t, "alice")
29 bobKey := inst.newKey(t, "bob")
30 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
31 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
32 must := func(key, stdin string, args ...string) string {
33 t.Helper()
34 out, errOut, code := inst.ssh(t, key, stdin, args...)
35 if code != 0 {
36 t.Fatalf("%v: exit %d %s", args, code, errOut)
37 }
38 return out
39 }
40 public := snippetIDFrom(t, must(aliceKey, "package main\n", "snippet", "create", "main.go", "--visibility", "public", "--description", "'hello world'", "--json"))
41 unlisted := snippetIDFrom(t, must(aliceKey, "quiet\n", "snippet", "create", "q.txt", "--json"))
42 private := snippetIDFrom(t, must(aliceKey, "secret\n", "snippet", "create", "s.txt", "--visibility", "private", "--json"))
43
44 // Anonymous: the public list, the unlisted page by URL, 404 for private.
45 status, body := inst.get(t, "/alice/-/snippets")
46 if status != 200 || !strings.Contains(body, public) || strings.Contains(body, unlisted) || strings.Contains(body, private) {
47 t.Fatalf("anonymous list: %d\n%s", status, body)
48 }
49 status, body = inst.get(t, "/alice/-/snippets/"+public)
50 if status != 200 || !strings.Contains(body, "hello world") || !strings.Contains(body, `class="chroma"`) || !strings.Contains(body, "/raw/main.go") {
51 t.Fatalf("public page: %d\n%s", status, body)
52 }
53 if status, _ := inst.get(t, "/alice/-/snippets/"+unlisted); status != 200 {
54 t.Fatalf("unlisted page: %d", status)
55 }
56 if status, _ := inst.get(t, "/alice/-/snippets/"+private); status != 404 {
57 t.Fatalf("private page for anonymous: %d", status)
58 }
59 if status, _ := inst.get(t, "/bob/-/snippets/"+public); status != 404 {
60 t.Fatalf("id under the wrong owner: %d", status)
61 }
62 if status, _ := inst.get(t, "/nobody/-/snippets"); status != 404 {
63 t.Fatalf("list for a missing owner: %d", status)
64 }
65
66 // Raw is text/plain with nosniff, whatever the extension.
67 resp, err := http.Get(inst.base() + "/alice/-/snippets/" + public + "/raw/main.go")
68 if err != nil {
69 t.Fatal(err)
70 }
71 resp.Body.Close()
72 if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") || resp.Header.Get("X-Content-Type-Options") != "nosniff" {
73 t.Fatalf("raw headers: %d %v", resp.StatusCode, resp.Header)
74 }
75 if status, _ := inst.get(t, "/alice/-/snippets/" + public + "/raw/other.go"); status != 404 {
76 t.Fatalf("raw for a missing file: %d", status)
77 }
78
79 // The owner sees everything with visibility marks; the owner page links.
80 alice := inst.login(t, aliceKey)
81 status, body = browserGet(t, alice, inst.base()+"/alice/-/snippets")
82 if status != 200 || !strings.Contains(body, private) || !strings.Contains(body, ">private<") {
83 t.Fatalf("owner list: %d\n%s", status, body)
84 }
85 if status, body := browserGet(t, alice, inst.base()+"/alice/-/snippets/"+private); status != 200 || !strings.Contains(body, "secret") {
86 t.Fatalf("owner's private page: %d", status)
87 }
88 if status, body := inst.get(t, "/alice"); status != 200 || !strings.Contains(body, `href="/alice/-/snippets"`) {
89 t.Fatalf("owner page lacks the snippets link: %d", status)
90 }
91 // bob has no public snippets and is not the viewer: no link.
92 if status, body := inst.get(t, "/bob"); status != 200 || strings.Contains(body, `href="/bob/-/snippets"`) {
93 t.Fatalf("bob's page shows a snippets link with nothing to list: %d", status)
94 }
95
96 // The create form makes a snippet through snippet create.
97 status, body = browserPost(t, alice, inst.base()+"/alice/-/snippets/new", url.Values{
98 "name": {"notes.md"}, "description": {"from the browser"}, "visibility": {"public"}, "content": {"# notes\n"}})
99 if status != 200 || !strings.Contains(body, "from the browser") || !strings.Contains(body, "notes.md") {
100 t.Fatalf("create form: %d\n%s", status, body)
101 }
102 var listed struct {
103 Data []struct {
104 ID string `json:"id"`
105 Description string `json:"description"`
106 } `json:"data"`
107 }
108 json.Unmarshal([]byte(must(aliceKey, "", "snippet", "list", "--json")), &listed)
109 created := ""
110 for _, sn := range listed.Data {
111 if sn.Description == "from the browser" {
112 created = sn.ID
113 }
114 }
115 if created == "" {
116 t.Fatalf("created from the web, not listed: %+v", listed.Data)
117 }
118 if status, _ := browserGet(t, alice, inst.base()+"/bob/-/snippets/new"); status != 404 {
119 t.Fatalf("new form under another owner: %d", status)
120 }
121
122 // A refused create re-renders the form with the paste kept, not a
123 // bare error page.
124 _, body = browserPost(t, alice, inst.base()+"/alice/-/snippets/new", url.Values{
125 "name": {"../x"}, "content": {"kept content\n"}})
126 if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "kept content") {
127 t.Fatalf("refused create form:\n%s", body)
128 }
129
130 // The file form replaces a file and adds one; remove drops it.
131 page := inst.base() + "/alice/-/snippets/" + created
132 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"notes.md"}, "content": {"# changed\n"}}); status != 200 {
133 t.Fatal("file replace failed")
134 }
135 if got := must(aliceKey, "", "snippet", "file", "get", created, "notes.md"); got != "# changed\n" {
136 t.Fatalf("after web replace: %q", got)
137 }
138 if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"b.txt"}, "content": {"b\n"}}); status != 200 {
139 t.Fatal("file add failed")
140 }
141 if status, _ := browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}}); status != 200 {
142 t.Fatal("file remove failed")
143 }
144 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 3 {
145 t.Fatalf("b.txt after web remove: exit %d", code)
146 }
147 // A refusal comes back on the page as a message, not a bare error.
148 _, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"notes.md"}})
149 if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "at least one file") {
150 t.Fatalf("last-file refusal on the page:\n%s", body)
151 }
152
153 // Edit changes visibility; delete removes.
154 if status, _ := browserPost(t, alice, page+"/edit", url.Values{"description": {"renamed"}, "visibility": {"private"}}); status != 200 {
155 t.Fatal("edit failed")
156 }
157 if status, _ := inst.get(t, "/alice/-/snippets/"+created); status != 404 {
158 t.Fatalf("private after web edit, anonymous: %d", status)
159 }
160 // bob cannot write alice's snippet from the browser either.
161 bob := inst.login(t, bobKey)
162 // A logged-in stranger sees the same visibility rule as anonymous:
163 // 404 for a private snippet, 200 for an unlisted one.
164 if status, _ := browserGet(t, bob, inst.base()+"/alice/-/snippets/"+private); status != 404 {
165 t.Fatalf("stranger on a private page: %d", status)
166 }
167 if status, _ := browserGet(t, bob, inst.base()+"/alice/-/snippets/"+unlisted); status != 200 {
168 t.Fatalf("stranger on an unlisted page: %d", status)
169 }
170 if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+public+"/edit", url.Values{"description": {"x"}, "visibility": {"public"}}); status != 403 {
171 t.Fatalf("bob editing alice's snippet: %d", status)
172 }
173 if status, _ := browserPost(t, alice, page+"/delete", nil); status != 200 {
174 t.Fatal("delete failed")
175 }
176 if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 3 {
177 t.Fatalf("after web delete: exit %d", code)
178 }
179}
internal/config/config.go +6 −1
@@ -175,7 +175,11 @@ type Deps struct {
175type Limits struct { 175type Limits struct {
176 MaxPackBytes int64 `toml:"max_pack_bytes"` 176 MaxPackBytes int64 `toml:"max_pack_bytes"`
177 MaxBlobBytes int64 `toml:"max_blob_bytes"` 177 MaxBlobBytes int64 `toml:"max_blob_bytes"`
178 MaxAssetBytes int64 `toml:"max_asset_bytes"` // per release asset 178 MaxAssetBytes int64 `toml:"max_asset_bytes"` // per release asset
179 MaxSnippetBytes int64 `toml:"max_snippet_bytes"` // per snippet file
180 // MaxSnippetsPerUser caps snippets an account may own. 0 means
181 // unlimited, like MaxReposPerUser.
182 MaxSnippetsPerUser int `toml:"max_snippets_per_user"`
179 CloneTimeoutSec int `toml:"clone_timeout"` 183 CloneTimeoutSec int `toml:"clone_timeout"`
180 SSHAuthRate int `toml:"ssh_auth_rate"` 184 SSHAuthRate int `toml:"ssh_auth_rate"`
181 // APIRate is sustained JSON-API requests per minute per caller; writes 185 // APIRate is sustained JSON-API requests per minute per caller; writes
@@ -216,6 +220,7 @@ func Default() Config {
216 MaxPackBytes: 2 << 30, // 2 GiB 220 MaxPackBytes: 2 << 30, // 2 GiB
217 MaxBlobBytes: 100 << 20, 221 MaxBlobBytes: 100 << 20,
218 MaxAssetBytes: 512 << 20, 222 MaxAssetBytes: 512 << 20,
223 MaxSnippetBytes: 1 << 20,
219 CloneTimeoutSec: 3600, 224 CloneTimeoutSec: 3600,
220 SSHAuthRate: 10, 225 SSHAuthRate: 10,
221 APIRate: 120, 226 APIRate: 120,
internal/control/profile.go +14 −4
@@ -176,10 +176,13 @@ type ProfileOut struct {
176 // they own that you can see, and how active they have been. The web 176 // they own that you can see, and how active they have been. The web
177 // read these straight out of the store, which kept them off every 177 // read these straight out of the store, which kept them off every
178 // other surface. 178 // other surface.
179 Orgs []ProfileMember `json:"orgs,omitempty"` // for a user 179 Orgs []ProfileMember `json:"orgs,omitempty"` // for a user
180 Members []ProfileMember `json:"members,omitempty"` // for an org 180 Members []ProfileMember `json:"members,omitempty"` // for an org
181 Repos []ProfileRepo `json:"repos"` 181 Repos []ProfileRepo `json:"repos"`
182 Activity []ActivityDay `json:"activity,omitempty"` 182 // Snippets counts the owner's snippets the caller may list: public
183 // ones, or all of them for the owner and admins. Orgs own none.
184 Snippets int `json:"snippets"`
185 Activity []ActivityDay `json:"activity,omitempty"`
183 // ActivityTotal counts the same window the days cover. 186 // ActivityTotal counts the same window the days cover.
184 ActivityTotal int `json:"activity_total"` 187 ActivityTotal int `json:"activity_total"`
185} 188}
@@ -323,6 +326,13 @@ func runProfileShow(c *Ctx, args []string) int {
323 }) 326 })
324 } 327 }
325 328
329 if kind == "user" {
330 seeAll := id == c.User.ID || c.User.IsAdmin
331 if d.Snippets, err = c.Store.CountSnippets(id, seeAll); err != nil {
332 return c.fail(protocol.ExitFailure, "%v", err)
333 }
334 }
335
326 var counts map[string]int 336 var counts map[string]int
327 if kind == "user" { 337 if kind == "user" {
328 counts, err = c.Store.ActivityByDay(id, ActivityWindow()) 338 counts, err = c.Store.ActivityByDay(id, ActivityWindow())
internal/control/snippet.go added +382
@@ -0,0 +1,382 @@
1package control
2
3import (
4 "crypto/rand"
5 "encoding/hex"
6 "errors"
7 "fmt"
8 "io"
9 "strconv"
10 "unicode/utf8"
11
12 "gitbay.org/gitbay/internal/policy"
13 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/store"
15)
16
17// A snippet keeps at most this many files; a paste is not a repository.
18const maxSnippetFiles = 64
19
20func init() {
21 register(Command{Path: []string{"snippet", "create"},
22 Summary: "create a snippet from one file on stdin",
23 Usage: "snippet create <filename> [--description <d>] [--visibility public|unlisted|private] < file",
24 ReadsStdin: true, Run: runSnippetCreate})
25 register(Command{Path: []string{"snippet", "show"},
26 Summary: "show a snippet's metadata and files",
27 Usage: "snippet show <id>", ReadOnly: true, Run: runSnippetShow})
28 register(Command{Path: []string{"snippet", "list"},
29 Summary: "list your snippets, or an owner's public ones",
30 Usage: "snippet list [<owner>] [--limit n] [--cursor c]", ReadOnly: true, Run: runSnippetList})
31 register(Command{Path: []string{"snippet", "edit"},
32 Summary: "change a snippet's description or visibility",
33 Usage: "snippet edit <id> [--description <d>] [--visibility public|unlisted|private]", Run: runSnippetEdit})
34 register(Command{Path: []string{"snippet", "delete"},
35 Summary: "delete a snippet and its files",
36 Usage: "snippet delete <id>", Run: runSnippetDelete})
37 register(Command{Path: []string{"snippet", "file", "set"},
38 Summary: "add a file to a snippet, or replace one, from stdin",
39 Usage: "snippet file set <id> <filename> < file",
40 ReadsStdin: true, Run: runSnippetFileSet})
41 register(Command{Path: []string{"snippet", "file", "get"},
42 Summary: "write a snippet file to stdout",
43 Usage: "snippet file get <id> <filename> > file", ReadOnly: true, Run: runSnippetFileGet})
44 register(Command{Path: []string{"snippet", "file", "remove"},
45 Summary: "remove a file from a snippet",
46 Usage: "snippet file remove <id> <filename>", Run: runSnippetFileRemove})
47}
48
49type SnippetFileOut struct {
50 Name string `json:"name"`
51 Size int64 `json:"size"`
52 Content string `json:"content,omitempty"`
53}
54
55type SnippetOut struct {
56 ID string `json:"id"`
57 URL string `json:"url"`
58 Owner string `json:"owner"`
59 Description string `json:"description"`
60 Visibility string `json:"visibility"`
61 CreatedAt string `json:"created_at"`
62 UpdatedAt string `json:"updated_at"`
63 Files []SnippetFileOut `json:"files"`
64}
65
66func snippetURL(c *Ctx, sn store.Snippet) string {
67 return c.Cfg.Server.SiteURL + "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
68}
69
70func snippetOut(c *Ctx, sn store.Snippet) SnippetOut {
71 o := SnippetOut{ID: sn.PublicID, URL: snippetURL(c, sn), Owner: sn.OwnerName,
72 Description: sn.Description, Visibility: sn.Visibility,
73 CreatedAt: sn.CreatedAt, UpdatedAt: sn.UpdatedAt, Files: []SnippetFileOut{}}
74 for _, f := range sn.Files {
75 o.Files = append(o.Files, SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)})
76 }
77 return o
78}
79
80func validSnippetVisibility(v string) bool {
81 return v == "public" || v == "unlisted" || v == "private"
82}
83
84// snippetRef loads a snippet the caller may read; with write, one they
85// may change. Unreadable and missing are the same not-found, so a
86// private id cannot be confirmed by probing.
87func snippetRef(c *Ctx, id string, write bool) (store.Snippet, int) {
88 sn, err := c.Store.SnippetByPublicID(id)
89 if err != nil && !errors.Is(err, store.ErrNotFound) {
90 return sn, c.fail(protocol.ExitFailure, "%v", err)
91 }
92 if err != nil || !policy.CanReadSnippet(c.User, sn) {
93 return sn, c.fail(protocol.ExitNotFound, "no snippet %q", id)
94 }
95 if write && !policy.CanWriteSnippet(c.User, sn) {
96 return sn, c.fail(protocol.ExitDenied, "snippet %s belongs to %s", id, sn.OwnerName)
97 }
98 return sn, -1
99}
100
101// readSnippetBody reads one file from stdin under the limit, and insists
102// on text: the page highlights it and the raw route serves text/plain.
103func readSnippetBody(c *Ctx) ([]byte, int) {
104 limit := c.Cfg.Limits.MaxSnippetBytes
105 data, err := io.ReadAll(io.LimitReader(c.Stdin, limit+1))
106 if err != nil {
107 return nil, c.fail(protocol.ExitFailure, "reading stdin: %v", err)
108 }
109 if int64(len(data)) > limit {
110 return nil, c.fail(protocol.ExitUsage, "file exceeds max_snippet_bytes (%d)", limit)
111 }
112 if len(data) == 0 {
113 return nil, c.fail(protocol.ExitUsage, "empty file: pipe it on stdin")
114 }
115 if !utf8.Valid(data) {
116 return nil, c.fail(protocol.ExitUsage, "snippets hold text: the file is not valid UTF-8")
117 }
118 return data, -1
119}
120
121func checkSnippetFileName(c *Ctx, name string) int {
122 if !assetNamePat.MatchString(name) {
123 return c.fail(protocol.ExitUsage, "invalid file name %q: letters, digits, '._+-'; must not start with '.'", name)
124 }
125 return -1
126}
127
128func newSnippetID() string {
129 buf := make([]byte, 6)
130 rand.Read(buf)
131 return hex.EncodeToString(buf)
132}
133
134func runSnippetCreate(c *Ctx, args []string) int {
135 const usage = "usage: snippet create <filename> [--description <d>] [--visibility public|unlisted|private] < file"
136 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: usage})
137 if err != nil {
138 return c.fail(protocol.ExitUsage, "%v", err)
139 }
140 name := f.pos(0)
141 if name == "" {
142 return c.fail(protocol.ExitUsage, usage)
143 }
144 if code := checkSnippetFileName(c, name); code >= 0 {
145 return code
146 }
147 visibility := f.Value("--visibility")
148 if visibility == "" {
149 visibility = "unlisted"
150 }
151 if !validSnippetVisibility(visibility) {
152 return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
153 }
154 if limit := c.Cfg.Limits.MaxSnippetsPerUser; limit > 0 {
155 n, err := c.Store.CountSnippets(c.User.ID, true)
156 if err != nil {
157 return c.fail(protocol.ExitFailure, "%v", err)
158 }
159 if n >= limit {
160 return c.fail(protocol.ExitUsage, "snippet limit reached (%d); delete one first", limit)
161 }
162 }
163 data, code := readSnippetBody(c)
164 if code >= 0 {
165 return code
166 }
167 var pid string
168 for try := 0; ; try++ {
169 pid = newSnippetID()
170 _, err = c.Store.CreateSnippet(c.User.ID, pid, f.Value("--description"), visibility, name, data)
171 if !errors.Is(err, store.ErrExists) || try == 4 {
172 break
173 }
174 }
175 if err != nil {
176 return c.failErr(err)
177 }
178 sn, err := c.Store.SnippetByPublicID(pid)
179 if err != nil {
180 return c.fail(protocol.ExitFailure, "%v", err)
181 }
182 return c.emit(snippetOut(c, sn), func(w io.Writer) {
183 fmt.Fprintf(w, "created snippet %s\n%s\n", sn.PublicID, snippetURL(c, sn))
184 })
185}
186
187func runSnippetShow(c *Ctx, args []string) int {
188 if len(args) != 1 {
189 return c.fail(protocol.ExitUsage, "usage: snippet show <id>")
190 }
191 sn, code := snippetRef(c, args[0], false)
192 if code >= 0 {
193 return code
194 }
195 files, err := c.Store.SnippetFiles(sn.ID)
196 if err != nil {
197 return c.fail(protocol.ExitFailure, "%v", err)
198 }
199 sn.Files = files
200 return c.emit(snippetOut(c, sn), func(w io.Writer) {
201 fmt.Fprintf(w, "snippet %s by %s (%s)\n", sn.PublicID, sn.OwnerName, sn.Visibility)
202 if sn.Description != "" {
203 fmt.Fprintf(w, "%s\n", sn.Description)
204 }
205 fmt.Fprintf(w, "%s\nupdated %s\n", snippetURL(c, sn), sn.UpdatedAt)
206 for _, f := range files {
207 fmt.Fprintf(w, " %s\t%d bytes\n", f.Name, f.Size)
208 }
209 })
210}
211
212func runSnippetList(c *Ctx, args []string) int {
213 rest, p, code := parsePageFlags(c, args, "snippet", true)
214 if code >= 0 {
215 return code
216 }
217 if len(rest) > 1 {
218 return c.fail(protocol.ExitUsage, "usage: snippet list [<owner>] [--limit n] [--cursor c]")
219 }
220 owner := c.User
221 if len(rest) == 1 {
222 u, err := c.Store.UserByUsername(rest[0])
223 if errors.Is(err, store.ErrNotFound) {
224 return c.fail(protocol.ExitNotFound, "no user %q", rest[0])
225 }
226 if err != nil {
227 return c.fail(protocol.ExitFailure, "%v", err)
228 }
229 owner = u
230 }
231 all := owner.ID == c.User.ID || c.User.IsAdmin
232 rows, err := c.Store.ListSnippets(owner.ID, all, p.queryLimit(), p.keyInt())
233 if err != nil {
234 return c.fail(protocol.ExitFailure, "%v", err)
235 }
236 rows, next := trimPage(p, rows, "snippet", func(sn store.Snippet) string { return strconv.FormatInt(sn.ID, 10) })
237 items := make([]SnippetOut, 0, len(rows))
238 for _, sn := range rows {
239 items = append(items, snippetOut(c, sn))
240 }
241 return c.emitPage(p, items, next, func(w io.Writer) {
242 for _, sn := range rows {
243 names := ""
244 for i, f := range sn.Files {
245 if i > 0 {
246 names += ", "
247 }
248 names += f.Name
249 }
250 fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", sn.PublicID, sn.Visibility, names, sn.Description)
251 }
252 })
253}
254
255func runSnippetEdit(c *Ctx, args []string) int {
256 const usage = "usage: snippet edit <id> [--description <d>] [--visibility public|unlisted|private]"
257 f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: usage})
258 if err != nil {
259 return c.fail(protocol.ExitUsage, "%v", err)
260 }
261 if f.pos(0) == "" || (!f.Has("--description") && !f.Has("--visibility")) {
262 return c.fail(protocol.ExitUsage, usage)
263 }
264 sn, code := snippetRef(c, f.pos(0), true)
265 if code >= 0 {
266 return code
267 }
268 description, visibility := sn.Description, sn.Visibility
269 if f.Has("--description") {
270 description = f.Value("--description")
271 }
272 if f.Has("--visibility") {
273 visibility = f.Value("--visibility")
274 if !validSnippetVisibility(visibility) {
275 return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
276 }
277 }
278 if err := c.Store.UpdateSnippet(sn.ID, description, visibility); err != nil {
279 return c.failErr(err)
280 }
281 sn, err = c.Store.SnippetByPublicID(sn.PublicID)
282 if err != nil {
283 return c.fail(protocol.ExitFailure, "%v", err)
284 }
285 return c.emit(snippetOut(c, sn), func(w io.Writer) {
286 fmt.Fprintf(w, "updated snippet %s (%s)\n", sn.PublicID, sn.Visibility)
287 })
288}
289
290func runSnippetDelete(c *Ctx, args []string) int {
291 if len(args) != 1 {
292 return c.fail(protocol.ExitUsage, "usage: snippet delete <id>")
293 }
294 sn, code := snippetRef(c, args[0], true)
295 if code >= 0 {
296 return code
297 }
298 if err := c.Store.DeleteSnippet(sn.ID); err != nil {
299 return c.failErr(err)
300 }
301 return c.emit(map[string]string{"id": sn.PublicID}, func(w io.Writer) {
302 fmt.Fprintf(w, "deleted snippet %s\n", sn.PublicID)
303 })
304}
305
306func runSnippetFileSet(c *Ctx, args []string) int {
307 if len(args) != 2 {
308 return c.fail(protocol.ExitUsage, "usage: snippet file set <id> <filename> < file")
309 }
310 sn, code := snippetRef(c, args[0], true)
311 if code >= 0 {
312 return code
313 }
314 name := args[1]
315 if code := checkSnippetFileName(c, name); code >= 0 {
316 return code
317 }
318 exists := false
319 for _, f := range sn.Files {
320 exists = exists || f.Name == name
321 }
322 if !exists && len(sn.Files) >= maxSnippetFiles {
323 return c.fail(protocol.ExitUsage, "a snippet holds at most %d files", maxSnippetFiles)
324 }
325 data, code := readSnippetBody(c)
326 if code >= 0 {
327 return code
328 }
329 if err := c.Store.SetSnippetFile(sn.ID, name, data); err != nil {
330 return c.failErr(err)
331 }
332 return c.emit(SnippetFileOut{Name: name, Size: int64(len(data))}, func(w io.Writer) {
333 fmt.Fprintf(w, "set %s (%d bytes) on snippet %s\n", name, len(data), sn.PublicID)
334 })
335}
336
337func runSnippetFileGet(c *Ctx, args []string) int {
338 if len(args) != 2 {
339 return c.fail(protocol.ExitUsage, "usage: snippet file get <id> <filename> > file")
340 }
341 sn, code := snippetRef(c, args[0], false)
342 if code >= 0 {
343 return code
344 }
345 f, err := c.Store.SnippetFile(sn.ID, args[1])
346 if errors.Is(err, store.ErrNotFound) {
347 return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
348 }
349 if err != nil {
350 return c.fail(protocol.ExitFailure, "%v", err)
351 }
352 if c.JSON {
353 return c.emit(SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)}, nil)
354 }
355 if _, err := c.Stdout.Write(f.Content); err != nil {
356 return protocol.ExitFailure
357 }
358 return protocol.ExitOK
359}
360
361func runSnippetFileRemove(c *Ctx, args []string) int {
362 if len(args) != 2 {
363 return c.fail(protocol.ExitUsage, "usage: snippet file remove <id> <filename>")
364 }
365 sn, code := snippetRef(c, args[0], true)
366 if code >= 0 {
367 return code
368 }
369 if len(sn.Files) == 1 && sn.Files[0].Name == args[1] {
370 return c.fail(protocol.ExitUsage, "a snippet keeps at least one file; delete the snippet instead")
371 }
372 err := c.Store.RemoveSnippetFile(sn.ID, args[1])
373 if errors.Is(err, store.ErrNotFound) {
374 return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
375 }
376 if err != nil {
377 return c.failErr(err)
378 }
379 return c.emit(map[string]string{"id": sn.PublicID, "name": args[1]}, func(w io.Writer) {
380 fmt.Fprintf(w, "removed %s from snippet %s\n", args[1], sn.PublicID)
381 })
382}
internal/control/snippet_test.go added +63
@@ -0,0 +1,63 @@
1package control
2
3import (
4 "bytes"
5 "fmt"
6 "strconv"
7 "strings"
8 "testing"
9
10 "gitbay.org/gitbay/internal/config"
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15// A snippet already at maxSnippetFiles refuses a new name but still
16// accepts a replacement of one it already holds.
17func TestSnippetFileSetRefusesThe65thFile(t *testing.T) {
18 st, err := store.Open(":memory:")
19 if err != nil {
20 t.Fatal(err)
21 }
22 t.Cleanup(func() { st.Close() })
23 if err := st.MigrateUp(); err != nil {
24 t.Fatal(err)
25 }
26 uid, err := st.CreateUser("alice", false)
27 if err != nil {
28 t.Fatal(err)
29 }
30 snID, err := st.CreateSnippet(uid, "abc123abc123", "", "unlisted", "f0", []byte("x\n"))
31 if err != nil {
32 t.Fatal(err)
33 }
34 for i := 1; i < maxSnippetFiles; i++ {
35 if err := st.SetSnippetFile(snID, fmt.Sprintf("f%d", i), []byte("x\n")); err != nil {
36 t.Fatal(err)
37 }
38 }
39
40 ctx := func() (*Ctx, *bytes.Buffer) {
41 var out bytes.Buffer
42 return &Ctx{
43 User: store.User{ID: uid, Username: "alice"},
44 Scope: "full",
45 Store: st,
46 Cfg: config.Config{Limits: config.Limits{MaxSnippetBytes: 1 << 20}},
47 Stdin: strings.NewReader("x\n"),
48 Stdout: &out,
49 Stderr: &out,
50 }, &out
51 }
52
53 c, out := ctx()
54 if code := runSnippetFileSet(c, []string{"abc123abc123", "new.txt"}); code != protocol.ExitUsage ||
55 !strings.Contains(out.String(), strconv.Itoa(maxSnippetFiles)) {
56 t.Fatalf("new file at the cap: exit %d, want %d naming %d: %s", code, protocol.ExitUsage, maxSnippetFiles, out.String())
57 }
58
59 c, out = ctx()
60 if code := runSnippetFileSet(c, []string{"abc123abc123", "f0"}); code != protocol.ExitOK {
61 t.Fatalf("replacing an existing file at the cap: exit %d: %s", code, out.String())
62 }
63}
internal/httpd/routes.go +14
@@ -73,6 +73,9 @@ func (s *Server) Routes() []Route {
73 Route{Method: "GET", Pattern: "/{owner}/activity.atom", Handler: s.ownerAtom}, 73 Route{Method: "GET", Pattern: "/{owner}/activity.atom", Handler: s.ownerAtom},
74 Route{Method: "GET", Pattern: "/{owner}/-/labels", Handler: s.orgLabels}, 74 Route{Method: "GET", Pattern: "/{owner}/-/labels", Handler: s.orgLabels},
75 Route{Method: "GET", Pattern: "/{owner}/-/milestones", Handler: s.orgMilestones}, 75 Route{Method: "GET", Pattern: "/{owner}/-/milestones", Handler: s.orgMilestones},
76 Route{Method: "GET", Pattern: "/{owner}/-/snippets", Handler: s.snippetsPage},
77 Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}", Handler: s.snippetPage},
78 Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}/raw/{name}", Handler: s.snippetRaw},
76 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds}, 79 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds},
77 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge}, 80 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.svg", Handler: s.buildBadge},
78 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.png", Handler: s.buildBadgePNG}, 81 Route{Method: "GET", Pattern: "/{owner}/{repo}/badge/build.png", Handler: s.buildBadgePNG},
@@ -159,6 +162,17 @@ func (s *Server) Routes() []Route {
159 Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true, 162 Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true,
160 Handler: s.checkOrigin(s.requireUser(s.labelSubmit))}, 163 Handler: s.checkOrigin(s.requireUser(s.labelSubmit))},
161 Route{Method: "GET", Pattern: "/bookmarks", Handler: s.requireUser(s.bookmarksPage)}, 164 Route{Method: "GET", Pattern: "/bookmarks", Handler: s.requireUser(s.bookmarksPage)},
165 Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)},
166 Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true,
167 Handler: s.checkOrigin(s.requireUser(s.snippetNewSubmit))},
168 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/edit", Mutating: true,
169 Handler: s.checkOrigin(s.requireUser(s.snippetEditSubmit))},
170 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/delete", Mutating: true,
171 Handler: s.checkOrigin(s.requireUser(s.snippetDeleteSubmit))},
172 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/file", Mutating: true,
173 Handler: s.checkOrigin(s.requireUser(s.snippetFileSubmit))},
174 Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/file/remove", Mutating: true,
175 Handler: s.checkOrigin(s.requireUser(s.snippetFileRemoveSubmit))},
162 Route{Method: "POST", Pattern: "/{owner}/{repo}/bookmark", Mutating: true, 176 Route{Method: "POST", Pattern: "/{owner}/{repo}/bookmark", Mutating: true,
163 Handler: s.checkOrigin(s.requireUser(s.bookmarkToggle))}, 177 Handler: s.checkOrigin(s.requireUser(s.bookmarkToggle))},
164 Route{Method: "POST", Pattern: "/{owner}/{repo}/fork", Mutating: true, 178 Route{Method: "POST", Pattern: "/{owner}/{repo}/fork", Mutating: true,
internal/httpd/snippets.go added +229
@@ -0,0 +1,229 @@
1package httpd
2
3import (
4 "bytes"
5 "html/template"
6 "net/http"
7 "strings"
8
9 "gitbay.org/gitbay/internal/control"
10 "gitbay.org/gitbay/internal/policy"
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15// snippetScope resolves the owner and id in the URL for the viewer. A
16// missing owner, an id under another owner, and a private snippet the
17// viewer may not read are all the same 404.
18func (s *Server) snippetScope(w http.ResponseWriter, r *http.Request) (store.Snippet, store.User, bool) {
19 viewer := s.viewer(r)
20 sn, err := s.st.SnippetByPublicID(r.PathValue("id"))
21 if err != nil || sn.OwnerName != r.PathValue("owner") || !policy.CanReadSnippet(viewer, sn) {
22 s.notFound(w, r)
23 return sn, viewer, false
24 }
25 return sn, viewer, true
26}
27
28type snippetRow struct {
29 store.Snippet
30 Names string
31}
32
33func (s *Server) snippetsPage(w http.ResponseWriter, r *http.Request) {
34 viewer := s.viewer(r)
35 owner, err := s.st.UserByUsername(r.PathValue("owner"))
36 if err != nil {
37 s.notFound(w, r)
38 return
39 }
40 self := viewer.ID != 0 && viewer.ID == owner.ID
41 all := self || viewer.IsAdmin
42 list, err := s.st.ListSnippets(owner.ID, all, 0, 0)
43 if err != nil {
44 http.Error(w, "internal error", http.StatusInternalServerError)
45 return
46 }
47 rows := make([]snippetRow, 0, len(list))
48 for _, sn := range list {
49 var names bytes.Buffer
50 for i, f := range sn.Files {
51 if i > 0 {
52 names.WriteString(", ")
53 }
54 names.WriteString(f.Name)
55 }
56 rows = append(rows, snippetRow{sn, names.String()})
57 }
58 s.render(w, "snippets.html", struct {
59 basePage
60 Owner string
61 Self bool
62 All bool
63 Snippets []snippetRow
64 Notice string
65 }{s.baseFor(viewer), owner.Username, self, all, rows, s.takeFlash(w, r)})
66}
67
68type snippetFileView struct {
69 Name string
70 Size int64
71 Lines int
72 Content string
73 HTML template.HTML
74 TooLarge bool
75}
76
77// snippetPage highlights files up to a shared budget across the page: a
78// snippet with many or large files does not make one request highlight
79// megabytes of markup. Content is filled only for the owner, whose edit
80// textarea needs the raw text regardless of the budget.
81func (s *Server) snippetPage(w http.ResponseWriter, r *http.Request) {
82 sn, viewer, ok := s.snippetScope(w, r)
83 if !ok {
84 return
85 }
86 files, err := s.st.SnippetFiles(sn.ID)
87 if err != nil {
88 http.Error(w, "internal error", http.StatusInternalServerError)
89 return
90 }
91 canWrite := policy.CanWriteSnippet(viewer, sn)
92 budget := int64(maxRenderBytes)
93 views := make([]snippetFileView, 0, len(files))
94 for _, f := range files {
95 lines := bytes.Count(f.Content, []byte("\n"))
96 if len(f.Content) > 0 && f.Content[len(f.Content)-1] != '\n' {
97 lines++
98 }
99 view := snippetFileView{Name: f.Name, Size: f.Size, Lines: lines}
100 if canWrite {
101 view.Content = string(f.Content)
102 }
103 if f.Size <= budget {
104 view.HTML = highlightPlain(f.Name, f.Content)
105 budget -= f.Size
106 } else {
107 view.TooLarge = true
108 }
109 views = append(views, view)
110 }
111 s.render(w, "snippet.html", struct {
112 basePage
113 Owner string
114 Snippet store.Snippet
115 Files []snippetFileView
116 CanWrite bool
117 Notice string
118 }{s.baseFor(viewer), sn.OwnerName, sn, views, canWrite, s.takeFlash(w, r)})
119}
120
121// snippetRaw serves one file as text, inert on the forge's origin.
122func (s *Server) snippetRaw(w http.ResponseWriter, r *http.Request) {
123 sn, _, ok := s.snippetScope(w, r)
124 if !ok {
125 return
126 }
127 f, err := s.st.SnippetFile(sn.ID, r.PathValue("name"))
128 if err != nil {
129 s.notFound(w, r)
130 return
131 }
132 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
133 w.Header().Set("X-Content-Type-Options", "nosniff")
134 w.Write(f.Content)
135}
136
137type snippetNewPage struct {
138 basePage
139 Owner string
140 Name string
141 Description string
142 Visibility string
143 Content string
144 Error string
145}
146
147// snippetNewForm is the owner's own page only: the URL names the owner
148// and a snippet cannot be created for someone else.
149func (s *Server) snippetNewForm(w http.ResponseWriter, r *http.Request, u store.User) {
150 if r.PathValue("owner") != u.Username {
151 s.notFound(w, r)
152 return
153 }
154 s.render(w, "snippetnew.html", snippetNewPage{basePage: s.baseFor(u), Owner: u.Username})
155}
156
157// snippetNewSubmit re-renders the form with the submitted values on a
158// refusal, so a typo in the name does not throw away a pasted body.
159func (s *Server) snippetNewSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
160 if r.PathValue("owner") != u.Username {
161 s.notFound(w, r)
162 return
163 }
164 name := strings.TrimSpace(r.FormValue("name"))
165 description := strings.TrimSpace(r.FormValue("description"))
166 visibility := r.FormValue("visibility")
167 content := r.FormValue("content")
168 argv := []string{"snippet", "create", name, "--description", description, "--visibility", visibility}
169 var out control.SnippetOut
170 code, msg := s.dispatchIntoStdin(u, argv, content, &out)
171 if code != protocol.ExitOK {
172 s.render(w, "snippetnew.html", snippetNewPage{
173 basePage: s.baseFor(u), Owner: u.Username,
174 Name: name, Description: description, Visibility: visibility, Content: content, Error: msg,
175 })
176 return
177 }
178 http.Redirect(w, r, "/"+u.Username+"/-/snippets/"+out.ID, http.StatusSeeOther)
179}
180
181// snippetAction runs a write on the snippet in the URL and returns to its
182// page with the message, or to dest (the list, for a delete) on success.
183// A snippet the viewer may not read is the 404 page, as on every read.
184func (s *Server) snippetAction(w http.ResponseWriter, r *http.Request, u store.User, argv []string, stdin string, dest string) {
185 sn, _, ok := s.snippetScope(w, r)
186 if !ok {
187 return
188 }
189 page := "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
190 if dest == "" {
191 dest = page
192 }
193 back := func(w http.ResponseWriter, r *http.Request, msg string) {
194 s.setFlash(w, msg)
195 to := dest
196 if msg != "" {
197 to = page
198 }
199 http.Redirect(w, r, to, http.StatusSeeOther)
200 }
201 msg, code := s.runControlStdinCode(u, argv, stdin)
202 if code == protocol.ExitDenied {
203 http.Error(w, msg, http.StatusForbidden)
204 return
205 }
206 s.done(w, r, code, msg, back)
207}
208
209func (s *Server) snippetEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
210 s.snippetAction(w, r, u, []string{"snippet", "edit", r.PathValue("id"),
211 "--description", strings.TrimSpace(r.FormValue("description")),
212 "--visibility", r.FormValue("visibility")}, "", "")
213}
214
215func (s *Server) snippetDeleteSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
216 s.snippetAction(w, r, u, []string{"snippet", "delete", r.PathValue("id")}, "",
217 "/"+r.PathValue("owner")+"/-/snippets")
218}
219
220// An empty textarea reaches the command as empty stdin, which it refuses;
221// the message lands on the page like any other.
222func (s *Server) snippetFileSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
223 s.snippetAction(w, r, u, []string{"snippet", "file", "set", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))},
224 r.FormValue("content"), "")
225}
226
227func (s *Server) snippetFileRemoveSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
228 s.snippetAction(w, r, u, []string{"snippet", "file", "remove", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))}, "", "")
229}
internal/httpd/web.go +18 −1
@@ -445,12 +445,14 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
445 Teams []teamView 445 Teams []teamView
446 CanAdmin bool 446 CanAdmin bool
447 Self bool 447 Self bool
448 Snippets int
448 Notice string 449 Notice string
449 Feed string 450 Feed string
450 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile), 451 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
451 d.Repos, d.Members, d.Orgs, 452 d.Repos, d.Members, d.Orgs,
452 weeks, activityTotal, teams, canAdmin, 453 weeks, activityTotal, teams, canAdmin,
453 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name), 454 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name),
455 d.Snippets,
454 s.takeFlash(w, r), "/" + name + "/activity.atom"}) 456 s.takeFlash(w, r), "/" + name + "/activity.atom"})
455} 457}
456 458
@@ -908,7 +910,22 @@ var chromaFormatter = html.New(html.WithClasses(true),
908 html.WithLineNumbers(true), html.LineNumbersInTable(false), 910 html.WithLineNumbers(true), html.LineNumbersInTable(false),
909 html.WithLinkableLineNumbers(true, "L")) 911 html.WithLinkableLineNumbers(true, "L"))
910 912
913// chromaFormatterPlain is chromaFormatter without linkable line numbers,
914// for a page that highlights more than one file: linkable ids are
915// per-file line numbers, so several files on one page would repeat
916// id="L1", id="L2", ...
917var chromaFormatterPlain = html.New(html.WithClasses(true),
918 html.WithLineNumbers(true), html.LineNumbersInTable(false))
919
911func highlight(filePath string, data []byte) template.HTML { 920func highlight(filePath string, data []byte) template.HTML {
921 return highlightWith(chromaFormatter, filePath, data)
922}
923
924func highlightPlain(filePath string, data []byte) template.HTML {
925 return highlightWith(chromaFormatterPlain, filePath, data)
926}
927
928func highlightWith(formatter *html.Formatter, filePath string, data []byte) template.HTML {
912 lexer := lexers.Match(filePath) 929 lexer := lexers.Match(filePath)
913 if lexer == nil { 930 if lexer == nil {
914 lexer = lexers.Fallback 931 lexer = lexers.Fallback
@@ -918,7 +935,7 @@ func highlight(filePath string, data []byte) template.HTML {
918 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>") 935 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
919 } 936 }
920 var buf bytes.Buffer 937 var buf bytes.Buffer
921 if err := chromaFormatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil { 938 if err := formatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
922 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>") 939 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
923 } 940 }
924 return template.HTML(buf.String()) 941 return template.HTML(buf.String())
internal/policy/snippets.go added +17
@@ -0,0 +1,17 @@
1package policy
2
3import "gitbay.org/gitbay/internal/store"
4
5// CanReadSnippet: anyone for public and unlisted, the owner and admins
6// for private. Anonymous readers have user.ID 0.
7func CanReadSnippet(user store.User, sn store.Snippet) bool {
8 if sn.Visibility != "private" {
9 return true
10 }
11 return user.ID != 0 && (user.ID == sn.OwnerID || user.IsAdmin)
12}
13
14// CanWriteSnippet: the owner and admins.
15func CanWriteSnippet(user store.User, sn store.Snippet) bool {
16 return user.ID != 0 && (user.ID == sn.OwnerID || user.IsAdmin)
17}
internal/store/migrations/0054_snippets.down.sql added +2
@@ -0,0 +1,2 @@
1DROP TABLE snippet_files;
2DROP TABLE snippets;
internal/store/migrations/0054_snippets.up.sql added +20
@@ -0,0 +1,20 @@
1-- Snippets: named text files a user owns and shares by URL, outside any
2-- repository. public_id is the opaque id in URLs and commands.
3CREATE TABLE snippets (
4 id INTEGER PRIMARY KEY,
5 public_id TEXT NOT NULL UNIQUE,
6 owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
7 description TEXT NOT NULL DEFAULT '',
8 visibility TEXT NOT NULL CHECK (visibility IN ('public','unlisted','private')),
9 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
10 updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
11);
12CREATE INDEX snippets_owner ON snippets(owner_id, id);
13
14CREATE TABLE snippet_files (
15 snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
16 name TEXT NOT NULL,
17 content BLOB NOT NULL,
18 size INTEGER NOT NULL,
19 PRIMARY KEY (snippet_id, name)
20);
internal/store/snippets.go added +225
@@ -0,0 +1,225 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6)
7
8type Snippet struct {
9 ID int64
10 PublicID string
11 OwnerID int64
12 OwnerName string
13 Description string
14 Visibility string // public | unlisted | private
15 CreatedAt string
16 UpdatedAt string
17 // Files carries names and sizes. Content is filled by SnippetFiles and
18 // SnippetFile only, so a listing does not read every body.
19 Files []SnippetFile
20}
21
22type SnippetFile struct {
23 Name string
24 Size int64
25 Content []byte
26}
27
28const snippetSelect = `
29 SELECT s.id, s.public_id, s.owner_id, u.username, s.description, s.visibility, s.created_at, s.updated_at
30 FROM snippets s JOIN users u ON u.id = s.owner_id`
31
32func scanSnippet(row interface{ Scan(...any) error }) (Snippet, error) {
33 var sn Snippet
34 err := row.Scan(&sn.ID, &sn.PublicID, &sn.OwnerID, &sn.OwnerName, &sn.Description, &sn.Visibility, &sn.CreatedAt, &sn.UpdatedAt)
35 return sn, err
36}
37
38// CreateSnippet inserts the snippet and its first file in one transaction.
39// A public_id collision is ErrExists so the caller can draw another.
40func (s *Store) CreateSnippet(ownerID int64, publicID, description, visibility, name string, content []byte) (int64, error) {
41 tx, err := s.DB.Begin()
42 if err != nil {
43 return 0, err
44 }
45 defer tx.Rollback()
46 res, err := tx.Exec(
47 "INSERT INTO snippets (public_id, owner_id, description, visibility) VALUES (?, ?, ?, ?)",
48 publicID, ownerID, description, visibility)
49 if err != nil {
50 if isUniqueErr(err) {
51 return 0, ErrExists
52 }
53 return 0, err
54 }
55 id, err := res.LastInsertId()
56 if err != nil {
57 return 0, err
58 }
59 if _, err := tx.Exec("INSERT INTO snippet_files (snippet_id, name, content, size) VALUES (?, ?, ?, ?)",
60 id, name, content, len(content)); err != nil {
61 return 0, err
62 }
63 return id, tx.Commit()
64}
65
66func (s *Store) SnippetByPublicID(publicID string) (Snippet, error) {
67 sn, err := scanSnippet(s.DB.QueryRow(snippetSelect+" WHERE s.public_id = ?", publicID))
68 if errors.Is(err, sql.ErrNoRows) {
69 return sn, ErrNotFound
70 }
71 if err != nil {
72 return sn, err
73 }
74 sn.Files, err = s.snippetFileNames(sn.ID)
75 return sn, err
76}
77
78func (s *Store) snippetFileNames(id int64) ([]SnippetFile, error) {
79 rows, err := s.DB.Query("SELECT name, size FROM snippet_files WHERE snippet_id = ? ORDER BY name", id)
80 if err != nil {
81 return nil, err
82 }
83 defer rows.Close()
84 var out []SnippetFile
85 for rows.Next() {
86 var f SnippetFile
87 if err := rows.Scan(&f.Name, &f.Size); err != nil {
88 return nil, err
89 }
90 out = append(out, f)
91 }
92 return out, rows.Err()
93}
94
95// SnippetFiles returns every file with its content, by name.
96func (s *Store) SnippetFiles(id int64) ([]SnippetFile, error) {
97 rows, err := s.DB.Query("SELECT name, size, content FROM snippet_files WHERE snippet_id = ? ORDER BY name", id)
98 if err != nil {
99 return nil, err
100 }
101 defer rows.Close()
102 var out []SnippetFile
103 for rows.Next() {
104 var f SnippetFile
105 if err := rows.Scan(&f.Name, &f.Size, &f.Content); err != nil {
106 return nil, err
107 }
108 out = append(out, f)
109 }
110 return out, rows.Err()
111}
112
113func (s *Store) SnippetFile(id int64, name string) (SnippetFile, error) {
114 var f SnippetFile
115 err := s.DB.QueryRow("SELECT name, size, content FROM snippet_files WHERE snippet_id = ? AND name = ?", id, name).
116 Scan(&f.Name, &f.Size, &f.Content)
117 if errors.Is(err, sql.ErrNoRows) {
118 return f, ErrNotFound
119 }
120 return f, err
121}
122
123// ListSnippets lists an owner's snippets newest first. all=false keeps
124// public ones only. afterID is the keyset cursor: rows older than it.
125// Ids grow with creation, so ordering by id is creation order.
126func (s *Store) ListSnippets(ownerID int64, all bool, limit int, afterID int64) ([]Snippet, error) {
127 q := snippetSelect + " WHERE s.owner_id = ?"
128 args := []any{ownerID}
129 if !all {
130 q += " AND s.visibility = 'public'"
131 }
132 if afterID > 0 {
133 q += " AND s.id < ?"
134 args = append(args, afterID)
135 }
136 q += " ORDER BY s.id DESC"
137 if limit > 0 {
138 q += " LIMIT ?"
139 args = append(args, limit)
140 }
141 rows, err := s.DB.Query(q, args...)
142 if err != nil {
143 return nil, err
144 }
145 defer rows.Close()
146 var out []Snippet
147 for rows.Next() {
148 sn, err := scanSnippet(rows)
149 if err != nil {
150 return nil, err
151 }
152 out = append(out, sn)
153 }
154 if err := rows.Err(); err != nil {
155 return nil, err
156 }
157 // One query per row for the names. Command callers page at 200 rows
158 // or fewer; the web list page is uncapped, which the per-account
159 // snippet limit bounds.
160 for i := range out {
161 if out[i].Files, err = s.snippetFileNames(out[i].ID); err != nil {
162 return nil, err
163 }
164 }
165 return out, nil
166}
167
168func (s *Store) CountSnippets(ownerID int64, all bool) (int, error) {
169 q := "SELECT COUNT(*) FROM snippets WHERE owner_id = ?"
170 if !all {
171 q += " AND visibility = 'public'"
172 }
173 var n int
174 err := s.DB.QueryRow(q, ownerID).Scan(&n)
175 return n, err
176}
177
178func (s *Store) UpdateSnippet(id int64, description, visibility string) error {
179 _, err := s.DB.Exec(
180 "UPDATE snippets SET description = ?, visibility = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
181 description, visibility, id)
182 return err
183}
184
185func (s *Store) DeleteSnippet(id int64) error {
186 _, err := s.DB.Exec("DELETE FROM snippets WHERE id = ?", id)
187 return err
188}
189
190// SetSnippetFile adds the file or replaces one of the same name.
191func (s *Store) SetSnippetFile(id int64, name string, content []byte) error {
192 tx, err := s.DB.Begin()
193 if err != nil {
194 return err
195 }
196 defer tx.Rollback()
197 if _, err := tx.Exec(`INSERT INTO snippet_files (snippet_id, name, content, size) VALUES (?, ?, ?, ?)
198 ON CONFLICT (snippet_id, name) DO UPDATE SET content = excluded.content, size = excluded.size`,
199 id, name, content, len(content)); err != nil {
200 return err
201 }
202 if _, err := tx.Exec("UPDATE snippets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id); err != nil {
203 return err
204 }
205 return tx.Commit()
206}
207
208func (s *Store) RemoveSnippetFile(id int64, name string) error {
209 tx, err := s.DB.Begin()
210 if err != nil {
211 return err
212 }
213 defer tx.Rollback()
214 res, err := tx.Exec("DELETE FROM snippet_files WHERE snippet_id = ? AND name = ?", id, name)
215 if err != nil {
216 return err
217 }
218 if n, _ := res.RowsAffected(); n == 0 {
219 return ErrNotFound
220 }
221 if _, err := tx.Exec("UPDATE snippets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id); err != nil {
222 return err
223 }
224 return tx.Commit()
225}
internal/store/snippets_test.go added +108
@@ -0,0 +1,108 @@
1package store
2
3import (
4 "errors"
5 "testing"
6)
7
8func TestSnippets(t *testing.T) {
9 s := open(t)
10 if err := s.MigrateUp(); err != nil {
11 t.Fatal(err)
12 }
13 alice, err := s.CreateUser("alice", false)
14 if err != nil {
15 t.Fatal(err)
16 }
17 id, err := s.CreateSnippet(alice, "abcdef012345", "a log", "unlisted", "build.log", []byte("ok\n"))
18 if err != nil {
19 t.Fatal(err)
20 }
21 if _, err := s.CreateSnippet(alice, "abcdef012345", "", "public", "x", []byte("x")); !errors.Is(err, ErrExists) {
22 t.Fatalf("duplicate public id: %v", err)
23 }
24 sn, err := s.SnippetByPublicID("abcdef012345")
25 if err != nil {
26 t.Fatal(err)
27 }
28 if sn.ID != id || sn.OwnerName != "alice" || sn.Visibility != "unlisted" || sn.Description != "a log" {
29 t.Fatalf("snippet: %+v", sn)
30 }
31 if len(sn.Files) != 1 || sn.Files[0].Name != "build.log" || sn.Files[0].Size != 3 || sn.Files[0].Content != nil {
32 t.Fatalf("files on lookup: %+v", sn.Files)
33 }
34
35 // Set adds, then replaces; remove drops; the file read carries content.
36 if err := s.SetSnippetFile(id, "notes.txt", []byte("one\n")); err != nil {
37 t.Fatal(err)
38 }
39 if err := s.SetSnippetFile(id, "notes.txt", []byte("two\n")); err != nil {
40 t.Fatal(err)
41 }
42 f, err := s.SnippetFile(id, "notes.txt")
43 if err != nil || string(f.Content) != "two\n" || f.Size != 4 {
44 t.Fatalf("file after replace: %+v %v", f, err)
45 }
46 files, err := s.SnippetFiles(id)
47 if err != nil || len(files) != 2 || files[0].Name != "build.log" || string(files[1].Content) != "two\n" {
48 t.Fatalf("files: %+v %v", files, err)
49 }
50 if err := s.RemoveSnippetFile(id, "notes.txt"); err != nil {
51 t.Fatal(err)
52 }
53 if err := s.RemoveSnippetFile(id, "notes.txt"); !errors.Is(err, ErrNotFound) {
54 t.Fatalf("remove missing file: %v", err)
55 }
56 if _, err := s.SnippetFile(id, "notes.txt"); !errors.Is(err, ErrNotFound) {
57 t.Fatalf("read removed file: %v", err)
58 }
59
60 // Listing: public only unless all; newest first; keyset by id.
61 pub, err := s.CreateSnippet(alice, "000000000001", "", "public", "a", []byte("a"))
62 if err != nil {
63 t.Fatal(err)
64 }
65 if _, err := s.CreateSnippet(alice, "000000000002", "", "private", "b", []byte("b")); err != nil {
66 t.Fatal(err)
67 }
68 got, err := s.ListSnippets(alice, false, 0, 0)
69 if err != nil || len(got) != 1 || got[0].ID != pub {
70 t.Fatalf("public list: %+v %v", got, err)
71 }
72 got, err = s.ListSnippets(alice, true, 0, 0)
73 if err != nil || len(got) != 3 || got[0].PublicID != "000000000002" || got[2].ID != id {
74 t.Fatalf("all list: %+v %v", got, err)
75 }
76 got, err = s.ListSnippets(alice, true, 2, got[0].ID)
77 if err != nil || len(got) != 2 || got[0].ID != pub {
78 t.Fatalf("paged list: %+v %v", got, err)
79 }
80 if n, err := s.CountSnippets(alice, false); err != nil || n != 1 {
81 t.Fatalf("public count: %d %v", n, err)
82 }
83 if n, err := s.CountSnippets(alice, true); err != nil || n != 3 {
84 t.Fatalf("all count: %d %v", n, err)
85 }
86
87 // Update, delete, and the owner cascade.
88 if err := s.UpdateSnippet(id, "renamed", "public"); err != nil {
89 t.Fatal(err)
90 }
91 sn, _ = s.SnippetByPublicID("abcdef012345")
92 if sn.Description != "renamed" || sn.Visibility != "public" {
93 t.Fatalf("after update: %+v", sn)
94 }
95 if err := s.DeleteSnippet(id); err != nil {
96 t.Fatal(err)
97 }
98 if _, err := s.SnippetByPublicID("abcdef012345"); !errors.Is(err, ErrNotFound) {
99 t.Fatalf("after delete: %v", err)
100 }
101 if err := s.DeleteUser(alice); err != nil {
102 t.Fatal(err)
103 }
104 var n int
105 if err := s.DB.QueryRow("SELECT COUNT(*) FROM snippet_files").Scan(&n); err != nil || n != 0 {
106 t.Fatalf("files after user delete: %d %v", n, err)
107 }
108}
internal/web/static/style.css +1
@@ -1499,6 +1499,7 @@ table.tree td.name.dir a { color: var(--accent); }
1499p.clone { margin: 0 0 var(--sp-3); } 1499p.clone { margin: 0 0 var(--sp-3); }
1500p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); } 1500p.filefacts { color: var(--muted); font-size: var(--fs-1); margin: 0 0 var(--sp-3); }
1501.pathbar .actions { font-size: var(--fs-1); color: var(--muted); } 1501.pathbar .actions { font-size: var(--fs-1); color: var(--muted); }
1502.snippetfile { margin-bottom: var(--sp-5); }
1502 1503
1503/* merge request: a two-column split, so state has somewhere to live that 1504/* merge request: a two-column split, so state has somewhere to live that
1504 is not a run-on sentence under the title */ 1505 is not a run-on sentence under the title */
internal/web/templates/owner.html +1
@@ -23,6 +23,7 @@
23{{range .Repos}}{{template "reporow" .}} 23{{range .Repos}}{{template "reporow" .}}
24{{else}}<li class="empty">no visible repositories</li>{{end}} 24{{else}}<li class="empty">no visible repositories</li>{{end}}
25</ul> 25</ul>
26{{if or .Snippets .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets">snippets{{if .Snippets}} <span class="count">{{.Snippets}}</span>{{end}}</a></p>{{end}}
26 27
27{{if .Self}} 28{{if .Self}}
28<h2>organizations</h2> 29<h2>organizations</h2>
internal/web/templates/snippet.html added +51
@@ -0,0 +1,51 @@
1{{define "title"}}{{if .Snippet.Description}}{{.Snippet.Description}}{{else}}{{.Snippet.PublicID}}{{end}} · {{.Owner}}{{end}}
2{{define "content"}}
3<h1><a href="/{{.Owner}}">{{.Owner}}</a> / <a href="/{{.Owner}}/-/snippets">snippets</a> / {{.Snippet.PublicID}}</h1>
4{{if .Snippet.Description}}<p class="desc lede">{{.Snippet.Description}}</p>{{end}}
5<p class="meta"><span class="chip chip-neutral">{{.Snippet.Visibility}}</span> · updated {{.Snippet.UpdatedAt}} · <code>gitbay snippet show {{.Snippet.PublicID}}</code></p>
6{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
7{{range .Files}}
8<section class="snippetfile" id="file-{{.Name}}">
9<div class="pathbar">
10 <span class="crumbs"><strong>{{.Name}}</strong></span>
11 <span class="spacer"></span>
12 <span class="actions"><a href="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/raw/{{.Name}}">raw</a></span>
13</div>
14<p class="filefacts">{{.Lines}} lines · {{.Size}} bytes</p>
15{{if .TooLarge}}<p class="empty-note">too large to render here — <a href="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/raw/{{.Name}}">raw</a></p>{{else}}<div class="code">{{.HTML}}</div>{{end}}
16{{if $.CanWrite}}<details class="editbox"><summary>edit {{.Name}}</summary>
17<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file" class="commentform">
18<input type="hidden" name="name" value="{{.Name}}">
19<p><textarea name="content" aria-label="Content of {{.Name}}" rows="12">{{.Content}}</textarea></p>
20<p><button type="submit">Save</button></p>
21</form>
22<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file/remove">
23<input type="hidden" name="name" value="{{.Name}}">
24<p><button type="submit">Remove {{.Name}}</button></p>
25</form>
26</details>{{end}}
27</section>
28{{end}}
29{{if .CanWrite}}
30<details class="editbox"><summary>add a file</summary>
31<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/file" class="commentform">
32<p><input type="text" name="name" aria-label="File name" placeholder="filename" required></p>
33<p><textarea name="content" aria-label="Content" rows="12" required></textarea></p>
34<p><button type="submit">Add file</button></p>
35</form></details>
36<details class="editbox"><summary>settings</summary>
37<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/edit">
38<p><input type="text" name="description" aria-label="Description" value="{{.Snippet.Description}}" placeholder="description"></p>
39<p><select name="visibility" aria-label="Visibility">
40<option value="public"{{if eq .Snippet.Visibility "public"}} selected{{end}}>public</option>
41<option value="unlisted"{{if eq .Snippet.Visibility "unlisted"}} selected{{end}}>unlisted</option>
42<option value="private"{{if eq .Snippet.Visibility "private"}} selected{{end}}>private</option>
43</select></p>
44<p><button type="submit">Save</button></p>
45</form>
46<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/delete">
47<p><button type="submit">Delete snippet</button></p>
48</form>
49</details>
50{{end}}
51{{end}}
internal/web/templates/snippetnew.html added +16
@@ -0,0 +1,16 @@
1{{define "title"}}new snippet · {{.Owner}}{{end}}
2{{define "content"}}
3<h1>New snippet</h1>
4{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
5<form method="post" action="/{{.Owner}}/-/snippets/new" class="commentform">
6<p><input type="text" name="name" aria-label="File name" placeholder="filename" value="{{.Name}}" required></p>
7<p><input type="text" name="description" aria-label="Description" placeholder="description" value="{{.Description}}"></p>
8<p><select name="visibility" aria-label="Visibility">
9<option value="unlisted"{{if or (eq .Visibility "unlisted") (eq .Visibility "")}} selected{{end}}>unlisted</option>
10<option value="public"{{if eq .Visibility "public"}} selected{{end}}>public</option>
11<option value="private"{{if eq .Visibility "private"}} selected{{end}}>private</option>
12</select></p>
13<p><textarea name="content" aria-label="Content" rows="16" required>{{.Content}}</textarea></p>
14<p><button type="submit">Create snippet</button></p>
15</form>
16{{end}}
internal/web/templates/snippets.html added +16
@@ -0,0 +1,16 @@
1{{define "title"}}snippets · {{.Owner}}{{end}}
2{{define "content"}}
3<h1><a href="/{{.Owner}}">{{.Owner}}</a> snippets</h1>
4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5{{if .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets/new">new snippet</a> · or <code>gitbay snippet create &lt;file&gt; &lt; file</code></p>{{end}}
6{{if .Snippets}}<div class="tablewrap"><table class="keys">
7<tr class="cols"><th scope="col">snippet</th><th scope="col">files</th>{{if .All}}<th scope="col">visibility</th>{{end}}<th scope="col">updated</th></tr>
8{{range .Snippets}}<tr>
9 <td><a href="/{{$.Owner}}/-/snippets/{{.PublicID}}">{{if .Description}}{{.Description}}{{else}}{{.PublicID}}{{end}}</a></td>
10 <td><span class="mono">{{.Names}}</span></td>
11 {{if $.All}}<td><span class="chip chip-neutral">{{.Visibility}}</span></td>{{end}}
12 <td>{{.UpdatedAt}}</td>
13</tr>
14{{end}}</table></div>
15{{else}}<p class="none">No snippets yet.</p>{{end}}
16{{end}}