Reader read-aloud now streams neural WAV audio from a new POST /api/tts endpoint that shells out to Piper, instead of the browser's Web Speech voice. Each paragraph is synthesized on demand with the next one prefetched during playback, keeping the existing highlight/scroll sync. Voices are configured under [web.tts] (piper binary + voices_dir + a labelled voice list) and exposed to the client as window.PETE_TTS; the reader gets a Voice selector in the Aa menu, persisted per-device. Still a signed-in-only perk and gated on auth.
209 lines
8.0 KiB
Go
209 lines
8.0 KiB
Go
// Package web serves Pete's read-only news interface.
|
|
package web
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"errors"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"pete/internal/config"
|
|
)
|
|
|
|
//go:embed templates/*.html
|
|
var templateFS embed.FS
|
|
|
|
//go:embed static
|
|
var staticFS embed.FS
|
|
|
|
// Channel describes one section of the site.
|
|
type Channel struct {
|
|
Slug string // "gaming"
|
|
Title string // "Gaming"
|
|
Theme string // tailwind theme key: "gaming" | "tech" | "politics"
|
|
Emoji string
|
|
Blurb string
|
|
}
|
|
|
|
// channels in display order. Add a new entry here to add a section.
|
|
var channels = []Channel{
|
|
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
|
|
{Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."},
|
|
{Slug: "politics", Title: "Politics", Theme: "politics", Emoji: "🏛️", Blurb: "Policy, power, and the news of the day."},
|
|
{Slug: "eu", Title: "EU", Theme: "eu", Emoji: "🇪🇺", Blurb: "Portugal and the wider European beat. Read-only — these stories don't post to Matrix."},
|
|
{Slug: "music", Title: "Music", Theme: "music", Emoji: "🎵", Blurb: "Records, scenes, and the artists shaping the sound."},
|
|
{Slug: "anime", Title: "Anime", Theme: "anime", Emoji: "🌸", Blurb: "Series, studios, and the wider world of anime and manga."},
|
|
{Slug: "foss", Title: "FOSS", Theme: "foss", Emoji: "🐧", Blurb: "Kernel, distros, and the wider free and open source software world."},
|
|
{Slug: "kids", Title: "Kids", Theme: "kids", Emoji: "🧒", Blurb: "World news written for younger readers — short, clear, and curious."},
|
|
{Slug: "finance", Title: "Finance", Theme: "finance", Emoji: "💰", Blurb: "Markets, money, and the business of the world. Read-only — these stories don't post to Matrix."},
|
|
{Slug: "lego", Title: "LEGO", Theme: "lego", Emoji: "🧱", Blurb: "Sets, releases, and the wider brick-building world."},
|
|
}
|
|
|
|
// SourceInfo is the trimmed view of a configured feed for the settings panel.
|
|
type SourceInfo struct {
|
|
Name string `json:"name"`
|
|
Channel string `json:"channel"`
|
|
}
|
|
|
|
// Server is the HTTP server for Pete's web UI.
|
|
type Server struct {
|
|
cfg config.WebConfig
|
|
sources []SourceInfo
|
|
postingEnabled bool // false = web-only mode; hides Matrix-posting UI
|
|
srv *http.Server
|
|
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
|
|
auth *Authenticator // nil when sign-in is disabled or unavailable
|
|
tts *ttsService // nil when server-side read-aloud is disabled
|
|
adminSubs map[string]bool // OIDC subjects allowed to view /status
|
|
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
|
|
|
|
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
|
|
// Guarded by metricsMu; never persisted (see metrics.go).
|
|
metricsMu sync.Mutex
|
|
saltDay int64
|
|
salt [16]byte
|
|
}
|
|
|
|
// New builds the server. Templates are parsed once at startup. Each page
|
|
// gets its own template set sharing layout.html + _card.html, which avoids
|
|
// `main` define collisions between pages.
|
|
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool) (*Server, error) {
|
|
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status"}
|
|
tpls := make(map[string]*template.Template, len(pages))
|
|
for _, p := range pages {
|
|
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
|
|
"templates/layout.html",
|
|
"templates/_card.html",
|
|
"templates/"+p+".html",
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tpls[p] = t
|
|
}
|
|
infos := make([]SourceInfo, 0, len(sources))
|
|
for _, sc := range sources {
|
|
if !sc.Enabled || sc.Name == "" {
|
|
continue
|
|
}
|
|
infos = append(infos, SourceInfo{Name: sc.Name, Channel: sc.DirectRoute})
|
|
}
|
|
adminSubs := make(map[string]bool, len(cfg.AdminSubs))
|
|
for _, sub := range cfg.AdminSubs {
|
|
if sub != "" {
|
|
adminSubs[sub] = true
|
|
}
|
|
}
|
|
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// Optional server-side neural read-aloud (Piper). Signed-in only, so it is
|
|
// only useful alongside auth; newTTS logs and disables itself on any misconfig.
|
|
if s.auth != nil {
|
|
tts, err := newTTS(cfg.TTS)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.tts = tts
|
|
} else if cfg.TTS.Enabled {
|
|
slog.Warn("web: TTS enabled but auth is off; read-aloud is signed-in only, so it stays disabled")
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
staticSub, err := fs.Sub(staticFS, "static")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
|
mux.HandleFunc("GET /img/{name}", s.handleImg)
|
|
|
|
mux.HandleFunc("GET /manifest.webmanifest", s.handleManifest)
|
|
mux.HandleFunc("GET /sw.js", s.handleServiceWorker)
|
|
|
|
mux.HandleFunc("GET /{$}", s.handleIndex)
|
|
mux.HandleFunc("GET /weather", s.handleWeatherDemo)
|
|
mux.HandleFunc("GET /api/search", s.handleSearch)
|
|
mux.HandleFunc("GET /api/article", s.handleArticle)
|
|
mux.HandleFunc("GET /api/related", s.handleRelated)
|
|
mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, "") })
|
|
mux.HandleFunc("GET /feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, "") })
|
|
for _, ch := range channels {
|
|
ch := ch
|
|
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
|
|
s.handleChannel(w, r, ch)
|
|
})
|
|
mux.HandleFunc("GET /"+ch.Slug+"/feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, ch.Slug) })
|
|
mux.HandleFunc("GET /"+ch.Slug+"/feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, ch.Slug) })
|
|
}
|
|
// Public source-health page. The handler renders a trimmed reader view for
|
|
// everyone and the full diagnostic view only for admins, so it lives outside
|
|
// the auth block.
|
|
mux.HandleFunc("GET /status", s.handleStatus)
|
|
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = 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)
|
|
mux.HandleFunc("POST /api/read", s.handleRead)
|
|
mux.HandleFunc("POST /api/bookmark", s.handleBookmark)
|
|
mux.HandleFunc("GET /api/state", s.handleState)
|
|
mux.HandleFunc("GET /bookmarks", s.handleBookmarks)
|
|
mux.HandleFunc("GET /for-you", s.handleForYou)
|
|
if s.cfg.Push.Enabled {
|
|
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
|
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
|
|
}
|
|
if s.tts != nil {
|
|
mux.HandleFunc("POST /api/tts", s.handleTTS)
|
|
}
|
|
}
|
|
|
|
s.srv = &http.Server{
|
|
Addr: cfg.ListenAddr,
|
|
Handler: mux,
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Start runs the HTTP server and blocks until ctx is canceled.
|
|
func (s *Server) Start(ctx context.Context) {
|
|
go func() {
|
|
<-ctx.Done()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = s.srv.Shutdown(shutdownCtx)
|
|
}()
|
|
slog.Info("web server listening", "addr", s.cfg.ListenAddr)
|
|
if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
slog.Error("web server crashed", "err", err)
|
|
}
|
|
}
|