Compare commits
4 Commits
bb7373166e
...
9c0427a1ec
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c0427a1ec | |||
| 5c016ca49e | |||
| acf2931fb4 | |||
| 9207aaa4aa |
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
|
return cs.get(userID), nil
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
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()
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
// 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 s.all(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
out := make([]Event, 0, len(s.data))
|
|
||||||
for _, e := range s.data {
|
|
||||||
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))
|
||||||
|
|||||||
+231
-17
@@ -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,6 +1619,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 +1682,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();
|
||||||
|
})();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user