Add accounts and multi-tenancy

Every event, profile and photo is now scoped to a signed-in account, so
separate people can track separate puppies on one server.

Server:
- users + sessions tables; bcrypt passwords; random session tokens stored
  hashed and set as an HttpOnly cookie. Middleware gates /api/* behind a
  valid session.
- register/login/logout/me endpoints. Registration requires a shared invite
  code (-invite-code / PUPPY_INVITE_CODE); empty disables it.
- events, config and photos are keyed by user_id; the sync upsert guards
  against cross-user overwrites and reads are scoped, so accounts are isolated.
  Photos live under photos/<user_id>/ and are only served to their owner.
- in-place schema migration adds user_id and reshapes config; legacy
  single-tenant data (including imported events.json) is parked ownerless and
  adopted by the first account to register.

Client:
- login/register gate in front of the app; the tracker only boots once the
  session check resolves. localStorage is namespaced per user.
- 401s bounce back to login; an offline reload falls back to the last cached
  session so offline-first still works. Logout clears the session and reloads.

Deployment:
- module.nix gains inviteCodeFile (secret via EnvironmentFile) and
  secureCookies options.

Verified end to end (curl + a headless-browser run of the auth flow):
isolation between accounts, invite enforcement, first-user adoption, photo
ownership, and session persistence across reload.
This commit is contained in:
Alexander Heldt
2026-07-09 18:20:36 +00:00
parent 9207aaa4aa
commit acf2931fb4
11 changed files with 896 additions and 90 deletions
+38 -5
View File
@@ -21,12 +21,15 @@ source-of-truth and sync between devices.
`config.json` sitting alongside it, renaming them to `*.imported`. `config.json` sitting alongside it, renaming them to `*.imported`.
- Service worker bypasses cache for `/api/*` so writes always hit the server - Service worker bypasses cache for `/api/*` so writes always hit the server
when online; static assets are still cached for offline use. when online; static assets are still cached for offline use.
- The puppy's name and birthday are a shared profile stored on the host - The puppy's name and birthday are a per-account profile stored on the host
(`GET`/`PUT /api/config`), so a new device picks them up automatically instead (`GET`/`PUT /api/config`), so a new device picks them up automatically instead
of being configured per-client. The client caches the last-seen values in of being configured per-client. The client caches the last-seen values in
`localStorage` for offline/instant paint and reconciles with the server by `localStorage` for offline/instant paint and reconciles with the server by
last-write-wins on `updatedAt`. The age shown in the header (in weeks and last-write-wins on `updatedAt`. The age shown in the header (in weeks and
months) is derived from the birthday. months) is derived from the birthday.
- All data is scoped to the signed-in account (see [Accounts](#accounts)): every
event, profile and photo carries a `user_id`, and `localStorage` is namespaced
per user so two accounts on one browser never mix.
A status pill in the header shows `syncing…` / `synced 2m ago` / `pending` / A status pill in the header shows `syncing…` / `synced 2m ago` / `pending` /
`sync error` / `offline`. Tap it to force-sync. `sync error` / `offline`. Tap it to force-sync.
@@ -40,7 +43,8 @@ puppy-tracker/
├── server/ ├── server/
│ ├── go.mod │ ├── go.mod
│ ├── go.sum │ ├── go.sum
── main.go # SQLite store, LWW sync, static file serving ── main.go # SQLite store, LWW sync, static file serving
│ └── auth.go # accounts, sessions, invite-gated registration
└── src/ # the web app └── src/ # the web app
├── index.html ├── index.html
├── app.js ├── app.js
@@ -56,10 +60,32 @@ puppy-tracker/
nix run # http://localhost:8080, data in $XDG_DATA_HOME/puppy-tracker nix run # http://localhost:8080, data in $XDG_DATA_HOME/puppy-tracker
PUPPY_ADDR=:9000 nix run # custom port PUPPY_ADDR=:9000 nix run # custom port
# Registration needs an invite code (see Accounts). Set it in the environment:
PUPPY_INVITE_CODE=letmein nix run
# Hot-iterate (data in /tmp): # Hot-iterate (data in /tmp):
nix develop -c sh -c 'cd server && go run . -static ../src -data /tmp/puppy.db' nix develop -c sh -c 'cd server && go run . -static ../src -data /tmp/puppy.db -invite-code letmein'
``` ```
## Accounts
The app is multi-tenant: each person signs in and sees only their own puppy's
events, profile and photos.
- **Sessions.** Passwords are hashed with bcrypt; login mints a random session
token stored (hashed) in the `sessions` table and set as an `HttpOnly` cookie.
`/api/*` (except `login`/`register`/`logout`) requires a valid session.
- **Registration is invite-gated.** Sign-up requires the shared secret passed via
`-invite-code` / `PUPPY_INVITE_CODE`. With no code set, registration is
disabled (existing accounts can still log in). Share the code with whoever you
want to give an account.
- **First account adopts existing data.** When accounts are introduced on a DB
that already had single-tenant data (or that imported a legacy `events.json`),
the first account to register inherits all of it — events, profile and photos.
- **Serve over HTTPS in production.** Session cookies are only marked `Secure`
when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords
aren't sent in the clear.
## Use it on NixOS ## Use it on NixOS
In your system flake: In your system flake:
@@ -78,6 +104,11 @@ In your system flake:
enable = true; enable = true;
port = 8080; port = 8080;
openFirewall = true; openFirewall = true;
# Registration secret, kept out of the Nix store. The file holds:
# PUPPY_INVITE_CODE=some-shared-secret
inviteCodeFile = "/run/secrets/puppy-invite-code";
# Enable once you terminate TLS in front of the service.
secureCookies = false;
}; };
} }
]; ];
@@ -92,5 +123,7 @@ alongside it under `photos/`).
## Notes ## Notes
- No auth. Intended for a home LAN. If exposing publicly, terminate TLS and - Accounts gate access, but there is no built-in TLS. If exposing publicly,
authenticate with a reverse proxy in front (Caddy / nginx / Tailscale Funnel). terminate TLS with a reverse proxy in front (Caddy / nginx / Tailscale Funnel)
and set `secureCookies = true`. Without HTTPS, passwords and session cookies
travel in the clear.
+1 -1
View File
@@ -26,7 +26,7 @@
pname = "puppy-tracker-server"; pname = "puppy-tracker-server";
version = "0.2.0"; version = "0.2.0";
src = ./server; src = ./server;
vendorHash = "sha256-fqXpr9fV1jeT7503uAjb4jjNPlXfESFnXr7/Uc83d4o="; vendorHash = "sha256-z9Kf7i4WfLAHmceRi8T42+uMitjxEzr0pmOn+STpsAU=";
# Pure-Go build for a tiny static binary. # Pure-Go build for a tiny static binary.
env.CGO_ENABLED = "0"; env.CGO_ENABLED = "0";
ldflags = [ "-s" "-w" ]; ldflags = [ "-s" "-w" ];
+28 -2
View File
@@ -27,6 +27,28 @@ in
description = "Whether to open the configured port in the firewall."; description = "Whether to open the configured port in the firewall.";
}; };
inviteCodeFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/secrets/puppy-invite-code";
description = ''
Path to an EnvironmentFile containing the shared registration secret as
`PUPPY_INVITE_CODE=...`. Kept out of the Nix store so the code stays
secret. When null, registration is disabled (existing accounts can still
log in).
'';
};
secureCookies = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Mark session cookies Secure. Enable once the service is reached over
HTTPS (e.g. behind a TLS-terminating reverse proxy); leave off for plain
HTTP on a LAN, or browsers will drop the cookie and logins won't stick.
'';
};
package = lib.mkOption { package = lib.mkOption {
type = lib.types.package; type = lib.types.package;
default = serverPkg; default = serverPkg;
@@ -49,12 +71,16 @@ in
after = [ "network.target" ]; after = [ "network.target" ];
serviceConfig = { serviceConfig = {
ExecStart = lib.concatStringsSep " " [ ExecStart = lib.concatStringsSep " " ([
"${cfg.package}/bin/puppy-tracker-server" "${cfg.package}/bin/puppy-tracker-server"
"-addr ${cfg.address}:${toString cfg.port}" "-addr ${cfg.address}:${toString cfg.port}"
"-static ${cfg.staticPackage}/share/puppy-tracker" "-static ${cfg.staticPackage}/share/puppy-tracker"
"-data /var/lib/puppy-tracker/puppy.db" "-data /var/lib/puppy-tracker/puppy.db"
]; ] ++ lib.optional cfg.secureCookies "-secure-cookies");
# Invite code (registration secret) is read from an env file kept out of
# the store, exposed to the server as PUPPY_INVITE_CODE.
EnvironmentFile = lib.mkIf (cfg.inviteCodeFile != null) cfg.inviteCodeFile;
DynamicUser = true; DynamicUser = true;
StateDirectory = "puppy-tracker"; StateDirectory = "puppy-tracker";
+383
View File
@@ -0,0 +1,383 @@
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
)
// ctxKey is an unexported type so our context values can't collide with any
// set elsewhere.
type ctxKey int
const userIDKey ctxKey = 0
// User is the public shape returned to clients — never the password hash.
type User struct {
ID string `json:"id"`
Email string `json:"email"`
}
// 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.
func (a *Auth) startSession(userID string) (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) VALUES (?, ?, ?, ?)`,
hashToken(token), userID, now.UnixMilli(), now.Add(sessionValidity).UnixMilli())
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) {
if token == "" {
return "", 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
}
return userID, true
}
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
}
return a.adoptPhotos(userID)
}
// adoptPhotos moves any *.jpg sitting directly in photosDir (the pre-accounts
// flat layout) into photosDir/<userID>/.
func (a *Auth) adoptPhotos(userID string) error {
entries, err := os.ReadDir(a.photosDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
dstDir := filepath.Join(a.photosDir, userID)
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jpg") {
continue
}
if err := os.MkdirAll(dstDir, 0o755); err != nil {
return err
}
if err := os.Rename(
filepath.Join(a.photosDir, e.Name()),
filepath.Join(dstDir, e.Name()),
); err != nil {
return err
}
}
return nil
}
// ---------- cookies & middleware ----------
func (a *Auth) setCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
HttpOnly: true,
Secure: a.secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionValidity),
})
}
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 user id 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))
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userIDKey, userID)))
}
}
// 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
}
// ---------- 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 a session, sets the cookie, and returns the user.
func (a *Auth) issue(w http.ResponseWriter, u User) {
token, err := a.startSession(u.ID)
if err != nil {
log.Printf("start session: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
a.setCookie(w, token)
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. Wrapped in requireUser, so reaching it
// means the session is valid.
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
}
writeUser(w, u)
}
+5 -2
View File
@@ -2,7 +2,10 @@ module puppy-tracker
go 1.25.0 go 1.25.0
require modernc.org/sqlite v1.53.0 require (
golang.org/x/crypto v0.54.0
modernc.org/sqlite v1.53.0
)
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
@@ -10,7 +13,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.73.4 // indirect modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
+4 -2
View File
@@ -12,13 +12,15 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
+162 -44
View File
@@ -54,12 +54,12 @@ func newConfigStore(db *sql.DB) *ConfigStore {
return &ConfigStore{db: db} return &ConfigStore{db: db}
} }
func (cs *ConfigStore) get() Config { func (cs *ConfigStore) get(userID string) Config {
var c Config var c Config
// The profile lives in a single row (id = 1). A missing row is the // One profile row per user. A missing row is the pre-configuration state,
// pre-configuration state, so a zero-value Config is the right answer. // so a zero-value Config is the right answer.
err := cs.db.QueryRow( err := cs.db.QueryRow(
`SELECT name, birthday, updated FROM config WHERE id = 1`, `SELECT name, birthday, updated FROM config WHERE user_id = ?`, userID,
).Scan(&c.Name, &c.Birthday, &c.UpdatedAt) ).Scan(&c.Name, &c.Birthday, &c.UpdatedAt)
if err != nil && !errors.Is(err, sql.ErrNoRows) { if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.Printf("config get: %v", err) log.Printf("config get: %v", err)
@@ -67,22 +67,22 @@ func (cs *ConfigStore) get() Config {
return c return c
} }
// merge applies an incoming config with last-write-wins by UpdatedAt and // merge applies an incoming config for one user with last-write-wins by
// returns the resulting stored config (which the caller sends back). // UpdatedAt and returns the resulting stored config (which the caller sends back).
func (cs *ConfigStore) merge(in Config) (Config, error) { func (cs *ConfigStore) merge(userID string, in Config) (Config, error) {
// The upsert's WHERE clause enforces last-write-wins: the incoming row only // The upsert's WHERE clause enforces last-write-wins: the incoming row only
// replaces the stored one when it is strictly newer. // replaces the stored one when it is strictly newer.
_, err := cs.db.Exec(` _, err := cs.db.Exec(`
INSERT INTO config (id, name, birthday, updated) INSERT INTO config (user_id, name, birthday, updated)
VALUES (1, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(user_id) DO UPDATE SET
name = excluded.name, birthday = excluded.birthday, updated = excluded.updated name = excluded.name, birthday = excluded.birthday, updated = excluded.updated
WHERE excluded.updated > config.updated`, WHERE excluded.updated > config.updated`,
in.Name, in.Birthday, in.UpdatedAt) userID, in.Name, in.Birthday, in.UpdatedAt)
if err != nil { if err != nil {
return Config{}, err return Config{}, err
} }
return cs.get(), nil return cs.get(userID), nil
} }
type Store struct { type Store struct {
@@ -93,9 +93,10 @@ func newStore(db *sql.DB) *Store {
return &Store{db: db} return &Store{db: db}
} }
// sync merges client events into the store using last-write-wins by UpdatedAt, // sync merges one user's client events into the store using last-write-wins by
// then returns the full merged set (including tombstones, which must propagate). // UpdatedAt, then returns that user's full merged set (tombstones included, as
func (s *Store) sync(client []Event) ([]Event, error) { // they must propagate).
func (s *Store) sync(userID string, client []Event) ([]Event, error) {
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -104,14 +105,18 @@ func (s *Store) sync(client []Event) ([]Event, error) {
// The WHERE clause on the upsert is the last-write-wins rule: an incoming // 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. // 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.
stmt, err := tx.Prepare(` stmt, err := tx.Prepare(`
INSERT INTO events (id, type, at, note, photo_id, weight, updated, deleted) INSERT INTO events (id, type, at, note, photo_id, weight, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
type = excluded.type, at = excluded.at, note = excluded.note, type = excluded.type, at = excluded.at, note = excluded.note,
photo_id = excluded.photo_id, weight = excluded.weight, photo_id = excluded.photo_id, weight = excluded.weight,
updated = excluded.updated, deleted = excluded.deleted updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > events.updated`) WHERE excluded.updated > events.updated
AND events.user_id = excluded.user_id`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -122,7 +127,7 @@ func (s *Store) sync(client []Event) ([]Event, error) {
continue continue
} }
if _, err := stmt.Exec( if _, err := stmt.Exec(
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.UpdatedAt, ce.Deleted, ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.UpdatedAt, ce.Deleted, userID,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -130,13 +135,14 @@ func (s *Store) sync(client []Event) ([]Event, error) {
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return nil, err return nil, err
} }
return s.all() return s.all(userID)
} }
// all returns every stored event, tombstones included. // all returns one user's events, tombstones included.
func (s *Store) all() ([]Event, error) { func (s *Store) all(userID string) ([]Event, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT id, type, at, note, photo_id, weight, updated, deleted FROM events`) `SELECT id, type, at, note, photo_id, weight, updated, deleted
FROM events WHERE user_id = ?`, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -175,6 +181,9 @@ func openDB(path string) (*sql.DB, error) {
return nil, err return nil, err
} }
} }
// Fresh-install schema. Every row is scoped to a user_id; the empty string
// is the "ownerless" bucket that legacy single-tenant data lands in until
// the first account adopts it (see Auth.adopt).
schema := ` schema := `
CREATE TABLE IF NOT EXISTS events ( CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -184,21 +193,105 @@ func openDB(path string) (*sql.DB, error) {
photo_id TEXT NOT NULL DEFAULT '', photo_id TEXT NOT NULL DEFAULT '',
weight REAL NOT NULL DEFAULT 0, weight REAL NOT NULL DEFAULT 0,
updated INTEGER NOT NULL DEFAULT 0, updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0 deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT ''
); );
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
CREATE TABLE IF NOT EXISTS config ( CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY CHECK (id = 1), user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '', name TEXT NOT NULL DEFAULT '',
birthday TEXT NOT NULL DEFAULT '', birthday TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0 updated INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password TEXT NOT NULL,
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
);` );`
if _, err := db.Exec(schema); err != nil { if _, err := db.Exec(schema); err != nil {
db.Close() db.Close()
return nil, err return nil, err
} }
if err := migrateSchema(db); err != nil {
db.Close()
return nil, err
}
return db, nil return db, nil
} }
// migrateSchema upgrades a single-tenant database (from before accounts existed)
// in place: it adds events.user_id and rewrites the config table from its
// old single-row (id = 1) shape to one keyed by user_id. Pre-accounts data ends
// up ownerless (user_id = ”), ready for the first account to adopt. It is a
// no-op on a fresh DB, where openDB already created the current schema.
func migrateSchema(db *sql.DB) error {
has, err := columnExists(db, "events", "user_id")
if err != nil {
return err
}
if !has {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN user_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id)`); err != nil {
return err
}
}
oldConfig, err := columnExists(db, "config", "id")
if err != nil {
return err
}
if oldConfig {
// Rebuild config keyed by user_id, moving the lone id=1 row into the
// ownerless bucket.
stmts := []string{
`ALTER TABLE config RENAME TO config_old`,
`CREATE TABLE config (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
birthday TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0
)`,
`INSERT INTO config (user_id, name, birthday, updated)
SELECT '', name, birthday, updated FROM config_old WHERE id = 1`,
`DROP TABLE config_old`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
return err
}
}
}
return nil
}
// columnExists reports whether the given table has a column of the given name.
// A missing table reports false (no error), which is what fresh installs want.
func columnExists(db *sql.DB, table, col string) (bool, error) {
rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return false, err
}
if name == col {
return true, nil
}
}
return false, rows.Err()
}
// migrateJSON imports a pre-SQLite events.json / config.json sitting in dataDir // migrateJSON imports a pre-SQLite events.json / config.json sitting in dataDir
// into an otherwise-empty database, then renames each file to *.imported so the // into an otherwise-empty database, then renames each file to *.imported so the
// import runs exactly once. It is a no-op when the DB already holds data or the // import runs exactly once. It is a no-op when the DB already holds data or the
@@ -232,8 +325,10 @@ func importEvents(db *sql.DB, path string) error {
if err != nil && !errors.Is(err, io.EOF) { if err != nil && !errors.Is(err, io.EOF) {
return err return err
} }
// Imported as ownerless (user_id = ""); the first account to register adopts
// them. Mirrors how in-place schema migration parks legacy rows.
store := newStore(db) store := newStore(db)
if _, err := store.sync(evs); err != nil { if _, err := store.sync("", evs); err != nil {
return err return err
} }
log.Printf("migrated %d events from %s", len(evs), path) log.Printf("migrated %d events from %s", len(evs), path)
@@ -266,7 +361,8 @@ func importConfig(db *sql.DB, path string) error {
// Nothing meaningful to import; leave config empty. // Nothing meaningful to import; leave config empty.
return os.Rename(path, path+".imported") return os.Rename(path, path+".imported")
} }
if _, err := newConfigStore(db).merge(c); err != nil { // Ownerless until the first account adopts it (see importEvents).
if _, err := newConfigStore(db).merge("", c); err != nil {
return err return err
} }
log.Printf("migrated config from %s", path) log.Printf("migrated config from %s", path)
@@ -292,6 +388,10 @@ func main() {
addr := flag.String("addr", ":8080", "listen address (e.g. :8080 or 0.0.0.0:8080)") addr := flag.String("addr", ":8080", "listen address (e.g. :8080 or 0.0.0.0:8080)")
dataPath := flag.String("data", "puppy.db", "path to SQLite database file") dataPath := flag.String("data", "puppy.db", "path to SQLite database file")
staticDir := flag.String("static", "", "directory of static files to serve") staticDir := flag.String("static", "", "directory of static files to serve")
inviteCode := flag.String("invite-code", os.Getenv("PUPPY_INVITE_CODE"),
"shared secret required to register (env PUPPY_INVITE_CODE); empty disables registration")
secureCookies := flag.Bool("secure-cookies", false,
"mark session cookies Secure (enable when served over HTTPS / behind a TLS proxy)")
flag.Parse() flag.Parse()
db, err := openDB(*dataPath) db, err := openDB(*dataPath)
@@ -313,9 +413,19 @@ func main() {
log.Fatalf("mkdir photos: %v", err) log.Fatalf("mkdir photos: %v", err)
} }
auth := newAuth(db, *inviteCode, *secureCookies, photosDir)
if *inviteCode == "" {
log.Print("WARNING: no invite code set — registration is disabled (set -invite-code / PUPPY_INVITE_CODE)")
}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/events/sync", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/register", auth.handleRegister)
mux.HandleFunc("/api/login", auth.handleLogin)
mux.HandleFunc("/api/logout", auth.handleLogout)
mux.HandleFunc("/api/me", auth.requireUser(auth.handleMe))
mux.HandleFunc("/api/events/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
@@ -325,7 +435,7 @@ func main() {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest) http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return return
} }
merged, err := store.sync(req.Events) merged, err := store.sync(userID(r), req.Events)
if err != nil { if err != nil {
log.Printf("sync: %v", err) log.Printf("sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError) http.Error(w, "server error", http.StatusInternalServerError)
@@ -337,11 +447,11 @@ func main() {
Events: merged, Events: merged,
ServerNow: time.Now().UnixMilli(), ServerNow: time.Now().UnixMilli(),
}) })
}) }))
// GET /api/config — return the shared puppy profile. // GET /api/config — return the caller's puppy profile.
// PUT /api/config — update it (last-write-wins by updatedAt). // PUT /api/config — update it (last-write-wins by updatedAt).
mux.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
writeConfig := func(c Config) { writeConfig := func(c Config) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
@@ -349,7 +459,7 @@ func main() {
} }
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
writeConfig(configStore.get()) writeConfig(configStore.get(userID(r)))
case http.MethodPut, http.MethodPost: case http.MethodPut, http.MethodPost:
var in Config var in Config
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil { if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil {
@@ -364,7 +474,7 @@ func main() {
http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest) http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest)
return return
} }
merged, err := configStore.merge(in) merged, err := configStore.merge(userID(r), in)
if err != nil { if err != nil {
log.Printf("config save: %v", err) log.Printf("config save: %v", err)
http.Error(w, "server error", http.StatusInternalServerError) http.Error(w, "server error", http.StatusInternalServerError)
@@ -374,7 +484,7 @@ func main() {
default: default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
} }
}) }))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok")) w.Write([]byte("ok"))
@@ -383,7 +493,7 @@ func main() {
// POST /api/photos — multipart upload with form fields `id` (UUID) and // POST /api/photos — multipart upload with form fields `id` (UUID) and
// `file` (JPEG). The client generates the ID so the event referencing // `file` (JPEG). The client generates the ID so the event referencing
// the photo can be written before the upload round-trips. // the photo can be written before the upload round-trips.
mux.HandleFunc("/api/photos", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/photos", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
@@ -405,7 +515,14 @@ func main() {
} }
defer file.Close() defer file.Close()
dstPath := filepath.Join(photosDir, id+".jpg") // Photos live under the owner's directory so a photo can only ever be
// read back by the account that uploaded it.
userDir := filepath.Join(photosDir, userID(r))
if err := os.MkdirAll(userDir, 0o755); err != nil {
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
return
}
dstPath := filepath.Join(userDir, id+".jpg")
tmp := dstPath + ".tmp" tmp := dstPath + ".tmp"
dst, err := os.Create(tmp) dst, err := os.Create(tmp)
if err != nil { if err != nil {
@@ -432,11 +549,12 @@ func main() {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]string{"id": id}) _ = json.NewEncoder(w).Encode(map[string]string{"id": id})
}) }))
// GET /api/photos/<id> — serves the JPEG. Photos are immutable per ID // GET /api/photos/<id> — serves the caller's own JPEG. Photos are immutable
// so we mark them as long-lived; both browser and SW can cache freely. // per ID so we mark them as long-lived; the private cache keeps them per
mux.HandleFunc("/api/photos/", func(w http.ResponseWriter, r *http.Request) { // user. Serving only from the caller's directory makes ownership implicit.
mux.HandleFunc("/api/photos/", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead { if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
@@ -446,7 +564,7 @@ func main() {
http.Error(w, "invalid id", http.StatusBadRequest) http.Error(w, "invalid id", http.StatusBadRequest)
return return
} }
path := filepath.Join(photosDir, id+".jpg") path := filepath.Join(photosDir, userID(r), id+".jpg")
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
@@ -459,9 +577,9 @@ func main() {
return return
} }
w.Header().Set("Content-Type", "image/jpeg") w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
http.ServeContent(w, r, path, stat.ModTime(), f) http.ServeContent(w, r, path, stat.ModTime(), f)
}) }))
if *staticDir != "" { if *staticDir != "" {
fileServer := http.FileServer(http.Dir(*staticDir)) fileServer := http.FileServer(http.Dir(*staticDir))
+163 -17
View File
@@ -1,8 +1,13 @@
(() => { (() => {
"use strict"; "use strict";
const STORAGE_KEY = "puppy-tracker:events:v1"; // Storage is namespaced per account so two people sharing a browser (or one
const CONFIG_KEY = "puppy-tracker:config:v1"; // person logging out and back in as someone else) never see each other's
// cached events/profile. currentUser is set by the auth gate before the app
// boots, so these are only ever called once a user is known.
let currentUser = null;
const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`;
const configKey = () => `puppy-tracker:${currentUser.id}:config:v1`;
const SYNC_URL = "api/events/sync"; const SYNC_URL = "api/events/sync";
const SYNC_DEBOUNCE_MS = 1200; const SYNC_DEBOUNCE_MS = 1200;
const SYNC_POLL_MS = 60_000; const SYNC_POLL_MS = 60_000;
@@ -178,7 +183,7 @@
// Internal "raw" storage includes deleted tombstones; UI uses live(). // Internal "raw" storage includes deleted tombstones; UI uses live().
function loadAll() { function loadAll() {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(eventsKey());
if (!raw) return []; if (!raw) return [];
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return []; if (!Array.isArray(parsed)) return [];
@@ -193,7 +198,7 @@
} }
function saveAll(events) { function saveAll(events) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(events)); localStorage.setItem(eventsKey(), JSON.stringify(events));
} }
function live() { function live() {
@@ -206,7 +211,7 @@
// source of truth, reconciled by last-write-wins on updatedAt (see syncConfig). // source of truth, reconciled by last-write-wins on updatedAt (see syncConfig).
function loadConfig() { function loadConfig() {
try { try {
const parsed = JSON.parse(localStorage.getItem(CONFIG_KEY)); const parsed = JSON.parse(localStorage.getItem(configKey()));
if (!parsed || typeof parsed !== "object") return { name: "", birthday: "", updatedAt: 0 }; if (!parsed || typeof parsed !== "object") return { name: "", birthday: "", updatedAt: 0 };
return { return {
name: parsed.name || "", name: parsed.name || "",
@@ -219,7 +224,7 @@
} }
function saveConfig(cfg) { function saveConfig(cfg) {
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg)); localStorage.setItem(configKey(), JSON.stringify(cfg));
} }
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday, // Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday,
@@ -1176,6 +1181,7 @@
} }
async function sync() { async function sync() {
if (!currentUser) return;
if (syncing) return; if (syncing) return;
if (!navigator.onLine) { setStatus("pending"); return; } if (!navigator.onLine) { setStatus("pending"); return; }
syncing = true; syncing = true;
@@ -1190,6 +1196,7 @@
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ events: loadAll() }), body: JSON.stringify({ events: loadAll() }),
}); });
if (res.status === 401) { handleLoggedOut(); return; }
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json(); const body = await res.json();
if (Array.isArray(body.events)) { if (Array.isArray(body.events)) {
@@ -1214,10 +1221,12 @@
// (e.g. edited on this device while another client hadn't changed it). This // (e.g. edited on this device while another client hadn't changed it). This
// self-heals a failed push — the local copy stays newer and re-pushes next tick. // self-heals a failed push — the local copy stays newer and re-pushes next tick.
async function syncConfig() { async function syncConfig() {
if (!currentUser) return;
if (!navigator.onLine) return; if (!navigator.onLine) return;
const local = loadConfig(); const local = loadConfig();
try { try {
const res = await fetch("api/config"); const res = await fetch("api/config");
if (res.status === 401) { handleLoggedOut(); return; }
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json(); const body = await res.json();
const server = { const server = {
@@ -1542,6 +1551,55 @@
window.addEventListener("online", () => { setStatus(); sync(); syncConfig(); }); window.addEventListener("online", () => { setStatus(); sync(); syncConfig(); });
window.addEventListener("offline", () => setStatus()); window.addEventListener("offline", () => setStatus());
// Service worker (independent of auth).
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("sw.js").catch(err => console.error("SW", err));
});
}
// ---------- auth gate ----------
// The tracker only boots once we know who the user is. startApp() does the
// first paint, initial sync, and starts the periodic timers — guarded so it
// runs at most once per page load even if login and the session check race.
const authScreen = document.getElementById("auth-screen");
const appEl = document.getElementById("app");
const authForm = document.getElementById("auth-form");
const authEmail = document.getElementById("auth-email");
const authPassword = document.getElementById("auth-password");
const authInvite = document.getElementById("auth-invite");
const authInviteFld= document.getElementById("auth-invite-field");
const authError = document.getElementById("auth-error");
const authSubmit = document.getElementById("auth-submit");
const authSub = document.getElementById("auth-sub");
const authToggleBtn= document.getElementById("auth-toggle-btn");
const authToggleTxt= document.getElementById("auth-toggle-text");
let authMode = "login"; // or "register"
let appStarted = false;
// Remember who was last signed in so an offline reload can still open the
// app against the cached data instead of stranding the user on a login screen
// it can't verify. Cleared only on an explicit logout or a server 401.
const SESSION_KEY = "puppy-tracker:session:v1";
function setUser(u) {
currentUser = u;
try { localStorage.setItem(SESSION_KEY, JSON.stringify(u)); } catch { /* ignore */ }
}
function clearUser() {
currentUser = null;
try { localStorage.removeItem(SESSION_KEY); } catch { /* ignore */ }
}
function cachedUser() {
try {
const u = JSON.parse(localStorage.getItem(SESSION_KEY));
return u && u.id ? u : null;
} catch { return null; }
}
function startApp() {
if (appStarted) return;
appStarted = true;
// Live-update relative times and (eventually) sync status text. // Live-update relative times and (eventually) sync status text.
setInterval(() => { setInterval(() => {
const evs = live(); const evs = live();
@@ -1556,23 +1614,111 @@
if (navigator.onLine && !syncing) setStatus(); if (navigator.onLine && !syncing) setStatus();
}, 60_000); }, 60_000);
// Big-clock counter updates once a second without re-deriving state.
setInterval(tickBigClock, 1000); setInterval(tickBigClock, 1000);
// Periodic pull from server so other clients' changes show up.
setInterval(sync, SYNC_POLL_MS); setInterval(sync, SYNC_POLL_MS);
setInterval(syncConfig, SYNC_POLL_MS); setInterval(syncConfig, SYNC_POLL_MS);
// Service worker
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("sw.js").catch(err => console.error("SW", err));
});
}
// First paint + initial sync.
setStatus(); setStatus();
render(); render();
sync(); sync();
syncConfig(); syncConfig();
}
function showAuth() {
appEl.hidden = true;
authScreen.hidden = false;
}
function showApp() {
authScreen.hidden = true;
appEl.hidden = false;
}
// Called when the server reports we're no longer authenticated (expired or
// revoked session). Drop back to the login screen without wiping the local
// cache — logging back in as the same user picks it straight back up.
function handleLoggedOut() {
clearUser();
setStatus("offline");
showAuth();
}
function renderAuthMode() {
const reg = authMode === "register";
authInviteFld.hidden = !reg;
authInvite.required = reg;
authSubmit.textContent = reg ? "Create account" : "Sign in";
authSub.textContent = reg ? "Create your account" : "Sign in to continue";
authToggleTxt.textContent = reg ? "Already have an account?" : "No account yet?";
authToggleBtn.textContent = reg ? "Sign in" : "Create one";
authPassword.autocomplete = reg ? "new-password" : "current-password";
authError.hidden = true;
}
authToggleBtn.addEventListener("click", () => {
authMode = authMode === "login" ? "register" : "login";
renderAuthMode();
});
authForm.addEventListener("submit", async (e) => {
e.preventDefault();
authError.hidden = true;
authSubmit.disabled = true;
const body = { email: authEmail.value.trim(), password: authPassword.value };
if (authMode === "register") body.invite = authInvite.value.trim();
try {
const res = await fetch(authMode === "register" ? "api/register" : "api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const msg = (await res.text()).trim();
throw new Error(msg || `HTTP ${res.status}`);
}
setUser(await res.json());
authForm.reset();
showApp();
startApp();
} catch (err) {
authError.textContent = err.message || "Something went wrong";
authError.hidden = false;
} finally {
authSubmit.disabled = false;
}
});
document.getElementById("logout-btn").addEventListener("click", async () => {
try { await fetch("api/logout", { method: "POST" }); } catch { /* ignore */ }
clearUser();
// Full reload is the simplest way to clear in-memory app state and timers.
location.reload();
});
// On load, ask the server who we are. A valid session boots straight into the
// app. A 401 means log in. A network failure (offline PWA) falls back to the
// last cached session so offline data stays reachable — a later sync will
// 401 and bounce to login if that session has actually gone stale.
(async function bootstrap() {
try {
const res = await fetch("api/me");
if (res.ok) {
setUser(await res.json());
showApp();
startApp();
return;
}
clearUser(); // explicit 401/403: session is gone
} catch {
const cached = cachedUser();
if (cached) {
currentUser = cached;
showApp();
startApp();
return;
}
}
renderAuthMode();
showAuth();
})();
})(); })();
+29
View File
@@ -11,6 +11,33 @@
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
</head> </head>
<body> <body>
<!-- Login / register gate. Shown until the session check succeeds; the app
(#app) stays hidden behind it so no puppy data paints while logged out. -->
<div id="auth-screen" class="auth-screen" hidden>
<div class="auth-card">
<h1>🐶 Puppy Tracker</h1>
<p class="auth-sub" id="auth-sub">Sign in to continue</p>
<form id="auth-form">
<label>Email
<input type="email" id="auth-email" autocomplete="username" required />
</label>
<label>Password
<input type="password" id="auth-password" autocomplete="current-password" required minlength="8" />
</label>
<label id="auth-invite-field" hidden>Invite code
<input type="text" id="auth-invite" autocomplete="off" placeholder="Ask the owner for this" />
</label>
<p id="auth-error" class="auth-error" hidden></p>
<button type="submit" id="auth-submit">Sign in</button>
</form>
<p class="auth-toggle">
<span id="auth-toggle-text">No account yet?</span>
<button type="button" id="auth-toggle-btn" class="linklike">Create one</button>
</p>
</div>
</div>
<div id="app" hidden>
<header> <header>
<div class="title"> <div class="title">
<h1 id="app-title">🐶 Puppy Tracker</h1> <h1 id="app-title">🐶 Puppy Tracker</h1>
@@ -18,6 +45,7 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" id="settings-btn" class="ghost icon-btn" aria-label="Settings" title="Settings">⚙️</button> <button type="button" id="settings-btn" class="ghost icon-btn" aria-label="Settings" title="Settings">⚙️</button>
<button type="button" id="logout-btn" class="ghost icon-btn" aria-label="Log out" title="Log out">🚪</button>
<div id="online-status" class="status-pill"></div> <div id="online-status" class="status-pill"></div>
</div> </div>
</header> </header>
@@ -149,6 +177,7 @@
<p id="empty-state" class="empty">No events logged for this day.</p> <p id="empty-state" class="empty">No events logged for this day.</p>
</section> </section>
</main> </main>
</div><!-- /#app -->
<dialog id="settings-dialog"> <dialog id="settings-dialog">
<form method="dialog" id="settings-form"> <form method="dialog" id="settings-form">
+66
View File
@@ -533,3 +533,69 @@ dialog menu {
.lg.pee .sw { background: var(--pee); } .lg.pee .sw { background: var(--pee); }
.lg.poo .sw { background: var(--poo); } .lg.poo .sw { background: var(--poo); }
.lg.eat .sw { background: var(--eat); } .lg.eat .sw { background: var(--eat); }
/* ---------- auth (login / register) ---------- */
.auth-screen {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--bg);
z-index: 50;
}
/* The display rule above beats the UA [hidden] rule, so hide explicitly. */
.auth-screen[hidden] { display: none; }
.auth-card {
width: 100%;
max-width: 360px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 28px 22px;
}
.auth-card h1 { margin: 0 0 4px; font-size: 1.5rem; text-align: center; }
.auth-sub { margin: 0 0 20px; text-align: center; color: var(--muted); font-size: 0.9rem; }
.auth-card label {
display: block;
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 12px;
}
.auth-card input[type="email"],
.auth-card input[type="password"],
.auth-card input[type="text"] {
font: inherit;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
width: 100%;
margin-top: 4px;
}
.auth-card #auth-submit { width: 100%; margin-top: 6px; }
.auth-error {
color: var(--danger);
font-size: 0.85rem;
margin: 0 0 12px;
text-align: center;
}
.auth-toggle {
margin: 16px 0 0;
text-align: center;
font-size: 0.85rem;
color: var(--muted);
}
button.linklike {
background: none;
border: none;
color: var(--accent);
font-weight: 600;
padding: 0 0 0 4px;
display: inline;
cursor: pointer;
}
button.linklike:hover { text-decoration: underline; filter: none; }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = "puppy-tracker-v5"; const CACHE = "puppy-tracker-v6";
const PHOTO_CACHE = "puppy-tracker-photos-v1"; const PHOTO_CACHE = "puppy-tracker-photos-v1";
const ASSETS = [ const ASSETS = [
"./", "./",