docs/plans/2026-09-11-snippets.md

v1.34.0
gitbay/docs/plans/2026-09-11-snippets.md rendered · source · history · blame · raw

1910 lines · 71051 bytes

Snippets: implementation plan

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.

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.

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.

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/.

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

Global Constraints

  • 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).
  • A command that reads stdin sets ReadsStdin: true, or control.go swaps in an empty reader and --file - stores nothing without an error.
  • 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.
  • Private things return not-found (exit 3, HTTP 404), never a denial that confirms they exist.
  • Every <input> and <textarea> a person types into carries an aria-label (internal/httpd/inputlabels_test.go). One <h1> per page.
  • Never mention an assistant or model anywhere: commit messages, comments, docs.
  • Commit messages reference the issue: Ref #195 on each task, Closes #195 on the last.
  • 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.
  • 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.
  • Work on branch snippets in the worktree ../gitbay-snippets, which already holds the spec.

Names used across tasks, fixed here so the tasks agree:

  • Store: store.Snippet, store.SnippetFile, CreateSnippet, SnippetByPublicID, SnippetFiles, SnippetFile, ListSnippets, CountSnippets, UpdateSnippet, DeleteSnippet, SetSnippetFile, RemoveSnippetFile.
  • Policy: policy.CanReadSnippet, policy.CanWriteSnippet.
  • Config: Limits.MaxSnippetBytes (max_snippet_bytes, default 1 << 20).
  • Control: exported control.SnippetOut, control.SnippetFileOut; the constant maxSnippetFiles = 64.
  • Web: handlers snippetsPage, snippetPage, snippetRaw, snippetNewForm, snippetNewSubmit, snippetEditSubmit, snippetDeleteSubmit, snippetFileSubmit, snippetFileRemoveSubmit; templates snippets.html, snippet.html, snippetnew.html.

Task 1: Migration 0054 and the store

Files:

  • Create: internal/store/migrations/0054_snippets.up.sql
  • Create: internal/store/migrations/0054_snippets.down.sql
  • Create: internal/store/snippets.go
  • Test: internal/store/snippets_test.go

Interfaces:

  • Produces the tables snippets and snippet_files as in the spec.
  • Produces:
