// 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, } 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) } } // #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 every real page render. Raw views increment in // handleRaw; the HTML path was missing its increment, so view_count only // ever moved via /raw and the API-stored count stayed at 0 (#33). // The just-created banner render does not count as a view. if !justCreated { h.Store.IncrementViews(row.ID) } // #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) { h.renderPage(w, "settings.html", map[string]any{"Page": "settings"}) } // 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) { next.ServeHTTP(w, r) h := w.Header() if strings.HasPrefix(h.Get("Content-Type"), "text/html") { 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") } }) }