Files
puppy-tracker/server/auth.go
T
Alexander Heldt 55667f7165 Add training tracking: exercises with instructions, one-tap session log, consistency overview
Exercises (name + how-to note) are a new synced collection with the same
LWW/tombstone contract as events, served by POST /api/exercises/sync.
Training sessions are ordinary events (type "training") referencing an
exercise by id, so they ride the existing event sync unchanged.

The Training panel lists each exercise with last-trained / this-week /
streak stats, expandable instructions, and a one-tap Log button with the
usual undo/add-note snackbar. An exercise-by-day heatmap shows the last
14 days of consistency, and history and the daily overview count
training sessions like any other event.
2026-07-12 16:21:46 +00:00

447 lines
12 KiB
Go

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)
}