diff --git a/cmd/api-proxy/main.go b/cmd/api-proxy/main.go index 3ede56b..db7ce5a 100644 --- a/cmd/api-proxy/main.go +++ b/cmd/api-proxy/main.go @@ -1,33 +1,67 @@ package main import ( + "context" "log/slog" - "net/http" + stdhttp "net/http" "os" + "os/signal" + "syscall" "time" -) -func healthz(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"status":"ok"}`)) -} + "github.com/Stoganet/api-proxy/internal/auth" + "github.com/Stoganet/api-proxy/internal/clients/jellyfin" + "github.com/Stoganet/api-proxy/internal/config" + "github.com/Stoganet/api-proxy/internal/db" + apihttp "github.com/Stoganet/api-proxy/internal/http" +) func main() { - mux := http.NewServeMux() - mux.HandleFunc("/healthz", healthz) - addr := os.Getenv("LISTEN_ADDR") - if addr == "" { - addr = ":8080" - } logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - logger.Info("api-proxy starting", "addr", addr) - srv := &http.Server{ - Addr: addr, - Handler: mux, - ReadHeaderTimeout: 10 * time.Second, + + cfg, err := config.LoadFromEnv() + if err != nil { + logger.Error("config", "err", err) + os.Exit(2) } - if err := srv.ListenAndServe(); err != nil { - logger.Error("server exited", "err", err) - os.Exit(1) + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + database, err := db.Open(ctx, cfg.DBPath) + if err != nil { + logger.Error("db open", "err", err) + os.Exit(2) + } + defer database.Close() + + jfClient := jellyfin.New(cfg.JellyfinURL, cfg.JellyfinAPIKey) + authSvc := auth.NewService(auth.Options{ + DB: database, + Jellyfin: jellyfin.AsAuthAdapter(jfClient), + SignKey: cfg.JWTSigningKey, + }) + + srv := apihttp.NewServer(authSvc, logger) + httpSrv := &stdhttp.Server{ + Addr: cfg.ListenAddr, + Handler: srv, + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + logger.Info("api-proxy listening", "addr", cfg.ListenAddr) + if err := httpSrv.ListenAndServe(); err != nil && err != stdhttp.ErrServerClosed { + logger.Error("listen", "err", err) + cancel() + } + }() + + <-ctx.Done() + logger.Info("shutting down") + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + if err := httpSrv.Shutdown(shutdownCtx); err != nil { + logger.Error("shutdown", "err", err) } } diff --git a/cmd/api-proxy/main_test.go b/cmd/api-proxy/main_test.go deleted file mode 100644 index e12f3ad..0000000 --- a/cmd/api-proxy/main_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -import ( - "net/http" - "net/http/httptest" - "testing" -) - -func TestHealthzReturns200(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/healthz", nil) - w := httptest.NewRecorder() - healthz(w, req) - if w.Code != http.StatusOK { - t.Fatalf("got %d, want 200", w.Code) - } -} diff --git a/internal/auth/lockout_test.go b/internal/auth/lockout_test.go index 14b6616..b63796f 100644 --- a/internal/auth/lockout_test.go +++ b/internal/auth/lockout_test.go @@ -2,13 +2,14 @@ package auth import ( "context" + "database/sql" "testing" "time" "github.com/Stoganet/api-proxy/internal/db" ) -func openTestDB(t *testing.T) *db.DB { +func openTestDB(t *testing.T) *sql.DB { t.Helper() d, err := db.Open(context.Background(), ":memory:") if err != nil { @@ -22,7 +23,7 @@ func newLockoutSvc(t *testing.T, now time.Time) *Service { t.Helper() d := openTestDB(t) return NewService(Options{ - DB: d.DB, + DB: d, SignKey: []byte("01234567890123456789012345678901"), Clock: func() time.Time { return now }, }) diff --git a/internal/auth/login_test.go b/internal/auth/login_test.go index ee5d7fb..8ac3dd4 100644 --- a/internal/auth/login_test.go +++ b/internal/auth/login_test.go @@ -26,7 +26,7 @@ func newLoginSvc(t *testing.T, jf JellyfinAuthenticator) *Service { t.Helper() d := openTestDB(t) return NewService(Options{ - DB: d.DB, + DB: d, Jellyfin: jf, SignKey: []byte("01234567890123456789012345678901"), Clock: func() time.Time { return time.Unix(1_700_000_000, 0) }, diff --git a/internal/clients/jellyfin/adapter.go b/internal/clients/jellyfin/adapter.go new file mode 100644 index 0000000..31161f9 --- /dev/null +++ b/internal/clients/jellyfin/adapter.go @@ -0,0 +1,53 @@ +package jellyfin + +import ( + "context" + "errors" + + "github.com/Stoganet/api-proxy/internal/auth" +) + +var _ auth.JellyfinAuthenticator = (*authAdapter)(nil) + +type authAdapter struct{ c *Client } + +// AsAuthAdapter wraps a *Client so it can be passed to auth.NewService. +// Translates jellyfin.Err* sentinels into auth.Err*. +func AsAuthAdapter(c *Client) auth.JellyfinAuthenticator { return &authAdapter{c} } + +func (a *authAdapter) AuthenticateByName(ctx context.Context, u, p string) (*auth.JFAuthResult, error) { + r, err := a.c.AuthenticateByName(ctx, u, p) + if err != nil { + return nil, translateErr(err) + } + return &auth.JFAuthResult{AccessToken: r.AccessToken, UserID: r.UserID, Username: r.Username}, nil +} + +func (a *authAdapter) QuickConnectInitiate(ctx context.Context) (*auth.JFQuickConnectInit, error) { + r, err := a.c.QuickConnectInitiate(ctx) + if err != nil { + return nil, translateErr(err) + } + return &auth.JFQuickConnectInit{Secret: r.Secret, Code: r.Code}, nil +} + +func (a *authAdapter) QuickConnectAuthenticate(ctx context.Context, secret string) (*auth.JFAuthResult, error) { + r, err := a.c.QuickConnectAuthenticate(ctx, secret) + if err != nil { + return nil, translateErr(err) + } + return &auth.JFAuthResult{AccessToken: r.AccessToken, UserID: r.UserID, Username: r.Username}, nil +} + +func translateErr(err error) error { + switch { + case errors.Is(err, ErrInvalidCredentials): + return auth.ErrInvalidCredentials + case errors.Is(err, ErrUpstreamUnavailable): + return auth.ErrJellyfinUnavailable + case errors.Is(err, ErrQuickConnectPending): + return auth.ErrQuickConnectPending + default: + return err + } +} diff --git a/internal/clients/jellyfin/adapter_test.go b/internal/clients/jellyfin/adapter_test.go new file mode 100644 index 0000000..e34580e --- /dev/null +++ b/internal/clients/jellyfin/adapter_test.go @@ -0,0 +1,116 @@ +package jellyfin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Stoganet/api-proxy/internal/auth" +) + +func newAdapterServer(t *testing.T, handler http.HandlerFunc) auth.JellyfinAuthenticator { + t.Helper() + s := httptest.NewServer(handler) + t.Cleanup(s.Close) + return AsAuthAdapter(New(s.URL, "")) +} + +func TestAdapter_AuthenticateByName_TranslatesResult(t *testing.T) { + a := newAdapterServer(t, func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "AccessToken": "tok", + "User": map[string]any{"Id": "jf-1", "Name": "alice"}, + }) + }) + res, err := a.AuthenticateByName(context.Background(), "alice", "pw") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.AccessToken != "tok" || res.UserID != "jf-1" || res.Username != "alice" { + t.Fatalf("unexpected result: %+v", res) + } +} + +func TestAdapter_AuthenticateByName_TranslatesInvalidCredentials(t *testing.T) { + a := newAdapterServer(t, func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "", http.StatusUnauthorized) + }) + _, err := a.AuthenticateByName(context.Background(), "alice", "wrong") + if !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("want auth.ErrInvalidCredentials, got %v", err) + } +} + +func TestAdapter_AuthenticateByName_TranslatesUpstreamUnavailable(t *testing.T) { + a := newAdapterServer(t, func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "", http.StatusInternalServerError) + }) + _, err := a.AuthenticateByName(context.Background(), "alice", "pw") + if !errors.Is(err, auth.ErrJellyfinUnavailable) { + t.Fatalf("want auth.ErrJellyfinUnavailable, got %v", err) + } +} + +func TestAdapter_QuickConnectInitiate_TranslatesResult(t *testing.T) { + a := newAdapterServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/QuickConnect/Initiate" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"Secret": "s", "Code": "C"}) + }) + res, err := a.QuickConnectInitiate(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.Secret != "s" || res.Code != "C" { + t.Fatalf("unexpected result: %+v", res) + } +} + +func TestAdapter_QuickConnectInitiate_TranslatesUpstreamUnavailable(t *testing.T) { + a := newAdapterServer(t, func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "", http.StatusInternalServerError) + }) + _, err := a.QuickConnectInitiate(context.Background()) + if !errors.Is(err, auth.ErrJellyfinUnavailable) { + t.Fatalf("want auth.ErrJellyfinUnavailable, got %v", err) + } +} + +func TestAdapter_QuickConnectAuthenticate_TranslatesPending(t *testing.T) { + a := newAdapterServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/QuickConnect/Authenticate" { + http.Error(w, "", http.StatusUnauthorized) + return + } + http.NotFound(w, r) + }) + _, err := a.QuickConnectAuthenticate(context.Background(), "secret") + if !errors.Is(err, auth.ErrQuickConnectPending) { + t.Fatalf("want auth.ErrQuickConnectPending, got %v", err) + } +} + +func TestAdapter_QuickConnectAuthenticate_TranslatesResult(t *testing.T) { + a := newAdapterServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/QuickConnect/Authenticate" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "AccessToken": "tok-qc", + "User": map[string]any{"Id": "jf-2", "Name": "bob"}, + }) + return + } + http.NotFound(w, r) + }) + res, err := a.QuickConnectAuthenticate(context.Background(), "secret") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.AccessToken != "tok-qc" || res.UserID != "jf-2" || res.Username != "bob" { + t.Fatalf("unexpected result: %+v", res) + } +} diff --git a/internal/db/db.go b/internal/db/db.go index b105e9e..c0ffd0d 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -12,11 +12,7 @@ import ( //go:embed migrations/*.sql var migrationsFS embed.FS -type DB struct { - *sql.DB -} - -func Open(ctx context.Context, path string) (*DB, error) { +func Open(ctx context.Context, path string) (*sql.DB, error) { dsn := path + "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)" sqldb, err := sql.Open("sqlite", dsn) if err != nil { @@ -28,7 +24,7 @@ func Open(ctx context.Context, path string) (*DB, error) { if err := applyMigrations(ctx, sqldb); err != nil { return nil, err } - return &DB{sqldb}, nil + return sqldb, nil } func applyMigrations(ctx context.Context, sqldb *sql.DB) error {