changing layout
This commit is contained in:
parent
17b5f751e2
commit
b1c6aa659b
37 changed files with 695 additions and 1018 deletions
18
.env.example
18
.env.example
|
|
@ -1,11 +1,15 @@
|
||||||
CMS_HTTP_ADDR=:8080
|
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_APPLICATION_DATABASE_URL="host=localhost user=cms password=cms dbname=cms port=5432 sslmode=disable"
|
||||||
CMS_ALLOWED_ORIGINS=http://localhost:5173
|
CMS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
CMS_SESSION_TTL=24h
|
|
||||||
|
|
||||||
# Download this file from Google Cloud Console and keep it untracked.
|
# Single sign-on. The CMS does not sign anyone in: an identity-aware proxy
|
||||||
CMS_GOOGLE_CLIENT_FILE=./client_secret_<client-id>.json
|
# (oauth2-proxy, Authelia, Cloudflare Access, ...) authenticates the owner and
|
||||||
CMS_GOOGLE_CLIENT_ID=
|
# forwards a header on every accepted request. Requests without it are anonymous.
|
||||||
CMS_GOOGLE_CLIENT_SECRET=
|
#
|
||||||
CMS_GOOGLE_REDIRECT_URL=http://localhost:5173/api/auth/google/callback
|
# 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=
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -2,12 +2,11 @@
|
||||||
bin/
|
bin/
|
||||||
*.test
|
*.test
|
||||||
|
|
||||||
# Local databases and secrets
|
# Local environment and data
|
||||||
|
.env
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
.env
|
|
||||||
client_secret*.json
|
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
@ -18,7 +17,3 @@ ui/app/.vite/
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Local backend data
|
|
||||||
*.db
|
|
||||||
*.db-*
|
|
||||||
|
|
|
||||||
3
Makefile
3
Makefile
|
|
@ -3,7 +3,6 @@ SHELL := /bin/sh
|
||||||
COMPOSE ?= docker compose
|
COMPOSE ?= docker compose
|
||||||
GOOSE ?= go run github.com/pressly/goose/v3/cmd/goose@v3.24.3
|
GOOSE ?= go run github.com/pressly/goose/v3/cmd/goose@v3.24.3
|
||||||
POSTGRES_URL ?= postgres://cms:cms@localhost:5432/cms?sslmode=disable
|
POSTGRES_URL ?= postgres://cms:cms@localhost:5432/cms?sslmode=disable
|
||||||
ADMIN_DATABASE_URL ?= ./cms-admin.db
|
|
||||||
|
|
||||||
.PHONY: up down migrate seed backend frontend dev
|
.PHONY: up down migrate seed backend frontend dev
|
||||||
|
|
||||||
|
|
@ -15,14 +14,12 @@ down:
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
$(GOOSE) -dir cmd/server/migrations/postgres postgres "$(POSTGRES_URL)" up
|
$(GOOSE) -dir cmd/server/migrations/postgres postgres "$(POSTGRES_URL)" up
|
||||||
$(GOOSE) -dir cmd/server/migrations/admin sqlite3 "$(ADMIN_DATABASE_URL)" up
|
|
||||||
|
|
||||||
seed:
|
seed:
|
||||||
psql "$(POSTGRES_URL)" -v ON_ERROR_STOP=1 -f scripts/seed_content.sql
|
psql "$(POSTGRES_URL)" -v ON_ERROR_STOP=1 -f scripts/seed_content.sql
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
set -a; [ ! -f .env ] || . ./.env; set +a; \
|
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}" \
|
CMS_APPLICATION_DATABASE_URL="$${CMS_APPLICATION_DATABASE_URL:-host=localhost user=cms password=cms dbname=cms port=5432 sslmode=disable}" \
|
||||||
go run ./cmd/server
|
go run ./cmd/server
|
||||||
|
|
||||||
|
|
|
||||||
30
README.md
30
README.md
|
|
@ -4,16 +4,36 @@ This repository contains a Go backend and React frontend for the CMS.
|
||||||
|
|
||||||
## Backend
|
## 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
|
```sh
|
||||||
go run ./cmd/server
|
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
|
||||||
|
```
|
||||||
|
|
@ -8,52 +8,25 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/example/cms/internal/config"
|
"github.com/example/cms/internal/config"
|
||||||
"github.com/example/cms/internal/identity"
|
|
||||||
"github.com/example/cms/internal/repository"
|
"github.com/example/cms/internal/repository"
|
||||||
|
"github.com/example/cms/internal/security"
|
||||||
"github.com/example/cms/internal/service"
|
"github.com/example/cms/internal/service"
|
||||||
"github.com/example/cms/internal/web"
|
"github.com/example/cms/internal/web"
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
"github.com/pressly/goose/v3/database"
|
"github.com/pressly/goose/v3/database"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed migrations/admin/*.sql migrations/postgres/*.sql
|
//go:embed migrations/postgres/*.sql
|
||||||
var migrations embed.FS
|
var migrations embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg := config.Load()
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
adminDB, err := gorm.Open(sqlite.Open(cfg.AdminDatabaseURL), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
applicationDB, err := gorm.Open(postgres.Open(cfg.ApplicationDatabaseURL), &gorm.Config{})
|
applicationDB, err := gorm.Open(postgres.Open(cfg.ApplicationDatabaseURL), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
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()
|
applicationSQLDB, err := applicationDB.DB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|
@ -73,17 +46,10 @@ func main() {
|
||||||
if _, err = applicationProvider.Up(context.Background()); err != nil {
|
if _, err = applicationProvider.Up(context.Background()); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
auth := service.NewAuthServiceWithSeparateSessions(
|
content := service.NewContentService(repository.NewContentRepository(applicationDB))
|
||||||
repository.NewUserRepository(applicationDB),
|
sso := security.NewSSOTokenVerifier(cfg.SSOHeader, cfg.SSOToken, cfg.SSOCookie)
|
||||||
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}
|
|
||||||
log.Printf("CMS listening on %s", cfg.HTTPAddr)
|
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)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
35
cmd/server/migrations/postgres/004_sso.sql
Normal file
35
cmd/server/migrations/postgres/004_sso.sql
Normal file
|
|
@ -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;
|
||||||
4
go.mod
4
go.mod
|
|
@ -4,21 +4,17 @@ go 1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/pressly/goose/v3 v3.24.3
|
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/postgres v1.6.0
|
||||||
gorm.io/driver/sqlite v1.6.0
|
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.7.4 // indirect
|
github.com/jackc/pgx/v5 v5.7.4 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // 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/mfridman/interpolate v0.0.2 // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
|
|
||||||
8
go.sum
8
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
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/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 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
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 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
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=
|
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 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
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 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
|
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,30 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
"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 {
|
type Config struct {
|
||||||
HTTPAddr, AdminDatabaseURL, ApplicationDatabaseURL, AllowedOrigins, GoogleClientID, GoogleClientSecret, GoogleRedirectURL string
|
HTTPAddr, ApplicationDatabaseURL string
|
||||||
SessionTTL time.Duration
|
AllowedOrigins, SSOHeader, SSOToken, SSOCookie string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() Config {
|
||||||
clientID, clientSecret, err := googleCredentials()
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
return Config{
|
return Config{
|
||||||
HTTPAddr: get("CMS_HTTP_ADDR", ":8080"),
|
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"),
|
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"),
|
AllowedOrigins: get("CMS_ALLOWED_ORIGINS", "http://localhost:5173"),
|
||||||
SessionTTL: duration("CMS_SESSION_TTL", 24*time.Hour),
|
SSOHeader: get("CMS_SSO_HEADER", "X-SSO-Token"),
|
||||||
GoogleClientID: get("CMS_GOOGLE_CLIENT_ID", clientID),
|
SSOToken: get("CMS_SSO_TOKEN", ""),
|
||||||
GoogleClientSecret: get("CMS_GOOGLE_CLIENT_SECRET", clientSecret),
|
SSOCookie: get("CMS_SSO_COOKIE", ""),
|
||||||
GoogleRedirectURL: get("CMS_GOOGLE_REDIRECT_URL", "http://localhost:8080/api/auth/google/callback"),
|
}
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
func get(k, d string) string {
|
||||||
if v := os.Getenv(k); v != "" {
|
if v := os.Getenv(k); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return d
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,27 +2,6 @@ package domain
|
||||||
|
|
||||||
import "time"
|
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 {
|
type Content struct {
|
||||||
ID uint
|
ID uint
|
||||||
Slug, Title, Summary, Body string
|
Slug, Title, Summary, Body string
|
||||||
|
|
@ -32,7 +11,6 @@ type Content struct {
|
||||||
Category string
|
Category string
|
||||||
Badges []string
|
Badges []string
|
||||||
CreatedAt, UpdatedAt time.Time
|
CreatedAt, UpdatedAt time.Time
|
||||||
CreatedBy, UpdatedBy uint
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Category struct {
|
type Category struct {
|
||||||
|
|
@ -50,8 +28,6 @@ type ContentHistory struct {
|
||||||
ID uint
|
ID uint
|
||||||
ContentID uint
|
ContentID uint
|
||||||
Operation string
|
Operation string
|
||||||
ActorID *uint
|
|
||||||
ActorAdminID *uint
|
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Snapshot string
|
Snapshot string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,28 +2,14 @@ package persistence
|
||||||
|
|
||||||
import "github.com/example/cms/internal/domain"
|
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 {
|
func ContentToDomain(m ContentModel) domain.Content {
|
||||||
badges := make([]string, len(m.Badges))
|
badges := make([]string, len(m.Badges))
|
||||||
for i := range m.Badges {
|
for i := range m.Badges {
|
||||||
badges[i] = m.Badges[i].Name
|
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 {
|
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}
|
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}
|
||||||
}
|
|
||||||
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}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,17 +2,6 @@ package persistence
|
||||||
|
|
||||||
import "time"
|
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 {
|
type ContentModel struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
Slug string `gorm:"uniqueIndex;not null"`
|
Slug string `gorm:"uniqueIndex;not null"`
|
||||||
|
|
@ -25,9 +14,10 @@ type ContentModel struct {
|
||||||
Category CategoryModel `gorm:"foreignKey:CategoryID"`
|
Category CategoryModel `gorm:"foreignKey:CategoryID"`
|
||||||
Badges []BadgeModel `gorm:"many2many:content_badges;joinForeignKey:ContentID;joinReferences:BadgeID"`
|
Badges []BadgeModel `gorm:"many2many:content_badges;joinForeignKey:ContentID;joinReferences:BadgeID"`
|
||||||
CreatedAt, UpdatedAt time.Time
|
CreatedAt, UpdatedAt time.Time
|
||||||
CreatedBy, UpdatedBy uint
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ContentModel) TableName() string { return "content" }
|
||||||
|
|
||||||
type CategoryModel struct {
|
type CategoryModel struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
Slug string `gorm:"uniqueIndex;not null"`
|
Slug string `gorm:"uniqueIndex;not null"`
|
||||||
|
|
@ -43,38 +33,12 @@ type BadgeModel struct {
|
||||||
|
|
||||||
func (BadgeModel) TableName() string { return "badges" }
|
func (BadgeModel) TableName() string { return "badges" }
|
||||||
|
|
||||||
func (ContentModel) TableName() string { return "content" }
|
|
||||||
|
|
||||||
type ContentHistoryModel struct {
|
type ContentHistoryModel struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
ContentID uint `gorm:"index;not null"`
|
ContentID uint `gorm:"index;not null"`
|
||||||
Operation string `gorm:"not null"`
|
Operation string `gorm:"not null"`
|
||||||
ActorID *uint
|
|
||||||
ActorAdminID *uint
|
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Snapshot string `gorm:"type:jsonb;not null"`
|
Snapshot string `gorm:"type:jsonb;not null"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ContentHistoryModel) TableName() string { return "content_history" }
|
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" }
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
package persistence
|
package persistence
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/example/cms/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
func TestModelsUseMigrationTableNames(t *testing.T) {
|
func TestModelsUseMigrationTableNames(t *testing.T) {
|
||||||
tests := map[string]string{
|
tests := map[string]string{
|
||||||
"users": (UserModel{}).TableName(),
|
"content": (ContentModel{}).TableName(),
|
||||||
"admins": (AdminModel{}).TableName(),
|
"content_history": (ContentHistoryModel{}).TableName(),
|
||||||
"sessions": (SessionModel{}).TableName(),
|
"categories": (CategoryModel{}).TableName(),
|
||||||
|
"badges": (BadgeModel{}).TableName(),
|
||||||
}
|
}
|
||||||
for expected, actual := range tests {
|
for expected, actual := range tests {
|
||||||
if actual != expected {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,64 +2,11 @@ package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"github.com/example/cms/internal/domain"
|
"github.com/example/cms/internal/domain"
|
||||||
"github.com/example/cms/internal/persistence"
|
"github.com/example/cms/internal/persistence"
|
||||||
"gorm.io/gorm"
|
"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 }
|
type GORMContentRepository struct{ db *gorm.DB }
|
||||||
|
|
||||||
func NewContentRepository(db *gorm.DB) IContentRepository { return &GORMContentRepository{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 {
|
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Exec("DELETE FROM content_badges WHERE content_id = ?", d.ID).Error; err != nil {
|
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 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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,6 @@ import (
|
||||||
"github.com/example/cms/internal/domain"
|
"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 {
|
type IContentRepository interface {
|
||||||
List(context.Context, int, int, string, string, []string) ([]domain.Content, error)
|
List(context.Context, int, int, string, string, []string) ([]domain.Content, error)
|
||||||
ListCategories(context.Context) ([]domain.Category, error)
|
ListCategories(context.Context) ([]domain.Category, error)
|
||||||
|
|
|
||||||
73
internal/security/token.go
Normal file
73
internal/security/token.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
42
internal/security/token_test.go
Normal file
42
internal/security/token_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -11,20 +11,21 @@ import (
|
||||||
"time"
|
"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 {
|
type IContentService interface {
|
||||||
List(context.Context, int, int, string, string, []string) ([]domain.Content, error)
|
List(context.Context, int, int, string, string, []string) ([]domain.Content, error)
|
||||||
ListCategories(context.Context) ([]domain.Category, error)
|
ListCategories(context.Context) ([]domain.Category, error)
|
||||||
ListBadges(context.Context) ([]domain.Badge, error)
|
ListBadges(context.Context) ([]domain.Badge, error)
|
||||||
Get(context.Context, string) (domain.Content, 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 {
|
type ContentService struct {
|
||||||
repo repository.IContentRepository
|
repo repository.IContentRepository
|
||||||
auth *AuthService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewContentService(r repository.IContentRepository, a *AuthService) *ContentService {
|
func NewContentService(r repository.IContentRepository) *ContentService {
|
||||||
return &ContentService{repo: r, auth: a}
|
return &ContentService{repo: r}
|
||||||
}
|
}
|
||||||
func (s *ContentService) List(ctx context.Context, limit, offset int, category, status string, badges []string) ([]domain.Content, error) {
|
func (s *ContentService) List(ctx context.Context, limit, offset int, category, status string, badges []string) ([]domain.Content, error) {
|
||||||
if limit < 1 {
|
if limit < 1 {
|
||||||
|
|
@ -50,10 +51,7 @@ func (s *ContentService) Get(ctx context.Context, slug string) (domain.Content,
|
||||||
}
|
}
|
||||||
return s.repo.FindBySlug(ctx, slug)
|
return s.repo.FindBySlug(ctx, slug)
|
||||||
}
|
}
|
||||||
func (s *ContentService) Update(ctx context.Context, c domain.Content, session domain.Session) (domain.Content, error) {
|
func (s *ContentService) Update(ctx context.Context, c domain.Content) (domain.Content, error) {
|
||||||
if s.auth == nil || !s.auth.CanEdit(ctx, session) {
|
|
||||||
return domain.Content{}, errors.New("forbidden")
|
|
||||||
}
|
|
||||||
if !validSlug(c.Slug) || strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" {
|
if !validSlug(c.Slug) || strings.TrimSpace(c.Title) == "" || strings.TrimSpace(c.Body) == "" {
|
||||||
return domain.Content{}, errors.New("invalid content")
|
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.ID = old.ID
|
||||||
c.CreatedAt = old.CreatedAt
|
c.CreatedAt = old.CreatedAt
|
||||||
c.CreatedBy = old.CreatedBy
|
|
||||||
c.UpdatedAt = time.Now()
|
c.UpdatedAt = time.Now()
|
||||||
c.Badges = normalizeBadges(c.Badges)
|
c.Badges = normalizeBadges(c.Badges)
|
||||||
if session.UserID != nil {
|
|
||||||
c.UpdatedBy = *session.UserID
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot, _ := json.Marshal(c)
|
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 {
|
if err = s.repo.Update(ctx, c, h); err != nil {
|
||||||
return domain.Content{}, err
|
return domain.Content{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,172 +1,55 @@
|
||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"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"
|
"net/http"
|
||||||
"strings"
|
"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 {
|
type Handler struct {
|
||||||
auth *service.AuthService
|
|
||||||
google identity.GoogleProvider
|
|
||||||
content service.IContentService
|
content service.IContentService
|
||||||
|
sso security.ITokenVerifier
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(a *service.AuthService, google identity.GoogleProvider) *Handler {
|
func NewHandler(content service.IContentService, sso security.ITokenVerifier) *Handler {
|
||||||
return &Handler{auth: a, google: google}
|
return &Handler{content: content, sso: sso}
|
||||||
}
|
|
||||||
func NewHandlerWithContent(a *service.AuthService, google identity.GoogleProvider, content service.IContentService) *Handler {
|
|
||||||
return &Handler{auth: a, google: google, content: content}
|
|
||||||
}
|
}
|
||||||
func (h *Handler) Routes(origins string) http.Handler {
|
func (h *Handler) Routes(origins string) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { jsonWrite(w, 200, map[string]string{"status": "ok"}) })
|
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("/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/protected", h.protected)
|
||||||
|
mux.HandleFunc("/api/test/protected", h.protected)
|
||||||
mux.HandleFunc("/api/content", h.contentList)
|
mux.HandleFunc("/api/content", h.contentList)
|
||||||
mux.HandleFunc("/api/content/categories", h.contentCategories)
|
mux.HandleFunc("/api/content/categories", h.contentCategories)
|
||||||
mux.HandleFunc("/api/content/badges", h.contentBadges)
|
mux.HandleFunc("/api/content/badges", h.contentBadges)
|
||||||
mux.HandleFunc("/api/content/", h.contentDetail)
|
mux.HandleFunc("/api/content/", h.contentDetail)
|
||||||
return cors(mux, origins)
|
return cors(mux, origins, h.sso.HeaderName())
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
|
||||||
token := bearerOrCookie(r)
|
authenticated := h.sso.Authenticated(r)
|
||||||
if token == "" {
|
jsonWrite(w, http.StatusOK, map[string]any{"authenticated": authenticated, "canEdit": authenticated})
|
||||||
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})
|
|
||||||
}
|
}
|
||||||
func (h *Handler) protected(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) protected(w http.ResponseWriter, r *http.Request) {
|
||||||
t := bearerOrCookie(r)
|
if !h.sso.Authenticated(r) {
|
||||||
if t == "" {
|
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
||||||
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"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonWrite(w, 200, map[string]string{"status": "authenticated"})
|
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) {
|
func (h *Handler) contentList(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.content == nil {
|
if h.content == nil {
|
||||||
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
|
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := h.sessionForRequest(r); !ok {
|
if r.Method != http.MethodGet {
|
||||||
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method != "GET" {
|
|
||||||
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -199,10 +82,6 @@ func (h *Handler) contentCategories(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
|
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := h.sessionForRequest(r); !ok {
|
|
||||||
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
||||||
return
|
return
|
||||||
|
|
@ -219,10 +98,6 @@ func (h *Handler) contentBadges(w http.ResponseWriter, r *http.Request) {
|
||||||
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
|
jsonWrite(w, 503, map[string]string{"error": "content unavailable"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := h.sessionForRequest(r); !ok {
|
|
||||||
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
jsonWrite(w, http.StatusMethodNotAllowed, nil)
|
||||||
return
|
return
|
||||||
|
|
@ -240,11 +115,7 @@ func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slug := strings.TrimPrefix(r.URL.Path, "/api/content/")
|
slug := strings.TrimPrefix(r.URL.Path, "/api/content/")
|
||||||
if r.Method == "GET" {
|
if r.Method == http.MethodGet {
|
||||||
if _, ok := h.sessionForRequest(r); !ok {
|
|
||||||
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c, e := h.content.Get(r.Context(), slug)
|
c, e := h.content.Get(r.Context(), slug)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
jsonWrite(w, 404, map[string]string{"error": "content not found"})
|
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)
|
jsonWrite(w, 200, c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.Method != "PUT" {
|
if r.Method != http.MethodPut {
|
||||||
jsonWrite(w, 405, nil)
|
jsonWrite(w, 405, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s, ok := h.sessionForRequest(r)
|
if !h.sso.Authenticated(r) {
|
||||||
if !ok {
|
|
||||||
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
jsonWrite(w, 401, map[string]string{"error": "authentication required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !h.auth.CanEdit(r.Context(), s) {
|
|
||||||
jsonWrite(w, 403, map[string]string{"error": "forbidden"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var c domain.Content
|
var c domain.Content
|
||||||
if !decode(r, &c) {
|
if !decode(r, &c) {
|
||||||
jsonWrite(w, 400, map[string]string{"error": "invalid JSON"})
|
jsonWrite(w, 400, map[string]string{"error": "invalid JSON"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Slug = slug
|
c.Slug = slug
|
||||||
out, e := h.content.Update(r.Context(), c, s)
|
out, e := h.content.Update(r.Context(), c)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
jsonWrite(w, 400, map[string]string{"error": e.Error()})
|
jsonWrite(w, 400, map[string]string{"error": e.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonWrite(w, 200, out)
|
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 decode(r *http.Request, v any) bool { return json.NewDecoder(r.Body).Decode(v) == nil }
|
||||||
func jsonWrite(w http.ResponseWriter, status int, v any) {
|
func jsonWrite(w http.ResponseWriter, status int, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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)
|
_ = 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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
w.Header().Set("Vary", "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")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
||||||
if r.Method == "OPTIONS" {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(204)
|
w.WriteHeader(204)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
internal/web/handlers_test.go
Normal file
81
internal/web/handlers_test.go
Normal file
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
|
||||||
}
|
|
||||||
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# Leave empty in development; Vite proxies API requests to localhost:8080.
|
# Leave empty in development; Vite proxies API requests to localhost:8080.
|
||||||
# Set this to the public backend URL when the frontend is hosted separately.
|
# 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_API_BASE_URL=
|
||||||
VITE_GOOGLE_AUTH_ENDPOINT=/api/auth/google
|
|
||||||
VITE_SESSION_ENDPOINT=/api/auth/session
|
VITE_SESSION_ENDPOINT=/api/auth/session
|
||||||
VITE_LOGOUT_ENDPOINT=/api/auth/logout
|
VITE_CONTENT_ENDPOINT=/api/content
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@
|
||||||
This Vite/React app lives in `ui/app` and is intentionally independent of the Go backend. Run
|
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`.
|
`npm install`, then `npm run dev` or `npm run build`.
|
||||||
|
|
||||||
The API defaults to same-origin `/api/auth/session`, `/api/auth/google`, and
|
There is no sign-in screen. Content is public and read-only for anonymous visitors; the Edit
|
||||||
`/api/auth/logout`. Set `VITE_API_BASE_URL` for a separate backend, or override the endpoint
|
button appears only when the backend reports `canEdit: true`, which happens when the upstream SSO
|
||||||
paths with `VITE_SESSION_ENDPOINT`, `VITE_GOOGLE_AUTH_ENDPOINT`, and `VITE_LOGOUT_ENDPOINT`.
|
proxy asserted the owner's identity on the request. `/api/auth/session` returns
|
||||||
The Google endpoint may return JSON containing `user` and an optional `token`/`accessToken`, or a
|
`{ "authenticated": bool, "canEdit": bool }` and never creates a session.
|
||||||
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
|
The API defaults to same-origin `/api/auth/session` and `/api/content`. Set `VITE_API_BASE_URL`
|
||||||
added without changing the routes or UI.
|
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.
|
||||||
|
|
@ -1,47 +1,46 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import {useEffect, useRef, useState} from 'react'
|
||||||
import { Link, Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
|
import {Link, Navigate, Route, Routes, useParams} from 'react-router-dom'
|
||||||
import { getContent, listBadges, listCategories, listContent, updateContent, type Badge, type Category, type Content, type ContentUpdate } from './api'
|
import {
|
||||||
import { useAuth } from './auth'
|
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 }) {
|
function Shell({children}: { children: React.ReactNode }) {
|
||||||
const { user, signOut } = useAuth()
|
const {authenticated, loading} = useAuth()
|
||||||
const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light')
|
const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||||
window.localStorage.setItem('urpi-theme', dark ? 'dark' : 'light')
|
window.localStorage.setItem('urpi-theme', dark ? 'dark' : 'light')
|
||||||
}, [dark])
|
}, [dark])
|
||||||
return <main className="shell">
|
return <main className="shell">
|
||||||
<header className="header"><Link className="brand" to="/"><span className="brand-prompt">~/</span> Urpi's backlog<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link><div className="header-actions"><button className="theme-button" type="button" aria-label={`Switch to ${dark ? 'light' : 'dark'} theme`} onClick={() => setDark(value => !value)}>{dark ? '☀ Light' : '☾ Dark'}</button>{user && <button className="text-button" onClick={() => void signOut()}>Sign out</button>}</div></header>
|
<header className="header"><Link className="brand" to="/content"><span className="brand-prompt">~/</span> Urpi's
|
||||||
|
backlog<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link>
|
||||||
|
<div className="header-actions">{!loading &&
|
||||||
|
<span className="mode-badge">{authenticated ? 'Owner · SSO' : 'Anonymous · read-only'}</span>}
|
||||||
|
<button className="theme-button" type="button" aria-label={`Switch to ${dark ? 'light' : 'dark'} theme`}
|
||||||
|
onClick={() => setDark(value => !value)}>{dark ? '☀ Light' : '☾ Dark'}</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
function Login() {
|
function ContentCard({item}: { item: Content }) {
|
||||||
const { user, loading, error, signIn } = useAuth()
|
|
||||||
if (user) return <Navigate to="/protected" replace />
|
|
||||||
if (loading) return <Shell><div className="card"><p>Checking your session…</p></div></Shell>
|
|
||||||
return <Shell><section className="card auth-card">
|
|
||||||
<span className="eyebrow">Welcome, developer</span><h1>Sign in to Urpi's backlog</h1>
|
|
||||||
<p className="muted">A cozy backlog for Linux, Go, Java, and the ideas between them.</p>
|
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
|
||||||
<button className="google-button" onClick={() => void signIn()}><span aria-hidden="true">G</span> Continue with Google</button>
|
|
||||||
</section></Shell>
|
|
||||||
}
|
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
|
||||||
const { user, loading } = useAuth()
|
|
||||||
const location = useLocation()
|
|
||||||
if (loading) return <Shell><div className="card"><p>Loading…</p></div></Shell>
|
|
||||||
if (!user) return <Navigate to="/" state={{ from: location }} replace />
|
|
||||||
return <>{children}</>
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContentCard({ item }: { item: Content }) {
|
|
||||||
return <article className="content-card">
|
return <article className="content-card">
|
||||||
<span className="status">{item.status}</span>
|
<span className="status">{item.status}</span>
|
||||||
<h2><Link to={`/content/${item.id}`}>{item.title}</Link></h2>
|
<h2><Link to={`/content/${item.id}`}>{item.title}</Link></h2>
|
||||||
<p>{item.summary}</p>
|
<p>{item.summary}</p>
|
||||||
{item.badges && item.badges.length > 0 && <div className="badge-list">{item.badges.map(badge => <span className="badge" key={badge}>{badge}</span>)}</div>}
|
{item.badges && item.badges.length > 0 &&
|
||||||
|
<div className="badge-list">{item.badges.map(badge => <span className="badge"
|
||||||
|
key={badge}>{badge}</span>)}</div>}
|
||||||
<Link className="read-link" to={`/content/${item.id}`}>Read more <span aria-hidden="true">→</span></Link>
|
<Link className="read-link" to={`/content/${item.id}`}>Read more <span aria-hidden="true">→</span></Link>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +59,7 @@ function ContentList() {
|
||||||
const loadingMore = useRef(false)
|
const loadingMore = useRef(false)
|
||||||
const requestVersion = useRef(0)
|
const requestVersion = useRef(0)
|
||||||
const sentinel = useRef<HTMLDivElement>(null)
|
const sentinel = useRef<HTMLDivElement>(null)
|
||||||
const filters = { category, status, badges }
|
const filters = {category, status, badges}
|
||||||
|
|
||||||
const load = async (next = false, activeFilters = filters) => {
|
const load = async (next = false, activeFilters = filters) => {
|
||||||
if (loadingMore.current || (next && !cursor)) return
|
if (loadingMore.current || (next && !cursor)) return
|
||||||
|
|
@ -85,25 +84,35 @@ function ContentList() {
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void Promise.all([listCategories(), listBadges()])
|
void Promise.all([listCategories(), listBadges()])
|
||||||
.then(([nextCategories, nextBadges]) => { setCategories(nextCategories); setBadgeOptions(nextBadges) })
|
.then(([nextCategories, nextBadges]) => {
|
||||||
.catch(() => { setCategories([]); setBadgeOptions([]) })
|
setCategories(nextCategories);
|
||||||
|
setBadgeOptions(nextBadges)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setCategories([]);
|
||||||
|
setBadgeOptions([])
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadingMore.current = false
|
loadingMore.current = false
|
||||||
setItems([])
|
setItems([])
|
||||||
setCursor(undefined)
|
setCursor(undefined)
|
||||||
void load(false, { category, status, badges })
|
void load(false, {category, status, badges})
|
||||||
}, [category, status, badges.join(',')])
|
}, [category, status, badges.join(',')])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = sentinel.current
|
const node = sentinel.current
|
||||||
if (!node) return
|
if (!node) return
|
||||||
const observer = new IntersectionObserver(entries => { if (entries[0]?.isIntersecting) void load(true) }, { rootMargin: '240px' })
|
const observer = new IntersectionObserver(entries => {
|
||||||
|
if (entries[0]?.isIntersecting) void load(true)
|
||||||
|
}, {rootMargin: '240px'})
|
||||||
observer.observe(node)
|
observer.observe(node)
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [cursor, category, status, badges.join(',')])
|
}, [cursor, category, status, badges.join(',')])
|
||||||
|
return <Shell>
|
||||||
return <Shell><section className="content-page">
|
<section className="content-page">
|
||||||
<div className="page-heading"><div><span className="eyebrow">$ cd ~/backlog && ls</span></div></div>
|
<div className="page-heading">
|
||||||
|
<div><span className="eyebrow">$ cd ~/backlog && ls</span></div>
|
||||||
|
</div>
|
||||||
<div className="filters" aria-label="Content filters">
|
<div className="filters" aria-label="Content filters">
|
||||||
<label className="status-filter">Status
|
<label className="status-filter">Status
|
||||||
<select value={status} onChange={event => setStatus(event.target.value)}>
|
<select value={status} onChange={event => setStatus(event.target.value)}>
|
||||||
|
|
@ -122,29 +131,39 @@ function ContentList() {
|
||||||
</label>
|
</label>
|
||||||
<label className="badge-filter">Badges
|
<label className="badge-filter">Badges
|
||||||
<div className="badge-input">
|
<div className="badge-input">
|
||||||
{badges.map(badge => <span className="filter-pill" key={badge}>{badge}<button type="button" aria-label={`Remove ${badge}`} onClick={() => setBadges(current => current.filter(value => value !== badge))}>×</button></span>)}
|
{badges.map(badge => <span className="filter-pill" key={badge}>{badge}
|
||||||
<input list="badge-options" value={badgeInput} placeholder="Type a badge and press Enter" onChange={event => setBadgeInput(event.target.value)} onKeyDown={event => {
|
<button type="button" aria-label={`Remove ${badge}`}
|
||||||
|
onClick={() => setBadges(current => current.filter(value => value !== badge))}>×</button></span>)}
|
||||||
|
<input list="badge-options" value={badgeInput} placeholder="Type a badge and press Enter"
|
||||||
|
onChange={event => setBadgeInput(event.target.value)} onKeyDown={event => {
|
||||||
if (event.key !== 'Enter') return
|
if (event.key !== 'Enter') return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const badge = badgeInput.trim().toLowerCase()
|
const badge = badgeInput.trim().toLowerCase()
|
||||||
if (badge && !badges.includes(badge)) setBadges(current => [...current, badge])
|
if (badge && !badges.includes(badge)) setBadges(current => [...current, badge])
|
||||||
setBadgeInput('')
|
setBadgeInput('')
|
||||||
}} />
|
}}/>
|
||||||
<datalist id="badge-options">{badgeOptions.filter(item => !badges.includes(item.name)).map(item => <option value={item.name} key={item.id}>{item.name}</option>)}</datalist>
|
<datalist
|
||||||
|
id="badge-options">{badgeOptions.filter(item => !badges.includes(item.name)).map(item =>
|
||||||
|
<option value={item.name} key={item.id}>{item.name}</option>)}</datalist>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
{error && <p className="error" role="alert">{error}</p>}
|
||||||
{!loading && !error && items.length === 0 && <div className="card empty-state"><h2>No content yet</h2><p className="muted">There is nothing published here yet.</p></div>}
|
{!loading && !error && items.length === 0 &&
|
||||||
<div className="content-grid">{items.map(item => <ContentCard item={item} key={item.id} />)}</div>
|
<div className="card empty-state"><h2>No content yet</h2><p className="muted">There is nothing published
|
||||||
<div ref={sentinel} className="list-status" aria-live="polite">{loading ? <p className="muted">Loading content…</p> : cursor ? <p className="muted">Loading more…</p> : items.length > 0 ? <p className="muted">You’ve reached the end.</p> : null}</div>
|
here yet.</p></div>}
|
||||||
</section></Shell>
|
<div className="content-grid">{items.map(item => <ContentCard item={item} key={item.id}/>)}</div>
|
||||||
|
<div ref={sentinel} className="list-status" aria-live="polite">{loading ?
|
||||||
|
<p className="muted">Loading content…</p> : cursor ?
|
||||||
|
<p className="muted">Loading more…</p> : items.length > 0 ?
|
||||||
|
<p className="muted">You’ve reached the end.</p> : null}</div>
|
||||||
|
</section>
|
||||||
|
</Shell>
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContentDetail() {
|
function ContentDetail() {
|
||||||
const { id = '' } = useParams()
|
const {id = ''} = useParams()
|
||||||
const { user } = useAuth()
|
const {canEdit} = useAuth()
|
||||||
const canEdit = Boolean(user?.capabilities?.canEdit)
|
|
||||||
const [item, setItem] = useState<Content | null>(null)
|
const [item, setItem] = useState<Content | null>(null)
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [draft, setDraft] = useState<ContentUpdate | null>(null)
|
const [draft, setDraft] = useState<ContentUpdate | null>(null)
|
||||||
|
|
@ -153,7 +172,6 @@ function ContentDetail() {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [badgeOptions, setBadgeOptions] = useState<Badge[]>([])
|
const [badgeOptions, setBadgeOptions] = useState<Badge[]>([])
|
||||||
const [badgeInput, setBadgeInput] = useState('')
|
const [badgeInput, setBadgeInput] = useState('')
|
||||||
const navigate = useNavigate()
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -161,59 +179,119 @@ function ContentDetail() {
|
||||||
if (active) {
|
if (active) {
|
||||||
setItem(value)
|
setItem(value)
|
||||||
setBadgeOptions(options)
|
setBadgeOptions(options)
|
||||||
setDraft({ title: value.title, summary: value.summary, body: value.body, status: value.status, badges: value.badges ?? [] })
|
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.'))
|
.catch(err => active && setError(err instanceof Error ? err.message : 'Unable to load content.'))
|
||||||
.finally(() => active && setLoading(false))
|
.finally(() => active && setLoading(false))
|
||||||
return () => { active = false }
|
return () => {
|
||||||
}, [id])
|
active = false
|
||||||
if (loading) return <Shell><div className="card"><p>Loading content…</p></div></Shell>
|
|
||||||
if (error || !item || !draft) return <Shell><div className="card"><p className="error" role="alert">{error ?? 'Content not found.'}</p><Link to="/content">Back to content</Link></div></Shell>
|
|
||||||
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 <Shell><article className="detail-page">
|
}, [id])
|
||||||
|
if (loading) return <Shell>
|
||||||
|
<div className="card"><p>Loading content…</p></div>
|
||||||
|
</Shell>
|
||||||
|
if (error || !item || !draft) return <Shell>
|
||||||
|
<div className="card"><p className="error" role="alert">{error ?? 'Content not found.'}</p><Link to="/content">Back
|
||||||
|
to content</Link></div>
|
||||||
|
</Shell>
|
||||||
|
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 <Shell>
|
||||||
|
<article className="detail-page">
|
||||||
<Link className="back-link" to="/content">← All content</Link>
|
<Link className="back-link" to="/content">← All content</Link>
|
||||||
{error && <p className="error" role="alert">{error}</p>}
|
{error && <p className="error" role="alert">{error}</p>}
|
||||||
{editing ? <div className="editor">
|
{editing ? <div className="editor">
|
||||||
<label>Title<input value={draft.title} onChange={e => setDraft({ ...draft, title: e.target.value })} /></label>
|
<label>Title<input value={draft.title}
|
||||||
<label>Summary<textarea rows={3} value={draft.summary} onChange={e => setDraft({ ...draft, summary: e.target.value })} /></label>
|
onChange={e => setDraft({...draft, title: e.target.value})}/></label>
|
||||||
<label>Body<textarea rows={12} value={draft.body} onChange={e => setDraft({ ...draft, body: e.target.value })} /></label>
|
<label>Summary<textarea rows={3} value={draft.summary}
|
||||||
<label>Status<select value={draft.status} onChange={e => setDraft({ ...draft, status: e.target.value })}><option value="draft">Draft</option><option value="published">Published</option><option value="archived">Archived</option></select></label>
|
onChange={e => setDraft({...draft, summary: e.target.value})}/></label>
|
||||||
|
<label>Body<textarea rows={12} value={draft.body}
|
||||||
|
onChange={e => setDraft({...draft, body: e.target.value})}/></label>
|
||||||
|
<label>Status<select value={draft.status} onChange={e => setDraft({...draft, status: e.target.value})}>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="published">Published</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select></label>
|
||||||
<label>Badges
|
<label>Badges
|
||||||
<div className="badge-input">
|
<div className="badge-input">
|
||||||
{(draft.badges ?? []).map(badge => <span className="filter-pill" key={badge}>{badge}<button type="button" aria-label={`Remove ${badge}`} onClick={() => setDraft({ ...draft, badges: (draft.badges ?? []).filter(value => value !== badge) })}>×</button></span>)}
|
{(draft.badges ?? []).map(badge => <span className="filter-pill" key={badge}>{badge}
|
||||||
<input list="detail-badge-options" value={badgeInput} placeholder="Type a badge and press Enter" onChange={e => setBadgeInput(e.target.value)} onKeyDown={e => {
|
<button type="button" aria-label={`Remove ${badge}`} onClick={() => setDraft({
|
||||||
|
...draft,
|
||||||
|
badges: (draft.badges ?? []).filter(value => value !== badge)
|
||||||
|
})}>×</button></span>)}
|
||||||
|
<input list="detail-badge-options" value={badgeInput} placeholder="Type a badge and press Enter"
|
||||||
|
onChange={e => setBadgeInput(e.target.value)} onKeyDown={e => {
|
||||||
if (e.key !== 'Enter') return
|
if (e.key !== 'Enter') return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const badge = badgeInput.trim().toLowerCase()
|
const badge = badgeInput.trim().toLowerCase()
|
||||||
if (badge && !(draft.badges ?? []).includes(badge)) setDraft({ ...draft, badges: [...(draft.badges ?? []), badge] })
|
if (badge && !(draft.badges ?? []).includes(badge)) setDraft({
|
||||||
|
...draft,
|
||||||
|
badges: [...(draft.badges ?? []), badge]
|
||||||
|
})
|
||||||
setBadgeInput('')
|
setBadgeInput('')
|
||||||
}} />
|
}}/>
|
||||||
<datalist id="detail-badge-options">{badgeOptions.filter(option => !(draft.badges ?? []).includes(option.name)).map(option => <option value={option.name} key={option.id} />)}</datalist>
|
<datalist
|
||||||
|
id="detail-badge-options">{badgeOptions.filter(option => !(draft.badges ?? []).includes(option.name)).map(option =>
|
||||||
|
<option value={option.name} key={option.id}/>)}</datalist>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<div className="actions"><button className="primary-button" disabled={saving} onClick={() => void save()}>{saving ? 'Saving…' : 'Save changes'}</button><button className="secondary-button" onClick={() => setEditing(false)}>Cancel</button></div>
|
<div className="actions">
|
||||||
</div> : <><div className="detail-heading"><span className="status">{item.status}</span><h1>{item.title}</h1><p className="muted">{item.summary}</p>{item.badges && item.badges.length > 0 && <div className="badge-list">{item.badges.map(badge => <span className="badge" key={badge}>{badge}</span>)}</div>}{canEdit && <button className="primary-button edit-button" onClick={() => setEditing(true)}>Edit</button>}</div><div className="body-copy">{item.body}</div></>}
|
<button className="primary-button" disabled={saving}
|
||||||
</article></Shell>
|
onClick={() => void save()}>{saving ? 'Saving…' : 'Save changes'}</button>
|
||||||
|
<button className="secondary-button" onClick={() => setEditing(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div> : <>
|
||||||
|
<div className="detail-heading"><span className="status">{item.status}</span><h1>{item.title}</h1><p
|
||||||
|
className="muted">{item.summary}</p>{item.badges && item.badges.length > 0 &&
|
||||||
|
<div className="badge-list">{item.badges.map(badge => <span className="badge"
|
||||||
|
key={badge}>{badge}</span>)}</div>}{canEdit &&
|
||||||
|
<button className="primary-button edit-button" onClick={() => setEditing(true)}>Edit</button>}</div>
|
||||||
|
<div className="body-copy">{item.body}</div>
|
||||||
|
</>}
|
||||||
|
</article>
|
||||||
|
</Shell>
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProtectedTest() {
|
function ProtectedTest() {
|
||||||
const { user } = useAuth()
|
return <Shell>
|
||||||
return <Shell><section className="card protected-card"><span className="eyebrow">Authenticated</span><h1>Protected content</h1><p>You are signed in and can access protected content.</p><div className="profile">{user?.email ?? user?.name ? `Signed in as ${user.email ?? user.name}` : 'Your session is active.'}</div><Link className="primary-button inline-button" to="/content">Browse content</Link></section></Shell>
|
<section className="card protected-card"><span className="eyebrow">Owner session</span><h1>Protected
|
||||||
|
content</h1><p>Your SSO proxy asserted the owner identity for this request.</p><Link
|
||||||
|
className="primary-button inline-button" to="/content">Browse content</Link></section>
|
||||||
|
</Shell>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return <Routes>
|
return <Routes>
|
||||||
<Route path="/" element={<Login />} />
|
<Route path="/" element={<Navigate to="/content" replace/>}/>
|
||||||
<Route path="/protected" element={<RequireAuth><ProtectedTest /></RequireAuth>} />
|
<Route path="/protected" element={<ProtectedTest/>}/>
|
||||||
<Route path="/test" element={<RequireAuth><ProtectedTest /></RequireAuth>} />
|
<Route path="/test" element={<ProtectedTest/>}/>
|
||||||
<Route path="/content" element={<RequireAuth><ContentList /></RequireAuth>} />
|
<Route path="/content" element={<ContentList/>}/>
|
||||||
<Route path="/content/:id" element={<RequireAuth><ContentDetail /></RequireAuth>} />
|
<Route path="/content/:id" element={<ContentDetail/>}/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/content" replace/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,16 @@ export type User = {
|
||||||
capabilities?: Record<string, boolean>
|
capabilities?: Record<string, boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The backend never signs users in: an upstream SSO proxy asserts the owner's
|
||||||
|
// identity. The session endpoint only reports whether that identity is present.
|
||||||
export type SessionResponse = {
|
export type SessionResponse = {
|
||||||
authenticated?: boolean
|
authenticated?: boolean
|
||||||
user?: User
|
|
||||||
capabilities?: Record<string, boolean>
|
|
||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
token?: string
|
|
||||||
accessToken?: string
|
|
||||||
sessionToken?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
|
const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
|
||||||
export const apiConfig = {
|
export const apiConfig = {
|
||||||
google: import.meta.env.VITE_GOOGLE_AUTH_ENDPOINT ?? '/api/auth/google',
|
|
||||||
session: import.meta.env.VITE_SESSION_ENDPOINT ?? '/api/auth/session',
|
session: import.meta.env.VITE_SESSION_ENDPOINT ?? '/api/auth/session',
|
||||||
logout: import.meta.env.VITE_LOGOUT_ENDPOINT ?? '/api/auth/logout',
|
|
||||||
content: import.meta.env.VITE_CONTENT_ENDPOINT ?? '/api/content',
|
content: import.meta.env.VITE_CONTENT_ENDPOINT ?? '/api/content',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,32 +29,13 @@ export async function requestSession(): Promise<SessionResponse | null> {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { Accept: 'application/json' },
|
headers: { Accept: 'application/json' },
|
||||||
})
|
})
|
||||||
if (response.status === 401 || response.status === 404) return null
|
if (response.status === 404) return null
|
||||||
if (!response.ok) throw new Error('Unable to check your session.')
|
if (!response.ok) throw new Error('Unable to check your session.')
|
||||||
const value = (await response.json()) as SessionResponse & { CanEdit?: boolean; User?: User }
|
const value = (await response.json()) as SessionResponse & { CanEdit?: boolean }
|
||||||
if (value.User && !value.user) value.user = value.User
|
|
||||||
if (value.canEdit === undefined && value.CanEdit !== undefined) value.canEdit = value.CanEdit
|
if (value.canEdit === undefined && value.CanEdit !== undefined) value.canEdit = value.CanEdit
|
||||||
if (value.user) value.user = normalizeUser(value.user)
|
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUser(value: User & { ID?: string; Email?: string; Name?: string; Editor?: boolean }): User {
|
|
||||||
return { id: value.id ?? value.ID, email: value.email ?? value.Email, name: value.name ?? value.Name, picture: value.picture, capabilities: { ...(value.capabilities ?? {}), ...(value.Editor === true ? { canEdit: true } : {}) } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signInWithGoogle(): Promise<SessionResponse> {
|
|
||||||
window.location.assign(apiUrl(apiConfig.google))
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signOut() {
|
|
||||||
await fetch(apiUrl(apiConfig.logout), {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Content = {
|
export type Content = {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -94,7 +70,7 @@ async function contentRequest<T>(path = '', init?: RequestInit): Promise<T> {
|
||||||
headers: { Accept: 'application/json', ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers },
|
headers: { Accept: 'application/json', ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers },
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 401) throw new Error('Please sign in to view content.')
|
if (response.status === 401) throw new Error('Your SSO session is required to edit content.')
|
||||||
throw new Error((await response.text()) || 'Unable to load content.')
|
throw new Error((await response.text()) || 'Unable to load content.')
|
||||||
}
|
}
|
||||||
return (await response.json()) as T
|
return (await response.json()) as T
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,21 @@
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { requestSession, signInWithGoogle, signOut, type User } from './api'
|
import { requestSession } from './api'
|
||||||
|
|
||||||
|
// There is no sign-in or sign-out: the upstream SSO proxy decides whether the
|
||||||
|
// current request carries the owner's identity.
|
||||||
type AuthState = {
|
type AuthState = {
|
||||||
user: User | null
|
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
authenticated: boolean
|
||||||
|
canEdit: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
signIn: () => Promise<void>
|
refresh: () => Promise<void>
|
||||||
signOut: () => Promise<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthState | undefined>(undefined)
|
const AuthContext = createContext<AuthState | undefined>(undefined)
|
||||||
|
|
||||||
function getUser(result: { authenticated?: boolean; user?: User; capabilities?: Record<string, boolean>; canEdit?: boolean; token?: string; accessToken?: string; sessionToken?: string }) {
|
|
||||||
const token = result.token ?? result.accessToken ?? result.sessionToken
|
|
||||||
if (token) sessionStorage.setItem('cms.auth.token', token)
|
|
||||||
if (result.authenticated === false) return null
|
|
||||||
if (!result.user && !token && result.authenticated !== true) return null
|
|
||||||
return { ...(result.user ?? {}), capabilities: { ...result.capabilities, ...(result.canEdit !== undefined ? { canEdit: result.canEdit } : {}), ...result.user?.capabilities } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [authenticated, setAuthenticated] = useState(false)
|
||||||
|
const [canEdit, setCanEdit] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -28,10 +23,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
try {
|
try {
|
||||||
setError(null)
|
setError(null)
|
||||||
const session = await requestSession()
|
const session = await requestSession()
|
||||||
setUser(session ? getUser(session) : null)
|
setAuthenticated(session?.authenticated === true)
|
||||||
|
setCanEdit(session?.canEdit === true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setUser(null)
|
setAuthenticated(false)
|
||||||
setError(err instanceof Error ? err.message : 'Unable to check your session.')
|
setCanEdit(false)
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to check your SSO session.')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -39,23 +36,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
useEffect(() => { void refresh() }, [refresh])
|
useEffect(() => { void refresh() }, [refresh])
|
||||||
|
|
||||||
const signIn = useCallback(async () => {
|
const value = useMemo(() => ({ loading, authenticated, canEdit, error, refresh }), [loading, authenticated, canEdit, error, refresh])
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const result = await signInWithGoogle()
|
|
||||||
setUser(getUser(result))
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Sign-in failed.')
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const logOut = useCallback(async () => {
|
|
||||||
await signOut().catch(() => undefined)
|
|
||||||
sessionStorage.removeItem('cms.auth.token')
|
|
||||||
setUser(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const value = useMemo(() => ({ user, loading, error, signIn, signOut: logOut }), [user, loading, error, signIn, logOut])
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,18 +25,14 @@ button, select { cursor: pointer; }
|
||||||
.text-button:hover { border-color: #89b4fa; color: #cdd6f4; }
|
.text-button:hover { border-color: #89b4fa; color: #cdd6f4; }
|
||||||
.theme-button { border: 1px solid #45475a; border-radius: 8px; background: #313244; color: #bac2de; padding: 8px 12px; }
|
.theme-button { border: 1px solid #45475a; border-radius: 8px; background: #313244; color: #bac2de; padding: 8px 12px; }
|
||||||
.theme-button:hover { border-color: #cba6f7; color: #f5e0e8; }
|
.theme-button:hover { border-color: #cba6f7; color: #f5e0e8; }
|
||||||
|
.mode-badge { border: 1px solid #45475a; border-radius: 999px; padding: 6px 11px; color: #a6adc8; background: #242438; font-size: .7rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
||||||
.card { margin: 10vh auto 0; max-width: 500px; padding: 42px; background: #242438; border: 1px solid #45475a; border-radius: 16px; box-shadow: 0 18px 50px #11111b66; }
|
.card { margin: 10vh auto 0; max-width: 500px; padding: 42px; background: #242438; border: 1px solid #45475a; border-radius: 16px; box-shadow: 0 18px 50px #11111b66; }
|
||||||
.auth-card { text-align: center; }
|
|
||||||
h1 { margin: 10px 0 12px; color: #f5e0e8; font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -.04em; }
|
h1 { margin: 10px 0 12px; color: #f5e0e8; font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -.04em; }
|
||||||
p { line-height: 1.65; }
|
p { line-height: 1.65; }
|
||||||
.muted { color: #a6adc8; }
|
.muted { color: #a6adc8; }
|
||||||
.small { font-size: .875rem; }
|
.small { font-size: .875rem; }
|
||||||
.eyebrow { color: #cba6f7; font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
.eyebrow { color: #cba6f7; font-size: .75rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||||
.google-button, .secondary-button { width: 100%; min-height: 48px; border-radius: 9px; cursor: pointer; font-weight: 650; }
|
.secondary-button { width: 100%; min-height: 48px; margin-top: 20px; border: 0; border-radius: 9px; background: #45475a; color: #cdd6f4; cursor: pointer; font-weight: 650; }
|
||||||
.google-button { margin-top: 22px; border: 1px solid #585b70; color: #cdd6f4; background: #313244; }
|
|
||||||
.google-button:hover { background: #45475a; border-color: #89b4fa; }
|
|
||||||
.google-button span { margin-right: 10px; color: #89b4fa; font-weight: 800; }
|
|
||||||
.secondary-button { margin-top: 20px; border: 0; background: #45475a; color: #cdd6f4; }
|
|
||||||
.error { padding: 10px 12px; border-radius: 8px; color: #f38ba8; background: #3b2634; font-size: .9rem; }
|
.error { padding: 10px 12px; border-radius: 8px; color: #f38ba8; background: #3b2634; font-size: .9rem; }
|
||||||
.profile { margin-top: 24px; padding: 14px; border-radius: 8px; background: #313244; color: #a6adc8; }
|
.profile { margin-top: 24px; padding: 14px; border-radius: 8px; background: #313244; color: #a6adc8; }
|
||||||
.content-page, .detail-page { padding-top: 5vh; }
|
.content-page, .detail-page { padding-top: 5vh; }
|
||||||
|
|
@ -97,6 +93,7 @@ html[data-theme="light"] .text-button,
|
||||||
html[data-theme="light"] .theme-button { border-color: #ccd0da; background: #e6e9ef; color: #5c5f77; }
|
html[data-theme="light"] .theme-button { border-color: #ccd0da; background: #e6e9ef; color: #5c5f77; }
|
||||||
html[data-theme="light"] .text-button:hover,
|
html[data-theme="light"] .text-button:hover,
|
||||||
html[data-theme="light"] .theme-button:hover { border-color: #8839ef; color: #4c4f69; }
|
html[data-theme="light"] .theme-button:hover { border-color: #8839ef; color: #4c4f69; }
|
||||||
|
html[data-theme="light"] .mode-badge { border-color: #ccd0da; background: #e6e9ef; color: #6c6f85; }
|
||||||
html[data-theme="light"] .card,
|
html[data-theme="light"] .card,
|
||||||
html[data-theme="light"] .filters,
|
html[data-theme="light"] .filters,
|
||||||
html[data-theme="light"] .content-card { background: #ffffff; border-color: #ccd0da; box-shadow: 0 12px 34px #4c4f6914; }
|
html[data-theme="light"] .content-card { background: #ffffff; border-color: #ccd0da; box-shadow: 0 12px 34px #4c4f6914; }
|
||||||
|
|
@ -105,13 +102,11 @@ html[data-theme="light"] .content-card h2 a { color: #4c4f69; }
|
||||||
html[data-theme="light"] .muted,
|
html[data-theme="light"] .muted,
|
||||||
html[data-theme="light"] .content-card p { color: #6c6f85; }
|
html[data-theme="light"] .content-card p { color: #6c6f85; }
|
||||||
html[data-theme="light"] .eyebrow { color: #8839ef; }
|
html[data-theme="light"] .eyebrow { color: #8839ef; }
|
||||||
html[data-theme="light"] .google-button,
|
|
||||||
html[data-theme="light"] .filters select,
|
html[data-theme="light"] .filters select,
|
||||||
html[data-theme="light"] .badge-input,
|
html[data-theme="light"] .badge-input,
|
||||||
html[data-theme="light"] .editor input,
|
html[data-theme="light"] .editor input,
|
||||||
html[data-theme="light"] .editor textarea,
|
html[data-theme="light"] .editor textarea,
|
||||||
html[data-theme="light"] .editor select { border-color: #ccd0da; background: #eff1f5; color: #4c4f69; }
|
html[data-theme="light"] .editor select { border-color: #ccd0da; background: #eff1f5; color: #4c4f69; }
|
||||||
html[data-theme="light"] .google-button:hover { background: #e6e9ef; border-color: #1e66f5; }
|
|
||||||
html[data-theme="light"] .profile,
|
html[data-theme="light"] .profile,
|
||||||
html[data-theme="light"] .secondary-button,
|
html[data-theme="light"] .secondary-button,
|
||||||
html[data-theme="light"] .actions .secondary-button { background: #e6e9ef; color: #5c5f77; }
|
html[data-theme="light"] .actions .secondary-button { background: #e6e9ef; color: #5c5f77; }
|
||||||
|
|
|
||||||
3
ui/app/src/vite-env.d.ts
vendored
3
ui/app/src/vite-env.d.ts
vendored
|
|
@ -2,9 +2,8 @@
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_BASE_URL?: string
|
readonly VITE_API_BASE_URL?: string
|
||||||
readonly VITE_GOOGLE_AUTH_ENDPOINT?: string
|
|
||||||
readonly VITE_SESSION_ENDPOINT?: string
|
readonly VITE_SESSION_ENDPOINT?: string
|
||||||
readonly VITE_LOGOUT_ENDPOINT?: string
|
readonly VITE_CONTENT_ENDPOINT?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,6 @@ export default defineConfig({
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/admin': {
|
|
||||||
target: 'http://localhost:8080',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/health': {
|
'/health': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|
|
||||||
|
|
@ -58,3 +58,28 @@ repositories, migrations, and pages.
|
||||||
- Exposed the authenticated user and generic capabilities in the session
|
- Exposed the authenticated user and generic capabilities in the session
|
||||||
response; no admin link was added to user-facing navigation.
|
response; no admin link was added to user-facing navigation.
|
||||||
- Revalidated with `go test ./...`, `npm run typecheck`, and `npm run build`.
|
- Revalidated with `go test ./...`, `npm run typecheck`, and `npm run build`.
|
||||||
|
|
||||||
|
## 2026-09-19 — Single sign-on
|
||||||
|
|
||||||
|
### Prompt
|
||||||
|
|
||||||
|
Change the security model: the owner is the only person who enters the application, so use SSO.
|
||||||
|
When the token is absent the visitor is anonymous; when it is present the owner can change the
|
||||||
|
application. Remove sign-in entirely, along with the admin database and the setup.
|
||||||
|
|
||||||
|
### Tasks completed
|
||||||
|
|
||||||
|
- Replaced the Google OAuth flow, direct login, and admin login with a single SSO seam: the
|
||||||
|
upstream identity-aware proxy authenticates the owner and the backend verifies the forwarded
|
||||||
|
header (`internal/security`).
|
||||||
|
- Removed the identity package, the auth service, the SQLite admin database, the admin migrations,
|
||||||
|
the admin login route, and every Google/OAuth setting and credential file.
|
||||||
|
- Reads are anonymous; `PUT /api/content/:slug` returns `401` unless the SSO header is present.
|
||||||
|
- Added the `GET /api/auth/session` capability response (`authenticated`, `canEdit`), which is
|
||||||
|
`false`/`false` for anonymous visitors and `true`/`true` for the owner.
|
||||||
|
- Dropped `users`, `sessions`, and all actor columns from PostgreSQL through migration `004_sso`;
|
||||||
|
content history keeps its snapshot timeline without an actor.
|
||||||
|
- Reworked the React app: no sign-in screen or sign-out button, public content browsing, an
|
||||||
|
SSO/anonymous mode badge in the header, and an Edit button gated on `canEdit`.
|
||||||
|
- Added backend tests for anonymous read-only access and proxy-authenticated editing.
|
||||||
|
- Revalidated with `gofmt`, `go build ./...`, `go test ./...`, `npm run typecheck`, and `npm run build`.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue