Remove cans feature; harden pw_/tok_ cookie flags (pre-release fixes) #307
No files matched your search
@@ -1,4 +1,4 @@
|
||||
Palette - self-hosted pastebin with paste cans
|
||||
Palette - self-hosted pastebin
|
||||
Copyright (C) 2026 poslop
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
|
||||
@@ -10,7 +10,7 @@ a web UI for sharing text and small files.
|
||||
|
||||
## Features
|
||||
|
||||
- Text pastes and cans (multiple items in one share)
|
||||
- Text pastes
|
||||
- File attachments (one file per paste, up to 25 MB)
|
||||
- Password protected pastes
|
||||
- Expire after a specified time
|
||||
@@ -66,7 +66,7 @@ go build -o palette ./cmd/palette
|
||||
| `PALETTE_ADDR` | `:8080` | Listen address |
|
||||
| `PALETTE_DB` | `palette.db` | SQLite database path |
|
||||
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
|
||||
| `PALETTE_MAX_ITEM` | `26214400` | Max can item / file attachment size in bytes (25 MB) |
|
||||
| `PALETTE_MAX_ITEM` | `26214400` | Max file attachment / multipart size in bytes (25 MB) |
|
||||
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) |
|
||||
| `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. |
|
||||
| `PALETTE_UNLOCK_SECRET` | random per start | HMAC secret for password-unlock cookies. Set a fixed value to keep unlock sessions across restarts or across replicas. |
|
||||
|
||||
+2
-2
@@ -32,8 +32,8 @@ services:
|
||||
# Default: 5242880 (5 MiB).
|
||||
# PALETTE_MAX_TEXT: "5242880"
|
||||
|
||||
# Max size in bytes of a single can item (file/text inside a can) or a
|
||||
# paste file attachment.
|
||||
# Max size in bytes for multipart request parsing (paste file
|
||||
# attachments are capped at 25 MB).
|
||||
# Default: 26214400 (25 MiB).
|
||||
# PALETTE_MAX_ITEM: "26214400"
|
||||
|
||||
|
||||
+5
-53
@@ -18,11 +18,11 @@ The examples below use `http://localhost:8080` as a placeholder.
|
||||
|
||||
There is no user account system. Access control works through three mechanisms:
|
||||
|
||||
- Viewer cookie. Every request gets an anonymous, random viewer id in an HttpOnly `vwr` cookie. This cookie identifies the browser that created a paste or can, and authorizes browser-scoped actions such as `GET /api/mine` and creator-only deletion.
|
||||
- Viewer cookie. Every request gets an anonymous, random viewer id in an HttpOnly `vwr` cookie. This cookie identifies the browser that created a paste, and authorizes browser-scoped actions such as `GET /api/mine` and creator-only deletion.
|
||||
- Deletion token. Creating a paste returns a one-time `deletion_token`. Sending it in the `Authorization` header (Bearer or Token prefix, or the bare value) authorizes deletion of that paste. The token is deliberately not accepted as a URL query parameter, because query strings leak into access logs and browser history.
|
||||
- Admin key. The admin settings endpoints require an install-time key sent in the `X-Admin-Key` header. The key comes from `PALETTE_ADMIN_KEY` or is generated and persisted server-side at first start.
|
||||
|
||||
Password-protected pastes and cans are unlocked with the `X-Paste-Password` request header (or the browser unlock flow on the web pages). Password checks are rate limited per IP and per paste.
|
||||
Password-protected pastes are unlocked with the `X-Paste-Password` request header (or the browser unlock flow on the web pages). Password checks are rate limited per IP and per paste.
|
||||
|
||||
### Rate limiting
|
||||
|
||||
@@ -41,7 +41,6 @@ Exceeding a limit returns `429` with a `Retry-After` header and the error code `
|
||||
|
||||
- Paste content: `PALETTE_MAX_TEXT` (default 5 MB), admin tunable. Larger content is rejected with `413`.
|
||||
- Attached files: 25 MB per file, one file per paste.
|
||||
- Can items: `PALETTE_MAX_ITEM` (default 25 MB) per item.
|
||||
- The request body itself is capped server side; oversized bodies fail with `413` before handlers run.
|
||||
|
||||
### Error format
|
||||
@@ -130,13 +129,13 @@ Errors: `400` token required, `403` invalid token, `404` not found.
|
||||
|
||||
#### GET /api/mine
|
||||
|
||||
List pastes (and cans) created from this browser, via the `vwr` cookie. Query parameters: `limit` (default 50, max 100), `offset`.
|
||||
List pastes created from this browser, via the `vwr` cookie. Query parameters: `limit` (default 50, max 100), `offset`.
|
||||
|
||||
Response `200`: `{"total": n, "limit": l, "offset": o, "items": [...]}` with each item carrying `id`, `title`, `language`, `created_at`, `view_count`, `size`, `custom_slug`, `visibility`, `is_can`.
|
||||
Response `200`: `{"total": n, "limit": l, "offset": o, "items": [...]}` with each item carrying `id`, `title`, `language`, `created_at`, `view_count`, `size`, `custom_slug`, `visibility`.
|
||||
|
||||
#### GET /api/public
|
||||
|
||||
List public pastes. Query parameters: `limit` (default 25, max 100), `offset`. Same shape as `/api/mine`; items include `is_can` and `custom_slug`.
|
||||
List public pastes. Query parameters: `limit` (default 25, max 100), `offset`. Same shape as `/api/mine`; items include `custom_slug`.
|
||||
|
||||
### Files and raw content
|
||||
|
||||
@@ -162,53 +161,6 @@ Response `200`: `{"language": "go"}`. The result is a best-effort guess and may
|
||||
|
||||
Errors: `400` invalid JSON body, `429` rate limited.
|
||||
|
||||
### Cans
|
||||
|
||||
A can bundles multiple text items and files into one shareable page at `/can/{id}`. Cans appear in `/api/mine` and `/api/public` with `is_can: true`.
|
||||
|
||||
#### POST /api/pastes/can
|
||||
|
||||
Create a can. Multipart form only. Fields:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `title` | string | optional |
|
||||
| `description` | string | optional |
|
||||
| `visibility` | string | `public` or `unlisted`; default `public` |
|
||||
| `expires_in` | string | Go duration, 1 minute to 1 year |
|
||||
| `password` | string | optional |
|
||||
| `custom_slug` | string | optional; becomes the can id |
|
||||
| `json_items` | string | JSON array of `{"title": "...", "content": "...", "language": "..."}` text items |
|
||||
| files | parts | one or more uploaded file parts |
|
||||
|
||||
A can needs at least one item (file or `json_items`); otherwise the can is deleted and `400` (`content_empty`) is returned.
|
||||
|
||||
Response `201`: `{"id": "...", "url": "/can/{id}", "items": n}`.
|
||||
|
||||
Errors: `400` (`invalid_visibility`, `expiry_invalid`, `invalid json_items`, `content_empty`), `409` slug errors (codes `slug_taken`, `slug_invalid`, `slug_reserved`), `413` item over the size cap.
|
||||
|
||||
#### GET /api/cans/{id}
|
||||
|
||||
Can metadata with an item list. Password-protected cans require `X-Paste-Password`.
|
||||
|
||||
Response `200`: `id`, `title`, `visibility`, `created_at`, and `items`, where each item has `id`, `title`, `content_type`, `size`, and `url` (the item's fetch URL).
|
||||
|
||||
Errors: `404` not found or expired, `401` password required.
|
||||
|
||||
#### GET /api/cans/{id}/items/{item}
|
||||
|
||||
Fetch one can item's content. Items inherit the can's password: send `X-Paste-Password`, or the browser's `pw_{can}` unlock cookie. Served with the same safe content-type guard as `/raw`.
|
||||
|
||||
Errors: `404` item not found or not a can item, `401` password required.
|
||||
|
||||
#### DELETE /api/cans/{id}
|
||||
|
||||
Soft delete a can. Creator browser only: the `vwr` cookie must match the can's viewer. Cans carry no deletion token.
|
||||
|
||||
Response `200`: `{"status": "soft-deleted"}`.
|
||||
|
||||
Errors: `403` deletion not authorized, `404` not found.
|
||||
|
||||
### Admin
|
||||
|
||||
#### GET /admin/api/settings and POST /admin/api/settings
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"palette/internal/store"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"palette/internal/web"
|
||||
)
|
||||
|
||||
// CreateCan makes a can with N items (multipart form).
|
||||
// Fields: title, description, visibility, expires_in, password, files (one or more), or json_items for text items.
|
||||
func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(a.cfg.MaxItemBytes); err != nil {
|
||||
writeErr(w, 400, "multipart form required")
|
||||
return
|
||||
}
|
||||
|
||||
title := r.FormValue("title")
|
||||
visibility := r.FormValue("visibility")
|
||||
if visibility == "" {
|
||||
visibility = "public"
|
||||
}
|
||||
if visibility != "public" && visibility != "unlisted" {
|
||||
writeErrCode(w, 400, "invalid_visibility", "visibility must be public or unlisted")
|
||||
return
|
||||
}
|
||||
expiresIn := r.FormValue("expires_in")
|
||||
password := r.FormValue("password")
|
||||
customSlug := r.FormValue("custom_slug")
|
||||
|
||||
var expiresAt *int64
|
||||
now := time.Now().Unix()
|
||||
if expiresIn != "" {
|
||||
d, err := time.ParseDuration(expiresIn)
|
||||
if err != nil {
|
||||
writeErrCode(w, 400, "expiry_invalid", "invalid expires_in")
|
||||
return
|
||||
}
|
||||
// #60/#48: clamp at the API boundary like the pastes API does -
|
||||
// reject zero/negative and durations past the 1-year UI cap.
|
||||
if !store.ValidExpiry(d) {
|
||||
writeErrCode(w, 400, "expiry_invalid", "expires_in must be between 1 minute and 1 year")
|
||||
return
|
||||
}
|
||||
t := now + int64(d.Seconds())
|
||||
expiresAt = &t
|
||||
}
|
||||
var pwHash *string
|
||||
if password != "" {
|
||||
h, err := store.Argon2IDHash(password)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "hash error")
|
||||
return
|
||||
}
|
||||
pwHash = &h
|
||||
}
|
||||
|
||||
canID := store.GenSlug(8)
|
||||
var slugPtr *string
|
||||
if customSlug != "" {
|
||||
slugPtr = &customSlug
|
||||
}
|
||||
err := a.store.CreateCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt, slugPtr)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case store.ErrSlugTaken, store.ErrInvalidSlug, store.ErrReservedSlug:
|
||||
writeErrCode(w, 409, createErrCode(err), err.Error())
|
||||
default:
|
||||
writeErr(w, 500, "db error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if slugPtr != nil {
|
||||
canID = customSlug // #4: custom slug becomes the can id
|
||||
}
|
||||
// #4: remember the creating browser so /mine and viewer-scoped delete work
|
||||
a.store.Exec(`UPDATE paste_cans SET viewer_id=? WHERE id=?`, currentViewerID(r), canID)
|
||||
|
||||
// text items passed as JSON array: [{"title":"notes.txt","content":"..."}]
|
||||
itemCount := 0
|
||||
if itemsJSON := r.FormValue("json_items"); itemsJSON != "" {
|
||||
var items []map[string]string
|
||||
if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil {
|
||||
writeErr(w, 400, "invalid json_items")
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
content := it["content"]
|
||||
if int64(len(content)) > a.cfg.MaxItemBytes {
|
||||
writeErr(w, 413, fmt.Sprintf("item %q exceeds max", it["title"]))
|
||||
return
|
||||
}
|
||||
lang := it["language"]
|
||||
if err := a.store.InsertCanItem(canID, it["title"], content, "text/plain", &lang, nil, nil, now); err != nil {
|
||||
writeErr(w, 500, "db error")
|
||||
return
|
||||
}
|
||||
itemCount++
|
||||
}
|
||||
}
|
||||
|
||||
// uploaded files
|
||||
if r.MultipartForm != nil {
|
||||
for _, headers := range r.MultipartForm.File {
|
||||
for _, fh := range headers {
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
writeErr(w, 400, "cannot read uploaded file")
|
||||
return
|
||||
}
|
||||
content, err := io.ReadAll(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
writeErr(w, 400, "cannot read uploaded file")
|
||||
return
|
||||
}
|
||||
if int64(len(content)) > a.cfg.MaxItemBytes {
|
||||
writeErr(w, 413, fmt.Sprintf("file %q exceeds max %d bytes", fh.Filename, a.cfg.MaxItemBytes))
|
||||
return
|
||||
}
|
||||
contentStr := string(content)
|
||||
if err := a.store.InsertCanItem(canID, fh.Filename, contentStr, detectContentType(fh.Filename, content), nil, nil, &contentStr, now); err != nil {
|
||||
writeErr(w, 500, "db error")
|
||||
return
|
||||
}
|
||||
itemCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if itemCount == 0 {
|
||||
a.store.DeleteCan(canID)
|
||||
writeErrCode(w, 400, "content_empty", "can needs at least one item (files or json_items)")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 201, map[string]any{
|
||||
"id": canID, "url": "/can/" + canID, "items": itemCount,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteCan soft-deletes a can (parity with paste deletion, #63):
|
||||
// requires the vwr viewer cookie matching the can's viewer (cans carry no
|
||||
// deletion token since they are built in the browser).
|
||||
func (a *apiServer) handleDeleteCan(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
can, err := a.store.GetCan(id)
|
||||
if err != nil || can == nil {
|
||||
writeErr(w, 404, "can not found")
|
||||
return
|
||||
}
|
||||
vid := currentViewerID(r)
|
||||
if !(vid != "" && viewerSentCookie(r) && can.ViewerID.Valid &&
|
||||
can.ViewerID.String != "" && can.ViewerID.String == vid) {
|
||||
writeErr(w, 403, "deletion not authorized")
|
||||
return
|
||||
}
|
||||
if _, err := a.store.SoftDeleteCan(can.ID); err != nil {
|
||||
writeErr(w, 500, "db error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
|
||||
}
|
||||
|
||||
func detectContentType(name string, content []byte) string {
|
||||
lower := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".png"):
|
||||
return "image/png"
|
||||
case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
|
||||
return "image/jpeg"
|
||||
case strings.HasSuffix(lower, ".gif"):
|
||||
return "image/gif"
|
||||
case strings.HasSuffix(lower, ".webp"):
|
||||
return "image/webp"
|
||||
case strings.HasSuffix(lower, ".pdf"):
|
||||
return "application/pdf"
|
||||
}
|
||||
if len(content) > 8 && content[0] == 0x89 && content[1] == 'P' {
|
||||
return "image/png"
|
||||
}
|
||||
return "text/plain"
|
||||
}
|
||||
|
||||
func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
can, err := a.store.GetCan(id)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "db error")
|
||||
return
|
||||
}
|
||||
if can == nil {
|
||||
writeErr(w, 404, "can not found")
|
||||
return
|
||||
}
|
||||
if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
|
||||
writeErr(w, 404, "can expired")
|
||||
return
|
||||
}
|
||||
if can.PasswordHash.Valid {
|
||||
pw := r.Header.Get("X-Paste-Password")
|
||||
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
|
||||
writeErr(w, 401, "password required")
|
||||
return
|
||||
}
|
||||
}
|
||||
items, err := a.store.ListCanItems(can.ID)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "db error")
|
||||
return
|
||||
}
|
||||
type itemMeta struct {
|
||||
ID string `json:"id"`
|
||||
Title *string `json:"title"`
|
||||
ContentType string `json:"content_type"`
|
||||
Size int `json:"size"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
metas := make([]itemMeta, 0, len(items))
|
||||
for _, it := range items {
|
||||
metas = append(metas, itemMeta{
|
||||
ID: it.ID, Title: store.NullStrPtr(it.Title), ContentType: it.ContentType,
|
||||
Size: len(it.Content), URL: "/api/pastes/" + it.ID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"id": can.ID, "title": store.NullStrPtr(can.Title), "visibility": can.Visibility,
|
||||
"created_at": can.CreatedAt, "items": metas,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "item")
|
||||
row, err := a.store.GetPaste(id)
|
||||
if err != nil || row == nil {
|
||||
writeErr(w, 404, "item not found")
|
||||
return
|
||||
}
|
||||
// must belong to a can
|
||||
if !row.CanID.Valid {
|
||||
writeErr(w, 404, "not a can item")
|
||||
return
|
||||
}
|
||||
// inherit can password protection: password via header/query, or the
|
||||
// same pw_<can> unlock cookie the can page sets (#4 cookie parity).
|
||||
can, _ := a.store.GetCan(row.CanID.String)
|
||||
if can != nil && can.PasswordHash.Valid {
|
||||
pw := r.Header.Get("X-Paste-Password")
|
||||
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
|
||||
// fall back to the browser's unlock cookie for this can
|
||||
c, cerr := r.Cookie("pw_" + can.ID)
|
||||
if cerr != nil || c.Value != web.UnlockToken(can.ID) {
|
||||
writeErr(w, 401, "password required")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// #34: same content-type guard as /raw — never serve active content types.
|
||||
ct := row.ContentType
|
||||
if !safeRawContentType(ct) {
|
||||
ct = "text/plain; charset=utf-8"
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Write([]byte(row.Content))
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// #60: the cans API must clamp expires_in at the boundary exactly like the
|
||||
// pastes API — reject zero/negative durations and anything over the 1-year
|
||||
// UI cap, accept the exact boundaries.
|
||||
func TestCreateCanExpiryBounds(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
cases := []struct {
|
||||
expiresIn string
|
||||
wantCode int
|
||||
}{
|
||||
{"-1h", 400}, // negative
|
||||
{"-0s", 400}, // negative zero
|
||||
{"0s", 400}, // zero
|
||||
{"1ns", 400}, // positive but below the 1-minute floor
|
||||
{"59s", 400}, // just under the floor
|
||||
{"1m", 201}, // exactly the floor
|
||||
{"90s", 201}, // just over the floor
|
||||
{"8760h", 201}, // exactly 1 year
|
||||
{"8785h", 400}, // 1 year + 1 day: over the cap
|
||||
{"87600h", 400}, // 10 years, the originally reported case
|
||||
}
|
||||
for _, c := range cases {
|
||||
globalLimiter = newLimiter() // avoid create rate limit between cases
|
||||
body, ct := multipartBody(t, map[string]string{
|
||||
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||
"expires_in": c.expiresIn,
|
||||
}, "files", "pic.txt", "file data")
|
||||
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != c.wantCode {
|
||||
t.Errorf("expires_in %q: got %d want %d (%s)",
|
||||
c.expiresIn, rec.Code, c.wantCode, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"palette/internal/store"
|
||||
)
|
||||
|
||||
// #4: cans appear in /api/public as normal rows with is_can=true.
|
||||
func TestCanInPublicListing(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
// a can and a regular paste
|
||||
body, ct := multipartBody(t, map[string]string{
|
||||
"title": "Listed can",
|
||||
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||
}, "", "", "")
|
||||
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create can: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var created struct{ ID string `json:"id"` }
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
|
||||
req = httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"plain"}`))
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
req = httptest.NewRequest("GET", "/api/public", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("public: %d", rec.Code)
|
||||
}
|
||||
var got struct {
|
||||
Total int `json:"total"`
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
IsCan bool `json:"is_can"`
|
||||
Title any `json:"title"`
|
||||
} `json:"items"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got.Total != 2 {
|
||||
t.Fatalf("expected total 2 (can + paste), got %d", got.Total)
|
||||
}
|
||||
foundCan := false
|
||||
for _, it := range got.Items {
|
||||
if it.ID == created.ID {
|
||||
if !it.IsCan {
|
||||
t.Fatalf("can row missing is_can flag")
|
||||
}
|
||||
foundCan = true
|
||||
} else if it.IsCan {
|
||||
t.Fatalf("plain paste flagged as can")
|
||||
}
|
||||
}
|
||||
if !foundCan {
|
||||
t.Fatalf("can not present in /api/public")
|
||||
}
|
||||
|
||||
// #4: unlisted can must not be listed
|
||||
body2, ct2 := multipartBody(t, map[string]string{
|
||||
"title": "Hidden can",
|
||||
"visibility": "unlisted",
|
||||
"json_items": `[{"title":"b.txt","content":"BBB"}]`,
|
||||
}, "", "", "")
|
||||
req = httptest.NewRequest("POST", "/api/pastes/can", body2)
|
||||
req.Header.Set("Content-Type", ct2)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("unlisted can create: %d", rec.Code)
|
||||
}
|
||||
req = httptest.NewRequest("GET", "/api/public", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if strings.Contains(rec.Body.String(), "Hidden can") {
|
||||
t.Fatalf("unlisted can leaked into /api/public")
|
||||
}
|
||||
}
|
||||
|
||||
// #4: custom slug support for cans + conflict with existing paste slug.
|
||||
func TestCanCustomSlug(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
body, ct := multipartBody(t, map[string]string{
|
||||
"title": "Slugged",
|
||||
"custom_slug": "my-bundle",
|
||||
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||
}, "", "", "")
|
||||
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create can with slug: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var created struct{ ID, URL string }
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
if created.ID != "my-bundle" {
|
||||
t.Fatalf("expected id my-bundle, got %q", created.ID)
|
||||
}
|
||||
// custom slug resolves on the can page route
|
||||
req = httptest.NewRequest("GET", "/can/my-bundle", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Slugged") {
|
||||
t.Fatalf("can page by slug: %d", rec.Code)
|
||||
}
|
||||
// duplicate slug rejected 409
|
||||
body, ct = multipartBody(t, map[string]string{
|
||||
"title": "Again", "custom_slug": "my-bundle",
|
||||
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||
}, "", "", "")
|
||||
req = httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 409 {
|
||||
t.Fatalf("duplicate slug expected 409, got %d", rec.Code)
|
||||
}
|
||||
// reserved slug rejected
|
||||
body, ct = multipartBody(t, map[string]string{
|
||||
"title": "R", "custom_slug": "admin",
|
||||
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||
}, "", "", "")
|
||||
req = httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 409 {
|
||||
t.Fatalf("reserved slug expected 409, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// #4: invalid expiry rejected on cans, mirroring paste behavior (#48).
|
||||
func TestCanExpiryValidation(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
body, ct := multipartBody(t, map[string]string{
|
||||
"title": "Bad expiry", "expires_in": "30s",
|
||||
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||
}, "", "", "")
|
||||
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 400 {
|
||||
t.Fatalf("expected 400 for 30s expiry, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// #4: expired cans 404 on the page and API.
|
||||
func TestCanExpiryAccess(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
body, ct := multipartBody(t, map[string]string{
|
||||
"title": "Dying", "expires_in": "1h",
|
||||
"json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||
}, "", "", "")
|
||||
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var created struct{ ID string `json:"id"` }
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
|
||||
// backdate expiry to force the expired path
|
||||
s.store.Exec(`UPDATE paste_cans SET expires_at=? WHERE id=?`, 100, created.ID)
|
||||
|
||||
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 404 {
|
||||
t.Fatalf("expired can API expected 404, got %d", rec.Code)
|
||||
}
|
||||
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 404 {
|
||||
t.Fatalf("expired can page expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// #4: password-protected can page uses the same unlock flow as pastes.
|
||||
func TestCanUnlockFlow(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
body, ct := multipartBody(t, map[string]string{
|
||||
"title": "Locked can", "password": "pw123",
|
||||
"json_items": `[{"title":"secret.txt","content":"sec content"}]`,
|
||||
}, "", "", "")
|
||||
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var created struct{ ID string `json:"id"` }
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
|
||||
// GET page without cookie -> unlock form
|
||||
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "locked") {
|
||||
t.Fatalf("expected unlock form, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// POST wrong password -> unlock form with error
|
||||
fd := strings.NewReader("password=wrong")
|
||||
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if !strings.Contains(rec.Body.String(), "Wrong password") {
|
||||
t.Fatalf("expected wrong-password message")
|
||||
}
|
||||
|
||||
// POST correct password -> page renders, cookie set
|
||||
fd = strings.NewReader("password=pw123")
|
||||
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if !strings.Contains(rec.Body.String(), "sec content") {
|
||||
t.Fatalf("unlocked can page missing item content")
|
||||
}
|
||||
var pwCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "pw_"+created.ID {
|
||||
pwCookie = c
|
||||
}
|
||||
}
|
||||
if pwCookie == nil {
|
||||
t.Fatalf("unlock cookie not set")
|
||||
}
|
||||
|
||||
// GET with cookie -> unlocked
|
||||
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
|
||||
req.AddCookie(pwCookie)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if !strings.Contains(rec.Body.String(), "sec content") {
|
||||
t.Fatalf("cookie unlock failed: %d", rec.Code)
|
||||
}
|
||||
// a forged cookie value must not unlock
|
||||
req = httptest.NewRequest("GET", "/can/"+created.ID, nil)
|
||||
req.AddCookie(&http.Cookie{Name: "pw_" + created.ID, Value: "forged"})
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if strings.Contains(rec.Body.String(), "sec content") {
|
||||
t.Fatalf("forged cookie unlocked the can")
|
||||
}
|
||||
}
|
||||
|
||||
// #4: can item access inherits the can unlock cookie (not just query param).
|
||||
func TestCanItemCookieParity(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
body, ct := multipartBody(t, map[string]string{
|
||||
"title": "Cookie can", "password": "pw123",
|
||||
"json_items": `[{"title":"s.txt","content":"sec"}]`,
|
||||
}, "", "", "")
|
||||
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var created struct{ ID string `json:"id"` }
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
|
||||
// unlock via page to get the cookie
|
||||
fd := strings.NewReader("password=pw123")
|
||||
req = httptest.NewRequest("POST", "/can/"+created.ID, fd)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var pwCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "pw_"+created.ID {
|
||||
pwCookie = c
|
||||
}
|
||||
}
|
||||
if pwCookie == nil {
|
||||
t.Fatalf("no unlock cookie")
|
||||
}
|
||||
|
||||
// item id from API (with password query)
|
||||
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
|
||||
req.Header.Set("X-Paste-Password", "pw123")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var can struct {
|
||||
Items []struct{ ID string `json:"id"` } `json:"items"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &can)
|
||||
itemID := can.Items[0].ID
|
||||
|
||||
// API item with the unlock cookie but no password -> 200
|
||||
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
|
||||
req.AddCookie(pwCookie)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("item via cookie expected 200, got %d", rec.Code)
|
||||
}
|
||||
// forged cookie -> 401
|
||||
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
|
||||
req.AddCookie(&http.Cookie{Name: "pw_" + created.ID, Value: "forged"})
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 401 {
|
||||
t.Fatalf("forged cookie on item expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// #4: viewer-scoped soft delete of cans.
|
||||
func TestCanDeleteParity(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
body, ct := multipartBody(t, map[string]string{
|
||||
"title": "Doomed", "json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||
}, "", "", "")
|
||||
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var created struct{ ID string `json:"id"` }
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
|
||||
// delete without matching viewer -> 403
|
||||
req = httptest.NewRequest("DELETE", "/api/cans/"+created.ID, nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 403 {
|
||||
t.Fatalf("unauthorized delete expected 403, got %d", rec.Code)
|
||||
}
|
||||
// creator's browser -> 200
|
||||
req = httptest.NewRequest("DELETE", "/api/cans/"+created.ID, nil)
|
||||
req.AddCookie(&http.Cookie{Name: "vwr", Value: "creator"})
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("creator delete expected 200, got %d", rec.Code)
|
||||
}
|
||||
// gone from API and page
|
||||
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 404 {
|
||||
t.Fatalf("deleted can expected 404, got %d", rec.Code)
|
||||
}
|
||||
// gone from listings
|
||||
req = httptest.NewRequest("GET", "/api/public", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if strings.Contains(rec.Body.String(), "Doomed") {
|
||||
t.Fatalf("deleted can still listed")
|
||||
}
|
||||
}
|
||||
|
||||
// #4: cans appear in /api/mine for the creating browser.
|
||||
func TestCanInMine(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
body, ct := multipartBody(t, map[string]string{
|
||||
"title": "Mine can", "json_items": `[{"title":"a.txt","content":"AAA"}]`,
|
||||
}, "", "", "")
|
||||
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-mine"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create: %d", rec.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest("GET", "/api/mine", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "vwr", Value: "v-mine"})
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var got struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
IsCan bool `json:"is_can"`
|
||||
} `json:"items"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if len(got.Items) != 1 || !got.Items[0].IsCan {
|
||||
t.Fatalf("can missing from /api/mine: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// #4: sweep soft-deletes expired cans (parity with pastes).
|
||||
func TestCanSweepExpired(t *testing.T) {
|
||||
s := testServer(t)
|
||||
s.store.CreateCan("sweepcan", "sweep", "", "public", nil, 1, nil, nil)
|
||||
s.store.InsertCanItem("sweepcan", "a.txt", "AAA", "text/plain", nil, nil, nil, 1)
|
||||
s.store.Exec(`UPDATE paste_cans SET expires_at=? WHERE id=?`, 100, "sweepcan")
|
||||
s.store.SweepExpired()
|
||||
if can, _ := s.store.GetCan("sweepcan"); can != nil {
|
||||
t.Fatalf("expired can survived sweep")
|
||||
}
|
||||
_ = store.GenSlug(4) // keep import if store pkg shrinks
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func multipartBody(t *testing.T, fields map[string]string, fileField, fileName, fileContent string) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
for k, v := range fields {
|
||||
w.WriteField(k, v)
|
||||
}
|
||||
if fileField != "" {
|
||||
fw, _ := w.CreateFormFile(fileField, fileName)
|
||||
fw.Write([]byte(fileContent))
|
||||
}
|
||||
w.Close()
|
||||
return &buf, w.FormDataContentType()
|
||||
}
|
||||
|
||||
func TestCreateAndGetCan(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
body, ct := multipartBody(t, map[string]string{
|
||||
"title": "My can",
|
||||
"json_items": `[{"title":"a.txt","content":"AAA"},{"title":"b.txt","content":"BBB"}]`,
|
||||
"expires_in": "1h",
|
||||
}, "files", "pic.txt", "file data")
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 201 {
|
||||
t.Fatalf("create can: got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
Items int `json:"items"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
if created.Items != 3 {
|
||||
t.Fatalf("expected 3 items, got %d", created.Items)
|
||||
}
|
||||
|
||||
// get can
|
||||
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("get can: got %d", rec.Code)
|
||||
}
|
||||
var can struct {
|
||||
Items []struct{ ID string `json:"id"` } `json:"items"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &can)
|
||||
if len(can.Items) != 3 {
|
||||
t.Fatalf("expected 3 items in get, got %d", len(can.Items))
|
||||
}
|
||||
|
||||
// fetch item
|
||||
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+can.Items[0].ID, nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("item fetch: got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyCanRejected(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
body, ct := multipartBody(t, map[string]string{"title": "empty"}, "", "", "")
|
||||
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 400 {
|
||||
t.Fatalf("expected 400 for empty can, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanPasswordInheritedByItems(t *testing.T) {
|
||||
s := testServer(t)
|
||||
h := s.routes()
|
||||
|
||||
body, ct := multipartBody(t, map[string]string{
|
||||
"title": "locked",
|
||||
"password": "pw123",
|
||||
"json_items": `[{"title":"s.txt","content":"sec"}]`,
|
||||
}, "", "", "")
|
||||
req := httptest.NewRequest("POST", "/api/pastes/can", body)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var created struct{ ID string `json:"id"` }
|
||||
json.Unmarshal(rec.Body.Bytes(), &created)
|
||||
|
||||
// can without pw -> 401
|
||||
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 401 {
|
||||
t.Fatalf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// get item id with pw
|
||||
req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
|
||||
req.Header.Set("X-Paste-Password", "pw123")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var can struct {
|
||||
Items []struct{ ID string `json:"id"` } `json:"items"`
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &can)
|
||||
itemID := can.Items[0].ID
|
||||
|
||||
// item without pw -> 401
|
||||
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 401 {
|
||||
t.Fatalf("item expected 401, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// item with pw -> 200
|
||||
req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
|
||||
req.Header.Set("X-Paste-Password", "pw123")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("item expected 200, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "sec") {
|
||||
t.Fatalf("item content mismatch: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package api
|
||||
|
||||
// #143: create responses must set the short-lived tok_<id> HttpOnly cookie
|
||||
// that the paste view reads for the one-time created banner.
|
||||
// #143: create responses must set the short-lived tok_<id> HttpOnly+Secure
|
||||
// cookie that the paste view reads for the one-time created banner.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -29,6 +29,9 @@ func TestCreateSetsDeletionTokenCookie(t *testing.T) {
|
||||
if !c.HttpOnly {
|
||||
t.Error("tok_ cookie not HttpOnly")
|
||||
}
|
||||
if !c.Secure {
|
||||
t.Error("tok_ cookie not Secure")
|
||||
}
|
||||
if c.MaxAge != 60 {
|
||||
t.Errorf("tok_ cookie MaxAge = %d, want 60", c.MaxAge)
|
||||
}
|
||||
|
||||
@@ -86,38 +86,3 @@ func TestErrorCodeRateLimited(t *testing.T) {
|
||||
}
|
||||
t.Fatalf("never got rate_limited, last code = %q", code)
|
||||
}
|
||||
|
||||
func TestErrorCodeCanValidation(t *testing.T) {
|
||||
h := testServer(t).routes()
|
||||
|
||||
post := func(fields map[string]string) *httptest.ResponseRecorder {
|
||||
var b strings.Builder
|
||||
for k, v := range fields {
|
||||
b.WriteString("--B\r\nContent-Disposition: form-data; name=\"" + k + "\"\r\n\r\n" + v + "\r\n")
|
||||
}
|
||||
b.WriteString("--B--\r\n")
|
||||
req := httptest.NewRequest("POST", "/api/pastes/can", strings.NewReader(b.String()))
|
||||
req.Header.Set("Content-Type", "multipart/form-data; boundary=B")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// bad visibility
|
||||
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "visibility": "nope"})); code != "invalid_visibility" {
|
||||
t.Fatalf("visibility code = %q, want invalid_visibility", code)
|
||||
}
|
||||
|
||||
// invalid expiry
|
||||
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "expires_in": "1s"})); code != "expiry_invalid" {
|
||||
t.Fatalf("expiry code = %q, want expiry_invalid", code)
|
||||
}
|
||||
|
||||
// slug taken on can create
|
||||
if rec := postCreate(h, `{"content":"a","custom_slug":"can-slug"}`); rec.Code != 201 {
|
||||
t.Fatalf("seed create: %d", rec.Code)
|
||||
}
|
||||
if _, _, code := decodeErr(t, post(map[string]string{"title": "x", "custom_slug": "can-slug", "json_items": `[{"title":"a","content":"b"}]`})); code != "slug_taken" {
|
||||
t.Fatalf("can slug code = %q, want slug_taken", code)
|
||||
}
|
||||
}
|
||||
+4
-141
@@ -1,5 +1,5 @@
|
||||
// Package api implements palette's REST handlers and the HTTP router:
|
||||
// pastes, cans, guess-language, rate limiting middleware, and the admin API.
|
||||
// pastes, guess-language, rate limiting middleware, and the admin API.
|
||||
package api
|
||||
|
||||
import (
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -18,7 +17,6 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
langpkg "palette/internal/lang"
|
||||
"palette/internal/store"
|
||||
"palette/internal/web"
|
||||
)
|
||||
@@ -59,8 +57,7 @@ func writeErrCode(w http.ResponseWriter, status int, code, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg, "code": code})
|
||||
}
|
||||
|
||||
// createErrCode maps a store.CreatePaste/CreateCan error to its UI error
|
||||
// code (#105).
|
||||
// createErrCode maps a store.CreatePaste error to its UI error code (#105).
|
||||
func createErrCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrSlugTaken):
|
||||
@@ -103,16 +100,8 @@ func (a *apiServer) routes() http.Handler {
|
||||
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
|
||||
r.Get("/public", a.handleListPublic)
|
||||
r.Post("/guess-language", a.handleGuessLang)
|
||||
r.Post("/pastes/can", a.handleCreateCan)
|
||||
r.Get("/cans/{id}", a.handleGetCan)
|
||||
r.Delete("/cans/{id}", a.handleDeleteCan)
|
||||
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
|
||||
})
|
||||
|
||||
// can page (#4): GET renders, POST unlocks (same flow as pastes)
|
||||
r.Get("/can/{id}", a.handleCanPage)
|
||||
r.Post("/can/{id}", a.handleCanPage)
|
||||
|
||||
// raw + #38 attachment serving
|
||||
r.Get("/f/{aid}/{filename}", a.handleServeAttachment)
|
||||
r.Get("/raw/{id}", a.handleRaw)
|
||||
@@ -189,7 +178,7 @@ func viewerSentCookie(r *http.Request) bool {
|
||||
func setDeletionTokenCookie(w http.ResponseWriter, pasteID, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "tok_" + pasteID, Value: token, Path: "/",
|
||||
MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -442,7 +431,6 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
|
||||
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
|
||||
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
||||
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
|
||||
"is_can": row.IsCan,
|
||||
})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
||||
@@ -462,8 +450,7 @@ func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
|
||||
items = append(items, map[string]any{
|
||||
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
|
||||
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
||||
"custom_slug": store.NullStrPtr(row.CustomSlug),
|
||||
"is_can": row.IsCan,
|
||||
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
|
||||
})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
||||
@@ -548,130 +535,6 @@ func safeRawContentType(ct string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
can, err := a.store.GetCan(id)
|
||||
if err != nil || can == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// #4: password-protected cans go through the same unlock flow as pastes:
|
||||
// the pw_<id> cookie carries an HMAC token bound to this can id. Items
|
||||
// inherit the protection (handleCanItem checks the same cookie).
|
||||
if can.PasswordHash.Valid {
|
||||
h := a.webHandlers()
|
||||
if r.Method == http.MethodPost {
|
||||
if !rateLimitUnlock(can.ID, r) {
|
||||
h.WriteRateLimited(w, 60)
|
||||
return
|
||||
}
|
||||
r.ParseForm()
|
||||
pw := r.FormValue("password")
|
||||
if pw != "" && store.CheckPassword(can.PasswordHash.String, pw) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "pw_" + can.ID, Value: web.UnlockToken(can.ID), Path: "/",
|
||||
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
a.renderCan(w, can)
|
||||
return
|
||||
}
|
||||
h.RenderPage(w, "unlock.html", map[string]any{
|
||||
"Page": "unlock", "ID": can.ID, "Wrong": true,
|
||||
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
|
||||
})
|
||||
return
|
||||
}
|
||||
c, err := r.Cookie("pw_" + can.ID)
|
||||
if err != nil || c.Value != web.UnlockToken(can.ID) {
|
||||
h.RenderPage(w, "unlock.html", map[string]any{
|
||||
"Page": "unlock", "ID": can.ID, "Wrong": false,
|
||||
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
a.renderCan(w, can)
|
||||
}
|
||||
|
||||
// renderCan renders the can view page: title/description and items as cards.
|
||||
// Text items expand inline; files link to download.
|
||||
func (a *apiServer) renderCan(w http.ResponseWriter, can *store.CanRow) {
|
||||
h := a.webHandlers()
|
||||
items, err := a.store.ListCanItems(can.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", 500)
|
||||
return
|
||||
}
|
||||
type canItem struct {
|
||||
ID string
|
||||
Title string
|
||||
ContentType string
|
||||
Size string
|
||||
IsFile bool
|
||||
Content string
|
||||
ContentHTML template.HTML
|
||||
Language string
|
||||
}
|
||||
cards := make([]canItem, 0, len(items))
|
||||
totalSize := 0
|
||||
for _, it := range items {
|
||||
totalSize += len(it.Content)
|
||||
isFile := it.ContentType != "text/plain" && !strings.HasPrefix(it.ContentType, "text/")
|
||||
ci := canItem{
|
||||
ID: it.ID,
|
||||
Title: nullStrOr(it.Title, it.ID),
|
||||
ContentType: it.ContentType,
|
||||
Size: web.HumanSize(len(it.Content)),
|
||||
IsFile: isFile,
|
||||
Language: it.Language.String,
|
||||
}
|
||||
if !isFile {
|
||||
ci.ContentHTML = template.HTML(langpkg.HighlightCode(it.Content, it.Language.String))
|
||||
}
|
||||
cards = append(cards, ci)
|
||||
}
|
||||
h.RenderPage(w, "can.html", map[string]any{
|
||||
"Page": "can",
|
||||
"ID": can.ID,
|
||||
"Title": nullStrOr(can.Title, "Untitled can"),
|
||||
"Description": can.Description.String,
|
||||
"HasDescription": can.Description.Valid && can.Description.String != "",
|
||||
"HasPassword": can.PasswordHash.Valid,
|
||||
"Items": cards,
|
||||
"ItemCount": len(cards),
|
||||
"SizeHuman": web.HumanSize(totalSize),
|
||||
"CreatedAgo": web.AgoString(can.CreatedAt),
|
||||
"CreatedAtUnix": can.CreatedAt,
|
||||
"ExpiresAt": can.ExpiresAt.Valid,
|
||||
"ExpiresIn": expiryStringIfValid(can.ExpiresAt),
|
||||
})
|
||||
}
|
||||
|
||||
// expiryStringIfValid formats remaining time for a valid expiry, "" otherwise.
|
||||
func expiryStringIfValid(ns sql.NullInt64) string {
|
||||
if !ns.Valid {
|
||||
return ""
|
||||
}
|
||||
remaining := ns.Int64 - time.Now().Unix()
|
||||
s := remaining
|
||||
switch {
|
||||
case s < 3600:
|
||||
return fmt.Sprintf("%dm", s/60)
|
||||
case s < 86400:
|
||||
return fmt.Sprintf("%dh", s/3600)
|
||||
default:
|
||||
return fmt.Sprintf("%dd", s/86400)
|
||||
}
|
||||
}
|
||||
|
||||
func templateEsc(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||
h := a.webHandlers()
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// #68 input-validation helpers. Paste/can payloads are size-capped and list
|
||||
// #68 input-validation helpers. Paste payloads are size-capped and list
|
||||
// endpoints get a single place where limit/offset are parsed and clamped.
|
||||
|
||||
// maxRequestBody returns the HTTP body cap for JSON create endpoints: the
|
||||
|
||||
@@ -10,7 +10,7 @@ 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,
|
||||
"api": true, "raw": true, "public": true,
|
||||
"history": true, "static": true, "assets": true, "favicon.ico": true,
|
||||
"new": true, "login": true, "logout": true, "admin": true, "settings": true,
|
||||
"mine": true, "saved": true, "unlock": true, "guess": true, "f": true,
|
||||
@@ -36,12 +36,5 @@ func (s *Store) SlugTaken(slug string) (bool, error) {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Cans feature removal (2026-10-16): opening a store on a pre-removal
|
||||
// database must drop the paste_cans table (only when empty) and the
|
||||
// pastes.can_id column. A non-empty paste_cans table is left in place.
|
||||
func TestMigrateDropsCansSchema(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
legacy := func(name string, canRows int) string {
|
||||
db, err := sql.Open("sqlite", filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
t.Fatalf("open raw db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
stmts := []string{
|
||||
`CREATE TABLE pastes (
|
||||
id TEXT PRIMARY KEY, custom_slug TEXT UNIQUE, content TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'text/plain', language TEXT, title TEXT,
|
||||
password_hash TEXT, expires_at INTEGER, burn_after_read INTEGER DEFAULT 0,
|
||||
visibility TEXT NOT NULL DEFAULT 'public', can_id TEXT,
|
||||
created_at INTEGER NOT NULL, deleted_at INTEGER,
|
||||
view_count INTEGER NOT NULL DEFAULT 0, deletion_token TEXT
|
||||
)`,
|
||||
`CREATE TABLE paste_cans (
|
||||
id TEXT PRIMARY KEY, title TEXT, description TEXT,
|
||||
visibility TEXT NOT NULL DEFAULT 'public', password_hash TEXT,
|
||||
created_at INTEGER NOT NULL, deleted_at INTEGER, expires_at INTEGER
|
||||
)`,
|
||||
`ALTER TABLE paste_cans ADD COLUMN viewer_id TEXT`,
|
||||
`INSERT INTO pastes (id, content, content_type, visibility, can_id, created_at)
|
||||
VALUES ('p1', 'x', 'text/plain', 'public', NULL, 1)`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := db.Exec(q); err != nil {
|
||||
t.Fatalf("seed %q: %v", q, err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < canRows; i++ {
|
||||
if _, err := db.Exec(`INSERT INTO paste_cans (id, created_at) VALUES (?, 1)`, string(rune('a'+i))); err != nil {
|
||||
t.Fatalf("seed can row: %v", err)
|
||||
}
|
||||
}
|
||||
return filepath.Join(dir, name)
|
||||
}
|
||||
|
||||
intCol := func(s *Store, table, col string) int {
|
||||
var n int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`, table, col).Scan(&n)
|
||||
return n
|
||||
}
|
||||
tableExists := func(s *Store, table string) bool {
|
||||
var n int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// Empty paste_cans: table and column both dropped.
|
||||
s1, err := OpenStore(legacy("empty.db", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer s1.db.Close()
|
||||
if tableExists(s1, "paste_cans") {
|
||||
t.Error("paste_cans still exists after migrate (was empty; should be dropped)")
|
||||
}
|
||||
if intCol(s1, "pastes", "can_id") != 0 {
|
||||
t.Error("pastes.can_id still exists after migrate; should be dropped")
|
||||
}
|
||||
// The store still works end to end.
|
||||
if _, err := s1.CreatePaste(&Paste{Content: "hello", Visibility: "public"}); err != nil {
|
||||
t.Fatalf("create paste on migrated db: %v", err)
|
||||
}
|
||||
if got := s1.QueryInt(`SELECT COUNT(*) FROM pastes`); got != 2 {
|
||||
t.Errorf("pastes count = %d, want 2", got)
|
||||
}
|
||||
|
||||
// Non-empty paste_cans: table kept, can_id still dropped.
|
||||
s2, err := OpenStore(legacy("full.db", 2))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer s2.db.Close()
|
||||
if !tableExists(s2, "paste_cans") {
|
||||
t.Error("non-empty paste_cans was dropped; must be kept until cleaned")
|
||||
}
|
||||
if intCol(s2, "pastes", "can_id") != 0 {
|
||||
t.Error("pastes.can_id still exists after migrate; should be dropped")
|
||||
}
|
||||
|
||||
// Fresh database: neither the table nor the column is ever created.
|
||||
s3, err := OpenStore(filepath.Join(dir, "fresh.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open fresh: %v", err)
|
||||
}
|
||||
defer s3.db.Close()
|
||||
if tableExists(s3, "paste_cans") {
|
||||
t.Error("fresh db created paste_cans")
|
||||
}
|
||||
if intCol(s3, "pastes", "can_id") != 0 {
|
||||
t.Error("fresh db created pastes.can_id")
|
||||
}
|
||||
}
|
||||
+38
-146
@@ -32,7 +32,6 @@ type Paste struct {
|
||||
Visibility string `json:"visibility"`
|
||||
// #83: accept "public": true/false as an alias for visibility.
|
||||
Public *bool `json:"public,omitempty"`
|
||||
CanID *string `json:"can_id,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
||||
@@ -55,29 +54,15 @@ type PasteRow struct {
|
||||
ReadsLimit sql.NullInt64
|
||||
ReadsUsed int
|
||||
Visibility string
|
||||
CanID sql.NullString
|
||||
CreatedAt int64
|
||||
DeletedAt sql.NullInt64
|
||||
ViewCount int
|
||||
Size int
|
||||
DeletionToken sql.NullString
|
||||
ViewerID sql.NullString
|
||||
IsCan bool // set on list rows that are cans (#4)
|
||||
AttFilename string // attachment filename when the paste is a file paste (#235); empty otherwise
|
||||
}
|
||||
|
||||
type CanRow struct {
|
||||
ID string
|
||||
Title sql.NullString
|
||||
Visibility string
|
||||
PasswordHash sql.NullString
|
||||
CreatedAt int64
|
||||
DeletedAt sql.NullInt64
|
||||
ExpiresAt sql.NullInt64
|
||||
Description sql.NullString
|
||||
ViewerID sql.NullString
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
blob BlobStore // #38: attachment byte storage (nil in some unit tests)
|
||||
@@ -119,7 +104,6 @@ func (s *Store) migrate() error {
|
||||
expires_at INTEGER,
|
||||
burn_after_read INTEGER DEFAULT 0,
|
||||
visibility TEXT NOT NULL DEFAULT 'public',
|
||||
can_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER,
|
||||
view_count INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -128,22 +112,32 @@ deletion_token TEXT
|
||||
CREATE INDEX IF NOT EXISTS idx_pastes_visibility_created ON pastes(visibility, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_pastes_expires ON pastes(expires_at) WHERE expires_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_pastes_deleted ON pastes(deleted_at) WHERE deleted_at IS NOT NULL;
|
||||
CREATE TABLE IF NOT EXISTS paste_cans (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
visibility TEXT NOT NULL DEFAULT 'public',
|
||||
password_hash TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER,
|
||||
expires_at INTEGER
|
||||
);
|
||||
`)
|
||||
s.db.Exec(`ALTER TABLE pastes ADD COLUMN deletion_token TEXT`) // ignore if exists
|
||||
s.db.Exec(`ALTER TABLE pastes ADD COLUMN viewer_id TEXT`) // ignore if exists (#37)
|
||||
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_limit INTEGER`) // ignore if exists (#49)
|
||||
s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_used INTEGER DEFAULT 0`) // ignore if exists (#49)
|
||||
s.db.Exec(`ALTER TABLE paste_cans ADD COLUMN viewer_id TEXT`) // ignore if exists (#4)
|
||||
// Cans feature removal (owner decision 2026-10-16): drop the leftover
|
||||
// schema. paste_cans is only dropped when empty so no data is silently
|
||||
// destroyed; a non-empty table is left in place and logged. The dormant
|
||||
// pastes.can_id column is dropped too (prod verified clean 2026-10-16:
|
||||
// zero rows referenced a can).
|
||||
var tbl int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='paste_cans'`).Scan(&tbl)
|
||||
if tbl > 0 {
|
||||
var rows int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM paste_cans`).Scan(&rows)
|
||||
if rows == 0 {
|
||||
s.db.Exec(`DROP TABLE paste_cans`)
|
||||
} else {
|
||||
log.Printf("store: paste_cans still holds %d row(s); leaving the table in place (cans feature removed)", rows)
|
||||
}
|
||||
}
|
||||
var col int
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('pastes') WHERE name='can_id'`).Scan(&col)
|
||||
if col > 0 {
|
||||
s.db.Exec(`ALTER TABLE pastes DROP COLUMN can_id`)
|
||||
}
|
||||
s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views (
|
||||
paste_id TEXT NOT NULL,
|
||||
viewer_id TEXT NOT NULL,
|
||||
@@ -290,10 +284,10 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
||||
}
|
||||
|
||||
func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
|
||||
row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count, deletion_token, viewer_id, reads_limit, COALESCE(reads_used, 0)
|
||||
row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deleted_at, view_count, deletion_token, viewer_id, reads_limit, COALESCE(reads_used, 0)
|
||||
FROM pastes WHERE (id = ? OR custom_slug = ?) AND deleted_at IS NULL`, idOrSlug, idOrSlug)
|
||||
var r PasteRow
|
||||
err := row.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken, &r.ViewerID, &r.ReadsLimit, &r.ReadsUsed)
|
||||
err := row.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken, &r.ViewerID, &r.ReadsLimit, &r.ReadsUsed)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -301,19 +295,15 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
|
||||
}
|
||||
|
||||
// ListPublic backs /api/public and the public listing page. Visibility rules
|
||||
// mirror the history page: only non-deleted, non-expired, non-can pastes are
|
||||
// listed, and password-protected pastes are excluded at the query level
|
||||
// (#65) so their metadata (title, slug, existence) never leaks.
|
||||
// mirror the history page: only non-deleted, non-expired pastes are listed,
|
||||
// and password-protected pastes are excluded at the query level (#65) so
|
||||
// their metadata (title, slug, existence) never leaks.
|
||||
func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
|
||||
rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.content_type, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), 0, COALESCE(a.filename, '')
|
||||
rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.content_type, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), COALESCE(a.filename, '')
|
||||
FROM pastes p
|
||||
LEFT JOIN attachments a ON a.paste_id = p.id
|
||||
WHERE p.visibility='public' AND p.deleted_at IS NULL AND p.can_id IS NULL AND p.password_hash IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
UNION ALL
|
||||
SELECT id, NULL, 'text/plain', NULL, title, visibility, created_at, 0, 0, 1, ''
|
||||
FROM paste_cans
|
||||
WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), time.Now().Unix(), limit, offset)
|
||||
WHERE p.visibility='public' AND p.deleted_at IS NULL AND p.password_hash IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
ORDER BY p.created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -322,34 +312,27 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
|
||||
for rows.Next() {
|
||||
var r PasteRow
|
||||
var cs, lang, title sql.NullString
|
||||
var isCan int
|
||||
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil {
|
||||
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &r.AttFilename); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
r.CustomSlug = cs
|
||||
r.Language = lang
|
||||
r.Title = title
|
||||
r.IsCan = isCan == 1
|
||||
out = append(out, r)
|
||||
}
|
||||
var total int
|
||||
s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?))
|
||||
+ (SELECT COUNT(*) FROM paste_cans WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`,
|
||||
time.Now().Unix(), time.Now().Unix()).Scan(&total)
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
|
||||
time.Now().Unix()).Scan(&total)
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// ListMine lists pastes created from the given viewer id (browser cookie), newest first.
|
||||
func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) {
|
||||
rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), 0, COALESCE(a.filename, '')
|
||||
rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), COALESCE(a.filename, '')
|
||||
FROM pastes p
|
||||
LEFT JOIN attachments a ON a.paste_id = p.id
|
||||
WHERE p.viewer_id = ? AND p.deleted_at IS NULL AND p.can_id IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
UNION ALL
|
||||
SELECT id, NULL, NULL, title, visibility, created_at, 0, 0, 1, ''
|
||||
FROM paste_cans
|
||||
WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), viewerID, time.Now().Unix(), limit, offset)
|
||||
WHERE p.viewer_id = ? AND p.deleted_at IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
ORDER BY p.created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -358,18 +341,15 @@ func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, e
|
||||
for rows.Next() {
|
||||
var r PasteRow
|
||||
var cs, lang, title sql.NullString
|
||||
var isCan int
|
||||
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil {
|
||||
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &r.AttFilename); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
r.CustomSlug, r.Language, r.Title = cs, lang, title
|
||||
r.IsCan = isCan == 1
|
||||
out = append(out, r)
|
||||
}
|
||||
var total int
|
||||
s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?))
|
||||
+ (SELECT COUNT(*) FROM paste_cans WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`,
|
||||
viewerID, time.Now().Unix(), viewerID, time.Now().Unix()).Scan(&total)
|
||||
s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`,
|
||||
viewerID, time.Now().Unix()).Scan(&total)
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
@@ -432,8 +412,6 @@ func (s *Store) IncrementViews(id, viewerID string, burnWindowMinutes int) bool
|
||||
func (s *Store) SweepExpired() {
|
||||
now := time.Now().Unix()
|
||||
s.db.Exec(`UPDATE pastes SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
|
||||
// #4: cans expire too — mirror paste behavior
|
||||
s.db.Exec(`UPDATE paste_cans SET deleted_at=? WHERE expires_at IS NOT NULL AND expires_at < ? AND deleted_at IS NULL`, now, now)
|
||||
grace := now - SoftDeleteGraceDays*86400
|
||||
s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace)
|
||||
}
|
||||
@@ -490,92 +468,6 @@ func (s *Store) HardDelete(id string) {
|
||||
s.db.Exec(`DELETE FROM pastes WHERE id = ?`, id)
|
||||
}
|
||||
|
||||
// CreateCan inserts a paste_can row with optional custom slug (parity with
|
||||
// pastes: validated by the same rules, checked against both tables).
|
||||
// Returns ErrSlugTaken / ErrInvalidSlug / ErrReservedSlug on conflict.
|
||||
func (s *Store) CreateCan(canID, title, description, visibility string, pwHash *string, createdAt int64, expiresAt *int64, customSlug *string) error {
|
||||
if customSlug != nil && *customSlug != "" {
|
||||
slug := *customSlug
|
||||
if err := ValidateCustomSlug(slug); err != nil {
|
||||
return err
|
||||
}
|
||||
taken, err := s.SlugTaken(slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if taken {
|
||||
return ErrSlugTaken
|
||||
}
|
||||
canID = slug
|
||||
}
|
||||
if visibility == "" {
|
||||
visibility = "public"
|
||||
}
|
||||
if visibility != "public" && visibility != "unlisted" {
|
||||
return errors.New("visibility must be public or unlisted")
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO paste_cans (id, title, description, visibility, password_hash, created_at, expires_at)
|
||||
VALUES (?,?,?,?,?,?,?)`, canID, title, description, visibility, pwHash, createdAt, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// SoftDeleteCan marks a can deleted (its items stay; they are unlisted and
|
||||
// hidden from listings by can_id and disappear with the can's page).
|
||||
func (s *Store) SoftDeleteCan(canID string) (bool, error) {
|
||||
res, err := s.db.Exec(`UPDATE paste_cans SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().Unix(), canID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// DeleteCan removes an (empty/aborted) can row.
|
||||
func (s *Store) DeleteCan(canID string) {
|
||||
s.db.Exec(`DELETE FROM paste_cans WHERE id=?`, canID)
|
||||
}
|
||||
|
||||
// InsertCanItem adds an item paste belonging to a can.
|
||||
func (s *Store) InsertCanItem(canID, title, content, contentType string, language *string, expiresAt, binary *string, now int64) error {
|
||||
// language/expiresAt unused here for now; content stored as text (binary-safe in sqlite)
|
||||
_, err := s.db.Exec(`INSERT INTO pastes
|
||||
(id, content, content_type, language, title, visibility, can_id, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)`,
|
||||
genSlug(6), content, contentType, language, &title, "unlisted", canID, now)
|
||||
_ = expiresAt
|
||||
_ = binary
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetCan(id string) (*CanRow, error) {
|
||||
row := s.db.QueryRow(`SELECT id, title, visibility, password_hash, created_at, deleted_at, expires_at, description, viewer_id
|
||||
FROM paste_cans WHERE (id = ?) AND deleted_at IS NULL`, id)
|
||||
var c CanRow
|
||||
err := row.Scan(&c.ID, &c.Title, &c.Visibility, &c.PasswordHash, &c.CreatedAt, &c.DeletedAt, &c.ExpiresAt, &c.Description, &c.ViewerID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return &c, err
|
||||
}
|
||||
|
||||
func (s *Store) ListCanItems(canID string) ([]PasteRow, error) {
|
||||
rows, err := s.db.Query(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count
|
||||
FROM pastes WHERE can_id = ? AND deleted_at IS NULL ORDER BY created_at ASC`, canID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PasteRow
|
||||
for rows.Next() {
|
||||
var r PasteRow
|
||||
if err := rows.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GenSlug is the exported slug generator.
|
||||
func GenSlug(n int) string { return genSlug(n) }
|
||||
|
||||
|
||||
@@ -694,26 +694,6 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; }
|
||||
.center .foot { font-size: 13px; }
|
||||
}
|
||||
|
||||
/* #4: can view page + can builder */
|
||||
.can-page h1 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.can-items { display: flex; flex-direction: column; gap: 12px; margin-top: 14px; }
|
||||
.can-item { padding: 12px 14px; }
|
||||
.can-item-head { display: flex; align-items: center; gap: 10px; }
|
||||
.can-item-head strong { flex: 1; word-break: break-all; }
|
||||
.can-item-body { margin-top: 8px; }
|
||||
.can-item-body summary { cursor: pointer; font-size: 19px; color: var(--muted, #888); }
|
||||
/* #169: divider between the dropdown title and body must be persistent
|
||||
(not hover dependent) and extend the full width of the card left and right */
|
||||
.can-item-body[open] summary {
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin: 0 -14px 8px;
|
||||
padding: 0 14px 8px;
|
||||
}
|
||||
.can-item-body pre.code { margin: 8px 0 0; overflow-x: auto; }
|
||||
.can-item-row { margin-top: 8px; }
|
||||
.can-item-row .can-item-title { width: 100%; margin-bottom: 6px; }
|
||||
.can-item-row .can-item-content { width: 100%; font-family: var(--mono, monospace); resize: vertical; }
|
||||
|
||||
/* ============================================================
|
||||
Styled controls (#123) — buttons, selects, checkboxes, radios
|
||||
match the custom URL box house pattern: 1px var(--border) on
|
||||
@@ -892,7 +872,6 @@ button[type="submit"]:focus-visible,
|
||||
|
||||
/* #139: CSP-safe replacements for inline style attributes (style-src 'self') */
|
||||
.hidden { display: none; }
|
||||
.can-page { max-width: 900px; width: 100%; }
|
||||
.col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; }
|
||||
.col-d { width: 90px; } .col-d2 { width: 150px; } .col-e { width: 150px; }
|
||||
.col-f { width: 100px; } .col-g { width: 120px; }
|
||||
|
||||
@@ -6,8 +6,8 @@ const t = PaletteTable.init({
|
||||
rowHtml: it =>
|
||||
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
|
||||
(it.title
|
||||
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
|
||||
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
|
||||
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>`
|
||||
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
|
||||
`</td>` +
|
||||
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
|
||||
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
|
||||
|
||||
@@ -18,8 +18,8 @@ const t = PaletteTable.init({
|
||||
rowHtml: it =>
|
||||
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
|
||||
(it.title
|
||||
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`
|
||||
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>${it.is_can ? ' <span class="badge" title="Can — bundle of items">can</span>' : ''}`) +
|
||||
? `<a class="paste-name" href="/${t.esc(it.id)}">${t.esc(it.title)}</a>`
|
||||
: `<a class="slug paste-name" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
|
||||
`</td>` +
|
||||
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></td>` +
|
||||
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
{{template "head" .}}
|
||||
{{template "topbar" .}}
|
||||
<div class="center">
|
||||
<div class="float can-page">
|
||||
<div class="inner">
|
||||
<h1>{{.Title}} <span class="badge" title="This is a can — a bundle of pastes">can</span></h1>
|
||||
{{if .HasDescription}}<p class="sub">{{.Description}}</p>{{end}}
|
||||
<p class="hint">{{.ItemCount}} item{{if ne .ItemCount 1}}s{{end}} · {{.SizeHuman}} · created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>{{if .ExpiresAt}} · expires in {{.ExpiresIn}}{{end}}{{if .HasPassword}} · password protected{{end}}</p>
|
||||
|
||||
<div class="can-items">
|
||||
{{range .Items}}
|
||||
<div class="can-item float">
|
||||
<div class="can-item-head">
|
||||
<strong>{{.Title}}</strong>
|
||||
<span class="dim">{{.Size}}</span>
|
||||
{{if .IsFile}}
|
||||
<a class="btn btn-icon" href="/api/cans/{{$.ID}}/items/{{.ID}}" download="{{.Title}}" title="Download file" aria-label="Download {{.Title}}">↓</a>
|
||||
{{else}}
|
||||
<a class="btn btn-icon" href="/api/cans/{{$.ID}}/items/{{.ID}}" title="Raw item" aria-label="Raw {{.Title}}">⧉</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if not .IsFile}}
|
||||
<details class="can-item-body">
|
||||
<summary>Show content</summary>
|
||||
<pre class="code"><code>{{.ContentHTML}}</code></pre>
|
||||
</details>
|
||||
{{else}}
|
||||
<div class="hint">Attached file — use the download button above.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "foot" .}}
|
||||
+5
-28
@@ -1,5 +1,5 @@
|
||||
// Package web serves palette's HTML routes: paste pages, cans, unlock, and
|
||||
// the admin page. Templates and static assets are embedded in this package.
|
||||
// Package web serves palette's HTML routes: paste pages, unlock, and the
|
||||
// admin page. Templates and static assets are embedded in this package.
|
||||
package web
|
||||
|
||||
import (
|
||||
@@ -111,29 +111,6 @@ func (h *Handlers) RenderNotFoundPage(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderNotFound(w, r, r.URL.Path)
|
||||
}
|
||||
|
||||
// RenderPage is the exported wrapper used by the api package (#4 can pages).
|
||||
func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) {
|
||||
h.renderPage(w, name, data)
|
||||
}
|
||||
|
||||
// WriteRateLimited is the exported rate-limit response used by the api package (#4).
|
||||
func (h *Handlers) WriteRateLimited(w http.ResponseWriter, retryAfterSecs int) {
|
||||
h.writeRateLimited(w, retryAfterSecs)
|
||||
}
|
||||
|
||||
// UnlockToken returns the per-id HMAC unlock token (cookie value, #34).
|
||||
// Exported for the api package so can pages share paste cookie semantics (#4).
|
||||
func UnlockToken(id string) string { return unlockToken(id) }
|
||||
|
||||
// AgoString formats a relative "N ago" string (exported for the api package, #4).
|
||||
func AgoString(ts int64) string { return agoString(ts) }
|
||||
|
||||
// HumanSize formats a byte count as a human string (exported for the api package, #4).
|
||||
func HumanSize(n int) string { return humanSize(n) }
|
||||
|
||||
// ExpiryString formats remaining time until an epoch seconds expiry (#4).
|
||||
func ExpiryString(expiresAt int64) string { return expiryString(expiresAt) }
|
||||
|
||||
// #34: per-paste unlock tokens. unlockSecret is generated once at startup
|
||||
// (also derivable from PALETTE_UNLOCK_SECRET for multi-instance deploys) and
|
||||
// used to HMAC paste ids, so a client can only hold a valid pw_<id> cookie by
|
||||
@@ -369,7 +346,7 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||
// the paste id under the server's random secret.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "pw_" + row.ID, Value: unlockToken(row.ID), Path: "/",
|
||||
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true,
|
||||
})
|
||||
// re-render without lock, or redirect if ?next= was given (#26)
|
||||
if next := r.FormValue("next"); next != "" {
|
||||
@@ -403,7 +380,7 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if justCreated && token != "" {
|
||||
// one-time display of the deletion token via sessionStorage (#143)
|
||||
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true})
|
||||
}
|
||||
// Count the view for real page renders, deduped per-viewer within the
|
||||
// burn window (#95): a reload by the same vwr cookie doesn't inflate
|
||||
@@ -466,7 +443,7 @@ func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||
func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }
|
||||
|
||||
// #59: security headers for rendered HTML pages. Applied wherever the
|
||||
// response is text/html (page templates and the inline can page); JSON API
|
||||
// response is text/html (page templates); JSON API
|
||||
// responses and /raw content pass through untouched.
|
||||
// #139: script-src and style-src no longer allow 'unsafe-inline'. All
|
||||
// previously-inline scripts moved to external files under static/ (page data
|
||||
|
||||
Reference in new issue
Block a user