235 lines
6.2 KiB
Go
235 lines
6.2 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"palette/internal/store"
|
|
"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
|
|
}
|
|
// #60: 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)
|
|
err := a.store.InsertCan(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.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,
|
|
})
|
|
}
|
|
|
|
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
|
|
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) {
|
|
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))
|
|
}
|