Refactor: split monolith into cmd/palette + internal/{store,api,web,lang} (#35)
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"palette/internal/store"
|
||||
"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 := 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))
|
||||
}
|
||||
Reference in New Issue
Block a user