264 lines
7.5 KiB
Go
264 lines
7.5 KiB
Go
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))
|
|
}
|