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//. 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/. 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/. // 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) }