// 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 } 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}, } 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) 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, ` Media Tracker TMDB-Proxy

Ultimate Media Tracker – TMDB-Proxy

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.

Mit Authentik anmelden

`) } 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, ` Dein Zugriffstoken

Angemeldet als ` + htmlEscape(who) + `

Kopiere dieses Token und trage es in der Desktop-App unter Einstellungen → Filme/Serien über → Proxy-Server ein:

  1. Proxy-URL: ` + htmlEscape(s.cfg.PublicURL) + `
  2. Proxy-Token: das Feld oben

Das Token ist persönlich und läuft nach einiger Zeit ab – dann hier erneut anmelden.

`) } 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/ -> /, 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) }