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.
|
||||
|
||||
Reference in New Issue
Block a user