The merged #224 fix covered the details grid but left the summary pill using humanSize(len(row.Content)), which reads 0 for attachment pastes (text content is empty). Use the attachment size there too.
466 lines
17 KiB
Go
466 lines
17 KiB
Go
// Package web serves palette's HTML routes: paste pages, cans, unlock, and
|
|
// the admin page. Templates and static assets are embedded in this package.
|
|
package web
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
cryptorand "crypto/rand"
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
langpkg "palette/internal/lang"
|
|
"palette/internal/store"
|
|
)
|
|
|
|
//go:embed templates/*.html
|
|
var tmplFS embed.FS
|
|
|
|
// DefaultDark is the server-configured default dark mode state for visitors
|
|
// without stored preferences (#127). Set via PALETTE_DEFAULT_DARK.
|
|
var DefaultDark = true
|
|
|
|
// SetDefaultDark applies the PALETTE_DEFAULT_DARK env var: unset = dark on,
|
|
// "false"/"0"/"off" = dark off.
|
|
func SetDefaultDark(v string) {
|
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
|
case "false", "0", "off":
|
|
DefaultDark = false
|
|
default:
|
|
DefaultDark = true
|
|
}
|
|
}
|
|
|
|
// ResolvePreset maps a theme pair id (or explicit variant id) plus dark state
|
|
// to a concrete preset id. Returns the defaults when pair is empty or unknown.
|
|
// Precedence handled by callers: URL ?theme= > stored pair+dark > defaults.
|
|
func ResolvePreset(pair string, dark bool) string {
|
|
variants := map[string][2]string{ // pair -> {light, dark}
|
|
"midnight": {"midnight-light", "midnight"},
|
|
"smooth": {"smooth", "smooth-dark"},
|
|
"pastel-lavender": {"pastel-lavender", "pastel-lavender-dark"},
|
|
"pastel-peach": {"pastel-peach", "pastel-peach-dark"},
|
|
"pastel-cloud": {"pastel-cloud", "pastel-cloud-dark"},
|
|
}
|
|
if v, ok := variants[pair]; ok {
|
|
if dark {
|
|
return v[1]
|
|
}
|
|
return v[0]
|
|
}
|
|
if dark {
|
|
return "midnight"
|
|
}
|
|
return "midnight-light"
|
|
}
|
|
|
|
//go:embed static
|
|
var staticFS embed.FS
|
|
|
|
type UI struct {
|
|
tmpl *template.Template
|
|
}
|
|
|
|
func New() (*UI, error) {
|
|
funcs := template.FuncMap{
|
|
"humanSize": humanSize,
|
|
"version": func() string { return Version }, // #93: topbar version label
|
|
"defaultDark": func() bool { return DefaultDark }, // #127: server-configured default
|
|
}
|
|
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &UI{tmpl: t}, nil
|
|
}
|
|
|
|
func humanSize(n int) string {
|
|
if n < 1024 {
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
if n < 1024*1024 {
|
|
return fmt.Sprintf("%.1f KB", float64(n)/1024)
|
|
}
|
|
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
|
|
}
|
|
|
|
func (u *UI) StaticHandler() http.Handler {
|
|
sub, _ := fs.Sub(staticFS, "static")
|
|
return http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
|
|
}
|
|
|
|
func (h *Handlers) renderPage(w http.ResponseWriter, name string, data any) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := h.UI.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
|
http.Error(w, "template error: "+err.Error(), 500)
|
|
}
|
|
}
|
|
|
|
// RenderNotFoundPage is the exported not-found renderer used by the api
|
|
// package (#173): the router's NotFound handler renders the main UI with a
|
|
// friendly "Paste ID not found" message, still with HTTP 404.
|
|
func (h *Handlers) RenderNotFoundPage(w http.ResponseWriter, r *http.Request) {
|
|
h.renderNotFound(w, r, r.URL.Path)
|
|
}
|
|
|
|
// RenderPage is the exported wrapper used by the api package (#4 can pages).
|
|
func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) {
|
|
h.renderPage(w, name, data)
|
|
}
|
|
|
|
// WriteRateLimited is the exported rate-limit response used by the api package (#4).
|
|
func (h *Handlers) WriteRateLimited(w http.ResponseWriter, retryAfterSecs int) {
|
|
h.writeRateLimited(w, retryAfterSecs)
|
|
}
|
|
|
|
// UnlockToken returns the per-id HMAC unlock token (cookie value, #34).
|
|
// Exported for the api package so can pages share paste cookie semantics (#4).
|
|
func UnlockToken(id string) string { return unlockToken(id) }
|
|
|
|
// AgoString formats a relative "N ago" string (exported for the api package, #4).
|
|
func AgoString(ts int64) string { return agoString(ts) }
|
|
|
|
// HumanSize formats a byte count as a human string (exported for the api package, #4).
|
|
func HumanSize(n int) string { return humanSize(n) }
|
|
|
|
// ExpiryString formats remaining time until an epoch seconds expiry (#4).
|
|
func ExpiryString(expiresAt int64) string { return expiryString(expiresAt) }
|
|
|
|
// #34: per-paste unlock tokens. unlockSecret is generated once at startup
|
|
// (also derivable from PALETTE_UNLOCK_SECRET for multi-instance deploys) and
|
|
// used to HMAC paste ids, so a client can only hold a valid pw_<id> cookie by
|
|
// actually submitting the correct password for that paste.
|
|
var unlockSecret = resolveUnlockSecret()
|
|
|
|
func resolveUnlockSecret() []byte {
|
|
if v := os.Getenv("PALETTE_UNLOCK_SECRET"); v != "" {
|
|
return []byte(v)
|
|
}
|
|
b := make([]byte, 32)
|
|
if _, err := cryptorand.Read(b); err != nil {
|
|
log.Fatal("cannot generate unlock secret: ", err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func unlockToken(id string) string {
|
|
mac := hmac.New(sha256.New, unlockSecret)
|
|
mac.Write([]byte("unlock:" + id))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func agoString(ts int64) string {
|
|
s := time.Now().Unix() - ts
|
|
switch {
|
|
case s < 60:
|
|
return fmt.Sprintf("%ds ago", s)
|
|
case s < 3600:
|
|
return fmt.Sprintf("%dm ago", s/60)
|
|
case s < 86400:
|
|
return fmt.Sprintf("%dh ago", s/3600)
|
|
default:
|
|
return fmt.Sprintf("%dd ago", s/86400)
|
|
}
|
|
}
|
|
|
|
func expiryString(expiresAt int64) string {
|
|
s := expiresAt - time.Now().Unix()
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Handlers is the set of store callbacks the web pages need. The web package
|
|
// renders HTML; all queries go through the store.
|
|
type Handlers struct {
|
|
UI *UI
|
|
Store *store.Store
|
|
ViewerID func(r *http.Request) string
|
|
BurnWindowMin func() int
|
|
RateLimitOK func(id string, r *http.Request) bool // per-paste unlock limiter
|
|
}
|
|
|
|
func (h *Handlers) rateLimitUnlock(id string, r *http.Request) bool {
|
|
if h.RateLimitOK != nil {
|
|
return h.RateLimitOK(id, r)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (h *Handlers) writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
|
|
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfterSecs))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(429)
|
|
w.Write([]byte(`{"error":"rate limit exceeded"}`))
|
|
}
|
|
|
|
// renderNotFound serves the friendly not-found page (#173): the main UI
|
|
// chrome (topbar, centered card) with a "Paste ID not found" message in the
|
|
// result card, instead of a bare text 404. Still returns HTTP 404 so
|
|
// crawlers/validators see the correct status.
|
|
func (h *Handlers) renderNotFound(w http.ResponseWriter, r *http.Request, id string) {
|
|
h.renderPageStatus(w, "notfound.html", http.StatusNotFound, map[string]any{"Page": "notfound", "ID": id})
|
|
}
|
|
|
|
// HandleNotFoundPage serves the friendly not-found page for unknown routes
|
|
// (#173): main UI chrome with a "Paste ID not found" message. Called from the
|
|
// chi NotFound handler in the api package.
|
|
func (h *Handlers) HandleNotFoundPage(w http.ResponseWriter, r *http.Request) {
|
|
h.renderNotFound(w, r, r.URL.Path)
|
|
}
|
|
|
|
// renderPageStatus renders a template with an explicit HTTP status code.
|
|
func (h *Handlers) renderPageStatus(w http.ResponseWriter, name string, status int, data any) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
if err := h.UI.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
|
http.Error(w, "template error: "+err.Error(), 500)
|
|
}
|
|
}
|
|
|
|
func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justCreated bool, deletionToken string, readsRemaining *int) {
|
|
lines := strings.Count(row.Content, "\n") + 1
|
|
gutter := ""
|
|
for i := 1; i <= lines; i++ {
|
|
gutter += fmt.Sprintf("%d\n", i)
|
|
}
|
|
expIn := ""
|
|
if row.ExpiresAt.Valid {
|
|
expIn = expiryString(row.ExpiresAt.Int64)
|
|
}
|
|
lang := row.Language.String
|
|
if lang == "" {
|
|
lang = "text"
|
|
}
|
|
// #38: one optional file attachment per paste; nil when none.
|
|
attachment, err := h.Store.GetAttachmentForPaste(row.ID)
|
|
if err != nil {
|
|
http.Error(w, "db error", 500)
|
|
return
|
|
}
|
|
// #221: for attachment pastes the stored text content is empty (the file
|
|
// replaced it), so the summary size must come from the attachment blob,
|
|
// not len(row.Content), or the summary shows "0 B".
|
|
summarySize := len(row.Content)
|
|
if attachment != nil {
|
|
summarySize = int(attachment.Size)
|
|
}
|
|
summary := fmt.Sprintf("%s · %s · %d views · %s", lang, humanSize(summarySize), row.ViewCount, agoString(row.CreatedAt))
|
|
// #221: image attachments render the image, not a text/code box. Size
|
|
// comes from the attachment's actual file size, not the text content.
|
|
attImage := false
|
|
if attachment != nil {
|
|
switch attachment.Mime {
|
|
case "image/png", "image/jpeg", "image/gif", "image/webp":
|
|
attImage = true
|
|
}
|
|
}
|
|
sizeHuman := humanSize(len(row.Content))
|
|
lineCount := lines
|
|
if attachment != nil {
|
|
sizeHuman = attachment.SizeHuman
|
|
lineCount = 1
|
|
}
|
|
data := map[string]any{
|
|
"Page": "paste",
|
|
"ID": row.ID,
|
|
"Title": row.Title.String,
|
|
"Language": row.Language.String,
|
|
"StatsSummary": summary,
|
|
"SizeHuman": sizeHuman,
|
|
"HasPassword": row.PasswordHash.Valid,
|
|
"BurnAfterRead": row.BurnAfterRead,
|
|
"CustomSlug": row.CustomSlug.String,
|
|
"ContentHTML": template.HTML(langpkg.HighlightCode(row.Content, row.Language.String)), // safe: HighlightCode escapes all non-span text
|
|
"ContentAttr": row.Content,
|
|
"Gutter": strings.TrimSuffix(gutter, "\n"),
|
|
"LineCount": lineCount,
|
|
"SizeBytes": len(row.Content),
|
|
"CreatedAgo": agoString(row.CreatedAt),
|
|
"CreatedAtUnix": row.CreatedAt,
|
|
"ViewCount": row.ViewCount,
|
|
"Visibility": row.Visibility,
|
|
"ExpiresAt": row.ExpiresAt.Valid,
|
|
"ExpiresIn": expIn,
|
|
"DeletionToken": deletionToken,
|
|
"ReadsLimit": row.ReadsLimit.Valid,
|
|
"ReadsLeftN": readsRemaining, // *int: reads remaining after this view
|
|
"ReadsTotal": int(row.ReadsLimit.Int64),
|
|
"JustCreated": justCreated,
|
|
"Attachment": attachment,
|
|
"AttachmentImage": attImage,
|
|
"Host": "this host",
|
|
}
|
|
h.renderPage(w, "paste.html", data)
|
|
}
|
|
|
|
// HandlePasteView renders the paste view; supports both ID and custom slug.
|
|
func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
row, err := h.Store.GetPaste(id)
|
|
if err != nil {
|
|
http.Error(w, "db error", 500)
|
|
return
|
|
}
|
|
if row == nil {
|
|
// #173: a missing paste ID gets the main UI with a friendly message,
|
|
// not a bare text 404 page.
|
|
h.renderNotFound(w, r, id)
|
|
return
|
|
}
|
|
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
|
|
// #173: expired pastes render the same friendly not-found UI.
|
|
h.renderNotFound(w, r, id)
|
|
return
|
|
}
|
|
if row.PasswordHash.Valid {
|
|
// if a password was submitted via unlock form, verify and set cookie for this paste
|
|
if r.Method == http.MethodPost {
|
|
if !h.rateLimitUnlock(row.ID, r) {
|
|
h.writeRateLimited(w, 60)
|
|
return
|
|
}
|
|
r.ParseForm()
|
|
pw := r.FormValue("password")
|
|
if pw != "" && store.CheckPassword(row.PasswordHash.String, pw) {
|
|
// #34: the unlock cookie must be bound to this specific paste and
|
|
// unforgable. A static value ("1") let anyone bypass the password
|
|
// by setting pw_<id>=1 for any paste id. The token is an HMAC of
|
|
// the paste id under the server's random secret.
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "pw_" + row.ID, Value: unlockToken(row.ID), Path: "/",
|
|
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
|
|
})
|
|
// re-render without lock, or redirect if ?next= was given (#26)
|
|
if next := r.FormValue("next"); next != "" {
|
|
// only allow same-origin relative paths
|
|
if len(next) > 0 && next[0] == '/' && !strings.HasPrefix(next, "//") {
|
|
http.Redirect(w, r, next, http.StatusSeeOther)
|
|
return
|
|
}
|
|
}
|
|
h.renderPaste(w, row, false, "", nil)
|
|
return
|
|
}
|
|
h.renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
|
|
return
|
|
}
|
|
// check cookie — must carry the valid per-paste unlock token (#34)
|
|
c, err := r.Cookie("pw_" + row.ID)
|
|
if err != nil || c.Value != unlockToken(row.ID) {
|
|
h.renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
|
|
return
|
|
}
|
|
}
|
|
|
|
justCreated := r.URL.Query().Get("created") == "1"
|
|
// #143: the deletion token is no longer round-tripped through the URL
|
|
// (?token= leaks into access logs and history). The create flow sets a
|
|
// short-lived tok_<id> cookie; the paste view reads it once from there.
|
|
token := ""
|
|
if c, err := r.Cookie("tok_" + row.ID); err == nil {
|
|
token = c.Value
|
|
}
|
|
if justCreated && token != "" {
|
|
// one-time display of the deletion token via sessionStorage (#143)
|
|
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
|
}
|
|
// Count the view for real page renders, deduped per-viewer within the
|
|
// burn window (#95): a reload by the same vwr cookie doesn't inflate
|
|
// view_count. Raw views increment unconditionally in handleRaw (#49); the
|
|
// HTML path was missing its increment originally (#33). The just-created
|
|
// banner render does not count as a view.
|
|
if !justCreated {
|
|
h.Store.IncrementViews(row.ID, h.ViewerID(r), h.BurnWindowMin())
|
|
}
|
|
// #49: burn-after-N-reads budget (per-viewer dedupe window).
|
|
// Just-created first render does not count as a read for the creator.
|
|
if !justCreated {
|
|
rem, admitted := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
|
|
if !admitted { // #58: lost the burn claim; #173: friendly not-found UI
|
|
h.renderNotFound(w, r, row.ID)
|
|
return
|
|
}
|
|
h.renderPaste(w, row, false, "", rem)
|
|
return
|
|
}
|
|
// only pass the token to the template right after creation
|
|
h.renderPaste(w, row, true, token, nil)
|
|
}
|
|
|
|
// HandleNewPage serves /new.
|
|
func (h *Handlers) HandleNewPage(w http.ResponseWriter, r *http.Request) {
|
|
h.renderPage(w, "new.html", map[string]any{"Page": "new"})
|
|
}
|
|
|
|
// HandleHistoryPage serves /history.
|
|
func (h *Handlers) HandleHistoryPage(w http.ResponseWriter, r *http.Request) {
|
|
h.renderPage(w, "history.html", map[string]any{"Page": "history"})
|
|
}
|
|
|
|
// HandleSettingsPage serves /settings.
|
|
func (h *Handlers) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
|
// #100: theme presets. Midnight is the default (no data-preset attribute),
|
|
// so its swatches are hardcoded here; the CSS defines the token values.
|
|
themes := []map[string]any{
|
|
{"Name": "midnight", "Label": "Midnight", "Swatches": []string{"#241B30", "#2D2340", "#3A2D52", "#C4A8F0", "#F2EDF8"}},
|
|
{"Name": "smooth", "Label": "Smooth", "Swatches": []string{"#F6F5FA", "#FFFFFF", "#DAD7E6", "#7A7796", "#2A2A36"}},
|
|
{"Name": "pastel-lavender", "Label": "Pastel Lavender", "Swatches": []string{"#e6e0f5", "#f1edfa", "#cbb8e7", "#806bb8", "#3E3059"}},
|
|
{"Name": "pastel-peach", "Label": "Pastel Peach", "Swatches": []string{"#ffe0d6", "#fff0ea", "#ffc4a8", "#f9826c", "#4F2318"}},
|
|
{"Name": "pastel-cloud", "Label": "Pastel Cloud", "Swatches": []string{"#fff0f6", "#fff7fb", "#ffc8dd", "#a2d2ff", "#4A3355"}},
|
|
}
|
|
h.renderPage(w, "settings.html", map[string]any{"Page": "settings", "Themes": themes})
|
|
}
|
|
|
|
// HandleMinePage serves /mine.
|
|
func (h *Handlers) HandleMinePage(w http.ResponseWriter, r *http.Request) {
|
|
h.renderPage(w, "mine.html", map[string]any{"Page": "mine"})
|
|
}
|
|
|
|
// HandleAdminPage serves /admin.
|
|
func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
|
|
h.renderPage(w, "admin.html", map[string]any{"Page": "admin"})
|
|
}
|
|
|
|
// Handlers builds a web.Handlers bound to this UI.
|
|
func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }
|
|
|
|
// #59: security headers for rendered HTML pages. Applied wherever the
|
|
// response is text/html (page templates and the inline can page); JSON API
|
|
// responses and /raw content pass through untouched.
|
|
// #139: script-src and style-src no longer allow 'unsafe-inline'. All
|
|
// previously-inline scripts moved to external files under static/ (page data
|
|
// reaches them via data-* attributes on the script tags), inline style
|
|
// attributes became CSS classes, and JS sets swatch colors via CSSOM. The
|
|
// img-src data: allowance stays: SVG data-URI backgrounds in app.css need it.
|
|
// default-src 'self' still blocks external content and object/frame embeds,
|
|
// and frame-ancestors 'none' closes the clickjacking gap flagged in the #34
|
|
// pentest. Runs after the handler so the Content-Type is already set.
|
|
func SecurityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Set before the handler runs: once a handler writes (template render
|
|
// flushes), header mutations are silently dropped. Setting the headers
|
|
// unconditionally is safe: CSP/nosniff/referrer on JSON or /raw bodies
|
|
// is harmless and arguably desirable.
|
|
h := w.Header()
|
|
h.Set("Content-Security-Policy",
|
|
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'")
|
|
h.Set("Referrer-Policy", "no-referrer")
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|