Files
puppy-tracker/server/auth.go
T
Alexander Heldt 66f89b35a9 Keep showing a guest link's URL so it can be copied again
The URL was shown once, in a box under the create button, and then gone: only
a hash of the token was stored, so the app genuinely could not produce it a
second time. Lose the message you sent the sitter and the only way back was to
mint a new link — which strands whoever is already holding the old one.

Settings now lists every live link with its URL and a Copy button, so re-sending
one is just copying it again.

That means keeping the token rather than only its hash, and it is worth being
plain about the trade. It is not the trade you would make for a password, which
the user has probably reused, or a session token, which grants everything
indefinitely. A guest link grants a strict subset of what the same database
already holds in plaintext, expires on a date the owner picked, and can be
revoked in one tap — so an attacker who can read puppy.db gains very little by
also being able to open it as a guest. The lookup column stays a hash and
remains the key redeem matches against; the secret sits in a new column beside
it, which also keeps the migration additive.

Links created before this have an empty secret. They keep working and stay
revocable — the migration touches nothing but the new column — and the list
says why their URL is missing rather than rendering a broken one.

The two tests that asserted the old contract now assert the new one: a listing
hands back a secret that really opens the link, and the lookup column is still
a hash. Added one for the legacy row, since "still works, just cannot be shown"
is the part a future change is most likely to break quietly.
2026-09-07 19:25:20 +00:00

779 lines
25 KiB
Go

