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:
@@ -33,6 +33,22 @@ listen_addr = ":8080"
|
|||||||
site_title = "Pete"
|
site_title = "Pete"
|
||||||
base_url = "https://news.parodia.dev"
|
base_url = "https://news.parodia.dev"
|
||||||
|
|
||||||
|
# Optional OIDC sign-in (Authentik). When enabled, signed-in users get their
|
||||||
|
# preferences (hidden feeds, weather location, toggles) stored server-side keyed
|
||||||
|
# by their OIDC subject and synced across devices. Anonymous visitors keep using
|
||||||
|
# browser localStorage — the site stays public. If the provider is unreachable
|
||||||
|
# at startup, Pete logs a warning and serves anonymously rather than refusing to
|
||||||
|
# boot. Create an OAuth2/OIDC provider + application in Authentik, set the
|
||||||
|
# redirect URI to <base_url>/auth/callback, then fill these in.
|
||||||
|
[web.auth]
|
||||||
|
enabled = false
|
||||||
|
issuer = "https://authentik.parodia.dev/application/o/pete/"
|
||||||
|
client_id = "${PETE_OIDC_CLIENT_ID}"
|
||||||
|
client_secret = "${PETE_OIDC_CLIENT_SECRET}"
|
||||||
|
redirect_url = "https://news.parodia.dev/auth/callback"
|
||||||
|
# HMAC key that signs the session cookie. Generate with: openssl rand -hex 32
|
||||||
|
session_secret = "${PETE_SESSION_SECRET}"
|
||||||
|
|
||||||
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
|
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
|
||||||
# There is no automatic classification — Pete posts each story to its configured channel.
|
# There is no automatic classification — Pete posts each story to its configured channel.
|
||||||
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
# Optional: language = "en" drops feed items whose per-item <language> tag is
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -13,7 +13,9 @@ require (
|
|||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
|
github.com/coreos/go-oidc/v3 v3.19.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
@@ -35,6 +37,7 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||||
golang.org/x/image v0.41.0 // indirect
|
golang.org/x/image v0.41.0 // indirect
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.55.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
modernc.org/libc v1.72.3 // indirect
|
modernc.org/libc v1.72.3 // indirect
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -8,11 +8,15 @@ github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO
|
|||||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
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/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
@@ -96,6 +100,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
|||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
|||||||
@@ -22,10 +22,23 @@ type Config struct {
|
|||||||
|
|
||||||
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
|
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
|
||||||
type WebConfig struct {
|
type WebConfig struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
|
ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
|
||||||
SiteTitle string `toml:"site_title"` // display name in the header
|
SiteTitle string `toml:"site_title"` // display name in the header
|
||||||
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
||||||
|
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
|
||||||
|
// When enabled, signed-in users get their preferences stored server-side keyed
|
||||||
|
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
||||||
|
type AuthConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
Issuer string `toml:"issuer"` // e.g. https://authentik.parodia.dev/application/o/pete/
|
||||||
|
ClientID string `toml:"client_id"`
|
||||||
|
ClientSecret string `toml:"client_secret"`
|
||||||
|
RedirectURL string `toml:"redirect_url"` // e.g. https://news.parodia.dev/auth/callback
|
||||||
|
SessionSecret string `toml:"session_secret"` // HMAC key for signing the session cookie
|
||||||
}
|
}
|
||||||
|
|
||||||
type MatrixConfig struct {
|
type MatrixConfig struct {
|
||||||
@@ -73,7 +86,7 @@ type SourceConfig struct {
|
|||||||
// Language, when set, drops feed items whose per-item language tag is
|
// Language, when set, drops feed items whose per-item language tag is
|
||||||
// present and does not match (prefix). Useful for multilingual feeds
|
// present and does not match (prefix). Useful for multilingual feeds
|
||||||
// like Politico Europe that publish English + French side-by-side.
|
// like Politico Europe that publish English + French side-by-side.
|
||||||
Language string `toml:"language"`
|
Language string `toml:"language"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
@@ -124,6 +137,16 @@ func (c *Config) validate() error {
|
|||||||
return fmt.Errorf("storage.db_path is required")
|
return fmt.Errorf("storage.db_path is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Web.Auth.Enabled {
|
||||||
|
a := c.Web.Auth
|
||||||
|
if a.Issuer == "" || a.ClientID == "" || a.ClientSecret == "" || a.RedirectURL == "" {
|
||||||
|
return fmt.Errorf("web.auth requires issuer, client_id, client_secret, and redirect_url when enabled")
|
||||||
|
}
|
||||||
|
if len(a.SessionSecret) < 16 {
|
||||||
|
return fmt.Errorf("web.auth.session_secret must be at least 16 characters")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i, s := range c.Sources {
|
for i, s := range c.Sources {
|
||||||
if s.Name == "" {
|
if s.Name == "" {
|
||||||
return fmt.Errorf("sources[%d].name is required", i)
|
return fmt.Errorf("sources[%d].name is required", i)
|
||||||
|
|||||||
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
|
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 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_event_id ON post_log(event_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
|
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
|
||||||
|
|||||||
270
internal/web/auth.go
Normal file
270
internal/web/auth.go
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
|
"pete/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookie = "pete_session"
|
||||||
|
oauthCookie = "pete_oauth"
|
||||||
|
sessionTTL = 30 * 24 * time.Hour // stay signed in for a month
|
||||||
|
oauthTTL = 10 * time.Minute // login round-trip window
|
||||||
|
)
|
||||||
|
|
||||||
|
// Authenticator holds the OIDC plumbing for optional sign-in via Authentik.
|
||||||
|
type Authenticator struct {
|
||||||
|
oauth *oauth2.Config
|
||||||
|
verifier *oidc.IDTokenVerifier
|
||||||
|
secret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionUser is the identity carried in the signed session cookie.
|
||||||
|
type SessionUser struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Exp int64 `json:"exp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display is the friendly name shown in the header (name, else email, else sub).
|
||||||
|
func (u *SessionUser) Display() string {
|
||||||
|
if u.Name != "" {
|
||||||
|
return u.Name
|
||||||
|
}
|
||||||
|
if u.Email != "" {
|
||||||
|
return u.Email
|
||||||
|
}
|
||||||
|
return u.Sub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial is the single uppercase glyph for the avatar bubble.
|
||||||
|
func (u *SessionUser) Initial() string {
|
||||||
|
d := strings.TrimSpace(u.Display())
|
||||||
|
if d == "" {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
return strings.ToUpper(d[:1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauthState is the short-lived CSRF/nonce envelope for the login round-trip.
|
||||||
|
type oauthState struct {
|
||||||
|
State string `json:"s"`
|
||||||
|
Nonce string `json:"n"`
|
||||||
|
Next string `json:"r"`
|
||||||
|
Exp int64 `json:"e"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAuthenticator performs OIDC discovery against the configured issuer. It is
|
||||||
|
// non-fatal at the call site: if discovery fails (provider down at boot), the
|
||||||
|
// site still serves anonymously.
|
||||||
|
func newAuthenticator(ctx context.Context, cfg config.AuthConfig) (*Authenticator, error) {
|
||||||
|
provider, err := oidc.NewProvider(ctx, strings.TrimRight(cfg.Issuer, "/")+"/")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("oidc discovery: %w", err)
|
||||||
|
}
|
||||||
|
return &Authenticator{
|
||||||
|
oauth: &oauth2.Config{
|
||||||
|
ClientID: cfg.ClientID,
|
||||||
|
ClientSecret: cfg.ClientSecret,
|
||||||
|
RedirectURL: cfg.RedirectURL,
|
||||||
|
Endpoint: provider.Endpoint(),
|
||||||
|
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||||
|
},
|
||||||
|
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||||
|
secret: []byte(cfg.SessionSecret),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- signed-cookie helpers ------------------------------------------------
|
||||||
|
|
||||||
|
// sign returns base64url(payload).base64url(hmac) — a compact stateless token.
|
||||||
|
func (a *Authenticator) sign(payload []byte) string {
|
||||||
|
mac := hmac.New(sha256.New, a.secret)
|
||||||
|
mac.Write(payload)
|
||||||
|
b64 := base64.RawURLEncoding
|
||||||
|
return b64.EncodeToString(payload) + "." + b64.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify checks the HMAC and returns the raw payload bytes.
|
||||||
|
func (a *Authenticator) verify(token string) ([]byte, bool) {
|
||||||
|
parts := strings.SplitN(token, ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
b64 := base64.RawURLEncoding
|
||||||
|
payload, err := b64.DecodeString(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
want, err := b64.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, a.secret)
|
||||||
|
mac.Write(payload)
|
||||||
|
if subtle.ConstantTimeCompare(want, mac.Sum(nil)) != 1 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return payload, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func randToken() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// userFromRequest returns the signed-in user, or nil for anonymous visitors.
|
||||||
|
func (a *Authenticator) userFromRequest(r *http.Request) *SessionUser {
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload, ok := a.verify(c.Value)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var u SessionUser
|
||||||
|
if json.Unmarshal(payload, &u) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if u.Sub == "" || time.Now().Unix() > u.Exp {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &u
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Authenticator) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: value,
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Now().Add(ttl),
|
||||||
|
MaxAge: int(ttl.Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Authenticator) clearCookie(w http.ResponseWriter, name string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: name, Value: "", Path: "/", MaxAge: -1,
|
||||||
|
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- handlers -------------------------------------------------------------
|
||||||
|
|
||||||
|
// handleLogin starts the OIDC authorization-code flow.
|
||||||
|
func (a *Authenticator) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
st := oauthState{
|
||||||
|
State: randToken(),
|
||||||
|
Nonce: randToken(),
|
||||||
|
Next: safeNext(r.URL.Query().Get("next")),
|
||||||
|
Exp: time.Now().Add(oauthTTL).Unix(),
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(st)
|
||||||
|
a.setCookie(w, oauthCookie, a.sign(payload), oauthTTL)
|
||||||
|
http.Redirect(w, r, a.oauth.AuthCodeURL(st.State, oidc.Nonce(st.Nonce)), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCallback completes the flow: validates state, exchanges the code,
|
||||||
|
// verifies the ID token, and issues a session cookie.
|
||||||
|
func (a *Authenticator) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c, err := r.Cookie(oauthCookie)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "login expired, please try again", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.clearCookie(w, oauthCookie)
|
||||||
|
payload, ok := a.verify(c.Value)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "invalid login state", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var st oauthState
|
||||||
|
if json.Unmarshal(payload, &st) != nil || time.Now().Unix() > st.Exp {
|
||||||
|
http.Error(w, "login expired, please try again", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(st.State), []byte(r.URL.Query().Get("state"))) != 1 {
|
||||||
|
http.Error(w, "state mismatch", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
tok, err := a.oauth.Exchange(ctx, r.URL.Query().Get("code"))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("auth: code exchange failed", "err", err)
|
||||||
|
http.Error(w, "sign-in failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rawID, ok := tok.Extra("id_token").(string)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "no id_token in response", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idToken, err := a.verifier.Verify(ctx, rawID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("auth: id_token verify failed", "err", err)
|
||||||
|
http.Error(w, "sign-in failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idToken.Nonce != st.Nonce {
|
||||||
|
http.Error(w, "nonce mismatch", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var claims struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PreferredUsername string `json:"preferred_username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := idToken.Claims(&claims); err != nil {
|
||||||
|
http.Error(w, "sign-in failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := claims.Name
|
||||||
|
if name == "" {
|
||||||
|
name = claims.PreferredUsername
|
||||||
|
}
|
||||||
|
u := SessionUser{Sub: claims.Sub, Name: name, Email: claims.Email, Exp: time.Now().Add(sessionTTL).Unix()}
|
||||||
|
sess, _ := json.Marshal(u)
|
||||||
|
a.setCookie(w, sessionCookie, a.sign(sess), sessionTTL)
|
||||||
|
slog.Info("auth: user signed in", "sub", claims.Sub, "name", name)
|
||||||
|
http.Redirect(w, r, st.Next, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogout clears the local session. It does not end the upstream Authentik
|
||||||
|
// SSO session (that stays the user's choice).
|
||||||
|
func (a *Authenticator) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.clearCookie(w, sessionCookie)
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeNext keeps post-login redirects on-site (no open redirects).
|
||||||
|
func safeNext(next string) string {
|
||||||
|
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
55
internal/web/auth_test.go
Normal file
55
internal/web/auth_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||||
|
a := &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
payload := []byte(`{"sub":"abc","exp":123}`)
|
||||||
|
tok := a.sign(payload)
|
||||||
|
|
||||||
|
got, ok := a.verify(tok)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("verify rejected a freshly signed token")
|
||||||
|
}
|
||||||
|
if string(got) != string(payload) {
|
||||||
|
t.Fatalf("payload mismatch: got %q want %q", got, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejectsTamperAndForeignKey(t *testing.T) {
|
||||||
|
a := &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||||
|
tok := a.sign([]byte(`{"sub":"abc"}`))
|
||||||
|
|
||||||
|
// Flip a byte in the payload segment.
|
||||||
|
bad := []byte(tok)
|
||||||
|
bad[0] ^= 0x01
|
||||||
|
if _, ok := a.verify(string(bad)); ok {
|
||||||
|
t.Error("verify accepted a tampered token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different key must not validate the same token.
|
||||||
|
other := &Authenticator{secret: []byte("a-totally-different-key-16")}
|
||||||
|
if _, ok := other.verify(tok); ok {
|
||||||
|
t.Error("verify accepted a token signed with a different key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := a.verify("not-a-valid-token"); ok {
|
||||||
|
t.Error("verify accepted a malformed token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeNext(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"": "/",
|
||||||
|
"/tech": "/tech",
|
||||||
|
"/tech?page=2": "/tech?page=2",
|
||||||
|
"//evil.com": "/", // protocol-relative open redirect
|
||||||
|
"https://evil.com": "/", // absolute URL
|
||||||
|
"javascript:alert": "/", // not a path
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := safeNext(in); got != want {
|
||||||
|
t.Errorf("safeNext(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,24 +43,28 @@ func toView(s storage.Story) StoryView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
SiteTitle string
|
SiteTitle string
|
||||||
Channels []Channel
|
Channels []Channel
|
||||||
Active string // slug of active channel, "" for landing
|
Active string // slug of active channel, "" for landing
|
||||||
Weather Weather
|
Weather Weather
|
||||||
Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
|
Phase string // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
|
||||||
AllSources template.JS // JSON array of {name, channel} for the settings panel
|
AllSources template.JS // JSON array of {name, channel} for the settings panel
|
||||||
|
AuthEnabled bool // sign-in is available
|
||||||
|
User *SessionUser // nil for anonymous visitors
|
||||||
|
UserPrefs template.JS // signed-in user's stored prefs blob (JSON), or "null"
|
||||||
|
Path string // current request path, for post-login return
|
||||||
}
|
}
|
||||||
|
|
||||||
type channelPage struct {
|
type channelPage struct {
|
||||||
pageData
|
pageData
|
||||||
Channel Channel
|
Channel Channel
|
||||||
Stories []StoryView
|
Stories []StoryView
|
||||||
Page int
|
Page int
|
||||||
HasPrev bool
|
HasPrev bool
|
||||||
HasNext bool
|
HasNext bool
|
||||||
PrevURL string
|
PrevURL string
|
||||||
NextURL string
|
NextURL string
|
||||||
Total int
|
Total int
|
||||||
}
|
}
|
||||||
|
|
||||||
type indexPage struct {
|
type indexPage struct {
|
||||||
@@ -78,20 +82,32 @@ type channelStat struct {
|
|||||||
Total int
|
Total int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) base() pageData {
|
func (s *Server) base(r *http.Request) pageData {
|
||||||
srcJSON, err := json.Marshal(s.sources)
|
srcJSON, err := json.Marshal(s.sources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
srcJSON = []byte("[]")
|
srcJSON = []byte("[]")
|
||||||
}
|
}
|
||||||
return pageData{
|
d := pageData{
|
||||||
SiteTitle: s.cfg.SiteTitle,
|
SiteTitle: s.cfg.SiteTitle,
|
||||||
Channels: channels,
|
Channels: channels,
|
||||||
Weather: currentWeather(time.Now()),
|
Weather: currentWeather(time.Now()),
|
||||||
AllSources: template.JS(srcJSON),
|
AllSources: template.JS(srcJSON),
|
||||||
|
AuthEnabled: s.auth != nil,
|
||||||
|
UserPrefs: template.JS("null"),
|
||||||
|
Path: r.URL.Path,
|
||||||
}
|
}
|
||||||
|
if s.auth != nil {
|
||||||
|
if u := s.auth.userFromRequest(r); u != nil {
|
||||||
|
d.User = u
|
||||||
|
if blob, err := storage.GetUserPrefs(u.Sub); err == nil && blob != "" {
|
||||||
|
d.UserPrefs = template.JS(blob)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleIndex(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
const (
|
const (
|
||||||
justPostedLimit = 6
|
justPostedLimit = 6
|
||||||
latestLimit = 16
|
latestLimit = 16
|
||||||
@@ -137,7 +153,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, _ *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := indexPage{
|
data := indexPage{
|
||||||
pageData: s.base(),
|
pageData: s.base(r),
|
||||||
JustPosted: justPosted,
|
JustPosted: justPosted,
|
||||||
Stats: stats,
|
Stats: stats,
|
||||||
Latest: latest,
|
Latest: latest,
|
||||||
@@ -169,7 +185,7 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
|
|||||||
}
|
}
|
||||||
total, _ := storage.CountClassifiedByChannel(ch.Slug)
|
total, _ := storage.CountClassifiedByChannel(ch.Slug)
|
||||||
|
|
||||||
base := s.base()
|
base := s.base(r)
|
||||||
base.Active = ch.Slug
|
base.Active = ch.Slug
|
||||||
data := channelPage{
|
data := channelPage{
|
||||||
pageData: base,
|
pageData: base,
|
||||||
@@ -230,7 +246,7 @@ func (s *Server) handleWeatherDemo(w http.ResponseWriter, r *http.Request) {
|
|||||||
intensity := pickOr(q.Get("intensity"), demoIntensities, "heavy")
|
intensity := pickOr(q.Get("intensity"), demoIntensities, "heavy")
|
||||||
phase := pickOr(q.Get("phase"), demoPhases, "day")
|
phase := pickOr(q.Get("phase"), demoPhases, "day")
|
||||||
|
|
||||||
base := s.base()
|
base := s.base(r)
|
||||||
base.Weather = Weather{Season: seasonForVariant(variant), Variant: variant, Intensity: intensity}
|
base.Weather = Weather{Season: seasonForVariant(variant), Variant: variant, Intensity: intensity}
|
||||||
base.Phase = phase
|
base.Phase = phase
|
||||||
|
|
||||||
|
|||||||
74
internal/web/prefs_api.go
Normal file
74
internal/web/prefs_api.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"pete/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxPrefsBytes caps the stored blob. Preferences are small (disabled feeds,
|
||||||
|
// a postal code, a couple toggles); this is generous headroom, not a target.
|
||||||
|
const maxPrefsBytes = 64 * 1024
|
||||||
|
|
||||||
|
// handlePrefs serves GET (load) and PUT (save) of the signed-in user's
|
||||||
|
// preferences blob. The blob is opaque JSON owned by the frontend. Both verbs
|
||||||
|
// require a session; anonymous callers get 401 and fall back to localStorage.
|
||||||
|
func (s *Server) handlePrefs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.auth == nil {
|
||||||
|
http.Error(w, "auth disabled", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u := s.auth.userFromRequest(r)
|
||||||
|
if u == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
http.Error(w, `{"error":"not signed in"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
blob, err := storage.GetUserPrefs(u.Sub)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("prefs: load failed", "sub", u.Sub, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
if blob == "" {
|
||||||
|
_, _ = w.Write([]byte(`{"prefs":null}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// blob is already valid JSON; embed it verbatim.
|
||||||
|
_, _ = w.Write([]byte(`{"prefs":`))
|
||||||
|
_, _ = io.WriteString(w, blob)
|
||||||
|
_, _ = w.Write([]byte(`}`))
|
||||||
|
|
||||||
|
case http.MethodPut:
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxPrefsBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read error", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body) > maxPrefsBytes {
|
||||||
|
http.Error(w, "preferences too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !json.Valid(body) {
|
||||||
|
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := storage.PutUserPrefs(u.Sub, string(body), u.Name, u.Email); err != nil {
|
||||||
|
slog.Error("prefs: save failed", "sub", u.Sub, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ type Server struct {
|
|||||||
sources []SourceInfo
|
sources []SourceInfo
|
||||||
srv *http.Server
|
srv *http.Server
|
||||||
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
||||||
|
auth *Authenticator // nil when sign-in is disabled or unavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds the server. Templates are parsed once at startup. Each page
|
// New builds the server. Templates are parsed once at startup. Each page
|
||||||
@@ -82,6 +83,22 @@ func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) {
|
|||||||
infos = append(infos, SourceInfo{Name: sc.Name, Channel: sc.DirectRoute})
|
infos = append(infos, SourceInfo{Name: sc.Name, Channel: sc.DirectRoute})
|
||||||
}
|
}
|
||||||
s := &Server{cfg: cfg, sources: infos, tpls: tpls}
|
s := &Server{cfg: cfg, sources: infos, tpls: tpls}
|
||||||
|
|
||||||
|
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
|
||||||
|
// provider is unreachable at boot we log and serve anonymously rather than
|
||||||
|
// refusing to start the whole site.
|
||||||
|
if cfg.Auth.Enabled {
|
||||||
|
dctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
auth, err := newAuthenticator(dctx, cfg.Auth)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("web: OIDC auth init failed; sign-in disabled", "err", err)
|
||||||
|
} else {
|
||||||
|
s.auth = auth
|
||||||
|
slog.Info("web: OIDC sign-in enabled", "issuer", cfg.Auth.Issuer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
staticSub, err := fs.Sub(staticFS, "static")
|
staticSub, err := fs.Sub(staticFS, "static")
|
||||||
@@ -105,6 +122,14 @@ func New(cfg config.WebConfig, sources []config.SourceConfig) (*Server, error) {
|
|||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if s.auth != nil {
|
||||||
|
mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
|
||||||
|
mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)
|
||||||
|
mux.HandleFunc("GET /auth/logout", s.auth.handleLogout)
|
||||||
|
mux.HandleFunc("GET /api/preferences", s.handlePrefs)
|
||||||
|
mux.HandleFunc("PUT /api/preferences", s.handlePrefs)
|
||||||
|
}
|
||||||
|
|
||||||
s.srv = &http.Server{
|
s.srv = &http.Server{
|
||||||
Addr: cfg.ListenAddr,
|
Addr: cfg.ListenAddr,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
70
internal/web/static/js/prefs.js
Normal file
70
internal/web/static/js/prefs.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// Preference sync. Anonymous visitors keep using localStorage exactly as
|
||||||
|
// before — this file is a no-op for them. When a user is signed in (Authentik
|
||||||
|
// via OIDC), the server is the source of truth: their stored blob is injected
|
||||||
|
// as window.PETE_PREFS and seeded into localStorage *synchronously* here, before
|
||||||
|
// the feature scripts (settings.js, weather*.js) read it. Those scripts then
|
||||||
|
// call PetePrefs.push() after every write to mirror the change back up.
|
||||||
|
//
|
||||||
|
// This script must run before the others — it's loaded first in layout.html,
|
||||||
|
// and all the feature scripts are `defer`, so document order is guaranteed.
|
||||||
|
(function () {
|
||||||
|
// The localStorage keys we sync. The weather *cache* is deliberately excluded:
|
||||||
|
// it's transient and per-device.
|
||||||
|
var SYNCED = ["pete.disabledSources.v1", "pete.weather.loc.v1", "pete-weather-off"];
|
||||||
|
|
||||||
|
var user = window.PETE_USER || null;
|
||||||
|
var serverPrefs = window.PETE_PREFS || null;
|
||||||
|
|
||||||
|
function seed(prefs) {
|
||||||
|
SYNCED.forEach(function (k) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(prefs, k)) return;
|
||||||
|
var v = prefs[k];
|
||||||
|
try {
|
||||||
|
if (v === null || v === undefined) localStorage.removeItem(k);
|
||||||
|
else localStorage.setItem(k, String(v));
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
var out = {};
|
||||||
|
SYNCED.forEach(function (k) {
|
||||||
|
try { var v = localStorage.getItem(k); if (v !== null) out[k] = v; } catch (e) {}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
var timer = null;
|
||||||
|
function push() {
|
||||||
|
if (!user) return; // anonymous: localStorage only
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(function () {
|
||||||
|
timer = null;
|
||||||
|
try {
|
||||||
|
fetch("/api/preferences", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(snapshot()),
|
||||||
|
credentials: "same-origin",
|
||||||
|
keepalive: true
|
||||||
|
}).catch(function () {});
|
||||||
|
} catch (e) {}
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.PetePrefs = { push: push, loggedIn: !!user, syncedKeys: SYNCED };
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
if (serverPrefs && typeof serverPrefs === "object") {
|
||||||
|
seed(serverPrefs); // cross-device: server wins on load
|
||||||
|
} else {
|
||||||
|
push(); // first sign-in: migrate this browser's prefs to the account
|
||||||
|
}
|
||||||
|
// Swap "saved in this browser" copy for the synced story.
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
document.querySelectorAll("[data-storage-note]").forEach(function (el) {
|
||||||
|
el.textContent = "Synced to your account ✓";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
}
|
}
|
||||||
function save(set) {
|
function save(set) {
|
||||||
try { localStorage.setItem(KEY, JSON.stringify(set)); } catch (e) {}
|
try { localStorage.setItem(KEY, JSON.stringify(set)); } catch (e) {}
|
||||||
|
if (window.PetePrefs) window.PetePrefs.push();
|
||||||
}
|
}
|
||||||
|
|
||||||
var disabled = load();
|
var disabled = load();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
function setLoc(loc) {
|
function setLoc(loc) {
|
||||||
if (loc) writeJSON(LOC_KEY, loc);
|
if (loc) writeJSON(LOC_KEY, loc);
|
||||||
else { try { localStorage.removeItem(LOC_KEY); } catch (e) {} }
|
else { try { localStorage.removeItem(LOC_KEY); } catch (e) {} }
|
||||||
|
if (window.PetePrefs) window.PetePrefs.push();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- WMO weather code → canvas variant + label + emoji -------------------
|
// ---- WMO weather code → canvas variant + label + emoji -------------------
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
if (v) localStorage.setItem(STORAGE_KEY, "1");
|
if (v) localStorage.setItem(STORAGE_KEY, "1");
|
||||||
else localStorage.removeItem(STORAGE_KEY);
|
else localStorage.removeItem(STORAGE_KEY);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
if (window.PetePrefs) window.PetePrefs.push();
|
||||||
}
|
}
|
||||||
function syncBtn(enabled) {
|
function syncBtn(enabled) {
|
||||||
if (!toggleBtn) return;
|
if (!toggleBtn) return;
|
||||||
|
|||||||
@@ -65,6 +65,28 @@
|
|||||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
||||||
<span data-weather-chip class="tabular-nums">📍 Weather</span>
|
<span data-weather-chip class="tabular-nums">📍 Weather</span>
|
||||||
</button>
|
</button>
|
||||||
|
{{if .AuthEnabled}}
|
||||||
|
{{if .User}}
|
||||||
|
<a href="/auth/logout" data-account
|
||||||
|
title="Signed in as {{.User.Display}}{{if .User.Email}} · {{.User.Email}}{{end}} — sign out"
|
||||||
|
class="inline-flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-2.5 py-1.5 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
||||||
|
<span class="grid h-6 w-6 place-items-center rounded-full bg-[color:var(--accent)] text-white text-xs font-bold">{{.User.Initial}}</span>
|
||||||
|
<span class="hidden sm:inline max-w-[7rem] truncate">{{.User.Display}}</span>
|
||||||
|
</a>
|
||||||
|
{{else}}
|
||||||
|
<a href="/auth/login?next={{.Path}}" data-signin
|
||||||
|
title="Sign in to sync your preferences"
|
||||||
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4 text-[color:var(--ink)]/60">
|
||||||
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
|
||||||
|
<polyline points="10 17 15 12 10 7"></polyline>
|
||||||
|
<line x1="15" y1="12" x2="3" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Sign in</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
<div class="flex items-center gap-2 min-w-0 max-w-full">
|
<div class="flex items-center gap-2 min-w-0 max-w-full">
|
||||||
<button type="button" data-search-trigger title="Search (⌘K)"
|
<button type="button" data-search-trigger title="Search (⌘K)"
|
||||||
class="flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
class="flex shrink-0 items-center gap-2 rounded-full bg-[color:var(--card)] px-3 py-2 text-sm shadow-pete border-2 border-[color:var(--ink)]/10 hover:bg-[color:var(--ink)]/5 transition">
|
||||||
@@ -112,7 +134,7 @@
|
|||||||
<h2 class="font-display text-lg font-bold">Feed settings</h2>
|
<h2 class="font-display text-lg font-bold">Feed settings</h2>
|
||||||
<button type="button" data-settings-close class="rounded-full px-2 py-1 text-sm text-[color:var(--ink)]/60 hover:bg-[color:var(--ink)]/5">esc</button>
|
<button type="button" data-settings-close class="rounded-full px-2 py-1 text-sm text-[color:var(--ink)]/60 hover:bg-[color:var(--ink)]/5">esc</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="px-5 pt-3 text-xs text-[color:var(--ink)]/60">Uncheck a feed to hide its stories. Saved in this browser.</p>
|
<p class="px-5 pt-3 text-xs text-[color:var(--ink)]/60">Uncheck a feed to hide its stories. <span data-storage-note>Saved in this browser.</span></p>
|
||||||
<div data-settings-list class="max-h-[55vh] overflow-y-auto p-3 space-y-1"></div>
|
<div data-settings-list class="max-h-[55vh] overflow-y-auto p-3 space-y-1"></div>
|
||||||
<div class="px-5 py-3 border-t border-[color:var(--ink)]/10 flex items-center justify-between text-xs">
|
<div class="px-5 py-3 border-t border-[color:var(--ink)]/10 flex items-center justify-between text-xs">
|
||||||
<button type="button" data-settings-reset class="text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] font-semibold">Enable all</button>
|
<button type="button" data-settings-reset class="text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] font-semibold">Enable all</button>
|
||||||
@@ -148,7 +170,12 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>window.PETE_SOURCES = {{.AllSources}};</script>
|
<script>
|
||||||
|
window.PETE_SOURCES = {{.AllSources}};
|
||||||
|
window.PETE_USER = {{if .User}}{ name: {{.User.Display}}, email: {{.User.Email}} }{{else}}null{{end}};
|
||||||
|
window.PETE_PREFS = {{.UserPrefs}};
|
||||||
|
</script>
|
||||||
|
<script src="/static/js/prefs.js" defer></script>
|
||||||
<script src="/static/js/weather.js" defer></script>
|
<script src="/static/js/weather.js" defer></script>
|
||||||
<script src="/static/js/weather-forecast.js" defer></script>
|
<script src="/static/js/weather-forecast.js" defer></script>
|
||||||
<script src="/static/js/search.js" defer></script>
|
<script src="/static/js/search.js" defer></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user