Compare commits
64 Commits
da692d84da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f9894abfc9 | |||
| 09d9d38c12 | |||
| a44c75d2d4 | |||
| e2f99590f1 | |||
| 52c50c97b1 | |||
| 1ed325b834 | |||
| 69e312175b | |||
| a2c9aa9716 | |||
| 12a5bd0548 | |||
| 01d64b682b | |||
| 8157e95066 | |||
| 86e51bb851 | |||
| c2f74e64c8 | |||
| 26ebe3bd86 | |||
| 374e630d8f | |||
| 22a5ea76aa | |||
| 0ebaa11c93 | |||
| 897311465c | |||
| 8fe8f9d417 | |||
| c24d59e672 | |||
| a1a6ec8720 | |||
| 9b6ee20b07 | |||
| 3e3eee678e | |||
| 6d54ecf238 | |||
| 83af641a12 | |||
| ba6e2c5e7d | |||
| 0e24ac989d | |||
| ab0e51108c | |||
| cebe923d68 | |||
| 8dc4fca4e2 | |||
| b030cfb72a | |||
| 7cb59a7a23 | |||
| 131346a7c7 | |||
| b608dfc342 | |||
| cdd701f0b4 | |||
| 8981214e55 | |||
| b6301156f9 | |||
| 6b12820592 | |||
| cee4455651 | |||
| 695c030f33 | |||
| a0e084dae8 | |||
| c103082ca5 | |||
| 0009e63d23 | |||
| a202f3e929 | |||
| 3a829161e3 | |||
| 68964b55e4 | |||
| c8dceb14d4 | |||
| 1c4bea51df | |||
| 094af3906d | |||
| 2b4731185c | |||
| ced415c3a5 | |||
| c1ece16347 | |||
| 2e817a086d | |||
| 561b98b64f | |||
| f4ce7dcb54 | |||
| 16799ad7c7 | |||
| d267ebef86 | |||
| 01ad078b2a | |||
| 8f4034ef47 | |||
| dbcac0653e | |||
| 9c0427a1ec | |||
| 5c016ca49e | |||
| acf2931fb4 | |||
| 9207aaa4aa |
@@ -0,0 +1,11 @@
|
||||
# puppy-tracker — notes for Claude
|
||||
|
||||
- **Every user-visible change must add an entry to `src/changelog.json`**
|
||||
(newest first, `{ "date": "YYYY-MM-DD", "text": "..." }`). The update banner
|
||||
shows users the entries their new version adds compared to the build they're
|
||||
running, so a missing entry means the change ships silently. Internal-only
|
||||
changes (refactors, server plumbing) don't need one.
|
||||
- Commit messages: no Claude attribution / Co-Authored-By lines.
|
||||
- Run locally: `nix develop -c sh -c 'cd server && go run . -static ../src -data /tmp/puppy.db -invite-code letmein'`
|
||||
- Architecture details are in `README.md` (offline-first PWA, LWW sync with
|
||||
tombstones, per-user data scoping).
|
||||
@@ -1,7 +1,7 @@
|
||||
# puppy-tracker
|
||||
|
||||
A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, poos, and
|
||||
weight.
|
||||
A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, poos,
|
||||
weight, and training.
|
||||
The browser is the primary client; a small Go server provides a shared
|
||||
source-of-truth and sync between devices.
|
||||
|
||||
@@ -15,14 +15,26 @@ source-of-truth and sync between devices.
|
||||
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
|
||||
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
|
||||
when online; static assets are still cached for offline use.
|
||||
- The puppy's name and birthday are a shared profile stored on the host
|
||||
- Training exercises (name + how-to instructions) are their own synced
|
||||
collection with the same contract as events (UUIDs, last-write-wins,
|
||||
tombstones) via `POST /api/exercises/sync`. Training sessions are ordinary
|
||||
events (`type: "training"`) referencing an exercise by id, so they ride the
|
||||
event sync unchanged.
|
||||
- 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
|
||||
of being configured per-client. The client caches the last-seen values in
|
||||
`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
|
||||
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` /
|
||||
`sync error` / `offline`. Tap it to force-sync.
|
||||
@@ -35,7 +47,9 @@ puppy-tracker/
|
||||
├── module.nix # systemd unit, StateDirectory, hardening
|
||||
├── server/
|
||||
│ ├── 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
|
||||
├── index.html
|
||||
├── app.js
|
||||
@@ -51,10 +65,55 @@ puppy-tracker/
|
||||
nix run # http://localhost:8080, data in $XDG_DATA_HOME/puppy-tracker
|
||||
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):
|
||||
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.
|
||||
|
||||
## Pedigree lookup
|
||||
|
||||
Set your dog's SKK chip or registration number in **Settings** (it rides the
|
||||
synced profile, next to name and birthday). Once set, a 🌳 button appears that
|
||||
opens a page rendering that dog's ancestry as a tree.
|
||||
|
||||
- SKK has no public API, so the server drives the interactive site the way a
|
||||
browser would: it resolves the id to SKK's internal dog id, fetches the
|
||||
pedigree page (7 generations per request), and follows each generation's leaves
|
||||
deeper. A lookup returns the first generations immediately and keeps crawling in
|
||||
the background; the client polls and fills the tree in as ancestors arrive.
|
||||
- Because a deep crawl is dozens of sequential upstream requests, finished trees
|
||||
are cached per dog in the `pedigree_cache` table (pedigrees don't change), and
|
||||
the id→dog resolution is memoised, so a dog is only ever crawled once and repeat
|
||||
opens hit SKK zero times. The client also mirrors the finished tree in
|
||||
`localStorage`, so the page paints instantly and shows the last-known tree even
|
||||
offline.
|
||||
- The lookup is behind auth like the rest of `/api/*`; the first trace of a new
|
||||
dog needs to reach SKK, but after that it works from cache (including offline).
|
||||
|
||||
## Use it on NixOS
|
||||
|
||||
In your system flake:
|
||||
@@ -73,6 +132,11 @@ In your system flake:
|
||||
enable = true;
|
||||
port = 8080;
|
||||
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 +145,13 @@ In your system flake:
|
||||
}
|
||||
```
|
||||
|
||||
The server runs as a `DynamicUser` systemd unit. Data is stored at
|
||||
`/var/lib/puppy-tracker/events.json` via `StateDirectory`.
|
||||
The server runs as a `DynamicUser` systemd unit. Data is stored in a SQLite
|
||||
database at `/var/lib/puppy-tracker/puppy.db` via `StateDirectory` (with photos
|
||||
alongside it under `photos/`).
|
||||
|
||||
## Notes
|
||||
|
||||
- No auth. Intended for a home LAN. If exposing publicly, terminate TLS and
|
||||
authenticate with a reverse proxy in front (Caddy / nginx / Tailscale Funnel).
|
||||
- Accounts gate access, but there is no built-in TLS. If exposing publicly,
|
||||
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";
|
||||
version = "0.2.0";
|
||||
src = ./server;
|
||||
vendorHash = null; # no external dependencies
|
||||
vendorHash = "sha256-J1lYhwbaRh2PeAh3SzyB9WgUZa1gCNXBWdaJ5isUedA=";
|
||||
# Pure-Go build for a tiny static binary.
|
||||
env.CGO_ENABLED = "0";
|
||||
ldflags = [ "-s" "-w" ];
|
||||
@@ -76,7 +76,7 @@
|
||||
exec ${server}/bin/puppy-tracker-server \
|
||||
-addr "''${PUPPY_ADDR:-:8080}" \
|
||||
-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)";
|
||||
};
|
||||
|
||||
+29
-3
@@ -27,6 +27,28 @@ in
|
||||
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 {
|
||||
type = lib.types.package;
|
||||
default = serverPkg;
|
||||
@@ -49,12 +71,16 @@ in
|
||||
after = [ "network.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = lib.concatStringsSep " " [
|
||||
ExecStart = lib.concatStringsSep " " ([
|
||||
"${cfg.package}/bin/puppy-tracker-server"
|
||||
"-addr ${cfg.address}:${toString cfg.port}"
|
||||
"-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;
|
||||
StateDirectory = "puppy-tracker";
|
||||
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
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
|
||||
}
|
||||
if _, err := a.db.Exec(`UPDATE exercises 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 exercises 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)
|
||||
}
|
||||
+19
-1
@@ -1,3 +1,21 @@
|
||||
module puppy-tracker
|
||||
|
||||
go 1.22
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/net v0.57.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,55 @@
|
||||
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/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
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=
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
// Small helpers over golang.org/x/net/html for walking the SKK pedigree markup.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func attr(n *html.Node, key string) string {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findByID returns the first element in the tree with the given id attribute.
|
||||
func findByID(n *html.Node, id string) *html.Node {
|
||||
if n.Type == html.ElementNode && attr(n, "id") == id {
|
||||
return n
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if got := findByID(c, id); got != nil {
|
||||
return got
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// descendants returns every element with the given tag anywhere under n, in
|
||||
// document order.
|
||||
func descendants(n *html.Node, tag string) []*html.Node {
|
||||
var out []*html.Node
|
||||
var walk func(*html.Node)
|
||||
walk = func(x *html.Node) {
|
||||
for c := x.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.ElementNode && c.Data == tag {
|
||||
out = append(out, c)
|
||||
}
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return out
|
||||
}
|
||||
|
||||
// directChildElements returns the immediate element children of n with the tag.
|
||||
func directChildElements(n *html.Node, tag string) []*html.Node {
|
||||
var out []*html.Node
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.ElementNode && c.Data == tag {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findElement returns the first descendant element with the given tag.
|
||||
func findElement(n *html.Node, tag string) *html.Node {
|
||||
els := descendants(n, tag)
|
||||
if len(els) == 0 {
|
||||
return nil
|
||||
}
|
||||
return els[0]
|
||||
}
|
||||
|
||||
// text concatenates all text under n.
|
||||
func text(n *html.Node) string {
|
||||
var sb strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(x *html.Node) {
|
||||
if x.Type == html.TextNode {
|
||||
sb.WriteString(x.Data)
|
||||
}
|
||||
for c := x.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// normalizeText trims and collapses internal whitespace.
|
||||
func normalizeText(s string) string {
|
||||
return strings.TrimSpace(wsRE.ReplaceAllString(s, " "))
|
||||
}
|
||||
|
||||
// findBoldSpan returns the normalized text of the first bold <span> (how subject
|
||||
// cells carry the registration number), or "".
|
||||
func findBoldSpan(n *html.Node) string {
|
||||
for _, sp := range descendants(n, "span") {
|
||||
if strings.Contains(strings.ReplaceAll(attr(sp, "style"), " ", ""), "font-weight:bold") {
|
||||
if t := normalizeText(text(sp)); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
+650
-160
@@ -1,9 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
@@ -14,17 +19,33 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
At int64 `json:"at"`
|
||||
Note string `json:"note"`
|
||||
PhotoID string `json:"photoId,omitempty"`
|
||||
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
At int64 `json:"at"`
|
||||
Note string `json:"note"`
|
||||
PhotoID string `json:"photoId,omitempty"` // photo UUIDs, comma-separated (legacy events hold one)
|
||||
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
|
||||
Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events
|
||||
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
}
|
||||
|
||||
// Exercise is a user-defined training exercise (e.g. "Sit", "Leash walking"):
|
||||
// a name plus optional instruction text. Training sessions reference one by
|
||||
// ExerciseID on the event. Exercises sync exactly like events: UUID ids,
|
||||
// last-write-wins on UpdatedAt, tombstoned deletes.
|
||||
type Exercise struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Note string `json:"note"` // instructions / reminder how to do it
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
}
|
||||
|
||||
var uuidRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
@@ -39,161 +60,462 @@ func validBirthday(s string) bool { return s == "" || birthdayRE.MatchString(s)
|
||||
// every client sees the same values without configuring each device. UpdatedAt
|
||||
// drives last-write-wins, mirroring how events sync.
|
||||
type Config struct {
|
||||
Name string `json:"name"`
|
||||
Birthday string `json:"birthday"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
Name string `json:"name"`
|
||||
Birthday string `json:"birthday"`
|
||||
// PedigreeID is the dog's SKK chip or registration number. When set, the app
|
||||
// unlocks the pedigree view and looks this dog up; empty means no pedigree.
|
||||
PedigreeID string `json:"pedigreeId"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ConfigStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
cfg Config
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func newConfigStore(path string) (*ConfigStore, error) {
|
||||
cs := &ConfigStore{path: path}
|
||||
f, err := os.Open(path)
|
||||
func newConfigStore(db *sql.DB) *ConfigStore {
|
||||
return &ConfigStore{db: db}
|
||||
}
|
||||
|
||||
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, pedigree_id, updated FROM config WHERE user_id = ?`, userID,
|
||||
).Scan(&c.Name, &c.Birthday, &c.PedigreeID, &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) {
|
||||
// Name/birthday/updated are last-write-wins: the incoming row replaces the
|
||||
// stored one only when strictly newer. The pedigree id is stickier — an empty
|
||||
// incoming value never clears a stored one, so a clock race between devices
|
||||
// can't drop it; when both are set, the newer profile's id wins with the rest.
|
||||
_, err := cs.db.Exec(`
|
||||
INSERT INTO config (user_id, name, birthday, pedigree_id, updated)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
name = excluded.name, birthday = excluded.birthday,
|
||||
pedigree_id = CASE WHEN excluded.pedigree_id != '' THEN excluded.pedigree_id ELSE config.pedigree_id END,
|
||||
updated = excluded.updated
|
||||
WHERE excluded.updated > config.updated`,
|
||||
userID, in.Name, in.Birthday, in.PedigreeID, in.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return cs, nil
|
||||
}
|
||||
return nil, err
|
||||
return Config{}, err
|
||||
}
|
||||
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
|
||||
// Adopt a pedigree id the server is missing even from an older-stamped profile,
|
||||
// so a device that set it isn't blocked by another device's newer name/birthday
|
||||
// edit. (A set id is only ever changed by a newer profile that also sets one.)
|
||||
if in.PedigreeID != "" {
|
||||
if _, err := cs.db.Exec(
|
||||
`UPDATE config SET pedigree_id = ? WHERE user_id = ? AND pedigree_id = ''`,
|
||||
in.PedigreeID, userID); err != nil {
|
||||
return Config{}, 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)
|
||||
return cs.get(userID), nil
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
data map[string]Event
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func newStore(path string) (*Store, error) {
|
||||
s := &Store{path: path, data: map[string]Event{}}
|
||||
if err := s.load(); err != nil {
|
||||
func newStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// 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 s, nil
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
func (s *Store) load() error {
|
||||
f, err := os.Open(s.path)
|
||||
// The WHERE clause on the upsert is the last-write-wins rule: an incoming
|
||||
// event only overwrites the stored one when its updatedAt is strictly newer.
|
||||
// The `events.user_id = excluded.user_id` guard means one user can never
|
||||
// clobber another's row even if a client forges a colliding event ID —
|
||||
// the row stays put and, because reads are scoped, stays invisible to them.
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, 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,
|
||||
grams = excluded.grams, exercise_id = excluded.exercise_id,
|
||||
updated = excluded.updated, deleted = excluded.deleted
|
||||
WHERE excluded.updated > events.updated
|
||||
AND events.user_id = excluded.user_id`)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
return nil, 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 {
|
||||
if ce.ID == "" {
|
||||
continue
|
||||
}
|
||||
existing, ok := s.data[ce.ID]
|
||||
if !ok || ce.UpdatedAt > existing.UpdatedAt {
|
||||
s.data[ce.ID] = ce
|
||||
if _, err := stmt.Exec(
|
||||
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.saveLocked(); err != nil {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Event, 0, len(s.data))
|
||||
for _, e := range s.data {
|
||||
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, grams, exercise_id, 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.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ExerciseStore mirrors Store for the exercises collection: same LWW sync by
|
||||
// UpdatedAt, same user_id guard against cross-user id collisions, same
|
||||
// tombstone propagation.
|
||||
type ExerciseStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func newExerciseStore(db *sql.DB) *ExerciseStore {
|
||||
return &ExerciseStore{db: db}
|
||||
}
|
||||
|
||||
func (s *ExerciseStore) sync(userID string, client []Exercise) ([]Exercise, error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO exercises (id, name, note, updated, deleted, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name, note = excluded.note,
|
||||
updated = excluded.updated, deleted = excluded.deleted
|
||||
WHERE excluded.updated > exercises.updated
|
||||
AND exercises.user_id = excluded.user_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, ce := range client {
|
||||
if ce.ID == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.Exec(
|
||||
ce.ID, ce.Name, ce.Note, ce.UpdatedAt, ce.Deleted, userID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.all(userID)
|
||||
}
|
||||
|
||||
func (s *ExerciseStore) all(userID string) ([]Exercise, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, name, note, updated, deleted
|
||||
FROM exercises WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]Exercise, 0)
|
||||
for rows.Next() {
|
||||
var e Exercise
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Note, &e.UpdatedAt, &e.Deleted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
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,
|
||||
grams REAL NOT NULL DEFAULT 0,
|
||||
exercise_id TEXT NOT NULL DEFAULT '',
|
||||
updated INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
user_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
|
||||
CREATE TABLE IF NOT EXISTS exercises (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
user_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_exercises_user ON exercises(user_id);
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
birthday TEXT NOT NULL DEFAULT '',
|
||||
pedigree_id 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
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS pedigree_cache (
|
||||
hundid TEXT PRIMARY KEY,
|
||||
subject TEXT NOT NULL DEFAULT '',
|
||||
nodes TEXT NOT NULL DEFAULT '',
|
||||
generations INTEGER NOT NULL DEFAULT 0,
|
||||
fetched INTEGER NOT NULL DEFAULT 0
|
||||
);`
|
||||
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
|
||||
}
|
||||
}
|
||||
hasExercise, err := columnExists(db, "events", "exercise_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasExercise {
|
||||
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN exercise_id TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
hasGrams, err := columnExists(db, "events", "grams")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasGrams {
|
||||
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN grams REAL NOT NULL DEFAULT 0`); 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
|
||||
}
|
||||
}
|
||||
}
|
||||
hasPedigree, err := columnExists(db, "config", "pedigree_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasPedigree {
|
||||
if _, err := db.Exec(`ALTER TABLE config ADD COLUMN pedigree_id TEXT NOT NULL DEFAULT ''`); 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 {
|
||||
@@ -205,36 +527,142 @@ type syncResponse struct {
|
||||
ServerNow int64 `json:"serverNow"`
|
||||
}
|
||||
|
||||
type exerciseSyncRequest struct {
|
||||
Exercises []Exercise `json:"exercises"`
|
||||
}
|
||||
|
||||
type exerciseSyncResponse struct {
|
||||
Exercises []Exercise `json:"exercises"`
|
||||
}
|
||||
|
||||
type cacheControlFS struct {
|
||||
root http.FileSystem
|
||||
}
|
||||
|
||||
func (c cacheControlFS) Open(name string) (http.File, error) { return c.root.Open(name) }
|
||||
|
||||
// swVersion computes a short content hash over the static assets the service
|
||||
// worker caches. The hash is substituted into sw.js at serve time (see
|
||||
// serveSW), so the served worker changes whenever any asset changes — that byte
|
||||
// difference is what makes the browser install a new worker and prompt to
|
||||
// reload. Results are memoised and only recomputed when a file's size or modtime
|
||||
// changes, so the steady state is a handful of cheap stats.
|
||||
type swVersion struct {
|
||||
dir string
|
||||
files []string
|
||||
|
||||
mu sync.Mutex
|
||||
sig string // signature of (name,size,modtime) across files
|
||||
hash string
|
||||
}
|
||||
|
||||
func newSWVersion(dir string) *swVersion {
|
||||
// The assets the SW caches and that actually change between builds. sw.js
|
||||
// itself is excluded: it carries the placeholder, so hashing it would be
|
||||
// circular and it never changes except when we edit it here.
|
||||
return &swVersion{
|
||||
dir: dir,
|
||||
files: []string{"index.html", "style.css", "app.js", "manifest.json", "icon.svg", "changelog.json"},
|
||||
}
|
||||
}
|
||||
|
||||
func (v *swVersion) hashHex() string {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
var sb strings.Builder
|
||||
for _, name := range v.files {
|
||||
if info, err := os.Stat(filepath.Join(v.dir, name)); err == nil {
|
||||
fmt.Fprintf(&sb, "%s:%d:%d;", name, info.Size(), info.ModTime().UnixNano())
|
||||
}
|
||||
}
|
||||
sig := sb.String()
|
||||
if sig == v.sig && v.hash != "" {
|
||||
return v.hash
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
for _, name := range v.files {
|
||||
f, err := os.Open(filepath.Join(v.dir, name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_, _ = io.Copy(h, f)
|
||||
f.Close()
|
||||
}
|
||||
v.sig = sig
|
||||
v.hash = hex.EncodeToString(h.Sum(nil))[:12]
|
||||
return v.hash
|
||||
}
|
||||
|
||||
// serveSW renders sw.js with the current build hash substituted for its
|
||||
// placeholder. no-cache lets the browser refetch and byte-compare on each
|
||||
// update check; the substituted hash is what actually differs between builds.
|
||||
func serveSW(w http.ResponseWriter, staticDir string, ver *swVersion) {
|
||||
data, err := os.ReadFile(filepath.Join(staticDir, "sw.js"))
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
out := bytes.ReplaceAll(data, []byte("__BUILD_HASH__"), []byte(ver.hashHex()))
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, _ = w.Write(out)
|
||||
}
|
||||
|
||||
func main() {
|
||||
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")
|
||||
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()
|
||||
|
||||
store, err := newStore(*dataPath)
|
||||
db, err := openDB(*dataPath)
|
||||
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"))
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
store := newStore(db)
|
||||
configStore := newConfigStore(db)
|
||||
exerciseStore := newExerciseStore(db)
|
||||
pedigrees := newPedManager(db)
|
||||
|
||||
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
|
||||
if err := os.MkdirAll(photosDir, 0o755); err != nil {
|
||||
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.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 {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
@@ -244,7 +672,7 @@ func main() {
|
||||
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
merged, err := store.sync(req.Events)
|
||||
merged, err := store.sync(userID(r), req.Events)
|
||||
if err != nil {
|
||||
log.Printf("sync: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
@@ -256,11 +684,34 @@ func main() {
|
||||
Events: merged,
|
||||
ServerNow: time.Now().UnixMilli(),
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
// GET /api/config — return the shared puppy profile.
|
||||
// POST /api/exercises/sync — merge the caller's training exercises, same
|
||||
// LWW contract as /api/events/sync.
|
||||
mux.HandleFunc("/api/exercises/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req exerciseSyncRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
merged, err := exerciseStore.sync(userID(r), req.Exercises)
|
||||
if err != nil {
|
||||
log.Printf("exercises sync: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(exerciseSyncResponse{Exercises: merged})
|
||||
}))
|
||||
|
||||
// GET /api/config — return the caller's puppy profile.
|
||||
// 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) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
@@ -268,7 +719,7 @@ func main() {
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeConfig(configStore.get())
|
||||
writeConfig(configStore.get(userID(r)))
|
||||
case http.MethodPut, http.MethodPost:
|
||||
var in Config
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil {
|
||||
@@ -279,11 +730,15 @@ func main() {
|
||||
if len(in.Name) > 100 {
|
||||
in.Name = in.Name[:100]
|
||||
}
|
||||
in.PedigreeID = strings.TrimSpace(in.PedigreeID)
|
||||
if len(in.PedigreeID) > 64 {
|
||||
in.PedigreeID = in.PedigreeID[:64]
|
||||
}
|
||||
if !validBirthday(in.Birthday) {
|
||||
http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
merged, err := configStore.merge(in)
|
||||
merged, err := configStore.merge(userID(r), in)
|
||||
if err != nil {
|
||||
log.Printf("config save: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
@@ -293,7 +748,25 @@ func main() {
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
// POST /api/pedigree — resolve a dog by chip / registration number / name and
|
||||
// return its ancestry tree (immediately for the first generations, then a
|
||||
// background crawl deepens it). GET /api/pedigree/status polls that crawl.
|
||||
mux.HandleFunc("/api/pedigree", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
pedigrees.handleLookup(w, r)
|
||||
}))
|
||||
mux.HandleFunc("/api/pedigree/status", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
pedigrees.handleStatus(w, r)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
@@ -302,7 +775,7 @@ func main() {
|
||||
// POST /api/photos — multipart upload with form fields `id` (UUID) and
|
||||
// `file` (JPEG). The client generates the ID so the event referencing
|
||||
// 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 {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
@@ -324,7 +797,14 @@ func main() {
|
||||
}
|
||||
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"
|
||||
dst, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
@@ -351,11 +831,12 @@ func main() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
|
||||
})
|
||||
}))
|
||||
|
||||
// GET /api/photos/<id> — serves the JPEG. Photos are immutable per ID
|
||||
// so we mark them as long-lived; both browser and SW can cache freely.
|
||||
mux.HandleFunc("/api/photos/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// GET /api/photos/<id> — serves the caller's own JPEG. Photos are immutable
|
||||
// per ID so we mark them as long-lived; the private cache keeps them per
|
||||
// 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 {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
@@ -365,7 +846,7 @@ func main() {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(photosDir, id+".jpg")
|
||||
path := filepath.Join(photosDir, userID(r), id+".jpg")
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
@@ -378,19 +859,28 @@ func main() {
|
||||
return
|
||||
}
|
||||
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)
|
||||
})
|
||||
}))
|
||||
|
||||
if *staticDir != "" {
|
||||
fileServer := http.FileServer(http.Dir(*staticDir))
|
||||
swVer := newSWVersion(*staticDir)
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// PWA: sw.js and manifest.json must revalidate so updates propagate.
|
||||
if r.URL.Path == "/sw.js" || r.URL.Path == "/manifest.json" {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
// The service worker is rendered with a per-build hash of the static
|
||||
// assets, so any asset change yields a byte-different sw.js — that's
|
||||
// what makes the browser detect an update and show the reload prompt.
|
||||
if r.URL.Path == "/sw.js" {
|
||||
serveSW(w, *staticDir, swVer)
|
||||
return
|
||||
}
|
||||
// SPA fallback: unknown paths -> index.html (so deep links work).
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
// All static assets revalidate on every request (cheap 304s via
|
||||
// Last-Modified). Offline/fast loads are the service worker
|
||||
// cache's job; leaving these to the browser's heuristic HTTP
|
||||
// caching let a stale app.js pair with a fresh index.html.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
candidate := filepath.Join(*staticDir, filepath.FromSlash(r.URL.Path))
|
||||
if r.URL.Path != "/" {
|
||||
if info, err := os.Stat(candidate); err != nil || info.IsDir() {
|
||||
|
||||
@@ -0,0 +1,912 @@
|
||||
package main
|
||||
|
||||
// Pedigree lookup: resolve a dog by chip / registration number / name against
|
||||
// SKK (Svenska Kennelklubben) HUNDDATA, then crawl its ancestry and expose it as
|
||||
// an ahnentafel-positioned tree. SKK has no public API, so this scrapes the
|
||||
// interactive ASP.NET WebForms app the same way a browser drives it:
|
||||
//
|
||||
// 1. Resolve — POST Hund_sok.aspx/HundData (a JSON page-method) → hundid.
|
||||
// 2. Fetch — GET Hund_Stamtavla.aspx?hundid=X, then POST ddlGenerationer=7
|
||||
// to render 7 generations in one page; parse its rowspan grid.
|
||||
// 3. Deepen — each generation-7 leaf links via __doPostBack; POST that link
|
||||
// and read the ancestor's hundid out of the response __VIEWSTATE,
|
||||
// then recurse. BFS terminates when the ancestry runs out.
|
||||
//
|
||||
// A deep crawl is dozens of sequential requests, so a lookup returns the first
|
||||
// 7 generations immediately and keeps crawling in the background; the finished
|
||||
// tree is cached per hundid (pedigrees don't change) so a dog is crawled once.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const (
|
||||
skkBase = "https://hundar.skk.se/hunddata/"
|
||||
skkUA = "Mozilla/5.0 (X11; Linux x86_64) puppy-tracker pedigree lookup"
|
||||
skkDelay = 250 * time.Millisecond // politeness between upstream requests
|
||||
crawlGens = 7 // generations SKK renders per page
|
||||
|
||||
maxCrawlPages = 400 // hard caps so a crawl can never run away
|
||||
maxCrawlRequests = 3000
|
||||
crawlDeadline = 5 * time.Minute
|
||||
maxActiveJobs = 4 // concurrent background crawls, total
|
||||
firstPageWait = 20 * time.Second
|
||||
)
|
||||
|
||||
// pedNode is one dog at an ahnentafel position (1 = subject, sire = 2n, dam = 2n+1).
|
||||
type pedNode struct {
|
||||
Reg string `json:"reg,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Titles string `json:"titles,omitempty"`
|
||||
Hundid string `json:"hundid,omitempty"`
|
||||
}
|
||||
|
||||
// pedSubject is the looked-up dog's headline info, from the resolver row.
|
||||
type pedSubject struct {
|
||||
Hundid string `json:"hundid"`
|
||||
Reg string `json:"reg"`
|
||||
Name string `json:"name"`
|
||||
Breed string `json:"breed"`
|
||||
Chip string `json:"chip"`
|
||||
Sex string `json:"sex,omitempty"`
|
||||
}
|
||||
|
||||
// skkClient is a single browser-like session against SKK (cookie jar + UA).
|
||||
type skkClient struct {
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func newSKKClient() (*skkClient, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &skkClient{hc: &http.Client{Jar: jar, Timeout: 30 * time.Second}}, nil
|
||||
}
|
||||
|
||||
func (c *skkClient) do(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", skkUA)
|
||||
return c.hc.Do(req)
|
||||
}
|
||||
|
||||
// warm establishes an ASP.NET session (SessionId + anti-XSRF cookies) that the
|
||||
// resolver and pedigree pages both require.
|
||||
func (c *skkClient) warm(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, skkBase+"Hund_sok.aspx", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// hundDataRow mirrors the fields the resolver page-method returns.
|
||||
type hundDataRow struct {
|
||||
Hundid string `json:"hundid"`
|
||||
Regnr string `json:"Regnr"`
|
||||
Hundnamn string `json:"hundnamn"`
|
||||
Chipnr string `json:"chipnr"`
|
||||
Rastext string `json:"rastext"`
|
||||
Kon string `json:"Kon"`
|
||||
IDnummer string `json:"IDnummer"`
|
||||
Antal string `json:"Antal"`
|
||||
IsError bool `json:"IsError"`
|
||||
ErrorText string `json:"ErrorText"`
|
||||
}
|
||||
|
||||
var digitsRE = regexp.MustCompile(`^\d+$`)
|
||||
|
||||
// resolve turns a user query (chip number, registration number, or name) into
|
||||
// matching dogs. The field is chosen by shape: a long all-digit string is a
|
||||
// chip; anything with a letter or slash is a registration number; otherwise a
|
||||
// name search (which may return several rows to disambiguate).
|
||||
func (c *skkClient) resolve(ctx context.Context, q string) ([]hundDataRow, error) {
|
||||
body := map[string]string{
|
||||
"txtRegnr": "", "txtIDnummer": "", "txtChipnr": "",
|
||||
"txtHundnamn": "", "ddlRasIn": "", "ddlKon": "", "txtLicensnr": "",
|
||||
}
|
||||
switch {
|
||||
case digitsRE.MatchString(q) && len(q) >= 10:
|
||||
body["txtChipnr"] = q
|
||||
case strings.ContainsAny(q, "/") || strings.IndexFunc(q, isLetter) >= 0:
|
||||
body["txtRegnr"] = q
|
||||
default:
|
||||
body["txtHundnamn"] = q
|
||||
}
|
||||
buf, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
skkBase+"Hund_sok.aspx/HundData", bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json;charset=utf-8")
|
||||
req.Header.Set("Referer", skkBase+"Hund_sok.aspx")
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("resolver HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var wrap struct {
|
||||
D []hundDataRow `json:"d"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("resolver response: %w", err)
|
||||
}
|
||||
// SKK signals "no matches" (and other soft failures like a query needing more
|
||||
// input) via a single IsError row rather than an HTTP error. Treat it as an
|
||||
// empty result so the caller reports a clean "not found" instead of a 502.
|
||||
if len(wrap.D) == 1 && wrap.D[0].IsError {
|
||||
return nil, nil
|
||||
}
|
||||
return wrap.D, nil
|
||||
}
|
||||
|
||||
func isLetter(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
}
|
||||
|
||||
// fetchPage GETs a dog's pedigree then POSTs ddlGenerationer=7 (with titles on)
|
||||
// to render 7 generations, returning that page's HTML. The returned HTML is used
|
||||
// for both grid parsing and the __doPostBack calls that resolve its leaf dogs,
|
||||
// so its hidden fields (viewstate / event validation) match its ctl ids.
|
||||
func (c *skkClient) fetchPage(ctx context.Context, hundid string) (string, string, error) {
|
||||
pageURL := skkBase + "Hund_Stamtavla.aspx?hundid=" + url.QueryEscape(hundid)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
|
||||
if err != nil {
|
||||
return "", pageURL, err
|
||||
}
|
||||
req.Header.Set("Referer", skkBase+"Hund_sok.aspx")
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return "", pageURL, err
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return "", pageURL, err
|
||||
}
|
||||
first := string(raw)
|
||||
|
||||
form := hiddenFields(first)
|
||||
form.Set("ctl00$bodyContent$ddlGenerationer", strconv.Itoa(crawlGens))
|
||||
form.Set("ctl00$bodyContent$ddlTitlar", "J")
|
||||
form.Set("__EVENTTARGET", "ctl00$bodyContent$ddlGenerationer")
|
||||
form.Set("__EVENTARGUMENT", "")
|
||||
html7, err := c.postForm(ctx, pageURL, form)
|
||||
if err != nil {
|
||||
return "", pageURL, err
|
||||
}
|
||||
return html7, pageURL, nil
|
||||
}
|
||||
|
||||
func (c *skkClient) postForm(ctx context.Context, pageURL string, form url.Values) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, pageURL,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", pageURL)
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
var (
|
||||
viewstateRE = regexp.MustCompile(`name="__VIEWSTATE" id="__VIEWSTATE" value="([^"]+)"`)
|
||||
// The clicked dog's internal hundid, encoded in the response viewstate as the
|
||||
// string "hundid", a type byte (\x05), a length byte, then ASCII digits.
|
||||
vsHundidRE = regexp.MustCompile(`(?s)hundid\x05.(\d+)`)
|
||||
)
|
||||
|
||||
// postbackHundid clicks an ancestor's __doPostBack link on the given page and
|
||||
// recovers that ancestor's hundid from the response viewstate. The rendered
|
||||
// pedigree table never re-roots on such a click, but the viewstate carries the
|
||||
// clicked dog's id — which is exactly the handle needed to fetch its own page.
|
||||
func (c *skkClient) postbackHundid(ctx context.Context, pageHTML, ctlid, pageURL string) (string, error) {
|
||||
form := hiddenFields(pageHTML)
|
||||
form.Set("ctl00$bodyContent$ddlGenerationer", strconv.Itoa(crawlGens))
|
||||
form.Set("ctl00$bodyContent$ddlTitlar", "J")
|
||||
form.Set("__EVENTTARGET", ctlid)
|
||||
form.Set("__EVENTARGUMENT", "")
|
||||
resp, err := c.postForm(ctx, pageURL, form)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
m := viewstateRE.FindStringSubmatch(resp)
|
||||
if m == nil {
|
||||
return "", nil
|
||||
}
|
||||
dec := decodeB64(m[1])
|
||||
mm := vsHundidRE.FindSubmatch(dec)
|
||||
if mm == nil {
|
||||
return "", nil
|
||||
}
|
||||
return string(mm[1]), nil
|
||||
}
|
||||
|
||||
func decodeB64(s string) []byte {
|
||||
if m := len(s) % 4; m != 0 {
|
||||
s += strings.Repeat("=", 4-m)
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// hiddenFields collects every <input type=hidden> on a page into a form value
|
||||
// set, so an ASP.NET postback can echo back __VIEWSTATE / __EVENTVALIDATION etc.
|
||||
func hiddenFields(pageHTML string) url.Values {
|
||||
vals := url.Values{}
|
||||
node, err := html.Parse(strings.NewReader(pageHTML))
|
||||
if err != nil {
|
||||
return vals
|
||||
}
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "input" {
|
||||
var typ, name, val string
|
||||
for _, a := range n.Attr {
|
||||
switch a.Key {
|
||||
case "type":
|
||||
typ = a.Val
|
||||
case "name":
|
||||
name = a.Val
|
||||
case "value":
|
||||
val = a.Val
|
||||
}
|
||||
}
|
||||
if typ == "hidden" && name != "" {
|
||||
vals.Set(name, val)
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(node)
|
||||
return vals
|
||||
}
|
||||
|
||||
// gridCell is a parsed pedigree table cell.
|
||||
type gridCell struct {
|
||||
reg, name, titles, ctlid string
|
||||
occupied bool
|
||||
}
|
||||
|
||||
var doPostBackRE = regexp.MustCompile(`__doPostBack\('([^']+)'`)
|
||||
var wsRE = regexp.MustCompile(`\s+`)
|
||||
|
||||
// parseGrid reconstructs the rowspan-based pedigree table into columns. Column c
|
||||
// holds 2^c cells top-to-bottom; a cell's index within its column is its
|
||||
// ahnentafel offset. Returns column index -> ordered cells.
|
||||
func parseGrid(pageHTML string) map[int][]gridCell {
|
||||
node, err := html.Parse(strings.NewReader(pageHTML))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
tbl := findByID(node, "bodyContent_tblStamtavla")
|
||||
if tbl == nil {
|
||||
return nil
|
||||
}
|
||||
occ := map[[2]int]bool{}
|
||||
type placed struct {
|
||||
r, c int
|
||||
cell gridCell
|
||||
}
|
||||
var placedCells []placed
|
||||
r := 0
|
||||
for _, tr := range descendants(tbl, "tr") {
|
||||
c := 0
|
||||
for _, td := range directChildElements(tr, "td") {
|
||||
for occ[[2]int{r, c}] {
|
||||
c++
|
||||
}
|
||||
rs := 1
|
||||
if v := attr(td, "rowspan"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
rs = n
|
||||
}
|
||||
}
|
||||
for dr := 0; dr < rs; dr++ {
|
||||
occ[[2]int{r + dr, c}] = true
|
||||
}
|
||||
placedCells = append(placedCells, placed{r, c, parseCell(td)})
|
||||
c++
|
||||
}
|
||||
r++
|
||||
}
|
||||
byCol := map[int][]placed{}
|
||||
for _, p := range placedCells {
|
||||
byCol[p.c] = append(byCol[p.c], p)
|
||||
}
|
||||
out := map[int][]gridCell{}
|
||||
for col, lst := range byCol {
|
||||
sort.SliceStable(lst, func(i, j int) bool { return lst[i].r < lst[j].r })
|
||||
cells := make([]gridCell, len(lst))
|
||||
for i, p := range lst {
|
||||
cells[i] = p.cell
|
||||
}
|
||||
out[col] = cells
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseCell extracts reg / name / titles / ctlid from one <td>, mirroring how
|
||||
// SKK marks them up: a <a __doPostBack> holds the reg number (subject cells use
|
||||
// a bold <span> instead), a <font> holds championship titles, and the last plain
|
||||
// <span> holds the dog's name. "Uppgift saknas" placeholders are left unoccupied.
|
||||
func parseCell(td *html.Node) gridCell {
|
||||
var cell gridCell
|
||||
if a := findElement(td, "a"); a != nil {
|
||||
if href := attr(a, "href"); strings.Contains(href, "__doPostBack") {
|
||||
cell.reg = normalizeText(text(a))
|
||||
if m := doPostBackRE.FindStringSubmatch(html.UnescapeString(href)); m != nil {
|
||||
cell.ctlid = m[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
if f := findElement(td, "font"); f != nil {
|
||||
cell.titles = normalizeText(text(f))
|
||||
}
|
||||
// Name: the last <span> whose text isn't the titles string. Subject cells put
|
||||
// the reg in a leading bold span; if we found no link reg, adopt it.
|
||||
for _, sp := range descendants(td, "span") {
|
||||
t := normalizeText(text(sp))
|
||||
if t == "" || t == cell.titles {
|
||||
continue
|
||||
}
|
||||
cell.name = t
|
||||
}
|
||||
if cell.reg == "" {
|
||||
if b := findBoldSpan(td); b != "" {
|
||||
cell.reg = b
|
||||
if cell.name == b {
|
||||
cell.name = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(cell.name, "Uppgift saknas") {
|
||||
cell.name = ""
|
||||
}
|
||||
cell.occupied = cell.reg != "" || cell.name != ""
|
||||
return cell
|
||||
}
|
||||
|
||||
// crawlProgress is the mutable snapshot a running crawl publishes.
|
||||
type crawlProgress struct {
|
||||
Pages int
|
||||
Distinct int
|
||||
MaxGen int
|
||||
Nodes map[string]pedNode
|
||||
}
|
||||
|
||||
// crawl performs the breadth-first ancestry walk from a subject hundid, calling
|
||||
// report after each page with a fresh snapshot. It places every dog at its global
|
||||
// ahnentafel position and resolves each generation-7 leaf to a hundid to recurse.
|
||||
func (c *skkClient) crawl(ctx context.Context, hundid string, report func(crawlProgress)) (map[string]pedNode, error) {
|
||||
tree := map[string]pedNode{}
|
||||
edges := map[string]string{} // subjectHundid|ctlid -> ancestor hundid
|
||||
done := map[string]bool{}
|
||||
type qitem struct {
|
||||
hundid string
|
||||
basePos uint64
|
||||
}
|
||||
queue := []qitem{{hundid, 1}}
|
||||
pages, requests, maxGen := 0, 0, 0
|
||||
|
||||
snapshot := func() crawlProgress {
|
||||
nodes := make(map[string]pedNode, len(tree))
|
||||
for k, v := range tree {
|
||||
nodes[k] = v
|
||||
}
|
||||
return crawlProgress{Pages: pages, Distinct: countDistinct(tree), MaxGen: maxGen, Nodes: nodes}
|
||||
}
|
||||
|
||||
for len(queue) > 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return tree, err
|
||||
}
|
||||
if pages >= maxCrawlPages || requests >= maxCrawlRequests {
|
||||
log.Printf("pedigree crawl %s: hit cap (pages=%d requests=%d)", hundid, pages, requests)
|
||||
break
|
||||
}
|
||||
item := queue[0]
|
||||
queue = queue[1:]
|
||||
if done[item.hundid] {
|
||||
continue
|
||||
}
|
||||
done[item.hundid] = true
|
||||
|
||||
time.Sleep(skkDelay)
|
||||
pageHTML, pageURL, err := c.fetchPage(ctx, item.hundid)
|
||||
requests += 2
|
||||
if err != nil {
|
||||
if pages == 0 {
|
||||
return tree, err // couldn't even fetch the subject
|
||||
}
|
||||
log.Printf("pedigree crawl %s: fetch %s failed: %v", hundid, item.hundid, err)
|
||||
continue
|
||||
}
|
||||
pages++
|
||||
grid := parseGrid(pageHTML)
|
||||
|
||||
var frontier []struct {
|
||||
gpos uint64
|
||||
ctlid string
|
||||
}
|
||||
for col := 0; col <= crawlGens-1; col++ {
|
||||
cells, ok := grid[col]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for pos, cell := range cells {
|
||||
if !cell.occupied {
|
||||
continue
|
||||
}
|
||||
gpos := item.basePos*(1<<uint(col)) + uint64(pos)
|
||||
key := strconv.FormatUint(gpos, 10)
|
||||
node := tree[key]
|
||||
node.Reg, node.Name, node.Titles = cell.reg, cell.name, cell.titles
|
||||
if col == 0 {
|
||||
node.Hundid = item.hundid
|
||||
}
|
||||
tree[key] = node
|
||||
if g := bitsLen(gpos); g > maxGen {
|
||||
maxGen = g
|
||||
}
|
||||
if col == crawlGens-1 && cell.ctlid != "" {
|
||||
frontier = append(frontier, struct {
|
||||
gpos uint64
|
||||
ctlid string
|
||||
}{gpos, cell.ctlid})
|
||||
}
|
||||
}
|
||||
}
|
||||
report(snapshot())
|
||||
|
||||
for _, f := range frontier {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return tree, err
|
||||
}
|
||||
if requests >= maxCrawlRequests {
|
||||
break
|
||||
}
|
||||
ekey := item.hundid + "|" + f.ctlid
|
||||
hid, ok := edges[ekey]
|
||||
if !ok {
|
||||
time.Sleep(skkDelay)
|
||||
hid, err = c.postbackHundid(ctx, pageHTML, f.ctlid, pageURL)
|
||||
requests++
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
edges[ekey] = hid
|
||||
}
|
||||
if hid != "" {
|
||||
key := strconv.FormatUint(f.gpos, 10)
|
||||
node := tree[key]
|
||||
node.Hundid = hid
|
||||
tree[key] = node
|
||||
queue = append(queue, qitem{hid, f.gpos})
|
||||
}
|
||||
}
|
||||
}
|
||||
report(snapshot())
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
func bitsLen(x uint64) int {
|
||||
n := 0
|
||||
for x > 0 {
|
||||
n++
|
||||
x >>= 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ---- job manager ---------------------------------------------------------
|
||||
|
||||
type jobState string
|
||||
|
||||
const (
|
||||
jobRunning jobState = "running"
|
||||
jobDone jobState = "done"
|
||||
jobError jobState = "error"
|
||||
)
|
||||
|
||||
type pedJob struct {
|
||||
id string
|
||||
hundid string
|
||||
subject pedSubject
|
||||
firstPage chan struct{} // closed once the subject's own page is parsed
|
||||
|
||||
mu sync.Mutex
|
||||
state jobState
|
||||
pages int
|
||||
distinct int
|
||||
maxGen int
|
||||
nodes map[string]pedNode
|
||||
errMsg string
|
||||
}
|
||||
|
||||
func (j *pedJob) apply(p crawlProgress) {
|
||||
j.mu.Lock()
|
||||
j.pages, j.distinct, j.maxGen, j.nodes = p.Pages, p.Distinct, p.MaxGen, p.Nodes
|
||||
j.mu.Unlock()
|
||||
}
|
||||
|
||||
type pedManager struct {
|
||||
db *sql.DB
|
||||
|
||||
mu sync.Mutex
|
||||
jobs map[string]*pedJob // keyed by hundid (coalesces duplicate lookups)
|
||||
|
||||
resolveMu sync.Mutex
|
||||
resolved map[string]string // query -> hundid, so a cache hit skips SKK entirely
|
||||
}
|
||||
|
||||
func newPedManager(db *sql.DB) *pedManager {
|
||||
return &pedManager{db: db, jobs: map[string]*pedJob{}, resolved: map[string]string{}}
|
||||
}
|
||||
|
||||
func (m *pedManager) rememberResolve(q, hundid string) {
|
||||
if q == "" || hundid == "" {
|
||||
return
|
||||
}
|
||||
m.resolveMu.Lock()
|
||||
m.resolved[q] = hundid
|
||||
m.resolveMu.Unlock()
|
||||
}
|
||||
|
||||
func (m *pedManager) resolvedHundid(q string) string {
|
||||
m.resolveMu.Lock()
|
||||
defer m.resolveMu.Unlock()
|
||||
return m.resolved[q]
|
||||
}
|
||||
|
||||
func (m *pedManager) activeCount() int {
|
||||
n := 0
|
||||
for _, j := range m.jobs {
|
||||
j.mu.Lock()
|
||||
if j.state == jobRunning {
|
||||
n++
|
||||
}
|
||||
j.mu.Unlock()
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// startOrAttach returns the running/finished job for a hundid, or starts a new
|
||||
// background crawl. The boolean reports whether a fresh job was created.
|
||||
func (m *pedManager) startOrAttach(client *skkClient, subject pedSubject) (*pedJob, bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if j, ok := m.jobs[subject.Hundid]; ok {
|
||||
return j, false, nil
|
||||
}
|
||||
if m.activeCount() >= maxActiveJobs {
|
||||
return nil, false, errors.New("busy: too many pedigree lookups in progress, try again shortly")
|
||||
}
|
||||
j := &pedJob{
|
||||
id: subject.Hundid,
|
||||
hundid: subject.Hundid,
|
||||
subject: subject,
|
||||
firstPage: make(chan struct{}),
|
||||
state: jobRunning,
|
||||
nodes: map[string]pedNode{},
|
||||
}
|
||||
m.jobs[subject.Hundid] = j
|
||||
go m.run(client, j)
|
||||
return j, true, nil
|
||||
}
|
||||
|
||||
func (m *pedManager) run(client *skkClient, j *pedJob) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), crawlDeadline)
|
||||
defer cancel()
|
||||
|
||||
firstDone := false
|
||||
report := func(p crawlProgress) {
|
||||
j.apply(p)
|
||||
if !firstDone && p.Pages >= 1 {
|
||||
firstDone = true
|
||||
close(j.firstPage)
|
||||
}
|
||||
}
|
||||
nodes, err := client.crawl(ctx, j.hundid, report)
|
||||
if !firstDone {
|
||||
close(j.firstPage) // unblock waiters even if the very first fetch failed
|
||||
}
|
||||
j.mu.Lock()
|
||||
if err != nil && len(nodes) == 0 {
|
||||
j.state = jobError
|
||||
j.errMsg = err.Error()
|
||||
} else {
|
||||
j.state = jobDone
|
||||
}
|
||||
j.mu.Unlock()
|
||||
|
||||
if len(nodes) > 0 {
|
||||
m.persist(j.subject, nodes)
|
||||
}
|
||||
}
|
||||
|
||||
// snapshot copies a job's current public state under its lock.
|
||||
func (j *pedJob) snapshot() (jobState, int, int, int, map[string]pedNode, string) {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
nodes := make(map[string]pedNode, len(j.nodes))
|
||||
for k, v := range j.nodes {
|
||||
nodes[k] = v
|
||||
}
|
||||
return j.state, j.pages, j.distinct, j.maxGen, nodes, j.errMsg
|
||||
}
|
||||
|
||||
// ---- persistent cache ----------------------------------------------------
|
||||
|
||||
func (m *pedManager) persist(subject pedSubject, nodes map[string]pedNode) {
|
||||
sj, _ := json.Marshal(subject)
|
||||
nj, _ := json.Marshal(nodes)
|
||||
_, err := m.db.Exec(`
|
||||
INSERT INTO pedigree_cache (hundid, subject, nodes, generations, fetched)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(hundid) DO UPDATE SET
|
||||
subject = excluded.subject, nodes = excluded.nodes,
|
||||
generations = excluded.generations, fetched = excluded.fetched`,
|
||||
subject.Hundid, string(sj), string(nj), maxGenerations(nodes), time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
log.Printf("pedigree cache save %s: %v", subject.Hundid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// cached returns a stored tree for a hundid, if present.
|
||||
func (m *pedManager) cached(hundid string) (pedSubject, map[string]pedNode, bool) {
|
||||
var sj, nj string
|
||||
err := m.db.QueryRow(
|
||||
`SELECT subject, nodes FROM pedigree_cache WHERE hundid = ?`, hundid,
|
||||
).Scan(&sj, &nj)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("pedigree cache get %s: %v", hundid, err)
|
||||
}
|
||||
return pedSubject{}, nil, false
|
||||
}
|
||||
var subject pedSubject
|
||||
var nodes map[string]pedNode
|
||||
json.Unmarshal([]byte(sj), &subject)
|
||||
json.Unmarshal([]byte(nj), &nodes)
|
||||
return subject, nodes, true
|
||||
}
|
||||
|
||||
func maxGenerations(nodes map[string]pedNode) int {
|
||||
max := 0
|
||||
for k := range nodes {
|
||||
if p, err := strconv.ParseUint(k, 10, 64); err == nil {
|
||||
if g := bitsLen(p); g > max {
|
||||
max = g
|
||||
}
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// ---- HTTP handlers -------------------------------------------------------
|
||||
|
||||
type pedLookupResponse struct {
|
||||
Status string `json:"status"` // done | running | choose
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
Hundid string `json:"hundid,omitempty"`
|
||||
Subject *pedSubject `json:"subject,omitempty"`
|
||||
Generations int `json:"generations,omitempty"`
|
||||
Nodes map[string]pedNode `json:"nodes,omitempty"`
|
||||
Matches []hundDataRow `json:"matches,omitempty"`
|
||||
}
|
||||
|
||||
func rowToSubject(r hundDataRow) pedSubject {
|
||||
return pedSubject{
|
||||
Hundid: r.Hundid,
|
||||
Reg: strings.TrimSpace(r.Regnr),
|
||||
Name: strings.TrimSpace(r.Hundnamn),
|
||||
Breed: strings.TrimSpace(r.Rastext),
|
||||
Chip: strings.TrimSpace(r.Chipnr),
|
||||
Sex: strings.TrimSpace(r.Kon),
|
||||
}
|
||||
}
|
||||
|
||||
// handleLookup resolves a query and returns a cached tree, a disambiguation list,
|
||||
// or an immediate 7-generation tree with a background job crawling deeper.
|
||||
func (m *pedManager) handleLookup(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Q string `json:"q"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
q := strings.TrimSpace(req.Q)
|
||||
if q == "" {
|
||||
http.Error(w, "empty query", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Fast path: if we've resolved this query before and its tree is cached, serve
|
||||
// it without contacting SKK at all (repeat opens of your own dog's pedigree).
|
||||
if hundid := m.resolvedHundid(q); hundid != "" {
|
||||
if subj, nodes, ok := m.cached(hundid); ok {
|
||||
writeJSON(w, pedLookupResponse{
|
||||
Status: "done", Hundid: subj.Hundid, Subject: &subj,
|
||||
Generations: maxGenerations(nodes), Nodes: nodes,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
client, err := newSKKClient()
|
||||
if err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if err := client.warm(ctx); err != nil {
|
||||
http.Error(w, "upstream unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
rows, err := client.resolve(ctx, q)
|
||||
if err != nil {
|
||||
http.Error(w, "lookup failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
rows = withHundid(rows)
|
||||
switch {
|
||||
case len(rows) == 0:
|
||||
http.Error(w, "no dog found for "+q, http.StatusNotFound)
|
||||
return
|
||||
case len(rows) > 1:
|
||||
writeJSON(w, pedLookupResponse{Status: "choose", Matches: rows})
|
||||
return
|
||||
}
|
||||
subject := rowToSubject(rows[0])
|
||||
m.rememberResolve(q, subject.Hundid)
|
||||
|
||||
if subj, nodes, ok := m.cached(subject.Hundid); ok {
|
||||
writeJSON(w, pedLookupResponse{
|
||||
Status: "done", Hundid: subj.Hundid, Subject: &subj,
|
||||
Generations: maxGenerations(nodes), Nodes: nodes,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
job, _, err := m.startOrAttach(client, subject)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-job.firstPage:
|
||||
case <-time.After(firstPageWait):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
state, _, _, gen, nodes, msg := job.snapshot()
|
||||
if state == jobError {
|
||||
http.Error(w, "pedigree fetch failed: "+msg, http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
status := "running"
|
||||
if state == jobDone {
|
||||
status = "done"
|
||||
}
|
||||
writeJSON(w, pedLookupResponse{
|
||||
Status: status, JobID: job.id, Hundid: subject.Hundid,
|
||||
Subject: &subject, Generations: gen, Nodes: nodes,
|
||||
})
|
||||
}
|
||||
|
||||
type pedStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
Pages int `json:"pages"`
|
||||
Distinct int `json:"distinct"`
|
||||
Generations int `json:"generations"`
|
||||
Nodes map[string]pedNode `json:"nodes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// handleStatus returns a running crawl's current partial tree so the client can
|
||||
// fill the view in progressively, and the final tree when it finishes.
|
||||
func (m *pedManager) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("job")
|
||||
m.mu.Lock()
|
||||
job := m.jobs[id]
|
||||
m.mu.Unlock()
|
||||
if job == nil {
|
||||
// A finished job may have been evicted, but the cache still has the tree.
|
||||
if _, nodes, ok := m.cached(id); ok {
|
||||
writeJSON(w, pedStatusResponse{
|
||||
Status: "done", Generations: maxGenerations(nodes),
|
||||
Distinct: countDistinct(nodes), Nodes: nodes, Pages: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Error(w, "unknown job", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
state, pages, distinct, gen, nodes, msg := job.snapshot()
|
||||
writeJSON(w, pedStatusResponse{
|
||||
Status: string(state), Pages: pages, Distinct: distinct,
|
||||
Generations: gen, Nodes: nodes, Error: msg,
|
||||
})
|
||||
}
|
||||
|
||||
// countDistinct counts unique ancestors, keyed by registration number (falling
|
||||
// back to name). Pedigree collapse means one dog can fill many positions, so
|
||||
// this is smaller than the number of occupied positions.
|
||||
func countDistinct(nodes map[string]pedNode) int {
|
||||
seen := map[string]bool{}
|
||||
for _, n := range nodes {
|
||||
k := n.Reg
|
||||
if k == "" {
|
||||
k = n.Name
|
||||
}
|
||||
if k != "" {
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// withHundid drops resolver rows lacking a usable hundid (defensive).
|
||||
func withHundid(rows []hundDataRow) []hundDataRow {
|
||||
out := rows[:0]
|
||||
for _, r := range rows {
|
||||
if strings.TrimSpace(r.Hundid) != "" {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+2346
-209
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
[
|
||||
{ "date": "2026-08-02", "text": "Added free-text notes: tap 📝 Note to jot down things that happened on a day — vaccinations, vet visits, milestones — with a date, optional photo, and any text. All your notes are collected in a new Notes section that stays visible whatever day you're viewing, so you can see at a glance when things like a tick vaccination were done" },
|
||||
{ "date": "2026-08-02", "text": "The Daily counts chart now has Pees / Poos / Meals checkboxes so you can focus on just the metrics you care about — untick the rest to see, say, only poos; your choice is remembered" },
|
||||
{ "date": "2026-08-01", "text": "Logging a pee or poo now sets off 💧/💩 fireworks that shoot up from the bottom of the screen — a little celebration you can switch off in Settings (and it honours a reduced-motion preference)" },
|
||||
{ "date": "2026-08-01", "text": "Tidied the header on long names and ages — the name now truncates instead of shoving the buttons, and the age reads as a compact \"16 wk · 3 mo 3 wk\"; weight-log rows are a single line again (\"Aug 1 · 16 wk\")" },
|
||||
{ "date": "2026-07-26", "text": "Added a fan-chart view of the pedigree (toggle it in the header): your dog at the centre with each generation fanning outward as a ring, so many generations fit at once without the tree sprawling sideways — tap a wedge for that dog, and repeated ancestors keep their colour" },
|
||||
{ "date": "2026-07-26", "text": "Added a Collapse all / Expand all toggle to the pedigree, to fold the whole tree down to your dog or open every branch at once" },
|
||||
{ "date": "2026-07-26", "text": "The pedigree is now zoomable — use the +/− buttons, ⌘/Ctrl-scroll, or pinch on a phone — to fit a wide tree on screen or zoom in for detail; your zoom level is remembered" },
|
||||
{ "date": "2026-07-26", "text": "In the pedigree, a dog that fills more than one spot (pedigree collapse, common in a breed's older lines) now carries a ×N badge — tap it to highlight every place that dog appears in the tree" },
|
||||
{ "date": "2026-07-26", "text": "The pedigree now reads top-down like a family tree — your dog on top with its sire and dam branching below — showing three generations at a glance, with each dog expandable to trace the line further back" },
|
||||
{ "date": "2026-07-26", "text": "The pedigree ID set in Settings now syncs reliably to your other devices — it's no longer dropped when two devices' clocks disagree" },
|
||||
{ "date": "2026-07-26", "text": "New 🌳 Pedigree page: add your dog's SKK chip or registration number in Settings to unlock it, then explore its ancestry as a tree — the first generations show at once and the line fills in further back as it's traced from SKK Hunddata. It's cached, so it reopens instantly and works offline" },
|
||||
{ "date": "2026-07-24", "text": "The age counter reads \"16 weeks (3 months and 3 weeks) old\" so weeks and months line up; past 4 months it drops the weeks and shows just months (e.g. \"5 months and 2 weeks old\")" },
|
||||
{ "date": "2026-07-17", "text": "The Sleep trend chart follows the selected day — pick a past day to see its full curve against the day before and the average leading up to it" },
|
||||
{ "date": "2026-07-15", "text": "The Sleep trend y-axis is stretched above 10h, giving the hours around the sleep goal most of the chart" },
|
||||
{ "date": "2026-07-15", "text": "The Sleep trend chart shows the sleep goal for your puppy's age as a shaded band — the projection chip gets a ✓ when today is on track" },
|
||||
{ "date": "2026-07-15", "text": "Logging a training session while viewing another day puts it on that day (the snackbar tells you where it went)" },
|
||||
{ "date": "2026-07-15", "text": "The Sleep trend yesterday line is orange instead of gray, which was hard to see" },
|
||||
{ "date": "2026-07-15", "text": "The Sleep trend average line is teal now, so it doesn't blend in with today's blue line and its projection" },
|
||||
{ "date": "2026-07-15", "text": "Tapping a day in a chart selects it without jumping down to the history" },
|
||||
{ "date": "2026-07-15", "text": "Meals with an amount logged show their grams in the day's history" },
|
||||
{ "date": "2026-07-15", "text": "Every 1-hour gridline on the sleep charts now shows its hour mark" },
|
||||
{ "date": "2026-07-15", "text": "The Sleep chart in the last-N-days card now has 1-hour gridlines too" },
|
||||
{ "date": "2026-07-15", "text": "The Sleep trend chart is taller with 1-hour gridlines, so nearby lines are easier to tell apart" },
|
||||
{ "date": "2026-07-15", "text": "The Sleep trend legend shows the hours for each line — Today, Yesterday and the average, next to the projection" },
|
||||
{ "date": "2026-07-15", "text": "The Sleep trend chart projects where today will land by midnight — a dashed tail continues today's line following the average day's rhythm" },
|
||||
{ "date": "2026-07-15", "text": "The charts highlight the selected day, so it's easy to spot which bars, rows and cells you're looking at" },
|
||||
{ "date": "2026-07-15", "text": "New Sleep trend chart: today's running sleep total through the day, against yesterday and the average over your chart window (7/14/30 days)" },
|
||||
{ "date": "2026-07-15", "text": "Quick actions that don't fit right now are dimmed (asleep → everything but Sleep end; awake → Sleep end) — still tappable for corrections" },
|
||||
{ "date": "2026-07-15", "text": "Editing an event no longer pops the date picker over the whole screen on iPhone" },
|
||||
{ "date": "2026-07-15", "text": "Weight and amount fields no longer show up on events that don't use them (e.g. editing a pee)" },
|
||||
{ "date": "2026-07-15", "text": "The date in the top bar is shorter (no year), so the whole bar fits on smaller iPhones" },
|
||||
{ "date": "2026-07-13", "text": "The big timer is back at the top — scroll past it and it hops into the frozen bar instead" },
|
||||
{ "date": "2026-07-13", "text": "The top bar fits on one row: timer, day arrows, date picker and Today" },
|
||||
{ "date": "2026-07-13", "text": "Pick how many days the charts cover — 7, 14 or 30 (default 7)" },
|
||||
{ "date": "2026-07-13", "text": "Much finer y-axis on the food chart" },
|
||||
{ "date": "2026-07-13", "text": "The awake/asleep timer is bigger and sits first in the top bar" },
|
||||
{ "date": "2026-07-13", "text": "The sleep, daily counts and food charts now cover the last 14 days instead of 7" },
|
||||
{ "date": "2026-07-13", "text": "The awake/asleep timer lives in the frozen top bar" },
|
||||
{ "date": "2026-07-13", "text": "The day picker is a bar frozen at the top of the page" },
|
||||
{ "date": "2026-07-13", "text": "Attach multiple photos to an event — the photo picker now also offers the gallery with multi-select" },
|
||||
{ "date": "2026-07-13", "text": "Track food by weight: logging a meal asks for grams (optional), with a daily total in the overview and a weekly chart" },
|
||||
{ "date": "2026-07-12", "text": "Browse the full changelog from the bottom of the page" },
|
||||
{ "date": "2026-07-12", "text": "Finer-grained y-axis on the daily counts chart" },
|
||||
{ "date": "2026-07-12", "text": "The update banner now lists what changed in the new version" },
|
||||
{ "date": "2026-07-12", "text": "Fixed adding an exercise on iPhone: stale app updates, and the keyboard's Go key discarding the input" },
|
||||
{ "date": "2026-07-12", "text": "Training: define exercises with how-to reminders, log sessions in one tap, and follow a 14-day consistency view" }
|
||||
]
|
||||
+264
-26
@@ -9,20 +9,95 @@
|
||||
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="icon.svg" />
|
||||
<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>
|
||||
<body>
|
||||
<!-- Shown when a newer build's service worker is waiting. "Reload" activates
|
||||
it and refreshes onto the new assets; "Later" dismisses until next time.
|
||||
Suppressed on the very first install (see app.js). -->
|
||||
<div id="update-banner" class="update-banner" hidden role="status" aria-live="polite">
|
||||
<div class="update-banner-row">
|
||||
<span class="update-banner-msg">A new version is available</span>
|
||||
<span class="update-banner-actions">
|
||||
<button type="button" id="update-reload" class="update-banner-btn">Reload</button>
|
||||
<button type="button" id="update-later" class="update-banner-btn ghostish">Later</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- What the waiting build adds over the running one; filled by app.js
|
||||
from the diff between the cached and the fresh changelog.json. -->
|
||||
<ul id="update-changelog" class="update-changelog" hidden></ul>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div class="title">
|
||||
<h1 id="app-title">🐶 Puppy Tracker</h1>
|
||||
<div id="puppy-age" class="puppy-age" hidden></div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" id="pedigree-btn" class="ghost icon-btn" aria-label="Pedigree" title="Pedigree" hidden>🌳</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>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="day-bar">
|
||||
<!-- Compact twin of the big timer below: invisible (but keeping its
|
||||
slot) while the big card is on screen, shown once it scrolls
|
||||
away. Hidden entirely until a sleep event exists. -->
|
||||
<div id="bar-clock" class="bar-clock" hidden>
|
||||
<span id="bar-clock-icon" aria-hidden="true"></span>
|
||||
<span id="bar-clock-time"></span>
|
||||
</div>
|
||||
<button type="button" id="day-prev" class="ghost" aria-label="Previous day">←</button>
|
||||
<!-- A browser won't let us shorten the text a native date input shows,
|
||||
so the face button carries a compact year-less date and the real
|
||||
input stays (visually hidden) as the value + native picker. -->
|
||||
<span class="day-date">
|
||||
<button type="button" id="day-date-face" class="ghost"></button>
|
||||
<input type="date" id="day-picker" tabindex="-1" aria-hidden="true" />
|
||||
</span>
|
||||
<button type="button" id="day-next" class="ghost" aria-label="Next day">→</button>
|
||||
<button type="button" id="day-today" class="ghost">Today</button>
|
||||
</section>
|
||||
|
||||
<section id="big-clock" class="big-clock" hidden>
|
||||
<div class="bc-label" id="bc-label">—</div>
|
||||
<div class="bc-time" id="bc-time">0:00</div>
|
||||
@@ -38,17 +113,23 @@
|
||||
<button class="action pee" data-type="pee">💧 Pee</button>
|
||||
<button class="action poo" data-type="poo">💩 Poo</button>
|
||||
<button class="action weight" data-type="weight">⚖️ Weigh-in</button>
|
||||
<button class="action note" data-type="note">📝 Note</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="day-bar">
|
||||
<button type="button" id="day-prev" class="ghost" aria-label="Previous day">←</button>
|
||||
<input type="date" id="day-picker" />
|
||||
<button type="button" id="day-today" class="ghost">Today</button>
|
||||
<button type="button" id="day-next" class="ghost" aria-label="Next day">→</button>
|
||||
<section class="training" data-panel="training">
|
||||
<h2>Training</h2>
|
||||
<ul id="training-list" class="training-list"></ul>
|
||||
<p id="training-empty" class="empty">No exercises yet. Add one to start tracking training.</p>
|
||||
<button type="button" id="exercise-add" class="ghost training-add">Add exercise</button>
|
||||
<div class="chart training-chart" id="training-chart-wrap" hidden>
|
||||
<div class="chart-title">Consistency <span data-chart-days-label>(last 7 days)</span></div>
|
||||
<svg id="chart-training" class="chart-svg" viewBox="0 0 320 60" role="img" aria-label="Training sessions per exercise per day"></svg>
|
||||
<p class="muted-note">Darker = more sessions that day. Tap a cell to open that day.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="overview">
|
||||
<section class="overview" data-panel="overview">
|
||||
<h2 id="overview-title">Today's overview</h2>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
@@ -62,6 +143,7 @@
|
||||
<div class="stat">
|
||||
<div class="stat-label">Meals</div>
|
||||
<div class="stat-value" id="stat-meals">0</div>
|
||||
<div class="stat-sub" id="stat-meals-grams" hidden></div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Pees</div>
|
||||
@@ -71,6 +153,10 @@
|
||||
<div class="stat-label">Poos</div>
|
||||
<div class="stat-value" id="stat-poos">0</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Training</div>
|
||||
<div class="stat-value" id="stat-training">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lasts">
|
||||
@@ -78,11 +164,10 @@
|
||||
<div class="last-row"><span>Last poo</span><span id="last-poo">—</span></div>
|
||||
<div class="last-row"><span>Last meal</span><span id="last-eat">—</span></div>
|
||||
<div class="last-row"><span>Last sleep</span><span id="last-sleep">—</span></div>
|
||||
<div class="last-row" id="currently-row" hidden><span>Currently</span><span id="currently">—</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="timing">
|
||||
<section class="timing" data-panel="timing">
|
||||
<h2>Bathroom timing <span class="muted-note">(last 7 days)</span></h2>
|
||||
<div class="lasts">
|
||||
<div class="last-row"><span>Typical time between pees</span><span id="gap-pee">—</span></div>
|
||||
@@ -93,36 +178,70 @@
|
||||
<p class="muted-note timing-hint" id="timing-hint"></p>
|
||||
</section>
|
||||
|
||||
<section class="sleep">
|
||||
<section class="sleep" data-panel="sleep-windows">
|
||||
<h2>Sleep windows</h2>
|
||||
<ul id="sleep-list" class="wake-list"></ul>
|
||||
<p id="sleep-empty" class="empty">No sleep windows yet for this day.</p>
|
||||
</section>
|
||||
|
||||
<section class="wake">
|
||||
<section class="wake" data-panel="wake-windows">
|
||||
<h2>Wake windows</h2>
|
||||
<ul id="wake-list" class="wake-list"></ul>
|
||||
<p id="wake-empty" class="empty">No wake windows yet for this day.</p>
|
||||
</section>
|
||||
|
||||
<section class="weekly">
|
||||
<h2>Last 7 days</h2>
|
||||
<section class="weekly" data-panel="weekly">
|
||||
<h2 id="daily-charts-title">Last 7 days</h2>
|
||||
<div class="chart-days-picker" role="group" aria-label="How many days the charts cover">
|
||||
<button type="button" class="ghost" data-days="7">7d</button>
|
||||
<button type="button" class="ghost" data-days="14">14d</button>
|
||||
<button type="button" class="ghost" data-days="30">30d</button>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<div class="chart-title">Sleep (hours)</div>
|
||||
<svg id="chart-sleep" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Sleep hours per day for the last 7 days"></svg>
|
||||
<svg id="chart-sleep" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Sleep hours per day"></svg>
|
||||
</div>
|
||||
<div class="chart">
|
||||
<div class="chart-title">Daily counts</div>
|
||||
<svg id="chart-counts" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Pee, poo and meal counts per day for the last 7 days"></svg>
|
||||
<div class="legend">
|
||||
<span class="lg pee"><span class="sw"></span>Pees</span>
|
||||
<span class="lg poo"><span class="sw"></span>Poos</span>
|
||||
<span class="lg eat"><span class="sw"></span>Meals</span>
|
||||
<svg id="chart-counts" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Pee, poo and meal counts per day"></svg>
|
||||
<div class="legend legend-toggle" id="counts-metrics" role="group" aria-label="Which counts to show">
|
||||
<label class="lg pee"><input type="checkbox" data-metric="pees" checked /><span class="sw"></span>Pees</label>
|
||||
<label class="lg poo"><input type="checkbox" data-metric="poos" checked /><span class="sw"></span>Poos</label>
|
||||
<label class="lg eat"><input type="checkbox" data-metric="meals" checked /><span class="sw"></span>Meals</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart" id="grams-chart-wrap" hidden>
|
||||
<div class="chart-title">Food (grams)</div>
|
||||
<svg id="chart-grams" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Grams of food eaten per day"></svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="weight">
|
||||
<section class="patterns" data-panel="sleep-timeline">
|
||||
<h2><span id="sleep-timeline-title">When sleeping</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
|
||||
<svg id="chart-sleep-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Sleep periods per day"></svg>
|
||||
<p class="muted-note">Each row is a day, midnight to midnight; shaded = asleep. Tap a row to open that day.</p>
|
||||
</section>
|
||||
|
||||
<section class="patterns" data-panel="sleep-trend">
|
||||
<h2>Sleep trend</h2>
|
||||
<svg id="chart-sleep-trend" class="chart-svg" viewBox="0 0 320 220" role="img" aria-label="Cumulative sleep hours through the selected day, the day before it, the recent average and (for today) the projected end-of-day total, with the age-based sleep goal band"></svg>
|
||||
<div class="legend">
|
||||
<span class="lg trend-today"><span class="sw"></span><span id="legend-trend-today-text">Today</span></span>
|
||||
<span class="lg trend-projected" id="legend-trend-projected" hidden><span class="sw"></span><span id="legend-trend-projected-text">Projected</span></span>
|
||||
<span class="lg trend-yesterday" id="legend-trend-yesterday"><span class="sw"></span><span id="legend-trend-yesterday-text">Yesterday</span></span>
|
||||
<span class="lg trend-avg" id="legend-trend-avg"><span class="sw"></span><span id="legend-trend-avg-text">7-day avg</span></span>
|
||||
<span class="lg trend-goal" id="legend-trend-goal" hidden><span class="sw"></span><span id="legend-trend-goal-text">Goal</span></span>
|
||||
</div>
|
||||
<p class="muted-note">Hours slept so far at each point of the day, against yesterday and the average over the picked chart window. The dashed tail continues today's line the way the average day usually plays out. The axis is stretched above 10h to give the hours around the goal more room.</p>
|
||||
</section>
|
||||
|
||||
<section class="patterns" data-panel="hour-heatmap">
|
||||
<h2>By hour of day <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
|
||||
<svg id="chart-hour-heatmap" class="chart-svg" viewBox="0 0 320 120" role="img" aria-label="Pee, poo and meal frequency by hour of day"></svg>
|
||||
<p class="muted-note">Darker = happens more often at that hour.</p>
|
||||
</section>
|
||||
|
||||
<section class="weight" data-panel="weight">
|
||||
<h2>Weight</h2>
|
||||
<div class="weight-summary">
|
||||
<div class="stat">
|
||||
@@ -143,13 +262,68 @@
|
||||
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
|
||||
</section>
|
||||
|
||||
<section class="history">
|
||||
<section class="notes-log" data-panel="notes">
|
||||
<h2>Notes</h2>
|
||||
<ul id="notes-list" class="event-list"></ul>
|
||||
<p id="notes-empty" class="empty">No notes yet. Use the 📝 Note button to jot down things like vaccinations or vet visits — they'll be listed here across every day.</p>
|
||||
</section>
|
||||
|
||||
<section class="history" data-panel="history">
|
||||
<h2>History</h2>
|
||||
<ul id="event-list" class="event-list"></ul>
|
||||
<p id="empty-state" class="empty">No events logged for this day.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<button type="button" id="changelog-btn" class="linklike">Changelog</button>
|
||||
</footer>
|
||||
</div><!-- /#app -->
|
||||
|
||||
<!-- Pedigree lookup. A distinct full-screen view (hides #app while open)
|
||||
that resolves a dog by chip / registration number / name against SKK
|
||||
and renders its ancestry as a tree. Online-only. -->
|
||||
<div id="pedigree-screen" class="pedigree-screen" hidden>
|
||||
<header class="pedigree-header">
|
||||
<button type="button" id="pedigree-back" class="ghost icon-btn" aria-label="Back to tracker" title="Back">←</button>
|
||||
<h1>🌳 Pedigree</h1>
|
||||
<div class="ped-controls">
|
||||
<button type="button" id="ped-view" class="ghost">Fan view</button>
|
||||
<button type="button" id="ped-foldall" class="ghost">Collapse all</button>
|
||||
<div class="ped-zoom" role="group" aria-label="Zoom">
|
||||
<button type="button" id="ped-zoom-out" class="ghost icon-btn" aria-label="Zoom out" title="Zoom out">−</button>
|
||||
<button type="button" id="ped-zoom-reset" class="ghost" aria-label="Reset zoom" title="Reset zoom">100%</button>
|
||||
<button type="button" id="ped-zoom-in" class="ghost icon-btn" aria-label="Zoom in" title="Zoom in">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="pedigree-main">
|
||||
<p class="muted-note pedigree-hint">
|
||||
Your dog's ancestry from <strong>SKK Hunddata</strong>, traced from the
|
||||
ID set in Settings. The first generations show at once, then the line
|
||||
fills in further back.
|
||||
<button type="button" id="pedigree-refresh" class="linklike">Refresh</button>
|
||||
</p>
|
||||
<p id="pedigree-status" class="pedigree-status" hidden></p>
|
||||
<div id="pedigree-choose" class="pedigree-choose" hidden></div>
|
||||
<div id="pedigree-subject" class="pedigree-subject" hidden></div>
|
||||
<p id="pedigree-repeat-note" class="muted-note pedigree-repeat-note" hidden>Some ancestors appear in more than one place further back (pedigree collapse). Expand the tree to reveal their ×N badges, then tap one to highlight every spot that dog appears.</p>
|
||||
<div id="pedigree-tree" class="pedigree-tree"></div>
|
||||
<p id="pedigree-caption" class="pedigree-caption" hidden></p>
|
||||
</main>
|
||||
</div><!-- /#pedigree-screen -->
|
||||
|
||||
<dialog id="changelog-dialog">
|
||||
<form method="dialog" id="changelog-form">
|
||||
<h3>Changelog</h3>
|
||||
<ul id="changelog-list" class="changelog-list"></ul>
|
||||
<p id="changelog-empty" class="empty" hidden>No changelog available.</p>
|
||||
<menu>
|
||||
<button value="close">Close</button>
|
||||
</menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="settings-dialog">
|
||||
<form method="dialog" id="settings-form">
|
||||
<h3>Puppy settings</h3>
|
||||
@@ -159,10 +333,63 @@
|
||||
<label>Birthday
|
||||
<input type="date" id="settings-birthday" />
|
||||
</label>
|
||||
<label>Pedigree ID
|
||||
<input type="text" id="settings-pedigree" autocomplete="off" spellcheck="false"
|
||||
placeholder="SKK chip or reg. number (optional)" />
|
||||
</label>
|
||||
<p class="settings-hint">Set your dog's SKK chip or registration number to unlock the 🌳 pedigree page.</p>
|
||||
<label class="toggle-row">
|
||||
<span>Dark mode</span>
|
||||
<input type="checkbox" id="settings-theme" role="switch" class="switch" />
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<span>Pee/poo confetti 💩</span>
|
||||
<input type="checkbox" id="settings-confetti" role="switch" class="switch" />
|
||||
</label>
|
||||
<menu>
|
||||
<button value="cancel" class="ghost">Cancel</button>
|
||||
<button value="save" id="settings-save">Save</button>
|
||||
</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>
|
||||
</dialog>
|
||||
|
||||
<dialog id="exercise-dialog">
|
||||
<form method="dialog" id="exercise-form">
|
||||
<h3 id="exercise-title">Add exercise</h3>
|
||||
<label>Name
|
||||
<input type="text" id="exercise-name" placeholder="e.g. Sit" autocomplete="off" />
|
||||
</label>
|
||||
<label>How to do it
|
||||
<textarea id="exercise-note" rows="5" placeholder="Reminder for how to train it, e.g. lure with a treat, mark the moment the butt touches the ground, reward"></textarea>
|
||||
</label>
|
||||
<menu>
|
||||
<!-- type="button" keeps Save the form's default button, so Enter /
|
||||
the iOS keyboard's "Go" saves instead of silently hitting the
|
||||
(hidden) Delete button via implicit form submission. -->
|
||||
<button type="button" value="delete" id="exercise-delete" class="danger" hidden>Delete</button>
|
||||
<button type="button" value="cancel" class="ghost">Cancel</button>
|
||||
<button value="save" id="exercise-save">Save</button>
|
||||
</menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -179,13 +406,15 @@
|
||||
<label id="note-weight-field" hidden>Weight (kg)
|
||||
<input type="number" id="note-weight" inputmode="decimal" step="0.01" min="0" placeholder="e.g. 5.2" />
|
||||
</label>
|
||||
<label id="note-grams-field" hidden>Amount (g)
|
||||
<input type="number" id="note-grams" inputmode="numeric" step="1" min="0" placeholder="e.g. 80 — leave empty if unknown" />
|
||||
</label>
|
||||
<label>Note
|
||||
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
|
||||
</label>
|
||||
<div class="photo-field">
|
||||
<input type="file" id="note-photo-input" accept="image/*" capture="environment" hidden />
|
||||
<button type="button" id="note-photo-btn" class="ghost">📷 Add photo</button>
|
||||
<button type="button" id="note-photo-clear" class="ghost" hidden>Remove photo</button>
|
||||
<input type="file" id="note-photo-input" accept="image/*" multiple hidden />
|
||||
<button type="button" id="note-photo-btn" class="ghost">📷 Add photos</button>
|
||||
<div id="note-photo-preview" class="photo-preview" hidden></div>
|
||||
</div>
|
||||
<menu>
|
||||
@@ -207,13 +436,15 @@
|
||||
<label id="edit-weight-field" hidden>Weight (kg)
|
||||
<input type="number" id="edit-weight" inputmode="decimal" step="0.01" min="0" />
|
||||
</label>
|
||||
<label id="edit-grams-field" hidden>Amount (g)
|
||||
<input type="number" id="edit-grams" inputmode="numeric" step="1" min="0" />
|
||||
</label>
|
||||
<label>Note
|
||||
<textarea id="edit-note" rows="4"></textarea>
|
||||
</label>
|
||||
<div class="photo-field">
|
||||
<input type="file" id="edit-photo-input" accept="image/*" capture="environment" hidden />
|
||||
<button type="button" id="edit-photo-btn" class="ghost">📷 Add photo</button>
|
||||
<button type="button" id="edit-photo-clear" class="ghost" hidden>Remove photo</button>
|
||||
<input type="file" id="edit-photo-input" accept="image/*" multiple hidden />
|
||||
<button type="button" id="edit-photo-btn" class="ghost">📷 Add photos</button>
|
||||
<div id="edit-photo-preview" class="photo-preview" hidden></div>
|
||||
</div>
|
||||
<menu>
|
||||
@@ -229,6 +460,13 @@
|
||||
<img id="lightbox-img" alt="" />
|
||||
</dialog>
|
||||
|
||||
<!-- Brief confirmation after a one-tap quick log, with Undo / Add note. -->
|
||||
<div id="snackbar" class="snackbar" hidden role="status" aria-live="polite">
|
||||
<span id="snackbar-msg" class="snackbar-msg"></span>
|
||||
<button type="button" id="snackbar-note" class="snackbar-action">Add note</button>
|
||||
<button type="button" id="snackbar-undo" class="snackbar-action">Undo</button>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+875
-13
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,10 @@
|
||||
const CACHE = "puppy-tracker-v5";
|
||||
// BUILD is substituted per-deploy by the server with a hash of the static
|
||||
// assets (see serveSW in server/main.go), so the cache name — and therefore the
|
||||
// bytes of this file — change whenever any asset changes. That byte difference
|
||||
// is what makes the browser install a new worker and surface the update prompt.
|
||||
// Served unsubstituted (dev / a plain static host) it stays a valid constant.
|
||||
const BUILD = "__BUILD_HASH__";
|
||||
const CACHE = `puppy-tracker-${BUILD}`;
|
||||
const PHOTO_CACHE = "puppy-tracker-photos-v1";
|
||||
const ASSETS = [
|
||||
"./",
|
||||
@@ -7,13 +13,26 @@ const ASSETS = [
|
||||
"./app.js",
|
||||
"./manifest.json",
|
||||
"./icon.svg",
|
||||
"./changelog.json",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
// cache: "reload" bypasses the browser's HTTP cache, so a new build always
|
||||
// caches assets fetched fresh from the server. Without it, addAll could mix
|
||||
// a fresh index.html with a heuristically-cached stale app.js and install a
|
||||
// build whose markup references listeners the old script never registers.
|
||||
event.waitUntil(
|
||||
caches.open(CACHE).then((cache) => cache.addAll(ASSETS))
|
||||
caches.open(CACHE).then((cache) =>
|
||||
cache.addAll(ASSETS.map((u) => new Request(u, { cache: "reload" })))
|
||||
)
|
||||
);
|
||||
self.skipWaiting();
|
||||
// No skipWaiting() here: a new worker stays in "waiting" while an old one is
|
||||
// controlling a tab, so the page can prompt before swapping assets out from
|
||||
// under it. The page tells us to activate via a SKIP_WAITING message.
|
||||
});
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
|
||||
Reference in New Issue
Block a user