Cans API: multipart create with mixed items, password-protected cans, item fetch inheriting can auth
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// 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")
|
||||
|
||||
var expiresAt *int64
|
||||
now := time.Now().Unix()
|
||||
if expiresIn != "" {
|
||||
d, err := time.ParseDuration(expiresIn)
|
||||
if err != nil {
|
||||
writeErr(w, 400, "invalid expires_in")
|
||||
return
|
||||
}
|
||||
t := now + int64(d.Seconds())
|
||||
expiresAt = &t
|
||||
}
|
||||
var pwHash *string
|
||||
if password != "" {
|
||||
h, err := hashPassword(password)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "hash error")
|
||||
return
|
||||
}
|
||||
pwHash = &h
|
||||
}
|
||||
|
||||
canID := genSlug(8)
|
||||
_, err := a.store.db.Exec(`INSERT INTO paste_cans (id, title, description, visibility, password_hash, created_at, expires_at)
|
||||
VALUES (?,?,?,?,?,?,?)`, canID, title, r.FormValue("description"), visibility, pwHash, now, expiresAt)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "db error")
|
||||
return
|
||||
}
|
||||
|
||||
// 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.db.Exec(`DELETE FROM paste_cans WHERE id=?`, 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,
|
||||
})
|
||||
}
|
||||
|
||||
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 (s *Store) insertCanItem(canID, title, content, contentType string, language, expiresAt *string, 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
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
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 == "" || !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: 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": 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
|
||||
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 == "" || !checkPassword(can.PasswordHash.String, pw) {
|
||||
writeErr(w, 401, "password required")
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", row.ContentType)
|
||||
w.Write([]byte(row.Content))
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package main
|
||||
|
||||
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+"?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
|
||||
|
||||
// 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+"?password=pw123", nil)
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -302,8 +302,14 @@ func (a *apiServer) routes() http.Handler {
|
||||
r.Get("/pastes/{id}", a.handleGetPaste)
|
||||
r.Delete("/pastes/{id}", a.handleDeletePaste)
|
||||
r.Get("/public", a.handleListPublic)
|
||||
r.Post("/pastes/can", a.handleCreateCan)
|
||||
r.Get("/cans/{id}", a.handleGetCan)
|
||||
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
|
||||
})
|
||||
|
||||
// can page
|
||||
r.Get("/can/{id}", a.handleCanPage)
|
||||
|
||||
// raw
|
||||
r.Get("/raw/{id}", a.handleRaw)
|
||||
|
||||
@@ -445,6 +451,29 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(row.Content))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
fmt.Fprintf(w, "</ul></body></html>")
|
||||
}
|
||||
|
||||
func nullStrOr(ns sql.NullString, def string) string {
|
||||
if ns.Valid {
|
||||
return ns.String
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (a *apiServer) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("palette pastebin api\nPOST /api/pastes {\"content\": \"...\", \"language\": \"go\", \"expires_in\": \"168h\", \"password\": \"...\", \"visibility\": \"public\"}\nGET /api/pastes/{id}\nGET /api/public?limit=25&offset=0\nGET /raw/{id}\n"))
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user