Initialize CMS application scaffold
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
commit
b50c054b30
39 changed files with 3040 additions and 0 deletions
7
.env.example
Normal file
7
.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
CMS_HTTP_ADDR=:8080
|
||||||
|
CMS_DATABASE_URL=./cms.db
|
||||||
|
CMS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
CMS_SESSION_TTL=24h
|
||||||
|
CMS_GOOGLE_CLIENT_ID=
|
||||||
|
CMS_GOOGLE_CLIENT_SECRET=
|
||||||
|
CMS_GOOGLE_REDIRECT_URL=http://localhost:8080/auth/google/callback
|
||||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Go
|
||||||
|
bin/
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Local databases and secrets
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
ui/app/dist/
|
||||||
|
ui/app/.vite/
|
||||||
|
|
||||||
|
# IDE and OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Local backend data
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
15
README.md
Normal file
15
README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# CMS project
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
|
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.
|
||||||
45
cmd/server/main.go
Normal file
45
cmd/server/main.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/example/cms/internal/config"
|
||||||
|
"github.com/example/cms/internal/identity"
|
||||||
|
"github.com/example/cms/internal/repository"
|
||||||
|
"github.com/example/cms/internal/service"
|
||||||
|
"github.com/example/cms/internal/web"
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrations embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
db, err := gorm.Open(sqlite.Open(cfg.DatabaseURL), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
provider, err := goose.NewProvider(goose.DialectSQLite3, sqlDB, migrations)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = provider.Up(context.Background()); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
auth := service.NewAuthService(repository.NewUserRepository(db), repository.NewAdminRepository(db), repository.NewSessionRepository(db), 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 {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
cmd/server/migrations/001_init.sql
Normal file
11
cmd/server/migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- +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;
|
||||||
22
go.mod
Normal file
22
go.mod
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
module github.com/example/cms
|
||||||
|
|
||||||
|
go 1.27.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/pressly/goose/v3 v3.24.3
|
||||||
|
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/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/sync v0.14.0 // indirect
|
||||||
|
golang.org/x/text v0.25.0 // indirect
|
||||||
|
)
|
||||||
56
go.sum
Normal file
56
go.sum
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
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.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/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=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
|
||||||
|
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
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/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/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||||
|
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||||
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
|
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||||
|
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
|
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
|
||||||
|
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||||
|
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||||
|
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||||
29
internal/config/config.go
Normal file
29
internal/config/config.go
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
HTTPAddr, DatabaseURL, 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 get(k, d string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
func duration(k string, d time.Duration) time.Duration {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
if n, e := time.ParseDuration(v); e == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
22
internal/domain/models.go
Normal file
22
internal/domain/models.go
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID uint
|
||||||
|
Email, Name string
|
||||||
|
Provider, ProviderSubject string
|
||||||
|
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
|
||||||
|
}
|
||||||
64
internal/identity/provider.go
Normal file
64
internal/identity/provider.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"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{}, 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{}, errors.New("google userinfo request failed")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
13
internal/identity/provider_test.go
Normal file
13
internal/identity/provider_test.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGoogleProviderRequiresConfiguration(t *testing.T) {
|
||||||
|
_, e := (GoogleProvider{}).Authenticate(context.Background(), "")
|
||||||
|
if e == nil {
|
||||||
|
t.Fatal("expected configuration error")
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/persistence/mapping.go
Normal file
19
internal/persistence/mapping.go
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
package persistence
|
||||||
|
|
||||||
|
import "github.com/example/cms/internal/domain"
|
||||||
|
|
||||||
|
func UserToDomain(m UserModel) domain.User {
|
||||||
|
return domain.User{ID: m.ID, Email: m.Email, Name: m.Name, Provider: m.Provider, ProviderSubject: m.ProviderSubject, 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, CreatedAt: d.CreatedAt}
|
||||||
|
}
|
||||||
|
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}
|
||||||
|
}
|
||||||
14
internal/persistence/mapping_test.go
Normal file
14
internal/persistence/mapping_test.go
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
internal/persistence/models.go
Normal file
26
internal/persistence/models.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
package persistence
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type UserModel struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Email string `gorm:"uniqueIndex;not null"`
|
||||||
|
Name string
|
||||||
|
Provider string `gorm:"not null"`
|
||||||
|
ProviderSubject string `gorm:"uniqueIndex;not null"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
type AdminModel struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Email string `gorm:"uniqueIndex;not null"`
|
||||||
|
Name string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
type SessionModel struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Token string `gorm:"uniqueIndex;not null"`
|
||||||
|
UserID *uint
|
||||||
|
AdminID *uint
|
||||||
|
ExpiresAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
56
internal/repository/gorm.go
Normal file
56
internal/repository/gorm.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"github.com/example/cms/internal/domain"
|
||||||
|
"github.com/example/cms/internal/persistence"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GORMUserRepository struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
func NewUserRepository(db *gorm.DB) IUserRepository { return &GORMUserRepository{db} }
|
||||||
|
func (r *GORMUserRepository) FindOrCreateByIdentity(ctx context.Context, d domain.User) (domain.User, error) {
|
||||||
|
var m persistence.UserModel
|
||||||
|
err := r.db.WithContext(ctx).Where("provider = ? AND provider_subject = ?", d.Provider, d.ProviderSubject).First(&m).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
m = persistence.UserFromDomain(d)
|
||||||
|
if err = r.db.WithContext(ctx).Create(&m).Error; err != nil {
|
||||||
|
return domain.User{}, err
|
||||||
|
}
|
||||||
|
return persistence.UserToDomain(m), nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.User{}, err
|
||||||
|
}
|
||||||
|
return persistence.UserToDomain(m), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
e := r.db.WithContext(ctx).Where("email = ?", email).First(&m).Error
|
||||||
|
if e != nil {
|
||||||
|
return domain.Admin{}, e
|
||||||
|
}
|
||||||
|
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.SessionFromDomain(d)).Error
|
||||||
|
}
|
||||||
|
func (r *GORMSessionRepository) FindValid(ctx context.Context, t string) (domain.Session, error) {
|
||||||
|
var m persistence.SessionModel
|
||||||
|
e := r.db.WithContext(ctx).Where("token = ? AND expires_at > ?", t, time.Now()).First(&m).Error
|
||||||
|
if e != nil {
|
||||||
|
return domain.Session{}, e
|
||||||
|
}
|
||||||
|
return persistence.SessionToDomain(m), nil
|
||||||
|
}
|
||||||
17
internal/repository/interfaces.go
Normal file
17
internal/repository/interfaces.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/example/cms/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IUserRepository interface {
|
||||||
|
FindOrCreateByIdentity(context.Context, domain.User) (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)
|
||||||
|
}
|
||||||
76
internal/service/auth.go
Normal file
76
internal/service/auth.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
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
|
||||||
|
sessions 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 (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.sessions.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)
|
||||||
|
}
|
||||||
|
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.sessions.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)
|
||||||
|
}
|
||||||
|
func newToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, e := rand.Read(b); e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
39
internal/service/auth_test.go
Normal file
39
internal/service/auth_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
163
internal/web/handlers.go
Normal file
163
internal/web/handlers.go
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/example/cms/internal/identity"
|
||||||
|
"github.com/example/cms/internal/service"
|
||||||
|
"github.com/example/cms/internal/web/mapping"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
auth *service.AuthService
|
||||||
|
google identity.GoogleProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(a *service.AuthService, google identity.GoogleProvider) *Handler {
|
||||||
|
return &Handler{auth: a, google: google}
|
||||||
|
}
|
||||||
|
func (h *Handler) Routes(origins string) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { jsonWrite(w, 200, map[string]string{"status": "ok"}) })
|
||||||
|
mux.HandleFunc("/api/login", h.login)
|
||||||
|
mux.HandleFunc("/api/auth/google", h.googleLogin)
|
||||||
|
mux.HandleFunc("/api/auth/google/callback", h.googleCallback)
|
||||||
|
mux.HandleFunc("/api/auth/session", h.session)
|
||||||
|
mux.HandleFunc("/admin/login", h.adminLogin)
|
||||||
|
mux.HandleFunc("/api/test/protected", h.protected)
|
||||||
|
return cors(mux, origins)
|
||||||
|
}
|
||||||
|
func (h *Handler) googleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.google.ClientID == "" {
|
||||||
|
jsonWrite(w, http.StatusServiceUnavailable, map[string]string{"error": "google identity provider is not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stateBytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(stateBytes); err != nil {
|
||||||
|
jsonWrite(w, http.StatusInternalServerError, map[string]string{"error": "unable to create oauth state"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state := base64.RawURLEncoding.EncodeToString(stateBytes)
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, Path: "/", MaxAge: 600})
|
||||||
|
http.Redirect(w, r, h.google.OAuthConfig().AuthCodeURL(state), http.StatusFound)
|
||||||
|
}
|
||||||
|
func (h *Handler) googleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
stateCookie, err := r.Cookie("oauth_state")
|
||||||
|
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
|
||||||
|
http.Error(w, "invalid oauth state", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := h.google.FetchIdentity(r.Context(), r.URL.Query().Get("code"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "google authentication failed", 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) {
|
||||||
|
token := bearerOrCookie(r)
|
||||||
|
if token == "" {
|
||||||
|
jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, err := h.auth.Authenticate(r.Context(), token)
|
||||||
|
if err != nil || s.UserID == nil {
|
||||||
|
jsonWrite(w, http.StatusUnauthorized, map[string]string{"authenticated": "false"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonWrite(w, http.StatusOK, map[string]bool{"authenticated": true})
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
t := bearerOrCookie(r)
|
||||||
|
if t == "" {
|
||||||
|
jsonWrite(w, 401, map[string]string{"error": "missing bearer token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s, e := h.auth.Authenticate(r.Context(), t)
|
||||||
|
if e != nil || s.UserID == nil {
|
||||||
|
jsonWrite(w, 401, map[string]string{"error": "invalid session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonWrite(w, 200, map[string]string{"status": "authenticated"})
|
||||||
|
}
|
||||||
|
func bearerOrCookie(r *http.Request) string {
|
||||||
|
header := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||||
|
if header != "" {
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
cookie, err := r.Cookie("cms_session")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
func setSessionCookie(w http.ResponseWriter, token string, secure bool) {
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "cms_session", Value: token, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, Path: "/", MaxAge: 86400})
|
||||||
|
}
|
||||||
|
func decode(r *http.Request, v any) bool { return json.NewDecoder(r.Body).Decode(v) == nil }
|
||||||
|
func jsonWrite(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if v != nil {
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func cors(next http.Handler, origin string) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Vary", "Origin")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.WriteHeader(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
15
internal/web/mapping/auth.go
Normal file
15
internal/web/mapping/auth.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
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"`
|
||||||
|
}
|
||||||
10
internal/web/mapping/auth_test.go
Normal file
10
internal/web/mapping/auth_test.go
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
11
migrations/001_init.sql
Normal file
11
migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- +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;
|
||||||
5
ui/app/.env.example
Normal file
5
ui/app/.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Leave empty for a same-origin backend.
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
VITE_GOOGLE_AUTH_ENDPOINT=/api/auth/google
|
||||||
|
VITE_SESSION_ENDPOINT=/api/auth/session
|
||||||
|
VITE_LOGOUT_ENDPOINT=/api/auth/logout
|
||||||
4
ui/app/.gitignore
vendored
Normal file
4
ui/app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
12
ui/app/README.md
Normal file
12
ui/app/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# CMS frontend
|
||||||
|
|
||||||
|
This Vite/React app lives in `ui/app` and is intentionally independent of the Go backend. Run
|
||||||
|
`npm install`, then `npm run dev` or `npm run build`.
|
||||||
|
|
||||||
|
The API defaults to same-origin `/api/auth/session`, `/api/auth/google`, and
|
||||||
|
`/api/auth/logout`. Set `VITE_API_BASE_URL` for a separate backend, or override the endpoint
|
||||||
|
paths with `VITE_SESSION_ENDPOINT`, `VITE_GOOGLE_AUTH_ENDPOINT`, and `VITE_LOGOUT_ENDPOINT`.
|
||||||
|
The Google endpoint may return JSON containing `user` and an optional `token`/`accessToken`, or a
|
||||||
|
redirect response. Sessions use cookies (`credentials: include`) and tokens are retained only for
|
||||||
|
the current browser tab. Provider-specific login is isolated in `src/api.ts` so Facebook can be
|
||||||
|
added without changing the routes or UI.
|
||||||
13
ui/app/index.html
Normal file
13
ui/app/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#f8fafc" />
|
||||||
|
<title>CMS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1941
ui/app/package-lock.json
generated
Normal file
1941
ui/app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
ui/app/package.json
Normal file
23
ui/app/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "cms-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-router-dom": "^7.9.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.13",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vite": "^7.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
43
ui/app/src/App.tsx
Normal file
43
ui/app/src/App.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Navigate, Route, Routes, Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from './auth'
|
||||||
|
|
||||||
|
function Shell({ children, admin = false }: { children: React.ReactNode; admin?: boolean }) {
|
||||||
|
const { user, signOut } = useAuth()
|
||||||
|
return <main className="shell">
|
||||||
|
<header className="header"><Link className="brand" to="/">CMS</Link>{user && !admin && <button className="text-button" onClick={() => void signOut()}>Sign out</button>}</header>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Login({ admin = false }: { admin?: boolean }) {
|
||||||
|
const { user, loading, error, signIn } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
if (!admin && user) return <Navigate to="/test" replace />
|
||||||
|
if (loading) return <Shell admin={admin}><div className="card"><p>Checking your session…</p></div></Shell>
|
||||||
|
return <Shell admin={admin}><section className="card auth-card">
|
||||||
|
<span className="eyebrow">{admin ? 'Administration' : 'Welcome'}</span>
|
||||||
|
<h1>{admin ? 'Admin sign in' : 'Sign in to CMS'}</h1>
|
||||||
|
<p className="muted">{admin ? 'Use your administrator account to continue.' : 'Sign in securely with your external identity provider.'}</p>
|
||||||
|
{error && <p className="error" role="alert">{error}</p>}
|
||||||
|
{!admin && <button className="google-button" onClick={() => void signIn()}><span aria-hidden="true">G</span> Continue with Google</button>}
|
||||||
|
{admin && <p className="muted small">Administrator authentication is handled by the backend.</p>}
|
||||||
|
{admin && <button className="secondary-button" onClick={() => navigate('/')}>Return to home</button>}
|
||||||
|
</section></Shell>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProtectedTest() {
|
||||||
|
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 <Shell><section className="card"><span className="eyebrow">Authenticated</span><h1>Test page</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></section></Shell>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return <Routes>
|
||||||
|
<Route path="/" element={<Login />} />
|
||||||
|
<Route path="/admin/login" element={<Login admin />} />
|
||||||
|
<Route path="/test" element={<ProtectedTest />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
}
|
||||||
49
ui/app/src/api.ts
Normal file
49
ui/app/src/api.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
export type User = {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
email?: string
|
||||||
|
picture?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionResponse = {
|
||||||
|
authenticated?: boolean
|
||||||
|
user?: User
|
||||||
|
token?: string
|
||||||
|
accessToken?: string
|
||||||
|
sessionToken?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
|
||||||
|
export const apiConfig = {
|
||||||
|
google: import.meta.env.VITE_GOOGLE_AUTH_ENDPOINT ?? '/api/auth/google',
|
||||||
|
session: import.meta.env.VITE_SESSION_ENDPOINT ?? '/api/auth/session',
|
||||||
|
logout: import.meta.env.VITE_LOGOUT_ENDPOINT ?? '/api/auth/logout',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiUrl(path: string) {
|
||||||
|
if (/^https?:\/\//i.test(path)) return path
|
||||||
|
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestSession(): Promise<SessionResponse | null> {
|
||||||
|
const response = await fetch(apiUrl(apiConfig.session), {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
if (response.status === 401 || response.status === 404) return null
|
||||||
|
if (!response.ok) throw new Error('Unable to check your session.')
|
||||||
|
return (await response.json()) as SessionResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
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' },
|
||||||
|
})
|
||||||
|
}
|
||||||
64
ui/app/src/auth.tsx
Normal file
64
ui/app/src/auth.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { requestSession, signInWithGoogle, signOut, type User } from './api'
|
||||||
|
|
||||||
|
type AuthState = {
|
||||||
|
user: User | null
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
signIn: () => Promise<void>
|
||||||
|
signOut: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | undefined>(undefined)
|
||||||
|
|
||||||
|
function getUser(result: { authenticated?: boolean; user?: User; token?: string; accessToken?: string; sessionToken?: string }) {
|
||||||
|
const token = result.token ?? result.accessToken ?? result.sessionToken
|
||||||
|
if (token) sessionStorage.setItem('cms.auth.token', token)
|
||||||
|
return result.authenticated === false ? null : result.user ?? (token ? {} : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setError(null)
|
||||||
|
const session = await requestSession()
|
||||||
|
setUser(session ? getUser(session) : null)
|
||||||
|
} catch (err) {
|
||||||
|
setUser(null)
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to check your session.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { void refresh() }, [refresh])
|
||||||
|
|
||||||
|
const signIn = useCallback(async () => {
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (!context) throw new Error('useAuth must be used inside AuthProvider')
|
||||||
|
return context
|
||||||
|
}
|
||||||
14
ui/app/src/main.tsx
Normal file
14
ui/app/src/main.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from './auth'
|
||||||
|
import { App } from './App'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider><App /></AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
23
ui/app/src/styles.css
Normal file
23
ui/app/src/styles.css
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #172033; background: #f5f7fb; font-synthesis: none; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-width: 320px; }
|
||||||
|
button, a { font: inherit; }
|
||||||
|
.shell { min-height: 100vh; max-width: 760px; margin: auto; padding: 0 24px; }
|
||||||
|
.header { height: 76px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.brand { color: #172033; font-size: 1.15rem; font-weight: 750; text-decoration: none; letter-spacing: -.02em; }
|
||||||
|
.text-button { border: 0; background: transparent; color: #536176; cursor: pointer; padding: 8px; }
|
||||||
|
.card { margin: 10vh auto 0; max-width: 440px; padding: 40px; background: white; border: 1px solid #e3e8f0; border-radius: 16px; box-shadow: 0 14px 40px #1720330b; }
|
||||||
|
.auth-card { text-align: center; }
|
||||||
|
h1 { margin: 10px 0 12px; font-size: clamp(1.7rem, 4vw, 2.25rem); letter-spacing: -.04em; }
|
||||||
|
p { line-height: 1.6; }
|
||||||
|
.muted { color: #657286; }
|
||||||
|
.small { font-size: .875rem; }
|
||||||
|
.eyebrow { color: #536fc4; 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; }
|
||||||
|
.google-button { margin-top: 22px; border: 1px solid #ccd4e0; color: #172033; background: #fff; }
|
||||||
|
.google-button:hover { background: #f8fafc; border-color: #9ba8bb; }
|
||||||
|
.google-button span { margin-right: 10px; color: #4285f4; font-weight: 800; }
|
||||||
|
.secondary-button { margin-top: 20px; border: 0; background: #edf1f8; color: #344563; }
|
||||||
|
.error { padding: 10px 12px; border-radius: 8px; color: #a32d2d; background: #fff0f0; font-size: .9rem; }
|
||||||
|
.profile { margin-top: 24px; padding: 14px; border-radius: 8px; background: #f5f7fb; color: #536176; }
|
||||||
|
@media (max-width: 480px) { .shell { padding: 0 16px; } .card { padding: 28px 22px; } }
|
||||||
12
ui/app/src/vite-env.d.ts
vendored
Normal file
12
ui/app/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL?: string
|
||||||
|
readonly VITE_GOOGLE_AUTH_ENDPOINT?: string
|
||||||
|
readonly VITE_SESSION_ENDPOINT?: string
|
||||||
|
readonly VITE_LOGOUT_ENDPOINT?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
21
ui/app/tsconfig.app.json
Normal file
21
ui/app/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
ui/app/tsconfig.json
Normal file
7
ui/app/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
16
ui/app/tsconfig.node.json
Normal file
16
ui/app/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
6
ui/app/vite.config.ts
Normal file
6
ui/app/vite.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
32
ui/changelog.md
Normal file
32
ui/changelog.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Prompt and implementation changelog
|
||||||
|
|
||||||
|
## 2026-09-19
|
||||||
|
|
||||||
|
### Prompt
|
||||||
|
|
||||||
|
Create a CMS React application with a Go backend, initialize a Git repository, and
|
||||||
|
prepare the security foundation first. Add an extensible external identity-provider
|
||||||
|
login beginning with Google, a protected test page, and an admin login at
|
||||||
|
`/admin/login` whose link is never exposed in user-facing pages. Use Goose for
|
||||||
|
migrations and GORM for persistence. Structure the backend into web handlers,
|
||||||
|
web-to-domain mapping, domain-only services, database models, repositories, and
|
||||||
|
domain/database mappers. Prefix interfaces with `I`. Use Go 1.27.1 and the latest
|
||||||
|
React. Keep this changelog in `ui/changelog.md`.
|
||||||
|
|
||||||
|
### Tasks completed
|
||||||
|
|
||||||
|
- Initialized a local Git repository on the `main` branch; GitHub remote
|
||||||
|
configuration is intentionally left for later.
|
||||||
|
- Added a Go 1.27.1 backend module with configuration via environment variables.
|
||||||
|
- Added layered backend packages for domain, web handlers and mapping, services,
|
||||||
|
repositories, persistence models, and persistence mapping.
|
||||||
|
- Added GORM with SQLite as the local database driver and Goose migrations for
|
||||||
|
users, administrators, and sessions.
|
||||||
|
- Added an OAuth provider abstraction and Google OAuth authorization-code flow with
|
||||||
|
state validation, userinfo lookup, and an HTTP-only session cookie.
|
||||||
|
- Added health, session, Google auth, protected test, and hidden admin login routes.
|
||||||
|
- Added a React/Vite frontend with a public Google sign-in page, protected `/test`
|
||||||
|
page, and `/admin/login` route without a visible navigation link.
|
||||||
|
- Added environment examples, ignore rules, README documentation, and backend
|
||||||
|
unit tests.
|
||||||
|
- Verified Go tests and prepared frontend typecheck/build scripts.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue