Add optional Authentik (OIDC) sign-in with server-side preference sync
Signed-in users get their preferences (hidden feeds, weather location, weather toggle) stored server-side keyed by their OIDC subject and synced across devices. Anonymous visitors keep using browser localStorage, so the site stays public. First sign-in migrates existing localStorage prefs up. - config: [web.auth] section (issuer, client_id/secret, redirect, session_secret) - storage: user_preferences table + Get/PutUserPrefs - web/auth: OIDC code flow, HMAC-signed session cookie, CSRF state + nonce - web/prefs_api: GET/PUT /api/preferences (auth-gated, 64KB cap) - frontend: prefs.js sync layer seeds localStorage from server, pushes on write - header: sign-in / account control OIDC discovery is non-fatal at boot: if Authentik is down, Pete serves anonymously rather than refusing to start.
This commit is contained in:
50
internal/storage/prefs.go
Normal file
50
internal/storage/prefs.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// UserPrefs is one signed-in user's stored state. Prefs is an opaque JSON blob
|
||||
// owned by the web frontend (disabled feeds, weather location, toggles, …) —
|
||||
// the backend just persists and returns it, keyed by the OIDC subject.
|
||||
type UserPrefs struct {
|
||||
Sub string
|
||||
Prefs string
|
||||
Username string
|
||||
Email string
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// GetUserPrefs returns the stored prefs blob for an OIDC subject. It returns
|
||||
// ("", nil) when the user has no record yet (first sign-in).
|
||||
func GetUserPrefs(sub string) (string, error) {
|
||||
var prefs string
|
||||
err := Get().QueryRow(`SELECT prefs FROM user_preferences WHERE user_sub = ?`, sub).Scan(&prefs)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get user prefs: %w", err)
|
||||
}
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
// PutUserPrefs upserts a user's prefs blob along with identity hints (username,
|
||||
// email) for later features. The prefs string is stored verbatim.
|
||||
func PutUserPrefs(sub, prefs, username, email string) error {
|
||||
_, err := Get().Exec(`
|
||||
INSERT INTO user_preferences (user_sub, prefs, username, email, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_sub) DO UPDATE SET
|
||||
prefs = excluded.prefs,
|
||||
username = excluded.username,
|
||||
email = excluded.email,
|
||||
updated_at = excluded.updated_at`,
|
||||
sub, prefs, username, email, nowUnix())
|
||||
if err != nil {
|
||||
return fmt.Errorf("put user prefs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
44
internal/storage/prefs_test.go
Normal file
44
internal/storage/prefs_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserPrefsRoundTrip(t *testing.T) {
|
||||
if err := Init(filepath.Join(t.TempDir(), "prefs.db")); err != nil {
|
||||
t.Fatalf("init db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = Close() })
|
||||
|
||||
// Missing user → empty, no error.
|
||||
got, err := GetUserPrefs("nobody")
|
||||
if err != nil {
|
||||
t.Fatalf("get missing: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty for missing user, got %q", got)
|
||||
}
|
||||
|
||||
blob := `{"pete.weather.loc.v1":"{\"postal\":\"1000\"}"}`
|
||||
if err := PutUserPrefs("ak-sub-1", blob, "misaki", "m@example.com"); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
got, err = GetUserPrefs("ak-sub-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got != blob {
|
||||
t.Fatalf("blob mismatch:\n got %q\nwant %q", got, blob)
|
||||
}
|
||||
|
||||
// Upsert: second put for same sub replaces the blob.
|
||||
blob2 := `{"pete-weather-off":"1"}`
|
||||
if err := PutUserPrefs("ak-sub-1", blob2, "misaki", "m@example.com"); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
got, _ = GetUserPrefs("ak-sub-1")
|
||||
if got != blob2 {
|
||||
t.Fatalf("upsert mismatch: got %q want %q", got, blob2)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,14 @@ CREATE TABLE IF NOT EXISTS reactions (
|
||||
reacted_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_sub TEXT PRIMARY KEY,
|
||||
prefs TEXT NOT NULL,
|
||||
username TEXT,
|
||||
email TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
|
||||
|
||||
Reference in New Issue
Block a user