Files
puppy-tracker/server/auth.go
T
Alexander Heldt e22031ed4f Add guest links for temporary shared access
Handing a dog sitter the ability to log a pee meant handing them the account
password: permanent, total control, revocable only by changing it. Settings →
Guest access now mints a URL that does the one thing instead.

A link is a session, not an account. Opening /guest/<token> inserts an ordinary
session row against the owner's user_id, tagged with the link it came from, so
every data path downstream — sync, photos, the profile — stays scoped by
user_id exactly as before and needed no changes at all. Only the capability
checks differ by role, which is what kept this from touching the sync contract.
Redemption is a plain GET so tapping the link in a message works, and the 303
to / leaves the token out of the address bar, bookmarks and the PWA start URL.

What a guest cannot change is enforced in the upsert, not in the UI. The WHERE
clause gains a logged_by_share test: an owner (empty share id) may change
anything, a guest only rows carrying their own link's id. A sitter can fix up
their own entries and cannot rewrite or delete one of the owner's, including
everything logged before this existed, since those rows carry the empty id too.
Deletes come along free, being tombstones. The test is on the link id rather
than its label because two links can easily both be "Sitter", and the id is
also why /api/me hands the guest its share id: the client needs it to know what
to grey out. The exercise library is the owner's on the same reasoning — a
guest trains against it but the server drops any exercise a guest sends.

Attribution is stamped from the session on insert and left out of DO UPDATE
SET, so it is decided once by whoever logged the event and survives every later
edit. It never comes off the wire, so it cannot be forged — a guest re-POSTs
the owner's whole event list on every sync, but those rows already exist and
keep their stored values.

Expiry is a date the owner picks; the link dies at the end of that day in their
own timezone, which the client computes because the server has no way to know
it. Sessions are capped at the link's own end, and every request re-checks the
link is live rather than trusting the session row, so revoking kicks a guest
out on their next request instead of whenever their session happens to lapse.
Only the token hash is stored, as with session tokens, so the URL is shown once
at creation and cannot be read back.

The client side follows from that. A guest opening someone else's entry gets
the edit dialog read-only rather than a form that would silently discard what
they typed, and mergeSynced takes the server's copy for anything they may not
change — otherwise a refused write would sit in their cache forever showing an
edit that never happened. An ended link wipes their cached copy of someone
else's history and says so, rather than offering a sign-in form they have no
password for.
2026-09-07 11:19:20 +00:00

771 lines
24 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 raw token is shown exactly once, at creation. Only its hash is stored,
// the same way session tokens are, so a leaked database yields no usable links.
// ShareLink is the public shape of a guest link. Token is set only on the
// response to the call that created it, and never stored in the clear.
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, label, created, expires) VALUES (?, ?, ?, ?, ?, ?)`,
link.ID, userID, hashToken(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. Revoked and
// lapsed ones are of no interest to the UI — the point of the list is "who can
// get in right now".
func (a *Auth) listShares(userID string) ([]ShareLink, error) {
rows, err := a.db.Query(`
SELECT id, label, created, expires, last_used
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); 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)
}