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.
This commit is contained in:
Alexander Heldt
2026-09-07 11:19:20 +00:00
parent 103a5f9937
commit e22031ed4f
9 changed files with 1698 additions and 110 deletions
+355 -34
View File
@@ -23,20 +23,50 @@ import (
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 userIDKey ctxKey = 0
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
@@ -121,8 +151,10 @@ func (a *Auth) verify(email, password string) (User, bool) {
}
// startSession mints a token, stores its hash, and returns the raw token for
// the cookie.
func (a *Auth) startSession(userID string) (string, error) {
// 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
@@ -130,28 +162,53 @@ func (a *Auth) startSession(userID string) (string, error) {
token := hex.EncodeToString(raw)
now := time.Now()
_, err := a.db.Exec(
`INSERT INTO sessions (token, user_id, created, expires) VALUES (?, ?, ?, ?)`,
hashToken(token), userID, now.UnixMilli(), now.Add(sessionValidity).UnixMilli())
`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
}
// userForToken resolves a raw cookie token to a user id, honouring expiry.
func (a *Auth) userForToken(token string) (string, bool) {
// 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 "", false
return session{}, false
}
var userID string
var expires int64
err := a.db.QueryRow(
`SELECT user_id, expires FROM sessions WHERE token = ?`, hashToken(token),
).Scan(&userID, &expires)
if err != nil || time.Now().UnixMilli() > expires {
return "", 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)
}
return userID, true
}
func (a *Auth) endSession(token string) {
@@ -209,7 +266,10 @@ func (a *Auth) adoptPhotos(userID string) error {
// ---------- cookies & middleware ----------
func (a *Auth) setCookie(w http.ResponseWriter, token string) {
// 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,
@@ -217,7 +277,7 @@ func (a *Auth) setCookie(w http.ResponseWriter, token string) {
HttpOnly: true,
Secure: a.secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionValidity),
Expires: expires,
})
}
@@ -242,26 +302,50 @@ func cookieToken(r *http.Request) string {
}
// requireUser wraps a handler so it only runs for an authenticated request,
// stashing the user id in the context. Unauthenticated calls get a 401 that the
// client uses as its cue to show the login screen.
// 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) {
userID, ok := a.userForToken(cookieToken(r))
s, ok := a.sessionForToken(cookieToken(r))
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userIDKey, userID)))
next(w, r.WithContext(context.WithValue(r.Context(), sessionKey, s)))
}
}
// userID returns the authenticated user's id; only valid inside a requireUser
// handler.
func userID(r *http.Request) string {
id, _ := r.Context().Value(userIDKey).(string)
return id
// 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 {
@@ -349,15 +433,17 @@ func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) {
a.issue(w, u)
}
// issue starts a session, sets the cookie, and returns the user.
// issue starts an owner session, sets the cookie, and returns the user.
func (a *Auth) issue(w http.ResponseWriter, u User) {
token, err := a.startSession(u.ID)
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)
a.setCookie(w, token, expires)
u.Role = "owner"
writeUser(w, u)
}
@@ -371,8 +457,10 @@ func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleMe reports the current account. Wrapped in requireUser, so reaching it
// means the session is valid.
// 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(
@@ -382,6 +470,15 @@ func (a *Auth) handleMe(w http.ResponseWriter, r *http.Request) {
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)
}
@@ -395,8 +492,8 @@ func (a *Auth) checkPassword(userID, password string) bool {
}
// deleteAccount removes a user and everything owned by them: events, profile,
// reminders, push subscriptions, sessions, the user row, and their photo
// directory. The table wipes run in one transaction; photos are best-effort
// 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()
@@ -410,6 +507,7 @@ func (a *Auth) deleteAccount(userID string) error {
`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 = ?`,
} {
@@ -447,3 +545,226 @@ func (a *Auth) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
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)
}
+647
View File
@@ -0,0 +1,647 @@
package main
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
func testAuth(t *testing.T) *Auth {
t.Helper()
dir := t.TempDir()
db, err := openDB(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return newAuth(db, "letmein", false, filepath.Join(dir, "photos"))
}
// testOwner registers an account and returns its id.
func testOwner(t *testing.T, a *Auth) string {
t.Helper()
u, err := a.createUser("owner@example.com", "hunter2hunter2")
if err != nil {
t.Fatalf("create user: %v", err)
}
return u.ID
}
// hoursAhead is an expiry that many hours from now, in Unix milliseconds —
// what the client sends after the owner picks a date.
func hoursAhead(h int) int64 {
return time.Now().Add(time.Duration(h) * time.Hour).UnixMilli()
}
// guestToken mints a link and redeems it, returning the raw session token a
// guest's cookie would carry.
func guestToken(t *testing.T, a *Auth, ownerID, label string, hours int) string {
t.Helper()
link, err := a.createShare(ownerID, label, hoursAhead(hours))
if err != nil {
t.Fatalf("create share: %v", err)
}
raw, _, ok := a.redeemShare(link.Token)
if !ok {
t.Fatal("redeem: fresh link was rejected")
}
return raw
}
// request builds a request carrying the given session cookie.
func request(method, path, cookie, body string) *http.Request {
r := httptest.NewRequest(method, path, strings.NewReader(body))
if cookie != "" {
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: cookie})
}
return r
}
func TestRedeemedLinkActsAsTheOwner(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
token := guestToken(t, a, ownerID, "Anna", 24)
s, ok := a.sessionForToken(token)
if !ok {
t.Fatal("session for a fresh guest token was rejected")
}
if s.userID != ownerID {
t.Errorf("guest session scoped to %q, want the owner %q", s.userID, ownerID)
}
if !s.guest() {
t.Error("session from a share link does not report itself as a guest")
}
if s.label != "Anna" {
t.Errorf("label = %q, want %q", s.label, "Anna")
}
}
func TestOwnerSessionIsNotAGuest(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
raw, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli())
if err != nil {
t.Fatalf("start session: %v", err)
}
s, ok := a.sessionForToken(raw)
if !ok {
t.Fatal("owner session was rejected")
}
if s.guest() || s.label != "" {
t.Errorf("owner session reports guest=%v label=%q, want false/empty", s.guest(), s.label)
}
}
func TestUnusableTokensAreRejected(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
if _, _, ok := a.redeemShare("not-a-real-token"); ok {
t.Error("a garbage token was redeemed")
}
if _, _, ok := a.redeemShare(""); ok {
t.Error("an empty token was redeemed")
}
// An expired link: backdate it past its own end.
link, err := a.createShare(ownerID, "Stale", hoursAhead(12))
if err != nil {
t.Fatalf("create share: %v", err)
}
if _, err := a.db.Exec(
`UPDATE share_links SET expires = ? WHERE id = ?`,
time.Now().Add(-time.Minute).UnixMilli(), link.ID); err != nil {
t.Fatalf("backdate: %v", err)
}
if _, _, ok := a.redeemShare(link.Token); ok {
t.Error("an expired link was redeemed")
}
// A revoked link.
revoked, err := a.createShare(ownerID, "Revoked", hoursAhead(12))
if err != nil {
t.Fatalf("create share: %v", err)
}
if err := a.revokeShare(ownerID, revoked.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, _, ok := a.redeemShare(revoked.Token); ok {
t.Error("a revoked link was redeemed")
}
}
// Revocation has to bite on the next request, not whenever the guest's own
// session row happens to lapse — that is the whole point of being able to
// revoke.
func TestRevokingKillsLiveSessions(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
token, _, ok := a.redeemShare(link.Token)
if !ok {
t.Fatal("redeem: fresh link was rejected")
}
if _, ok := a.sessionForToken(token); !ok {
t.Fatal("session invalid before revoking")
}
if err := a.revokeShare(ownerID, link.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, ok := a.sessionForToken(token); ok {
t.Error("session still valid after its link was revoked")
}
}
// A guest session must never outlive its link, however long the default
// session validity is.
func TestGuestSessionIsCappedAtTheLinkExpiry(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(12))
if err != nil {
t.Fatalf("create share: %v", err)
}
_, sessionEnd, ok := a.redeemShare(link.Token)
if !ok {
t.Fatal("redeem: fresh link was rejected")
}
if sessionEnd != link.Expires {
t.Errorf("session ends at %d, want the link's own %d", sessionEnd, link.Expires)
}
}
func TestRevokeIsScopedToTheOwner(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
other, err := a.createUser("other@example.com", "hunter2hunter2")
if err != nil {
t.Fatalf("create user: %v", err)
}
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
if err := a.revokeShare(other.ID, link.ID); err == nil {
t.Error("another account revoked a link it does not own")
}
if _, _, ok := a.redeemShare(link.Token); !ok {
t.Error("link was revoked by an account that does not own it")
}
}
func TestListSharesHidesRevokedAndExpired(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
live, err := a.createShare(ownerID, "Live", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
gone, err := a.createShare(ownerID, "Gone", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
if err := a.revokeShare(ownerID, gone.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
stale, err := a.createShare(ownerID, "Stale", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
if _, err := a.db.Exec(
`UPDATE share_links SET expires = ? WHERE id = ?`,
time.Now().Add(-time.Minute).UnixMilli(), stale.ID); err != nil {
t.Fatalf("backdate: %v", err)
}
links, err := a.listShares(ownerID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(links) != 1 || links[0].ID != live.ID {
t.Fatalf("listed %d link(s), want only the live one", len(links))
}
if links[0].Token != "" {
t.Error("listing leaked a raw token")
}
}
// The raw token is only ever handed back once, at creation.
func TestOnlyTheTokenHashIsStored(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
var stored string
if err := a.db.QueryRow(`SELECT token FROM share_links WHERE id = ?`, link.ID).Scan(&stored); err != nil {
t.Fatalf("read back: %v", err)
}
if stored == link.Token {
t.Error("the raw token is stored in the clear")
}
if stored != hashToken(link.Token) {
t.Error("stored token is not the hash of the issued one")
}
}
func TestDeletingAnAccountDropsItsLinks(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
if err := a.deleteAccount(ownerID); err != nil {
t.Fatalf("delete account: %v", err)
}
var n int
if err := a.db.QueryRow(`SELECT COUNT(*) FROM share_links WHERE id = ?`, link.ID).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 0 {
t.Error("the deleted account's guest links survived")
}
}
// ---------- capability gating ----------
func TestRequireOwnerBlocksGuests(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
guest := guestToken(t, a, ownerID, "Anna", 24)
owner, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli())
if err != nil {
t.Fatalf("start session: %v", err)
}
reached := false
h := a.requireOwner(func(w http.ResponseWriter, r *http.Request) { reached = true })
w := httptest.NewRecorder()
h(w, request(http.MethodPost, "/api/shares", guest, "{}"))
if w.Code != http.StatusForbidden {
t.Errorf("guest got %d, want %d", w.Code, http.StatusForbidden)
}
if reached {
t.Error("the guarded handler ran for a guest")
}
w = httptest.NewRecorder()
h(w, request(http.MethodPost, "/api/shares", owner, "{}"))
if w.Code != http.StatusOK || !reached {
t.Errorf("owner got %d and reached=%v, want 200 and true", w.Code, reached)
}
}
// A guest must still be able to do the thing the link exists for.
func TestRequireUserAllowsGuests(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
guest := guestToken(t, a, ownerID, "Anna", 24)
var sawUser, sawLabel string
h := a.requireUser(func(w http.ResponseWriter, r *http.Request) {
sawUser, sawLabel = userID(r), guestLabel(r)
})
w := httptest.NewRecorder()
h(w, request(http.MethodPost, "/api/events/sync", guest, "{}"))
if w.Code != http.StatusOK {
t.Fatalf("guest got %d on a shared route, want 200", w.Code)
}
if sawUser != ownerID {
t.Errorf("handler saw user %q, want the owner %q", sawUser, ownerID)
}
if sawLabel != "Anna" {
t.Errorf("handler saw label %q, want %q", sawLabel, "Anna")
}
}
func TestHandleMeHidesTheOwnerFromAGuest(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
guest := guestToken(t, a, ownerID, "Anna", 24)
w := httptest.NewRecorder()
a.requireUser(a.handleMe)(w, request(http.MethodGet, "/api/me", guest, ""))
if w.Code != http.StatusOK {
t.Fatalf("got %d, want 200", w.Code)
}
var u User
if err := json.NewDecoder(w.Body).Decode(&u); err != nil {
t.Fatalf("decode: %v", err)
}
if u.Email != "" {
t.Errorf("a guest was told the owner's email (%q)", u.Email)
}
if u.Role != "guest" || u.Label != "Anna" {
t.Errorf("role/label = %q/%q, want guest/Anna", u.Role, u.Label)
}
if u.ID != ownerID {
t.Errorf("id = %q, want the owner's %q so the client scopes its cache right", u.ID, ownerID)
}
}
// ---------- attribution ----------
func loggedBy(t *testing.T, db *sql.DB, id string) string {
t.Helper()
var by string
if err := db.QueryRow(`SELECT logged_by FROM events WHERE id = ?`, id).Scan(&by); err != nil {
t.Fatalf("read logged_by: %v", err)
}
return by
}
func TestAttributionIsStampedFromTheSession(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000},
}); err != nil {
t.Fatalf("guest sync: %v", err)
}
if got := loggedBy(t, a.db, "e1"); got != "Anna" {
t.Errorf("logged_by = %q, want %q", got, "Anna")
}
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e2", Type: "poo", At: 2000, UpdatedAt: 2000},
}); err != nil {
t.Fatalf("owner sync: %v", err)
}
if got := loggedBy(t, a.db, "e2"); got != "" {
t.Errorf("the owner's own event was attributed to %q", got)
}
}
// Attribution is decided once, by whoever logged the event. A later edit —
// by the owner or by another guest — must not rewrite it.
func TestAttributionSurvivesLaterEdits(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000},
}); err != nil {
t.Fatalf("guest sync: %v", err)
}
// The owner edits the note, bumping updatedAt so LWW takes the change.
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "on the walk", UpdatedAt: 2000},
}); err != nil {
t.Fatalf("owner edit: %v", err)
}
if got := loggedBy(t, a.db, "e1"); got != "Anna" {
t.Errorf("logged_by = %q after an owner edit, want it to stay %q", got, "Anna")
}
// And the guest re-POSTing their own event leaves it alone too.
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "on the walk", UpdatedAt: 3000},
}); err != nil {
t.Fatalf("guest re-sync: %v", err)
}
if got := loggedBy(t, a.db, "e1"); got != "Anna" {
t.Errorf("logged_by = %q after the guest re-synced, want %q", got, "Anna")
}
}
// The value never comes off the wire, so a client cannot claim to be someone
// else — or launder its own events into looking like the owner's.
func TestAttributionCannotBeSetByTheClient(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000, LoggedBy: ""},
{ID: "e2", Type: "poo", At: 2000, UpdatedAt: 2000, LoggedBy: "The Owner", LoggedByShare: "s-other"},
})
if err != nil {
t.Fatalf("sync: %v", err)
}
for _, id := range []string{"e1", "e2"} {
if got := loggedBy(t, a.db, id); got != "Anna" {
t.Errorf("%s: logged_by = %q, want the session's %q", id, got, "Anna")
}
}
// And the server's own answer carries the stamp back, so the client can
// render the badge without having to guess.
for _, e := range merged {
if e.LoggedBy != "Anna" {
t.Errorf("%s came back as %q, want %q", e.ID, e.LoggedBy, "Anna")
}
if e.LoggedByShare != "s1" {
t.Errorf("%s came back from link %q, want %q", e.ID, e.LoggedByShare, "s1")
}
}
}
// ---------- what a guest is allowed to change ----------
// eventNote reads back one event's note and tombstone flag — enough to tell
// whether an attempted edit or delete actually landed.
func eventState(t *testing.T, db *sql.DB, id string) (note string, deleted bool) {
t.Helper()
if err := db.QueryRow(`SELECT note, deleted FROM events WHERE id = ?`, id).Scan(&note, &deleted); err != nil {
t.Fatalf("read event %s: %v", id, err)
}
return note, deleted
}
// The point of the whole guard: a sitter must not be able to rewrite or delete
// what the owner logged, however their client asks.
func TestGuestCannotChangeTheOwnersEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
// The owner logs something.
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "mine", UpdatedAt: 1000},
}); err != nil {
t.Fatalf("owner sync: %v", err)
}
// A guest tries to edit it, with a much newer timestamp so last-write-wins
// alone would take the change.
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "rewritten by the sitter", UpdatedAt: 9000},
})
if err != nil {
t.Fatalf("guest edit: %v", err)
}
if note, _ := eventState(t, a.db, "e1"); note != "mine" {
t.Errorf("a guest rewrote the owner's event: note = %q", note)
}
// The guest gets the stored version back, so an honest client can heal.
for _, e := range merged {
if e.ID == "e1" && e.Note != "mine" {
t.Errorf("server returned %q for the owner's event, want %q", e.Note, "mine")
}
}
// And cannot delete it either — a tombstone is just another update.
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "mine", UpdatedAt: 9001, Deleted: true},
}); err != nil {
t.Fatalf("guest delete: %v", err)
}
if _, deleted := eventState(t, a.db, "e1"); deleted {
t.Error("a guest deleted the owner's event")
}
}
func TestGuestCanChangeItsOwnEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "oops", UpdatedAt: 1000},
}); err != nil {
t.Fatalf("guest sync: %v", err)
}
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "fixed", UpdatedAt: 2000},
}); err != nil {
t.Fatalf("guest edit: %v", err)
}
if note, _ := eventState(t, a.db, "e1"); note != "fixed" {
t.Errorf("a guest could not fix up their own entry: note = %q", note)
}
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "fixed", UpdatedAt: 3000, Deleted: true},
}); err != nil {
t.Fatalf("guest delete: %v", err)
}
if _, deleted := eventState(t, a.db, "e1"); !deleted {
t.Error("a guest could not delete their own entry")
}
}
// Two links can carry the same label ("Sitter"), so the id — not the label —
// has to be what authorises the change.
func TestGuestCannotChangeAnotherLinksEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Sitter", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "anna's", UpdatedAt: 1000},
}); err != nil {
t.Fatalf("first guest sync: %v", err)
}
// Same label, different link.
if _, err := store.sync(ownerID, "Sitter", "s2", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "bob's", UpdatedAt: 2000},
}); err != nil {
t.Fatalf("second guest sync: %v", err)
}
if note, _ := eventState(t, a.db, "e1"); note != "anna's" {
t.Errorf("one link's guest edited another's event: note = %q", note)
}
}
// The owner keeps full control of everything on their account, including what
// a guest logged.
func TestOwnerCanChangeAGuestsEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "sitter's", UpdatedAt: 1000},
}); err != nil {
t.Fatalf("guest sync: %v", err)
}
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "corrected", UpdatedAt: 2000},
}); err != nil {
t.Fatalf("owner edit: %v", err)
}
if note, _ := eventState(t, a.db, "e1"); note != "corrected" {
t.Errorf("the owner could not edit a guest's event: note = %q", note)
}
// And delete it. A tombstone is just another update, so this rides the same
// clause — but it is the half that matters if a sitter logs something wrong
// and the owner wants it gone rather than fixed.
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "corrected", UpdatedAt: 3000, Deleted: true},
}); err != nil {
t.Fatalf("owner delete: %v", err)
}
if _, deleted := eventState(t, a.db, "e1"); !deleted {
t.Error("the owner could not delete a guest's event")
}
}
// Guests still log freely — the guard is on changing what already exists.
func TestGuestCanStillAddEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000},
})
if err != nil {
t.Fatalf("guest sync: %v", err)
}
if len(merged) != 1 || merged[0].ID != "e1" {
t.Fatalf("guest's new event did not land: %+v", merged)
}
}
// ---------- link expiry ----------
func TestShareExpiryIsWhateverWasAskedFor(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
want := hoursAhead(53) // an odd span no fixed duration would produce
link, err := a.createShare(ownerID, "Anna", want)
if err != nil {
t.Fatalf("create share: %v", err)
}
if link.Expires != want {
t.Errorf("link expires at %d, want the requested %d", link.Expires, want)
}
}
func TestCreateShareRejectsBadDates(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
owner, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli())
if err != nil {
t.Fatalf("start session: %v", err)
}
for _, tc := range []struct {
name string
expires int64
}{
{"in the past", time.Now().Add(-time.Hour).UnixMilli()},
{"missing", 0},
{"absurdly far off", time.Now().Add(5 * 365 * 24 * time.Hour).UnixMilli()},
} {
body := `{"label":"Anna","expires":` + strconv.FormatInt(tc.expires, 10) + `}`
w := httptest.NewRecorder()
a.requireOwner(a.handleShares)(w, request(http.MethodPost, "/api/shares", owner, body))
if w.Code != http.StatusBadRequest {
t.Errorf("%s: got %d, want %d", tc.name, w.Code, http.StatusBadRequest)
}
}
}
+136 -35
View File
@@ -34,6 +34,15 @@ type Event struct {
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
// LoggedBy names the guest link an event was logged through, empty for the
// owner's own. It is stamped by the server from the session (see Store.sync)
// and never read off the wire, so a client can neither forge nor rewrite it.
LoggedBy string `json:"loggedBy,omitempty"`
// LoggedByShare is that link's id. LoggedBy is a label the owner typed and
// two links may well share one ("Sitter"), so the id — not the label — is
// what decides whether a guest may change this event. Sent to the client so
// it can grey out what it isn't allowed to touch; opaque and harmless.
LoggedByShare string `json:"loggedByShare,omitempty"`
}
// Exercise is a user-defined training exercise (e.g. "Sit", "Leash walking"):
@@ -131,8 +140,11 @@ func newStore(db *sql.DB) *Store {
// sync merges one user's client events into the store using last-write-wins by
// UpdatedAt, then returns that user's full merged set (tombstones included, as
// they must propagate).
func (s *Store) sync(userID string, client []Event) ([]Event, error) {
// they must propagate). loggedBy/shareID describe the caller's session — the
// guest link's label and id, both empty for the owner. They are stamped onto
// events this call inserts, and shareID additionally decides which existing
// events the caller is allowed to change.
func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
@@ -141,19 +153,34 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
// The WHERE clause on the upsert is the last-write-wins rule: an incoming
// event only overwrites the stored one when its updatedAt is strictly newer.
// The `events.user_id = excluded.user_id` guard means one user can never
// clobber another's row even if a client forges a colliding event ID —
// the row stays put and, because reads are scoped, stays invisible to them.
// Two further guards ride on it:
//
// - events.user_id = excluded.user_id — one user can never clobber
// another's row even if a client forges a colliding event ID; the row
// stays put and, because reads are scoped, stays invisible to them.
// - the logged_by_share clause — an owner (excluded.logged_by_share = '')
// may change anything; a guest may only change events logged through
// their own link. So a sitter can fix up their own entries, and cannot
// edit or delete a single one of the owner's. A rejected row simply
// stays as it was, and the caller gets the stored version back.
//
// The two attribution columns are deliberately absent from the DO UPDATE SET
// list: attribution is decided once, by whoever first inserted the event, and
// a later edit by anyone leaves it alone. That is also what makes it
// unspoofable — a guest re-POSTs the owner's whole event list on every sync,
// but those rows already exist and so keep their stored values.
stmt, err := tx.Prepare(`
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id, logged_by, logged_by_share)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type, at = excluded.at, note = excluded.note,
photo_id = excluded.photo_id, weight = excluded.weight,
grams = excluded.grams, exercise_id = excluded.exercise_id,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > events.updated
AND events.user_id = excluded.user_id`)
AND events.user_id = excluded.user_id
AND (excluded.logged_by_share = ''
OR events.logged_by_share = excluded.logged_by_share)`)
if err != nil {
return nil, err
}
@@ -164,7 +191,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
continue
}
if _, err := stmt.Exec(
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID,
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID, loggedBy, shareID,
); err != nil {
return nil, err
}
@@ -178,7 +205,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
// all returns one user's events, tombstones included.
func (s *Store) all(userID string) ([]Event, error) {
rows, err := s.db.Query(
`SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted
`SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, logged_by, logged_by_share
FROM events WHERE user_id = ?`, userID)
if err != nil {
return nil, err
@@ -188,7 +215,7 @@ func (s *Store) all(userID string) ([]Event, error) {
for rows.Next() {
var e Event
if err := rows.Scan(
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted,
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted, &e.LoggedBy, &e.LoggedByShare,
); err != nil {
return nil, err
}
@@ -289,17 +316,19 @@ func openDB(path string) (*sql.DB, error) {
// the first account adopts it (see Auth.adopt).
schema := `
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT '',
at INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
photo_id TEXT NOT NULL DEFAULT '',
weight REAL NOT NULL DEFAULT 0,
grams REAL NOT NULL DEFAULT 0,
exercise_id TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT ''
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT '',
at INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
photo_id TEXT NOT NULL DEFAULT '',
weight REAL NOT NULL DEFAULT 0,
grams REAL NOT NULL DEFAULT 0,
exercise_id TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT '',
logged_by TEXT NOT NULL DEFAULT '',
logged_by_share TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
CREATE TABLE IF NOT EXISTS exercises (
@@ -325,11 +354,23 @@ func openDB(path string) (*sql.DB, error) {
created INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created INTEGER NOT NULL,
expires INTEGER NOT NULL
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created INTEGER NOT NULL,
expires INTEGER NOT NULL,
share_id TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS share_links (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
label TEXT NOT NULL DEFAULT '',
created INTEGER NOT NULL,
expires INTEGER NOT NULL,
last_used INTEGER NOT NULL DEFAULT 0,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_share_links_user ON share_links(user_id);
CREATE TABLE IF NOT EXISTS pedigree_cache (
hundid TEXT PRIMARY KEY,
subject TEXT NOT NULL DEFAULT '',
@@ -401,6 +442,36 @@ func migrateSchema(db *sql.DB) error {
return err
}
}
// Guest links (see Auth.createShare). Every column defaults to the empty
// string, which is exactly what pre-guest-link rows mean: an event nobody
// but the owner logged, and a session that isn't a guest's.
hasLoggedBy, err := columnExists(db, "events", "logged_by")
if err != nil {
return err
}
if !hasLoggedBy {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN logged_by TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
hasLoggedByShare, err := columnExists(db, "events", "logged_by_share")
if err != nil {
return err
}
if !hasLoggedByShare {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN logged_by_share TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
hasShareID, err := columnExists(db, "sessions", "share_id")
if err != nil {
return err
}
if !hasShareID {
if _, err := db.Exec(`ALTER TABLE sessions ADD COLUMN share_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
oldConfig, err := columnExists(db, "config", "id")
if err != nil {
return err
@@ -494,7 +565,7 @@ func importEvents(db *sql.DB, path string) error {
// Imported as ownerless (user_id = ""); the first account to register adopts
// them. Mirrors how in-place schema migration parks legacy rows.
store := newStore(db)
if _, err := store.sync("", evs); err != nil {
if _, err := store.sync("", "", "", evs); err != nil {
return err
}
log.Printf("migrated %d events from %s", len(evs), path)
@@ -686,12 +757,24 @@ func main() {
case http.MethodGet:
auth.handleMe(w, r)
case http.MethodDelete:
// Deleting the account is the owner's alone, so this arm — and only
// this arm — is gated; a guest still needs the GET to learn its role.
if isGuest(r) {
http.Error(w, "guest links cannot do this", http.StatusForbidden)
return
}
auth.handleDeleteAccount(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}))
// Guest links: minting, listing and revoking are the owner's, redeeming is
// the unauthenticated entry point the link itself points at.
mux.HandleFunc("/api/shares", auth.requireOwner(auth.handleShares))
mux.HandleFunc("/api/shares/", auth.requireOwner(auth.handleShare))
mux.HandleFunc("/guest/", auth.handleRedeem)
mux.HandleFunc("/api/events/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -702,7 +785,7 @@ func main() {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
merged, err := store.sync(userID(r), req.Events)
merged, err := store.sync(userID(r), guestLabel(r), sessionOf(r).shareID, req.Events)
if err != nil {
log.Printf("sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
@@ -728,7 +811,16 @@ func main() {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
merged, err := exerciseStore.sync(userID(r), req.Exercises)
// Exercises are the owner's library, not a log: a guest logs training
// sessions against them (ordinary events) but does not get to rename or
// delete them. Dropping the incoming list makes this direction-only —
// the guest still receives the full set back. The client hides the
// editing UI to match; this is the part that enforces it.
incoming := req.Exercises
if isGuest(r) {
incoming = nil
}
merged, err := exerciseStore.sync(userID(r), incoming)
if err != nil {
log.Printf("exercises sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
@@ -751,6 +843,13 @@ func main() {
case http.MethodGet:
writeConfig(configStore.get(userID(r)))
case http.MethodPut, http.MethodPost:
// The profile (name, birthday, pedigree id) is the owner's to set.
// The GET above stays open — a guest needs the name and birthday to
// render the header at all.
if isGuest(r) {
http.Error(w, "guest links cannot do this", http.StatusForbidden)
return
}
var in Config
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
@@ -800,13 +899,15 @@ func main() {
// Push reminders. Registered only when the scheduler came up, so a server
// without a usable VAPID key 404s these rather than half-working — which is
// also what tells the client to hide the reminder UI entirely.
// also what tells the client to hide the reminder UI entirely. Owner-only:
// the reminders are the owner's own, and a guest device subscribing would
// route them to the sitter's lock screen.
if scheduler != nil {
mux.HandleFunc("/api/push/key", auth.requireUser(scheduler.handleKey))
mux.HandleFunc("/api/push/subscribe", auth.requireUser(scheduler.handleSubscribe))
mux.HandleFunc("/api/push/unsubscribe", auth.requireUser(scheduler.handleUnsubscribe))
mux.HandleFunc("/api/push/test", auth.requireUser(scheduler.handleTest))
mux.HandleFunc("/api/reminders", auth.requireUser(scheduler.handleReminders))
mux.HandleFunc("/api/push/key", auth.requireOwner(scheduler.handleKey))
mux.HandleFunc("/api/push/subscribe", auth.requireOwner(scheduler.handleSubscribe))
mux.HandleFunc("/api/push/unsubscribe", auth.requireOwner(scheduler.handleUnsubscribe))
mux.HandleFunc("/api/push/test", auth.requireOwner(scheduler.handleTest))
mux.HandleFunc("/api/reminders", auth.requireOwner(scheduler.handleReminders))
}
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {