initial commit

This commit is contained in:
Daniel Noulet 2026-09-19 14:48:42 +02:00
parent b50c054b30
commit fac7588d23
21 changed files with 266 additions and 55 deletions

View file

@ -1,7 +1,11 @@
CMS_HTTP_ADDR=:8080 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_ALLOWED_ORIGINS=http://localhost:5173
CMS_SESSION_TTL=24h CMS_SESSION_TTL=24h
# Download this file from Google Cloud Console and keep it untracked.
CMS_GOOGLE_CLIENT_FILE=./client_secret_<client-id>.json
CMS_GOOGLE_CLIENT_ID= CMS_GOOGLE_CLIENT_ID=
CMS_GOOGLE_CLIENT_SECRET= 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

3
.gitignore vendored
View file

@ -7,8 +7,7 @@ bin/
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
.env .env
.env.* client_secret*.json
!.env.example
# Node # Node
node_modules/ node_modules/

33
Makefile Normal file
View file

@ -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

View file

@ -4,12 +4,14 @@ 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`). 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 ```sh
go run ./cmd/server 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. 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.

View file

@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"embed" "embed"
"io/fs"
"log" "log"
"net/http" "net/http"
@ -12,31 +13,73 @@ import (
"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"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
) )
//go:embed migrations/*.sql //go:embed migrations/admin/*.sql migrations/postgres/*.sql
var migrations embed.FS var migrations embed.FS
func main() { func main() {
cfg := config.Load() cfg, err := config.Load()
db, err := gorm.Open(sqlite.Open(cfg.DatabaseURL), &gorm.Config{})
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
sqlDB, err := db.DB() adminDB, err := gorm.Open(sqlite.Open(cfg.AdminDatabaseURL), &gorm.Config{})
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
provider, err := goose.NewProvider(goose.DialectSQLite3, sqlDB, migrations) applicationDB, err := gorm.Open(postgres.Open(cfg.ApplicationDatabaseURL), &gorm.Config{})
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
if _, err = provider.Up(context.Background()); err != nil { adminSQLDB, err := adminDB.DB()
if err != nil {
log.Fatal(err) 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} 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.NewHandler(auth, google).Routes(cfg.AllowedOrigins)); err != nil { if err = http.ListenAndServe(cfg.HTTPAddr, web.NewHandler(auth, google).Routes(cfg.AllowedOrigins)); err != nil {

View file

@ -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;

View file

@ -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;

View file

@ -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;

19
docker-compose.yml Normal file
View file

@ -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:

8
go.mod
View file

@ -4,19 +4,25 @@ 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/sqlite 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 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/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/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
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/sync v0.14.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/text v0.25.0 // indirect
) )

18
go.sum
View file

@ -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 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 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 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=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 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/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 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= 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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 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 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 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/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 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 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 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/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= 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=

View file

@ -1,17 +1,60 @@
package config package config
import ( import (
"encoding/json"
"fmt"
"os" "os"
"time" "time"
) )
type Config struct { type Config struct {
HTTPAddr, DatabaseURL, AllowedOrigins, GoogleClientID, GoogleClientSecret, GoogleRedirectURL string HTTPAddr, AdminDatabaseURL, ApplicationDatabaseURL, AllowedOrigins, GoogleClientID, GoogleClientSecret, GoogleRedirectURL string
SessionTTL time.Duration SessionTTL time.Duration
} }
func Load() Config { func Load() (Config, error) {
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")} 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 { func get(k, d string) string {
if v := os.Getenv(k); v != "" { if v := os.Getenv(k); v != "" {

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/oauth2/google" "golang.org/x/oauth2/google"
"net/http" "net/http"
@ -37,7 +38,7 @@ func (p GoogleProvider) FetchIdentity(ctx context.Context, code string) (Identit
} }
token, err := p.OAuthConfig().Exchange(ctx, code) token, err := p.OAuthConfig().Exchange(ctx, code)
if err != nil { 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) req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://openidconnect.googleapis.com/v1/userinfo", nil)
if err != nil { if err != nil {
@ -50,7 +51,7 @@ func (p GoogleProvider) FetchIdentity(ctx context.Context, code string) (Identit
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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 { var profile struct {
Subject string `json:"sub"` Subject string `json:"sub"`

View file

@ -10,12 +10,18 @@ type UserModel struct {
ProviderSubject string `gorm:"uniqueIndex;not null"` ProviderSubject string `gorm:"uniqueIndex;not null"`
CreatedAt time.Time CreatedAt time.Time
} }
func (UserModel) TableName() string { return "users" }
type AdminModel struct { type AdminModel struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
Email string `gorm:"uniqueIndex;not null"` Email string `gorm:"uniqueIndex;not null"`
Name string Name string
CreatedAt time.Time CreatedAt time.Time
} }
func (AdminModel) TableName() string { return "admins" }
type SessionModel struct { type SessionModel struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
Token string `gorm:"uniqueIndex;not null"` Token string `gorm:"uniqueIndex;not null"`
@ -24,3 +30,5 @@ type SessionModel struct {
ExpiresAt time.Time ExpiresAt time.Time
CreatedAt time.Time CreatedAt time.Time
} }
func (SessionModel) TableName() string { return "sessions" }

View file

@ -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)
}
}
}

View file

@ -44,7 +44,8 @@ type GORMSessionRepository struct{ db *gorm.DB }
func NewSessionRepository(db *gorm.DB) ISessionRepository { return &GORMSessionRepository{db} } func NewSessionRepository(db *gorm.DB) ISessionRepository { return &GORMSessionRepository{db} }
func (r *GORMSessionRepository) Create(ctx context.Context, d domain.Session) error { 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) { func (r *GORMSessionRepository) FindValid(ctx context.Context, t string) (domain.Session, error) {
var m persistence.SessionModel var m persistence.SessionModel

View file

@ -21,14 +21,19 @@ type IAdminAuthService interface {
Authenticate(context.Context, string) (domain.Session, error) Authenticate(context.Context, string) (domain.Session, error)
} }
type AuthService struct { type AuthService struct {
users repository.IUserRepository users repository.IUserRepository
admins repository.IAdminRepository admins repository.IAdminRepository
sessions repository.ISessionRepository userSessions repository.ISessionRepository
ttl time.Duration adminSessions repository.ISessionRepository
ttl time.Duration
} }
func NewAuthService(u repository.IUserRepository, a repository.IAdminRepository, s repository.ISessionRepository, ttl time.Duration) *AuthService { func NewAuthService(u repository.IUserRepository, a repository.IAdminRepository, sessions repository.ISessionRepository, ttl time.Duration) *AuthService {
return &AuthService{u, a, s, ttl} 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) { func (s *AuthService) Login(ctx context.Context, id identity.Identity) (domain.User, string, error) {
if strings.TrimSpace(id.Email) == "" || strings.TrimSpace(id.Subject) == "" { 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 { if e != nil {
return domain.User{}, "", e 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 domain.User{}, "", e
} }
return u, t, nil return u, t, nil
} }
func (s *AuthService) Authenticate(ctx context.Context, t string) (domain.Session, error) { 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) { func (s *AuthService) AdminLogin(ctx context.Context, email string) (domain.Admin, string, error) {
a, e := s.admins.FindByEmail(ctx, email) 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 { if e != nil {
return domain.Admin{}, "", e 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 domain.Admin{}, "", e
} }
return a, t, nil return a, t, nil
} }
func (s *AuthService) AdminAuthenticate(ctx context.Context, t string) (domain.Session, error) { 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) { func newToken() (string, error) {
b := make([]byte, 32) b := make([]byte, 32)

View file

@ -7,6 +7,7 @@ import (
"github.com/example/cms/internal/identity" "github.com/example/cms/internal/identity"
"github.com/example/cms/internal/service" "github.com/example/cms/internal/service"
"github.com/example/cms/internal/web/mapping" "github.com/example/cms/internal/web/mapping"
"log"
"net/http" "net/http"
"strings" "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")) id, err := h.google.FetchIdentity(r.Context(), r.URL.Query().Get("code"))
if err != nil { 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 return
} }
user, token, err := h.auth.Login(r.Context(), id) user, token, err := h.auth.Login(r.Context(), id)

View file

@ -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;

View file

@ -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_API_BASE_URL=
VITE_GOOGLE_AUTH_ENDPOINT=/api/auth/google VITE_GOOGLE_AUTH_ENDPOINT=/api/auth/google
VITE_SESSION_ENDPOINT=/api/auth/session VITE_SESSION_ENDPOINT=/api/auth/session

View file

@ -3,4 +3,20 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], 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,
},
},
},
}) })