- image pastes scale to fit the viewer box (max-width/max-height, object-fit) - no text/code box rendered below the image for image pastes - link pill positioning cleaned up on the image view - /raw for attachment pastes redirects to the file itself - view details size now uses the attachment blob size, not empty text len
664 lines
21 KiB
Go
664 lines
21 KiB
Go
// Package api implements palette's REST handlers and the HTTP router:
|
|
// pastes, cans, guess-language, rate limiting middleware, and the admin API.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
langpkg "palette/internal/lang"
|
|
"palette/internal/store"
|
|
"palette/internal/web"
|
|
)
|
|
|
|
type Config struct {
|
|
Addr string
|
|
DBPath string
|
|
MaxTextBytes int64
|
|
MaxItemBytes int64
|
|
}
|
|
|
|
type apiServer struct {
|
|
store *store.Store
|
|
cfg Config
|
|
ui *web.UI
|
|
settings *settingsStore
|
|
adminKey string
|
|
}
|
|
|
|
func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer {
|
|
return &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: adminKey}
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeErr(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
// writeErrCode emits a JSON error with a machine-readable code (#105): the
|
|
// new-page JS maps known codes to plain-language messages, API consumers get
|
|
// the stable `code` field alongside the human `error` text.
|
|
func writeErrCode(w http.ResponseWriter, status int, code, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg, "code": code})
|
|
}
|
|
|
|
// createErrCode maps a store.CreatePaste/CreateCan error to its UI error
|
|
// code (#105).
|
|
func createErrCode(err error) string {
|
|
switch {
|
|
case errors.Is(err, store.ErrSlugTaken):
|
|
return "slug_taken"
|
|
case errors.Is(err, store.ErrReservedSlug):
|
|
return "slug_reserved"
|
|
case errors.Is(err, store.ErrInvalidSlug):
|
|
return "slug_invalid"
|
|
case strings.Contains(err.Error(), "expires_in"):
|
|
return "expiry_invalid"
|
|
default:
|
|
return "invalid_request"
|
|
}
|
|
}
|
|
|
|
// Routes returns the HTTP handler for the server.
|
|
func (a *apiServer) Routes() http.Handler {
|
|
return a.routes()
|
|
}
|
|
|
|
func (a *apiServer) routes() http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(middleware.Timeout(30 * time.Second))
|
|
r.Use(a.limitRequestBody) // #68: hard server-side body cap -> 413
|
|
r.Use(viewerCookieMiddleware)
|
|
r.Use(web.SecurityHeaders) // #59: CSP + hardening headers on HTML pages
|
|
|
|
// admin (#40): HTML page is open (key entry via form); API is key-guarded
|
|
r.Get("/admin", a.ui.Handlers().HandleAdminPage)
|
|
r.Get("/admin/api/settings", a.adminAuth(a.handleAdminGetSettings, a.adminKey))
|
|
r.Post("/admin/api/settings", a.adminAuth(a.handleAdminPostSettings, a.adminKey))
|
|
|
|
// API
|
|
r.Route("/api", func(r chi.Router) {
|
|
r.Post("/pastes", a.handleCreatePaste)
|
|
r.Get("/pastes/{id}", a.handleGetPaste)
|
|
r.Delete("/pastes/{id}", a.handleDeletePaste)
|
|
r.Get("/mine", a.handleListMine)
|
|
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
|
|
r.Get("/public", a.handleListPublic)
|
|
r.Post("/guess-language", a.handleGuessLang)
|
|
r.Post("/pastes/can", a.handleCreateCan)
|
|
r.Get("/cans/{id}", a.handleGetCan)
|
|
r.Delete("/cans/{id}", a.handleDeleteCan)
|
|
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
|
|
})
|
|
|
|
// can page (#4): GET renders, POST unlocks (same flow as pastes)
|
|
r.Get("/can/{id}", a.handleCanPage)
|
|
r.Post("/can/{id}", a.handleCanPage)
|
|
|
|
// raw + #38 attachment serving
|
|
r.Get("/f/{aid}/{filename}", a.handleServeAttachment)
|
|
r.Get("/raw/{id}", a.handleRaw)
|
|
|
|
// web pages
|
|
r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP)
|
|
r.Get("/new", a.ui.Handlers().HandleNewPage)
|
|
r.Get("/history", a.ui.Handlers().HandleHistoryPage)
|
|
r.Get("/settings", a.ui.Handlers().HandleSettingsPage)
|
|
r.Get("/mine", a.ui.Handlers().HandleMinePage)
|
|
r.Handle("/static/*", a.ui.StaticHandler())
|
|
r.Get("/unlock/{id}", a.handlePasteView)
|
|
r.Post("/unlock/{id}", a.handlePasteView)
|
|
r.Get("/{id}", a.handlePasteView)
|
|
r.Post("/{id}", a.handlePasteView)
|
|
|
|
// #173: missing paste URLs render the main UI with a friendly not-found
|
|
// message instead of a bare JSON 404. Known routes (above) handle real pages;
|
|
// anything else is a nonexistent paste ID or typo.
|
|
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
|
a.webHandlers().HandleNotFoundPage(w, r)
|
|
})
|
|
return r
|
|
}
|
|
|
|
// viewerCookieMiddleware ensures every request carries an anonymous browser id
|
|
// cookie ("vwr"); sets one on the response if absent. Used by /mine (#37, #49).
|
|
func viewerCookieMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie("vwr"); err != nil || c.Value == "" {
|
|
id := store.GenSlug(16)
|
|
// #138: Secure keeps the viewer id off plain-HTTP requests
|
|
// (all deployments are HTTPS-only behind traefik).
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "vwr", Value: id, Path: "/",
|
|
MaxAge: 31536000, HttpOnly: true, Secure: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
r.AddCookie(&http.Cookie{Name: "vwr", Value: id})
|
|
// remember that this cookie was minted here, not sent by the client
|
|
r = r.WithContext(context.WithValue(r.Context(), vwrMintedKey, true))
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
type vwrMintedKeyType struct{}
|
|
|
|
var vwrMintedKey vwrMintedKeyType
|
|
|
|
func currentViewerID(r *http.Request) string {
|
|
if c, err := r.Cookie("vwr"); err == nil {
|
|
return c.Value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// viewerSentCookie reports whether the client itself sent a vwr cookie
|
|
// (as opposed to the middleware minting one for this request).
|
|
func viewerSentCookie(r *http.Request) bool {
|
|
if _, err := r.Cookie("vwr"); err != nil {
|
|
return false
|
|
}
|
|
_, minted := r.Context().Value(vwrMintedKey).(bool)
|
|
return !minted
|
|
}
|
|
|
|
// #143: hand the deletion token to the creator's browser via a short-lived
|
|
// HttpOnly cookie instead of the URL. The paste view reads it once to show
|
|
// the one-time created banner; it expires after 60s.
|
|
func setDeletionTokenCookie(w http.ResponseWriter, pasteID, token string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "tok_" + pasteID, Value: token, Path: "/",
|
|
MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
|
|
s := a.settings.get()
|
|
setRateLimitHeaders(w, 1, 5)
|
|
if !rateLimitCreate(r, s) {
|
|
writeRateLimited(w, 1)
|
|
return
|
|
}
|
|
ct := r.Header.Get("Content-Type")
|
|
if strings.HasPrefix(ct, "multipart/form-data") {
|
|
a.handleCreatePasteMultipart(w, r, s) // #38: file attachment create
|
|
return
|
|
}
|
|
var p store.Paste
|
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
|
if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader
|
|
writeErrCode(w, http.StatusRequestEntityTooLarge, "content_too_large", "request body too large")
|
|
return
|
|
}
|
|
writeErr(w, 400, "invalid json body")
|
|
return
|
|
}
|
|
if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 {
|
|
if status == http.StatusRequestEntityTooLarge {
|
|
writeErrCode(w, status, "content_too_large", msg)
|
|
} else {
|
|
writeErrCode(w, status, "content_empty", msg)
|
|
}
|
|
return
|
|
}
|
|
// #86: bound free-form metadata at create time
|
|
if p.Title != nil {
|
|
t, err := checkTitle(*p.Title)
|
|
if err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
p.Title = &t
|
|
}
|
|
if p.Language != nil {
|
|
l, err := checkLanguage(*p.Language)
|
|
if err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if l == "" {
|
|
p.Language = nil
|
|
} else {
|
|
p.Language = &l
|
|
}
|
|
}
|
|
if p.BurnAfterReads != nil { // #68: reject negative read budgets
|
|
if err := parseBurnAfterReads(*p.BurnAfterReads); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
}
|
|
// #40: admin-configurable default expiry
|
|
if (p.ExpiresIn == nil || *p.ExpiresIn == "") && s.DefaultExpiry != "" {
|
|
def := s.DefaultExpiry
|
|
p.ExpiresIn = &def
|
|
}
|
|
p.ViewerID = currentViewerID(r)
|
|
created, err := a.store.CreatePaste(&p)
|
|
if err != nil {
|
|
writeErrCode(w, 400, createErrCode(err), err.Error())
|
|
return
|
|
}
|
|
setDeletionTokenCookie(w, created.ID, created.DeletionToken) // #143
|
|
writeJSON(w, 201, map[string]any{
|
|
"id": created.ID,
|
|
"deletion_token": created.DeletionToken,
|
|
"url": "/" + created.ID,
|
|
"raw_url": "/raw/" + created.ID,
|
|
"api_url": "/api/pastes/" + created.ID,
|
|
"expires_at": created.ExpiresAt,
|
|
"created_at": created.CreatedAt,
|
|
"rate_limit": map[string]int{"create_per_sec": 1, "burst": 5},
|
|
})
|
|
}
|
|
|
|
func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
row, err := a.store.GetPaste(id)
|
|
if err != nil {
|
|
writeErr(w, 500, "db error")
|
|
return
|
|
}
|
|
if row == nil {
|
|
writeErr(w, 404, "paste not found")
|
|
return
|
|
}
|
|
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
|
writeErr(w, 404, "paste expired")
|
|
return
|
|
}
|
|
if row.Burned() { // #49: read budget exhausted
|
|
writeErr(w, 404, "paste not found")
|
|
return
|
|
}
|
|
if row.PasswordHash.Valid {
|
|
// #81/#141: every password verification (header or empty) goes
|
|
// through the same per-IP+paste unlock limiter as the POST form
|
|
// path, so brute-force via X-Paste-Password gets 429. The
|
|
// ?password= query fallback was removed (#141): query strings
|
|
// leak into access logs, browser history, and Referer headers.
|
|
if !rateLimitUnlock(row.ID, r) {
|
|
writeRateLimited(w, 60)
|
|
return
|
|
}
|
|
// require password via header or query
|
|
pw := r.Header.Get("X-Paste-Password")
|
|
if pw == "" || !store.CheckPassword(row.PasswordHash.String, pw) {
|
|
writeErr(w, 401, "password required")
|
|
return
|
|
}
|
|
}
|
|
// #58: only the reader that wins the atomic burn claim may see content.
|
|
rem, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
|
|
if !admitted {
|
|
writeErr(w, 404, "paste not found")
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{
|
|
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
|
|
"language": store.NullStrPtr(row.Language), "title": store.NullStrPtr(row.Title), "created_at": row.CreatedAt,
|
|
"view_count": row.ViewCount, "visibility": row.Visibility,
|
|
"reads_remaining": rem,
|
|
})
|
|
}
|
|
|
|
func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
row, err := a.store.GetPaste(id)
|
|
if err != nil || row == nil {
|
|
writeErr(w, 404, "paste not found")
|
|
return
|
|
}
|
|
// #63: deletion requires authorization. Either the deletion token issued
|
|
// at create time (Authorization header; #143 removed the ?token= query
|
|
// path so the bearer secret never lands in access logs or history), or
|
|
// the creator browser itself (client-sent vwr cookie matching the
|
|
// paste's viewer, #37).
|
|
if !a.deletionAuthorized(r, row) {
|
|
writeErr(w, 403, "deletion token required")
|
|
return
|
|
}
|
|
if _, err := a.store.SoftDelete(row.ID); err != nil {
|
|
writeErr(w, 500, "db error")
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]string{"status": "soft-deleted"})
|
|
}
|
|
|
|
// deletionAuthorization extracts the deletion token from the request:
|
|
// the Authorization header ("Bearer <t>", "Token <t>", or a bare token).
|
|
// The ?token= query parameter is deliberately NOT accepted (#143): URL
|
|
// query strings end up in proxy access logs and browser history. Returns
|
|
// "" when absent.
|
|
func deletionAuthorization(r *http.Request) string {
|
|
if h := r.Header.Get("Authorization"); h != "" {
|
|
for _, prefix := range []string{"Bearer ", "Token "} {
|
|
if len(h) > len(prefix) && strings.EqualFold(h[:len(prefix)], prefix) {
|
|
return strings.TrimSpace(h[len(prefix):])
|
|
}
|
|
}
|
|
return strings.TrimSpace(h)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// deletionAuthorized reports whether the request may soft-delete the paste:
|
|
// a valid constant-time-matched deletion token, or the creator browser's
|
|
// viewer cookie (#37). Plain API clients with no token get false.
|
|
func (a *apiServer) deletionAuthorized(r *http.Request, row *store.PasteRow) bool {
|
|
if tok := deletionAuthorization(r); tok != "" {
|
|
return row.DeletionToken.Valid && row.DeletionToken.String != "" &&
|
|
store.DeletionTokenEqual(row.DeletionToken.String, tok)
|
|
}
|
|
// viewer-cookie delete enforcement (#37): only the browser that created
|
|
// the paste (matching vwr) may delete it via this endpoint. Requests with
|
|
// no client-sent vwr cookie (plain API clients) are unaffected.
|
|
vid := currentViewerID(r)
|
|
return vid != "" && viewerSentCookie(r) && row.ViewerID.Valid &&
|
|
row.ViewerID.String != "" && row.ViewerID.String == vid
|
|
}
|
|
|
|
// handleListMine serves /api/mine: pastes created from this browser (#37).
|
|
func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
|
|
vid := currentViewerID(r)
|
|
if vid == "" {
|
|
writeJSON(w, 200, map[string]any{"total": 0, "items": []any{}})
|
|
return
|
|
}
|
|
limit := parseLimit(r, 50, 100)
|
|
offset := parseOffset(r)
|
|
rows, total, err := a.store.ListMine(vid, limit, offset)
|
|
if err != nil {
|
|
writeErr(w, 500, "db error")
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title)
|
|
items = append(items, map[string]any{
|
|
"id": row.ID, "title": title, "language": lang,
|
|
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
|
"custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility,
|
|
"is_can": row.IsCan,
|
|
})
|
|
}
|
|
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
|
}
|
|
|
|
func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
|
|
limit := parseLimit(r, 25, 100)
|
|
offset := parseOffset(r)
|
|
rows, total, err := a.store.ListPublic(limit, offset)
|
|
if err != nil {
|
|
writeErr(w, 500, "db error")
|
|
return
|
|
}
|
|
items := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title)
|
|
items = append(items, map[string]any{
|
|
"id": row.ID, "title": title, "language": lang,
|
|
"created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
|
|
"custom_slug": store.NullStrPtr(row.CustomSlug),
|
|
"is_can": row.IsCan,
|
|
})
|
|
}
|
|
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
|
|
}
|
|
|
|
func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
row, err := a.store.GetPaste(id)
|
|
if err != nil || row == nil {
|
|
http.Error(w, "not found", 404)
|
|
return
|
|
}
|
|
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
|
http.Error(w, "paste expired", 404)
|
|
return
|
|
}
|
|
if row.PasswordHash.Valid {
|
|
http.Error(w, "password required", 401)
|
|
return
|
|
}
|
|
if row.Burned() { // #49: read budget exhausted
|
|
http.Error(w, "not found", 404)
|
|
return
|
|
}
|
|
// #49 decision: raw reads count against the read budget too, with the
|
|
// same per-viewer dedupe window as page views. #58: a reader that loses
|
|
// the burn claim must not receive the content.
|
|
_, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
|
|
if !admitted {
|
|
http.Error(w, "not found", 404)
|
|
return
|
|
}
|
|
// #221: an attachment paste has no text content; raw view must serve the
|
|
// file itself, not empty text. Redirect to the /f/ serving route, which
|
|
// applies the same sniffed-mime + disposition safety rules.
|
|
if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil {
|
|
a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95)
|
|
http.Redirect(w, r, "/f/"+att.ID+"/"+att.Filename, http.StatusFound)
|
|
return
|
|
}
|
|
// #34: content_type is attacker-controlled via the create API. Serving it
|
|
// verbatim let a paste be stored with text/html (or image/svg+xml) and
|
|
// render as active content on this origin when fetched from /raw —
|
|
// stored XSS. Only pass through a fixed safe set; anything else is
|
|
// served as plain text with nosniff.
|
|
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")
|
|
a.store.IncrementViews(row.ID, "", 0) // raw views always count (#49/#95)
|
|
w.Write([]byte(row.Content))
|
|
}
|
|
|
|
// safeRawContentType reports whether ct is in the fixed set of types that are
|
|
// safe to serve verbatim on /raw (no active-content execution contexts).
|
|
func safeRawContentType(ct string) bool {
|
|
base := ct
|
|
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
|
base = ct[:i]
|
|
}
|
|
base = strings.ToLower(strings.TrimSpace(base))
|
|
switch base {
|
|
case "text/plain", "text/markdown", "text/x-markdown",
|
|
"application/json", "application/pdf",
|
|
"image/png", "image/jpeg", "image/gif", "image/webp",
|
|
"application/octet-stream":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|
|
if can.ExpiresAt.Valid && can.ExpiresAt.Int64 < time.Now().Unix() {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
// #4: password-protected cans go through the same unlock flow as pastes:
|
|
// the pw_<id> cookie carries an HMAC token bound to this can id. Items
|
|
// inherit the protection (handleCanItem checks the same cookie).
|
|
if can.PasswordHash.Valid {
|
|
h := a.webHandlers()
|
|
if r.Method == http.MethodPost {
|
|
if !rateLimitUnlock(can.ID, r) {
|
|
h.WriteRateLimited(w, 60)
|
|
return
|
|
}
|
|
r.ParseForm()
|
|
pw := r.FormValue("password")
|
|
if pw != "" && store.CheckPassword(can.PasswordHash.String, pw) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "pw_" + can.ID, Value: web.UnlockToken(can.ID), Path: "/",
|
|
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
})
|
|
a.renderCan(w, can)
|
|
return
|
|
}
|
|
h.RenderPage(w, "unlock.html", map[string]any{
|
|
"Page": "unlock", "ID": can.ID, "Wrong": true,
|
|
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
|
|
})
|
|
return
|
|
}
|
|
c, err := r.Cookie("pw_" + can.ID)
|
|
if err != nil || c.Value != web.UnlockToken(can.ID) {
|
|
h.RenderPage(w, "unlock.html", map[string]any{
|
|
"Page": "unlock", "ID": can.ID, "Wrong": false,
|
|
"CreatedAgo": web.AgoString(can.CreatedAt), "CreatedAtUnix": can.CreatedAt,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
a.renderCan(w, can)
|
|
}
|
|
|
|
// renderCan renders the can view page: title/description and items as cards.
|
|
// Text items expand inline; files link to download.
|
|
func (a *apiServer) renderCan(w http.ResponseWriter, can *store.CanRow) {
|
|
h := a.webHandlers()
|
|
items, err := a.store.ListCanItems(can.ID)
|
|
if err != nil {
|
|
http.Error(w, "db error", 500)
|
|
return
|
|
}
|
|
type canItem struct {
|
|
ID string
|
|
Title string
|
|
ContentType string
|
|
Size string
|
|
IsFile bool
|
|
Content string
|
|
ContentHTML template.HTML
|
|
Language string
|
|
}
|
|
cards := make([]canItem, 0, len(items))
|
|
totalSize := 0
|
|
for _, it := range items {
|
|
totalSize += len(it.Content)
|
|
isFile := it.ContentType != "text/plain" && !strings.HasPrefix(it.ContentType, "text/")
|
|
ci := canItem{
|
|
ID: it.ID,
|
|
Title: nullStrOr(it.Title, it.ID),
|
|
ContentType: it.ContentType,
|
|
Size: web.HumanSize(len(it.Content)),
|
|
IsFile: isFile,
|
|
Language: it.Language.String,
|
|
}
|
|
if !isFile {
|
|
ci.ContentHTML = template.HTML(langpkg.HighlightCode(it.Content, it.Language.String))
|
|
}
|
|
cards = append(cards, ci)
|
|
}
|
|
h.RenderPage(w, "can.html", map[string]any{
|
|
"Page": "can",
|
|
"ID": can.ID,
|
|
"Title": nullStrOr(can.Title, "Untitled can"),
|
|
"Description": can.Description.String,
|
|
"HasDescription": can.Description.Valid && can.Description.String != "",
|
|
"HasPassword": can.PasswordHash.Valid,
|
|
"Items": cards,
|
|
"ItemCount": len(cards),
|
|
"SizeHuman": web.HumanSize(totalSize),
|
|
"CreatedAgo": web.AgoString(can.CreatedAt),
|
|
"CreatedAtUnix": can.CreatedAt,
|
|
"ExpiresAt": can.ExpiresAt.Valid,
|
|
"ExpiresIn": expiryStringIfValid(can.ExpiresAt),
|
|
})
|
|
}
|
|
|
|
// expiryStringIfValid formats remaining time for a valid expiry, "" otherwise.
|
|
func expiryStringIfValid(ns sql.NullInt64) string {
|
|
if !ns.Valid {
|
|
return ""
|
|
}
|
|
remaining := ns.Int64 - time.Now().Unix()
|
|
s := remaining
|
|
switch {
|
|
case s < 3600:
|
|
return fmt.Sprintf("%dm", s/60)
|
|
case s < 86400:
|
|
return fmt.Sprintf("%dh", s/3600)
|
|
default:
|
|
return fmt.Sprintf("%dd", s/86400)
|
|
}
|
|
}
|
|
|
|
func templateEsc(s string) string {
|
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
|
return r.Replace(s)
|
|
}
|
|
|
|
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
|
h := a.webHandlers()
|
|
// unlock POST rate limiting is wired through h.RateLimitOK
|
|
h.HandlePasteView(w, r)
|
|
}
|
|
|
|
func (a *apiServer) webHandlers() *web.Handlers {
|
|
return &web.Handlers{
|
|
UI: a.ui,
|
|
Store: a.store,
|
|
ViewerID: currentViewerID,
|
|
BurnWindowMin: a.burnViewerWindow,
|
|
RateLimitOK: func(id string, r *http.Request) bool { return rateLimitUnlock(id, r) },
|
|
}
|
|
}
|
|
|
|
func envOr(k, d string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return d
|
|
}
|
|
|
|
func envIntOr(k string, d int) int {
|
|
if v := os.Getenv(k); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
return n
|
|
}
|
|
}
|
|
return d
|
|
}
|
|
|
|
func nullStrOr(ns sql.NullString, def string) string {
|
|
if ns.Valid {
|
|
return ns.String
|
|
}
|
|
return def
|
|
}
|
|
|
|
// EnvOr returns the env var value or default.
|
|
func EnvOr(k, d string) string { return envOr(k, d) }
|
|
|
|
// EnvIntOr returns the env int value or default.
|
|
func EnvIntOr(k string, d int) int { return envIntOr(k, d) }
|