type Snippet struct {
	ID          int64
	PublicID    string
	OwnerID     int64
	OwnerName   string
	Description string
	Visibility  string // public | unlisted | private
	CreatedAt   string
	UpdatedAt   string
	Files       []SnippetFile // names and sizes; Content is filled by SnippetFiles and SnippetFile only
}
type SnippetFile struct {
	Name    string
	Size    int64
	Content []byte
}
func (s *Store) CreateSnippet(ownerID int64, publicID, description, visibility, name string, content []byte) (int64, error) // ErrExists on a public_id collision
func (s *Store) SnippetByPublicID(publicID string) (Snippet, error)                 // ErrNotFound; Files carry Name and Size
func (s *Store) SnippetFiles(id int64) ([]SnippetFile, error)                       // with Content, by name
func (s *Store) SnippetFile(id int64, name string) (SnippetFile, error)             // ErrNotFound
func (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
func (s *Store) CountSnippets(ownerID int64, all bool) (int, error)
func (s *Store) UpdateSnippet(id int64, description, visibility string) error
func (s *Store) DeleteSnippet(id int64) error
func (s *Store) SetSnippetFile(id int64, name string, content []byte) error         // insert or replace; touches updated_at
func (s *Store) RemoveSnippetFile(id int64, name string) error                      // ErrNotFound when absent; touches updated_at
  • Step 1: Write the failing store test

Create internal/store/snippets_test.go:

package store

import (
	"errors"
	"testing"
)

func TestSnippets(t *testing.T) {
	s := open(t)
	alice, err := s.CreateUser("alice", false)
	if err != nil {
		t.Fatal(err)
	}
	id, err := s.CreateSnippet(alice, "abcdef012345", "a log", "unlisted", "build.log", []byte("ok\n"))
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.CreateSnippet(alice, "abcdef012345", "", "public", "x", []byte("x")); !errors.Is(err, ErrExists) {
		t.Fatalf("duplicate public id: %v", err)
	}
	sn, err := s.SnippetByPublicID("abcdef012345")
	if err != nil {
		t.Fatal(err)
	}
	if sn.ID != id || sn.OwnerName != "alice" || sn.Visibility != "unlisted" || sn.Description != "a log" {
		t.Fatalf("snippet: %+v", sn)
	}
	if len(sn.Files) != 1 || sn.Files[0].Name != "build.log" || sn.Files[0].Size != 3 || sn.Files[0].Content != nil {
		t.Fatalf("files on lookup: %+v", sn.Files)
	}

	// Set adds, then replaces; remove drops; the file read carries content.
	if err := s.SetSnippetFile(id, "notes.txt", []byte("one\n")); err != nil {
		t.Fatal(err)
	}
	if err := s.SetSnippetFile(id, "notes.txt", []byte("two\n")); err != nil {
		t.Fatal(err)
	}
	f, err := s.SnippetFile(id, "notes.txt")
	if err != nil || string(f.Content) != "two\n" || f.Size != 4 {
		t.Fatalf("file after replace: %+v %v", f, err)
	}
	files, err := s.SnippetFiles(id)
	if err != nil || len(files) != 2 || files[0].Name != "build.log" || string(files[1].Content) != "two\n" {
		t.Fatalf("files: %+v %v", files, err)
	}
	if err := s.RemoveSnippetFile(id, "notes.txt"); err != nil {
		t.Fatal(err)
	}
	if err := s.RemoveSnippetFile(id, "notes.txt"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("remove missing file: %v", err)
	}
	if _, err := s.SnippetFile(id, "notes.txt"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("read removed file: %v", err)
	}

	// Listing: public only unless all; newest first; keyset by id.
	pub, err := s.CreateSnippet(alice, "000000000001", "", "public", "a", []byte("a"))
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.CreateSnippet(alice, "000000000002", "", "private", "b", []byte("b")); err != nil {
		t.Fatal(err)
	}
	got, err := s.ListSnippets(alice, false, 0, 0)
	if err != nil || len(got) != 1 || got[0].ID != pub {
		t.Fatalf("public list: %+v %v", got, err)
	}
	got, err = s.ListSnippets(alice, true, 0, 0)
	if err != nil || len(got) != 3 || got[0].PublicID != "000000000002" || got[2].ID != id {
		t.Fatalf("all list: %+v %v", got, err)
	}
	got, err = s.ListSnippets(alice, true, 2, got[0].ID)
	if err != nil || len(got) != 2 || got[0].ID != pub {
		t.Fatalf("paged list: %+v %v", got, err)
	}
	if n, err := s.CountSnippets(alice, false); err != nil || n != 1 {
		t.Fatalf("public count: %d %v", n, err)
	}
	if n, err := s.CountSnippets(alice, true); err != nil || n != 3 {
		t.Fatalf("all count: %d %v", n, err)
	}

	// Update, delete, and the owner cascade.
	if err := s.UpdateSnippet(id, "renamed", "public"); err != nil {
		t.Fatal(err)
	}
	sn, _ = s.SnippetByPublicID("abcdef012345")
	if sn.Description != "renamed" || sn.Visibility != "public" {
		t.Fatalf("after update: %+v", sn)
	}
	if err := s.DeleteSnippet(id); err != nil {
		t.Fatal(err)
	}
	if _, err := s.SnippetByPublicID("abcdef012345"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("after delete: %v", err)
	}
	if err := s.DeleteUser(alice); err != nil {
		t.Fatal(err)
	}
	var n int
	if err := s.DB.QueryRow("SELECT COUNT(*) FROM snippet_files").Scan(&n); err != nil || n != 0 {
		t.Fatalf("files after user delete: %d %v", n, err)
	}
}
  • Step 2: Run it to see it fail

Run: go test ./internal/store -run TestSnippets Expected: compile error, s.CreateSnippet undefined.

  • Step 3: Write the migration

internal/store/migrations/0054_snippets.up.sql:

-- Snippets: named text files a user owns and shares by URL, outside any
-- repository. public_id is the opaque id in URLs and commands.
CREATE TABLE snippets (
    id          INTEGER PRIMARY KEY,
    public_id   TEXT NOT NULL UNIQUE,
    owner_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    description TEXT NOT NULL DEFAULT '',
    visibility  TEXT NOT NULL CHECK (visibility IN ('public','unlisted','private')),
    created_at  TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
    updated_at  TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX snippets_owner ON snippets(owner_id, id);

CREATE TABLE snippet_files (
    snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
    name       TEXT NOT NULL,
    content    BLOB NOT NULL,
    size       INTEGER NOT NULL,
    PRIMARY KEY (snippet_id, name)
);

internal/store/migrations/0054_snippets.down.sql:

DROP TABLE snippet_files;
DROP TABLE snippets;
  • Step 4: Write the store

Create internal/store/snippets.go:

package store

import (
	"database/sql"
	"errors"
)

type Snippet struct {
	ID          int64
	PublicID    string
	OwnerID     int64
	OwnerName   string
	Description string
	Visibility  string // public | unlisted | private
	CreatedAt   string
	UpdatedAt   string
	// Files carries names and sizes. Content is filled by SnippetFiles and
	// SnippetFile only, so a listing does not read every body.
	Files []SnippetFile
}

type SnippetFile struct {
	Name    string
	Size    int64
	Content []byte
}

const snippetSelect = `
	SELECT s.id, s.public_id, s.owner_id, u.username, s.description, s.visibility, s.created_at, s.updated_at
	FROM snippets s JOIN users u ON u.id = s.owner_id`

func scanSnippet(row interface{ Scan(...any) error }) (Snippet, error) {
	var sn Snippet
	err := row.Scan(&sn.ID, &sn.PublicID, &sn.OwnerID, &sn.OwnerName, &sn.Description, &sn.Visibility, &sn.CreatedAt, &sn.UpdatedAt)
	return sn, err
}

// CreateSnippet inserts the snippet and its first file in one transaction.
// A public_id collision is ErrExists so the caller can draw another.
func (s *Store) CreateSnippet(ownerID int64, publicID, description, visibility, name string, content []byte) (int64, error) {
	tx, err := s.DB.Begin()
	if err != nil {
		return 0, err
	}
	defer tx.Rollback()
	res, err := tx.Exec(
		"INSERT INTO snippets (public_id, owner_id, description, visibility) VALUES (?, ?, ?, ?)",
		publicID, ownerID, description, visibility)
	if err != nil {
		if isUniqueErr(err) {
			return 0, ErrExists
		}
		return 0, err
	}
	id, err := res.LastInsertId()
	if err != nil {
		return 0, err
	}
	if _, err := tx.Exec("INSERT INTO snippet_files (snippet_id, name, content, size) VALUES (?, ?, ?, ?)",
		id, name, content, len(content)); err != nil {
		return 0, err
	}
	return id, tx.Commit()
}

func (s *Store) SnippetByPublicID(publicID string) (Snippet, error) {
	sn, err := scanSnippet(s.DB.QueryRow(snippetSelect+" WHERE s.public_id = ?", publicID))
	if errors.Is(err, sql.ErrNoRows) {
		return sn, ErrNotFound
	}
	if err != nil {
		return sn, err
	}
	sn.Files, err = s.snippetFileNames(sn.ID)
	return sn, err
}

func (s *Store) snippetFileNames(id int64) ([]SnippetFile, error) {
	rows, err := s.DB.Query("SELECT name, size FROM snippet_files WHERE snippet_id = ? ORDER BY name", id)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []SnippetFile
	for rows.Next() {
		var f SnippetFile
		if err := rows.Scan(&f.Name, &f.Size); err != nil {
			return nil, err
		}
		out = append(out, f)
	}
	return out, rows.Err()
}

// SnippetFiles returns every file with its content, by name.
func (s *Store) SnippetFiles(id int64) ([]SnippetFile, error) {
	rows, err := s.DB.Query("SELECT name, size, content FROM snippet_files WHERE snippet_id = ? ORDER BY name", id)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []SnippetFile
	for rows.Next() {
		var f SnippetFile
		if err := rows.Scan(&f.Name, &f.Size, &f.Content); err != nil {
			return nil, err
		}
		out = append(out, f)
	}
	return out, rows.Err()
}

func (s *Store) SnippetFile(id int64, name string) (SnippetFile, error) {
	var f SnippetFile
	err := s.DB.QueryRow("SELECT name, size, content FROM snippet_files WHERE snippet_id = ? AND name = ?", id, name).
		Scan(&f.Name, &f.Size, &f.Content)
	if errors.Is(err, sql.ErrNoRows) {
		return f, ErrNotFound
	}
	return f, err
}

// ListSnippets lists an owner's snippets newest first. all=false keeps
// public ones only. afterID is the keyset cursor: rows older than it.
// Ids grow with creation, so ordering by id is creation order.
func (s *Store) ListSnippets(ownerID int64, all bool, limit int, afterID int64) ([]Snippet, error) {
	q := snippetSelect + " WHERE s.owner_id = ?"
	args := []any{ownerID}
	if !all {
		q += " AND s.visibility = 'public'"
	}
	if afterID > 0 {
		q += " AND s.id < ?"
		args = append(args, afterID)
	}
	q += " ORDER BY s.id DESC"
	if limit > 0 {
		q += " LIMIT ?"
		args = append(args, limit)
	}
	rows, err := s.DB.Query(q, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Snippet
	for rows.Next() {
		sn, err := scanSnippet(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, sn)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	// One query per row for the names; pages are at most 200 rows.
	for i := range out {
		if out[i].Files, err = s.snippetFileNames(out[i].ID); err != nil {
			return nil, err
		}
	}
	return out, nil
}

func (s *Store) CountSnippets(ownerID int64, all bool) (int, error) {
	q := "SELECT COUNT(*) FROM snippets WHERE owner_id = ?"
	if !all {
		q += " AND visibility = 'public'"
	}
	var n int
	err := s.DB.QueryRow(q, ownerID).Scan(&n)
	return n, err
}

func (s *Store) UpdateSnippet(id int64, description, visibility string) error {
	_, err := s.DB.Exec(
		"UPDATE snippets SET description = ?, visibility = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
		description, visibility, id)
	return err
}

func (s *Store) DeleteSnippet(id int64) error {
	_, err := s.DB.Exec("DELETE FROM snippets WHERE id = ?", id)
	return err
}

// SetSnippetFile adds the file or replaces one of the same name.
func (s *Store) SetSnippetFile(id int64, name string, content []byte) error {
	tx, err := s.DB.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	if _, err := tx.Exec(`INSERT INTO snippet_files (snippet_id, name, content, size) VALUES (?, ?, ?, ?)
		ON CONFLICT (snippet_id, name) DO UPDATE SET content = excluded.content, size = excluded.size`,
		id, name, content, len(content)); err != nil {
		return err
	}
	if _, err := tx.Exec("UPDATE snippets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id); err != nil {
		return err
	}
	return tx.Commit()
}

func (s *Store) RemoveSnippetFile(id int64, name string) error {
	tx, err := s.DB.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	res, err := tx.Exec("DELETE FROM snippet_files WHERE snippet_id = ? AND name = ?", id, name)
	if err != nil {
		return err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return ErrNotFound
	}
	if _, err := tx.Exec("UPDATE snippets SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", id); err != nil {
		return err
	}
	return tx.Commit()
}
  • Step 5: Run the store tests

Run: go test ./internal/store Expected: PASS, including TestMigrateUpDown (the down file drops both tables) and TestSnippets.

  • Step 6: Commit
git add internal/store/migrations/0054_snippets.up.sql internal/store/migrations/0054_snippets.down.sql internal/store/snippets.go internal/store/snippets_test.go
git commit -m "store: snippets and snippet_files (migration 0054)

Ref #195"

Task 2: Policy, config limit, and the snippet commands

Files:

  • Create: internal/policy/snippets.go
  • Modify: internal/config/config.go:175-193 (the Limits struct) and the defaults near line 217
  • Create: internal/control/snippet.go
  • Modify: cmd/gitbay/main.go:74-77 (after the wiki group)
  • Modify: e2e/readonly_test.go:72-73 (fixtures) and :146-158 (readArgs)
  • Test: e2e/snippet_test.go

Interfaces:

  • Consumes the store API from Task 1.
  • Produces:
// internal/policy/snippets.go
func CanReadSnippet(user store.User, sn store.Snippet) bool
func CanWriteSnippet(user store.User, sn store.Snippet) bool

// internal/config/config.go
Limits.MaxSnippetBytes int64 `toml:"max_snippet_bytes"` // default 1 << 20

// internal/control/snippet.go
type SnippetFileOut struct {
	Name    string `json:"name"`
	Size    int64  `json:"size"`
	Content string `json:"content,omitempty"`
}
type SnippetOut struct {
	ID          string           `json:"id"`
	URL         string           `json:"url"`
	Owner       string           `json:"owner"`
	Description string           `json:"description"`
	Visibility  string           `json:"visibility"`
	CreatedAt   string           `json:"created_at"`
	UpdatedAt   string           `json:"updated_at"`
	Files       []SnippetFileOut `json:"files"`
}

Commands registered: snippet create, snippet show, snippet list, snippet edit, snippet delete, snippet file set, snippet file get, snippet file remove.

  • Step 1: Write the failing e2e test

Create e2e/snippet_test.go:

package e2e

import (
	"encoding/json"
	"regexp"
	"strings"
	"testing"
)

// Snippets over SSH: create from stdin, read back, list by visibility,
// edit files and metadata, and the not-found rule for private ones.
func TestSnippets(t *testing.T) {
	inst := startInstance(t)
	aliceKey := inst.newKey(t, "alice")
	bobKey := inst.newKey(t, "bob")
	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
	inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
	must := func(key, stdin string, args ...string) string {
		t.Helper()
		out, errOut, code := inst.ssh(t, key, stdin, args...)
		if code != 0 {
			t.Fatalf("%v: exit %d %s", args, code, errOut)
		}
		return out
	}
	fails := func(key, stdin string, want int, args ...string) string {
		t.Helper()
		_, errOut, code := inst.ssh(t, key, stdin, args...)
		if code != want {
			t.Fatalf("%v: exit %d, want %d: %s", args, code, want, errOut)
		}
		return errOut
	}
	idOf := func(out string) string {
		t.Helper()
		var env struct {
			Data struct {
				ID  string `json:"id"`
				URL string `json:"url"`
			} `json:"data"`
		}
		if err := json.Unmarshal([]byte(out), &env); err != nil || !regexp.MustCompile(`^[0-9a-f]{12}$`).MatchString(env.Data.ID) {
			t.Fatalf("create output: %s", out)
		}
		if !strings.HasSuffix(env.Data.URL, "/alice/-/snippets/"+env.Data.ID) {
			t.Fatalf("url: %s", env.Data.URL)
		}
		return env.Data.ID
	}

	// Create with the default visibility, read back byte for byte.
	body := "line one\nline two\n"
	unlisted := idOf(must(aliceKey, body, "snippet", "create", "build.log", "--description", "'a log'", "--json"))
	if got := must(aliceKey, "", "snippet", "file", "get", unlisted, "build.log"); got != body {
		t.Fatalf("file get: %q", got)
	}
	out := must(aliceKey, "", "snippet", "show", unlisted, "--json")
	if !strings.Contains(out, `"visibility":"unlisted"`) || !strings.Contains(out, `"content":"line one\nline two\n"`) {
		t.Fatalf("show: %s", out)
	}
	public := idOf(must(aliceKey, "pub\n", "snippet", "create", "a.txt", "--visibility", "public", "--json"))
	private := idOf(must(aliceKey, "sec\n", "snippet", "create", "b.txt", "--visibility", "private", "--json"))

	// Refusals on create: empty, not text, over the limit, bad name.
	fails(aliceKey, "", 2, "snippet", "create", "x.txt")
	fails(aliceKey, "\xff\xfe\n", 2, "snippet", "create", "x.bin")
	fails(aliceKey, strings.Repeat("x", 1<<20+1), 2, "snippet", "create", "big.txt")
	fails(aliceKey, "x\n", 2, "snippet", "create", "../x")
	fails(aliceKey, "x\n", 2, "snippet", "create", "x.txt", "--visibility", "secret")

	// Visibility from the other side. Private is not-found, never denied.
	fails(bobKey, "", 3, "snippet", "show", private)
	fails(bobKey, "", 3, "snippet", "file", "get", private, "b.txt")
	must(bobKey, "", "snippet", "show", unlisted)
	out = must(bobKey, "", "snippet", "list", "alice", "--json")
	if !strings.Contains(out, public) || strings.Contains(out, unlisted) || strings.Contains(out, private) {
		t.Fatalf("bob's view of alice's list: %s", out)
	}
	out = must(aliceKey, "", "snippet", "list", "--json")
	for _, id := range []string{public, unlisted, private} {
		if !strings.Contains(out, id) {
			t.Fatalf("alice's own list lacks %s: %s", id, out)
		}
	}
	fails(bobKey, "", 3, "snippet", "list", "nobody")

	// Paging: two pages of one, the second reached by cursor.
	out = must(aliceKey, "", "snippet", "list", "--limit", "1", "--json")
	var page struct {
		Data struct {
			Items []struct{ ID string `json:"id"` } `json:"items"`
			Next  string                            `json:"next"`
		} `json:"data"`
	}
	json.Unmarshal([]byte(out), &page)
	if len(page.Data.Items) != 1 || page.Data.Items[0].ID != private || page.Data.Next == "" {
		t.Fatalf("first page: %s", out)
	}
	out = must(aliceKey, "", "snippet", "list", "--limit", "1", "--cursor", page.Data.Next, "--json")
	if !strings.Contains(out, public) {
		t.Fatalf("second page: %s", out)
	}

	// Files: set adds, set replaces, remove drops, the last one stays.
	must(aliceKey, "notes\n", "snippet", "file", "set", unlisted, "notes.txt")
	must(aliceKey, "changed\n", "snippet", "file", "set", unlisted, "build.log")
	if got := must(aliceKey, "", "snippet", "file", "get", unlisted, "build.log"); got != "changed\n" {
		t.Fatalf("after replace: %q", got)
	}
	must(aliceKey, "", "snippet", "file", "remove", unlisted, "notes.txt")
	fails(aliceKey, "", 3, "snippet", "file", "remove", unlisted, "notes.txt")
	if msg := fails(aliceKey, "", 2, "snippet", "file", "remove", unlisted, "build.log"); !strings.Contains(msg, "at least one file") {
		t.Fatalf("last file removal: %s", msg)
	}

	// Only the owner writes: denied on a readable one, not-found on a private one.
	fails(bobKey, "x\n", 4, "snippet", "file", "set", unlisted, "x.txt")
	fails(bobKey, "", 4, "snippet", "edit", unlisted, "--description", "mine")
	fails(bobKey, "", 4, "snippet", "delete", unlisted)
	fails(bobKey, "", 3, "snippet", "delete", private)

	// Edit moves visibility and the listing follows.
	fails(aliceKey, "", 2, "snippet", "edit", unlisted)
	must(aliceKey, "", "snippet", "edit", unlisted, "--visibility", "public", "--description", "shared")
	out = must(bobKey, "", "snippet", "list", "alice", "--json")
	if !strings.Contains(out, unlisted) || !strings.Contains(out, `"description":"shared"`) {
		t.Fatalf("list after edit: %s", out)
	}

	// Delete, then gone; deleting the user takes the rest.
	must(aliceKey, "", "snippet", "delete", unlisted)
	fails(aliceKey, "", 3, "snippet", "show", unlisted)
	inst.admin(t, "admin", "user", "delete", "alice", "--yes")
	fails(bobKey, "", 3, "snippet", "show", public)
}
  • Step 2: Run it to see it fail

Run: go test ./e2e -run 'TestSnippets$' Expected: FAIL at the first must: unknown command "snippet" (or similar) with exit 2.

  • Step 3: Policy

Create internal/policy/snippets.go:

package policy

import "gitbay.org/gitbay/internal/store"

// CanReadSnippet: anyone for public and unlisted, the owner and admins
// for private. Anonymous readers have user.ID 0.
func CanReadSnippet(user store.User, sn store.Snippet) bool {
	if sn.Visibility != "private" {
		return true
	}
	return user.ID != 0 && (user.ID == sn.OwnerID || user.IsAdmin)
}

// CanWriteSnippet: the owner and admins.
func CanWriteSnippet(user store.User, sn store.Snippet) bool {
	return user.ID != 0 && (user.ID == sn.OwnerID || user.IsAdmin)
}
  • Step 4: Config limit

In internal/config/config.go, add to Limits after MaxAssetBytes:

	MaxSnippetBytes int64 `toml:"max_snippet_bytes"` // per snippet file

and in the defaults block that sets MaxAssetBytes: 512 << 20, add:

			MaxSnippetBytes: 1 << 20,
  • Step 5: The commands

Create internal/control/snippet.go:

package control

import (
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"strconv"
	"unicode/utf8"

	"gitbay.org/gitbay/internal/policy"
	"gitbay.org/gitbay/internal/protocol"
	"gitbay.org/gitbay/internal/store"
)

// A snippet keeps at most this many files; a paste is not a repository.
const maxSnippetFiles = 64

func init() {
	register(Command{Path: []string{"snippet", "create"},
		Summary:    "create a snippet from one file on stdin",
		Usage:      "snippet create <filename> [--description <d>] [--visibility public|unlisted|private] < file",
		ReadsStdin: true, Run: runSnippetCreate})
	register(Command{Path: []string{"snippet", "show"},
		Summary: "show a snippet's metadata and files",
		Usage:   "snippet show <id>", ReadOnly: true, Run: runSnippetShow})
	register(Command{Path: []string{"snippet", "list"},
		Summary: "list your snippets, or an owner's public ones",
		Usage:   "snippet list [<owner>] [--limit n] [--cursor c]", ReadOnly: true, Run: runSnippetList})
	register(Command{Path: []string{"snippet", "edit"},
		Summary: "change a snippet's description or visibility",
		Usage:   "snippet edit <id> [--description <d>] [--visibility public|unlisted|private]", Run: runSnippetEdit})
	register(Command{Path: []string{"snippet", "delete"},
		Summary: "delete a snippet and its files",
		Usage:   "snippet delete <id>", Run: runSnippetDelete})
	register(Command{Path: []string{"snippet", "file", "set"},
		Summary:    "add a file to a snippet, or replace one, from stdin",
		Usage:      "snippet file set <id> <filename> < file",
		ReadsStdin: true, Run: runSnippetFileSet})
	register(Command{Path: []string{"snippet", "file", "get"},
		Summary:  "write a snippet file to stdout",
		Usage:    "snippet file get <id> <filename> > file", ReadOnly: true, Run: runSnippetFileGet})
	register(Command{Path: []string{"snippet", "file", "remove"},
		Summary: "remove a file from a snippet",
		Usage:   "snippet file remove <id> <filename>", Run: runSnippetFileRemove})
}

type SnippetFileOut struct {
	Name    string `json:"name"`
	Size    int64  `json:"size"`
	Content string `json:"content,omitempty"`
}

type SnippetOut struct {
	ID          string           `json:"id"`
	URL         string           `json:"url"`
	Owner       string           `json:"owner"`
	Description string           `json:"description"`
	Visibility  string           `json:"visibility"`
	CreatedAt   string           `json:"created_at"`
	UpdatedAt   string           `json:"updated_at"`
	Files       []SnippetFileOut `json:"files"`
}

func snippetURL(c *Ctx, sn store.Snippet) string {
	return c.Cfg.Server.SiteURL + "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
}

func snippetOut(c *Ctx, sn store.Snippet) SnippetOut {
	o := SnippetOut{ID: sn.PublicID, URL: snippetURL(c, sn), Owner: sn.OwnerName,
		Description: sn.Description, Visibility: sn.Visibility,
		CreatedAt: sn.CreatedAt, UpdatedAt: sn.UpdatedAt, Files: []SnippetFileOut{}}
	for _, f := range sn.Files {
		o.Files = append(o.Files, SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)})
	}
	return o
}

func validSnippetVisibility(v string) bool {
	return v == "public" || v == "unlisted" || v == "private"
}

// snippetRef loads a snippet the caller may read; with write, one they
// may change. Unreadable and missing are the same not-found, so a
// private id cannot be confirmed by probing.
func snippetRef(c *Ctx, id string, write bool) (store.Snippet, int) {
	sn, err := c.Store.SnippetByPublicID(id)
	if err != nil && !errors.Is(err, store.ErrNotFound) {
		return sn, c.fail(protocol.ExitFailure, "%v", err)
	}
	if err != nil || !policy.CanReadSnippet(c.User, sn) {
		return sn, c.fail(protocol.ExitNotFound, "no snippet %q", id)
	}
	if write && !policy.CanWriteSnippet(c.User, sn) {
		return sn, c.fail(protocol.ExitDenied, "snippet %s belongs to %s", id, sn.OwnerName)
	}
	return sn, -1
}

// readSnippetBody reads one file from stdin under the limit, and insists
// on text: the page highlights it and the raw route serves text/plain.
func readSnippetBody(c *Ctx) ([]byte, int) {
	limit := c.Cfg.Limits.MaxSnippetBytes
	data, err := io.ReadAll(io.LimitReader(c.Stdin, limit+1))
	if err != nil {
		return nil, c.fail(protocol.ExitFailure, "reading stdin: %v", err)
	}
	if int64(len(data)) > limit {
		return nil, c.fail(protocol.ExitUsage, "file exceeds max_snippet_bytes (%d)", limit)
	}
	if len(data) == 0 {
		return nil, c.fail(protocol.ExitUsage, "empty file: pipe it on stdin")
	}
	if !utf8.Valid(data) {
		return nil, c.fail(protocol.ExitUsage, "snippets hold text: the file is not valid UTF-8")
	}
	return data, -1
}

func checkSnippetFileName(c *Ctx, name string) int {
	if !assetNamePat.MatchString(name) {
		return c.fail(protocol.ExitUsage, "invalid file name %q: letters, digits, '._+-'; must not start with '.'", name)
	}
	return -1
}

func newSnippetID() string {
	buf := make([]byte, 6)
	rand.Read(buf)
	return hex.EncodeToString(buf)
}

func runSnippetCreate(c *Ctx, args []string) int {
	const usage = "usage: snippet create <filename> [--description <d>] [--visibility public|unlisted|private] < file"
	f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: usage})
	if err != nil {
		return c.fail(protocol.ExitUsage, "%v", err)
	}
	name := f.pos(0)
	if name == "" {
		return c.fail(protocol.ExitUsage, usage)
	}
	if code := checkSnippetFileName(c, name); code >= 0 {
		return code
	}
	visibility := f.Value("--visibility")
	if visibility == "" {
		visibility = "unlisted"
	}
	if !validSnippetVisibility(visibility) {
		return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
	}
	data, code := readSnippetBody(c)
	if code >= 0 {
		return code
	}
	var pid string
	for try := 0; ; try++ {
		pid = newSnippetID()
		_, err = c.Store.CreateSnippet(c.User.ID, pid, f.Value("--description"), visibility, name, data)
		if !errors.Is(err, store.ErrExists) || try == 4 {
			break
		}
	}
	if err != nil {
		return c.failErr(err)
	}
	sn, err := c.Store.SnippetByPublicID(pid)
	if err != nil {
		return c.fail(protocol.ExitFailure, "%v", err)
	}
	return c.emit(snippetOut(c, sn), func(w io.Writer) {
		fmt.Fprintf(w, "created snippet %s\n%s\n", sn.PublicID, snippetURL(c, sn))
	})
}

