317 lines
10 KiB
Go
317 lines
10 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
|
|
|
|
//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_<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))
|
|
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_<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"
|
|
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")
|
|
}
|
|
})
|
|
}
|