package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "puppy_session"
sessionValidity = 30 * 24 * time.Hour
// A guest link's last_used is only refreshed this often, so "last used" can
// be shown in Settings without a write on every single request.
lastUsedResolution = 5 * time.Minute
// How far ahead a guest link may be set to expire. The owner picks the date,
// so this is only a backstop against a mistyped year turning a sitter's link
// into a permanent credential.
maxShareAhead = 365 * 24 * time.Hour
)
// ctxKey is an unexported type so our context values can't collide with any
// set elsewhere.
type ctxKey int
const sessionKey ctxKey = 0
// User is the public shape returned to clients — never the password hash.
// Role is "owner" for a normal login and "guest" for a session minted from a
// share link, in which case Label names the link and Email is blanked (it is
// the owner's address, and a guest has no business seeing it).
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role,omitempty"`
Label string `json:"label,omitempty"`
// ShareID is the guest's own link id, which is what decides the events they
// are allowed to change (see Store.sync). The client uses it to grey out
// everything logged by someone else.
ShareID string `json:"shareId,omitempty"`
// Expires is the guest session's end, in Unix milliseconds. Owner sessions
// leave it zero — they only end by logging out.
Expires int64 `json:"expires,omitempty"`
}
// session is a resolved cookie: who the request acts as, and whether it got
// there through a guest link. ShareID is empty for an owner session.
type session struct {
userID string
shareID string
label string
expires int64
}
func (s session) guest() bool { return s.shareID != "" }
// Auth owns everything account-related: the users/sessions tables, the shared
// invite code required to register, and whether session cookies are marked
// Secure (on behind TLS/a proxy). photosDir is needed so the first account can
// adopt legacy flat-layout photos.
type Auth struct {
db *sql.DB
inviteCode string
secure bool
photosDir string
}
func newAuth(db *sql.DB, inviteCode string, secure bool, photosDir string) *Auth {
return &Auth{db: db, inviteCode: inviteCode, secure: secure, photosDir: photosDir}
}
// ---------- users & sessions ----------
func newID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic(err) // crypto/rand failing is unrecoverable
}
// RFC-4122-ish v4 layout; good enough as an opaque unique id.
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return hex.EncodeToString(b[0:4]) + "-" + hex.EncodeToString(b[4:6]) + "-" +
hex.EncodeToString(b[6:8]) + "-" + hex.EncodeToString(b[8:10]) + "-" +
hex.EncodeToString(b[10:16])
}
// hashToken stores only the hash of a session token, so a leaked database can't
// be used to impersonate live sessions.
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
func (a *Auth) userCount() (int, error) {
var n int
err := a.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
var errEmailTaken = errors.New("email already registered")
func (a *Auth) createUser(email, password string) (User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return User{}, err
}
u := User{ID: newID(), Email: email}
_, err = a.db.Exec(
`INSERT INTO users (id, email, password, created) VALUES (?, ?, ?, ?)`,
u.ID, email, string(hash), time.Now().UnixMilli())
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
return User{}, errEmailTaken
}
return User{}, err
}
return u, nil
}
// verify returns the user for the given credentials, or ok=false if the email
// is unknown or the password is wrong (indistinguishable to the caller).
func (a *Auth) verify(email, password string) (User, bool) {
var u User
var hash string
err := a.db.QueryRow(
`SELECT id, email, password FROM users WHERE email = ? COLLATE NOCASE`, email,
).Scan(&u.ID, &u.Email, &hash)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
log.Printf("verify: %v", err)
}
return User{}, false
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
return User{}, false
}
return u, true
}
// startSession mints a token, stores its hash, and returns the raw token for
// the cookie. shareID is empty for an owner login; for a guest it names the
// share link, and expires is capped at that link's own end so the session can
// never outlive the link it came from.
func (a *Auth) startSession(userID, shareID string, expires int64) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := hex.EncodeToString(raw)
now := time.Now()
_, err := a.db.Exec(
`INSERT INTO sessions (token, user_id, created, expires, share_id) VALUES (?, ?, ?, ?, ?)`,
hashToken(token), userID, now.UnixMilli(), expires, shareID)
if err != nil {
return "", err
}
return token, nil
}
// sessionForToken resolves a raw cookie token to the session it stands for,
// honouring expiry. A guest session is additionally only valid while its link
// is un-revoked and unexpired — checked here, on every request, so revoking a
// link kicks its live sessions out immediately rather than whenever their own
// row happens to lapse.
func (a *Auth) sessionForToken(token string) (session, bool) {
if token == "" {
return session{}, false
}
var s session
var expires, linkRevoked, linkExpires int64
err := a.db.QueryRow(`
SELECT s.user_id, s.expires, s.share_id,
COALESCE(l.revoked, 0), COALESCE(l.expires, 0), COALESCE(l.label, '')
FROM sessions s LEFT JOIN share_links l ON l.id = s.share_id
WHERE s.token = ?`, hashToken(token),
).Scan(&s.userID, &expires, &s.shareID, &linkRevoked, &linkExpires, &s.label)
now := time.Now().UnixMilli()
if err != nil || now > expires {
return session{}, false
}
if s.guest() {
if linkRevoked != 0 || now > linkExpires {
return session{}, false
}
s.expires = expires
a.touchShare(s.shareID, now)
}
return s, true
}
// touchShare records that a link was used, at lastUsedResolution granularity so
// an active guest doesn't cause a write per request.
func (a *Auth) touchShare(shareID string, now int64) {
if _, err := a.db.Exec(
`UPDATE share_links SET last_used = ? WHERE id = ? AND last_used < ?`,
now, shareID, now-lastUsedResolution.Milliseconds()); err != nil {
log.Printf("touch share %s: %v", shareID, err)
}
}
func (a *Auth) endSession(token string) {
if token == "" {
return
}
if _, err := a.db.Exec(`DELETE FROM sessions WHERE token = ?`, hashToken(token)); err != nil {
log.Printf("endSession: %v", err)
}
}
// adopt gives every ownerless row (legacy single-tenant data) to userID, and
// moves legacy flat-layout photos into that user's photo directory. Called once,
// when the very first account registers.
func (a *Auth) adopt(userID string) error {
if _, err := a.db.Exec(`UPDATE events SET user_id = ? WHERE user_id = ''`, userID); err != nil {
return err
}
if _, err := a.db.Exec(`UPDATE config SET user_id = ? WHERE user_id = ''`, userID); err != nil {
return err
}
if _, err := a.db.Exec(`UPDATE exercises SET user_id = ? WHERE user_id = ''`, userID); err != nil {
return err
}
return a.adoptPhotos(userID)
}
// adoptPhotos moves any *.jpg sitting directly in photosDir (the pre-accounts
// flat layout) into photosDir/<userID>/.
func (a *Auth) adoptPhotos(userID string) error {
entries, err := os.ReadDir(a.photosDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
dstDir := filepath.Join(a.photosDir, userID)
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jpg") {
continue
}
if err := os.MkdirAll(dstDir, 0o755); err != nil {
return err
}
if err := os.Rename(
filepath.Join(a.photosDir, e.Name()),
filepath.Join(dstDir, e.Name()),
); err != nil {
return err
}
}
return nil
}
// ---------- cookies & middleware ----------
// setCookie writes the session cookie. expires mirrors the session row's own
// end, so a guest's cookie lapses with the link rather than sitting around for
// the full 30 days pointing at a session the server already refuses.
func (a *Auth) setCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
HttpOnly: true,
Secure: a.secure,
SameSite: http.SameSiteLaxMode,
Expires: expires,
})
}
func (a *Auth) clearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
Secure: a.secure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
func cookieToken(r *http.Request) string {
c, err := r.Cookie(sessionCookie)
if err != nil {
return ""
}
return c.Value
}
// requireUser wraps a handler so it only runs for an authenticated request,
// stashing the resolved session in the context. Unauthenticated calls get a 401
// that the client uses as its cue to show the login screen.
func (a *Auth) requireUser(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, ok := a.sessionForToken(cookieToken(r))
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), sessionKey, s)))
}
}
// requireOwner is requireUser plus "and not through a guest link". It guards
// everything a temporary helper has no business touching: the profile, the
// owner's reminders, the share links themselves, and account deletion.
func (a *Auth) requireOwner(next http.HandlerFunc) http.HandlerFunc {
return a.requireUser(func(w http.ResponseWriter, r *http.Request) {
if isGuest(r) {
http.Error(w, "guest links cannot do this", http.StatusForbidden)
return
}
next(w, r)
})
}
// sessionOf returns the request's resolved session; only valid inside a
// requireUser handler.
func sessionOf(r *http.Request) session {
s, _ := r.Context().Value(sessionKey).(session)
return s
}
// userID returns the authenticated user's id — the owner's, for a guest
// session, which is what keeps all data scoping working unchanged.
func userID(r *http.Request) string { return sessionOf(r).userID }
// isGuest reports whether the request arrived through a share link.
func isGuest(r *http.Request) bool { return sessionOf(r).guest() }
// guestLabel is the share link's label, or empty for the owner. It is what gets
// stamped onto events the request creates.
func guestLabel(r *http.Request) string { return sessionOf(r).label }
// ---------- handlers ----------
type credentials struct {
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"`
}
func writeUser(w http.ResponseWriter, u User) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(u)
}
func (a *Auth) handleRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if a.inviteCode == "" {
http.Error(w, "registration disabled", http.StatusForbidden)
return
}
var c credentials
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&c); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// Constant-time compare so a wrong invite code can't be timed out.
if subtle.ConstantTimeCompare([]byte(c.Invite), []byte(a.inviteCode)) != 1 {
http.Error(w, "invalid invite code", http.StatusForbidden)
return
}
email := strings.TrimSpace(strings.ToLower(c.Email))
if !strings.Contains(email, "@") || len(email) > 200 {
http.Error(w, "invalid email", http.StatusBadRequest)
return
}
if len(c.Password) < 8 || len(c.Password) > 200 {
http.Error(w, "password must be at least 8 characters", http.StatusBadRequest)
return
}
// Whether this is the first account decides adoption of legacy data. Check
// before insert; the users table has no other writer during registration.
first, err := a.userCount()
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
}
u, err := a.createUser(email, c.Password)
if errors.Is(err, errEmailTaken) {
http.Error(w, "email already registered", http.StatusConflict)
return
}
if err != nil {
log.Printf("register: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
if first == 0 {
if err := a.adopt(u.ID); err != nil {
log.Printf("adopt legacy data: %v", err)
// Non-fatal: the account exists; legacy data just stays ownerless.
}
}
a.issue(w, u)
}
func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var c credentials
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&c); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
u, ok := a.verify(strings.TrimSpace(strings.ToLower(c.Email)), c.Password)
if !ok {
http.Error(w, "invalid email or password", http.StatusUnauthorized)
return
}
a.issue(w, u)
}
// issue starts an owner session, sets the cookie, and returns the user.
func (a *Auth) issue(w http.ResponseWriter, u User) {
expires := time.Now().Add(sessionValidity)
token, err := a.startSession(u.ID, "", expires.UnixMilli())
if err != nil {
log.Printf("start session: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
a.setCookie(w, token, expires)
u.Role = "owner"
writeUser(w, u)
}
func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
a.endSession(cookieToken(r))
a.clearCookie(w)
w.WriteHeader(http.StatusNoContent)
}
// handleMe reports the current account, and which role the caller holds over
// it. Wrapped in requireUser, so reaching it means the session is valid. The id
// is the owner's either way — it is what the client namespaces its local cache
// by — but a guest is told so, and never told whose account this is.
func (a *Auth) handleMe(w http.ResponseWriter, r *http.Request) {
var u User
err := a.db.QueryRow(
`SELECT id, email FROM users WHERE id = ?`, userID(r),
).Scan(&u.ID, &u.Email)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if s := sessionOf(r); s.guest() {
u.Email = ""
u.Role = "guest"
u.Label = s.label
u.ShareID = s.shareID
u.Expires = s.expires
} else {
u.Role = "owner"
}
writeUser(w, u)
}
// checkPassword reports whether password matches the stored hash for userID.
func (a *Auth) checkPassword(userID, password string) bool {
var hash string
if err := a.db.QueryRow(`SELECT password FROM users WHERE id = ?`, userID).Scan(&hash); err != nil {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// deleteAccount removes a user and everything owned by them: events, profile,
// reminders, push subscriptions, share links, sessions, the user row, and their
// photo directory. The table wipes run in one transaction; photos are best-effort
// afterwards (orphaned files are harmless).
func (a *Auth) deleteAccount(userID string) error {
tx, err := a.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, q := range []string{
`DELETE FROM events WHERE user_id = ?`,
`DELETE FROM exercises WHERE user_id = ?`,
`DELETE FROM config WHERE user_id = ?`,
`DELETE FROM push_subscriptions WHERE user_id = ?`,
`DELETE FROM reminders WHERE user_id = ?`,
`DELETE FROM share_links WHERE user_id = ?`,
`DELETE FROM sessions WHERE user_id = ?`,
`DELETE FROM users WHERE id = ?`,
} {
if _, err := tx.Exec(q, userID); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
if err := os.RemoveAll(filepath.Join(a.photosDir, userID)); err != nil {
log.Printf("delete photos for %s: %v", userID, err)
}
return nil
}
// handleDeleteAccount deletes the caller's own account after re-checking their
// password (guards against an unattended session). Wrapped in requireUser.
func (a *Auth) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
var c credentials
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&c); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
uid := userID(r)
if !a.checkPassword(uid, c.Password) {
http.Error(w, "invalid password", http.StatusUnauthorized)
return
}
if err := a.deleteAccount(uid); err != nil {
log.Printf("delete account %s: %v", uid, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
a.clearCookie(w)
w.WriteHeader(http.StatusNoContent)
}
// ---------- guest links ----------
//
// A guest link lets the owner hand someone (a dog sitter, family for a weekend)
// the ability to log events without handing over their password. Redeeming one
// mints an ordinary session row against the *owner's* user_id, tagged with the
// link it came from — so every data path downstream (sync, photos, config) keeps
// working untouched, and only the capability checks differ by role.
//
// The token is kept, not just its hash, so Settings can show the URL again
// whenever the owner wants to re-send it. That is a deliberate trade the way it
// would not be for a password or a session token: a guest link grants a subset
// of what the same database already holds in plaintext, so whoever can read
// puppy.db gains very little from it, and the link expires and can be revoked
// besides. The `token` column stays a hash and remains the lookup key; `secret`
// is the copy handed back to the owner.
// ShareLink is the public shape of a guest link. Token carries the URL's secret
// and comes back on every listing, so the owner can copy the link again rather
// than having one chance at it when it is created.
type ShareLink struct {
ID string `json:"id"`
Label string `json:"label"`
Created int64 `json:"created"`
Expires int64 `json:"expires"`
LastUsed int64 `json:"lastUsed,omitempty"`
Token string `json:"token,omitempty"`
}
// createShare mints a link that stops working at expires (Unix milliseconds)
// and returns it with its one-time raw token attached.
func (a *Auth) createShare(userID, label string, expires int64) (ShareLink, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return ShareLink{}, err
}
token := hex.EncodeToString(raw)
now := time.Now()
link := ShareLink{
ID: newID(),
Label: label,
Created: now.UnixMilli(),
Expires: expires,
Token: token,
}
_, err := a.db.Exec(
`INSERT INTO share_links (id, user_id, token, secret, label, created, expires) VALUES (?, ?, ?, ?, ?, ?, ?)`,
link.ID, userID, hashToken(token), token, link.Label, link.Created, link.Expires)
if err != nil {
return ShareLink{}, err
}
return link, nil
}
// listShares returns the account's links that are still usable, each with its
// URL secret so Settings can offer the link for copying at any time. Revoked and
// lapsed ones are of no interest to the UI — the point of the list is "who can
// get in right now". A link created before secrets were kept comes back with an
// empty Token; the UI says so rather than showing a broken URL.
func (a *Auth) listShares(userID string) ([]ShareLink, error) {
rows, err := a.db.Query(`
SELECT id, label, created, expires, last_used, secret
FROM share_links
WHERE user_id = ? AND revoked = 0 AND expires > ?
ORDER BY created DESC`, userID, time.Now().UnixMilli())
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]ShareLink, 0)
for rows.Next() {
var l ShareLink
if err := rows.Scan(&l.ID, &l.Label, &l.Created, &l.Expires, &l.LastUsed, &l.Token); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
// revokeShare kills a link and every session already minted from it. The
// user_id guard means one account can never revoke another's link.
func (a *Auth) revokeShare(userID, id string) error {
res, err := a.db.Exec(
`UPDATE share_links SET revoked = 1 WHERE id = ? AND user_id = ?`, id, userID)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err == nil && n == 0 {
return sql.ErrNoRows
}
// sessionForToken would reject these anyway, on the revoked flag; dropping
// the rows means a revoked link leaves nothing behind either way.
_, err = a.db.Exec(`DELETE FROM sessions WHERE share_id = ?`, id)
return err
}
// redeemShare exchanges a raw token for a session on the owner's account. The
// session is capped at the link's own expiry, so it cannot outlive it.
func (a *Auth) redeemShare(token string) (raw string, sessionEnd int64, ok bool) {
if token == "" {
return "", 0, false
}
var id, ownerID string
var expires, revoked int64
err := a.db.QueryRow(
`SELECT id, user_id, expires, revoked FROM share_links WHERE token = ?`, hashToken(token),
).Scan(&id, &ownerID, &expires, &revoked)
now := time.Now()
if err != nil || revoked != 0 || now.UnixMilli() > expires {
return "", 0, false
}
sessionEnd = now.Add(sessionValidity).UnixMilli()
if expires < sessionEnd {
sessionEnd = expires
}
raw, err = a.startSession(ownerID, id, sessionEnd)
if err != nil {
log.Printf("redeem share %s: %v", id, err)
return "", 0, false
}
if _, err := a.db.Exec(`UPDATE share_links SET last_used = ? WHERE id = ?`, now.UnixMilli(), id); err != nil {
log.Printf("stamp share %s: %v", id, err)
}
return raw, sessionEnd, true
}
type shareRequest struct {
Label string `json:"label"`
// Expires is when the link should stop working, in Unix milliseconds. The
// client computes it from the date the owner picked — end of that day in
// their own timezone, which is the only place that timezone is known.
Expires int64 `json:"expires"`
}
// handleShares lists (GET) and creates (POST) guest links. Wrapped in
// requireOwner: a guest cannot see, mint or extend links.
func (a *Auth) handleShares(w http.ResponseWriter, r *http.Request) {
writeJSON := func(v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(v)
}
switch r.Method {
case http.MethodGet:
links, err := a.listShares(userID(r))
if err != nil {
log.Printf("list shares: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
writeJSON(map[string]any{"links": links})
case http.MethodPost:
var req shareRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
label := strings.TrimSpace(req.Label)
if len(label) > 40 {
label = label[:40]
}
if label == "" {
label = "Guest"
}
now := time.Now()
if req.Expires <= now.UnixMilli() {
http.Error(w, "pick a date in the future", http.StatusBadRequest)
return
}
if req.Expires > now.Add(maxShareAhead).UnixMilli() {
http.Error(w, "that date is too far off", http.StatusBadRequest)
return
}
link, err := a.createShare(userID(r), label, req.Expires)
if err != nil {
log.Printf("create share: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
writeJSON(link)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// handleShare revokes one link: DELETE /api/shares/<id>. Wrapped in
// requireOwner.
func (a *Auth) handleShare(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/shares/")
if id == "" || strings.Contains(id, "/") {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
if err := a.revokeShare(userID(r), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "no such link", http.StatusNotFound)
return
}
log.Printf("revoke share %s: %v", id, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRedeem is what a guest link actually points at: GET /guest/<token>.
// A plain navigation so tapping the link in a message just works — it sets the
// session cookie and bounces to the app, which keeps the token out of the
// address bar, out of bookmarks and out of the PWA's start URL. SameSite=Lax
// permits the cookie on a top-level GET like this one.
func (a *Auth) handleRedeem(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
token := strings.TrimPrefix(r.URL.Path, "/guest/")
raw, expires, ok := a.redeemShare(token)
if !ok {
// Nothing usable — send them to the app with a marker it renders as
// "this link has ended" rather than a login form they can't fill in.
http.Redirect(w, r, "/?guest=expired", http.StatusSeeOther)
return
}
a.setCookie(w, raw, time.UnixMilli(expires))
http.Redirect(w, r, "/", http.StatusSeeOther)
}