From fac7588d233da7f4c4d835f9993049b72e2cc854 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Sat, 19 Sep 2026 14:48:42 +0200 Subject: [PATCH] initial commit --- .env.example | 8 ++- .gitignore | 3 +- Makefile | 33 ++++++++++++ README.md | 6 ++- cmd/server/main.go | 57 ++++++++++++++++++--- cmd/server/migrations/001_init.sql | 11 ---- cmd/server/migrations/admin/001_init.sql | 7 +++ cmd/server/migrations/postgres/001_init.sql | 9 ++++ docker-compose.yml | 19 +++++++ go.mod | 8 ++- go.sum | 18 +++++++ internal/config/config.go | 51 ++++++++++++++++-- internal/identity/provider.go | 5 +- internal/persistence/models.go | 8 +++ internal/persistence/models_test.go | 16 ++++++ internal/repository/gorm.go | 3 +- internal/service/auth.go | 25 +++++---- internal/web/handlers.go | 4 +- migrations/001_init.sql | 11 ---- ui/app/.env.example | 3 +- ui/app/vite.config.ts | 16 ++++++ 21 files changed, 266 insertions(+), 55 deletions(-) create mode 100644 Makefile delete mode 100644 cmd/server/migrations/001_init.sql create mode 100644 cmd/server/migrations/admin/001_init.sql create mode 100644 cmd/server/migrations/postgres/001_init.sql create mode 100644 docker-compose.yml create mode 100644 internal/persistence/models_test.go delete mode 100644 migrations/001_init.sql diff --git a/.env.example b/.env.example index 770bee9..fca6b8e 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,11 @@ CMS_HTTP_ADDR=:8080 -CMS_DATABASE_URL=./cms.db +CMS_ADMIN_DATABASE_URL=./cms-admin.db +CMS_APPLICATION_DATABASE_URL="host=localhost user=cms password=cms dbname=cms port=5432 sslmode=disable" CMS_ALLOWED_ORIGINS=http://localhost:5173 CMS_SESSION_TTL=24h + +# Download this file from Google Cloud Console and keep it untracked. +CMS_GOOGLE_CLIENT_FILE=./client_secret_.json CMS_GOOGLE_CLIENT_ID= CMS_GOOGLE_CLIENT_SECRET= -CMS_GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback +CMS_GOOGLE_REDIRECT_URL=http://localhost:5173/api/auth/google/callback diff --git a/.gitignore b/.gitignore index e1a17f1..5c41b40 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,7 @@ bin/ *.sqlite *.sqlite3 .env -.env.* -!.env.example +client_secret*.json # Node node_modules/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2f5f76b --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +SHELL := /bin/sh + +COMPOSE ?= docker compose +GOOSE ?= go run github.com/pressly/goose/v3/cmd/goose@v3.24.3 +POSTGRES_URL ?= postgres://cms:cms@localhost:5432/cms?sslmode=disable +ADMIN_DATABASE_URL ?= ./cms-admin.db + +.PHONY: up down migrate backend frontend dev + +up: + $(COMPOSE) up -d --wait + +down: + $(COMPOSE) down + +migrate: + $(GOOSE) -dir cmd/server/migrations/postgres postgres "$(POSTGRES_URL)" up + $(GOOSE) -dir cmd/server/migrations/admin sqlite3 "$(ADMIN_DATABASE_URL)" up + +backend: + set -a; [ ! -f .env ] || . ./.env; set +a; \ + CMS_ADMIN_DATABASE_URL="$${CMS_ADMIN_DATABASE_URL:-$(ADMIN_DATABASE_URL)}" \ + CMS_APPLICATION_DATABASE_URL="$${CMS_APPLICATION_DATABASE_URL:-host=localhost user=cms password=cms dbname=cms port=5432 sslmode=disable}" \ + go run ./cmd/server + +frontend: + npm --prefix ui/app run dev + +dev: up migrate + @trap 'kill 0' INT TERM; \ + $(MAKE) backend & \ + $(MAKE) frontend & \ + wait diff --git a/README.md b/README.md index bbb367f..b625a67 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,14 @@ This repository contains a Go backend and React frontend for the CMS. ## Backend -The backend targets Go 1.27.1 and uses a layered design (`internal/web`, `service`, `repository`, `persistence`, and `domain`). SQLite is the default local database; Goose migrations run automatically at startup. +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. ```sh go run ./cmd/server ``` -Configuration is read from environment variables (see `.env.example`). 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. +For local development, `make up` starts PostgreSQL, `make migrate` applies both database migrations, `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/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. 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. diff --git a/cmd/server/main.go b/cmd/server/main.go index d7ebd6f..1b8588c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,6 +3,7 @@ package main import ( "context" "embed" + "io/fs" "log" "net/http" @@ -12,31 +13,73 @@ import ( "github.com/example/cms/internal/service" "github.com/example/cms/internal/web" "github.com/pressly/goose/v3" + "github.com/pressly/goose/v3/database" + "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" ) -//go:embed migrations/*.sql +//go:embed migrations/admin/*.sql migrations/postgres/*.sql var migrations embed.FS func main() { - cfg := config.Load() - db, err := gorm.Open(sqlite.Open(cfg.DatabaseURL), &gorm.Config{}) + cfg, err := config.Load() if err != nil { log.Fatal(err) } - sqlDB, err := db.DB() + adminDB, err := gorm.Open(sqlite.Open(cfg.AdminDatabaseURL), &gorm.Config{}) if err != nil { log.Fatal(err) } - provider, err := goose.NewProvider(goose.DialectSQLite3, sqlDB, migrations) + applicationDB, err := gorm.Open(postgres.Open(cfg.ApplicationDatabaseURL), &gorm.Config{}) if err != nil { log.Fatal(err) } - if _, err = provider.Up(context.Background()); err != nil { + adminSQLDB, err := adminDB.DB() + if err != nil { log.Fatal(err) } - auth := service.NewAuthService(repository.NewUserRepository(db), repository.NewAdminRepository(db), repository.NewSessionRepository(db), cfg.SessionTTL) + adminMigrations, err := fs.Sub(migrations, "migrations/admin") + if err != nil { + log.Fatal(err) + } + adminStore, err := database.NewStore(goose.DialectSQLite3, "goose_admin_db_version") + if err != nil { + log.Fatal(err) + } + adminProvider, err := goose.NewProvider("", adminSQLDB, adminMigrations, goose.WithStore(adminStore)) + if err != nil { + log.Fatal(err) + } + if _, err = adminProvider.Up(context.Background()); err != nil { + log.Fatal(err) + } + applicationSQLDB, err := applicationDB.DB() + if err != nil { + log.Fatal(err) + } + applicationMigrations, err := fs.Sub(migrations, "migrations/postgres") + if err != nil { + log.Fatal(err) + } + applicationStore, err := database.NewStore(goose.DialectPostgres, "goose_application_db_version") + if err != nil { + log.Fatal(err) + } + applicationProvider, err := goose.NewProvider("", applicationSQLDB, applicationMigrations, goose.WithStore(applicationStore)) + if err != nil { + log.Fatal(err) + } + if _, err = applicationProvider.Up(context.Background()); err != nil { + log.Fatal(err) + } + auth := service.NewAuthServiceWithSeparateSessions( + repository.NewUserRepository(applicationDB), + repository.NewAdminRepository(adminDB), + repository.NewSessionRepository(applicationDB), + repository.NewSessionRepository(adminDB), + cfg.SessionTTL, + ) google := identity.GoogleProvider{ClientID: cfg.GoogleClientID, ClientSecret: cfg.GoogleClientSecret, RedirectURL: cfg.GoogleRedirectURL} log.Printf("CMS listening on %s", cfg.HTTPAddr) if err = http.ListenAndServe(cfg.HTTPAddr, web.NewHandler(auth, google).Routes(cfg.AllowedOrigins)); err != nil { diff --git a/cmd/server/migrations/001_init.sql b/cmd/server/migrations/001_init.sql deleted file mode 100644 index 578866d..0000000 --- a/cmd/server/migrations/001_init.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL, provider_subject TEXT NOT NULL, created_at DATETIME 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 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 REFERENCES users(id), 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; -DROP TABLE IF EXISTS users; diff --git a/cmd/server/migrations/admin/001_init.sql b/cmd/server/migrations/admin/001_init.sql new file mode 100644 index 0000000..a259f4d --- /dev/null +++ b/cmd/server/migrations/admin/001_init.sql @@ -0,0 +1,7 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS admins (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL); +CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT NOT NULL UNIQUE, user_id INTEGER, admin_id INTEGER REFERENCES admins(id), expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); +-- +goose Down +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS admins; diff --git a/cmd/server/migrations/postgres/001_init.sql b/cmd/server/migrations/postgres/001_init.sql new file mode 100644 index 0000000..a79aa24 --- /dev/null +++ b/cmd/server/migrations/postgres/001_init.sql @@ -0,0 +1,9 @@ +-- +goose Up +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, 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); +-- +goose Down +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS users; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5eda2d9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: cms + POSTGRES_USER: cms + POSTGRES_PASSWORD: cms + ports: + - "5432:5432" + volumes: + - cms-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U cms -d cms"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + cms-postgres-data: diff --git a/go.mod b/go.mod index de29c31..c68e356 100644 --- a/go.mod +++ b/go.mod @@ -4,19 +4,25 @@ go 1.27.1 require ( github.com/pressly/goose/v3 v3.24.3 + golang.org/x/oauth2 v0.30.0 + gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.4 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/crypto v0.38.0 // indirect golang.org/x/sync v0.14.0 // indirect golang.org/x/text v0.25.0 // indirect ) diff --git a/go.sum b/go.sum index c275aae..feaa5a5 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,20 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -26,10 +35,15 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= @@ -40,8 +54,12 @@ golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= diff --git a/internal/config/config.go b/internal/config/config.go index 05b28ed..0c1925e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,17 +1,60 @@ package config import ( + "encoding/json" + "fmt" "os" "time" ) type Config struct { - HTTPAddr, DatabaseURL, AllowedOrigins, GoogleClientID, GoogleClientSecret, GoogleRedirectURL string - SessionTTL time.Duration + HTTPAddr, AdminDatabaseURL, ApplicationDatabaseURL, AllowedOrigins, GoogleClientID, GoogleClientSecret, GoogleRedirectURL string + SessionTTL time.Duration } -func Load() Config { - return Config{HTTPAddr: get("CMS_HTTP_ADDR", ":8080"), DatabaseURL: get("CMS_DATABASE_URL", "./cms.db"), AllowedOrigins: get("CMS_ALLOWED_ORIGINS", "http://localhost:5173"), SessionTTL: duration("CMS_SESSION_TTL", 24*time.Hour), GoogleClientID: os.Getenv("CMS_GOOGLE_CLIENT_ID"), GoogleClientSecret: os.Getenv("CMS_GOOGLE_CLIENT_SECRET"), GoogleRedirectURL: os.Getenv("CMS_GOOGLE_REDIRECT_URL")} +func Load() (Config, error) { + clientID, clientSecret, err := googleCredentials() + if err != nil { + return Config{}, err + } + return Config{ + HTTPAddr: get("CMS_HTTP_ADDR", ":8080"), + AdminDatabaseURL: get("CMS_ADMIN_DATABASE_URL", "./cms-admin.db"), + ApplicationDatabaseURL: get("CMS_APPLICATION_DATABASE_URL", "host=localhost user=cms password=cms dbname=cms port=5432 sslmode=disable"), + AllowedOrigins: get("CMS_ALLOWED_ORIGINS", "http://localhost:5173"), + SessionTTL: duration("CMS_SESSION_TTL", 24*time.Hour), + GoogleClientID: get("CMS_GOOGLE_CLIENT_ID", clientID), + GoogleClientSecret: get("CMS_GOOGLE_CLIENT_SECRET", clientSecret), + GoogleRedirectURL: get("CMS_GOOGLE_REDIRECT_URL", "http://localhost:8080/api/auth/google/callback"), + }, nil +} + +func googleCredentials() (string, string, error) { + path := os.Getenv("CMS_GOOGLE_CLIENT_FILE") + if path == "" { + return "", "", nil + } + data, err := os.ReadFile(path) + if err != nil { + return "", "", fmt.Errorf("read Google OAuth client file: %w", err) + } + var credentials struct { + Web struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + } `json:"web"` + Installed struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + } `json:"installed"` + } + if err := json.Unmarshal(data, &credentials); err != nil { + return "", "", fmt.Errorf("parse Google OAuth client file: %w", err) + } + if credentials.Web.ClientID != "" || credentials.Web.ClientSecret != "" { + return credentials.Web.ClientID, credentials.Web.ClientSecret, nil + } + return credentials.Installed.ClientID, credentials.Installed.ClientSecret, nil } func get(k, d string) string { if v := os.Getenv(k); v != "" { diff --git a/internal/identity/provider.go b/internal/identity/provider.go index b055d14..1d8b1f8 100644 --- a/internal/identity/provider.go +++ b/internal/identity/provider.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "net/http" @@ -37,7 +38,7 @@ func (p GoogleProvider) FetchIdentity(ctx context.Context, code string) (Identit } token, err := p.OAuthConfig().Exchange(ctx, code) if err != nil { - return Identity{}, err + 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 { @@ -50,7 +51,7 @@ func (p GoogleProvider) FetchIdentity(ctx context.Context, code string) (Identit } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return Identity{}, errors.New("google userinfo request failed") + return Identity{}, fmt.Errorf("Google userinfo request failed with status %s", resp.Status) } var profile struct { Subject string `json:"sub"` diff --git a/internal/persistence/models.go b/internal/persistence/models.go index 64e8967..d351cd5 100644 --- a/internal/persistence/models.go +++ b/internal/persistence/models.go @@ -10,12 +10,18 @@ type UserModel struct { ProviderSubject string `gorm:"uniqueIndex;not null"` CreatedAt time.Time } + +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"` @@ -24,3 +30,5 @@ type SessionModel struct { ExpiresAt time.Time CreatedAt time.Time } + +func (SessionModel) TableName() string { return "sessions" } diff --git a/internal/persistence/models_test.go b/internal/persistence/models_test.go new file mode 100644 index 0000000..5f701ad --- /dev/null +++ b/internal/persistence/models_test.go @@ -0,0 +1,16 @@ +package persistence + +import "testing" + +func TestModelsUseMigrationTableNames(t *testing.T) { + tests := map[string]string{ + "users": (UserModel{}).TableName(), + "admins": (AdminModel{}).TableName(), + "sessions": (SessionModel{}).TableName(), + } + for expected, actual := range tests { + if actual != expected { + t.Errorf("table name = %q, want %q", actual, expected) + } + } +} diff --git a/internal/repository/gorm.go b/internal/repository/gorm.go index 3ddae3f..dda3aab 100644 --- a/internal/repository/gorm.go +++ b/internal/repository/gorm.go @@ -44,7 +44,8 @@ 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.SessionFromDomain(d)).Error + model := persistence.SessionFromDomain(d) + return r.db.WithContext(ctx).Create(&model).Error } func (r *GORMSessionRepository) FindValid(ctx context.Context, t string) (domain.Session, error) { var m persistence.SessionModel diff --git a/internal/service/auth.go b/internal/service/auth.go index 1968575..26118e6 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -21,14 +21,19 @@ type IAdminAuthService interface { Authenticate(context.Context, string) (domain.Session, error) } type AuthService struct { - users repository.IUserRepository - admins repository.IAdminRepository - sessions repository.ISessionRepository - ttl time.Duration + users repository.IUserRepository + admins repository.IAdminRepository + userSessions repository.ISessionRepository + adminSessions repository.ISessionRepository + ttl time.Duration } -func NewAuthService(u repository.IUserRepository, a repository.IAdminRepository, s repository.ISessionRepository, ttl time.Duration) *AuthService { - return &AuthService{u, a, s, ttl} +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) == "" { @@ -42,13 +47,13 @@ func (s *AuthService) Login(ctx context.Context, id identity.Identity) (domain.U if e != nil { return domain.User{}, "", e } - if e = s.sessions.Create(ctx, domain.Session{Token: t, UserID: &u.ID, ExpiresAt: time.Now().Add(s.ttl), CreatedAt: time.Now()}); e != nil { + 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.sessions.FindValid(ctx, t) + 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) @@ -59,13 +64,13 @@ func (s *AuthService) AdminLogin(ctx context.Context, email string) (domain.Admi if e != nil { return domain.Admin{}, "", e } - if e = s.sessions.Create(ctx, domain.Session{Token: t, AdminID: &a.ID, ExpiresAt: time.Now().Add(s.ttl), CreatedAt: time.Now()}); e != nil { + 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.Authenticate(ctx, t) + return s.adminSessions.FindValid(ctx, t) } func newToken() (string, error) { b := make([]byte, 32) diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 1eb35dd..fb2c6e0 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -7,6 +7,7 @@ import ( "github.com/example/cms/internal/identity" "github.com/example/cms/internal/service" "github.com/example/cms/internal/web/mapping" + "log" "net/http" "strings" ) @@ -52,7 +53,8 @@ func (h *Handler) googleCallback(w http.ResponseWriter, r *http.Request) { } id, err := h.google.FetchIdentity(r.Context(), r.URL.Query().Get("code")) if err != nil { - http.Error(w, "google authentication failed", http.StatusUnauthorized) + 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) diff --git a/migrations/001_init.sql b/migrations/001_init.sql deleted file mode 100644 index 578866d..0000000 --- a/migrations/001_init.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL, provider_subject TEXT NOT NULL, created_at DATETIME 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 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 REFERENCES users(id), 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; -DROP TABLE IF EXISTS users; diff --git a/ui/app/.env.example b/ui/app/.env.example index 4761b99..fd3ac7e 100644 --- a/ui/app/.env.example +++ b/ui/app/.env.example @@ -1,4 +1,5 @@ -# Leave empty for a same-origin backend. +# Leave empty in development; Vite proxies API requests to localhost:8080. +# Set this to the public backend URL when the frontend is hosted separately. VITE_API_BASE_URL= VITE_GOOGLE_AUTH_ENDPOINT=/api/auth/google VITE_SESSION_ENDPOINT=/api/auth/session diff --git a/ui/app/vite.config.ts b/ui/app/vite.config.ts index 9ffcc67..3184e23 100644 --- a/ui/app/vite.config.ts +++ b/ui/app/vite.config.ts @@ -3,4 +3,20 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + '/admin': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + '/health': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, })