Cans UI + parity: unlock-cookie flow, /can page, listings badge, custom slug, delete, sweeper; #32 perf notes (#4, #32)
This commit is contained in:
+55
-5
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"palette/internal/web"
|
||||
)
|
||||
|
||||
// CreateCan makes a can with N items (multipart form).
|
||||
@@ -31,6 +33,7 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
expiresIn := r.FormValue("expires_in")
|
||||
password := r.FormValue("password")
|
||||
customSlug := r.FormValue("custom_slug")
|
||||
|
||||
var expiresAt *int64
|
||||
now := time.Now().Unix()
|
||||
@@ -40,6 +43,11 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, "invalid expires_in")
|
||||
return
|
||||
}
|
||||
// parity with pastes (#48): same expiry window is enforced
|
||||
if !store.ValidExpiry(d) {
|
||||
writeErr(w, 400, "expires_in must be between 1 minute and 1 year")
|
||||
return
|
||||
}
|
||||
t := now + int64(d.Seconds())
|
||||
expiresAt = &t
|
||||
}
|
||||
@@ -54,11 +62,25 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
canID := store.GenSlug(8)
|
||||
err := a.store.InsertCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt)
|
||||
var slugPtr *string
|
||||
if customSlug != "" {
|
||||
slugPtr = &customSlug
|
||||
}
|
||||
err := a.store.CreateCan(canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt, slugPtr)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "db error")
|
||||
switch err {
|
||||
case store.ErrSlugTaken, store.ErrInvalidSlug, store.ErrReservedSlug:
|
||||
writeErr(w, 409, 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
|
||||
@@ -123,6 +145,29 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -207,7 +252,8 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 404, "not a can item")
|
||||
return
|
||||
}
|
||||
// inherit can password protection
|
||||
// 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")
|
||||
@@ -215,8 +261,12 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
|
||||
pw = r.URL.Query().Get("password")
|
||||
}
|
||||
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
|
||||
writeErr(w, 401, "password required")
|
||||
return
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
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+"?password=pw123", nil)
|
||||
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
|
||||
}
|
||||
+118
-9
@@ -4,8 +4,10 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -15,8 +17,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"database/sql"
|
||||
|
||||
langpkg "palette/internal/lang"
|
||||
"palette/internal/store"
|
||||
"palette/internal/web"
|
||||
)
|
||||
@@ -77,11 +78,13 @@ func (a *apiServer) routes() http.Handler {
|
||||
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
|
||||
// can page (#4): GET renders, POST unlocks (same flow as pastes)
|
||||
r.Get("/can/{id}", a.handleCanPage)
|
||||
r.Post("/can/{id}", a.handleCanPage)
|
||||
|
||||
// raw
|
||||
r.Get("/raw/{id}", a.handleRaw)
|
||||
@@ -307,6 +310,7 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
|
||||
"id": row.ID, "title": title, "language": lang,
|
||||
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
||||
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
|
||||
"is_can": row.IsCan,
|
||||
})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
||||
@@ -330,6 +334,7 @@ func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
|
||||
"id": row.ID, "title": title, "language": lang,
|
||||
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
||||
"custom_slug": store.NullStrPtr(row.CustomSlug),
|
||||
"is_can": row.IsCan,
|
||||
})
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
||||
@@ -373,7 +378,7 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
a.store.IncrementViews(row.ID)
|
||||
a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95)
|
||||
w.Write([]byte(row.Content))
|
||||
}
|
||||
|
||||
@@ -402,13 +407,117 @@ func (a *apiServer) handleCanPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
items, _ := a.store.ListCanItems(can.ID)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, "<!doctype html><html><head><title>can/%s — palette</title></head><body><h1>can/%s</h1><ul>", can.ID, can.ID)
|
||||
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 {
|
||||
fmt.Fprintf(w, `<li><a href="/api/cans/%s/items/%s">%s</a> (%s)</li>`, can.ID, it.ID, templateEsc(nullStrOr(it.Title, it.ID)), it.ContentType)
|
||||
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)
|
||||
}
|
||||
fmt.Fprintf(w, "</ul></body></html>")
|
||||
}
|
||||
|
||||
func templateEsc(s string) string {
|
||||
|
||||
Reference in New Issue
Block a user