diff --git a/.env.example b/.env.example index fca6b8e..54ceb07 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,15 @@ CMS_HTTP_ADDR=:8080 -CMS_ADMIN_DATABASE_URL=./cms-admin.db CMS_APPLICATION_DATABASE_URL="host=localhost user=cms password=cms dbname=cms port=5432 sslmode=disable" CMS_ALLOWED_ORIGINS=http://localhost:5173 -CMS_SESSION_TTL=24h -# Download this file from Google Cloud Console and keep it untracked. -CMS_GOOGLE_CLIENT_FILE=./client_secret_.json -CMS_GOOGLE_CLIENT_ID= -CMS_GOOGLE_CLIENT_SECRET= -CMS_GOOGLE_REDIRECT_URL=http://localhost:5173/api/auth/google/callback +# Single sign-on. The CMS does not sign anyone in: an identity-aware proxy +# (oauth2-proxy, Authelia, Cloudflare Access, ...) authenticates the owner and +# forwards a header on every accepted request. Requests without it are anonymous. +# +# Header the proxy sets. oauth2-proxy forwards its access token in +# X-Auth-Request-Access-Token; Authelia uses Remote-User or Authorization. +CMS_SSO_HEADER=X-SSO-Token +# Optional shared secret. Leave empty to accept any non-empty forwarded value. +CMS_SSO_TOKEN= +# Optional cookie fallback when the proxy sets a cookie instead of a header. +CMS_SSO_COOKIE= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5c41b40..18c8452 100644 --- a/.gitignore +++ b/.gitignore @@ -2,12 +2,11 @@ bin/ *.test -# Local databases and secrets +# Local environment and data +.env *.db *.sqlite *.sqlite3 -.env -client_secret*.json # Node node_modules/ @@ -18,7 +17,3 @@ ui/app/.vite/ .idea/ .vscode/ .DS_Store - -# Local backend data -*.db -*.db-* diff --git a/Makefile b/Makefile index 731b70a..a7fffdf 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,6 @@ SHELL := /bin/sh COMPOSE ?= docker compose GOOSE ?= go run github.com/pressly/goose/v3/cmd/goose@v3.24.3 POSTGRES_URL ?= postgres://cms:cms@localhost:5432/cms?sslmode=disable -ADMIN_DATABASE_URL ?= ./cms-admin.db .PHONY: up down migrate seed backend frontend dev @@ -15,14 +14,12 @@ down: migrate: $(GOOSE) -dir cmd/server/migrations/postgres postgres "$(POSTGRES_URL)" up - $(GOOSE) -dir cmd/server/migrations/admin sqlite3 "$(ADMIN_DATABASE_URL)" up seed: psql "$(POSTGRES_URL)" -v ON_ERROR_STOP=1 -f scripts/seed_content.sql backend: set -a; [ ! -f .env ] || . ./.env; set +a; \ - CMS_ADMIN_DATABASE_URL="$${CMS_ADMIN_DATABASE_URL:-$(ADMIN_DATABASE_URL)}" \ CMS_APPLICATION_DATABASE_URL="$${CMS_APPLICATION_DATABASE_URL:-host=localhost user=cms password=cms dbname=cms port=5432 sslmode=disable}" \ go run ./cmd/server diff --git a/README.md b/README.md index 06664c4..d22178a 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,36 @@ This repository contains a Go backend and React frontend for the CMS. ## Backend -The backend targets Go 1.27.1 and uses a layered design (`internal/web`, `service`, `repository`, `persistence`, and `domain`). PostgreSQL stores application users and sessions; SQLite stores admin records and admin sessions. Goose migrations run automatically at startup. +The backend targets Go 1.27.1 and uses a layered design (`internal/web`, `service`, `repository`, `persistence`, `security`, and `domain`). A single PostgreSQL database stores content; Goose migrations run automatically at startup. ```sh go run ./cmd/server ``` -For local development, `make up` starts PostgreSQL, `make migrate` applies both database migrations, `make seed` loads repeatable sample content, `make backend` runs the Go server, and `make frontend` runs the React app. `make dev` runs the database, migrations, backend, and frontend together. +Configuration is read from environment variables (see `.env`). The API includes `GET /health`, `GET /api/auth/session`, `GET /api/protected`, `GET /api/content`, `GET /api/content/categories`, `GET /api/content/badges`, and `GET|PUT /api/content/:slug`. -Configuration is read from environment variables (see `.env`). The API includes `GET /health`, `GET /api/auth/google`, `GET /api/auth/google/callback`, `GET /api/auth/session`, `POST /api/login`, `GET /api/test/protected`, and the intentionally non-public admin route `POST /admin/login`. Google uses an authorization-code flow when its environment variables are configured; additional providers can implement `identity.IProvider`. The direct login endpoint remains a local integration seam and should not be exposed as a production identity flow. +## Single sign-on -Content supports one reusable category per document plus any number of badges. The authenticated `GET /api/content` endpoint accepts `category=engineering` and repeated `badge=go&badge=docker` filters; selected badges are combined with AND semantics. `GET /api/content/categories` returns the category dropdown values. The frontend applies these filters immediately when the category changes or when a badge is entered. +The CMS never signs anyone in and stores no users, admins, or sessions. An identity-aware proxy (oauth2-proxy, Authelia, Cloudflare Access, ...) authenticates the owner and forwards a header on every accepted request: -The admin route is intentionally not linked from the frontend. Admin credential policy and admin pages are reserved for a later iteration; the current model and service boundary provide the persistence seam. +- **Header present** → the request is the owner and can edit content (`canEdit: true`). +- **Header absent** → the request is anonymous and content is read-only (`canEdit: false`). + +Configure the header with `CMS_SSO_HEADER` (default `X-SSO-Token`). Set `CMS_SSO_TOKEN` to a shared secret to require an exact value; leave it empty to trust any non-empty forwarded value. `CMS_SSO_COOKIE` optionally names a cookie fallback. + +Because the backend trusts the forwarded header, it must only be reachable through the proxy. Bind it to the loopback interface or a private network, and make sure the proxy strips any client-supplied copy of that header. + +## Content + +Content supports one reusable category per document plus any number of badges. The anonymous `GET /api/content` endpoint accepts `category=engineering` and repeated `badge=go&badge=docker` filters; selected badges are combined with AND semantics. `GET /api/content/categories` returns the category dropdown values. The frontend applies these filters immediately when the category changes or when a badge is entered. + +Editing requires the SSO identity: `PUT /api/content/:slug` returns `401` without it. `GET /api/content/:slug` and the taxonomy endpoints are public. + +```sh +make up # start PostgreSQL +make migrate # apply the PostgreSQL migrations +make seed # load repeatable sample content +make backend # run the Go server +make frontend # run the React app +make dev # up + migrate + backend + frontend +``` \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index fdc6aa1..02e3321 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,52 +8,25 @@ import ( "net/http" "github.com/example/cms/internal/config" - "github.com/example/cms/internal/identity" "github.com/example/cms/internal/repository" + "github.com/example/cms/internal/security" "github.com/example/cms/internal/service" "github.com/example/cms/internal/web" "github.com/pressly/goose/v3" "github.com/pressly/goose/v3/database" "gorm.io/driver/postgres" - "gorm.io/driver/sqlite" "gorm.io/gorm" ) -//go:embed migrations/admin/*.sql migrations/postgres/*.sql +//go:embed migrations/postgres/*.sql var migrations embed.FS func main() { - cfg, err := config.Load() - if err != nil { - log.Fatal(err) - } - adminDB, err := gorm.Open(sqlite.Open(cfg.AdminDatabaseURL), &gorm.Config{}) - if err != nil { - log.Fatal(err) - } + cfg := config.Load() applicationDB, err := gorm.Open(postgres.Open(cfg.ApplicationDatabaseURL), &gorm.Config{}) if err != nil { log.Fatal(err) } - adminSQLDB, err := adminDB.DB() - if err != nil { - log.Fatal(err) - } - adminMigrations, err := fs.Sub(migrations, "migrations/admin") - if err != nil { - log.Fatal(err) - } - adminStore, err := database.NewStore(goose.DialectSQLite3, "goose_admin_db_version") - if err != nil { - log.Fatal(err) - } - adminProvider, err := goose.NewProvider("", adminSQLDB, adminMigrations, goose.WithStore(adminStore)) - if err != nil { - log.Fatal(err) - } - if _, err = adminProvider.Up(context.Background()); err != nil { - log.Fatal(err) - } applicationSQLDB, err := applicationDB.DB() if err != nil { log.Fatal(err) @@ -73,17 +46,10 @@ func main() { if _, err = applicationProvider.Up(context.Background()); err != nil { log.Fatal(err) } - auth := service.NewAuthServiceWithSeparateSessions( - repository.NewUserRepository(applicationDB), - repository.NewAdminRepository(adminDB), - repository.NewSessionRepository(applicationDB), - repository.NewSessionRepository(adminDB), - cfg.SessionTTL, - ) - content := service.NewContentService(repository.NewContentRepository(applicationDB), auth) - google := identity.GoogleProvider{ClientID: cfg.GoogleClientID, ClientSecret: cfg.GoogleClientSecret, RedirectURL: cfg.GoogleRedirectURL} + content := service.NewContentService(repository.NewContentRepository(applicationDB)) + sso := security.NewSSOTokenVerifier(cfg.SSOHeader, cfg.SSOToken, cfg.SSOCookie) log.Printf("CMS listening on %s", cfg.HTTPAddr) - if err = http.ListenAndServe(cfg.HTTPAddr, web.NewHandlerWithContent(auth, google, content).Routes(cfg.AllowedOrigins)); err != nil { + if err = http.ListenAndServe(cfg.HTTPAddr, web.NewHandler(content, sso).Routes(cfg.AllowedOrigins)); err != nil { log.Fatal(err) } } diff --git a/cmd/server/migrations/admin/001_init.sql b/cmd/server/migrations/admin/001_init.sql deleted file mode 100644 index a259f4d..0000000 --- a/cmd/server/migrations/admin/001_init.sql +++ /dev/null @@ -1,7 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS admins (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL); -CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT NOT NULL UNIQUE, user_id INTEGER, admin_id INTEGER REFERENCES admins(id), expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL); -CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); --- +goose Down -DROP TABLE IF EXISTS sessions; -DROP TABLE IF EXISTS admins; diff --git a/cmd/server/migrations/postgres/004_sso.sql b/cmd/server/migrations/postgres/004_sso.sql new file mode 100644 index 0000000..200cada --- /dev/null +++ b/cmd/server/migrations/postgres/004_sso.sql @@ -0,0 +1,35 @@ +-- +goose Up +-- The CMS no longer stores identities: the upstream SSO proxy owns them. +ALTER TABLE content DROP COLUMN IF EXISTS created_by; +ALTER TABLE content DROP COLUMN IF EXISTS updated_by; +ALTER TABLE content_history DROP COLUMN IF EXISTS actor_id; +ALTER TABLE content_history DROP COLUMN IF EXISTS actor_admin_id; +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS users; + +-- +goose Down +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + email TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + provider TEXT NOT NULL, + provider_subject TEXT NOT NULL, + role TEXT NOT NULL DEFAULT '', + editor BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_subject ON users(provider, provider_subject); +CREATE TABLE IF NOT EXISTS sessions ( + id BIGSERIAL PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + user_id BIGINT REFERENCES users(id), + admin_id BIGINT, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); +ALTER TABLE content ADD COLUMN IF NOT EXISTS created_by BIGINT REFERENCES users(id); +ALTER TABLE content ADD COLUMN IF NOT EXISTS updated_by BIGINT REFERENCES users(id); +ALTER TABLE content_history ADD COLUMN IF NOT EXISTS actor_id BIGINT REFERENCES users(id); +ALTER TABLE content_history ADD COLUMN IF NOT EXISTS actor_admin_id BIGINT; diff --git a/go.mod b/go.mod index c68e356..4198609 100644 --- a/go.mod +++ b/go.mod @@ -4,21 +4,17 @@ go 1.27.1 require ( github.com/pressly/goose/v3 v3.24.3 - golang.org/x/oauth2 v0.30.0 gorm.io/driver/postgres v1.6.0 - gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) require ( - cloud.google.com/go/compute/metadata v0.3.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.4 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index feaa5a5..4a11e27 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= -cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= @@ -21,8 +19,6 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -46,8 +42,6 @@ golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= @@ -60,8 +54,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= -gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= -gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= diff --git a/internal/config/config.go b/internal/config/config.go index 0c1925e..1b35eb1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,72 +1,30 @@ package config import ( - "encoding/json" - "fmt" "os" - "time" ) +// Config carries the runtime settings for the CMS. The application database is +// the only datastore: identities and sessions belong to the upstream SSO proxy. type Config struct { - HTTPAddr, AdminDatabaseURL, ApplicationDatabaseURL, AllowedOrigins, GoogleClientID, GoogleClientSecret, GoogleRedirectURL string - SessionTTL time.Duration + HTTPAddr, ApplicationDatabaseURL string + AllowedOrigins, SSOHeader, SSOToken, SSOCookie string } -func Load() (Config, error) { - clientID, clientSecret, err := googleCredentials() - if err != nil { - return Config{}, err - } +func Load() Config { return Config{ HTTPAddr: get("CMS_HTTP_ADDR", ":8080"), - AdminDatabaseURL: get("CMS_ADMIN_DATABASE_URL", "./cms-admin.db"), ApplicationDatabaseURL: get("CMS_APPLICATION_DATABASE_URL", "host=localhost user=cms password=cms dbname=cms port=5432 sslmode=disable"), AllowedOrigins: get("CMS_ALLOWED_ORIGINS", "http://localhost:5173"), - SessionTTL: duration("CMS_SESSION_TTL", 24*time.Hour), - GoogleClientID: get("CMS_GOOGLE_CLIENT_ID", clientID), - GoogleClientSecret: get("CMS_GOOGLE_CLIENT_SECRET", clientSecret), - GoogleRedirectURL: get("CMS_GOOGLE_REDIRECT_URL", "http://localhost:8080/api/auth/google/callback"), - }, nil + SSOHeader: get("CMS_SSO_HEADER", "X-SSO-Token"), + SSOToken: get("CMS_SSO_TOKEN", ""), + SSOCookie: get("CMS_SSO_COOKIE", ""), + } } -func googleCredentials() (string, string, error) { - path := os.Getenv("CMS_GOOGLE_CLIENT_FILE") - if path == "" { - return "", "", nil - } - data, err := os.ReadFile(path) - if err != nil { - return "", "", fmt.Errorf("read Google OAuth client file: %w", err) - } - var credentials struct { - Web struct { - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - } `json:"web"` - Installed struct { - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - } `json:"installed"` - } - if err := json.Unmarshal(data, &credentials); err != nil { - return "", "", fmt.Errorf("parse Google OAuth client file: %w", err) - } - if credentials.Web.ClientID != "" || credentials.Web.ClientSecret != "" { - return credentials.Web.ClientID, credentials.Web.ClientSecret, nil - } - return credentials.Installed.ClientID, credentials.Installed.ClientSecret, nil -} func get(k, d string) string { if v := os.Getenv(k); v != "" { return v } return d } -func duration(k string, d time.Duration) time.Duration { - if v := os.Getenv(k); v != "" { - if n, e := time.ParseDuration(v); e == nil { - return n - } - } - return d -} diff --git a/internal/domain/models.go b/internal/domain/models.go index 8e4545f..efe8168 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -2,27 +2,6 @@ package domain import "time" -type User struct { - ID uint - Email, Name string - Provider, ProviderSubject string - Role string - Editor bool - CreatedAt time.Time -} -type Admin struct { - ID uint - Email, Name string - CreatedAt time.Time -} -type Session struct { - ID uint - Token string - UserID *uint - AdminID *uint - ExpiresAt, CreatedAt time.Time -} - type Content struct { ID uint Slug, Title, Summary, Body string @@ -32,7 +11,6 @@ type Content struct { Category string Badges []string CreatedAt, UpdatedAt time.Time - CreatedBy, UpdatedBy uint } type Category struct { @@ -47,11 +25,9 @@ type Badge struct { } type ContentHistory struct { - ID uint - ContentID uint - Operation string - ActorID *uint - ActorAdminID *uint - CreatedAt time.Time - Snapshot string + ID uint + ContentID uint + Operation string + CreatedAt time.Time + Snapshot string } diff --git a/internal/identity/provider.go b/internal/identity/provider.go deleted file mode 100644 index 1d8b1f8..0000000 --- a/internal/identity/provider.go +++ /dev/null @@ -1,65 +0,0 @@ -package identity - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "golang.org/x/oauth2" - "golang.org/x/oauth2/google" - "net/http" -) - -type Identity struct{ Subject, Email, Name string } -type IProvider interface { - Name() string - Authenticate(context.Context, string) (Identity, error) -} -type GoogleProvider struct{ ClientID, ClientSecret, RedirectURL string } - -func (GoogleProvider) Name() string { return "google" } -func (p GoogleProvider) Authenticate(context.Context, string) (Identity, error) { - if p.ClientID == "" { - return Identity{}, errors.New("google identity provider is not configured") - } - return Identity{}, errors.New("google authentication requires the OAuth callback") -} - -func (p GoogleProvider) OAuthConfig() *oauth2.Config { - return &oauth2.Config{ - ClientID: p.ClientID, ClientSecret: p.ClientSecret, RedirectURL: p.RedirectURL, - Endpoint: google.Endpoint, Scopes: []string{"openid", "email", "profile"}, - } -} - -func (p GoogleProvider) FetchIdentity(ctx context.Context, code string) (Identity, error) { - if p.ClientID == "" || p.ClientSecret == "" || p.RedirectURL == "" { - return Identity{}, errors.New("google identity provider is not configured") - } - token, err := p.OAuthConfig().Exchange(ctx, code) - if err != nil { - return Identity{}, fmt.Errorf("exchange authorization code: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://openidconnect.googleapis.com/v1/userinfo", nil) - if err != nil { - return Identity{}, err - } - req.Header.Set("Authorization", "Bearer "+token.AccessToken) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return Identity{}, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return Identity{}, fmt.Errorf("Google userinfo request failed with status %s", resp.Status) - } - var profile struct { - Subject string `json:"sub"` - Email string `json:"email"` - Name string `json:"name"` - } - if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { - return Identity{}, err - } - return Identity{Subject: profile.Subject, Email: profile.Email, Name: profile.Name}, nil -} diff --git a/internal/identity/provider_test.go b/internal/identity/provider_test.go deleted file mode 100644 index d203d79..0000000 --- a/internal/identity/provider_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package identity - -import ( - "context" - "testing" -) - -func TestGoogleProviderRequiresConfiguration(t *testing.T) { - _, e := (GoogleProvider{}).Authenticate(context.Background(), "") - if e == nil { - t.Fatal("expected configuration error") - } -} diff --git a/internal/persistence/mapping.go b/internal/persistence/mapping.go index b4c491d..4b834af 100644 --- a/internal/persistence/mapping.go +++ b/internal/persistence/mapping.go @@ -2,28 +2,14 @@ package persistence import "github.com/example/cms/internal/domain" -func UserToDomain(m UserModel) domain.User { - return domain.User{ID: m.ID, Email: m.Email, Name: m.Name, Provider: m.Provider, ProviderSubject: m.ProviderSubject, Role: m.Role, Editor: m.Editor, CreatedAt: m.CreatedAt} -} -func UserFromDomain(d domain.User) UserModel { - return UserModel{ID: d.ID, Email: d.Email, Name: d.Name, Provider: d.Provider, ProviderSubject: d.ProviderSubject, Role: d.Role, Editor: d.Editor, CreatedAt: d.CreatedAt} -} func ContentToDomain(m ContentModel) domain.Content { badges := make([]string, len(m.Badges)) for i := range m.Badges { badges[i] = m.Badges[i].Name } - return domain.Content{ID: m.ID, Slug: m.Slug, Title: m.Title, Summary: m.Summary, Body: m.Body, Status: m.Status, Published: m.Published, CategoryID: m.CategoryID, Category: m.Category.Name, Badges: badges, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, CreatedBy: m.CreatedBy, UpdatedBy: m.UpdatedBy} + return domain.Content{ID: m.ID, Slug: m.Slug, Title: m.Title, Summary: m.Summary, Body: m.Body, Status: m.Status, Published: m.Published, CategoryID: m.CategoryID, Category: m.Category.Name, Badges: badges, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt} } + func ContentFromDomain(d domain.Content) ContentModel { - return ContentModel{ID: d.ID, Slug: d.Slug, Title: d.Title, Summary: d.Summary, Body: d.Body, Status: d.Status, Published: d.Published, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, CreatedBy: d.CreatedBy, UpdatedBy: d.UpdatedBy} -} -func AdminToDomain(m AdminModel) domain.Admin { - return domain.Admin{ID: m.ID, Email: m.Email, Name: m.Name, CreatedAt: m.CreatedAt} -} -func SessionToDomain(m SessionModel) domain.Session { - return domain.Session{ID: m.ID, Token: m.Token, UserID: m.UserID, AdminID: m.AdminID, ExpiresAt: m.ExpiresAt, CreatedAt: m.CreatedAt} -} -func SessionFromDomain(d domain.Session) SessionModel { - return SessionModel{ID: d.ID, Token: d.Token, UserID: d.UserID, AdminID: d.AdminID, ExpiresAt: d.ExpiresAt, CreatedAt: d.CreatedAt} + return ContentModel{ID: d.ID, Slug: d.Slug, Title: d.Title, Summary: d.Summary, Body: d.Body, Status: d.Status, Published: d.Published, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt} } diff --git a/internal/persistence/mapping_test.go b/internal/persistence/mapping_test.go deleted file mode 100644 index 6ae7e36..0000000 --- a/internal/persistence/mapping_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package persistence - -import ( - "github.com/example/cms/internal/domain" - "testing" - "time" -) - -func TestUserMappingRoundTrip(t *testing.T) { - d := domain.User{ID: 2, Email: "a@b", Name: "A", Provider: "google", ProviderSubject: "s", CreatedAt: time.Now()} - if got := UserToDomain(UserFromDomain(d)); got.Email != d.Email || got.ProviderSubject != d.ProviderSubject { - t.Fatalf("round trip mismatch: %+v", got) - } -} diff --git a/internal/persistence/models.go b/internal/persistence/models.go index c1f051a..46be5e0 100644 --- a/internal/persistence/models.go +++ b/internal/persistence/models.go @@ -2,17 +2,6 @@ package persistence import "time" -type UserModel struct { - ID uint `gorm:"primaryKey"` - Email string `gorm:"uniqueIndex;not null"` - Name string - Provider string `gorm:"not null"` - ProviderSubject string `gorm:"uniqueIndex;not null"` - Role string `gorm:"not null;default:''"` - Editor bool `gorm:"not null;default:false"` - CreatedAt time.Time -} - type ContentModel struct { ID uint `gorm:"primaryKey"` Slug string `gorm:"uniqueIndex;not null"` @@ -25,9 +14,10 @@ type ContentModel struct { Category CategoryModel `gorm:"foreignKey:CategoryID"` Badges []BadgeModel `gorm:"many2many:content_badges;joinForeignKey:ContentID;joinReferences:BadgeID"` CreatedAt, UpdatedAt time.Time - CreatedBy, UpdatedBy uint } +func (ContentModel) TableName() string { return "content" } + type CategoryModel struct { ID uint `gorm:"primaryKey"` Slug string `gorm:"uniqueIndex;not null"` @@ -43,38 +33,12 @@ type BadgeModel struct { func (BadgeModel) TableName() string { return "badges" } -func (ContentModel) TableName() string { return "content" } - type ContentHistoryModel struct { - ID uint `gorm:"primaryKey"` - ContentID uint `gorm:"index;not null"` - Operation string `gorm:"not null"` - ActorID *uint - ActorAdminID *uint - CreatedAt time.Time - Snapshot string `gorm:"type:jsonb;not null"` + ID uint `gorm:"primaryKey"` + ContentID uint `gorm:"index;not null"` + Operation string `gorm:"not null"` + CreatedAt time.Time + Snapshot string `gorm:"type:jsonb;not null"` } func (ContentHistoryModel) TableName() string { return "content_history" } - -func (UserModel) TableName() string { return "users" } - -type AdminModel struct { - ID uint `gorm:"primaryKey"` - Email string `gorm:"uniqueIndex;not null"` - Name string - CreatedAt time.Time -} - -func (AdminModel) TableName() string { return "admins" } - -type SessionModel struct { - ID uint `gorm:"primaryKey"` - Token string `gorm:"uniqueIndex;not null"` - UserID *uint - AdminID *uint - ExpiresAt time.Time - CreatedAt time.Time -} - -func (SessionModel) TableName() string { return "sessions" } diff --git a/internal/persistence/models_test.go b/internal/persistence/models_test.go index 5f701ad..e916641 100644 --- a/internal/persistence/models_test.go +++ b/internal/persistence/models_test.go @@ -1,12 +1,18 @@ package persistence -import "testing" +import ( + "testing" + "time" + + "github.com/example/cms/internal/domain" +) func TestModelsUseMigrationTableNames(t *testing.T) { tests := map[string]string{ - "users": (UserModel{}).TableName(), - "admins": (AdminModel{}).TableName(), - "sessions": (SessionModel{}).TableName(), + "content": (ContentModel{}).TableName(), + "content_history": (ContentHistoryModel{}).TableName(), + "categories": (CategoryModel{}).TableName(), + "badges": (BadgeModel{}).TableName(), } for expected, actual := range tests { if actual != expected { @@ -14,3 +20,11 @@ func TestModelsUseMigrationTableNames(t *testing.T) { } } } + +func TestContentMappingRoundTrip(t *testing.T) { + d := domain.Content{ID: 2, Slug: "hello-world", Title: "Hello", Body: "body", Status: "draft", Badges: []string{"go"}, CreatedAt: time.Now()} + got := ContentToDomain(ContentFromDomain(d)) + if got.Slug != d.Slug || got.Title != d.Title || got.Status != d.Status { + t.Fatalf("round trip mismatch: %+v", got) + } +} diff --git a/internal/repository/gorm.go b/internal/repository/gorm.go index 44385bd..e148450 100644 --- a/internal/repository/gorm.go +++ b/internal/repository/gorm.go @@ -2,64 +2,11 @@ package repository import ( "context" - "errors" "github.com/example/cms/internal/domain" "github.com/example/cms/internal/persistence" "gorm.io/gorm" - "time" ) -type GORMUserRepository struct{ db *gorm.DB } - -func NewUserRepository(db *gorm.DB) IUserRepository { return &GORMUserRepository{db} } -func (r *GORMUserRepository) FindOrCreateByIdentity(ctx context.Context, d domain.User) (domain.User, error) { - var m persistence.UserModel - err := r.db.WithContext(ctx).Where("provider = ? AND provider_subject = ?", d.Provider, d.ProviderSubject).First(&m).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - m = persistence.UserFromDomain(d) - if err = r.db.WithContext(ctx).Create(&m).Error; err != nil { - return domain.User{}, err - } - return persistence.UserToDomain(m), nil - } - if err != nil { - return domain.User{}, err - } - return persistence.UserToDomain(m), nil -} -func (r *GORMUserRepository) FindByID(ctx context.Context, id uint) (domain.User, error) { - var m persistence.UserModel - if err := r.db.WithContext(ctx).First(&m, id).Error; err != nil { - return domain.User{}, err - } - return persistence.UserToDomain(m), nil -} - -type GORMAdminRepository struct{ db *gorm.DB } - -func NewAdminRepository(db *gorm.DB) IAdminRepository { return &GORMAdminRepository{db} } -func (r *GORMAdminRepository) FindByEmail(ctx context.Context, email string) (domain.Admin, error) { - var m persistence.AdminModel - if err := r.db.WithContext(ctx).Where("email = ?", email).First(&m).Error; err != nil { - return domain.Admin{}, err - } - return persistence.AdminToDomain(m), nil -} - -type GORMSessionRepository struct{ db *gorm.DB } - -func NewSessionRepository(db *gorm.DB) ISessionRepository { return &GORMSessionRepository{db} } -func (r *GORMSessionRepository) Create(ctx context.Context, d domain.Session) error { - return r.db.WithContext(ctx).Create(&persistence.SessionModel{ID: d.ID, Token: d.Token, UserID: d.UserID, AdminID: d.AdminID, ExpiresAt: d.ExpiresAt, CreatedAt: d.CreatedAt}).Error -} -func (r *GORMSessionRepository) FindValid(ctx context.Context, t string) (domain.Session, error) { - var m persistence.SessionModel - if err := r.db.WithContext(ctx).Where("token = ? AND expires_at > ?", t, time.Now()).First(&m).Error; err != nil { - return domain.Session{}, err - } - return persistence.SessionToDomain(m), nil -} - type GORMContentRepository struct{ db *gorm.DB } func NewContentRepository(db *gorm.DB) IContentRepository { return &GORMContentRepository{db} } @@ -113,7 +60,7 @@ func (r *GORMContentRepository) FindBySlug(ctx context.Context, slug string) (do } func (r *GORMContentRepository) Update(ctx context.Context, d domain.Content, h domain.ContentHistory) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&persistence.ContentModel{}).Where("id = ?", d.ID).Updates(map[string]any{"slug": d.Slug, "title": d.Title, "summary": d.Summary, "body": d.Body, "status": d.Status, "published": d.Published, "updated_at": d.UpdatedAt, "updated_by": d.UpdatedBy}).Error; err != nil { + if err := tx.Model(&persistence.ContentModel{}).Where("id = ?", d.ID).Updates(map[string]any{"slug": d.Slug, "title": d.Title, "summary": d.Summary, "body": d.Body, "status": d.Status, "published": d.Published, "updated_at": d.UpdatedAt}).Error; err != nil { return err } if err := tx.Exec("DELETE FROM content_badges WHERE content_id = ?", d.ID).Error; err != nil { @@ -127,6 +74,6 @@ func (r *GORMContentRepository) Update(ctx context.Context, d domain.Content, h return err } } - return tx.Create(&persistence.ContentHistoryModel{ContentID: h.ContentID, Operation: h.Operation, ActorID: h.ActorID, ActorAdminID: h.ActorAdminID, CreatedAt: h.CreatedAt, Snapshot: h.Snapshot}).Error + return tx.Create(&persistence.ContentHistoryModel{ContentID: h.ContentID, Operation: h.Operation, CreatedAt: h.CreatedAt, Snapshot: h.Snapshot}).Error }) } diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index 2312be1..832ed06 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -5,19 +5,6 @@ import ( "github.com/example/cms/internal/domain" ) -type IUserRepository interface { - FindOrCreateByIdentity(context.Context, domain.User) (domain.User, error) -} -type IUserLookup interface { - FindByID(context.Context, uint) (domain.User, error) -} -type IAdminRepository interface { - FindByEmail(context.Context, string) (domain.Admin, error) -} -type ISessionRepository interface { - Create(context.Context, domain.Session) error - FindValid(context.Context, string) (domain.Session, error) -} type IContentRepository interface { List(context.Context, int, int, string, string, []string) ([]domain.Content, error) ListCategories(context.Context) ([]domain.Category, error) diff --git a/internal/security/token.go b/internal/security/token.go new file mode 100644 index 0000000..5bb03cd --- /dev/null +++ b/internal/security/token.go @@ -0,0 +1,73 @@ +// Package security decides whether a request carries the identity asserted by +// the upstream single-sign-on proxy. The CMS never signs users in itself: the +// proxy authenticates the owner and forwards a header on every accepted +// request. Requests without that header are anonymous. +package security + +import ( + "crypto/subtle" + "net/http" + "strings" +) + +// ITokenVerifier reports whether a request was authenticated by the SSO proxy. +type ITokenVerifier interface { + Authenticated(*http.Request) bool + HeaderName() string +} + +// SSOTokenVerifier trusts the header injected by the identity-aware proxy. An +// empty Header disables header lookups; Cookie optionally names a cookie that +// carries the same value. When Token is non-empty the forwarded value must +// match it exactly, otherwise the presence of any non-empty value is accepted. +type SSOTokenVerifier struct { + Header string + Token string + Cookie string +} + +// NewSSOTokenVerifier builds a verifier for the given header, optional expected +// token, and optional cookie fallback. +func NewSSOTokenVerifier(header, token, cookie string) SSOTokenVerifier { + return SSOTokenVerifier{Header: header, Token: token, Cookie: cookie} +} + +// HeaderName returns the proxy header carrying the SSO token. +func (v SSOTokenVerifier) HeaderName() string { return v.Header } + +// Authenticated reports whether the request carries an accepted SSO token. +func (v SSOTokenVerifier) Authenticated(r *http.Request) bool { + return v.accepted(v.presented(r)) +} + +func (v SSOTokenVerifier) presented(r *http.Request) string { + if v.Header != "" { + if value := trimBearer(r.Header.Get(v.Header)); value != "" { + return value + } + } + if v.Cookie != "" { + if c, err := r.Cookie(v.Cookie); err == nil { + return trimBearer(c.Value) + } + } + return "" +} + +func (v SSOTokenVerifier) accepted(presented string) bool { + if presented == "" { + return false + } + if v.Token == "" { + return true + } + return subtle.ConstantTimeCompare([]byte(presented), []byte(v.Token)) == 1 +} + +func trimBearer(value string) string { + value = strings.TrimSpace(value) + if len(value) >= len("bearer ") && strings.EqualFold(value[:len("bearer ")], "bearer ") { + return strings.TrimSpace(value[len("bearer "):]) + } + return value +} diff --git a/internal/security/token_test.go b/internal/security/token_test.go new file mode 100644 index 0000000..eaedb61 --- /dev/null +++ b/internal/security/token_test.go @@ -0,0 +1,42 @@ +package security + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func requestWithHeader(name, value string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set(name, value) + return r +} + +func TestSSOTokenVerifierRequiresProxyHeader(t *testing.T) { + v := NewSSOTokenVerifier("X-SSO-Token", "", "") + if v.Authenticated(httptest.NewRequest(http.MethodGet, "/", nil)) { + t.Fatal("request without proxy header must stay anonymous") + } + if !v.Authenticated(requestWithHeader("X-SSO-Token", "oauth2-access-token")) { + t.Fatal("request with proxy header must be authenticated") + } +} + +func TestSSOTokenVerifierChecksExpectedToken(t *testing.T) { + v := NewSSOTokenVerifier("X-SSO-Token", "secret", "") + if v.Authenticated(requestWithHeader("X-SSO-Token", "other")) { + t.Fatal("mismatching token must stay anonymous") + } + if !v.Authenticated(requestWithHeader("X-SSO-Token", "Bearer secret")) { + t.Fatal("bearer-prefixed token must be authenticated") + } +} + +func TestSSOTokenVerifierCookieFallback(t *testing.T) { + v := NewSSOTokenVerifier("X-SSO-Token", "", "cms_session") + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: "cms_session", Value: "cookie-token"}) + if !v.Authenticated(r) { + t.Fatal("cookie token must be authenticated") + } +} diff --git a/internal/service/auth.go b/internal/service/auth.go deleted file mode 100644 index 2bb7245..0000000 --- a/internal/service/auth.go +++ /dev/null @@ -1,97 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "encoding/base64" - "errors" - "github.com/example/cms/internal/domain" - "github.com/example/cms/internal/identity" - "github.com/example/cms/internal/repository" - "strings" - "time" -) - -type IUserService interface { - Login(context.Context, identity.Identity) (domain.User, string, error) - Authenticate(context.Context, string) (domain.Session, error) -} -type IAdminAuthService interface { - Login(context.Context, string) (domain.Admin, string, error) - Authenticate(context.Context, string) (domain.Session, error) -} -type AuthService struct { - users repository.IUserRepository - admins repository.IAdminRepository - userSessions repository.ISessionRepository - adminSessions repository.ISessionRepository - ttl time.Duration -} - -func NewAuthService(u repository.IUserRepository, a repository.IAdminRepository, sessions repository.ISessionRepository, ttl time.Duration) *AuthService { - return NewAuthServiceWithSeparateSessions(u, a, sessions, sessions, ttl) -} - -func NewAuthServiceWithSeparateSessions(u repository.IUserRepository, a repository.IAdminRepository, userSessions, adminSessions repository.ISessionRepository, ttl time.Duration) *AuthService { - return &AuthService{users: u, admins: a, userSessions: userSessions, adminSessions: adminSessions, ttl: ttl} -} -func (s *AuthService) Login(ctx context.Context, id identity.Identity) (domain.User, string, error) { - if strings.TrimSpace(id.Email) == "" || strings.TrimSpace(id.Subject) == "" { - return domain.User{}, "", errors.New("identity email and subject are required") - } - u, e := s.users.FindOrCreateByIdentity(ctx, domain.User{Email: id.Email, Name: id.Name, Provider: "google", ProviderSubject: id.Subject, CreatedAt: time.Now()}) - if e != nil { - return domain.User{}, "", e - } - t, e := newToken() - if e != nil { - return domain.User{}, "", e - } - if e = s.userSessions.Create(ctx, domain.Session{Token: t, UserID: &u.ID, ExpiresAt: time.Now().Add(s.ttl), CreatedAt: time.Now()}); e != nil { - return domain.User{}, "", e - } - return u, t, nil -} -func (s *AuthService) Authenticate(ctx context.Context, t string) (domain.Session, error) { - return s.userSessions.FindValid(ctx, t) -} -func (s *AuthService) AdminLogin(ctx context.Context, email string) (domain.Admin, string, error) { - a, e := s.admins.FindByEmail(ctx, email) - if e != nil { - return domain.Admin{}, "", errors.New("invalid admin credentials") - } - t, e := newToken() - if e != nil { - return domain.Admin{}, "", e - } - if e = s.adminSessions.Create(ctx, domain.Session{Token: t, AdminID: &a.ID, ExpiresAt: time.Now().Add(s.ttl), CreatedAt: time.Now()}); e != nil { - return domain.Admin{}, "", e - } - return a, t, nil -} -func (s *AuthService) AdminAuthenticate(ctx context.Context, t string) (domain.Session, error) { - return s.adminSessions.FindValid(ctx, t) -} -func (s *AuthService) UserByID(ctx context.Context, id uint) (domain.User, error) { - if lookup, ok := s.users.(repository.IUserLookup); ok { - return lookup.FindByID(ctx, id) - } - return domain.User{}, errors.New("user lookup unavailable") -} -func (s *AuthService) CanEdit(ctx context.Context, session domain.Session) bool { - if session.AdminID != nil { - return true - } - if session.UserID == nil { - return false - } - u, err := s.UserByID(ctx, *session.UserID) - return err == nil && (u.Editor || u.Role == "editor" || u.Role == "admin") -} -func newToken() (string, error) { - b := make([]byte, 32) - if _, e := rand.Read(b); e != nil { - return "", e - } - return base64.RawURLEncoding.EncodeToString(b), nil -} diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go deleted file mode 100644 index a73cc03..0000000 --- a/internal/service/auth_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package service - -import ( - "context" - "testing" - "time" - - "github.com/example/cms/internal/domain" - "github.com/example/cms/internal/identity" -) - -type fakeUsers struct{} - -func (fakeUsers) FindOrCreateByIdentity(_ context.Context, u domain.User) (domain.User, error) { - u.ID = 7 - return u, nil -} - -type fakeAdmins struct{} - -func (fakeAdmins) FindByEmail(context.Context, string) (domain.Admin, error) { - return domain.Admin{ID: 2, Email: "admin@example.com"}, nil -} - -type fakeSessions struct{ created domain.Session } - -func (f *fakeSessions) Create(_ context.Context, s domain.Session) error { f.created = s; return nil } -func (f *fakeSessions) FindValid(context.Context, string) (domain.Session, error) { - return f.created, nil -} - -func TestAuthServiceCreatesUserSession(t *testing.T) { - sessions := &fakeSessions{} - s := NewAuthService(fakeUsers{}, fakeAdmins{}, sessions, time.Hour) - u, token, err := s.Login(context.Background(), identity.Identity{Subject: "sub", Email: "user@example.com"}) - if err != nil || token == "" || u.ID != 7 || sessions.created.UserID == nil { - t.Fatalf("login failed: user=%+v token=%q session=%+v err=%v", u, token, sessions.created, err) - } -} diff --git a/internal/service/content.go b/internal/service/content.go index 0304e80..3ff2a41 100644 --- a/internal/service/content.go +++ b/internal/service/content.go @@ -11,20 +11,21 @@ import ( "time" ) +// IContentService exposes the content use cases. Read operations are anonymous; +// Update is only reachable once the web layer verified the SSO proxy identity. type IContentService interface { List(context.Context, int, int, string, string, []string) ([]domain.Content, error) ListCategories(context.Context) ([]domain.Category, error) ListBadges(context.Context) ([]domain.Badge, error) Get(context.Context, string) (domain.Content, error) - Update(context.Context, domain.Content, domain.Session) (domain.Content, error) + Update(context.Context, domain.Content) (domain.Content, error) } type ContentService struct { repo repository.IContentRepository - auth *AuthService } -func NewContentService(r repository.IContentRepository, a *AuthService) *ContentService { - return &ContentService{repo: r, auth: a} +func NewContentService(r repository.IContentRepository) *ContentService { + return &ContentService{repo: r} } func (s *ContentService) List(ctx context.Context, limit, offset int, category, status string, badges []string) ([]domain.Content, error) { if limit < 1 { @@ -50,10 +51,7 @@ func (s *ContentService) Get(ctx context.Context, slug string) (domain.Content, } return s.repo.FindBySlug(ctx, slug) } -func (s *ContentService) Update(ctx context.Context, c domain.Content, session domain.Session) (domain.Content, error) { - if s.auth == nil || !s.auth.CanEdit(ctx, session) { - return domain.Content{}, errors.New("forbidden") - } +func (s *ContentService) Update(ctx context.Context, c domain.Content) (domain.Content, error) { if !validSlug(c.Slug) || strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" { return domain.Content{}, errors.New("invalid content") } @@ -63,15 +61,11 @@ func (s *ContentService) Update(ctx context.Context, c domain.Content, session d } c.ID = old.ID c.CreatedAt = old.CreatedAt - c.CreatedBy = old.CreatedBy c.UpdatedAt = time.Now() c.Badges = normalizeBadges(c.Badges) - if session.UserID != nil { - c.UpdatedBy = *session.UserID - } snapshot, _ := json.Marshal(c) - h := domain.ContentHistory{ContentID: c.ID, Operation: "updated", CreatedAt: c.UpdatedAt, Snapshot: string(snapshot), ActorID: session.UserID, ActorAdminID: session.AdminID} + h := domain.ContentHistory{ContentID: c.ID, Operation: "updated", CreatedAt: c.UpdatedAt, Snapshot: string(snapshot)} if err = s.repo.Update(ctx, c, h); err != nil { return domain.Content{}, err } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 64a02a6..e225a92 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -1,172 +1,55 @@ package web import ( - "crypto/rand" - "encoding/base64" "encoding/json" "fmt" - "github.com/example/cms/internal/domain" - "github.com/example/cms/internal/identity" - "github.com/example/cms/internal/service" - "github.com/example/cms/internal/web/mapping" - "log" "net/http" "strings" + + "github.com/example/cms/internal/domain" + "github.com/example/cms/internal/security" + "github.com/example/cms/internal/service" ) +// Handler serves the CMS API. Reading content is anonymous; every mutation +// requires the identity asserted by the upstream SSO proxy. type Handler struct { - auth *service.AuthService - google identity.GoogleProvider content service.IContentService + sso security.ITokenVerifier } -func NewHandler(a *service.AuthService, google identity.GoogleProvider) *Handler { - return &Handler{auth: a, google: google} -} -func NewHandlerWithContent(a *service.AuthService, google identity.GoogleProvider, content service.IContentService) *Handler { - return &Handler{auth: a, google: google, content: content} +func NewHandler(content service.IContentService, sso security.ITokenVerifier) *Handler { + return &Handler{content: content, sso: sso} } func (h *Handler) Routes(origins string) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { jsonWrite(w, 200, map[string]string{"status": "ok"}) }) - mux.HandleFunc("/api/login", h.login) - mux.HandleFunc("/api/auth/google", h.googleLogin) - mux.HandleFunc("/api/auth/google/callback", h.googleCallback) mux.HandleFunc("/api/auth/session", h.session) - mux.HandleFunc("/admin/login", h.adminLogin) - mux.HandleFunc("/api/test/protected", h.protected) mux.HandleFunc("/api/protected", h.protected) + mux.HandleFunc("/api/test/protected", h.protected) mux.HandleFunc("/api/content", h.contentList) mux.HandleFunc("/api/content/categories", h.contentCategories) mux.HandleFunc("/api/content/badges", h.contentBadges) mux.HandleFunc("/api/content/", h.contentDetail) - return cors(mux, origins) -} -func (h *Handler) googleLogin(w http.ResponseWriter, r *http.Request) { - if h.google.ClientID == "" { - jsonWrite(w, http.StatusServiceUnavailable, map[string]string{"error": "google identity provider is not configured"}) - return - } - stateBytes := make([]byte, 32) - if _, err := rand.Read(stateBytes); err != nil { - jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": "unable to create oauth state"}) - return - } - state := base64.RawURLEncoding.EncodeToString(stateBytes) - http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, Path: "/", MaxAge: 600}) - http.Redirect(w, r, h.google.OAuthConfig().AuthCodeURL(state), http.StatusFound) -} -func (h *Handler) googleCallback(w http.ResponseWriter, r *http.Request) { - stateCookie, err := r.Cookie("oauth_state") - if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") { - http.Error(w, "invalid oauth state", http.StatusBadRequest) - return - } - id, err := h.google.FetchIdentity(r.Context(), r.URL.Query().Get("code")) - if err != nil { - log.Printf("Google OAuth callback failed: %v", err) - http.Error(w, "google authentication failed; start a new sign-in attempt", http.StatusUnauthorized) - return - } - user, token, err := h.auth.Login(r.Context(), id) - if err != nil { - http.Error(w, "unable to create session", http.StatusInternalServerError) - return - } - setSessionCookie(w, token, r.TLS != nil) - _ = user - http.Redirect(w, r, "/test", http.StatusFound) + return cors(mux, origins, h.sso.HeaderName()) } func (h *Handler) session(w http.ResponseWriter, r *http.Request) { - token := bearerOrCookie(r) - if token == "" { - jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"}) - return - } - s, err := h.auth.Authenticate(r.Context(), token) - if err != nil || s.UserID == nil { - jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"}) - return - } - u, err := h.auth.UserByID(r.Context(), *s.UserID) - if err != nil { - jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"}) - return - } - jsonWrite(w, http.StatusOK, map[string]any{"authenticated": true, "user": u, "canEdit": h.auth.CanEdit(r.Context(), s)}) -} -func (h *Handler) login(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - jsonWrite(w, 405, nil) - return - } - var req mapping.LoginRequest - if !decode(r, &req) { - jsonWrite(w, 400, map[string]string{"error": "invalid JSON"}) - return - } - u, t, e := h.auth.Login(r.Context(), req.Identity()) - if e != nil { - jsonWrite(w, 400, map[string]string{"error": e.Error()}) - return - } - jsonWrite(w, 200, mapping.LoginResponse{Token: t, User: u}) -} -func (h *Handler) adminLogin(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - jsonWrite(w, 405, nil) - return - } - var req struct { - Email string `json:"email"` - } - if !decode(r, &req) { - jsonWrite(w, 400, map[string]string{"error": "invalid JSON"}) - return - } - a, t, e := h.auth.AdminLogin(r.Context(), req.Email) - if e != nil { - jsonWrite(w, 401, map[string]string{"error": e.Error()}) - return - } - jsonWrite(w, 200, mapping.LoginResponse{Token: t, Admin: a}) + authenticated := h.sso.Authenticated(r) + jsonWrite(w, http.StatusOK, map[string]any{"authenticated": authenticated, "canEdit": authenticated}) } func (h *Handler) protected(w http.ResponseWriter, r *http.Request) { - t := bearerOrCookie(r) - if t == "" { - jsonWrite(w, 401, map[string]string{"error": "missing bearer token"}) - return - } - s, e := h.auth.Authenticate(r.Context(), t) - if e != nil || s.UserID == nil { - jsonWrite(w, 401, map[string]string{"error": "invalid session"}) + if !h.sso.Authenticated(r) { + jsonWrite(w, 401, map[string]string{"error": "authentication required"}) return } jsonWrite(w, 200, map[string]string{"status": "authenticated"}) } -func (h *Handler) sessionForRequest(r *http.Request) (domain.Session, bool) { - t := bearerOrCookie(r) - if t == "" { - return domain.Session{}, false - } - if s, e := h.auth.Authenticate(r.Context(), t); e == nil { - return s, true - } - if s, e := h.auth.AdminAuthenticate(r.Context(), t); e == nil { - return s, true - } - return domain.Session{}, false -} func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) { if h.content == nil { jsonWrite(w, 503, map[string]string{"error": "content unavailable"}) return } - if _, ok := h.sessionForRequest(r); !ok { - jsonWrite(w, 401, map[string]string{"error": "authentication required"}) - return - } - if r.Method != "GET" { + if r.Method != http.MethodGet { jsonWrite(w, http.StatusMethodNotAllowed, nil) return } @@ -199,10 +82,6 @@ func (h *Handler) contentCategories(w http.ResponseWriter, r *http.Request) { jsonWrite(w, 503, map[string]string{"error": "content unavailable"}) return } - if _, ok := h.sessionForRequest(r); !ok { - jsonWrite(w, 401, map[string]string{"error": "authentication required"}) - return - } if r.Method != http.MethodGet { jsonWrite(w, http.StatusMethodNotAllowed, nil) return @@ -219,10 +98,6 @@ func (h *Handler) contentBadges(w http.ResponseWriter, r *http.Request) { jsonWrite(w, 503, map[string]string{"error": "content unavailable"}) return } - if _, ok := h.sessionForRequest(r); !ok { - jsonWrite(w, 401, map[string]string{"error": "authentication required"}) - return - } if r.Method != http.MethodGet { jsonWrite(w, http.StatusMethodNotAllowed, nil) return @@ -240,11 +115,7 @@ func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) { return } slug := strings.TrimPrefix(r.URL.Path, "/api/content/") - if r.Method == "GET" { - if _, ok := h.sessionForRequest(r); !ok { - jsonWrite(w, 401, map[string]string{"error": "authentication required"}) - return - } + if r.Method == http.MethodGet { c, e := h.content.Get(r.Context(), slug) if e != nil { jsonWrite(w, 404, map[string]string{"error": "content not found"}) @@ -253,46 +124,27 @@ func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) { jsonWrite(w, 200, c) return } - if r.Method != "PUT" { + if r.Method != http.MethodPut { jsonWrite(w, 405, nil) return } - s, ok := h.sessionForRequest(r) - if !ok { + if !h.sso.Authenticated(r) { jsonWrite(w, 401, map[string]string{"error": "authentication required"}) return } - if !h.auth.CanEdit(r.Context(), s) { - jsonWrite(w, 403, map[string]string{"error": "forbidden"}) - return - } var c domain.Content if !decode(r, &c) { jsonWrite(w, 400, map[string]string{"error": "invalid JSON"}) return } c.Slug = slug - out, e := h.content.Update(r.Context(), c, s) + out, e := h.content.Update(r.Context(), c) if e != nil { jsonWrite(w, 400, map[string]string{"error": e.Error()}) return } jsonWrite(w, 200, out) } -func bearerOrCookie(r *http.Request) string { - header := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) - if header != "" { - return header - } - cookie, err := r.Cookie("cms_session") - if err != nil { - return "" - } - return cookie.Value -} -func setSessionCookie(w http.ResponseWriter, token string, secure bool) { - http.SetCookie(w, &http.Cookie{Name: "cms_session", Value: token, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, Path: "/", MaxAge: 86400}) -} func decode(r *http.Request, v any) bool { return json.NewDecoder(r.Body).Decode(v) == nil } func jsonWrite(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") @@ -301,13 +153,17 @@ func jsonWrite(w http.ResponseWriter, status int, v any) { _ = json.NewEncoder(w).Encode(v) } } -func cors(next http.Handler, origin string) http.Handler { +func cors(next http.Handler, origin, ssoHeader string) http.Handler { + allowed := "Content-Type, Authorization" + if ssoHeader != "" && !strings.EqualFold(ssoHeader, "Authorization") { + allowed += ", " + ssoHeader + } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Headers", allowed) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS") - if r.Method == "OPTIONS" { + if r.Method == http.MethodOptions { w.WriteHeader(204) return } diff --git a/internal/web/handlers_test.go b/internal/web/handlers_test.go new file mode 100644 index 0000000..fb9f0fb --- /dev/null +++ b/internal/web/handlers_test.go @@ -0,0 +1,81 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/example/cms/internal/domain" + "github.com/example/cms/internal/security" +) + +type fakeContent struct{ updated bool } + +func (f *fakeContent) List(context.Context, int, int, string, string, []string) ([]domain.Content, error) { + return nil, nil +} +func (f *fakeContent) ListCategories(context.Context) ([]domain.Category, error) { return nil, nil } +func (f *fakeContent) ListBadges(context.Context) ([]domain.Badge, error) { return nil, nil } +func (f *fakeContent) Get(context.Context, string) (domain.Content, error) { + return domain.Content{Slug: "post", Title: "Post", Body: "body"}, nil +} +func (f *fakeContent) Update(_ context.Context, c domain.Content) (domain.Content, error) { + f.updated = true + return c, nil +} + +func newTestRoutes(content *fakeContent) http.Handler { + return NewHandler(content, security.NewSSOTokenVerifier("X-SSO-Token", "", "")).Routes("http://localhost:5173") +} + +func TestAnonymousCanReadButNotEdit(t *testing.T) { + content := &fakeContent{} + routes := newTestRoutes(content) + + get := httptest.NewRecorder() + routes.ServeHTTP(get, httptest.NewRequest(http.MethodGet, "/api/content/post", nil)) + if get.Code != http.StatusOK { + t.Fatalf("anonymous read status = %d, want %d", get.Code, http.StatusOK) + } + + put := httptest.NewRecorder() + routes.ServeHTTP(put, httptest.NewRequest(http.MethodPut, "/api/content/post", strings.NewReader(`{"title":"T","body":"B"}`))) + if put.Code != http.StatusUnauthorized { + t.Fatalf("anonymous update status = %d, want %d", put.Code, http.StatusUnauthorized) + } + if content.updated { + t.Fatal("anonymous request must not reach the content service") + } + + session := httptest.NewRecorder() + routes.ServeHTTP(session, httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)) + if !strings.Contains(session.Body.String(), `"authenticated":false`) { + t.Fatalf("anonymous session = %s", session.Body.String()) + } +} + +func TestProxyIdentityCanEdit(t *testing.T) { + content := &fakeContent{} + routes := newTestRoutes(content) + + put := httptest.NewRequest(http.MethodPut, "/api/content/post", strings.NewReader(`{"title":"T","body":"B"}`)) + put.Header.Set("X-SSO-Token", "owner-token") + putRec := httptest.NewRecorder() + routes.ServeHTTP(putRec, put) + if putRec.Code != http.StatusOK { + t.Fatalf("owner update status = %d, body = %s", putRec.Code, putRec.Body.String()) + } + if !content.updated { + t.Fatal("owner request must reach the content service") + } + + session := httptest.NewRequest(http.MethodGet, "/api/auth/session", nil) + session.Header.Set("X-SSO-Token", "owner-token") + sessionRec := httptest.NewRecorder() + routes.ServeHTTP(sessionRec, session) + if !strings.Contains(sessionRec.Body.String(), `"canEdit":true`) { + t.Fatalf("owner session = %s", sessionRec.Body.String()) + } +} diff --git a/internal/web/mapping/auth.go b/internal/web/mapping/auth.go deleted file mode 100644 index 379594b..0000000 --- a/internal/web/mapping/auth.go +++ /dev/null @@ -1,15 +0,0 @@ -package mapping - -import "github.com/example/cms/internal/identity" - -type LoginRequest struct{ Subject, Email, Name string } - -func (r LoginRequest) Identity() identity.Identity { - return identity.Identity{Subject: r.Subject, Email: r.Email, Name: r.Name} -} - -type LoginResponse struct { - Token string `json:"token"` - User any `json:"user,omitempty"` - Admin any `json:"admin,omitempty"` -} diff --git a/internal/web/mapping/auth_test.go b/internal/web/mapping/auth_test.go deleted file mode 100644 index 1498920..0000000 --- a/internal/web/mapping/auth_test.go +++ /dev/null @@ -1,10 +0,0 @@ -package mapping - -import "testing" - -func TestLoginRequestIdentity(t *testing.T) { - i := (LoginRequest{Subject: "s", Email: "e"}).Identity() - if i.Subject != "s" || i.Email != "e" { - t.Fatal("identity mapping failed") - } -} diff --git a/ui/app/.env.example b/ui/app/.env.example index fd3ac7e..c9cdf36 100644 --- a/ui/app/.env.example +++ b/ui/app/.env.example @@ -1,6 +1,8 @@ # Leave empty in development; Vite proxies API requests to localhost:8080. # Set this to the public backend URL when the frontend is hosted separately. +# +# There is no sign-in endpoint: the upstream SSO proxy authenticates the owner +# and the backend trusts the header it forwards. VITE_API_BASE_URL= -VITE_GOOGLE_AUTH_ENDPOINT=/api/auth/google VITE_SESSION_ENDPOINT=/api/auth/session -VITE_LOGOUT_ENDPOINT=/api/auth/logout +VITE_CONTENT_ENDPOINT=/api/content diff --git a/ui/app/README.md b/ui/app/README.md index becf4ec..8332d58 100644 --- a/ui/app/README.md +++ b/ui/app/README.md @@ -3,10 +3,12 @@ This Vite/React app lives in `ui/app` and is intentionally independent of the Go backend. Run `npm install`, then `npm run dev` or `npm run build`. -The API defaults to same-origin `/api/auth/session`, `/api/auth/google`, and -`/api/auth/logout`. Set `VITE_API_BASE_URL` for a separate backend, or override the endpoint -paths with `VITE_SESSION_ENDPOINT`, `VITE_GOOGLE_AUTH_ENDPOINT`, and `VITE_LOGOUT_ENDPOINT`. -The Google endpoint may return JSON containing `user` and an optional `token`/`accessToken`, or a -redirect response. Sessions use cookies (`credentials: include`) and tokens are retained only for -the current browser tab. Provider-specific login is isolated in `src/api.ts` so Facebook can be -added without changing the routes or UI. +There is no sign-in screen. Content is public and read-only for anonymous visitors; the Edit +button appears only when the backend reports `canEdit: true`, which happens when the upstream SSO +proxy asserted the owner's identity on the request. `/api/auth/session` returns +`{ "authenticated": bool, "canEdit": bool }` and never creates a session. + +The API defaults to same-origin `/api/auth/session` and `/api/content`. Set `VITE_API_BASE_URL` +for a separate backend, or override the endpoint paths with `VITE_SESSION_ENDPOINT` and +`VITE_CONTENT_ENDPOINT`. Requests use cookies (`credentials: include`) so the edge proxy cookie +travels with them. \ No newline at end of file diff --git a/ui/app/src/App.tsx b/ui/app/src/App.tsx index a17316e..ebfbca6 100644 --- a/ui/app/src/App.tsx +++ b/ui/app/src/App.tsx @@ -1,219 +1,297 @@ -import { useEffect, useRef, useState } from 'react' -import { Link, Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom' -import { getContent, listBadges, listCategories, listContent, updateContent, type Badge, type Category, type Content, type ContentUpdate } from './api' -import { useAuth } from './auth' +import {useEffect, useRef, useState} from 'react' +import {Link, Navigate, Route, Routes, useParams} from 'react-router-dom' +import { + type Badge, + type Category, + type Content, + type ContentUpdate, + getContent, + listBadges, + listCategories, + listContent, + updateContent +} from './api' +import {useAuth} from './auth' -function Shell({ children }: { children: React.ReactNode }) { - const { user, signOut } = useAuth() - const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light') - useEffect(() => { - document.documentElement.dataset.theme = dark ? 'dark' : 'light' - window.localStorage.setItem('urpi-theme', dark ? 'dark' : 'light') - }, [dark]) - return
-
~/ Urpi's backlog_linux · go · java · ideas
{user && }
- {children} -
+function Shell({children}: { children: React.ReactNode }) { + const {authenticated, loading} = useAuth() + const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light') + useEffect(() => { + document.documentElement.dataset.theme = dark ? 'dark' : 'light' + window.localStorage.setItem('urpi-theme', dark ? 'dark' : 'light') + }, [dark]) + return
+
~/ Urpi's + backlog_linux · go · java · ideas +
{!loading && + {authenticated ? 'Owner · SSO' : 'Anonymous · read-only'}} + +
+
+ {children} +
} -function Login() { - const { user, loading, error, signIn } = useAuth() - if (user) return - if (loading) return

Checking your session…

- return
- Welcome, developer

Sign in to Urpi's backlog

-

A cozy backlog for Linux, Go, Java, and the ideas between them.

- {error &&

{error}

} - -
-} - -function RequireAuth({ children }: { children: React.ReactNode }) { - const { user, loading } = useAuth() - const location = useLocation() - if (loading) return

Loading…

- if (!user) return - return <>{children} -} - -function ContentCard({ item }: { item: Content }) { - return
- {item.status} -

{item.title}

-

{item.summary}

- {item.badges && item.badges.length > 0 &&
{item.badges.map(badge => {badge})}
} - Read more -
+function ContentCard({item}: { item: Content }) { + return
+ {item.status} +

{item.title}

+

{item.summary}

+ {item.badges && item.badges.length > 0 && +
{item.badges.map(badge => {badge})}
} + Read more +
} function ContentList() { - const [items, setItems] = useState([]) - const [cursor, setCursor] = useState(undefined) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [categories, setCategories] = useState([]) - const [badgeOptions, setBadgeOptions] = useState([]) - const [category, setCategory] = useState('') - const [status, setStatus] = useState('') - const [badges, setBadges] = useState([]) - const [badgeInput, setBadgeInput] = useState('') - const loadingMore = useRef(false) - const requestVersion = useRef(0) - const sentinel = useRef(null) - const filters = { category, status, badges } + const [items, setItems] = useState([]) + const [cursor, setCursor] = useState(undefined) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [categories, setCategories] = useState([]) + const [badgeOptions, setBadgeOptions] = useState([]) + const [category, setCategory] = useState('') + const [status, setStatus] = useState('') + const [badges, setBadges] = useState([]) + const [badgeInput, setBadgeInput] = useState('') + const loadingMore = useRef(false) + const requestVersion = useRef(0) + const sentinel = useRef(null) + const filters = {category, status, badges} - const load = async (next = false, activeFilters = filters) => { - if (loadingMore.current || (next && !cursor)) return - const version = next ? requestVersion.current : ++requestVersion.current - loadingMore.current = true - setLoading(true) - try { - const response = await listContent(next ? cursor : undefined, activeFilters.category, activeFilters.status, activeFilters.badges) - if (version !== requestVersion.current) return - const page = Array.isArray(response) ? response : response.items ?? response.content ?? response.data ?? [] - setItems(current => next ? [...current, ...page] : page) - setCursor(Array.isArray(response) ? null : response.nextCursor ?? response.cursor ?? (response.hasMore ? undefined : null)) - setError(null) - } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to load content.') - } finally { - if (version === requestVersion.current) { - loadingMore.current = false - setLoading(false) - } + const load = async (next = false, activeFilters = filters) => { + if (loadingMore.current || (next && !cursor)) return + const version = next ? requestVersion.current : ++requestVersion.current + loadingMore.current = true + setLoading(true) + try { + const response = await listContent(next ? cursor : undefined, activeFilters.category, activeFilters.status, activeFilters.badges) + if (version !== requestVersion.current) return + const page = Array.isArray(response) ? response : response.items ?? response.content ?? response.data ?? [] + setItems(current => next ? [...current, ...page] : page) + setCursor(Array.isArray(response) ? null : response.nextCursor ?? response.cursor ?? (response.hasMore ? undefined : null)) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to load content.') + } finally { + if (version === requestVersion.current) { + loadingMore.current = false + setLoading(false) + } + } } - } - useEffect(() => { - void Promise.all([listCategories(), listBadges()]) - .then(([nextCategories, nextBadges]) => { setCategories(nextCategories); setBadgeOptions(nextBadges) }) - .catch(() => { setCategories([]); setBadgeOptions([]) }) - }, []) - useEffect(() => { - loadingMore.current = false - setItems([]) - setCursor(undefined) - void load(false, { category, status, badges }) - }, [category, status, badges.join(',')]) - useEffect(() => { - const node = sentinel.current - if (!node) return - const observer = new IntersectionObserver(entries => { if (entries[0]?.isIntersecting) void load(true) }, { rootMargin: '240px' }) - observer.observe(node) - return () => observer.disconnect() - }, [cursor, category, status, badges.join(',')]) - - return
-
$ cd ~/backlog && ls
-
- - - -
- {error &&

{error}

} - {!loading && !error && items.length === 0 &&

No content yet

There is nothing published here yet.

} -
{items.map(item => )}
-
{loading ?

Loading content…

: cursor ?

Loading more…

: items.length > 0 ?

You’ve reached the end.

: null}
-
+ useEffect(() => { + void Promise.all([listCategories(), listBadges()]) + .then(([nextCategories, nextBadges]) => { + setCategories(nextCategories); + setBadgeOptions(nextBadges) + }) + .catch(() => { + setCategories([]); + setBadgeOptions([]) + }) + }, []) + useEffect(() => { + loadingMore.current = false + setItems([]) + setCursor(undefined) + void load(false, {category, status, badges}) + }, [category, status, badges.join(',')]) + useEffect(() => { + const node = sentinel.current + if (!node) return + const observer = new IntersectionObserver(entries => { + if (entries[0]?.isIntersecting) void load(true) + }, {rootMargin: '240px'}) + observer.observe(node) + return () => observer.disconnect() + }, [cursor, category, status, badges.join(',')]) + return +
+
+
$ cd ~/backlog && ls
+
+
+ + + +
+ {error &&

{error}

} + {!loading && !error && items.length === 0 && +

No content yet

There is nothing published + here yet.

} +
{items.map(item => )}
+
{loading ? +

Loading content…

: cursor ? +

Loading more…

: items.length > 0 ? +

You’ve reached the end.

: null}
+
+
} function ContentDetail() { - const { id = '' } = useParams() - const { user } = useAuth() - const canEdit = Boolean(user?.capabilities?.canEdit) - const [item, setItem] = useState(null) - const [editing, setEditing] = useState(false) - const [draft, setDraft] = useState(null) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [error, setError] = useState(null) - const [badgeOptions, setBadgeOptions] = useState([]) - const [badgeInput, setBadgeInput] = useState('') - const navigate = useNavigate() - useEffect(() => { - let active = true - setLoading(true) - void Promise.all([getContent(id), listBadges()]).then(([value, options]) => { - if (active) { - setItem(value) - setBadgeOptions(options) - setDraft({ title: value.title, summary: value.summary, body: value.body, status: value.status, badges: value.badges ?? [] }) - } - }) - .catch(err => active && setError(err instanceof Error ? err.message : 'Unable to load content.')) - .finally(() => active && setLoading(false)) - return () => { active = false } - }, [id]) - if (loading) return

Loading content…

- if (error || !item || !draft) return

{error ?? 'Content not found.'}

Back to content
- const save = async () => { - setSaving(true); setError(null) - try { const saved = await updateContent(id, draft); setItem(saved); setDraft({ title: saved.title, summary: saved.summary, body: saved.body, status: saved.status, badges: saved.badges ?? [] }); setEditing(false) } - catch (err) { setError(err instanceof Error ? err.message : 'Unable to save changes.') } - finally { setSaving(false) } - } - return
- ← All content - {error &&

{error}

} - {editing ?
- -