func runSnippetShow(c *Ctx, args []string) int {
	if len(args) != 1 {
		return c.fail(protocol.ExitUsage, "usage: snippet show <id>")
	}
	sn, code := snippetRef(c, args[0], false)
	if code >= 0 {
		return code
	}
	files, err := c.Store.SnippetFiles(sn.ID)
	if err != nil {
		return c.fail(protocol.ExitFailure, "%v", err)
	}
	sn.Files = files
	return c.emit(snippetOut(c, sn), func(w io.Writer) {
		fmt.Fprintf(w, "snippet %s by %s (%s)\n", sn.PublicID, sn.OwnerName, sn.Visibility)
		if sn.Description != "" {
			fmt.Fprintf(w, "%s\n", sn.Description)
		}
		fmt.Fprintf(w, "%s\nupdated %s\n", snippetURL(c, sn), sn.UpdatedAt)
		for _, f := range files {
			fmt.Fprintf(w, "  %s\t%d bytes\n", f.Name, f.Size)
		}
	})
}

func runSnippetList(c *Ctx, args []string) int {
	rest, p, code := parsePageFlags(c, args, "snippet", true)
	if code >= 0 {
		return code
	}
	if len(rest) > 1 {
		return c.fail(protocol.ExitUsage, "usage: snippet list [<owner>] [--limit n] [--cursor c]")
	}
	owner := c.User
	if len(rest) == 1 {
		u, err := c.Store.UserByUsername(rest[0])
		if errors.Is(err, store.ErrNotFound) {
			return c.fail(protocol.ExitNotFound, "no user %q", rest[0])
		}
		if err != nil {
			return c.fail(protocol.ExitFailure, "%v", err)
		}
		owner = u
	}
	all := owner.ID == c.User.ID || c.User.IsAdmin
	rows, err := c.Store.ListSnippets(owner.ID, all, p.queryLimit(), p.keyInt())
	if err != nil {
		return c.fail(protocol.ExitFailure, "%v", err)
	}
	rows, next := trimPage(p, rows, "snippet", func(sn store.Snippet) string { return strconv.FormatInt(sn.ID, 10) })
	items := make([]SnippetOut, 0, len(rows))
	for _, sn := range rows {
		items = append(items, snippetOut(c, sn))
	}
	return c.emitPage(p, items, next, func(w io.Writer) {
		for _, sn := range rows {
			names := ""
			for i, f := range sn.Files {
				if i > 0 {
					names += ", "
				}
				names += f.Name
			}
			fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", sn.PublicID, sn.Visibility, names, sn.Description)
		}
	})
}

func runSnippetEdit(c *Ctx, args []string) int {
	const usage = "usage: snippet edit <id> [--description <d>] [--visibility public|unlisted|private]"
	f, err := parseFlags(args, flagSpec{Values: []string{"--description", "--visibility"}, MaxPos: 1, Usage: usage})
	if err != nil {
		return c.fail(protocol.ExitUsage, "%v", err)
	}
	if f.pos(0) == "" || (!f.Has("--description") && !f.Has("--visibility")) {
		return c.fail(protocol.ExitUsage, usage)
	}
	sn, code := snippetRef(c, f.pos(0), true)
	if code >= 0 {
		return code
	}
	description, visibility := sn.Description, sn.Visibility
	if f.Has("--description") {
		description = f.Value("--description")
	}
	if f.Has("--visibility") {
		visibility = f.Value("--visibility")
		if !validSnippetVisibility(visibility) {
			return c.fail(protocol.ExitUsage, "visibility is public, unlisted or private")
		}
	}
	if err := c.Store.UpdateSnippet(sn.ID, description, visibility); err != nil {
		return c.failErr(err)
	}
	sn, err = c.Store.SnippetByPublicID(sn.PublicID)
	if err != nil {
		return c.fail(protocol.ExitFailure, "%v", err)
	}
	return c.emit(snippetOut(c, sn), func(w io.Writer) {
		fmt.Fprintf(w, "updated snippet %s (%s)\n", sn.PublicID, sn.Visibility)
	})
}

func runSnippetDelete(c *Ctx, args []string) int {
	if len(args) != 1 {
		return c.fail(protocol.ExitUsage, "usage: snippet delete <id>")
	}
	sn, code := snippetRef(c, args[0], true)
	if code >= 0 {
		return code
	}
	if err := c.Store.DeleteSnippet(sn.ID); err != nil {
		return c.failErr(err)
	}
	return c.emit(map[string]string{"id": sn.PublicID}, func(w io.Writer) {
		fmt.Fprintf(w, "deleted snippet %s\n", sn.PublicID)
	})
}

func runSnippetFileSet(c *Ctx, args []string) int {
	if len(args) != 2 {
		return c.fail(protocol.ExitUsage, "usage: snippet file set <id> <filename> < file")
	}
	sn, code := snippetRef(c, args[0], true)
	if code >= 0 {
		return code
	}
	name := args[1]
	if code := checkSnippetFileName(c, name); code >= 0 {
		return code
	}
	exists := false
	for _, f := range sn.Files {
		exists = exists || f.Name == name
	}
	if !exists && len(sn.Files) >= maxSnippetFiles {
		return c.fail(protocol.ExitUsage, "a snippet holds at most %d files", maxSnippetFiles)
	}
	data, code := readSnippetBody(c)
	if code >= 0 {
		return code
	}
	if err := c.Store.SetSnippetFile(sn.ID, name, data); err != nil {
		return c.failErr(err)
	}
	return c.emit(SnippetFileOut{Name: name, Size: int64(len(data))}, func(w io.Writer) {
		fmt.Fprintf(w, "set %s (%d bytes) on snippet %s\n", name, len(data), sn.PublicID)
	})
}

func runSnippetFileGet(c *Ctx, args []string) int {
	if len(args) != 2 {
		return c.fail(protocol.ExitUsage, "usage: snippet file get <id> <filename> > file")
	}
	sn, code := snippetRef(c, args[0], false)
	if code >= 0 {
		return code
	}
	f, err := c.Store.SnippetFile(sn.ID, args[1])
	if errors.Is(err, store.ErrNotFound) {
		return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
	}
	if err != nil {
		return c.fail(protocol.ExitFailure, "%v", err)
	}
	if c.JSON {
		return c.emit(SnippetFileOut{Name: f.Name, Size: f.Size, Content: string(f.Content)}, nil)
	}
	if _, err := c.Stdout.Write(f.Content); err != nil {
		return protocol.ExitFailure
	}
	return protocol.ExitOK
}

func runSnippetFileRemove(c *Ctx, args []string) int {
	if len(args) != 2 {
		return c.fail(protocol.ExitUsage, "usage: snippet file remove <id> <filename>")
	}
	sn, code := snippetRef(c, args[0], true)
	if code >= 0 {
		return code
	}
	if len(sn.Files) == 1 && sn.Files[0].Name == args[1] {
		return c.fail(protocol.ExitUsage, "a snippet keeps at least one file; delete the snippet instead")
	}
	err := c.Store.RemoveSnippetFile(sn.ID, args[1])
	if errors.Is(err, store.ErrNotFound) {
		return c.fail(protocol.ExitNotFound, "no file %q in snippet %s", args[1], sn.PublicID)
	}
	if err != nil {
		return c.failErr(err)
	}
	return c.emit(map[string]string{"id": sn.PublicID, "name": args[1]}, func(w io.Writer) {
		fmt.Fprintf(w, "removed %s from snippet %s\n", args[1], sn.PublicID)
	})
}

One 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.

  • Step 6: CLI rows

In cmd/gitbay/main.go, directly after the group("wiki", ...) entry (around line 77), add:

		group("snippet", "shared text files, outside any repository",
			pass("create", "create from one file on stdin: <filename> [--description d] [--visibility public|unlisted|private] < file",
				passOpts{server: []string{"snippet", "create"}, alwaysStdin: true, stdinWhat: "the file's text"}),
			pass("show", "metadata and files: <id>", passOpts{server: []string{"snippet", "show"}}),
			pass("list", "your snippets, or an owner's public ones: [<owner>] [--limit n] [--cursor c]",
				passOpts{server: []string{"snippet", "list"}}),
			pass("edit", "change description or visibility: <id> [--description d] [--visibility v]",
				passOpts{server: []string{"snippet", "edit"}}),
			pass("delete", "delete a snippet: <id>", passOpts{server: []string{"snippet", "delete"}}),
			group("file", "the files in a snippet",
				pass("set", "add or replace a file from stdin: <id> <filename> < file",
					passOpts{server: []string{"snippet", "file", "set"}, alwaysStdin: true, stdinWhat: "the file's text"}),
				pass("get", "print a file: <id> <filename> > file", passOpts{server: []string{"snippet", "file", "get"}}),
				pass("remove", "remove a file: <id> <filename>", passOpts{server: []string{"snippet", "file", "remove"}}),
			),
		),
  • Step 7: Read-only coverage entries

In e2e/readonly_test.go, after the line must("data\n", "release", "asset", "add", "alice/app", "v1", "a.txt") add:

	snippetOut := must("hello\n", "snippet", "create", "a.txt", "--json")
	snippetID := regexp.MustCompile(`"id":"([0-9a-f]{12})"`).FindStringSubmatch(snippetOut)[1]

Add "regexp" to the file's imports if it is not there. In the readArgs map add, beside the release rows:

		"snippet show":                {snippetID},
		"snippet list":                {},
		"snippet file get":            {snippetID, "a.txt"},
  • Step 8: Build, vet, run the tests

Run: go build ./... && go vet ./... && go test ./internal/control ./internal/policy ./internal/config && go test ./e2e -run 'TestSnippets$|TestCLI$|TestReadOnlyCommandsWriteNothing$' Expected: all PASS. TestCLI proves every snippet command has a CLI row with a stdinWhat; TestReadOnlyCommandsWriteNothing proves the three reads write nothing.

If 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.

  • Step 9: Commit
git 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
git commit -m "control: snippet commands

create, show, list, edit, delete, and file set|get|remove. Content is
UTF-8 under limits.max_snippet_bytes per file, 64 files per snippet.
Private snippets are not-found to everyone but the owner and admins.

Ref #195"

Files:

  • Create: internal/httpd/snippets.go
  • Create: internal/web/templates/snippets.html
  • Create: internal/web/templates/snippet.html
  • Modify: internal/httpd/routes.go:74-75 (beside the /{owner}/-/labels routes)
  • Modify: internal/control/profile.go:165-185 (ProfileOut) and the profile show body near line 296
  • Modify: internal/httpd/web.go:436-466 (the owner.html page struct and its literal)
  • Modify: internal/web/templates/owner.html:21-25
  • Test: e2e/snippetweb_test.go

Interfaces:

  • 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.

  • 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.

  • Step 1: Write the failing e2e test (read half)

Create e2e/snippetweb_test.go:

package e2e

import (
	"encoding/json"
	"net/http"
	"net/url"
	"strings"
	"testing"
)

func snippetIDFrom(t *testing.T, out string) string {
	t.Helper()
	var env struct {
		Data struct {
			ID string `json:"id"`
		} `json:"data"`
	}
	if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.ID == "" {
		t.Fatalf("snippet create: %s", out)
	}
	return env.Data.ID
}

// Snippet pages: the owner's list, one snippet with highlighted files, the
// raw route, the owner-page link, and 404 for what the viewer may not see.
func TestSnippetsWeb(t *testing.T) {
	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
	aliceKey := inst.newKey(t, "alice")
	bobKey := inst.newKey(t, "bob")
	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
	inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
	must := func(key, stdin string, args ...string) string {
		t.Helper()
		out, errOut, code := inst.ssh(t, key, stdin, args...)
		if code != 0 {
			t.Fatalf("%v: exit %d %s", args, code, errOut)
		}
		return out
	}
	public := snippetIDFrom(t, must(aliceKey, "package main\n", "snippet", "create", "main.go", "--visibility", "public", "--description", "'hello world'", "--json"))
	unlisted := snippetIDFrom(t, must(aliceKey, "quiet\n", "snippet", "create", "q.txt", "--json"))
	private := snippetIDFrom(t, must(aliceKey, "secret\n", "snippet", "create", "s.txt", "--visibility", "private", "--json"))

	// Anonymous: the public list, the unlisted page by URL, 404 for private.
	status, body := inst.get(t, "/alice/-/snippets")
	if status != 200 || !strings.Contains(body, public) || strings.Contains(body, unlisted) || strings.Contains(body, private) {
		t.Fatalf("anonymous list: %d\n%s", status, body)
	}
	status, body = inst.get(t, "/alice/-/snippets/"+public)
	if status != 200 || !strings.Contains(body, "hello world") || !strings.Contains(body, `class="chroma"`) || !strings.Contains(body, "/raw/main.go") {
		t.Fatalf("public page: %d\n%s", status, body)
	}
	if status, _ := inst.get(t, "/alice/-/snippets/"+unlisted); status != 200 {
		t.Fatalf("unlisted page: %d", status)
	}
	if status, _ := inst.get(t, "/alice/-/snippets/"+private); status != 404 {
		t.Fatalf("private page for anonymous: %d", status)
	}
	if status, _ := inst.get(t, "/bob/-/snippets/"+public); status != 404 {
		t.Fatalf("id under the wrong owner: %d", status)
	}
	if status, _ := inst.get(t, "/nobody/-/snippets"); status != 404 {
		t.Fatalf("list for a missing owner: %d", status)
	}

	// Raw is text/plain with nosniff, whatever the extension.
	resp, err := http.Get(inst.base() + "/alice/-/snippets/" + public + "/raw/main.go")
	if err != nil {
		t.Fatal(err)
	}
	resp.Body.Close()
	if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/plain") || resp.Header.Get("X-Content-Type-Options") != "nosniff" {
		t.Fatalf("raw headers: %d %v", resp.StatusCode, resp.Header)
	}
	if status, _ := inst.get(t, "/alice/-/snippets/" + public + "/raw/other.go"); status != 404 {
		t.Fatalf("raw for a missing file: %d", status)
	}

	// The owner sees everything with visibility marks; the owner page links.
	alice := inst.login(t, aliceKey)
	status, body = browserGet(t, alice, inst.base()+"/alice/-/snippets")
	if status != 200 || !strings.Contains(body, private) || !strings.Contains(body, ">private<") {
		t.Fatalf("owner list: %d\n%s", status, body)
	}
	if status, body := browserGet(t, alice, inst.base()+"/alice/-/snippets/"+private); status != 200 || !strings.Contains(body, "secret") {
		t.Fatalf("owner's private page: %d", status)
	}
	if status, body := inst.get(t, "/alice"); status != 200 || !strings.Contains(body, `href="/alice/-/snippets"`) {
		t.Fatalf("owner page lacks the snippets link: %d", status)
	}
	// bob has no public snippets and is not the viewer: no link.
	if status, body := inst.get(t, "/bob"); status != 200 || strings.Contains(body, `href="/bob/-/snippets"`) {
		t.Fatalf("bob's page shows a snippets link with nothing to list: %d", status)
	}

	_ = url.Values{}
	_ = bobKey
}

The last two lines keep the imports and bobKey used until Task 4 extends the test; Task 4 removes them.

  • Step 2: Run it to see it fail

Run: go test ./e2e -run 'TestSnippetsWeb$' Expected: FAIL at "anonymous list", status 404.

  • Step 3: Profile count

In internal/control/profile.go, add to ProfileOut after Repos:

	// Snippets counts the owner's snippets the caller may list: public
	// ones, or all of them for the owner and admins. Orgs own none.
	Snippets int `json:"snippets"`

In the profile show body, after the loop that fills d.Repos and before the activity counts, add:

	if kind == "user" {
		all := id == c.User.ID || c.User.IsAdmin
		if d.Snippets, err = c.Store.CountSnippets(id, all); err != nil {
			return c.fail(protocol.ExitFailure, "%v", err)
		}
	}

(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.)

  • Step 4: Routes

In internal/httpd/routes.go, after the /{owner}/-/milestones route add:

		Route{Method: "GET", Pattern: "/{owner}/-/snippets", Handler: s.snippetsPage},
		Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}", Handler: s.snippetPage},
		Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}/raw/{name}", Handler: s.snippetRaw},

These are read routes, registered in every web mode; the literal - and snippets segments keep them from overlapping any /{owner}/{repo}/... pattern.

  • Step 5: Handlers

Create internal/httpd/snippets.go:

package httpd

import (
	"bytes"
	"html/template"
	"net/http"

	"gitbay.org/gitbay/internal/policy"
	"gitbay.org/gitbay/internal/store"
)

// snippetScope resolves the owner and id in the URL for the viewer. A
// missing owner, an id under another owner, and a private snippet the
// viewer may not read are all the same 404.
func (s *Server) snippetScope(w http.ResponseWriter, r *http.Request) (store.Snippet, store.User, bool) {
	viewer := s.viewer(r)
	sn, err := s.st.SnippetByPublicID(r.PathValue("id"))
	if err != nil || sn.OwnerName != r.PathValue("owner") || !policy.CanReadSnippet(viewer, sn) {
		s.notFound(w, r)
		return sn, viewer, false
	}
	return sn, viewer, true
}

type snippetRow struct {
	store.Snippet
	Names string
}

func (s *Server) snippetsPage(w http.ResponseWriter, r *http.Request) {
	viewer := s.viewer(r)
	owner, err := s.st.UserByUsername(r.PathValue("owner"))
	if err != nil {
		s.notFound(w, r)
		return
	}
	self := viewer.ID != 0 && viewer.ID == owner.ID
	all := self || viewer.IsAdmin
	list, err := s.st.ListSnippets(owner.ID, all, 0, 0)
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	rows := make([]snippetRow, 0, len(list))
	for _, sn := range list {
		var names bytes.Buffer
		for i, f := range sn.Files {
			if i > 0 {
				names.WriteString(", ")
			}
			names.WriteString(f.Name)
		}
		rows = append(rows, snippetRow{sn, names.String()})
	}
	s.render(w, "snippets.html", struct {
		basePage
		Owner    string
		Self     bool
		All      bool
		Snippets []snippetRow
		Notice   string
	}{s.baseFor(viewer), owner.Username, self, all, rows, s.takeFlash(w, r)})
}

type snippetFileView struct {
	Name    string
	Size    int64
	Lines   int
	Content string
	HTML    template.HTML
}

func (s *Server) snippetPage(w http.ResponseWriter, r *http.Request) {
	sn, viewer, ok := s.snippetScope(w, r)
	if !ok {
		return
	}
	files, err := s.st.SnippetFiles(sn.ID)
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	views := make([]snippetFileView, 0, len(files))
	for _, f := range files {
		lines := bytes.Count(f.Content, []byte("\n"))
		if len(f.Content) > 0 && f.Content[len(f.Content)-1] != '\n' {
			lines++
		}
		views = append(views, snippetFileView{f.Name, f.Size, lines, string(f.Content), highlight(f.Name, f.Content)})
	}
	s.render(w, "snippet.html", struct {
		basePage
		Owner    string
		Snippet  store.Snippet
		Files    []snippetFileView
		CanWrite bool
		Notice   string
	}{s.baseFor(viewer), sn.OwnerName, sn, views, policy.CanWriteSnippet(viewer, sn), s.takeFlash(w, r)})
}

// snippetRaw serves one file as text, inert on the forge's origin.
func (s *Server) snippetRaw(w http.ResponseWriter, r *http.Request) {
	sn, _, ok := s.snippetScope(w, r)
	if !ok {
		return
	}
	f, err := s.st.SnippetFile(sn.ID, r.PathValue("name"))
	if err != nil {
		s.notFound(w, r)
		return
	}
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.Header().Set("X-Content-Type-Options", "nosniff")
	w.Write(f.Content)
}

s.viewer reads the session cookie; in view_only mode there is never one, so the viewer is anonymous there without a mode check.

  • Step 6: Templates

Create internal/web/templates/snippets.html:

{{define "title"}}snippets · {{.Owner}}{{end}}
{{define "content"}}
<h1><a href="/{{.Owner}}">{{.Owner}}</a> snippets</h1>
{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
{{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}}
{{if .Snippets}}<div class="tablewrap"><table class="keys">
<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>
{{range .Snippets}}<tr>
  <td><a href="/{{$.Owner}}/-/snippets/{{.PublicID}}">{{if .Description}}{{.Description}}{{else}}{{.PublicID}}{{end}}</a></td>
  <td><span class="mono">{{.Names}}</span></td>
  {{if $.All}}<td><span class="chip chip-neutral">{{.Visibility}}</span></td>{{end}}
  <td>{{.UpdatedAt}}</td>
</tr>
{{end}}</table></div>
{{else}}<p class="none">No snippets yet.</p>{{end}}
{{end}}

Create internal/web/templates/snippet.html (the write forms come in Task 4; leave the {{if .CanWrite}} block out for now):

{{define "title"}}{{if .Snippet.Description}}{{.Snippet.Description}}{{else}}{{.Snippet.PublicID}}{{end}} · {{.Owner}}{{end}}
{{define "content"}}
<h1><a href="/{{.Owner}}">{{.Owner}}</a> / <a href="/{{.Owner}}/-/snippets">snippets</a> / {{.Snippet.PublicID}}</h1>
{{if .Snippet.Description}}<p class="desc lede">{{.Snippet.Description}}</p>{{end}}
<p class="meta"><span class="chip chip-neutral">{{.Snippet.Visibility}}</span> · updated {{.Snippet.UpdatedAt}} · <code>gitbay snippet show {{.Snippet.PublicID}}</code></p>
{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
{{range .Files}}
<section class="snippetfile" id="file-{{.Name}}">
<div class="pathbar">
  <span class="crumbs"><strong>{{.Name}}</strong></span>
  <span class="spacer"></span>
  <span class="actions"><a href="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/raw/{{.Name}}">raw</a></span>
</div>
<p class="filefacts">{{.Lines}} lines · {{.Size}} bytes</p>
<div class="code">{{.HTML}}</div>
</section>
{{end}}
{{end}}
  • Step 7: Owner page link

In 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:

{{if or .Snippets .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets">snippets{{if .Snippets}} <span class="count">{{.Snippets}}</span>{{end}}</a></p>{{end}}
  • Step 8: Build and run the tests

Run: go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestSnippetsWeb$' Expected: 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).

  • Step 9: Commit
git 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
git commit -m "web: snippet pages

The owner's list, one snippet with highlighted files, and a raw route
under /{owner}/-/snippets. The owner page links when there is
something to list.

Ref #195"

Task 4: Web writes

Files:

  • Modify: internal/httpd/snippets.go (append the write handlers)
  • Modify: internal/httpd/routes.go (the account-mode block, beside the /bookmarks routes)
  • Modify: internal/web/templates/snippet.html (the {{if .CanWrite}} forms)
  • Create: internal/web/templates/snippetnew.html
  • Test: e2e/snippetweb_test.go (extend)

Interfaces:

  • Consumes snippetScope, control.SnippetOut, s.dispatchIntoStdin, s.runControlCode, s.runControlStdinCode, s.done, s.setFlash, statusForExit.

  • Produces the five POST handlers and the GET form named in the constraints.

  • Step 1: Extend the e2e test

In e2e/snippetweb_test.go, replace the two placeholder lines (_ = url.Values{} and _ = bobKey) with:

	// The create form makes a snippet through snippet create.
	status, body = browserPost(t, alice, inst.base()+"/alice/-/snippets/new", url.Values{
		"name": {"notes.md"}, "description": {"from the browser"}, "visibility": {"public"}, "content": {"# notes\n"}})
	if status != 200 || !strings.Contains(body, "from the browser") || !strings.Contains(body, "notes.md") {
		t.Fatalf("create form: %d\n%s", status, body)
	}
	var listed struct {
		Data []struct {
			ID          string `json:"id"`
			Description string `json:"description"`
		} `json:"data"`
	}
	json.Unmarshal([]byte(must(aliceKey, "", "snippet", "list", "--json")), &listed)
	created := ""
	for _, sn := range listed.Data {
		if sn.Description == "from the browser" {
			created = sn.ID
		}
	}
	if created == "" {
		t.Fatalf("created from the web, not listed: %+v", listed.Data)
	}
	if status, _ := browserGet(t, alice, inst.base()+"/bob/-/snippets/new"); status != 404 {
		t.Fatalf("new form under another owner: %d", status)
	}

	// The file form replaces a file and adds one; remove drops it.
	page := inst.base() + "/alice/-/snippets/" + created
	if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"notes.md"}, "content": {"# changed\n"}}); status != 200 {
		t.Fatal("file replace failed")
	}
	if got := must(aliceKey, "", "snippet", "file", "get", created, "notes.md"); got != "# changed\n" {
		t.Fatalf("after web replace: %q", got)
	}
	if status, _ := browserPost(t, alice, page+"/file", url.Values{"name": {"b.txt"}, "content": {"b\n"}}); status != 200 {
		t.Fatal("file add failed")
	}
	if status, _ := browserPost(t, alice, page+"/file/remove", url.Values{"name": {"b.txt"}}); status != 200 {
		t.Fatal("file remove failed")
	}
	if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "file", "get", created, "b.txt"); code != 3 {
		t.Fatalf("b.txt after web remove: exit %d", code)
	}
	// A refusal comes back on the page as a message, not a bare error.
	_, body = browserPost(t, alice, page+"/file/remove", url.Values{"name": {"notes.md"}})
	if !strings.Contains(body, `class="error"`) || !strings.Contains(body, "at least one file") {
		t.Fatalf("last-file refusal on the page:\n%s", body)
	}

	// Edit changes visibility; delete removes.
	if status, _ := browserPost(t, alice, page+"/edit", url.Values{"description": {"renamed"}, "visibility": {"private"}}); status != 200 {
		t.Fatal("edit failed")
	}
	if status, _ := inst.get(t, "/alice/-/snippets/"+created); status != 404 {
		t.Fatalf("private after web edit, anonymous: %d", status)
	}
	// bob cannot write alice's snippet from the browser either.
	bob := inst.login(t, bobKey)
	if status, _ := browserPost(t, bob, inst.base()+"/alice/-/snippets/"+public+"/edit", url.Values{"description": {"x"}, "visibility": {"public"}}); status != 403 {
		t.Fatalf("bob editing alice's snippet: %d", status)
	}
	if status, _ := browserPost(t, alice, page+"/delete", nil); status != 200 {
		t.Fatal("delete failed")
	}
	if _, _, code := inst.ssh(t, aliceKey, "", "snippet", "show", created); code != 3 {
		t.Fatalf("after web delete: exit %d", code)
	}

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.

  • Step 2: Run it to see it fail

Run: go test ./e2e -run 'TestSnippetsWeb$' Expected: FAIL at "create form" with 404 or 405.

  • Step 3: Routes

In internal/httpd/routes.go, inside the web.mode == "accounts" block, after the /bookmarks GET route add:

			Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)},
			Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true,
				Handler: s.checkOrigin(s.requireUser(s.snippetNewSubmit))},
			Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/edit", Mutating: true,
				Handler: s.checkOrigin(s.requireUser(s.snippetEditSubmit))},
			Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/delete", Mutating: true,
				Handler: s.checkOrigin(s.requireUser(s.snippetDeleteSubmit))},
			Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/file", Mutating: true,
				Handler: s.checkOrigin(s.requireUser(s.snippetFileSubmit))},
			Route{Method: "POST", Pattern: "/{owner}/-/snippets/{id}/file/remove", Mutating: true,
				Handler: s.checkOrigin(s.requireUser(s.snippetFileRemoveSubmit))},

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.

  • Step 4: Handlers

Append to internal/httpd/snippets.go (add "strings", "gitbay.org/gitbay/internal/control" and "gitbay.org/gitbay/internal/protocol" to its imports):

// snippetNewForm is the owner's own page only: the URL names the owner
// and a snippet cannot be created for someone else.
func (s *Server) snippetNewForm(w http.ResponseWriter, r *http.Request, u store.User) {
	if r.PathValue("owner") != u.Username {
		s.notFound(w, r)
		return
	}
	s.render(w, "snippetnew.html", struct {
		basePage
		Owner string
	}{s.baseFor(u), u.Username})
}

func (s *Server) snippetNewSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
	if r.PathValue("owner") != u.Username {
		s.notFound(w, r)
		return
	}
	argv := []string{"snippet", "create", strings.TrimSpace(r.FormValue("name")),
		"--description", strings.TrimSpace(r.FormValue("description")),
		"--visibility", r.FormValue("visibility")}
	var out control.SnippetOut
	code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("content"), &out)
	if code != protocol.ExitOK {
		http.Error(w, msg, statusForExit(code))
		return
	}
	http.Redirect(w, r, "/"+u.Username+"/-/snippets/"+out.ID, http.StatusSeeOther)
}

// snippetAction runs a write on the snippet in the URL and returns to
// its page with the message, or to the list after a delete. A snippet
// the viewer may not read is the 404 page, as on every read.
func (s *Server) snippetAction(w http.ResponseWriter, r *http.Request, u store.User, argv []string, stdin string, dest string) {
	sn, _, ok := s.snippetScope(w, r)
	if !ok {
		return
	}
	if dest == "" {
		dest = "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
	}
	back := func(w http.ResponseWriter, r *http.Request, msg string) {
		s.setFlash(w, msg)
		http.Redirect(w, r, dest, http.StatusSeeOther)
	}
	var msg string
	var code int
	if stdin == "" {
		_, msg, code = s.runControlCode(u, argv)
	} else {
		msg, code = s.runControlStdinCode(u, argv, stdin)
	}
	if code == protocol.ExitDenied {
		http.Error(w, msg, http.StatusForbidden)
		return
	}
	s.done(w, r, code, msg, back)
}

func (s *Server) snippetEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
	s.snippetAction(w, r, u, []string{"snippet", "edit", r.PathValue("id"),
		"--description", strings.TrimSpace(r.FormValue("description")),
		"--visibility", r.FormValue("visibility")}, "", "")
}

func (s *Server) snippetDeleteSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
	s.snippetAction(w, r, u, []string{"snippet", "delete", r.PathValue("id")}, "",
		"/"+r.PathValue("owner")+"/-/snippets")
}

// An empty textarea reaches the command as empty stdin, which it refuses;
// the message lands on the page like any other.
func (s *Server) snippetFileSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
	s.snippetAction(w, r, u, []string{"snippet", "file", "set", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))},
		r.FormValue("content"), "")
}

func (s *Server) snippetFileRemoveSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
	s.snippetAction(w, r, u, []string{"snippet", "file", "remove", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))}, "", "")
}

The 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.

  • Step 5: Templates

Create internal/web/templates/snippetnew.html:

{{define "title"}}new snippet · {{.Owner}}{{end}}
{{define "content"}}
<h1>New snippet</h1>
<form method="post" action="/{{.Owner}}/-/snippets/new" class="commentform">
<p><input type="text" name="name" aria-label="File name" placeholder="filename" required></p>
<p><input type="text" name="description" aria-label="Description" placeholder="description"></p>
<p><select name="visibility" aria-label="Visibility"><option value="unlisted">unlisted</option><option value="public">public</option><option value="private">private</option></select></p>
<p><textarea name="content" aria-label="Content" rows="16" required></textarea></p>
<p><button type="submit">Create snippet</button></p>
</form>
{{end}}

In internal/web/templates/snippet.html, inside the {{range .Files}} section after <div class="code">{{.HTML}}</div>, add:

{{if $.CanWrite}}<details class="editbox"><summary>edit {{.Name}}</summary>
<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file" class="commentform">
<input type="hidden" name="name" value="{{.Name}}">
<p><textarea name="content" aria-label="Content of {{.Name}}" rows="12">{{.Content}}</textarea></p>
<p><button type="submit">Save</button></p>
</form>
<form method="post" action="/{{$.Owner}}/-/snippets/{{$.Snippet.PublicID}}/file/remove">
<input type="hidden" name="name" value="{{.Name}}">
<p><button type="submit">Remove {{.Name}}</button></p>
</form>
</details>{{end}}

and after the {{end}} that closes the range, before the final {{end}}:

{{if .CanWrite}}
<details class="editbox"><summary>add a file</summary>
<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/file" class="commentform">
<p><input type="text" name="name" aria-label="File name" placeholder="filename" required></p>
<p><textarea name="content" aria-label="Content" rows="12" required></textarea></p>
<p><button type="submit">Add file</button></p>
</form></details>
<details class="editbox"><summary>settings</summary>
<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/edit">
<p><input type="text" name="description" aria-label="Description" value="{{.Snippet.Description}}" placeholder="description"></p>
<p><select name="visibility" aria-label="Visibility">
<option value="public"{{if eq .Snippet.Visibility "public"}} selected{{end}}>public</option>
<option value="unlisted"{{if eq .Snippet.Visibility "unlisted"}} selected{{end}}>unlisted</option>
<option value="private"{{if eq .Snippet.Visibility "private"}} selected{{end}}>private</option>
</select></p>
<p><button type="submit">Save</button></p>
</form>
<form method="post" action="/{{.Owner}}/-/snippets/{{.Snippet.PublicID}}/delete">
<p><button type="submit">Delete snippet</button></p>
</form>
</details>
{{end}}

If .editbox or .commentform render poorly beside .code, add a .snippetfile { margin-bottom: 1.5rem } rule to internal/web/static/style.css; nothing more.

  • Step 6: Build and run the tests

Run: go build ./... && go vet ./... && go test ./internal/httpd && go test ./e2e -run 'TestSnippetsWeb$' Expected: 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.

  • Step 7: Commit
git add internal/httpd/snippets.go internal/httpd/routes.go internal/web/templates/snippet.html internal/web/templates/snippetnew.html e2e/snippetweb_test.go
git commit -m "web: create, edit and delete snippets

Every form dispatches the snippet command the CLI runs.

Ref #195"

Task 5: Documentation and changelog

Files:

  • Modify: .gitbay/wiki/Users.org (a * Snippets section after * Pages, before * Browser sessions)

  • Modify: .gitbay/wiki/Parity.org (rows after release asset remove)

  • Modify: .gitbay/wiki/Admin.org:116 (the [limits] list)

  • Modify: CHANGELOG.org (a new top entry)

  • Step 1: Users.org

Insert before * Browser sessions:

* Snippets

A snippet is one or more named text files you own outside any
repository, for a log or a fragment shared by URL. Create one from a
file on stdin; the reply is the id and the URL:

#+begin_src sh
gitbay snippet create build.log --description "failing build" < build.log
gitbay snippet file set <id> notes.txt < notes.txt   # add or replace a file
gitbay snippet file get <id> build.log > build.log
gitbay snippet edit <id> --visibility public
gitbay snippet list                                  # yours
gitbay snippet list <owner>                          # their public ones
gitbay snippet delete <id>
#+end_src

Visibility is =public= (listed on your page), =unlisted= (anyone with
the URL, listed nowhere; the default) or =private= (you alone; not
found to everyone else). Files are text, valid UTF-8, each under the
instance's =max_snippet_bytes=, at most 64 per snippet. A snippet keeps
at least one file. There is no history: setting a file replaces it.

On the web, =/<you>/-/snippets= lists yours, each snippet page renders
its files with a raw link per file, and the same page creates, edits
and deletes through the commands above.
  • Step 2: Parity.org

After the release asset remove row add:

| snippet create, edit, delete | yes | yes | no  |
| snippet show, list          | yes | yes | no  |
| snippet file set, get, remove | yes | yes | no  |

Match the table's column alignment by hand; org tables tolerate ragged cells but the page is read raw too.

  • Step 3: Admin.org

After the max_asset_bytes line in ** [limits] add:

- =max_snippet_bytes= (1MB) — cap per snippet file.
  • Step 4: CHANGELOG.org

Before * v1.19.0 — 2026-09-11 add:

* v1.20.0 — unreleased

Snippets (#195): named text files a user owns outside any repository,
shared by URL and edited in place.

- Migration 0054: =snippets= and =snippet_files=.
- =snippet create|show|list|edit|delete= and =snippet file
  set|get|remove=. Files are UTF-8 under =limits.max_snippet_bytes=
  (1MB), at most 64 per snippet; a snippet keeps at least one.
  Visibility =public=, =unlisted= (default) or =private=; a private
  snippet is not found to everyone but its owner and admins.
- Web: =/<owner>/-/snippets= lists, each snippet page renders its
  files with a raw route per file, and the owner creates, edits and
  deletes from the page through the same commands. The owner page
  links to the list.
  • Step 5: Commit
git add .gitbay/wiki/Users.org .gitbay/wiki/Parity.org .gitbay/wiki/Admin.org CHANGELOG.org
git commit -m "wiki, CHANGELOG: snippets

Closes #195"

Task 6: Merge request

  • Step 1: Push and open the MR
git push -u origin snippets
gitbay mr create --source snippets --target main --title "Snippets (#195)" --file - <<'EOF'
Named text files a user owns outside any repository, shared by URL and
edited in place. Spec: docs/specs/2026-09-11-snippets-design.md.

Migration 0054. Commands snippet create|show|list|edit|delete and
snippet file set|get|remove; web under /{owner}/-/snippets with forms
dispatching the same commands. New limit max_snippet_bytes (1MB).

Closes #195
EOF
  • Step 2: Wait for CI, then merge

Check 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:

gitbay mr merge <n> --strategy ff
git push origin --delete snippets

and remove the worktree and branch locally after the merge lands.