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>
332 lines
10 KiB
Go
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 – TMDB-Proxy</h1>
|
|
<p class="muted">Melde dich mit deinem Konto an, um ein Zugriffstoken fü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 → Filme/Serien über → 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 – dann hier erneut anmelden.</p>
|
|
</body></html>`)
|
|
}
|
|
|
|
func htmlEscape(s string) string {
|
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
|
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)
|
|
}
|