diff --git a/customslug_test.go b/customslug_test.go
index 394af86..aec2dde 100644
--- a/customslug_test.go
+++ b/customslug_test.go
@@ -47,8 +47,9 @@ func TestCustomSlugValidation(t *testing.T) {
{"bad slug", `{"content":"x","custom_slug":"has space"}`, 400},
{"", `{"content":"x","custom_slug":""}`, 201}, // empty = no custom slug, fine
}
- for _, c := range cases {
+ for i, c := range cases {
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(c.body))
+ req.RemoteAddr = "10.7.1." + string(rune('1'+i)) + ":1000" // avoid rate-limit bucket sharing
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != c.wantCode {
diff --git a/guess.go b/guess.go
index 3f471e8..7d95434 100644
--- a/guess.go
+++ b/guess.go
@@ -62,6 +62,11 @@ func guessLang(s string) string {
}
func (a *apiServer) handleGuessLang(w http.ResponseWriter, r *http.Request) {
+ setRateLimitHeaders(w, 1, 5)
+ if !rateLimitGuess(r) {
+ writeRateLimited(w, 1)
+ return
+ }
var req struct {
Content string `json:"content"`
}
diff --git a/highlight.go b/highlight.go
new file mode 100644
index 0000000..ce28cf2
--- /dev/null
+++ b/highlight.go
@@ -0,0 +1,150 @@
+package main
+
+import (
+ "html"
+ "regexp"
+ "strings"
+)
+
+// Minimal regex-based syntax highlighter for the paste view (#1).
+// Server-side, no external dependencies. Tokens: comments, strings,
+// numbers, keywords. Output is HTML with span classes styled in app.css.
+// Highlighting is applied per line so the gutter stays line-aligned.
+
+type hlLang struct {
+ keywords map[string]bool
+ lineComps []string // line comment prefixes
+ blockCom [2]string
+}
+
+var hlLangs = map[string]hlLang{
+ "go": {
+ keywords: set("break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var nil true false string int int64 int32 uint byte rune bool float64 float32 error make new len cap append panic recover"),
+ lineComps: []string{"//"},
+ blockCom: [2]string{"/*", "*/"},
+ },
+ "python": {
+ keywords: set("and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return True try while with yield self print len range str int float list dict set tuple open"),
+ lineComps: []string{"#"},
+ },
+ "javascript": {
+ keywords: set("async await break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new null of return static super switch this throw true false try typeof undefined var void while with yield console log document window Math JSON Array Object String Number Boolean Promise"),
+ lineComps: []string{"//"},
+ blockCom: [2]string{"/*", "*/"},
+ },
+ "json": {
+ keywords: set("true false null"),
+ },
+ "bash": {
+ keywords: set("if then else elif fi for while do done case esac function return exit local export echo cd ls grep awk sed cat curl sudo apt git make echo read shift set unset trap source alias printf test rm mv cp mkdir chmod chown"),
+ lineComps: []string{"#"},
+ },
+ "sql": {
+ keywords: set("SELECT FROM WHERE INSERT INTO VALUES UPDATE SET DELETE CREATE TABLE DROP ALTER INDEX JOIN LEFT RIGHT INNER OUTER ON GROUP BY ORDER HAVING LIMIT OFFSET AND OR NOT NULL IS IN AS DISTINCT UNION ALL PRIMARY KEY FOREIGN REFERENCES DEFAULT UNIQUE CHECK VIEW WITH RETURNING EXISTS CASE WHEN THEN ELSE END COUNT SUM AVG MIN MAX"),
+ lineComps: []string{"--"},
+ blockCom: [2]string{"/*", "*/"},
+ },
+}
+
+// aliases from the language dropdown / guesser
+var hlAliases = map[string]string{
+ "py": "python", "python3": "python",
+ "js": "javascript", "node": "javascript", "typescript": "javascript", "ts": "javascript",
+ "sh": "bash", "shell": "bash", "zsh": "bash",
+ "golang": "go",
+ "c": "go", "cpp": "go", "c++": "go", "java": "go", "rust": "go", "rs": "go",
+ // C-family shares the same token rules as Go for highlighting purposes
+}
+
+func set(words string) map[string]bool {
+ m := make(map[string]bool)
+ for _, w := range strings.Fields(words) {
+ m[w] = true
+ }
+ return m
+}
+
+func resolveLang(lang string) (string, hlLang, bool) {
+ l := strings.ToLower(strings.TrimSpace(lang))
+ if l == "" || l == "text" || l == "markdown" || l == "yaml" {
+ return "", hlLang{}, false
+ }
+ if l == "yml" {
+ return "", hlLang{}, false
+ }
+ if g, ok := hlAliases[l]; ok {
+ if h, ok2 := hlLangs[g]; ok2 {
+ return g, h, true
+ }
+ return "", hlLang{}, false
+ }
+ h, ok := hlLangs[l]
+ return l, h, ok
+}
+
+var hlTokenRe = regexp.MustCompile(`("(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?|` + "`" + `[^` + "`" + `]*` + "`" + `?|//[^\n]*|--[^\n]*|#[^\n]*|/\*.*?(?:\*/|$)|\b(?:[0-9]+\.?[0-9]*|0x[0-9a-fA-F]+)\b|[A-Za-z_][A-Za-z0-9_]*)`)
+
+func highlightLine(line string, h hlLang, lang string) string {
+ var b strings.Builder
+ rest := line
+ // strip a trailing block-comment opener handled below; regex covers it
+ for {
+ loc := hlTokenRe.FindStringIndex(rest)
+ if loc == nil {
+ b.WriteString(html.EscapeString(rest))
+ break
+ }
+ b.WriteString(html.EscapeString(rest[:loc[0]]))
+ tok := rest[loc[0]:loc[1]]
+ cls := ""
+ switch {
+ case strings.HasPrefix(tok, "//") || strings.HasPrefix(tok, "#") ||
+ strings.HasPrefix(tok, "--") || strings.HasPrefix(tok, "/*"):
+ // '#/--' are comments only in langs that use them
+ if (strings.HasPrefix(tok, "#") && !containsStr(h.lineComps, "#")) ||
+ (strings.HasPrefix(tok, "--") && !containsStr(h.lineComps, "--")) {
+ cls = ""
+ } else {
+ cls = "tok-com"
+ }
+ case strings.HasPrefix(tok, "\"") || strings.HasPrefix(tok, "'") || strings.HasPrefix(tok, "`"):
+ cls = "tok-str"
+ case tok[0] >= '0' && tok[0] <= '9':
+ cls = "tok-num"
+ case h.keywords[tok]:
+ cls = "tok-kw"
+ }
+ if cls != "" {
+ b.WriteString(`` + html.EscapeString(tok) + ``)
+ } else {
+ b.WriteString(html.EscapeString(tok))
+ }
+ rest = rest[loc[1]:]
+ }
+ return b.String()
+}
+
+func containsStr(list []string, s string) bool {
+ for _, v := range list {
+ if v == s {
+ return true
+ }
+ }
+ return false
+}
+
+// highlightCode returns HTML with highlighting spans; safe because all
+// non-token text is html-escaped.
+func highlightCode(content, lang string) string {
+ l, h, ok := resolveLang(lang)
+ _ = l
+ if !ok {
+ return html.EscapeString(content)
+ }
+ lines := strings.Split(content, "\n")
+ out := make([]string, len(lines))
+ for i, line := range lines {
+ out[i] = highlightLine(line, h, lang)
+ }
+ return strings.Join(out, "\n")
+}
diff --git a/main.go b/main.go
index 266e8dd..f2c55d9 100644
--- a/main.go
+++ b/main.go
@@ -371,6 +371,7 @@ func (a *apiServer) routes() http.Handler {
r.Get("/unlock/{id}", a.handlePasteView)
r.Post("/unlock/{id}", a.handlePasteView)
r.Get("/{id}", a.handlePasteView)
+ r.Post("/{id}", a.handlePasteView)
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "not found")
@@ -379,6 +380,11 @@ func (a *apiServer) routes() http.Handler {
}
func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
+ setRateLimitHeaders(w, 1, 5)
+ if !rateLimitCreate(r) {
+ writeRateLimited(w, 1)
+ return
+ }
var p Paste
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeErr(w, 400, "invalid json body")
@@ -405,6 +411,7 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
"api_url": "/api/pastes/" + created.ID,
"expires_at": created.ExpiresAt,
"created_at": created.CreatedAt,
+ "rate_limit": map[string]int{"create_per_sec": 1, "burst": 5},
})
}
diff --git a/main_test.go b/main_test.go
index c06f0bb..c91e83c 100644
--- a/main_test.go
+++ b/main_test.go
@@ -11,6 +11,7 @@ import (
func testServer(t *testing.T) *apiServer {
t.Helper()
+ globalLimiter = newLimiter() // fresh buckets per test
store, err := OpenStore(":memory:")
if err != nil {
t.Fatal(err)
diff --git a/palette b/palette
index 7dee7fb..5653f95 100755
Binary files a/palette and b/palette differ
diff --git a/ratelimit.go b/ratelimit.go
new file mode 100644
index 0000000..e96e603
--- /dev/null
+++ b/ratelimit.go
@@ -0,0 +1,87 @@
+package main
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Per-IP token bucket rate limiting (#2). Goroutine-safe via mutex.
+
+type bucket struct {
+ tokens float64
+ last time.Time
+ rate float64 // tokens per second
+ burst float64
+}
+
+type limiter struct {
+ mu sync.Mutex
+ buckets map[string]*bucket
+}
+
+func newLimiter() *limiter {
+ return &limiter{buckets: make(map[string]*bucket)}
+}
+
+func (l *limiter) allow(key string, rate, burst float64) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ now := time.Now()
+ b, ok := l.buckets[key]
+ if !ok {
+ b = &bucket{tokens: burst, last: now, rate: rate, burst: burst}
+ l.buckets[key] = b
+ }
+ elapsed := now.Sub(b.last).Seconds()
+ b.tokens += elapsed * b.rate
+ if b.tokens > b.burst {
+ b.tokens = b.burst
+ }
+ b.last = now
+ if b.tokens < 1 {
+ return false
+ }
+ b.tokens--
+ return true
+}
+
+// clientIP extracts the request IP (no reverse proxy header by default).
+func clientIP(r *http.Request) string {
+ host := r.RemoteAddr
+ if i := strings.LastIndex(host, ":"); i > 0 {
+ host = host[:i]
+ }
+ return host
+}
+
+var globalLimiter = newLimiter()
+
+// rateLimitCreate: 1 req/sec refill, burst 5, per IP.
+func rateLimitCreate(r *http.Request) bool {
+ return globalLimiter.allow("create:"+clientIP(r), 1, 5)
+}
+
+// rateLimitGuess: 1 req/sec refill, burst 5, per IP.
+func rateLimitGuess(r *http.Request) bool {
+ return globalLimiter.allow("guess:"+clientIP(r), 1, 5)
+}
+
+// rateLimitUnlock: 5 per minute per IP+paste.
+func rateLimitUnlock(id string, r *http.Request) bool {
+ return globalLimiter.allow("unlock:"+id+":"+clientIP(r), 5.0/60.0, 5)
+}
+
+// writeRateLimited responds 429 with Retry-After based on refill rate.
+func writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
+ w.Header().Set("Retry-After", strconv.Itoa(retryAfterSecs))
+ writeErr(w, 429, "rate limit exceeded")
+}
+
+// setRateLimitHeaders sets informational X-RateLimit headers for create/guess.
+func setRateLimitHeaders(w http.ResponseWriter, limit, burst int) {
+ w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit))
+ w.Header().Set("X-RateLimit-Burst", strconv.Itoa(burst))
+}
diff --git a/ratelimit_test.go b/ratelimit_test.go
new file mode 100644
index 0000000..ca970c5
--- /dev/null
+++ b/ratelimit_test.go
@@ -0,0 +1,230 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func newTestServer(t *testing.T) *apiServer {
+ t.Helper()
+ globalLimiter = newLimiter() // fresh buckets per test
+ store, err := OpenStore(t.TempDir() + "/test.db")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if webUIInstance == nil {
+ ui, err := NewWebUI()
+ if err != nil {
+ t.Fatal(err)
+ }
+ webUIInstance = ui
+ }
+ return &apiServer{store: store, cfg: Config{MaxTextBytes: 1024 * 1024}}
+}
+
+func postJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
+ t.Helper()
+ b, _ := json.Marshal(body)
+ req := httptest.NewRequest("POST", path, bytes.NewReader(b))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ return rr
+}
+
+// TestRateLimitCreateBurst: burst of 5 creates allowed, then 429.
+func TestRateLimitCreateBurst(t *testing.T) {
+ srv := newTestServer(t)
+ h := srv.routes()
+ // unique IP per test run so tests don't share buckets
+ reqIP := "10.9.9.1:1234"
+ for i := 0; i < 5; i++ {
+ req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
+ req.RemoteAddr = reqIP
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ if rr.Code != 201 {
+ t.Fatalf("req %d: want 201, got %d: %s", i, rr.Code, rr.Body.String())
+ }
+ }
+ req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
+ req.RemoteAddr = reqIP
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ if rr.Code != 429 {
+ t.Fatalf("6th req: want 429, got %d", rr.Code)
+ }
+ if ra := rr.Header().Get("Retry-After"); ra == "" {
+ t.Fatal("missing Retry-After header")
+ }
+ if ra := rr.Header().Get("X-RateLimit-Limit"); ra == "" {
+ t.Fatal("missing X-RateLimit-Limit header")
+ }
+}
+
+// TestRateLimitRefill: after waiting >1s a token refills and a create succeeds.
+func TestRateLimitRefill(t *testing.T) {
+ srv := newTestServer(t)
+ h := srv.routes()
+ reqIP := "10.9.9.2:1234"
+ for i := 0; i < 6; i++ {
+ req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
+ req.RemoteAddr = reqIP
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ }
+ time.Sleep(1100 * time.Millisecond)
+ req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
+ req.RemoteAddr = reqIP
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ if rr.Code != 201 {
+ t.Fatalf("after refill: want 201, got %d", rr.Code)
+ }
+}
+
+// TestRateLimitGuess: guess-language endpoint is limited too.
+func TestRateLimitGuess(t *testing.T) {
+ srv := newTestServer(t)
+ h := srv.routes()
+ reqIP := "10.9.9.3:1234"
+ for i := 0; i < 6; i++ {
+ req := httptest.NewRequest("POST", "/api/guess-language", bytes.NewReader([]byte(`{"content":"def f(): pass"}`)))
+ req.RemoteAddr = reqIP
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ if i < 5 && rr.Code != 200 {
+ t.Fatalf("req %d: want 200, got %d", i, rr.Code)
+ }
+ }
+ req := httptest.NewRequest("POST", "/api/guess-language", bytes.NewReader([]byte(`{"content":"x"}`)))
+ req.RemoteAddr = reqIP
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ if rr.Code != 429 {
+ t.Fatalf("want 429, got %d", rr.Code)
+ }
+}
+
+// TestRateLimitUnlock: 5 unlock attempts per IP+paste per minute, then 429.
+func TestRateLimitUnlock(t *testing.T) {
+ srv := newTestServer(t)
+ h := srv.routes()
+ // create a password-protected paste
+ rr := postJSON(t, h, "/api/pastes", map[string]any{"content": "secret", "password": "pw1", "visibility": "unlisted"})
+ if rr.Code != 201 {
+ t.Fatalf("create failed: %d", rr.Code)
+ }
+ var created map[string]any
+ json.Unmarshal(rr.Body.Bytes(), &created)
+ id := created["id"].(string)
+ reqIP := "10.9.9.4:1234"
+ for i := 0; i < 6; i++ {
+ req := httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=wrong")))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.RemoteAddr = reqIP
+ rr2 := httptest.NewRecorder()
+ h.ServeHTTP(rr2, req)
+ if i < 5 && rr2.Code == 429 {
+ t.Fatalf("req %d: unexpected 429", i)
+ }
+ }
+ req := httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=wrong")))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.RemoteAddr = reqIP
+ rr2 := httptest.NewRecorder()
+ h.ServeHTTP(rr2, req)
+ if rr2.Code != 429 {
+ t.Fatalf("want 429, got %d", rr2.Code)
+ }
+}
+
+// TestHighlightCode basic expectations.
+func TestHighlightCode(t *testing.T) {
+ in := "func main() {\n\t// comment\n\tfmt.Println(\"hello\")\n}\n"
+ out := highlightCode(in, "go")
+ if !bytes.Contains([]byte(out), []byte(`func`)) {
+ t.Fatalf("no keyword span: %s", out)
+ }
+ if !bytes.Contains([]byte(out), []byte(`// comment`)) {
+ t.Fatalf("no comment span: %s", out)
+ }
+ if !bytes.Contains([]byte(out), []byte(`tok-str">"hello"`)) {
+ t.Fatalf("no string span: %s", out)
+ }
+ // unsupported language returns escaped plain text
+ plain := highlightCode("x", "text")
+ if plain != "<b>x</b>" {
+ t.Fatalf("plain escaping wrong: %q", plain)
+ }
+ // line count preserved (gutter alignment)
+ if got := len(splitLines(highlightCode("a\nb\nc", "go"))); got != 3 {
+ t.Fatalf("want 3 lines, got %d", got)
+ }
+}
+
+func splitLines(s string) []string {
+ var out []string
+ start := 0
+ for i := 0; i < len(s); i++ {
+ if s[i] == '\n' {
+ out = append(out, s[start:i])
+ start = i + 1
+ }
+ }
+ out = append(out, s[start:])
+ return out
+}
+
+// TestCreatorAutoUnlock: create with password, then POST the password to
+// /{id}, then GET /{id} with the cookie shows the paste (#26).
+func TestCreatorAutoUnlock(t *testing.T) {
+ srv := newTestServer(t)
+ h := srv.routes()
+ rr := postJSON(t, h, "/api/pastes", map[string]any{"content": "secret stuff", "password": "pw2", "visibility": "unlisted"})
+ if rr.Code != 201 {
+ t.Fatalf("create failed: %d", rr.Code)
+ }
+ var created map[string]any
+ json.Unmarshal(rr.Body.Bytes(), &created)
+ id := created["id"].(string)
+
+ // locked GET shows unlock page
+ req := httptest.NewRequest("GET", "/"+id, nil)
+ rr2 := httptest.NewRecorder()
+ h.ServeHTTP(rr2, req)
+ if bytes.Contains(rr2.Body.Bytes(), []byte("secret stuff")) {
+ t.Fatal("locked paste leaked content")
+ }
+
+ // unlock POST with ?next= should set cookie and redirect
+ req = httptest.NewRequest("POST", "/"+id, bytes.NewReader([]byte("password=pw2&next=/"+id+"?created=1")))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rr3 := httptest.NewRecorder()
+ h.ServeHTTP(rr3, req)
+ if rr3.Code != http.StatusSeeOther {
+ t.Fatalf("unlock POST: want 303, got %d", rr3.Code)
+ }
+ var cookie *http.Cookie
+ for _, c := range rr3.Result().Cookies() {
+ if c.Name == "pw_"+id {
+ cookie = c
+ }
+ }
+ if cookie == nil {
+ t.Fatal("no pw_ cookie set")
+ }
+
+ // GET with cookie shows content
+ req = httptest.NewRequest("GET", "/"+id, nil)
+ req.AddCookie(cookie)
+ rr4 := httptest.NewRecorder()
+ h.ServeHTTP(rr4, req)
+ if !bytes.Contains(rr4.Body.Bytes(), []byte("secret stuff")) {
+ t.Fatalf("cookie unlock failed: %d %s", rr4.Code, rr4.Body.String())
+ }
+}
diff --git a/web.go b/web.go
index 6235edd..afef6e8 100644
--- a/web.go
+++ b/web.go
@@ -107,7 +107,7 @@ func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreate
"ID": row.ID,
"Title": row.Title.String,
"Language": row.Language.String,
- "ContentHTML": template.HTMLEscapeString(row.Content),
+ "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,
@@ -143,6 +143,10 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
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) {
@@ -150,7 +154,14 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
Name: "pw_" + row.ID, Value: "1", Path: "/",
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
- // re-render without lock
+ // 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
}
diff --git a/web/static/app.css b/web/static/app.css
index ad67cd0..ae73d56 100644
--- a/web/static/app.css
+++ b/web/static/app.css
@@ -129,6 +129,11 @@ body {
}
.code .gutter { flex-shrink: 0; }
.codebody { padding: 0 18px; white-space: pre; }
+/* syntax highlight tokens (#1) */
+.tok-kw { color: #c792ea; }
+.tok-str { color: #a5e075; }
+.tok-num { color: #f78c6c; }
+.tok-com { color: #6a737d; font-style: italic; }
.footnote { display: flex; gap: 20px; padding: 10px 18px; font-size: 20.7px; color: var(--muted-fg); border-top: 1px solid var(--border); flex-wrap: wrap; }
/* history */
diff --git a/web/templates/new.html b/web/templates/new.html
index 78dfa9c..841fc21 100644
--- a/web/templates/new.html
+++ b/web/templates/new.html
@@ -162,8 +162,18 @@ async function create() {
showResult('' + url + '', false);
$('result').dataset.token = data.deletion_token || '';
try { navigator.clipboard.writeText(url); toast('Copied'); } catch(e) {}
+ const dest = '/' + data.id + '?created=1&token=' + encodeURIComponent(data.deletion_token || '');
+ // password-protected: unlock now with the password we already have (#26)
+ if ($('haspw').checked && data.id) {
+ const fd = new FormData();
+ fd.append('password', $('password').value);
+ fd.append('next', dest);
+ try {
+ await fetch('/' + data.id, {method: 'POST', body: fd});
+ } catch(e) {}
+ }
// show the paste
- location.href = '/' + data.id + '?created=1&token=' + encodeURIComponent(data.deletion_token || '');
+ location.href = dest;
}
$('create').addEventListener('click', create);
// reset stale result state when returning via Back (bfcache) (#28)