feat: initial commit for media tracking software
- Setup C++/Qt6 desktop application structure - Added initial Go proxy backend for TMDB/IGDB integration
This commit is contained in:
commit
3f07c78316
63 changed files with 6547 additions and 0 deletions
33
server/.env.example
Normal file
33
server/.env.example
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# --- umt-tmdb-proxy configuration ---------------------------------------
|
||||
# Copy to .env and fill in. All values are read from the environment.
|
||||
|
||||
# Where the server listens (inside the container / on the host).
|
||||
LISTEN_ADDR=:8080
|
||||
|
||||
# Externally reachable base URL of this proxy (no trailing slash).
|
||||
# This is also what users enter as "Proxy-URL" in the desktop app.
|
||||
PUBLIC_URL=https://tmdb.example.com
|
||||
|
||||
# The single shared TMDB v3 API key the whole group will use.
|
||||
TMDB_API_KEY=your_tmdb_v3_key
|
||||
|
||||
# --- Authentik / OIDC ---------------------------------------------------
|
||||
# Issuer is the OIDC provider URL from your Authentik application
|
||||
# (e.g. https://auth.example.com/application/o/watchlist-proxy/).
|
||||
OIDC_ISSUER=https://auth.example.com/application/o/watchlist-proxy/
|
||||
OIDC_CLIENT_ID=your_client_id
|
||||
OIDC_CLIENT_SECRET=your_client_secret
|
||||
# Must exactly match a Redirect URI configured in Authentik.
|
||||
# Defaults to PUBLIC_URL + /callback if left empty.
|
||||
OIDC_REDIRECT_URL=https://tmdb.example.com/callback
|
||||
|
||||
# Optional: restrict access to members of these Authentik groups
|
||||
# (comma-separated). Requires a "groups" scope/claim mapping in Authentik.
|
||||
# ALLOWED_GROUPS=watchlist,friends
|
||||
|
||||
# A long random secret used to sign issued access tokens. KEEP IT PRIVATE.
|
||||
# Generate with: openssl rand -base64 48
|
||||
SESSION_SECRET=change-me-to-a-long-random-string
|
||||
|
||||
# How long an issued desktop-app token stays valid (in days).
|
||||
TOKEN_TTL_DAYS=90
|
||||
14
server/Dockerfile
Normal file
14
server/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# --- build stage ---
|
||||
FROM golang:1.22-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /umt-tmdb-proxy .
|
||||
|
||||
# --- run stage ---
|
||||
FROM gcr.io/distroless/static-debian12
|
||||
COPY --from=build /umt-tmdb-proxy /umt-tmdb-proxy
|
||||
EXPOSE 8080
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/umt-tmdb-proxy"]
|
||||
69
server/README.md
Normal file
69
server/README.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# UMT TMDB Proxy
|
||||
|
||||
A small Go server that lets a group share **one** TMDB API key. Access is gated
|
||||
by **OIDC via Authentik**: each user signs in once and receives a personal token
|
||||
that they paste into the Ultimate Media Tracker desktop app.
|
||||
|
||||
```
|
||||
desktop app ──Bearer token──▶ proxy ──?api_key=SHARED──▶ api.themoviedb.org
|
||||
▲
|
||||
└── login gated by Authentik (OIDC)
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. A user opens the proxy URL and clicks **Mit Authentik anmelden**.
|
||||
2. The server runs the OIDC Authorization-Code + PKCE flow against Authentik and
|
||||
verifies the returned ID token (signature, issuer, audience, expiry).
|
||||
3. On success it mints a stateless, HMAC-signed token and shows it on a page.
|
||||
4. The user enters the proxy URL + token into the app (*Einstellungen → Filme/
|
||||
Serien über → Proxy-Server*).
|
||||
5. Every `/3/...` request from the app is validated and forwarded to TMDB with
|
||||
the shared key appended. (Poster images come straight from the public
|
||||
`image.tmdb.org` and need no key.)
|
||||
|
||||
## Configure
|
||||
|
||||
Copy `.env.example` to `.env` and fill it in. Required: `TMDB_API_KEY`,
|
||||
`OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `SESSION_SECRET`.
|
||||
|
||||
### Authentik setup
|
||||
|
||||
Create an **OAuth2/OpenID Provider** + **Application**:
|
||||
|
||||
- Client type: **Confidential**
|
||||
- Redirect URI: `https://tmdb.example.com/callback` (your `OIDC_REDIRECT_URL`)
|
||||
- Scopes: `openid`, `profile`, `email` (add a `groups` mapping if you use
|
||||
`ALLOWED_GROUPS`)
|
||||
- Copy the Client ID / Secret and the provider's **OpenID Configuration Issuer**
|
||||
into `.env`.
|
||||
|
||||
## Run
|
||||
|
||||
### With Go
|
||||
|
||||
```bash
|
||||
go mod tidy # once, to fetch dependencies
|
||||
set -a; . ./.env; set +a
|
||||
go run .
|
||||
```
|
||||
|
||||
### With Docker
|
||||
|
||||
```bash
|
||||
docker build -t umt-tmdb-proxy .
|
||||
docker run --env-file .env -p 8080:8080 umt-tmdb-proxy
|
||||
```
|
||||
|
||||
Put it behind a TLS-terminating reverse proxy (Caddy, Traefik, nginx) so
|
||||
`PUBLIC_URL` is `https://…`; cookies are then marked `Secure` automatically.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Path | Purpose |
|
||||
|-------------|----------------------------------------------------|
|
||||
| `/` | Landing page with a login button |
|
||||
| `/login` | Starts the Authentik OIDC flow |
|
||||
| `/callback` | OIDC redirect target; shows the personal token |
|
||||
| `/3/*` | Authenticated TMDB API proxy |
|
||||
| `/healthz` | Liveness probe |
|
||||
89
server/config.go
Normal file
89
server/config.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all runtime settings, sourced from environment variables so the
|
||||
// server is easy to run under systemd, Docker or a PaaS without code changes.
|
||||
type Config struct {
|
||||
ListenAddr string // e.g. ":8080"
|
||||
PublicURL string // externally reachable base URL, e.g. https://tmdb.example.com
|
||||
TMDBKey string // the single shared TMDB v3 API key
|
||||
TMDBBase string // upstream base, defaults to https://api.themoviedb.org/3
|
||||
Issuer string // Authentik OIDC issuer URL
|
||||
ClientID string // OAuth2 client id
|
||||
ClientSecret string // OAuth2 client secret
|
||||
RedirectURL string // OAuth2 redirect URL (PublicURL + /callback if empty)
|
||||
SessionKey []byte // HMAC secret for signing access tokens / state cookies
|
||||
AllowGroups []string // if set, the user must be in one of these Authentik groups
|
||||
TokenTTL time.Duration // lifetime of issued desktop-app tokens
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func loadConfig() (*Config, error) {
|
||||
c := &Config{
|
||||
ListenAddr: getenv("LISTEN_ADDR", ":8080"),
|
||||
PublicURL: strings.TrimRight(getenv("PUBLIC_URL", "http://localhost:8080"), "/"),
|
||||
TMDBKey: getenv("TMDB_API_KEY", ""),
|
||||
TMDBBase: strings.TrimRight(getenv("TMDB_BASE_URL", "https://api.themoviedb.org/3"), "/"),
|
||||
Issuer: getenv("OIDC_ISSUER", ""),
|
||||
ClientID: getenv("OIDC_CLIENT_ID", ""),
|
||||
ClientSecret: getenv("OIDC_CLIENT_SECRET", ""),
|
||||
RedirectURL: getenv("OIDC_REDIRECT_URL", ""),
|
||||
}
|
||||
|
||||
if c.RedirectURL == "" {
|
||||
c.RedirectURL = c.PublicURL + "/callback"
|
||||
}
|
||||
|
||||
secret := getenv("SESSION_SECRET", "")
|
||||
if secret == "" {
|
||||
return nil, fmt.Errorf("SESSION_SECRET is required (a long random string)")
|
||||
}
|
||||
c.SessionKey = []byte(secret)
|
||||
|
||||
if groups := getenv("ALLOWED_GROUPS", ""); groups != "" {
|
||||
for _, g := range strings.Split(groups, ",") {
|
||||
if g = strings.TrimSpace(g); g != "" {
|
||||
c.AllowGroups = append(c.AllowGroups, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
days, err := strconv.Atoi(getenv("TOKEN_TTL_DAYS", "90"))
|
||||
if err != nil || days <= 0 {
|
||||
days = 90
|
||||
}
|
||||
c.TokenTTL = time.Duration(days) * 24 * time.Hour
|
||||
|
||||
// Required fields.
|
||||
var missing []string
|
||||
if c.TMDBKey == "" {
|
||||
missing = append(missing, "TMDB_API_KEY")
|
||||
}
|
||||
if c.Issuer == "" {
|
||||
missing = append(missing, "OIDC_ISSUER")
|
||||
}
|
||||
if c.ClientID == "" {
|
||||
missing = append(missing, "OIDC_CLIENT_ID")
|
||||
}
|
||||
if c.ClientSecret == "" {
|
||||
missing = append(missing, "OIDC_CLIENT_SECRET")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
13
server/go.mod
Normal file
13
server/go.mod
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
module umt-tmdb-proxy
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
golang.org/x/oauth2 v0.21.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
|
||||
golang.org/x/crypto v0.25.0 // indirect
|
||||
)
|
||||
18
server/go.sum
Normal file
18
server/go.sum
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
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/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
318
server/main.go
Normal file
318
server/main.go
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
// 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, `<!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)
|
||||
}
|
||||
68
server/token.go
Normal file
68
server/token.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// appClaims is the payload of a desktop-app access token. These tokens are
|
||||
// minted by this server after a successful Authentik login and are independent
|
||||
// of the (short-lived) OIDC tokens, so a friend can paste one into the desktop
|
||||
// app and keep using it until it expires.
|
||||
type appClaims struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Expires int64 `json:"exp"` // unix seconds
|
||||
}
|
||||
|
||||
// mintToken creates a stateless, HMAC-signed token of the form
|
||||
// base64url(payload).base64url(sig). No server-side storage is needed.
|
||||
func mintToken(secret []byte, sub, email string, ttl time.Duration) (string, error) {
|
||||
payload := appClaims{
|
||||
Subject: sub,
|
||||
Email: email,
|
||||
Expires: time.Now().Add(ttl).Unix(),
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := base64.RawURLEncoding.EncodeToString(raw)
|
||||
sig := sign(secret, body)
|
||||
return body + "." + sig, nil
|
||||
}
|
||||
|
||||
// verifyToken validates the signature and expiry, returning the claims.
|
||||
func verifyToken(secret []byte, token string) (*appClaims, error) {
|
||||
parts := strings.SplitN(strings.TrimSpace(token), ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, errors.New("malformed token")
|
||||
}
|
||||
expected := sign(secret, parts[0])
|
||||
if !hmac.Equal([]byte(expected), []byte(parts[1])) {
|
||||
return nil, errors.New("bad signature")
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, errors.New("bad payload encoding")
|
||||
}
|
||||
var claims appClaims
|
||||
if err := json.Unmarshal(raw, &claims); err != nil {
|
||||
return nil, errors.New("bad payload")
|
||||
}
|
||||
if time.Now().Unix() >= claims.Expires {
|
||||
return nil, errors.New("token expired")
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
func sign(secret []byte, body string) string {
|
||||
m := hmac.New(sha256.New, secret)
|
||||
m.Write([]byte(body))
|
||||
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue