initial commit
This commit is contained in:
parent
b50c054b30
commit
fac7588d23
21 changed files with 266 additions and 55 deletions
|
|
@ -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_<client-id>.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
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -7,8 +7,7 @@ bin/
|
|||
*.sqlite
|
||||
*.sqlite3
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
client_secret*.json
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
|
|
|||
33
Makefile
Normal file
33
Makefile
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
7
cmd/server/migrations/admin/001_init.sql
Normal file
7
cmd/server/migrations/admin/001_init.sql
Normal 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;
|
||||
9
cmd/server/migrations/postgres/001_init.sql
Normal file
9
cmd/server/migrations/postgres/001_init.sql
Normal 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
19
docker-compose.yml
Normal 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
8
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
|
||||
)
|
||||
|
|
|
|||
18
go.sum
18
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=
|
||||
|
|
|
|||
|
|
@ -1,17 +1,60 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr, DatabaseURL, AllowedOrigins, GoogleClientID, GoogleClientSecret, GoogleRedirectURL string
|
||||
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 != "" {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
16
internal/persistence/models_test.go
Normal file
16
internal/persistence/models_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,12 +23,17 @@ type IAdminAuthService interface {
|
|||
type AuthService struct {
|
||||
users repository.IUserRepository
|
||||
admins repository.IAdminRepository
|
||||
sessions repository.ISessionRepository
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue