diff --git a/customslug.go b/customslug.go new file mode 100644 index 0000000..dbcfbbc --- /dev/null +++ b/customslug.go @@ -0,0 +1,49 @@ +package main + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +var slugRE = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-_]{0,63}$`) + +// reserved words that would collide with routes +var reservedSlugs = map[string]bool{ + "api": true, "raw": true, "can": true, "cans": true, "public": true, + "history": true, "static": true, "assets": true, "favicon.ico": true, + "new": true, "login": true, "logout": true, "admin": true, "settings": true, +} + +var errInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit") +var errReservedSlug = errors.New("that slug is reserved") +var errSlugTaken = errors.New("that slug is already taken") + +func ValidateCustomSlug(slug string) error { + if !slugRE.MatchString(slug) { + return errInvalidSlug + } + if reservedSlugs[strings.ToLower(slug)] { + return errReservedSlug + } + return nil +} + +func (s *Store) SlugTaken(slug string) (bool, error) { + var n int + err := s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE custom_slug = ? OR id = ?`, slug, slug).Scan(&n) + if err != nil { + return false, err + } + if n > 0 { + return true, nil + } + err = s.db.QueryRow(`SELECT COUNT(*) FROM paste_cans WHERE id = ?`, slug).Scan(&n) + if err != nil { + return false, err + } + return n > 0, nil +} + +var _ = fmt.Sprintf // keep fmt if unused later diff --git a/customslug_test.go b/customslug_test.go new file mode 100644 index 0000000..0e223d7 --- /dev/null +++ b/customslug_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" +) + +func TestCustomSlugCreateAndFetch(t *testing.T) { + s := testServer(t) + h := s.routes() + + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"x","custom_slug":"release-notes"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 201 { + t.Fatalf("create: %d %s", rec.Code, rec.Body.String()) + } + + // fetch by custom slug + req = httptest.NewRequest("GET", "/api/pastes/release-notes", nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("fetch by slug: %d", rec.Code) + } + var got map[string]any + json.Unmarshal(rec.Body.Bytes(), &got) + if got["content"] != "x" { + t.Fatal("content mismatch via custom slug") + } +} + +func TestCustomSlugValidation(t *testing.T) { + s := testServer(t) + h := s.routes() + + cases := []struct { + slug, body string + wantCode int + }{ + {"dup", `{"content":"first","custom_slug":"dup"}`, 201}, + {"dup", `{"content":"second","custom_slug":"dup"}`, 400}, + {"api", `{"content":"x","custom_slug":"api"}`, 400}, + {"raw", `{"content":"x","custom_slug":"raw"}`, 400}, + {"bad slug", `{"content":"x","custom_slug":"has space"}`, 400}, + {"", `{"content":"x","custom_slug":""}`, 201}, // empty = no custom slug, fine + } + for _, c := range cases { + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(c.body)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != c.wantCode { + t.Fatalf("slug %q: got %d want %d: %s", c.slug, rec.Code, c.wantCode, rec.Body.String()) + } + } +} + +func TestSlugCollisionWithAutoID(t *testing.T) { + s := testServer(t) + // manually insert a paste, then try to claim its auto ID as a custom slug + p, err := s.CreatePaste(&Paste{Content: "auto"}) + if err != nil { + t.Fatal(err) + } + if taken, _ := s.SlugTaken(p.ID); !taken { + t.Fatal("auto id should be claimed") + } +} diff --git a/main.go b/main.go index 0ed3600..bd2a2af 100644 --- a/main.go +++ b/main.go @@ -168,6 +168,20 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) { pwHash = &h } + if p.CustomSlug != nil && *p.CustomSlug != "" { + slug := *p.CustomSlug + if err := ValidateCustomSlug(slug); err != nil { + return nil, err + } + taken, err := s.SlugTaken(slug) + if err != nil { + return nil, err + } + if taken { + return nil, errSlugTaken + } + } + visibility := p.Visibility if visibility == "" { visibility = "public" @@ -181,10 +195,14 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) { contentType = "text/plain" } + var slugVal *string + if p.CustomSlug != nil && *p.CustomSlug != "" { + slugVal = p.CustomSlug + } _, err := s.db.Exec(`INSERT INTO pastes - (id, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at) - VALUES (?,?,?,?,?,?,?,?,?,?)`, - id, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now) + (id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now) if err != nil { return nil, err } @@ -337,10 +355,6 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) { writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", a.cfg.MaxTextBytes)) return } - if p.CustomSlug != nil && *p.CustomSlug != "" { - writeErr(w, 400, "custom slugs not implemented yet") - return - } created, err := a.store.CreatePaste(&p) if err != nil { writeErr(w, 400, err.Error()) diff --git a/palette b/palette index d14c0fc..077ee0c 100755 Binary files a/palette and b/palette differ diff --git a/palette.db-shm b/palette.db-shm index 03c2ae5..ddcf9b9 100644 Binary files a/palette.db-shm and b/palette.db-shm differ diff --git a/palette.db-wal b/palette.db-wal index 8486dbc..92b7450 100644 Binary files a/palette.db-wal and b/palette.db-wal differ