Custom URLs: validated slugs with reserved words, uniqueness across pastes and cans
This commit is contained in:
@@ -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
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,6 +168,20 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
|||||||
pwHash = &h
|
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
|
visibility := p.Visibility
|
||||||
if visibility == "" {
|
if visibility == "" {
|
||||||
visibility = "public"
|
visibility = "public"
|
||||||
@@ -181,10 +195,14 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
|||||||
contentType = "text/plain"
|
contentType = "text/plain"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var slugVal *string
|
||||||
|
if p.CustomSlug != nil && *p.CustomSlug != "" {
|
||||||
|
slugVal = p.CustomSlug
|
||||||
|
}
|
||||||
_, err := s.db.Exec(`INSERT INTO pastes
|
_, err := s.db.Exec(`INSERT INTO pastes
|
||||||
(id, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at)
|
(id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
id, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now)
|
id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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))
|
writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", a.cfg.MaxTextBytes))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if p.CustomSlug != nil && *p.CustomSlug != "" {
|
|
||||||
writeErr(w, 400, "custom slugs not implemented yet")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
created, err := a.store.CreatePaste(&p)
|
created, err := a.store.CreatePaste(&p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 400, err.Error())
|
writeErr(w, 400, err.Error())
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user