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:
+136
-35
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user