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" { writeErr(w, 400, "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 { writeErr(w, 400, "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) { writeErr(w, 400, "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: 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 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) writeErr(w, 400, "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 == "" { pw = r.URL.Query().Get("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_ 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 == "" { pw = r.URL.Query().Get("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)) }