diff --git a/cans.go b/cans.go new file mode 100644 index 0000000..47ae8bb --- /dev/null +++ b/cans.go @@ -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)) +} diff --git a/cans_test.go b/cans_test.go new file mode 100644 index 0000000..e704aad --- /dev/null +++ b/cans_test.go @@ -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()) + } +} diff --git a/main.go b/main.go index 8b18889..0ed3600 100644 --- a/main.go +++ b/main.go @@ -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, "can/%s — palette

can/%s

") +} + +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")) diff --git a/palette b/palette index c599cef..d14c0fc 100755 Binary files a/palette and b/palette differ diff --git a/palette.db-shm b/palette.db-shm index b9d03e4..03c2ae5 100644 Binary files a/palette.db-shm and b/palette.db-shm differ diff --git a/palette.db-wal b/palette.db-wal index 710dffe..8486dbc 100644 Binary files a/palette.db-wal and b/palette.db-wal differ