package main import ( "embed" "fmt" "html/template" "io/fs" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" ) //go:embed web/templates/*.html var tmplFS embed.FS //go:embed web/static var staticFS embed.FS type webUI struct { tmpl *template.Template } func NewWebUI() (*webUI, error) { funcs := template.FuncMap{ "humanSize": humanSize, } t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "web/templates/*.html") if err != nil { return nil, err } return &webUI{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 staticHandler() http.Handler { sub, _ := fs.Sub(staticFS, "web/static") return http.StripPrefix("/static/", http.FileServer(http.FS(sub))) } func renderPage(w http.ResponseWriter, name string, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := webUIInstance.tmpl.ExecuteTemplate(w, name, data); err != nil { http.Error(w, "template error: "+err.Error(), 500) } } var webUIInstance *webUI func (a *apiServer) handleNewPage(w http.ResponseWriter, r *http.Request) { renderPage(w, "new.html", map[string]any{"Page": "new"}) } func (a *apiServer) handleHistoryPage(w http.ResponseWriter, r *http.Request) { renderPage(w, "history.html", map[string]any{"Page": "history"}) } func (a *apiServer) handleSettingsPage(w http.ResponseWriter, r *http.Request) { renderPage(w, "settings.html", map[string]any{"Page": "settings"}) } 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) } } func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreated bool, deletionToken string) { 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(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, "JustCreated": justCreated, "Host": "this host", } renderPage(w, "paste.html", data) } // handlePastePage renders the paste view; supports both ID and custom slug. func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") row, err := a.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 !rateLimitUnlock(row.ID, r) { writeRateLimited(w, 60) return } r.ParseForm() pw := r.FormValue("password") if pw != "" && checkPassword(row.PasswordHash.String, pw) { http.SetCookie(w, &http.Cookie{ Name: "pw_" + row.ID, Value: "1", 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 } } a.renderPaste(w, row, false, "") return } renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt}) return } // check cookie c, err := r.Cookie("pw_" + row.ID) if err != nil || c.Value != "1" { renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt}) return } } a.store.IncrementViews(row.ID) 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}) } // only pass the token to the template right after creation if justCreated { a.renderPaste(w, row, true, token) return } a.renderPaste(w, row, false, "") } var _ = strconv.Itoa