A closed PWA has no timers, so reminders are evaluated on the server: the event log is already there (clients sync on every mutation), and a ticker re-checks each enabled rule once a minute and pushes the ones that are due. Two rule shapes. "sleep" measures from the last sleep-end and fires only while the puppy is awake. "pee"/"poo"/"eat" measure from the newest event of that type and stay quiet while the puppy is asleep — otherwise they nag all night, and suppressing them means an overdue rule instead fires promptly on waking, which is when it actually matters. Sleep state is derived exactly the way currentSleepState() does in app.js, tie-break included, so both sides always agree. Rules read the event's own timestamp rather than when it synced, so a pee logged offline at 03:10 cancels the reminder retroactively. Every push carries a tag, so a repeat replaces the previous notification instead of stacking another one on the lock screen. last_fired is server-owned and not writable by a client, so a stale device can't force a re-fire. Web Push is implemented directly rather than pulled in as a dependency: RFC 8291 encryption in the RFC 8188 aes128gcm coding with an RFC 8292 VAPID token, stdlib only, checked against the RFC 8291 test vector. The key is generated into vapid.json beside the DB or supplied via -vapid-key; without one the server logs a warning, skips registering the routes, and the client hides the UI. Subscriptions a push service reports as 404/410 are dropped. PNG icons are added because iOS gates push on a Home Screen install and rejects SVG for apple-touch-icon, and Android has no notification icon without them.
450 lines
13 KiB
Go
450 lines
13 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,
|
|
// reminders, push subscriptions, 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 push_subscriptions WHERE user_id = ?`,
|
|
`DELETE FROM reminders 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)
|
|
}
|