Compare commits

...

4 Commits

Author SHA1 Message Date
Alexander Heldt bb7373166e Add light/dark mode toggle in settings
Settings gains a "Dark mode" switch. Theme preference is device-global
(localStorage), independent of accounts. With no explicit choice the app keeps
following the OS via prefers-color-scheme; picking a mode sets data-theme on
<html>, which the CSS treats as an override (attribute selector beats the media
query). A tiny <head> script applies a saved choice before first paint to avoid
a light/dark flash. Toggling previews live, independent of Save/Cancel.

Bumps the service-worker cache. Verified in a headless-browser run: default
follows OS, enabling dark swaps the palette, the choice persists across reload,
and toggling back restores light.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:41:30 +00:00
Alexander Heldt a1a3ccf339 Add self-service account deletion
Settings → Delete account removes the signed-in account and everything it
owns. DELETE /api/me re-checks the password (guarding an unattended session),
then wipes the user's events, config, sessions and user row in one transaction
and removes their photos/<user_id>/ directory. The client clears the account's
local cache and returns to the login screen.

Bumps the service-worker cache so clients pick up the new UI.

Verified: wrong password is rejected (401, data intact); correct password
returns 204, invalidates the session, drops all rows to zero and removes the
photo dir; the email can be re-registered afterwards. Confirmed end to end in
a headless-browser run of the Settings → delete flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:20:40 +00:00
Alexander Heldt 706d8d8d9f 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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:20:36 +00:00
Alexander Heldt d95cd52086 Store events and config in SQLite
Replace the JSON-file event and config stores with a SQLite database
(modernc.org/sqlite, pure-Go so the static build keeps CGO_ENABLED=0).
Last-write-wins now rides on the upsert's WHERE clause rather than a
Go-side map compare; the sync protocol and HTTP handlers are unchanged.

