From fb7decc608b2ec0ebe7d2d6fac09ae63fa8c3f77 Mon Sep 17 00:00:00 2001 From: Stephen Parkinson Date: Sat, 28 Feb 2026 22:50:25 -0800 Subject: [PATCH] add password protected pastes --- Backend/go.mod | 2 + Backend/go.sum | 2 + Backend/paste/handlers.go | 39 ++++++++++++-- Backend/paste/store.go | 14 ++--- Frontend/src/pages/Paste.tsx | 10 ++++ Frontend/src/pages/PasteView.tsx | 88 +++++++++++++++++++++++++++++--- 6 files changed, 140 insertions(+), 15 deletions(-) diff --git a/Backend/go.mod b/Backend/go.mod index ce92f49..068cd32 100644 --- a/Backend/go.mod +++ b/Backend/go.mod @@ -3,3 +3,5 @@ module smpark.in go 1.26.0 require golang.org/x/time v0.14.0 + +require golang.org/x/crypto v0.48.0 // indirect diff --git a/Backend/go.sum b/Backend/go.sum index ed7d3d5..fb66878 100644 --- a/Backend/go.sum +++ b/Backend/go.sum @@ -1,2 +1,4 @@ +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= diff --git a/Backend/paste/handlers.go b/Backend/paste/handlers.go index f177231..1e847de 100644 --- a/Backend/paste/handlers.go +++ b/Backend/paste/handlers.go @@ -5,6 +5,8 @@ import ( "errors" "net/http" "time" + + "golang.org/x/crypto/bcrypt" ) type Handlers struct { @@ -20,6 +22,7 @@ type createRequest struct { Content string `json:"content"` Language string `json:"language"` Expiry int `json:"expiry"` + Password string `json:"password"` } func writeJSON(w http.ResponseWriter, status int, v any) { @@ -53,10 +56,26 @@ func (h *Handlers) Create(w http.ResponseWriter, r *http.Request) { return } + var passwordHash string + if req.Password != "" { + if len(req.Password) > 72 { + writeError(w, http.StatusBadRequest, "password exceeds 72 characters") + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to process password") + return + } + passwordHash = string(hash) + } + p := &Paste{ - Title: req.Title, - Content: req.Content, - Language: req.Language, + Title: req.Title, + Content: req.Content, + Language: req.Language, + PasswordHash: passwordHash, + Protected: passwordHash != "", } if req.Expiry < 1 || req.Expiry > 10 { @@ -93,6 +112,13 @@ func (h *Handlers) Get(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusGone, "paste has expired") return } + if p.Protected { + pw := r.Header.Get("X-Paste-Password") + if pw == "" || bcrypt.CompareHashAndPassword([]byte(p.PasswordHash), []byte(pw)) != nil { + writeJSON(w, http.StatusForbidden, map[string]any{"protected": true, "error": "password required"}) + return + } + } writeJSON(w, http.StatusOK, p) } @@ -112,6 +138,13 @@ func (h *Handlers) GetRaw(w http.ResponseWriter, r *http.Request) { http.Error(w, "paste has expired", http.StatusGone) return } + if p.Protected { + pw := r.Header.Get("X-Paste-Password") + if pw == "" || bcrypt.CompareHashAndPassword([]byte(p.PasswordHash), []byte(pw)) != nil { + http.Error(w, "password required", http.StatusForbidden) + return + } + } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") w.Write([]byte(p.Content)) diff --git a/Backend/paste/store.go b/Backend/paste/store.go index 7990ebd..372a683 100644 --- a/Backend/paste/store.go +++ b/Backend/paste/store.go @@ -10,12 +10,14 @@ import ( var ErrNotFound = errors.New("paste not found") type Paste struct { - ID string `json:"id"` - Title string `json:"title"` - Content string `json:"content"` - Language string `json:"language"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt *time.Time `json:"expires_at"` + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + Language string `json:"language"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at"` + PasswordHash string `json:"-"` + Protected bool `json:"protected"` } type Store struct { diff --git a/Frontend/src/pages/Paste.tsx b/Frontend/src/pages/Paste.tsx index b5d94df..aee5f04 100644 --- a/Frontend/src/pages/Paste.tsx +++ b/Frontend/src/pages/Paste.tsx @@ -41,6 +41,7 @@ export default function Paste() { const [content, setContent] = useState(""); const [language, setLanguage] = useState(LANGUAGE_OPTIONS[0]); const [expiry, setExpiry] = useState(5); + const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -60,6 +61,7 @@ export default function Paste() { content, language: language.value, expiry, + password: password || undefined, }), }); const data = await res.json(); @@ -109,6 +111,14 @@ export default function Paste() { options={LANGUAGE_OPTIONS} /> + + setPassword(e.detail.value)} + placeholder="Leave blank for no password" + /> + (); const [paste, setPaste] = useState(null); - const [status, setStatus] = useState<"loading" | "ok" | "notfound" | "expired" | "error">( - "loading", - ); + const [status, setStatus] = useState< + "loading" | "ok" | "notfound" | "expired" | "error" | "locked" + >("loading"); const [copied, setCopied] = useState(false); + const [passwordInput, setPasswordInput] = useState(""); + const [passwordError, setPasswordError] = useState(null); + const [unlocking, setUnlocking] = useState(false); useEffect(() => { if (!id) return; @@ -44,6 +50,10 @@ export default function PasteView() { setStatus("expired"); return null; } + if (res.status === 403) { + setStatus("locked"); + return null; + } if (!res.ok) { setStatus("error"); return null; @@ -59,6 +69,40 @@ export default function PasteView() { .catch(() => setStatus("error")); }, [id]); + async function handleUnlock() { + if (!id || !passwordInput) return; + setUnlocking(true); + setPasswordError(null); + try { + const res = await fetch(`/api/paste/${id}`, { + headers: { "X-Paste-Password": passwordInput }, + }); + if (res.status === 403) { + setPasswordError("Incorrect password."); + return; + } + if (res.status === 404) { + setStatus("notfound"); + return; + } + if (res.status === 410) { + setStatus("expired"); + return; + } + if (!res.ok) { + setStatus("error"); + return; + } + const data = await res.json(); + setPaste(data); + setStatus("ok"); + } catch { + setPasswordError("Network error. Please try again."); + } finally { + setUnlocking(false); + } + } + function handleCopy() { if (!paste) return; navigator.clipboard.writeText(paste.content).then(() => { @@ -103,6 +147,36 @@ export default function PasteView() { ); } + if (status === "locked") { + return ( + Password Required}> + + + {passwordError && ( + setPasswordError(null)} dismissible> + {passwordError} + + )} + + setPasswordInput(e.detail.value)} + onKeyDown={(e) => { + if (e.detail.key === "Enter") handleUnlock(); + }} + placeholder="Enter password" + /> + + + + + + ); + } + if (status === "error" || !paste) { return ( Error}> @@ -123,9 +197,11 @@ export default function PasteView() { - + {!paste.protected && ( + + )} } >