Initialize CMS application scaffold

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-19 14:17:27 +02:00
commit b50c054b30
39 changed files with 3040 additions and 0 deletions

29
internal/config/config.go Normal file
View 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
View 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
}

View 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
}

View 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")
}
}

View 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}
}

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

View 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
}

View 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
}

View 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
View 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
}

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

View 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"`
}

View 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")
}
}