On first start the server auto-imports any legacy events.json/config.json
sitting in the data dir, renaming them to *.imported. The -data flag now
points at puppy.db; photos still live on the filesystem alongside it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:56:35 +00:00
11 changed files with 1392 additions and 197 deletions
+49 -7
View File
@@ -15,14 +15,21 @@ source-of-truth and sync between devices.
client POSTs its full event list to `/api/events/sync`. The server merges client POSTs its full event list to `/api/events/sync`. The server merges
it with its own copy using last-write-wins on `updatedAt` and returns the it with its own copy using last-write-wins on `updatedAt` and returns the
merged set. merged set.
- The server keeps its copy in a SQLite database (`puppy.db`); events and the
shared profile are separate tables, and last-write-wins is enforced by the
upsert itself. On first start it auto-imports any legacy `events.json` /
`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.
@@ -35,7 +42,9 @@ puppy-tracker/
├── module.nix # systemd unit, StateDirectory, hardening ├── module.nix # systemd unit, StateDirectory, hardening
├── server/ ├── server/
│ ├── go.mod │ ├── go.mod
── main.go # JSON-file store, LWW sync, static file serving ── go.sum
│ ├── 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
@@ -51,10 +60,35 @@ 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-events.json' 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.
- **Self-service deletion.** Settings → *Delete account* removes the signed-in
account and everything it owns (`DELETE /api/me`, re-confirming the password):
events, profile, sessions and the photo directory are all wiped.
- **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:
@@ -73,6 +107,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;
}; };
} }
]; ];
@@ -81,10 +120,13 @@ In your system flake:
} }
``` ```
The server runs as a `DynamicUser` systemd unit. Data is stored at The server runs as a `DynamicUser` systemd unit. Data is stored in a SQLite
`/var/lib/puppy-tracker/events.json` via `StateDirectory`. database at `/var/lib/puppy-tracker/puppy.db` via `StateDirectory` (with photos
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.
+2 -2
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 = null; # no external dependencies 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" ];
@@ -76,7 +76,7 @@
exec ${server}/bin/puppy-tracker-server \ exec ${server}/bin/puppy-tracker-server \
-addr "''${PUPPY_ADDR:-:8080}" \ -addr "''${PUPPY_ADDR:-:8080}" \
-static ${static}/share/puppy-tracker \ -static ${static}/share/puppy-tracker \
-data "$data_dir/events.json" -data "$data_dir/puppy.db"
''); '');
meta.description = "Run puppy-tracker locally (data in $XDG_DATA_HOME/puppy-tracker)"; meta.description = "Run puppy-tracker locally (data in $XDG_DATA_HOME/puppy-tracker)";
}; };
+29 -3
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/events.json" "-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";
+442
View File
@@ -0,0 +1,442 @@
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)
}
// checkPassword reports whether password matches the stored hash for userID.
func (a *Auth) checkPassword(userID, password string) bool {
var hash string
if err := a.db.QueryRow(`SELECT password FROM users WHERE id = ?`, userID).Scan(&hash); err != nil {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// deleteAccount removes a user and everything owned by them: events, profile,
// sessions, the user row, and their photo directory. The table wipes run in one
// transaction; photos are best-effort afterwards (orphaned files are harmless).
func (a *Auth) deleteAccount(userID string) error {
tx, err := a.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, q := range []string{
`DELETE FROM events WHERE user_id = ?`,
`DELETE FROM config WHERE user_id = ?`,
`DELETE FROM sessions WHERE user_id = ?`,
`DELETE FROM users WHERE id = ?`,
} {
if _, err := tx.Exec(q, userID); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
if err := os.RemoveAll(filepath.Join(a.photosDir, userID)); err != nil {
log.Printf("delete photos for %s: %v", userID, err)
}
return nil
}
// handleDeleteAccount deletes the caller's own account after re-checking their
// password (guards against an unattended session). Wrapped in requireUser.
func (a *Auth) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
var c credentials
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&c); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
uid := userID(r)
if !a.checkPassword(uid, c.Password) {
http.Error(w, "invalid password", http.StatusUnauthorized)
return
}
if err := a.deleteAccount(uid); err != nil {
log.Printf("delete account %s: %v", uid, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
a.clearCookie(w)
w.WriteHeader(http.StatusNoContent)
}
+18 -1
View File
@@ -1,3 +1,20 @@
module puppy-tracker module puppy-tracker
go 1.22 go 1.25.0
require (
golang.org/x/crypto v0.54.0
modernc.org/sqlite v1.53.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+53
View File
@@ -0,0 +1,53 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
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/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/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
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/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
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/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/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+357 -149
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"flag" "flag"
@@ -12,8 +13,9 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"sync"
"time" "time"
_ "modernc.org/sqlite"
) )
type Event struct { type Event struct {
@@ -45,155 +47,326 @@ type Config struct {
} }
type ConfigStore struct { type ConfigStore struct {
path string db *sql.DB
mu sync.Mutex
cfg Config
} }
func newConfigStore(path string) (*ConfigStore, error) { func newConfigStore(db *sql.DB) *ConfigStore {
cs := &ConfigStore{path: path} return &ConfigStore{db: db}
f, err := os.Open(path) }
func (cs *ConfigStore) get(userID string) Config {
var c Config
// One profile row per user. A missing row is the pre-configuration state,
// so a zero-value Config is the right answer.
err := cs.db.QueryRow(
`SELECT name, birthday, updated FROM config WHERE user_id = ?`, userID,
).Scan(&c.Name, &c.Birthday, &c.UpdatedAt)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.Printf("config get: %v", err)
}
return c
}
// merge applies an incoming config for one user with last-write-wins by
// UpdatedAt and returns the resulting stored config (which the caller sends back).
func (cs *ConfigStore) merge(userID string, in Config) (Config, error) {
// The upsert's WHERE clause enforces last-write-wins: the incoming row only
// replaces the stored one when it is strictly newer.
_, err := cs.db.Exec(`
INSERT INTO config (user_id, name, birthday, updated)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
name = excluded.name, birthday = excluded.birthday, updated = excluded.updated
WHERE excluded.updated > config.updated`,
userID, in.Name, in.Birthday, in.UpdatedAt)
if err != nil { if err != nil {
if errors.Is(err, fs.ErrNotExist) { return Config{}, err
return cs, nil
}
return nil, err
} }
defer f.Close() return cs.get(userID), nil
if err := json.NewDecoder(f).Decode(&cs.cfg); err != nil && !errors.Is(err, io.EOF) {
return nil, err
}
return cs, nil
}
func (cs *ConfigStore) get() Config {
cs.mu.Lock()
defer cs.mu.Unlock()
return cs.cfg
}
// merge applies an incoming config with last-write-wins by UpdatedAt and
// returns the resulting stored config (which the caller sends back).
func (cs *ConfigStore) merge(in Config) (Config, error) {
cs.mu.Lock()
defer cs.mu.Unlock()
if in.UpdatedAt > cs.cfg.UpdatedAt {
cs.cfg = in
if err := cs.saveLocked(); err != nil {
return cs.cfg, err
}
}
return cs.cfg, nil
}
// Caller must hold cs.mu.
func (cs *ConfigStore) saveLocked() error {
if err := os.MkdirAll(filepath.Dir(cs.path), 0o755); err != nil {
return err
}
tmp := cs.path + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
if err := enc.Encode(cs.cfg); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmp, cs.path)
} }
type Store struct { type Store struct {
path string db *sql.DB
mu sync.Mutex
data map[string]Event
} }
func newStore(path string) (*Store, error) { func newStore(db *sql.DB) *Store {
s := &Store{path: path, data: map[string]Event{}} return &Store{db: db}
if err := s.load(); err != nil { }
// 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) {
tx, err := s.db.Begin()
if err != nil {
return nil, err return nil, err
} }
return s, nil defer tx.Rollback()
}
func (s *Store) load() error { // The WHERE clause on the upsert is the last-write-wins rule: an incoming
f, err := os.Open(s.path) // 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(`
INSERT INTO events (id, type, at, note, photo_id, weight, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type, at = excluded.at, note = excluded.note,
photo_id = excluded.photo_id, weight = excluded.weight,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > events.updated
AND events.user_id = excluded.user_id`)
if err != nil { if err != nil {
if errors.Is(err, fs.ErrNotExist) { return nil, err
return nil
}
return err
} }
defer f.Close() defer stmt.Close()
var evs []Event
if err := json.NewDecoder(f).Decode(&evs); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
for _, e := range evs {
s.data[e.ID] = e
}
return nil
}
// Caller must hold s.mu.
func (s *Store) saveLocked() error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
tmp := s.path + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
evs := make([]Event, 0, len(s.data))
for _, e := range s.data {
evs = append(evs, e)
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
if err := enc.Encode(evs); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// sync merges client events into the store using last-write-wins by UpdatedAt,
// then returns the full merged set.
func (s *Store) sync(client []Event) ([]Event, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, ce := range client { for _, ce := range client {
if ce.ID == "" { if ce.ID == "" {
continue continue
} }
existing, ok := s.data[ce.ID] if _, err := stmt.Exec(
if !ok || ce.UpdatedAt > existing.UpdatedAt { ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.UpdatedAt, ce.Deleted, userID,
s.data[ce.ID] = ce ); err != nil {
return nil, err
} }
} }
if err := s.saveLocked(); err != nil { if err := tx.Commit(); err != nil {
return nil, err return nil, err
} }
out := make([]Event, 0, len(s.data)) return s.all(userID)
for _, e := range s.data { }
// 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, updated, deleted
FROM events WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]Event, 0)
for rows.Next() {
var e Event
if err := rows.Scan(
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.UpdatedAt, &e.Deleted,
); err != nil {
return nil, err
}
out = append(out, e) out = append(out, e)
} }
return out, nil return out, rows.Err()
}
// openDB opens (creating if needed) the SQLite database and ensures the schema
// exists. WAL mode plays nicely with concurrent readers during a sync write;
// busy_timeout avoids spurious "database is locked" errors under contention.
func openDB(path string) (*sql.DB, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
for _, pragma := range []string{
`PRAGMA journal_mode = WAL`,
`PRAGMA busy_timeout = 5000`,
`PRAGMA synchronous = NORMAL`,
} {
if _, err := db.Exec(pragma); err != nil {
db.Close()
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 := `
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,
updated 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 (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
birthday TEXT NOT NULL DEFAULT '',
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 {
db.Close()
return nil, err
}
if err := migrateSchema(db); err != nil {
db.Close()
return nil, err
}
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
// 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
// legacy files are absent.
func migrateJSON(db *sql.DB, dataDir string) error {
if err := importEvents(db, filepath.Join(dataDir, "events.json")); err != nil {
return err
}
return importConfig(db, filepath.Join(dataDir, "config.json"))
}
func importEvents(db *sql.DB, path string) error {
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM events`).Scan(&n); err != nil {
return err
}
if n > 0 {
return nil // DB already has data; never clobber it
}
f, err := os.Open(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
var evs []Event
dec := json.NewDecoder(f)
err = dec.Decode(&evs)
f.Close()
if err != nil && !errors.Is(err, io.EOF) {
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)
if _, err := store.sync("", evs); err != nil {
return err
}
log.Printf("migrated %d events from %s", len(evs), path)
return os.Rename(path, path+".imported")
}
func importConfig(db *sql.DB, path string) error {
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM config`).Scan(&n); err != nil {
return err
}
if n > 0 {
return nil
}
f, err := os.Open(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
var c Config
dec := json.NewDecoder(f)
err = dec.Decode(&c)
f.Close()
if err != nil && !errors.Is(err, io.EOF) {
return err
}
if c.UpdatedAt == 0 {
// Nothing meaningful to import; leave config empty.
return os.Rename(path, path+".imported")
}
// Ownerless until the first account adopts it (see importEvents).
if _, err := newConfigStore(db).merge("", c); err != nil {
return err
}
log.Printf("migrated config from %s", path)
return os.Rename(path, path+".imported")
} }
type syncRequest struct { type syncRequest struct {
@@ -213,28 +386,55 @@ func (c cacheControlFS) Open(name string) (http.File, error) { return c.root.Ope
func main() { 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", "events.json", "path to events JSON 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()
store, err := newStore(*dataPath) db, err := openDB(*dataPath)
if err != nil { if err != nil {
log.Fatalf("load store: %v", err) log.Fatalf("open db: %v", err)
}
defer db.Close()
// One-time import of any pre-SQLite JSON data sitting next to the DB.
if err := migrateJSON(db, filepath.Dir(*dataPath)); err != nil {
log.Fatalf("migrate json: %v", err)
} }
configStore, err := newConfigStore(filepath.Join(filepath.Dir(*dataPath), "config.json")) store := newStore(db)
if err != nil { configStore := newConfigStore(db)
log.Fatalf("load config: %v", err)
}
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos") photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
if err := os.MkdirAll(photosDir, 0o755); err != nil { if err := os.MkdirAll(photosDir, 0o755); err != nil {
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(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
auth.handleMe(w, r)
case http.MethodDelete:
auth.handleDeleteAccount(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}))
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
@@ -244,7 +444,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)
@@ -256,11 +456,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")
@@ -268,7 +468,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 {
@@ -283,7 +483,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)
@@ -293,7 +493,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"))
@@ -302,7 +502,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
@@ -324,7 +524,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 {
@@ -351,11 +558,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
@@ -365,7 +573,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)
@@ -378,9 +586,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))
+247 -33
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 = {
@@ -1476,15 +1485,39 @@
}); });
// Settings dialog (puppy name + birthday) // Settings dialog (puppy name + birthday)
// ---------- theme ----------
// Preference is device-global (not per user). No stored choice → follow the
// OS via the prefers-color-scheme media query; a choice sets data-theme on
// <html>, which the CSS treats as an override. The <head> applies any saved
// choice before first paint; this just resolves state and reacts to the toggle.
const THEME_KEY = "puppy-tracker:theme";
const prefersDark = () =>
window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
function effectiveTheme() {
const s = localStorage.getItem(THEME_KEY);
return s === "light" || s === "dark" ? s : (prefersDark() ? "dark" : "light");
}
function setTheme(theme) {
document.documentElement.dataset.theme = theme;
try { localStorage.setItem(THEME_KEY, theme); } catch { /* ignore */ }
}
const settingsDialog = document.getElementById("settings-dialog"); const settingsDialog = document.getElementById("settings-dialog");
const settingsForm = document.getElementById("settings-form"); const settingsForm = document.getElementById("settings-form");
const settingsName = document.getElementById("settings-name"); const settingsName = document.getElementById("settings-name");
const settingsBirthday = document.getElementById("settings-birthday"); const settingsBirthday = document.getElementById("settings-birthday");
const settingsTheme = document.getElementById("settings-theme");
// Apply live so the toggle previews immediately (independent of Save/Cancel).
settingsTheme.addEventListener("change", () => {
setTheme(settingsTheme.checked ? "dark" : "light");
});
function openSettingsDialog() { function openSettingsDialog() {
const cfg = loadConfig(); const cfg = loadConfig();
settingsName.value = cfg.name; settingsName.value = cfg.name;
settingsBirthday.value = cfg.birthday; settingsBirthday.value = cfg.birthday;
settingsTheme.checked = effectiveTheme() === "dark";
settingsDialog.showModal(); settingsDialog.showModal();
setTimeout(() => settingsName.focus(), 50); setTimeout(() => settingsName.focus(), 50);
} }
@@ -1513,6 +1546,50 @@
settingsDialog.close(); settingsDialog.close();
}); });
// ---------- delete account ----------
const deleteAccountDialog = document.getElementById("delete-account-dialog");
const deleteAccountPassword = document.getElementById("delete-account-password");
const deleteAccountError = document.getElementById("delete-account-error");
const deleteAccountConfirm = document.getElementById("delete-account-confirm");
document.getElementById("delete-account-btn").addEventListener("click", () => {
settingsDialog.close();
deleteAccountPassword.value = "";
deleteAccountError.hidden = true;
deleteAccountDialog.showModal();
setTimeout(() => deleteAccountPassword.focus(), 50);
});
deleteAccountConfirm.addEventListener("click", async () => {
deleteAccountError.hidden = true;
deleteAccountConfirm.disabled = true;
try {
const res = await fetch("api/me", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: deleteAccountPassword.value }),
});
if (!res.ok) {
const msg = (await res.text()).trim();
throw new Error(res.status === 401 ? "Incorrect password" : (msg || `HTTP ${res.status}`));
}
// Account is gone server-side. Wipe this user's local cache before the
// reload drops us back on the login screen.
try {
localStorage.removeItem(eventsKey());
localStorage.removeItem(configKey());
} catch { /* ignore */ }
clearUser();
deleteAccountDialog.close();
location.reload();
} catch (err) {
deleteAccountError.textContent = err.message || "Something went wrong";
deleteAccountError.hidden = false;
} finally {
deleteAccountConfirm.disabled = false;
}
});
// ---------- wiring ---------- // ---------- wiring ----------
document.querySelectorAll("button.action").forEach(btn => { document.querySelectorAll("button.action").forEach(btn => {
btn.addEventListener("click", () => openNoteDialog(btn.dataset.type)); btn.addEventListener("click", () => openNoteDialog(btn.dataset.type));
@@ -1542,37 +1619,174 @@
window.addEventListener("online", () => { setStatus(); sync(); syncConfig(); }); window.addEventListener("online", () => { setStatus(); sync(); syncConfig(); });
window.addEventListener("offline", () => setStatus()); window.addEventListener("offline", () => setStatus());
// Live-update relative times and (eventually) sync status text. // Service worker (independent of auth).
setInterval(() => {
const evs = live();
renderHeader();
renderBigClock(evs);
renderStats(evs);
renderLasts(evs);
renderTiming(evs);
renderSleepWindows(evs);
renderWakeWindows(evs);
renderWeekly(evs);
if (navigator.onLine && !syncing) setStatus();
}, 60_000);
// Big-clock counter updates once a second without re-deriving state.
setInterval(tickBigClock, 1000);
// Periodic pull from server so other clients' changes show up.
setInterval(sync, SYNC_POLL_MS);
setInterval(syncConfig, SYNC_POLL_MS);
// Service worker
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
window.addEventListener("load", () => { window.addEventListener("load", () => {
navigator.serviceWorker.register("sw.js").catch(err => console.error("SW", err)); navigator.serviceWorker.register("sw.js").catch(err => console.error("SW", err));
}); });
} }
// First paint + initial sync. // ---------- auth gate ----------
setStatus(); // The tracker only boots once we know who the user is. startApp() does the
render(); // first paint, initial sync, and starts the periodic timers — guarded so it
sync(); // runs at most once per page load even if login and the session check race.
syncConfig(); 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.
setInterval(() => {
const evs = live();
renderHeader();
renderBigClock(evs);
renderStats(evs);
renderLasts(evs);
renderTiming(evs);
renderSleepWindows(evs);
renderWakeWindows(evs);
renderWeekly(evs);
if (navigator.onLine && !syncing) setStatus();
}, 60_000);
setInterval(tickBigClock, 1000);
setInterval(sync, SYNC_POLL_MS);
setInterval(syncConfig, SYNC_POLL_MS);
setStatus();
render();
sync();
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();
})();
})(); })();
+63
View File
@@ -9,8 +9,45 @@
<link rel="icon" href="icon.svg" type="image/svg+xml" /> <link rel="icon" href="icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="icon.svg" /> <link rel="apple-touch-icon" href="icon.svg" />
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
<script>
// Apply a saved theme before first paint so there's no light/dark flash.
// No saved choice → leave it to the prefers-color-scheme media query.
(function () {
try {
var t = localStorage.getItem("puppy-tracker:theme");
if (t === "light" || t === "dark") document.documentElement.dataset.theme = t;
} catch (e) {}
})();
</script>
</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 +55,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 +187,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">
@@ -159,10 +198,34 @@
<label>Birthday <label>Birthday
<input type="date" id="settings-birthday" /> <input type="date" id="settings-birthday" />
</label> </label>
<label class="toggle-row">
<span>Dark mode</span>
<input type="checkbox" id="settings-theme" role="switch" class="switch" />
</label>
<menu> <menu>
<button value="cancel" class="ghost">Cancel</button> <button value="cancel" class="ghost">Cancel</button>
<button value="save" id="settings-save">Save</button> <button value="save" id="settings-save">Save</button>
</menu> </menu>
<hr class="settings-sep" />
<button type="button" id="delete-account-btn" class="danger danger-block">Delete account…</button>
</form>
</dialog>
<dialog id="delete-account-dialog">
<form method="dialog" id="delete-account-form">
<h3>Delete account</h3>
<p class="danger-text">
This permanently deletes your account and <strong>all its data</strong>
every event, photo and your puppy profile. This can't be undone.
</p>
<label>Confirm your password
<input type="password" id="delete-account-password" autocomplete="current-password" />
</label>
<p id="delete-account-error" class="auth-error" hidden></p>
<menu>
<button value="cancel" class="ghost">Cancel</button>
<button type="button" id="delete-account-confirm" class="danger">Delete forever</button>
</menu>
</form> </form>
</dialog> </dialog>
+131 -1
View File
@@ -17,8 +17,12 @@
--shadow: 0 1px 2px rgba(20, 14, 60, 0.05), 0 4px 16px rgba(20, 14, 60, 0.05); --shadow: 0 1px 2px rgba(20, 14, 60, 0.05), 0 4px 16px rgba(20, 14, 60, 0.05);
} }
/* Dark palette. Two triggers, same values (CSS has no way to share them):
system prefers dark and the user hasn't forced light, OR the user picked
dark via the Settings toggle. The [data-theme] attribute (set from JS) beats
the media query on specificity, so an explicit choice always wins. */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root:not([data-theme="light"]) {
--bg: #14131a; --bg: #14131a;
--surface: #1f1e28; --surface: #1f1e28;
--text: #ebeaf2; --text: #ebeaf2;
@@ -29,6 +33,16 @@
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.3); --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.3);
} }
} }
:root[data-theme="dark"] {
--bg: #14131a;
--surface: #1f1e28;
--text: #ebeaf2;
--muted: #9c9bab;
--accent: #a690ff;
--accent-soft: #2a2640;
--border: #2c2a3a;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.3);
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -533,3 +547,119 @@ 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; }
/* ---------- delete account ---------- */
.settings-sep {
border: none;
border-top: 1px solid var(--border);
margin: 18px 0 12px;
}
.danger-block { width: 100%; }
.danger-text {
font-size: 0.9rem;
color: var(--muted);
margin: 0 0 14px;
line-height: 1.4;
}
.danger-text strong { color: var(--danger); }
/* ---------- settings toggle switch ---------- */
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.toggle-row > span { color: var(--text); font-size: 0.95rem; }
input.switch {
appearance: none;
-webkit-appearance: none;
width: 44px;
height: 26px;
margin: 0;
border-radius: 13px;
background: var(--border);
position: relative;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s ease;
}
input.switch::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: transform 0.15s ease;
}
input.switch:checked { background: var(--accent); }
input.switch:checked::after { transform: translateX(18px); }
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = "puppy-tracker-v5"; const CACHE = "puppy-tracker-v8";
const PHOTO_CACHE = "puppy-tracker-photos-v1"; const PHOTO_CACHE = "puppy-tracker-photos-v1";
const ASSETS = [ const ASSETS = [
"./", "./",