// 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 //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 } 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) } } // 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_ 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"}`)) } 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" } summary := fmt.Sprintf("%s · %s · %d views · %s", lang, humanSize(len(row.Content)), row.ViewCount, agoString(row.CreatedAt)) data := map[string]any{ "Page": "paste", "ID": row.ID, "Title": row.Title.String, "Language": row.Language.String, "StatsSummary": summary, "SizeHuman": humanSize(len(row.Content)), "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": lines, "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, "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 { http.NotFound(w, r) return } if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() { http.Error(w, "paste expired", 404) 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_=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" token := r.URL.Query().Get("token") if justCreated && token != "" { // one-time display of the deletion token via the created banner 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; do not render content http.NotFound(w, r) 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. script-src allows // 'unsafe-inline' because the page templates carry inline scripts; CSP // 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' 'unsafe-inline'; frame-ancestors 'none'") h.Set("Referrer-Policy", "no-referrer") h.Set("X-Content-Type-Options", "nosniff") next.ServeHTTP(w, r) }) }