Add accounts and multi-tenancy
Every event, profile and photo is now scoped to a signed-in account, so separate people can track separate puppies on one server. Server: - users + sessions tables; bcrypt passwords; random session tokens stored hashed and set as an HttpOnly cookie. Middleware gates /api/* behind a valid session. - register/login/logout/me endpoints. Registration requires a shared invite code (-invite-code / PUPPY_INVITE_CODE); empty disables it. - events, config and photos are keyed by user_id; the sync upsert guards against cross-user overwrites and reads are scoped, so accounts are isolated. Photos live under photos/<user_id>/ and are only served to their owner. - in-place schema migration adds user_id and reshapes config; legacy single-tenant data (including imported events.json) is parked ownerless and adopted by the first account to register. Client: - login/register gate in front of the app; the tracker only boots once the session check resolves. localStorage is namespaced per user. - 401s bounce back to login; an offline reload falls back to the last cached session so offline-first still works. Logout clears the session and reloads. Deployment: - module.nix gains inviteCodeFile (secret via EnvironmentFile) and secureCookies options. Verified end to end (curl + a headless-browser run of the auth flow): isolation between accounts, invite enforcement, first-user adoption, photo ownership, and session persistence across reload.
This commit is contained in:
+383
@@ -0,0 +1,383 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "puppy_session"
|
||||
sessionValidity = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// ctxKey is an unexported type so our context values can't collide with any
|
||||
// set elsewhere.
|
||||
type ctxKey int
|
||||
|
||||
const userIDKey ctxKey = 0
|
||||
|
||||
// User is the public shape returned to clients — never the password hash.
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// Auth owns everything account-related: the users/sessions tables, the shared
|
||||
// invite code required to register, and whether session cookies are marked
|
||||
// Secure (on behind TLS/a proxy). photosDir is needed so the first account can
|
||||
// adopt legacy flat-layout photos.
|
||||
type Auth struct {
|
||||
db *sql.DB
|
||||
inviteCode string
|
||||
secure bool
|
||||
photosDir string
|
||||
}
|
||||
|
||||
func newAuth(db *sql.DB, inviteCode string, secure bool, photosDir string) *Auth {
|
||||
return &Auth{db: db, inviteCode: inviteCode, secure: secure, photosDir: photosDir}
|
||||
}
|
||||
|
||||
// ---------- users & sessions ----------
|
||||
|
||||
func newID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err) // crypto/rand failing is unrecoverable
|
||||
}
|
||||
// RFC-4122-ish v4 layout; good enough as an opaque unique id.
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return hex.EncodeToString(b[0:4]) + "-" + hex.EncodeToString(b[4:6]) + "-" +
|
||||
hex.EncodeToString(b[6:8]) + "-" + hex.EncodeToString(b[8:10]) + "-" +
|
||||
hex.EncodeToString(b[10:16])
|
||||
}
|
||||
|
||||
// hashToken stores only the hash of a session token, so a leaked database can't
|
||||
// be used to impersonate live sessions.
|
||||
func hashToken(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (a *Auth) userCount() (int, error) {
|
||||
var n int
|
||||
err := a.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
var errEmailTaken = errors.New("email already registered")
|
||||
|
||||
func (a *Auth) createUser(email, password string) (User, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
u := User{ID: newID(), Email: email}
|
||||
_, err = a.db.Exec(
|
||||
`INSERT INTO users (id, email, password, created) VALUES (?, ?, ?, ?)`,
|
||||
u.ID, email, string(hash), time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE") {
|
||||
return User{}, errEmailTaken
|
||||
}
|
||||
return User{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// verify returns the user for the given credentials, or ok=false if the email
|
||||
// is unknown or the password is wrong (indistinguishable to the caller).
|
||||
func (a *Auth) verify(email, password string) (User, bool) {
|
||||
var u User
|
||||
var hash string
|
||||
err := a.db.QueryRow(
|
||||
`SELECT id, email, password FROM users WHERE email = ? COLLATE NOCASE`, email,
|
||||
).Scan(&u.ID, &u.Email, &hash)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("verify: %v", err)
|
||||
}
|
||||
return User{}, false
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
||||
return User{}, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
// startSession mints a token, stores its hash, and returns the raw token for
|
||||
// the cookie.
|
||||
func (a *Auth) startSession(userID string) (string, error) {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
now := time.Now()
|
||||
_, err := a.db.Exec(
|
||||
`INSERT INTO sessions (token, user_id, created, expires) VALUES (?, ?, ?, ?)`,
|
||||
hashToken(token), userID, now.UnixMilli(), now.Add(sessionValidity).UnixMilli())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// userForToken resolves a raw cookie token to a user id, honouring expiry.
|
||||
func (a *Auth) userForToken(token string) (string, bool) {
|
||||
if token == "" {
|
||||
return "", false
|
||||
}
|
||||
var userID string
|
||||
var expires int64
|
||||
err := a.db.QueryRow(
|
||||
`SELECT user_id, expires FROM sessions WHERE token = ?`, hashToken(token),
|
||||
).Scan(&userID, &expires)
|
||||
if err != nil || time.Now().UnixMilli() > expires {
|
||||
return "", false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func (a *Auth) endSession(token string) {
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
if _, err := a.db.Exec(`DELETE FROM sessions WHERE token = ?`, hashToken(token)); err != nil {
|
||||
log.Printf("endSession: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// adopt gives every ownerless row (legacy single-tenant data) to userID, and
|
||||
// moves legacy flat-layout photos into that user's photo directory. Called once,
|
||||
// when the very first account registers.
|
||||
func (a *Auth) adopt(userID string) error {
|
||||
if _, err := a.db.Exec(`UPDATE events SET user_id = ? WHERE user_id = ''`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.Exec(`UPDATE config SET user_id = ? WHERE user_id = ''`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.adoptPhotos(userID)
|
||||
}
|
||||
|
||||
// adoptPhotos moves any *.jpg sitting directly in photosDir (the pre-accounts
|
||||
// flat layout) into photosDir/<userID>/.
|
||||
func (a *Auth) adoptPhotos(userID string) error {
|
||||
entries, err := os.ReadDir(a.photosDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
dstDir := filepath.Join(a.photosDir, userID)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jpg") {
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(
|
||||
filepath.Join(a.photosDir, e.Name()),
|
||||
filepath.Join(dstDir, e.Name()),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- cookies & middleware ----------
|
||||
|
||||
func (a *Auth) setCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: a.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(sessionValidity),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Auth) clearCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: a.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func cookieToken(r *http.Request) string {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// requireUser wraps a handler so it only runs for an authenticated request,
|
||||
// stashing the user id in the context. Unauthenticated calls get a 401 that the
|
||||
// client uses as its cue to show the login screen.
|
||||
func (a *Auth) requireUser(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := a.userForToken(cookieToken(r))
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userIDKey, userID)))
|
||||
}
|
||||
}
|
||||
|
||||
// userID returns the authenticated user's id; only valid inside a requireUser
|
||||
// handler.
|
||||
func userID(r *http.Request) string {
|
||||
id, _ := r.Context().Value(userIDKey).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// ---------- handlers ----------
|
||||
|
||||
type credentials struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Invite string `json:"invite"`
|
||||
}
|
||||
|
||||
func writeUser(w http.ResponseWriter, u User) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(u)
|
||||
}
|
||||
|
||||
func (a *Auth) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if a.inviteCode == "" {
|
||||
http.Error(w, "registration disabled", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var c credentials
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&c); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Constant-time compare so a wrong invite code can't be timed out.
|
||||
if subtle.ConstantTimeCompare([]byte(c.Invite), []byte(a.inviteCode)) != 1 {
|
||||
http.Error(w, "invalid invite code", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(strings.ToLower(c.Email))
|
||||
if !strings.Contains(email, "@") || len(email) > 200 {
|
||||
http.Error(w, "invalid email", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(c.Password) < 8 || len(c.Password) > 200 {
|
||||
http.Error(w, "password must be at least 8 characters", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Whether this is the first account decides adoption of legacy data. Check
|
||||
// before insert; the users table has no other writer during registration.
|
||||
first, err := a.userCount()
|
||||
if err != nil {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
u, err := a.createUser(email, c.Password)
|
||||
if errors.Is(err, errEmailTaken) {
|
||||
http.Error(w, "email already registered", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("register: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if first == 0 {
|
||||
if err := a.adopt(u.ID); err != nil {
|
||||
log.Printf("adopt legacy data: %v", err)
|
||||
// Non-fatal: the account exists; legacy data just stays ownerless.
|
||||
}
|
||||
}
|
||||
a.issue(w, u)
|
||||
}
|
||||
|
||||
func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var c credentials
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&c); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u, ok := a.verify(strings.TrimSpace(strings.ToLower(c.Email)), c.Password)
|
||||
if !ok {
|
||||
http.Error(w, "invalid email or password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
a.issue(w, u)
|
||||
}
|
||||
|
||||
// issue starts a session, sets the cookie, and returns the user.
|
||||
func (a *Auth) issue(w http.ResponseWriter, u User) {
|
||||
token, err := a.startSession(u.ID)
|
||||
if err != nil {
|
||||
log.Printf("start session: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.setCookie(w, token)
|
||||
writeUser(w, u)
|
||||
}
|
||||
|
||||
func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
a.endSession(cookieToken(r))
|
||||
a.clearCookie(w)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleMe reports the current account. Wrapped in requireUser, so reaching it
|
||||
// means the session is valid.
|
||||
func (a *Auth) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
var u User
|
||||
err := a.db.QueryRow(
|
||||
`SELECT id, email FROM users WHERE id = ?`, userID(r),
|
||||
).Scan(&u.ID, &u.Email)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
writeUser(w, u)
|
||||
}
|
||||
+5
-2
@@ -2,7 +2,10 @@ module puppy-tracker
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require modernc.org/sqlite v1.53.0
|
||||
require (
|
||||
golang.org/x/crypto v0.54.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -10,7 +13,7 @@ require (
|
||||
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.44.0 // 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
|
||||
|
||||
+4
-2
@@ -12,13 +12,15 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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=
|
||||
|
||||
+162
-44
@@ -54,12 +54,12 @@ func newConfigStore(db *sql.DB) *ConfigStore {
|
||||
return &ConfigStore{db: db}
|
||||
}
|
||||
|
||||
func (cs *ConfigStore) get() Config {
|
||||
func (cs *ConfigStore) get(userID string) Config {
|
||||
var c Config
|
||||
// The profile lives in a single row (id = 1). A missing row is the
|
||||
// pre-configuration state, so a zero-value Config is the right answer.
|
||||
// One profile row per user. A missing row is the pre-configuration state,
|
||||
// so a zero-value Config is the right answer.
|
||||
err := cs.db.QueryRow(
|
||||
`SELECT name, birthday, updated FROM config WHERE id = 1`,
|
||||
`SELECT name, birthday, updated FROM config WHERE user_id = ?`, userID,
|
||||
).Scan(&c.Name, &c.Birthday, &c.UpdatedAt)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("config get: %v", err)
|
||||
@@ -67,22 +67,22 @@ func (cs *ConfigStore) get() Config {
|
||||
return c
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// merge applies an incoming config for one user with last-write-wins by
|
||||
// UpdatedAt and returns the resulting stored config (which the caller sends back).
|
||||
func (cs *ConfigStore) merge(userID string, in Config) (Config, error) {
|
||||
// The upsert's WHERE clause enforces last-write-wins: the incoming row only
|
||||
// replaces the stored one when it is strictly newer.
|
||||
_, err := cs.db.Exec(`
|
||||
INSERT INTO config (id, name, birthday, updated)
|
||||
VALUES (1, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
INSERT INTO config (user_id, name, birthday, updated)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
name = excluded.name, birthday = excluded.birthday, updated = excluded.updated
|
||||
WHERE excluded.updated > config.updated`,
|
||||
in.Name, in.Birthday, in.UpdatedAt)
|
||||
userID, in.Name, in.Birthday, in.UpdatedAt)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cs.get(), nil
|
||||
return cs.get(userID), nil
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
@@ -93,9 +93,10 @@ func newStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// sync merges client events into the store using last-write-wins by UpdatedAt,
|
||||
// then returns the full merged set (including tombstones, which must propagate).
|
||||
func (s *Store) sync(client []Event) ([]Event, error) {
|
||||
// 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
|
||||
@@ -104,14 +105,18 @@ func (s *Store) sync(client []Event) ([]Event, error) {
|
||||
|
||||
// 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, updated, deleted)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO events (id, type, at, note, photo_id, weight, updated, deleted, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type = excluded.type, at = excluded.at, note = excluded.note,
|
||||
photo_id = excluded.photo_id, weight = excluded.weight,
|
||||
updated = excluded.updated, deleted = excluded.deleted
|
||||
WHERE excluded.updated > events.updated`)
|
||||
WHERE excluded.updated > events.updated
|
||||
AND events.user_id = excluded.user_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -122,7 +127,7 @@ func (s *Store) sync(client []Event) ([]Event, error) {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.Exec(
|
||||
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.UpdatedAt, ce.Deleted,
|
||||
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.UpdatedAt, ce.Deleted, userID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -130,13 +135,14 @@ func (s *Store) sync(client []Event) ([]Event, error) {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.all()
|
||||
return s.all(userID)
|
||||
}
|
||||
|
||||
// all returns every stored event, tombstones included.
|
||||
func (s *Store) all() ([]Event, error) {
|
||||
// all returns one user's events, tombstones included.
|
||||
func (s *Store) all(userID string) ([]Event, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, type, at, note, photo_id, weight, updated, deleted FROM events`)
|
||||
`SELECT id, type, at, note, photo_id, weight, updated, deleted
|
||||
FROM events WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -175,6 +181,9 @@ func openDB(path string) (*sql.DB, error) {
|
||||
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,
|
||||
@@ -184,21 +193,105 @@ func openDB(path string) (*sql.DB, error) {
|
||||
photo_id TEXT NOT NULL DEFAULT '',
|
||||
weight REAL NOT NULL DEFAULT 0,
|
||||
updated INTEGER NOT NULL DEFAULT 0,
|
||||
deleted INTEGER NOT NULL DEFAULT 0
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
user_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
user_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
birthday TEXT NOT NULL DEFAULT '',
|
||||
updated INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password TEXT NOT NULL,
|
||||
created INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created INTEGER NOT NULL,
|
||||
expires INTEGER NOT NULL
|
||||
);`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := migrateSchema(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// migrateSchema upgrades a single-tenant database (from before accounts existed)
|
||||
// in place: it adds events.user_id and rewrites the config table from its
|
||||
// old single-row (id = 1) shape to one keyed by user_id. Pre-accounts data ends
|
||||
// up ownerless (user_id = ”), ready for the first account to adopt. It is a
|
||||
// no-op on a fresh DB, where openDB already created the current schema.
|
||||
func migrateSchema(db *sql.DB) error {
|
||||
has, err := columnExists(db, "events", "user_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !has {
|
||||
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN user_id TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id)`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
oldConfig, err := columnExists(db, "config", "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if oldConfig {
|
||||
// Rebuild config keyed by user_id, moving the lone id=1 row into the
|
||||
// ownerless bucket.
|
||||
stmts := []string{
|
||||
`ALTER TABLE config RENAME TO config_old`,
|
||||
`CREATE TABLE config (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
birthday TEXT NOT NULL DEFAULT '',
|
||||
updated INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`INSERT INTO config (user_id, name, birthday, updated)
|
||||
SELECT '', name, birthday, updated FROM config_old WHERE id = 1`,
|
||||
`DROP TABLE config_old`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// columnExists reports whether the given table has a column of the given name.
|
||||
// A missing table reports false (no error), which is what fresh installs want.
|
||||
func columnExists(db *sql.DB, table, col string) (bool, error) {
|
||||
rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == col {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
// migrateJSON imports a pre-SQLite events.json / config.json sitting in dataDir
|
||||
// into an otherwise-empty database, then renames each file to *.imported so the
|
||||
// import runs exactly once. It is a no-op when the DB already holds data or the
|
||||
@@ -232,8 +325,10 @@ func importEvents(db *sql.DB, path string) error {
|
||||
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 {
|
||||
if _, err := store.sync("", evs); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("migrated %d events from %s", len(evs), path)
|
||||
@@ -266,7 +361,8 @@ func importConfig(db *sql.DB, path string) error {
|
||||
// Nothing meaningful to import; leave config empty.
|
||||
return os.Rename(path, path+".imported")
|
||||
}
|
||||
if _, err := newConfigStore(db).merge(c); err != nil {
|
||||
// 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)
|
||||
@@ -292,6 +388,10 @@ func main() {
|
||||
addr := flag.String("addr", ":8080", "listen address (e.g. :8080 or 0.0.0.0:8080)")
|
||||
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()
|
||||
|
||||
db, err := openDB(*dataPath)
|
||||
@@ -313,9 +413,19 @@ func main() {
|
||||
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(auth.handleMe))
|
||||
|
||||
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
|
||||
@@ -325,7 +435,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)
|
||||
@@ -337,11 +447,11 @@ func main() {
|
||||
Events: merged,
|
||||
ServerNow: time.Now().UnixMilli(),
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
// GET /api/config — return the shared puppy profile.
|
||||
// GET /api/config — return the caller's puppy profile.
|
||||
// PUT /api/config — update it (last-write-wins by updatedAt).
|
||||
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")
|
||||
@@ -349,7 +459,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 {
|
||||
@@ -364,7 +474,7 @@ func main() {
|
||||
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)
|
||||
@@ -374,7 +484,7 @@ func main() {
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
@@ -383,7 +493,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
|
||||
@@ -405,7 +515,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 {
|
||||
@@ -432,11 +549,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
|
||||
@@ -446,7 +564,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)
|
||||
@@ -459,9 +577,9 @@ 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))
|
||||
|
||||
Reference in New Issue
Block a user