Real web UI: /new editor, /history with live pagination, paste view with gutter and deletion banner, /unlock page; size in public API
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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 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)
|
||||
}
|
||||
data := map[string]any{
|
||||
"Page": "paste",
|
||||
"ID": row.ID,
|
||||
"Title": row.Title.String,
|
||||
"Language": row.Language.String,
|
||||
"ContentHTML": template.HTMLEscapeString(row.Content),
|
||||
"ContentAttr": row.Content,
|
||||
"Gutter": strings.TrimSuffix(gutter, "\n"),
|
||||
"LineCount": lines,
|
||||
"SizeBytes": len(row.Content),
|
||||
"CreatedAgo": agoString(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 {
|
||||
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
|
||||
a.renderPaste(w, row, false, "")
|
||||
return
|
||||
}
|
||||
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(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)})
|
||||
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
|
||||
Reference in New Issue
Block a user