Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c02550050f |
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
Palette - self-hosted pastebin with paste cans
|
Palette - self-hosted pastebin
|
||||||
Copyright (C) 2026 poslop
|
Copyright (C) 2026 poslop
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
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
|
## Features
|
||||||
|
|
||||||
- Text pastes and cans (multiple items in one share)
|
- Text pastes
|
||||||
- File attachments (one file per paste, up to 25 MB)
|
- File attachments (one file per paste, up to 25 MB)
|
||||||
- Password protected pastes
|
- Password protected pastes
|
||||||
- Expire after a specified time
|
- Expire after a specified time
|
||||||
@@ -66,7 +66,7 @@ go build -o palette ./cmd/palette
|
|||||||
| `PALETTE_ADDR` | `:8080` | Listen address |
|
| `PALETTE_ADDR` | `:8080` | Listen address |
|
||||||
| `PALETTE_DB` | `palette.db` | SQLite database path |
|
| `PALETTE_DB` | `palette.db` | SQLite database path |
|
||||||
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
|
| `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_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_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. |
|
| `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).
|
# Default: 5242880 (5 MiB).
|
||||||
# PALETTE_MAX_TEXT: "5242880"
|
# PALETTE_MAX_TEXT: "5242880"
|
||||||
|
|
||||||
# Max size in bytes of a single can item (file/text inside a can) or a
|
# Max size in bytes for multipart request parsing (paste file
|
||||||
# paste file attachment.
|
# attachments are capped at 25 MB).
|
||||||
# Default: 26214400 (25 MiB).
|
# Default: 26214400 (25 MiB).
|
||||||
# PALETTE_MAX_ITEM: "26214400"
|
# PALETTE_MAX_ITEM: "26214400"
|
||||||
|
|
||||||
|
|||||||
+5
-64
@@ -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:
|
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.
|
- 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.
|
- 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
|
### 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`.
|
- 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.
|
- 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.
|
- The request body itself is capped server side; oversized bodies fail with `413` before handlers run.
|
||||||
|
|
||||||
### Error format
|
### Error format
|
||||||
@@ -130,13 +129,13 @@ Errors: `400` token required, `403` invalid token, `404` not found.
|
|||||||
|
|
||||||
#### GET /api/mine
|
#### 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
|
#### 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
|
### 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.
|
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
|
### Admin
|
||||||
|
|
||||||
#### GET /admin/api/settings and POST /admin/api/settings
|
#### GET /admin/api/settings and POST /admin/api/settings
|
||||||
@@ -296,17 +248,6 @@ Hard delete immediately (row removed):
|
|||||||
curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/ab12cd34/redeem
|
curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/ab12cd34/redeem
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a can:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/pastes/can \
|
|
||||||
-F "title=My bundle" \
|
|
||||||
-F "expires_in=48h" \
|
|
||||||
-F "custom_slug=my-bundle" \
|
|
||||||
-F 'json_items=[{"title":"notes.txt","content":"some notes"}]' \
|
|
||||||
-F "files=@screenshot.png"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deletion and expiry behavior
|
## Deletion and expiry behavior
|
||||||
|
|
||||||
- Expired pastes are soft-deleted by a background sweeper that runs every minute.
|
- Expired pastes are soft-deleted by a background sweeper that runs every minute.
|
||||||
|
|||||||
@@ -186,26 +186,11 @@ func (a *apiServer) handleCreatePasteMultipart(w http.ResponseWriter, r *http.Re
|
|||||||
case "burn_after_read":
|
case "burn_after_read":
|
||||||
p.BurnAfterRead = v == "true" || v == "1" || v == "on"
|
p.BurnAfterRead = v == "true" || v == "1" || v == "on"
|
||||||
case "burn_after_reads":
|
case "burn_after_reads":
|
||||||
if v == "" { // absent value: unset, like the JSON path's omitempty
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
continue
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(v)
|
|
||||||
if err != nil {
|
|
||||||
// malformed burn_after_reads is a 400, not a silently-dropped
|
|
||||||
// budget: same 400-with-code shape the JSON path uses for
|
|
||||||
// invalid values (which fail unmarshal into the int field).
|
|
||||||
writeErrCode(w, 400, "burn_invalid", "burn_after_reads must be a positive number")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// negative values are invalid too: same 400 burn_invalid shape
|
|
||||||
// as the JSON path (validate.go:92), identical message text.
|
|
||||||
if err := parseBurnAfterReads(n); err != nil {
|
|
||||||
writeErrCode(w, 400, "burn_invalid", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.BurnAfterReads = &n
|
p.BurnAfterReads = &n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if fileSeen {
|
if fileSeen {
|
||||||
// 1 file = 1 paste: the file replaces text content.
|
// 1 file = 1 paste: the file replaces text content.
|
||||||
|
|||||||
@@ -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
|
package api
|
||||||
|
|
||||||
// #143: create responses must set the short-lived tok_<id> HttpOnly cookie
|
// #143: create responses must set the short-lived tok_<id> HttpOnly+Secure
|
||||||
// that the paste view reads for the one-time created banner.
|
// cookie that the paste view reads for the one-time created banner.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -29,6 +29,9 @@ func TestCreateSetsDeletionTokenCookie(t *testing.T) {
|
|||||||
if !c.HttpOnly {
|
if !c.HttpOnly {
|
||||||
t.Error("tok_ cookie not HttpOnly")
|
t.Error("tok_ cookie not HttpOnly")
|
||||||
}
|
}
|
||||||
|
if !c.Secure {
|
||||||
|
t.Error("tok_ cookie not Secure")
|
||||||
|
}
|
||||||
if c.MaxAge != 60 {
|
if c.MaxAge != 60 {
|
||||||
t.Errorf("tok_ cookie MaxAge = %d, want 60", c.MaxAge)
|
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)
|
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:
|
// 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
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -18,7 +17,6 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
langpkg "palette/internal/lang"
|
|
||||||
"palette/internal/store"
|
"palette/internal/store"
|
||||||
"palette/internal/web"
|
"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})
|
writeJSON(w, status, map[string]string{"error": msg, "code": code})
|
||||||
}
|
}
|
||||||
|
|
||||||
// createErrCode maps a store.CreatePaste/CreateCan error to its UI error
|
// createErrCode maps a store.CreatePaste error to its UI error code (#105).
|
||||||
// code (#105).
|
|
||||||
func createErrCode(err error) string {
|
func createErrCode(err error) string {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, store.ErrSlugTaken):
|
case errors.Is(err, store.ErrSlugTaken):
|
||||||
@@ -103,16 +100,8 @@ func (a *apiServer) routes() http.Handler {
|
|||||||
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
|
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
|
||||||
r.Get("/public", a.handleListPublic)
|
r.Get("/public", a.handleListPublic)
|
||||||
r.Post("/guess-language", a.handleGuessLang)
|
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
|
// raw + #38 attachment serving
|
||||||
r.Get("/f/{aid}/{filename}", a.handleServeAttachment)
|
r.Get("/f/{aid}/{filename}", a.handleServeAttachment)
|
||||||
r.Get("/raw/{id}", a.handleRaw)
|
r.Get("/raw/{id}", a.handleRaw)
|
||||||
@@ -189,7 +178,7 @@ func viewerSentCookie(r *http.Request) bool {
|
|||||||
func setDeletionTokenCookie(w http.ResponseWriter, pasteID, token string) {
|
func setDeletionTokenCookie(w http.ResponseWriter, pasteID, token string) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "tok_" + pasteID, Value: token, Path: "/",
|
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),
|
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
|
||||||
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
||||||
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
|
"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})
|
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{
|
items = append(items, map[string]any{
|
||||||
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
|
"id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename),
|
||||||
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
||||||
"custom_slug": store.NullStrPtr(row.CustomSlug),
|
"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})
|
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
|
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) {
|
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
||||||
h := a.webHandlers()
|
h := a.webHandlers()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"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.
|
// endpoints get a single place where limit/offset are parsed and clamped.
|
||||||
|
|
||||||
// maxRequestBody returns the HTTP body cap for JSON create endpoints: the
|
// 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
|
// reserved words that would collide with routes
|
||||||
var reservedSlugs = map[string]bool{
|
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,
|
"history": true, "static": true, "assets": true, "favicon.ico": true,
|
||||||
"new": true, "login": true, "logout": true, "admin": true, "settings": true,
|
"new": true, "login": true, "logout": true, "admin": true, "settings": true,
|
||||||
"mine": true, "saved": true, "unlock": true, "guess": true, "f": 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 {
|
if err != nil {
|
||||||
return false, err
|
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
|
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"`
|
Visibility string `json:"visibility"`
|
||||||
// #83: accept "public": true/false as an alias for visibility.
|
// #83: accept "public": true/false as an alias for visibility.
|
||||||
Public *bool `json:"public,omitempty"`
|
Public *bool `json:"public,omitempty"`
|
||||||
CanID *string `json:"can_id,omitempty"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||||
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
||||||
@@ -55,29 +54,15 @@ type PasteRow struct {
|
|||||||
ReadsLimit sql.NullInt64
|
ReadsLimit sql.NullInt64
|
||||||
ReadsUsed int
|
ReadsUsed int
|
||||||
Visibility string
|
Visibility string
|
||||||
CanID sql.NullString
|
|
||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
DeletedAt sql.NullInt64
|
DeletedAt sql.NullInt64
|
||||||
ViewCount int
|
ViewCount int
|
||||||
Size int
|
Size int
|
||||||
DeletionToken sql.NullString
|
DeletionToken sql.NullString
|
||||||
ViewerID 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
|
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 {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
blob BlobStore // #38: attachment byte storage (nil in some unit tests)
|
blob BlobStore // #38: attachment byte storage (nil in some unit tests)
|
||||||
@@ -119,7 +104,6 @@ func (s *Store) migrate() error {
|
|||||||
expires_at INTEGER,
|
expires_at INTEGER,
|
||||||
burn_after_read INTEGER DEFAULT 0,
|
burn_after_read INTEGER DEFAULT 0,
|
||||||
visibility TEXT NOT NULL DEFAULT 'public',
|
visibility TEXT NOT NULL DEFAULT 'public',
|
||||||
can_id TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
deleted_at INTEGER,
|
deleted_at INTEGER,
|
||||||
view_count INTEGER NOT NULL DEFAULT 0,
|
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_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_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 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 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 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_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 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 (
|
s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views (
|
||||||
paste_id TEXT NOT NULL,
|
paste_id TEXT NOT NULL,
|
||||||
viewer_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) {
|
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)
|
FROM pastes WHERE (id = ? OR custom_slug = ?) AND deleted_at IS NULL`, idOrSlug, idOrSlug)
|
||||||
var r PasteRow
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
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
|
// ListPublic backs /api/public and the public listing page. Visibility rules
|
||||||
// mirror the history page: only non-deleted, non-expired, non-can pastes are
|
// mirror the history page: only non-deleted, non-expired pastes are listed,
|
||||||
// listed, and password-protected pastes are excluded at the query level
|
// and password-protected pastes are excluded at the query level (#65) so
|
||||||
// (#65) so their metadata (title, slug, existence) never leaks.
|
// their metadata (title, slug, existence) never leaks.
|
||||||
func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
|
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
|
FROM pastes p
|
||||||
LEFT JOIN attachments a ON a.paste_id = p.id
|
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 > ?)
|
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 > ?)
|
||||||
UNION ALL
|
ORDER BY p.created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), limit, offset)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@@ -322,34 +312,27 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r PasteRow
|
var r PasteRow
|
||||||
var cs, lang, title sql.NullString
|
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, &r.AttFilename); err != nil {
|
||||||
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil {
|
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
r.CustomSlug = cs
|
r.CustomSlug = cs
|
||||||
r.Language = lang
|
r.Language = lang
|
||||||
r.Title = title
|
r.Title = title
|
||||||
r.IsCan = isCan == 1
|
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
}
|
}
|
||||||
var total int
|
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 > ?))
|
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 > ?)`,
|
||||||
+ (SELECT COUNT(*) FROM paste_cans WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`,
|
time.Now().Unix()).Scan(&total)
|
||||||
time.Now().Unix(), time.Now().Unix()).Scan(&total)
|
|
||||||
return out, total, nil
|
return out, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListMine lists pastes created from the given viewer id (browser cookie), newest first.
|
// 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) {
|
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
|
FROM pastes p
|
||||||
LEFT JOIN attachments a ON a.paste_id = p.id
|
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 > ?)
|
WHERE p.viewer_id = ? AND p.deleted_at IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||||
UNION ALL
|
ORDER BY p.created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), limit, offset)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@@ -358,18 +341,15 @@ func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, e
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r PasteRow
|
var r PasteRow
|
||||||
var cs, lang, title sql.NullString
|
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, &r.AttFilename); err != nil {
|
||||||
if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil {
|
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
r.CustomSlug, r.Language, r.Title = cs, lang, title
|
r.CustomSlug, r.Language, r.Title = cs, lang, title
|
||||||
r.IsCan = isCan == 1
|
|
||||||
out = append(out, r)
|
out = append(out, r)
|
||||||
}
|
}
|
||||||
var total int
|
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 > ?))
|
s.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE viewer_id = ? AND deleted_at 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()).Scan(&total)
|
||||||
viewerID, time.Now().Unix(), viewerID, time.Now().Unix()).Scan(&total)
|
|
||||||
return out, total, nil
|
return out, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,8 +412,6 @@ func (s *Store) IncrementViews(id, viewerID string, burnWindowMinutes int) bool
|
|||||||
func (s *Store) SweepExpired() {
|
func (s *Store) SweepExpired() {
|
||||||
now := time.Now().Unix()
|
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)
|
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
|
grace := now - SoftDeleteGraceDays*86400
|
||||||
s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace)
|
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)
|
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.
|
// GenSlug is the exported slug generator.
|
||||||
func GenSlug(n int) string { return genSlug(n) }
|
func GenSlug(n int) string { return genSlug(n) }
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ body {
|
|||||||
|
|
||||||
/* new paste page */
|
/* new paste page */
|
||||||
.deck {
|
.deck {
|
||||||
display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 16px;
|
display: grid; grid-template-columns: 1fr 300px; gap: 16px;
|
||||||
padding: 16px 20px 20px; height: calc(100vh - 76px);
|
padding: 16px 20px 20px; height: calc(100vh - 76px);
|
||||||
max-width: 1400px; margin: 0 auto;
|
max-width: 1400px; margin: 0 auto;
|
||||||
}
|
}
|
||||||
@@ -694,26 +694,6 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; }
|
|||||||
.center .foot { font-size: 13px; }
|
.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
|
Styled controls (#123) — buttons, selects, checkboxes, radios
|
||||||
match the custom URL box house pattern: 1px var(--border) on
|
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') */
|
/* #139: CSP-safe replacements for inline style attributes (style-src 'self') */
|
||||||
.hidden { display: none; }
|
.hidden { display: none; }
|
||||||
.can-page { max-width: 900px; width: 100%; }
|
|
||||||
.col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; }
|
.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-d { width: 90px; } .col-d2 { width: 150px; } .col-e { width: 150px; }
|
||||||
.col-f { width: 100px; } .col-g { width: 120px; }
|
.col-f { width: 100px; } .col-g { width: 120px; }
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ const t = PaletteTable.init({
|
|||||||
rowHtml: it =>
|
rowHtml: it =>
|
||||||
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
|
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
|
||||||
(it.title
|
(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="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>${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>`) +
|
||||||
`</td>` +
|
`</td>` +
|
||||||
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></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>` +
|
`<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 =>
|
rowHtml: it =>
|
||||||
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
|
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
|
||||||
(it.title
|
(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="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>${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>`) +
|
||||||
`</td>` +
|
`</td>` +
|
||||||
`<td><span class="badge">${t.esc(it.type || it.language || 'text')}</span></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>` +
|
`<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" .}}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>{{ pageTitle . }} · Palette</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
|
|||||||
+5
-61
@@ -1,5 +1,5 @@
|
|||||||
// Package web serves palette's HTML routes: paste pages, cans, unlock, and
|
// Package web serves palette's HTML routes: paste pages, unlock, and the
|
||||||
// the admin page. Templates and static assets are embedded in this package.
|
// admin page. Templates and static assets are embedded in this package.
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -74,7 +74,6 @@ func New() (*UI, error) {
|
|||||||
"humanSize": humanSize,
|
"humanSize": humanSize,
|
||||||
"version": func() string { return Version }, // #93: topbar version label
|
"version": func() string { return Version }, // #93: topbar version label
|
||||||
"defaultDark": func() bool { return DefaultDark }, // #127: server-configured default
|
"defaultDark": func() bool { return DefaultDark }, // #127: server-configured default
|
||||||
"pageTitle": pageTitle, // page <title> labels
|
|
||||||
}
|
}
|
||||||
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html")
|
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,38 +82,6 @@ func New() (*UI, error) {
|
|||||||
return &UI{tmpl: t}, nil
|
return &UI{tmpl: t}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageTitle returns the <title> label for the rendered page. Page data is
|
|
||||||
// always a map with a "Page" key set by the handlers.
|
|
||||||
func pageTitle(data any) string {
|
|
||||||
m, _ := data.(map[string]any)
|
|
||||||
page, _ := m["Page"].(string)
|
|
||||||
id, _ := m["ID"].(string)
|
|
||||||
title, _ := m["Title"].(string)
|
|
||||||
switch page {
|
|
||||||
case "new":
|
|
||||||
return "New paste"
|
|
||||||
case "public":
|
|
||||||
return "Public pastes"
|
|
||||||
case "saved":
|
|
||||||
return "Saved"
|
|
||||||
case "settings":
|
|
||||||
return "Settings"
|
|
||||||
case "paste":
|
|
||||||
if title != "" {
|
|
||||||
return title
|
|
||||||
}
|
|
||||||
return "Paste " + id
|
|
||||||
case "unlock":
|
|
||||||
return "Unlock " + id
|
|
||||||
case "can":
|
|
||||||
return "Can " + id
|
|
||||||
case "notfound":
|
|
||||||
return "Not found"
|
|
||||||
default:
|
|
||||||
return "Palette"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func humanSize(n int) string {
|
func humanSize(n int) string {
|
||||||
if n < 1024 {
|
if n < 1024 {
|
||||||
return fmt.Sprintf("%d B", n)
|
return fmt.Sprintf("%d B", n)
|
||||||
@@ -144,29 +111,6 @@ func (h *Handlers) RenderNotFoundPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.renderNotFound(w, r, r.URL.Path)
|
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
|
// #34: per-paste unlock tokens. unlockSecret is generated once at startup
|
||||||
// (also derivable from PALETTE_UNLOCK_SECRET for multi-instance deploys) and
|
// (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
|
// used to HMAC paste ids, so a client can only hold a valid pw_<id> cookie by
|
||||||
@@ -402,7 +346,7 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
|
|||||||
// the paste id under the server's random secret.
|
// the paste id under the server's random secret.
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: "pw_" + row.ID, Value: unlockToken(row.ID), Path: "/",
|
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)
|
// re-render without lock, or redirect if ?next= was given (#26)
|
||||||
if next := r.FormValue("next"); next != "" {
|
if next := r.FormValue("next"); next != "" {
|
||||||
@@ -436,7 +380,7 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if justCreated && token != "" {
|
if justCreated && token != "" {
|
||||||
// one-time display of the deletion token via sessionStorage (#143)
|
// 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
|
// 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
|
// burn window (#95): a reload by the same vwr cookie doesn't inflate
|
||||||
@@ -499,7 +443,7 @@ func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }
|
func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }
|
||||||
|
|
||||||
// #59: security headers for rendered HTML pages. Applied wherever the
|
// #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.
|
// responses and /raw content pass through untouched.
|
||||||
// #139: script-src and style-src no longer allow 'unsafe-inline'. All
|
// #139: script-src and style-src no longer allow 'unsafe-inline'. All
|
||||||
// previously-inline scripts moved to external files under static/ (page data
|
// previously-inline scripts moved to external files under static/ (page data
|
||||||
|
|||||||
Reference in new issue
Block a user