- Move all inline <script> blocks (layout head/theme, topbar dark toggle, foot, paste, new, history, mine, settings, admin, unlock) to external files under internal/web/static/. Page data reaches scripts via data-* attributes (data-paste-id, data-default-dark) instead of template vars. - Replace inline onclick handlers (copy, delete, stats toggle) with addEventListener wiring. - Convert inline style="" attributes to CSS utility classes; swatch colors are now set via CSSOM/DOM APIs instead of innerHTML strings. - script-src/style-src are now plain 'self'; img-src data: stays for the SVG data-URI backgrounds. Verified with headless chromium: zero CSP violations on all pages in dark and light presets, theme swatches, admin lock, tables and paste view render correctly.
409 lines
14 KiB
Go
409 lines
14 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)
|
|
}
|
|
}
|
|
|
|
// 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"}`))
|
|
}
|
|
|
|
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))
|
|
// #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
|
|
}
|
|
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,
|
|
"Attachment": attachment,
|
|
"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_<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 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.
|
|
// #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)
|
|
})
|
|
}
|