diff --git a/main.go b/main.go index 2fde6c5..923553d 100644 --- a/main.go +++ b/main.go @@ -66,6 +66,7 @@ type PasteRow struct { CreatedAt int64 DeletedAt sql.NullInt64 ViewCount int + Size int DeletionToken sql.NullString } @@ -230,7 +231,7 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) { } func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { - rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count FROM pastes + rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), limit, offset) if err != nil { @@ -241,7 +242,7 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { for rows.Next() { var r PasteRow var cs, lang, title sql.NullString - if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount); err != nil { + if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil { return nil, 0, err } r.CustomSlug = cs @@ -337,9 +338,14 @@ func (a *apiServer) routes() http.Handler { // raw r.Get("/raw/{id}", a.handleRaw) - // web (minimal for now) - r.Get("/", a.handleHome) - r.Get("/{id}", a.handlePastePage) + // web pages + r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP) + r.Get("/new", a.handleNewPage) + r.Get("/history", a.handleHistoryPage) + r.Handle("/static/*", staticHandler()) + r.Get("/unlock/{id}", a.handlePasteView) + r.Post("/unlock/{id}", a.handlePasteView) + r.Get("/{id}", a.handlePasteView) r.NotFound(func(w http.ResponseWriter, r *http.Request) { writeErr(w, 404, "not found") @@ -447,7 +453,7 @@ func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) { lang, title := nullStrPtr(row.Language), nullStrPtr(row.Title) items = append(items, map[string]any{ "id": row.ID, "title": title, "language": lang, - "created_at": row.CreatedAt, "view_count": row.ViewCount, + "created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size, }) } writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items}) @@ -538,6 +544,11 @@ func main() { } store.StartSweeper(time.Minute) + ui, err := NewWebUI() + if err != nil { + log.Fatal(err) + } + webUIInstance = ui srv := &apiServer{store: store, cfg: cfg} log.Printf("palette listening on %s", cfg.Addr) log.Fatal(http.ListenAndServe(cfg.Addr, srv.routes())) diff --git a/palette b/palette index 6edafc0..6351c8f 100755 Binary files a/palette and b/palette differ diff --git a/palette.db-shm b/palette.db-shm index f3c7a7e..19d280e 100644 Binary files a/palette.db-shm and b/palette.db-shm differ diff --git a/palette.db-wal b/palette.db-wal index 043d6f2..8856206 100644 Binary files a/palette.db-wal and b/palette.db-wal differ diff --git a/web.go b/web.go new file mode 100644 index 0000000..6235edd --- /dev/null +++ b/web.go @@ -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 diff --git a/web/static/app.css b/web/static/app.css index 76a8d06..23958dc 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -1 +1,184 @@ -/* palette static assets placeholder */ +/* Palette UI tokens (mirrors sketches/themes/tokens.css, midnight approved preset) */ +:root { + --bg: #241B30; --surface: #2D2340; --surface-2: #3A2D52; + --muted: #7A6A9E; --muted-fg: #C0B2DE; --fg: #F2EDF8; + --accent: #C4A8F0; --border: #42355C; + --radius: 14px; + --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-mono: ui-monospace, "JetBrains Mono", "Fira Code", monospace; +} +[data-preset="smooth"] { + --bg: #F6F5FA; --surface: #FFFFFF; --surface-2: #DAD7E6; + --muted: #B5B1C9; --muted-fg: #7A7796; --fg: #2A2A36; + --accent: #7A7796; --border: #DAD7E6; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +body { + font-family: var(--font-body); + background: var(--bg); + color: var(--fg); + line-height: 1.45; + -webkit-font-smoothing: antialiased; + min-height: 100vh; + font-size: 14px; +} +.topbar { + display: flex; align-items: center; gap: 20px; + padding: 0 20px; height: 52px; + background: var(--surface); border-bottom: 1px solid var(--border); +} +.logo { font-weight: 700; font-size: 16px; letter-spacing: -0.02em; text-decoration: none; color: var(--fg); } +.logo em { font-style: normal; color: var(--muted-fg); font-weight: 400; } +.topbar nav { display: flex; gap: 4px; } +.topbar nav a { color: var(--muted-fg); text-decoration: none; padding: 6px 12px; border-radius: 8px; font-size: 13.5px; } +.topbar nav a:hover { background: var(--surface-2); color: var(--fg); } +.topbar nav a.on { background: var(--accent); color: var(--bg); } +.topbar .spacer { flex: 1; } +.kbd { font-family: var(--font-mono); font-size: 11px; border: 1px solid var(--border); border-radius: 5px; padding: 2px 6px; color: var(--muted-fg); } + +.float { + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: 0 2px 6px rgba(20,14,32,.25), 0 12px 32px rgba(20,14,32,.3); + overflow: hidden; +} + +/* new paste page */ +.deck { + display: grid; grid-template-columns: 1fr 300px; gap: 16px; + padding: 16px 20px; height: calc(100vh - 52px); + max-width: 1400px; margin: 0 auto; +} +.pane-r { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; padding-bottom: 4px; } +.side-section { padding: 14px 16px; flex-shrink: 0; } +.side-section h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted-fg); margin-bottom: 10px; } +.pane-l { display: flex; flex-direction: column; } +.editor-head { + display: flex; align-items: center; gap: 12px; padding: 10px 16px; + border-bottom: 1px solid var(--border); +} +.editor-head input { + border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 13.5px; flex: 1; +} +.editor-head select { + border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); + border-radius: 7px; padding: 4px 10px; font: inherit; font-size: 12.5px; cursor: pointer; +} +.editor-wrap { flex: 1; display: flex; min-height: 0; } +.gutter { + padding: 14px 10px; text-align: right; color: var(--muted); font-family: var(--font-mono); + font-size: 12.5px; line-height: 1.7; user-select: none; white-space: pre; overflow: hidden; + border-right: 1px solid var(--border); +} +.editor { + flex: 1; padding: 14px 16px; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.7; + white-space: pre; outline: none; overflow: auto; border: none; background: transparent; color: var(--fg); + resize: none; width: 100%; +} +.editor::placeholder { color: var(--muted); } +.actionbar { + display: flex; align-items: center; gap: 14px; padding: 12px 16px; + border-top: 1px solid var(--border); +} +.btn { + background: var(--accent); color: var(--bg); border: none; cursor: pointer; + padding: 8px 18px; border-radius: 8px; font: inherit; font-size: 13px; font-weight: 600; +} +.btn:hover { filter: brightness(1.08); } +.hint { font-size: 12px; color: var(--muted-fg); } +.hint b { color: var(--fg); font-weight: 550; } +.seg { display: flex; flex-direction: column; gap: 2px; } +.seg label { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 7px; cursor: pointer; font-size: 13px; } +.seg label:hover { background: var(--surface-2); } +.seg input { accent-color: var(--accent); } +.toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; padding: 5px 8px; border-radius: 7px; } +.toggle:hover { background: var(--surface-2); } +.toggle input { accent-color: var(--accent); } +.row { display: flex; justify-content: space-between; align-items: center; font-size: 13px; padding: 4px 0; } +.row input[type="text"] { + border: 1px solid var(--border); border-radius: 7px; padding: 5px 8px; background: var(--bg); + color: var(--fg); font: inherit; font-size: 12.5px; width: 130px; +} +.created-banner { + display: none; padding: 10px 16px; font-size: 13px; background: var(--surface-2); + border-bottom: 1px solid var(--border); word-break: break-all; +} +.created-banner a { color: var(--accent); } + +/* paste view */ +.meta-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; } +.meta-bar h1 { font-size: 17px; font-weight: 600; } +.slug { font-family: var(--font-mono); font-size: 12.5px; color: var(--muted-fg); background: var(--surface-2); padding: 3px 9px; border-radius: 7px; } +.tag { font-size: 11.5px; color: var(--muted-fg); border: 1px solid var(--border); border-radius: 999px; padding: 2px 9px; } +.meta-bar .spacer { flex: 1; } +.iconbtn { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: 8px; padding: 5px 12px; font: inherit; font-size: 12.5px; cursor: pointer; text-decoration: none; } +.iconbtn:hover { color: var(--fg); border-color: var(--muted); } +.iconbtn.danger:hover { color: #ff8fa3; border-color: #ff8fa3; } +.code-head { + display: flex; align-items: center; gap: 10px; padding: 8px 16px; + border-bottom: 1px solid var(--border); font-size: 12.5px; color: var(--muted-fg); +} +.code-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); } +.code { + font-family: var(--font-mono); font-size: 13px; line-height: 1.7; + padding: 14px 0; display: flex; overflow-x: auto; +} +.code .gutter { flex-shrink: 0; } +.codebody { padding: 0 18px; white-space: pre; } +.footnote { display: flex; gap: 20px; padding: 10px 18px; font-size: 12px; color: var(--muted-fg); border-top: 1px solid var(--border); flex-wrap: wrap; } + +/* history */ +.page { max-width: 860px; margin: 0 auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; } +.head-row { display: flex; align-items: baseline; gap: 14px; } +.head-row h1 { font-size: 20px; font-weight: 600; } +.search { + display: flex; align-items: center; gap: 8px; background: var(--surface); + border: 1px solid var(--border); border-radius: 10px; padding: 8px 14px; width: 260px; +} +.search input { border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 13px; width: 100%; } +table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; } +th:nth-child(1), td:nth-child(1) { width: 130px; } +th:nth-child(2), td:nth-child(2) { width: auto; } +th:nth-child(3), td:nth-child(3) { width: 80px; } +th:nth-child(4), td:nth-child(4) { width: 80px; } +th:nth-child(5), td:nth-child(5) { width: 64px; } +th:nth-child(6), td:nth-child(6) { width: 90px; } +th { + text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; + color: var(--muted-fg); padding: 10px 16px; border-bottom: 1px solid var(--border); font-weight: 600; +} +td { padding: 10px 16px; border-bottom: 1px solid var(--border); } +tr:last-child td { border-bottom: none; } +tr.row:hover td { background: var(--surface-2); } +td a.slug { font-family: var(--font-mono); font-size: 12.5px; color: var(--fg); text-decoration: none; } +td a.slug:hover { color: var(--accent); } +.badge { font-size: 11px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: 999px; padding: 1px 8px; } +.badge.lock { color: var(--accent); border-color: var(--accent); } +.dim { color: var(--muted-fg); white-space: nowrap; } +.pager { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; font-size: 12.5px; color: var(--muted-fg); } +.pager .pg { display: flex; gap: 6px; } +.pager button { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: 7px; padding: 4px 11px; font: inherit; font-size: 12.5px; cursor: pointer; } +.pager button:hover:not(:disabled) { color: var(--fg); border-color: var(--muted); } +.pager button.on { background: var(--accent); color: var(--bg); border-color: var(--accent); } +.pager button:disabled { opacity: .4; cursor: default; } +.empty { text-align: center; padding: 40px 16px; color: var(--muted-fg); font-size: 13px; } + +/* unlock */ +.center { display: flex; align-items: center; justify-content: center; padding: 20px; height: calc(100vh - 52px); } +.center .float { width: 400px; max-width: 100%; } +.inner { padding: 28px; text-align: center; } +.lockring { + width: 56px; height: 56px; margin: 0 auto 16px; border-radius: 50%; + background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 24px; +} +.inner h1 { font-size: 17px; font-weight: 600; margin-bottom: 6px; } +.inner .sub { font-size: 13px; color: var(--muted-fg); margin-bottom: 20px; } +.pwinput { + width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: 9px; + background: var(--bg); color: var(--fg); font: inherit; font-size: 13.5px; outline: none; text-align: center; + letter-spacing: .12em; +} +.pwinput:focus { border-color: var(--accent); } +.center .btn { width: 100%; margin-top: 12px; } +.err { display: none; margin-top: 12px; font-size: 12.5px; color: #ff8fa3; } +.center .foot { font-size: 12px; color: var(--muted-fg); padding: 14px; border-top: 1px solid var(--border); } diff --git a/web/templates/foot.html b/web/templates/foot.html new file mode 100644 index 0000000..b1f3d7b --- /dev/null +++ b/web/templates/foot.html @@ -0,0 +1 @@ +{{define "foot"}}{{end}} diff --git a/web/templates/history.html b/web/templates/history.html new file mode 100644 index 0000000..da5788a --- /dev/null +++ b/web/templates/history.html @@ -0,0 +1,89 @@ +{{template "head" .}} +{{template "topbar" .}} +
| Paste | Description | Language | Size | Views | Created |
|---|
Enter the password to view /{{.ID}}
+ +