ultimate-media-tracker/server/main.go
Tronax de353f0218
feat: add Android app and end-to-end encrypted library sync
Introduce the Kotlin/Compose Android companion app and extend the TMDB
proxy into a combo server that synchronizes the whole library as an
encrypted snapshot, with all three components (Go server, Android,
desktop) sharing one zero-knowledge crypto envelope and JSON schema.

- server: add PostgreSQL-backed /sync/library (GET/PUT) with optimistic
  concurrency (revision + 409 on conflict); sync stays disabled unless
  DATABASE_URL is set. Add docker-compose with Postgres.
- desktop: add libsodium CryptoEnvelope, LibrarySerializer and SyncClient;
  storage-mode and passphrase settings; a "Sync now" toolbar action with
  conflict resolution.
- android: Room-backed library with local/cloud storage modes, encrypted
  sync client, and settings UI. The proxy server (URL + token) can be
  configured as a TMDB proxy even in local-only mode.

Crypto is identical across platforms: Argon2id (libsodium crypto_pwhash,
INTERACTIVE) + XChaCha20-Poly1305 in a versioned "UMTS" envelope, so a
snapshot encrypted on one client decrypts on the other.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-21 00:35:54 +02:00

332 lines
10 KiB
Go

// Command umt-tmdb-proxy is a small TMDB API proxy guarded by OIDC (Authentik).
//
// It lets a group of friends share a single TMDB API key: the server holds the
// key, requires each user to sign in once via Authentik, and then issues a
// long-lived token they paste into the Ultimate Media Tracker desktop app.
// All TMDB requests from the app are forwarded with the shared key attached.
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
type server struct {
cfg *Config
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
oauth oauth2.Config
client *http.Client
store *store // nil when DATABASE_URL is unset (sync disabled)
}
func main() {
cfg, err := loadConfig()
if err != nil {
log.Fatalf("config error: %v", err)
}
ctx := context.Background()
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
log.Fatalf("OIDC discovery failed for %q: %v", cfg.Issuer, err)
}
s := &server{
cfg: cfg,
provider: provider,
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
oauth: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
},
client: &http.Client{Timeout: 15 * time.Second},
}
if cfg.DatabaseURL != "" {
st, err := openStore(cfg.DatabaseURL)
if err != nil {
log.Fatalf("database connection failed: %v", err)
}
defer st.Close()
s.store = st
log.Printf("library sync enabled (PostgreSQL)")
} else {
log.Printf("library sync disabled (DATABASE_URL unset)")
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
mux.HandleFunc("/", s.handleIndex)
mux.HandleFunc("/login", s.handleLogin)
mux.HandleFunc("/callback", s.handleCallback)
mux.HandleFunc("/3/", s.handleProxy)
mux.HandleFunc("/sync/library", s.handleSyncLibrary)
log.Printf("umt-tmdb-proxy listening on %s (public %s)", cfg.ListenAddr, cfg.PublicURL)
srv := &http.Server{
Addr: cfg.ListenAddr,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
func (s *server) secure() bool {
return strings.HasPrefix(s.cfg.PublicURL, "https://")
}
func (s *server) setCookie(w http.ResponseWriter, name, value string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: maxAge,
HttpOnly: true,
Secure: s.secure(),
SameSite: http.SameSiteLaxMode,
})
}
func randString(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.WriteString(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Media Tracker TMDB-Proxy</title>
<style>body{font-family:system-ui,sans-serif;max-width:640px;margin:8vh auto;padding:0 20px;
background:#161618;color:#f2f2f7}a.btn{display:inline-block;background:#0a84ff;color:#fff;
text-decoration:none;padding:12px 22px;border-radius:10px;font-weight:600}
.muted{color:#8e8e93}</style></head><body>
<h1>Ultimate Media Tracker &ndash; TMDB-Proxy</h1>
<p class="muted">Melde dich mit deinem Konto an, um ein Zugriffstoken f&uuml;r die
Desktop-App zu erhalten. Damit nutzt du TMDB, ohne einen eigenen API-Key zu brauchen.</p>
<p><a class="btn" href="/login">Mit Authentik anmelden</a></p>
</body></html>`)
}
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
state := randString(24)
verifier := oauth2.GenerateVerifier()
s.setCookie(w, "umt_state", state, 600)
s.setCookie(w, "umt_verifier", verifier, 600)
// An optional app_redirect lets the desktop app capture the token via a
// loopback URL instead of copy/paste. Only accept localhost targets.
if appcb := r.URL.Query().Get("app_redirect"); isLoopbackRedirect(appcb) {
s.setCookie(w, "umt_appcb", appcb, 600)
} else {
s.setCookie(w, "umt_appcb", "", -1)
}
authURL := s.oauth.AuthCodeURL(state,
oauth2.AccessTypeOffline,
oauth2.S256ChallengeOption(verifier))
http.Redirect(w, r, authURL, http.StatusFound)
}
// isLoopbackRedirect reports whether raw is a plain-HTTP URL pointing at the
// local machine, so it is safe to hand a freshly minted token to.
func isLoopbackRedirect(raw string) bool {
if raw == "" {
return false
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "http" {
return false
}
switch u.Hostname() {
case "127.0.0.1", "localhost", "::1":
return true
}
return false
}
func (s *server) handleCallback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
stateCookie, err := r.Cookie("umt_state")
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
http.Error(w, "ungültiger oder abgelaufener Login-Versuch", http.StatusBadRequest)
return
}
verifierCookie, err := r.Cookie("umt_verifier")
if err != nil {
http.Error(w, "fehlender PKCE-Verifier", http.StatusBadRequest)
return
}
oauthToken, err := s.oauth.Exchange(ctx, r.URL.Query().Get("code"),
oauth2.VerifierOption(verifierCookie.Value))
if err != nil {
http.Error(w, "Token-Austausch fehlgeschlagen: "+err.Error(), http.StatusBadGateway)
return
}
rawIDToken, ok := oauthToken.Extra("id_token").(string)
if !ok {
http.Error(w, "kein id_token in der Antwort", http.StatusBadGateway)
return
}
idToken, err := s.verifier.Verify(ctx, rawIDToken)
if err != nil {
http.Error(w, "id_token-Prüfung fehlgeschlagen: "+err.Error(), http.StatusUnauthorized)
return
}
var claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Name string `json:"preferred_username"`
Groups []string `json:"groups"`
}
if err := idToken.Claims(&claims); err != nil {
http.Error(w, "Claims konnten nicht gelesen werden", http.StatusInternalServerError)
return
}
if !s.groupAllowed(claims.Groups) {
http.Error(w, "Dein Konto ist für diesen Dienst nicht freigeschaltet.", http.StatusForbidden)
return
}
// Clear the transient cookies.
s.setCookie(w, "umt_state", "", -1)
s.setCookie(w, "umt_verifier", "", -1)
appToken, err := mintToken(s.cfg.SessionKey, claims.Subject, claims.Email, s.cfg.TokenTTL)
if err != nil {
http.Error(w, "Token konnte nicht erstellt werden", http.StatusInternalServerError)
return
}
// If the login was started by the desktop app, hand the token back via its
// loopback URL so it is captured automatically.
if appcbCookie, err := r.Cookie("umt_appcb"); err == nil && isLoopbackRedirect(appcbCookie.Value) {
s.setCookie(w, "umt_appcb", "", -1)
cb, _ := url.Parse(appcbCookie.Value)
q := cb.Query()
q.Set("token", appToken)
cb.RawQuery = q.Encode()
http.Redirect(w, r, cb.String(), http.StatusFound)
return
}
who := claims.Name
if who == "" {
who = claims.Email
}
s.renderToken(w, who, appToken)
}
func (s *server) groupAllowed(userGroups []string) bool {
if len(s.cfg.AllowGroups) == 0 {
return true
}
for _, allowed := range s.cfg.AllowGroups {
for _, g := range userGroups {
if g == allowed {
return true
}
}
}
return false
}
func (s *server) renderToken(w http.ResponseWriter, who, token string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.WriteString(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dein Zugriffstoken</title>
<style>body{font-family:system-ui,sans-serif;max-width:680px;margin:6vh auto;padding:0 20px;
background:#161618;color:#f2f2f7}code,textarea{font-family:ui-monospace,monospace}
textarea{width:100%;height:120px;background:#1c1c1e;color:#30d158;border:1px solid #3a3a3c;
border-radius:10px;padding:12px;font-size:13px}.muted{color:#8e8e93}
ol{line-height:1.7}</style></head><body>
<h1>Angemeldet als ` + htmlEscape(who) + `</h1>
<p>Kopiere dieses Token und trage es in der Desktop-App unter
<b>Einstellungen &rarr; Filme/Serien &uuml;ber &rarr; Proxy-Server</b> ein:</p>
<textarea readonly onclick="this.select()">` + htmlEscape(token) + `</textarea>
<ol class="muted">
<li>Proxy-URL: <code>` + htmlEscape(s.cfg.PublicURL) + `</code></li>
<li>Proxy-Token: das Feld oben</li>
</ol>
<p class="muted">Das Token ist persönlich und läuft nach einiger Zeit ab &ndash; dann hier erneut anmelden.</p>
</body></html>`)
}
func htmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
return r.Replace(s)
}
// handleProxy forwards an authenticated /3/* request to TMDB with the shared key.
func (s *server) handleProxy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "only GET is supported", http.StatusMethodNotAllowed)
return
}
bearer := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
if _, err := verifyToken(s.cfg.SessionKey, bearer); err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Build upstream URL: /3/<path> -> <TMDBBase>/<path>, keep the query, force api_key.
upstreamPath := strings.TrimPrefix(r.URL.Path, "/3")
target, err := url.Parse(s.cfg.TMDBBase + upstreamPath)
if err != nil {
http.Error(w, "bad path", http.StatusBadRequest)
return
}
q := r.URL.Query()
q.Set("api_key", s.cfg.TMDBKey)
target.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
if err != nil {
http.Error(w, "request build failed", http.StatusInternalServerError)
return
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "umt-tmdb-proxy/1.0")
resp, err := s.client.Do(req)
if err != nil {
http.Error(w, "upstream error: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}