refac: removed 7 direct and 12 indirect go modules for custom code instead

This commit is contained in:
Florian Sylvain committed 2026-10-21 23:13:46 +02:00
1 parent cfcf108488
commit c68e9a7858
73 files changed
+2455 -428

No files matched your search

+3 -4
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"RenewCMS/api/pkg/authtoken"
domainAPIKey "RenewCMS/internal/domain/apikey" domainAPIKey "RenewCMS/internal/domain/apikey"
domainArticle "RenewCMS/internal/domain/article" domainArticle "RenewCMS/internal/domain/article"
domainAudit "RenewCMS/internal/domain/audit" domainAudit "RenewCMS/internal/domain/audit"
@@ -37,14 +38,12 @@ import (
"runtime" "runtime"
"sync" "sync"
"time" "time"
"github.com/go-chi/jwtauth/v5"
) )
type UseCases struct { type UseCases struct {
apiKeyAuthOnce sync.Once apiKeyAuthOnce sync.Once
apiKeyAuthMiddleware func(http.Handler) http.Handler apiKeyAuthMiddleware func(http.Handler) http.Handler
Token *jwtauth.JWTAuth Token *authtoken.Auth
PurgeScheduler *scheduler.Runner PurgeScheduler *scheduler.Runner
ActivityCleanupScheduler *scheduler.Runner ActivityCleanupScheduler *scheduler.Runner
CreateArticleUseCase *useCases.CreateArticleUseCase CreateArticleUseCase *useCases.CreateArticleUseCase
@@ -347,7 +346,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
return &UseCases{ return &UseCases{
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()), PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()), ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil), Token: authtoken.New([]byte(settings.JWTSecret)),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo), CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
+3 -4
View File
@@ -2,6 +2,7 @@ package auth
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
@@ -9,12 +10,10 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"github.com/go-chi/jwtauth/v5"
) )
type AcceptInviteHandler struct { type AcceptInviteHandler struct {
Token *jwtauth.JWTAuth Token *authtoken.Auth
GetInviteUseCase *useCases.GetInviteUseCase GetInviteUseCase *useCases.GetInviteUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase AcceptInviteUseCase *useCases.AcceptInviteUseCase
IsProduction bool IsProduction bool
@@ -30,7 +29,7 @@ type inviteEmailResponse struct {
Email string `json:"email"` Email string `json:"email"`
} }
func NewAcceptInviteHandler(getInvite *useCases.GetInviteUseCase, acceptInvite *useCases.AcceptInviteUseCase, token *jwtauth.JWTAuth, isProduction bool) *AcceptInviteHandler { func NewAcceptInviteHandler(getInvite *useCases.GetInviteUseCase, acceptInvite *useCases.AcceptInviteUseCase, token *authtoken.Auth, isProduction bool) *AcceptInviteHandler {
return &AcceptInviteHandler{ return &AcceptInviteHandler{
Token: token, Token: token,
GetInviteUseCase: getInvite, GetInviteUseCase: getInvite,
+6 -7
View File
@@ -2,6 +2,7 @@ package auth
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user" "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
@@ -9,12 +10,10 @@ import (
"errors" "errors"
"net/http" "net/http"
"time" "time"
"github.com/go-chi/jwtauth/v5"
) )
type LoginHandler struct { type LoginHandler struct {
Token *jwtauth.JWTAuth Token *authtoken.Auth
CreateUseCase *useCases.CreateUserUseCase CreateUseCase *useCases.CreateUserUseCase
AuthenticateUseCase *useCases.AuthenticateUserUseCase AuthenticateUseCase *useCases.AuthenticateUserUseCase
IsProduction bool IsProduction bool
@@ -31,7 +30,7 @@ type LoginCredentials struct {
Password string `json:"password" validate:"required,min=8"` Password string `json:"password" validate:"required,min=8"`
} }
func NewLoginHandler(token *jwtauth.JWTAuth, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase, isProduction bool) *LoginHandler { func NewLoginHandler(token *authtoken.Auth, create *useCases.CreateUserUseCase, authenticate *useCases.AuthenticateUserUseCase, isProduction bool) *LoginHandler {
return &LoginHandler{ return &LoginHandler{
Token: token, Token: token,
CreateUseCase: create, CreateUseCase: create,
@@ -52,15 +51,15 @@ func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
}) })
} }
func setJwtCookie(w http.ResponseWriter, token *jwtauth.JWTAuth, isProduction bool, u user.User) error { func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction bool, u user.User) error {
_, tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion}) tokenString, err := token.Encode(map[string]any{"user_id": u.ID, "token_version": u.TokenVersion})
if err != nil { if err != nil {
return err return err
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "jwt", Name: "jwt",
Value: tokenString, Value: tokenString,
Expires: time.Now().Add(24 * time.Hour), Expires: time.Now().Add(authtoken.SessionLifetime),
Secure: isProduction, Secure: isProduction,
HttpOnly: true, HttpOnly: true,
Path: "/", Path: "/",
+6 -18
View File
@@ -2,6 +2,7 @@ package middleware
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/internal/domain/apikey" "RenewCMS/internal/domain/apikey"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"context" "context"
@@ -10,9 +11,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v3/jwt"
) )
const bearerScheme = "bearer " const bearerScheme = "bearer "
@@ -79,13 +77,7 @@ func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests in
return return
} }
token, err := sessionTokenFor(authenticated) ctx := authtoken.NewContext(r.Context(), sessionClaimsFor(authenticated), nil)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
ctx := jwtauth.NewContext(r.Context(), token, nil)
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID) ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
ctx = context.WithValue(ctx, apiKeyNameContextKey{}, authenticated.Key.Name) ctx = context.WithValue(ctx, apiKeyNameContextKey{}, authenticated.Key.Name)
limited.ServeHTTP(w, r.WithContext(ctx)) limited.ServeHTTP(w, r.WithContext(ctx))
@@ -101,15 +93,11 @@ func writeAPIKeyError(w http.ResponseWriter, err error) {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
} }
func sessionTokenFor(authenticated useCases.AuthenticatedAPIKey) (jwt.Token, error) { func sessionClaimsFor(authenticated useCases.AuthenticatedAPIKey) authtoken.Claims {
token := jwt.New() return authtoken.Claims{
if err := token.Set("user_id", float64(authenticated.User.ID)); err != nil { "user_id": float64(authenticated.User.ID),
return nil, err "token_version": float64(authenticated.User.TokenVersion),
} }
if err := token.Set("token_version", float64(authenticated.User.TokenVersion)); err != nil {
return nil, err
}
return token, nil
} }
func apiKeyRateLimitKey(r *http.Request) string { func apiKeyRateLimitKey(r *http.Request) string {
+138
View File
@@ -0,0 +1,138 @@
package middleware
import (
"net/http"
"strconv"
"strings"
)
const (
corsAllowAnyOrigin = "*"
corsWildcardMarker = "*"
corsPreflightMaxAge = 300
corsPreflightSuccess = http.StatusNoContent
)
var (
corsAllowedMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodOptions}
corsAllowedHeaders = []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}
corsExposedHeaders = []string{"Link", "X-Total-Count"}
)
type originWildcard struct {
prefix string
suffix string
}
func (w originWildcard) matches(origin string) bool {
return len(origin) >= len(w.prefix)+len(w.suffix) && strings.HasPrefix(origin, w.prefix) && strings.HasSuffix(origin, w.suffix)
}
type originPolicy struct {
anyOrigin bool
exact map[string]struct{}
wildcards []originWildcard
}
func newOriginPolicy(origins []string) originPolicy {
policy := originPolicy{exact: map[string]struct{}{}}
for _, origin := range origins {
origin = strings.ToLower(strings.TrimSpace(origin))
prefix, suffix, hasWildcard := strings.Cut(origin, corsWildcardMarker)
switch {
case origin == "":
case origin == corsAllowAnyOrigin:
policy.anyOrigin = true
case hasWildcard:
policy.wildcards = append(policy.wildcards, originWildcard{prefix: prefix, suffix: suffix})
default:
policy.exact[origin] = struct{}{}
}
}
return policy
}
func (p originPolicy) allows(origin string) bool {
if p.anyOrigin {
return true
}
origin = strings.ToLower(origin)
if _, ok := p.exact[origin]; ok {
return true
}
for _, wildcard := range p.wildcards {
if wildcard.matches(origin) {
return true
}
}
return false
}
func isPreflight(r *http.Request) bool {
return r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != ""
}
func containsFold(allowed []string, value string) bool {
for _, candidate := range allowed {
if strings.EqualFold(candidate, value) {
return true
}
}
return false
}
func requestedHeadersAllowed(r *http.Request) bool {
requested := r.Header.Get("Access-Control-Request-Headers")
if requested == "" {
return true
}
for _, header := range strings.Split(requested, ",") {
if header = strings.TrimSpace(header); header != "" && !containsFold(corsAllowedHeaders, header) {
return false
}
}
return true
}
func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
policy := newOriginPolicy(allowedOrigins)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
return
}
w.Header().Add("Vary", "Origin")
if isPreflight(r) {
answerPreflight(w, r, origin, policy)
return
}
if policy.allows(origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Expose-Headers", strings.Join(corsExposedHeaders, ", "))
}
next.ServeHTTP(w, r)
})
}
}
func answerPreflight(w http.ResponseWriter, r *http.Request, origin string, policy originPolicy) {
w.Header().Add("Vary", "Access-Control-Request-Method")
w.Header().Add("Vary", "Access-Control-Request-Headers")
method := r.Header.Get("Access-Control-Request-Method")
if !policy.allows(origin) || !containsFold(corsAllowedMethods, method) || !requestedHeadersAllowed(r) {
w.WriteHeader(http.StatusOK)
return
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", strings.ToUpper(method))
w.Header().Set("Access-Control-Allow-Headers", strings.Join(corsAllowedHeaders, ", "))
w.Header().Set("Access-Control-Max-Age", strconv.Itoa(corsPreflightMaxAge))
w.WriteHeader(corsPreflightSuccess)
}
+2 -5
View File
@@ -2,16 +2,13 @@ package middleware
import ( import (
"RenewCMS/api/pkg/apperr" "RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"net/http" "net/http"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v3/jwt"
) )
func RequireSignedIn(next http.Handler) http.Handler { func RequireSignedIn(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, _, err := jwtauth.FromContext(r.Context()) if _, err := authtoken.FromContext(r.Context()); err != nil {
if err != nil || token == nil || jwt.Validate(token) != nil {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return return
} }
+170
View File
@@ -0,0 +1,170 @@
package authtoken
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"maps"
"strings"
"time"
)
const (
SessionLifetime = 24 * time.Hour
algorithmHS256 = "HS256"
tokenType = "JWT"
maxTokenBytes = 4096
segmentCount = 3
segmentSep = "."
)
var (
ErrNoToken = errors.New("no token found")
ErrInvalid = errors.New("token is invalid")
ErrExpired = errors.New("token is expired")
ErrNotYetValid = errors.New("token is not yet valid")
)
type Claims map[string]any
type header struct {
Algorithm string `json:"alg"`
Type string `json:"typ,omitempty"`
}
type Auth struct {
secret []byte
clock func() time.Time
}
type Option func(*Auth)
func WithClock(clock func() time.Time) Option {
return func(a *Auth) { a.clock = clock }
}
func New(secret []byte, options ...Option) *Auth {
auth := &Auth{secret: secret, clock: time.Now}
for _, option := range options {
option(auth)
}
return auth
}
func (a *Auth) Encode(claims map[string]any) (string, error) {
payload := maps.Clone(claims)
if payload == nil {
payload = map[string]any{}
}
now := a.clock()
if _, ok := payload["iat"]; !ok {
payload["iat"] = now.Unix()
}
if _, ok := payload["exp"]; !ok {
payload["exp"] = now.Add(SessionLifetime).Unix()
}
headerSegment, err := encodeSegment(header{Algorithm: algorithmHS256, Type: tokenType})
if err != nil {
return "", err
}
payloadSegment, err := encodeSegment(payload)
if err != nil {
return "", err
}
signingInput := headerSegment + segmentSep + payloadSegment
return signingInput + segmentSep + a.sign(signingInput), nil
}
func (a *Auth) Decode(token string) (Claims, error) {
if token == "" {
return nil, ErrNoToken
}
if len(token) > maxTokenBytes {
return nil, ErrInvalid
}
segments := strings.Split(token, segmentSep)
if len(segments) != segmentCount {
return nil, ErrInvalid
}
var tokenHeader header
if err := decodeSegment(segments[0], &tokenHeader); err != nil || tokenHeader.Algorithm != algorithmHS256 {
return nil, ErrInvalid
}
signingInput := segments[0] + segmentSep + segments[1]
if !hmac.Equal([]byte(a.sign(signingInput)), []byte(segments[2])) {
return nil, ErrInvalid
}
var claims Claims
if err := decodeSegment(segments[1], &claims); err != nil || claims == nil {
return nil, ErrInvalid
}
if err := a.validateTimes(claims); err != nil {
return nil, err
}
return claims, nil
}
func (a *Auth) validateTimes(claims Claims) error {
now := a.clock()
expiry, present, err := numericDate(claims, "exp")
if err != nil {
return err
}
if present && !now.Before(expiry) {
return ErrExpired
}
notBefore, present, err := numericDate(claims, "nbf")
if err != nil {
return err
}
if present && now.Before(notBefore) {
return ErrNotYetValid
}
return nil
}
func numericDate(claims Claims, name string) (time.Time, bool, error) {
raw, present := claims[name]
if !present {
return time.Time{}, false, nil
}
seconds, ok := raw.(float64)
if !ok {
return time.Time{}, false, ErrInvalid
}
return time.Unix(int64(seconds), 0), true, nil
}
func (a *Auth) sign(signingInput string) string {
mac := hmac.New(sha256.New, a.secret)
mac.Write([]byte(signingInput))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func encodeSegment(value any) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(encoded), nil
}
func decodeSegment(segment string, into any) error {
decoded, err := base64.RawURLEncoding.DecodeString(segment)
if err != nil {
return err
}
return json.Unmarshal(decoded, into)
}
+79
View File
@@ -0,0 +1,79 @@
package authtoken
import (
"RenewCMS/api/pkg/apperr"
"context"
"net/http"
"strings"
)
const (
cookieName = "jwt"
bearerPrefix = "bearer "
)
type contextKey struct{}
type verdict struct {
claims Claims
err error
}
func Verifier(auth *Auth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, err := auth.Decode(tokenFromRequest(r))
next.ServeHTTP(w, r.WithContext(NewContext(r.Context(), claims, err)))
})
}
}
func Authenticator(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := FromContext(r.Context()); err != nil {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func NewContext(ctx context.Context, claims Claims, err error) context.Context {
return context.WithValue(ctx, contextKey{}, verdict{claims: claims, err: err})
}
func FromContext(ctx context.Context) (Claims, error) {
stored, ok := ctx.Value(contextKey{}).(verdict)
switch {
case !ok:
return nil, ErrNoToken
case stored.err != nil:
return nil, stored.err
case stored.claims == nil:
return nil, ErrNoToken
}
return stored.claims, nil
}
func tokenFromRequest(r *http.Request) string {
if token := tokenFromHeader(r); token != "" {
return token
}
return tokenFromCookie(r)
}
func tokenFromHeader(r *http.Request) string {
header := r.Header.Get("Authorization")
if len(header) <= len(bearerPrefix) || !strings.EqualFold(header[:len(bearerPrefix)], bearerPrefix) {
return ""
}
return header[len(bearerPrefix):]
}
func tokenFromCookie(r *http.Request) string {
cookie, err := r.Cookie(cookieName)
if err != nil {
return ""
}
return cookie.Value
}
+3 -4
View File
@@ -1,14 +1,13 @@
package claims package claims
import ( import (
"RenewCMS/api/pkg/authtoken"
"math" "math"
"net/http" "net/http"
"github.com/go-chi/jwtauth/v5"
) )
func UserId(r *http.Request) (uint32, bool) { func UserId(r *http.Request) (uint32, bool) {
_, claims, err := jwtauth.FromContext(r.Context()) claims, err := authtoken.FromContext(r.Context())
if err != nil { if err != nil {
return 0, false return 0, false
} }
@@ -31,7 +30,7 @@ func UserId(r *http.Request) (uint32, bool) {
} }
func TokenVersion(r *http.Request) (uint32, bool) { func TokenVersion(r *http.Request) (uint32, bool) {
_, claims, err := jwtauth.FromContext(r.Context()) claims, err := authtoken.FromContext(r.Context())
if err != nil { if err != nil {
return 0, false return 0, false
} }
+42 -47
View File
@@ -18,21 +18,23 @@ import (
"RenewCMS/api/handlers/update" "RenewCMS/api/handlers/update"
"RenewCMS/api/handlers/user" "RenewCMS/api/handlers/user"
"RenewCMS/api/middleware" "RenewCMS/api/middleware"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/config"
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"os"
"time" "time"
"github.com/MadAppGang/httplog"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware" chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/jwtauth/v5"
) )
func enableLoggingIfDev(r *chi.Mux, routerName string, environment string) { func enableLoggingIfDev(r *chi.Mux, routerName string, environment string) {
if environment == "development" { if environment == "development" {
r.Use(httplog.LoggerWithName(routerName)) r.Use(chimiddleware.RequestLogger(&chimiddleware.DefaultLogFormatter{
Logger: log.New(os.Stdout, "["+routerName+"] ", log.LstdFlags),
}))
} }
} }
@@ -68,7 +70,7 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
r.Get("/{id}/seo", seoHandler.Resolve) r.Get("/{id}/seo", seoHandler.Resolve)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/", articleHandler.PostArticle) r.Post("/", articleHandler.PostArticle)
@@ -83,7 +85,7 @@ func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler { func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction) loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
@@ -114,16 +116,16 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession) r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Post("/verify", verificationHandler.Verify) r.Post("/verify", verificationHandler.Verify)
r.Delete("/register", verificationHandler.CancelRegistration) r.Delete("/register", verificationHandler.CancelRegistration)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Get("/me", meHandler.Me) r.Get("/me", meHandler.Me)
}) })
@@ -137,10 +139,10 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase) lookupHandler := user.NewLookupHandler(container.LookupUserNamesUseCase)
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase) inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Patch("/me", userHandler.UpdateMe) r.Patch("/me", userHandler.UpdateMe)
@@ -163,19 +165,19 @@ func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handle
settingsHandler := settings.NewHandler(container.GetSettingsUseCase, container.GetActivityRetentionUseCase, container.UpdateActivityRetentionUseCase) settingsHandler := settings.NewHandler(container.GetSettingsUseCase, container.GetActivityRetentionUseCase, container.UpdateActivityRetentionUseCase)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", settingsHandler.GetSettings) r.Get("/", settingsHandler.GetSettings)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession) r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn) r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwner(container.GetUserUseCase)) r.Use(middleware.RequireOwner(container.GetUserUseCase))
@@ -193,19 +195,19 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
r.Get("/{key}/sitemap", sitemapHandler.Sitemap) r.Get("/{key}/sitemap", sitemapHandler.Sitemap)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", siteHandler.List) r.Get("/", siteHandler.List)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession) r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn) r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwner(container.GetUserUseCase)) r.Use(middleware.RequireOwner(container.GetUserUseCase))
@@ -223,19 +225,19 @@ func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler
authorHandler := author.NewHandler(container.ListAuthorsUseCase, container.CreateAuthorUseCase, container.CreateMyAuthorUseCase, container.UpdateAuthorUseCase, container.DeleteAuthorUseCase, container.GetUserUseCase) authorHandler := author.NewHandler(container.ListAuthorsUseCase, container.CreateAuthorUseCase, container.CreateMyAuthorUseCase, container.UpdateAuthorUseCase, container.DeleteAuthorUseCase, container.GetUserUseCase)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", authorHandler.List) r.Get("/", authorHandler.List)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession) r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn) r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/me", authorHandler.CreateMine) r.Post("/me", authorHandler.CreateMine)
@@ -256,11 +258,11 @@ func NewContentTypeRouter(container *UseCases, trustedProxies []string) http.Han
entryHandler := entry.NewHandler(container.CreateEntryUseCase, container.GetEntryUseCase, container.ListEntriesUseCase, container.UpdateEntryUseCase, container.DeleteEntryUseCase) entryHandler := entry.NewHandler(container.CreateEntryUseCase, container.GetEntryUseCase, container.ListEntriesUseCase, container.UpdateEntryUseCase, container.DeleteEntryUseCase)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn) r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", contentTypeHandler.List) r.Get("/", contentTypeHandler.List)
@@ -284,11 +286,11 @@ func NewBuiltinTypeRouter(container *UseCases, trustedProxies []string) http.Han
builtinTypeHandler := builtintype.NewHandler(container.ListBuiltinTypesUseCase, container.GetBuiltinTypeUseCase) builtinTypeHandler := builtintype.NewHandler(container.ListBuiltinTypesUseCase, container.GetBuiltinTypeUseCase)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn) r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
@@ -303,10 +305,10 @@ func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handle
activityHandler := activity.NewHandler(container.ListActivityUseCase, container.GetActivitySummaryUseCase, container.ExportActivityUseCase) activityHandler := activity.NewHandler(container.ListActivityUseCase, container.GetActivitySummaryUseCase, container.ExportActivityUseCase)
r.Use(middleware.RequireSession) r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn) r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", activityHandler.List) r.Get("/", activityHandler.List)
@@ -331,9 +333,9 @@ func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase) updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase)
r.Use(middleware.RequireSession) r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/update", updateHandler.GetStatus) r.Get("/update", updateHandler.GetStatus)
@@ -360,9 +362,9 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase) apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
r.Use(middleware.RequireSession) r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Post("/", apiKeyHandler.Create) r.Post("/", apiKeyHandler.Create)
@@ -376,11 +378,11 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase, container.ListImageVariantsUseCase, container.GenerateImageVariantUseCase, container.GenerateAllImageVariantsUseCase, container.PurgeImageVariantUseCase, container.GenerateVariantsOnUploadUseCase) mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase, container.ListImageVariantsUseCase, container.GenerateImageVariantUseCase, container.GenerateAllImageVariantsUseCase, container.PurgeImageVariantUseCase, container.GenerateVariantsOnUploadUseCase)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn) r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
@@ -413,10 +415,10 @@ func NewImageVariantRouter(container *UseCases, trustedProxies []string) http.Ha
variantHandler := imagevariant.NewHandler(container.ListVariantsUseCase, container.CreateVariantUseCase, container.UpdateVariantUseCase, container.SetVariantEnabledUseCase, container.DeleteVariantUseCase, container.PurgeVariantUseCase, container.GetVariantCacheStatsUseCase, container.PurgeVariantCacheUseCase) variantHandler := imagevariant.NewHandler(container.ListVariantsUseCase, container.CreateVariantUseCase, container.UpdateVariantUseCase, container.SetVariantEnabledUseCase, container.DeleteVariantUseCase, container.PurgeVariantUseCase, container.GetVariantCacheStatsUseCase, container.PurgeVariantCacheUseCase)
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn) r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase)) r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
@@ -440,7 +442,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Use(middleware.JsonContentTypeMiddleware) r.Use(middleware.JsonContentTypeMiddleware)
r.Get("/", GetHelloWorld) r.Get("/", GetHelloWorld)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token)) r.Use(authtoken.Verifier(container.Token))
r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies)) r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
}) })
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
@@ -478,14 +480,7 @@ func enableCorsIfConfigured(r *chi.Mux, origins []string) {
if len(origins) == 0 { if len(origins) == 0 {
return return
} }
r.Use(cors.Handler(cors.Options{ r.Use(middleware.CORS(origins))
AllowedOrigins: origins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link", "X-Total-Count"},
AllowCredentials: false,
MaxAge: 300,
}))
} }
func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux { func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
+6 -3
View File
@@ -7,6 +7,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log" "log"
"net" "net"
"net/http" "net/http"
@@ -15,8 +16,6 @@ import (
"slices" "slices"
"syscall" "syscall"
"time" "time"
"github.com/joho/godotenv"
) )
var version = "dev" var version = "dev"
@@ -106,9 +105,13 @@ func initEnvVariables() {
func loadEnvFile() { func loadEnvFile() {
for _, envLocation := range possibleEnvFileLocations { for _, envLocation := range possibleEnvFileLocations {
if godotenv.Load(envLocation) == nil { err := config.LoadDotenv(envLocation)
if err == nil {
return return
} }
if !errors.Is(err, fs.ErrNotExist) {
log.Println("WARNING: Could not parse env file:", err)
}
} }
log.Println("WARNING: Could not load any .env file") log.Println("WARNING: Could not load any .env file")
} }
+1 -1
View File
@@ -95,7 +95,7 @@ Media usage has more sources than article bodies: `Article.CoverImageID` and `So
`internal/domain/image` holds the `Variant` entity and its rules (`VariantSettings.Normalized`, the settings `Fingerprint`, `VariantETag`, `ETagMatches`, `IsAnimatedGIF`), the sentinels (`ErrVariantNotFound`, `ErrVariantExists`, `ErrVariantInvalid`, `ErrVariantFormatUnavailable`, `ErrUnknownVariant`, `ErrUnsupportedParameter`, `ErrSourceTooLarge`) and three ports: `VariantRepository` (definitions), `VariantFileRepository` (the index of generated files: variant name, source stored name, cached stored name, content type, size and the settings fingerprint, needed because `Storage.Put` chooses the stored name itself) and `VariantRenderer` (decode a source, encode a variant, report the formats it can encode). `internal/infrastructure/imaging` is the renderer adapter, free of GORM and HTTP; the persistence adapters are `VariantRepository` and `VariantFileRepository` over `models.ImageVariant` and `models.ImageVariantFile`. `internal/domain/image` holds the `Variant` entity and its rules (`VariantSettings.Normalized`, the settings `Fingerprint`, `VariantETag`, `ETagMatches`, `IsAnimatedGIF`), the sentinels (`ErrVariantNotFound`, `ErrVariantExists`, `ErrVariantInvalid`, `ErrVariantFormatUnavailable`, `ErrUnknownVariant`, `ErrUnsupportedParameter`, `ErrSourceTooLarge`) and three ports: `VariantRepository` (definitions), `VariantFileRepository` (the index of generated files: variant name, source stored name, cached stored name, content type, size and the settings fingerprint, needed because `Storage.Put` chooses the stored name itself) and `VariantRenderer` (decode a source, encode a variant, report the formats it can encode). `internal/infrastructure/imaging` is the renderer adapter, free of GORM and HTTP; the persistence adapters are `VariantRepository` and `VariantFileRepository` over `models.ImageVariant` and `models.ImageVariantFile`.
Generated bytes are stored only through `image.Storage`, so they follow `STORAGE_DRIVER`, and are never rows of `images`. `ServeVariantUseCase` checks, in order: unsupported parameters, the variant (it must exist and be enabled), the source blob, SVG and animated GIF (served as they are), the size cap, the `If-None-Match` ETag, then the cached file, and generates under a `singleflight` keyed by variant, settings and source. A row whose fingerprint no longer matches, or whose blob vanished, is replaced. `UpdateVariantUseCase` purges a variant's files when a rendering setting changes; `DeleteVariantUseCase` and `PurgeVariantUseCase` purge a blob and then its row, so a failed blob delete keeps the row and a retry can finish; `mediaRemover` purges the files of a source before it deletes the source (delete and merge). Generated bytes are stored only through `image.Storage`, so they follow `STORAGE_DRIVER`, and are never rows of `images`. `ServeVariantUseCase` checks, in order: unsupported parameters, the variant (it must exist and be enabled), the source blob, SVG and animated GIF (served as they are), the size cap, the `If-None-Match` ETag, then the cached file, and generates under an in-process single-flight group keyed by variant, settings and source. A row whose fingerprint no longer matches, or whose blob vanished, is replaced. `UpdateVariantUseCase` purges a variant's files when a rendering setting changes; `DeleteVariantUseCase` and `PurgeVariantUseCase` purge a blob and then its row, so a failed blob delete keeps the row and a retry can finish; `mediaRemover` purges the files of a source before it deletes the source (delete and merge).
The cache screen adds three ports in `internal/domain/image`: `VariantCacheRepository` (unused totals, top sources by bytes and cursor-paged row listing, implemented by the same persistence type as `VariantFileRepository`), `VariantUsageRepository` (one batched write of request times and per-day rejection counts, and the rejection total of a window) and `VariantUsageTracker` (in-memory hits and rejections). `internal/infrastructure/usage.Tracker` implements the tracker; `GetVariantCacheStatsUseCase` and `PurgeVariantCacheUseCase` use the ports only. See [image-variants.md](image-variants.md). The cache screen adds three ports in `internal/domain/image`: `VariantCacheRepository` (unused totals, top sources by bytes and cursor-paged row listing, implemented by the same persistence type as `VariantFileRepository`), `VariantUsageRepository` (one batched write of request times and per-day rejection counts, and the rejection total of a window) and `VariantUsageTracker` (in-memory hits and rejections). `internal/infrastructure/usage.Tracker` implements the tracker; `GetVariantCacheStatsUseCase` and `PurgeVariantCacheUseCase` use the ports only. See [image-variants.md](image-variants.md).
+2 -2
View File
@@ -1,6 +1,6 @@
# Configuration # Configuration
RenewCMS reads its configuration from environment variables. On startup it also loads a `.env` file from the working directory (or its parent) if one exists. Real environment variables take precedence over `.env`. RenewCMS reads its configuration from environment variables. On startup it also loads a `.env` file from the working directory (or its parent) if one exists. Real environment variables take precedence over `.env`. The parser reads `NAME=value` lines, an optional `export ` prefix, `#` comments (inline ones need a space before the `#`), and single- or double-quoted values (`\n`, `\t`, `\"` and `\\` are unescaped in double quotes). It does not expand `$VARIABLES` and does not support multi-line values. A malformed line makes the whole file ignored, and the server logs the line number.
Copy [`.env.example`](../.env.example) to `.env` to get started. When a required variable is missing, the server refuses to start and names the variable. Copy [`.env.example`](../.env.example) to `.env` to get started. When a required variable is missing, the server refuses to start and names the variable.
@@ -29,7 +29,7 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
| `DATA_DIR` | directory of `DB_FILE` | Directory for update state, staged updates and database backups (`backups/`). Set it with `DB_DRIVER=mysql` or `postgres`, where there is no database file to sit beside (the default still follows `DB_FILE`). With SQLite, leave it unset to keep everything beside the database. | | `DATA_DIR` | directory of `DB_FILE` | Directory for update state, staged updates and database backups (`backups/`). Set it with `DB_DRIVER=mysql` or `postgres`, where there is no database file to sit beside (the default still follows `DB_FILE`). With SQLite, leave it unset to keep everything beside the database. |
| `STORAGE_DRIVER` | `local` | `local` (files in `UPLOAD_DIR`) or `s3` (an S3-compatible bucket). Any other value stops the server with `unsupported storage driver "<value>"`. See [Image storage](#image-storage). | | `STORAGE_DRIVER` | `local` | `local` (files in `UPLOAD_DIR`) or `s3` (an S3-compatible bucket). Any other value stops the server with `unsupported storage driver "<value>"`. See [Image storage](#image-storage). |
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. Ignored, without error, when `STORAGE_DRIVER=s3`. | | `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. Ignored, without error, when `STORAGE_DRIVER=s3`. |
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). | | `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. Matching is case-insensitive. An entry may contain one `*` (`https://*.preview.example.com`), and a lone `*` allows every origin. Credentials are never allowed cross-origin. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. | | `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. | | `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). | | `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
+1 -1
View File
@@ -36,7 +36,7 @@ setting does, so a client holding an old value gets a fresh 200. `Cache-Control`
Every generated file is a blob in `image.Storage` (local directory or S3) plus a row in Every generated file is a blob in `image.Storage` (local directory or S3) plus a row in
`image_variant_files` (variant, source stored name, cached stored name, content type, size, settings `image_variant_files` (variant, source stored name, cached stored name, content type, size, settings
fingerprint). The pair (variant, source) is unique. Concurrent first requests share one generation fingerprint). The pair (variant, source) is unique. Concurrent first requests share one generation
(`singleflight` keyed by variant, settings and source); different variants or different images are (a single-flight group keyed by variant, settings and source); different variants or different images are
generated in parallel. generated in parallel.
Only a stored name that has an image record can be a variant source, so generated files and orphan blobs Only a stored name that has an image record can be a variant source, so generated files and orphan blobs
+2 -21
View File
@@ -3,7 +3,6 @@ module RenewCMS
go 1.27.1 go 1.27.1
require ( require (
github.com/MadAppGang/httplog v1.3.0
github.com/aws/aws-sdk-go-v2 v1.47.1 github.com/aws/aws-sdk-go-v2 v1.47.1
github.com/aws/aws-sdk-go-v2/config v1.33.6 github.com/aws/aws-sdk-go-v2/config v1.33.6
github.com/aws/aws-sdk-go-v2/credentials v1.20.6 github.com/aws/aws-sdk-go-v2/credentials v1.20.6
@@ -11,20 +10,14 @@ require (
github.com/gen2brain/webp v0.6.4 github.com/gen2brain/webp v0.6.4
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/go-chi/jwtauth/v5 v5.4.0
github.com/go-playground/validator/v10 v10.28.0 github.com/go-playground/validator/v10 v10.28.0
github.com/go-sql-driver/mysql v1.8.1 github.com/go-sql-driver/mysql v1.8.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/jwx/v3 v3.3.0
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0 golang.org/x/image v0.46.0
golang.org/x/net v0.59.0 golang.org/x/net v0.59.0
golang.org/x/sync v0.23.0
golang.org/x/text v0.42.0 golang.org/x/text v0.42.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.3 gorm.io/driver/postgres v1.6.3
gorm.io/gorm v1.31.2 gorm.io/gorm v1.31.2
@@ -32,7 +25,6 @@ require (
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect
@@ -47,36 +39,25 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
github.com/aws/smithy-go v1.28.1 // indirect github.com/aws/smithy-go v1.28.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.1 // indirect github.com/ebitengine/purego v0.10.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/dsig v1.4.0 // indirect
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/stretchr/testify v1.12.1 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sys v0.48.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/libc v1.67.0 // indirect modernc.org/libc v1.67.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
-43
View File
@@ -1,9 +1,5 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/MadAppGang/httplog v1.3.0 h1:1XU54TO8kiqTeO+7oZLKAM3RP/cJ7SadzslRcKspVHo=
github.com/MadAppGang/httplog v1.3.0/go.mod h1:gpYEdkjh/Cda6YxtDy4AB7KY+fR7mb3SqBZw74A5hJ4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw= github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw=
github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
@@ -41,14 +37,10 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWx
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU= github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=
@@ -59,10 +51,6 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -73,16 +61,12 @@ github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 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/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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -95,26 +79,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8=
github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI=
github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4=
github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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-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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
@@ -124,16 +90,11 @@ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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.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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
@@ -155,11 +116,7 @@ golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
+142
View File
@@ -0,0 +1,142 @@
package config
import (
"bufio"
"fmt"
"io"
"os"
"strings"
)
const (
dotenvExportPrefix = "export "
dotenvCommentMarker = "#"
dotenvInlineComment = " #"
dotenvByteOrderMark = "\xef\xbb\xbf"
dotenvMaxLineBytes = 1 << 20
dotenvAssignment = "="
dotenvDoubleQuote = '"'
dotenvSingleQuote = '\''
dotenvEscapeBackslash = '\\'
)
var dotenvDoubleQuoteEscapes = map[byte]byte{
'n': '\n',
'r': '\r',
't': '\t',
'"': '"',
'\\': '\\',
}
func LoadDotenv(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = file.Close() }()
values, err := ParseDotenv(file)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
for name, value := range values {
if _, isSet := os.LookupEnv(name); !isSet {
if err := os.Setenv(name, value); err != nil {
return err
}
}
}
return nil
}
func ParseDotenv(reader io.Reader) (map[string]string, error) {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), dotenvMaxLineBytes)
values := map[string]string{}
for lineNumber := 1; scanner.Scan(); lineNumber++ {
line := strings.TrimSpace(strings.TrimPrefix(scanner.Text(), dotenvByteOrderMark))
if line == "" || strings.HasPrefix(line, dotenvCommentMarker) {
continue
}
name, value, err := parseDotenvAssignment(line)
if err != nil {
return nil, fmt.Errorf("line %d: %w", lineNumber, err)
}
values[name] = value
}
return values, scanner.Err()
}
func parseDotenvAssignment(line string) (string, string, error) {
line = strings.TrimSpace(strings.TrimPrefix(line, dotenvExportPrefix))
name, raw, found := strings.Cut(line, dotenvAssignment)
name = strings.TrimSpace(name)
if !found || !isDotenvName(name) {
return "", "", fmt.Errorf("expected NAME=value")
}
value, err := parseDotenvValue(strings.TrimSpace(raw))
return name, value, err
}
func isDotenvName(name string) bool {
if name == "" {
return false
}
for _, r := range name {
isLetter := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z'
isDigit := r >= '0' && r <= '9'
if !isLetter && !isDigit && r != '_' && r != '.' {
return false
}
}
return true
}
func parseDotenvValue(raw string) (string, error) {
if raw == "" {
return "", nil
}
switch raw[0] {
case dotenvDoubleQuote:
return parseDoubleQuoted(raw)
case dotenvSingleQuote:
return parseSingleQuoted(raw)
}
if comment := strings.Index(raw, dotenvInlineComment); comment >= 0 {
raw = raw[:comment]
}
return strings.TrimSpace(raw), nil
}
func parseSingleQuoted(raw string) (string, error) {
end := strings.IndexByte(raw[1:], dotenvSingleQuote)
if end < 0 {
return "", fmt.Errorf("unterminated single quote")
}
return raw[1 : 1+end], nil
}
func parseDoubleQuoted(raw string) (string, error) {
var value strings.Builder
for i := 1; i < len(raw); i++ {
switch raw[i] {
case dotenvDoubleQuote:
return value.String(), nil
case dotenvEscapeBackslash:
i++
if i >= len(raw) {
return "", fmt.Errorf("unterminated double quote")
}
if unescaped, known := dotenvDoubleQuoteEscapes[raw[i]]; known {
value.WriteByte(unescaped)
} else {
value.WriteByte(dotenvEscapeBackslash)
value.WriteByte(raw[i])
}
default:
value.WriteByte(raw[i])
}
}
return "", fmt.Errorf("unterminated double quote")
}
+47
View File
@@ -0,0 +1,47 @@
package mailer
import (
"RenewCMS/internal/infrastructure/config"
"errors"
"net/smtp"
"strings"
)
type loginAuth struct {
username string
password string
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
if !server.TLS && !isLoopback(server.Name) {
return "", nil, errors.New("refusing to send credentials over an unencrypted connection")
}
return "LOGIN", nil, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if !more {
return nil, nil
}
switch strings.ToLower(strings.TrimSpace(string(fromServer))) {
case "username:":
return []byte(a.username), nil
case "password:":
return []byte(a.password), nil
}
return nil, errors.New("unexpected server challenge")
}
func isLoopback(host string) bool {
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
func selectAuth(mechanisms string, settings config.SmtpSettings) smtp.Auth {
advertised := strings.Fields(strings.ToUpper(mechanisms))
for _, mechanism := range advertised {
if mechanism == "PLAIN" {
return smtp.PlainAuth("", settings.Login, settings.Password, settings.Host)
}
}
return &loginAuth{username: settings.Login, password: settings.Password}
}
@@ -4,22 +4,47 @@ import (
domain "RenewCMS/internal/domain/mail" domain "RenewCMS/internal/domain/mail"
"RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/config"
"bytes" "bytes"
"crypto/tls"
"embed" "embed"
"html/template" "html/template"
"log" "log"
"time"
"gopkg.in/gomail.v2"
) )
type MailRepository struct { type MailRepository struct {
smtp config.SmtpSettings transport transport
sender string
now func() time.Time
}
type Option func(*options)
type options struct {
tlsConfig *tls.Config
now func() time.Time
}
func WithTLSConfig(tlsConfig *tls.Config) Option {
return func(o *options) { o.tlsConfig = tlsConfig }
}
func WithClock(now func() time.Time) Option {
return func(o *options) { o.now = now }
} }
//go:embed templates/* //go:embed templates/*
var mailTemplateFiles embed.FS var mailTemplateFiles embed.FS
func NewMailRepository(smtp config.SmtpSettings) *MailRepository { func NewMailRepository(smtp config.SmtpSettings, opts ...Option) *MailRepository {
return &MailRepository{smtp: smtp} configured := options{now: time.Now}
for _, opt := range opts {
opt(&configured)
}
return &MailRepository{
transport: newTransport(smtp, configured.tlsConfig),
sender: smtp.Sender,
now: configured.now,
}
} }
const defaultSubject = "RenewCMS | Action required" const defaultSubject = "RenewCMS | Action required"
@@ -60,28 +85,23 @@ func (m MailRepository) Render(templateName string, data any) (string, string, e
} }
func (m MailRepository) Send(receiverAddress string, templateName string, data any) error { func (m MailRepository) Send(receiverAddress string, templateName string, data any) error {
login := m.smtp.Login
sender := m.smtp.Sender
password := m.smtp.Password
smtpHost := m.smtp.Host
smtpPort := m.smtp.Port
d := gomail.NewDialer(smtpHost, smtpPort, login, password)
subject, body, err := m.Render(templateName, data) subject, body, err := m.Render(templateName, data)
if err != nil { if err != nil {
return err return err
} }
msg := gomail.NewMessage() msg, err := newMessage(m.sender, receiverAddress, subject, body, m.now())
msg.SetHeaders(map[string][]string{ if err != nil {
"From": {"RenewCMS <" + sender + ">"}, log.Println("error when building email:", err)
"To": {receiverAddress}, return err
"Subject": {subject}, }
}) payload, err := msg.bytes()
msg.SetBody("text/html", body) if err != nil {
log.Println("error when encoding email:", err)
return err
}
if err := d.DialAndSend(msg); err != nil { if err := m.transport.deliver(msg.from.Address, msg.to.Address, payload); err != nil {
log.Println("error when sending email:", err) log.Println("error when sending email:", err)
return err return err
} }
+90
View File
@@ -0,0 +1,90 @@
package mailer
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"mime"
"mime/quotedprintable"
"net/mail"
"strings"
"time"
)
const (
senderDisplayName = "RenewCMS"
messageIDBytes = 16
lineBreak = "\r\n"
)
type message struct {
from mail.Address
to mail.Address
subject string
body string
sentAt time.Time
}
func newMessage(sender string, receiver string, subject string, body string, sentAt time.Time) (message, error) {
from, err := mail.ParseAddress(sender)
if err != nil {
return message{}, fmt.Errorf("invalid sender address: %w", err)
}
to, err := mail.ParseAddress(receiver)
if err != nil {
return message{}, fmt.Errorf("invalid receiver address: %w", err)
}
if strings.ContainsAny(subject, "\r\n") {
return message{}, errors.New("subject contains a line break")
}
return message{
from: mail.Address{Name: senderDisplayName, Address: from.Address},
to: *to,
subject: subject,
body: body,
sentAt: sentAt,
}, nil
}
func (m message) messageID() (string, error) {
_, domain, _ := strings.Cut(m.from.Address, "@")
random := make([]byte, messageIDBytes)
if _, err := rand.Read(random); err != nil {
return "", err
}
return "<" + hex.EncodeToString(random) + "@" + domain + ">", nil
}
func (m message) bytes() ([]byte, error) {
id, err := m.messageID()
if err != nil {
return nil, err
}
var out bytes.Buffer
headers := []struct{ name, value string }{
{"From", m.from.String()},
{"To", m.to.String()},
{"Subject", mime.QEncoding.Encode("utf-8", m.subject)},
{"Date", m.sentAt.Format(time.RFC1123Z)},
{"Message-ID", id},
{"MIME-Version", "1.0"},
{"Content-Type", "text/html; charset=UTF-8"},
{"Content-Transfer-Encoding", "quoted-printable"},
}
for _, h := range headers {
out.WriteString(h.name + ": " + h.value + lineBreak)
}
out.WriteString(lineBreak)
encoder := quotedprintable.NewWriter(&out)
if _, err := encoder.Write([]byte(m.body)); err != nil {
return nil, err
}
if err := encoder.Close(); err != nil {
return nil, err
}
return out.Bytes(), nil
}
+116
View File
@@ -0,0 +1,116 @@
package mailer
import (
"RenewCMS/internal/infrastructure/config"
"crypto/tls"
"errors"
"net"
"net/smtp"
"strconv"
"time"
)
const (
implicitTLSPort = 465
dialTimeout = 10 * time.Second
sessionTimeout = 30 * time.Second
)
type transport struct {
settings config.SmtpSettings
tlsConfig *tls.Config
}
func newTransport(settings config.SmtpSettings, tlsConfig *tls.Config) transport {
if tlsConfig == nil {
tlsConfig = &tls.Config{}
}
tlsConfig = tlsConfig.Clone()
if tlsConfig.ServerName == "" {
tlsConfig.ServerName = settings.Host
}
if tlsConfig.MinVersion == 0 {
tlsConfig.MinVersion = tls.VersionTLS12
}
return transport{settings: settings, tlsConfig: tlsConfig}
}
func (t transport) deliver(sender string, receiver string, payload []byte) error {
client, err := t.connect()
if err != nil {
return err
}
defer func() { _ = client.Close() }()
if err := t.negotiate(client); err != nil {
return err
}
if err := client.Mail(sender); err != nil {
return err
}
if err := client.Rcpt(receiver); err != nil {
return err
}
if err := writeData(client, payload); err != nil {
return err
}
return client.Quit()
}
func (t transport) connect() (*smtp.Client, error) {
address := net.JoinHostPort(t.settings.Host, strconv.Itoa(t.settings.Port))
dialer := &net.Dialer{Timeout: dialTimeout}
connection, err := dialer.Dial("tcp", address)
if err != nil {
return nil, err
}
if err := connection.SetDeadline(time.Now().Add(sessionTimeout)); err != nil {
_ = connection.Close()
return nil, err
}
if t.settings.Port == implicitTLSPort {
connection = tls.Client(connection, t.tlsConfig)
}
client, err := smtp.NewClient(connection, t.settings.Host)
if err != nil {
_ = connection.Close()
return nil, err
}
return client, nil
}
func (t transport) negotiate(client *smtp.Client) error {
if t.settings.Port != implicitTLSPort {
if supported, _ := client.Extension("STARTTLS"); supported {
if err := client.StartTLS(t.tlsConfig); err != nil {
return err
}
}
}
return t.authenticate(client)
}
func (t transport) authenticate(client *smtp.Client) error {
if t.settings.Login == "" {
return nil
}
advertised, mechanisms := client.Extension("AUTH")
if !advertised {
return errors.New("smtp server does not support authentication")
}
return client.Auth(selectAuth(mechanisms, t.settings))
}
func writeData(client *smtp.Client, payload []byte) error {
writer, err := client.Data()
if err != nil {
return err
}
if _, err := writer.Write(payload); err != nil {
_ = writer.Close()
return err
}
return writer.Close()
}
@@ -11,8 +11,6 @@ import (
"slices" "slices"
"strings" "strings"
"time" "time"
"golang.org/x/sync/singleflight"
) )
const ( const (
@@ -54,7 +52,7 @@ type ServeVariantUseCase struct {
maxSourceBytes int64 maxSourceBytes int64
maxSourcePixels int64 maxSourcePixels int64
renderSlots chan struct{} renderSlots chan struct{}
flights singleflight.Group flights flightGroup
tracker image.VariantUsageTracker tracker image.VariantUsageTracker
} }
@@ -214,11 +212,11 @@ func (u *ServeVariantUseCase) lookupCached(variantName string, storedName string
func (u *ServeVariantUseCase) generated(ctx context.Context, variant image.Variant, storedName string, fingerprint string, previous image.VariantFile) (image.VariantFile, error) { func (u *ServeVariantUseCase) generated(ctx context.Context, variant image.Variant, storedName string, fingerprint string, previous image.VariantFile) (image.VariantFile, error) {
key := strings.Join([]string{variant.Name, fingerprint, storedName}, flightKeySep) key := strings.Join([]string{variant.Name, fingerprint, storedName}, flightKeySep)
for { for {
file, err, _ := u.flights.Do(key, func() (any, error) { file, err := u.flights.do(key, func() (image.VariantFile, error) {
return u.generate(ctx, variant, storedName, fingerprint, previous) return u.generate(ctx, variant, storedName, fingerprint, previous)
}) })
if err == nil { if err == nil {
return file.(image.VariantFile), nil return file, nil
} }
if ctx.Err() != nil || !errors.Is(err, context.Canceled) { if ctx.Err() != nil || !errors.Is(err, context.Canceled) {
return image.VariantFile{}, err return image.VariantFile{}, err
@@ -0,0 +1,45 @@
package useCases
import (
"RenewCMS/internal/domain/image"
"errors"
"sync"
)
var errFlightAbandoned = errors.New("shared call ended without a result")
type flightCall struct {
done chan struct{}
value image.VariantFile
err error
}
type flightGroup struct {
mu sync.Mutex
calls map[string]*flightCall
}
func (g *flightGroup) do(key string, work func() (image.VariantFile, error)) (image.VariantFile, error) {
g.mu.Lock()
if existing, running := g.calls[key]; running {
g.mu.Unlock()
<-existing.done
return existing.value, existing.err
}
if g.calls == nil {
g.calls = map[string]*flightCall{}
}
call := &flightCall{done: make(chan struct{}), err: errFlightAbandoned}
g.calls[key] = call
g.mu.Unlock()
defer func() {
g.mu.Lock()
delete(g.calls, key)
g.mu.Unlock()
close(call.done)
}()
call.value, call.err = work()
return call.value, call.err
}
+2 -2
View File
@@ -16,9 +16,9 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
) )
@@ -117,7 +117,7 @@ func newAtomicAcceptFixture(t *testing.T, wrap func(db *gorm.DB, repo invite.Rep
handler := auth.NewAcceptInviteHandler( handler := auth.NewAcceptInviteHandler(
useCases.NewGetInviteUseCase(invites, hasher), useCases.NewGetInviteUseCase(invites, hasher),
useCases.NewAcceptInviteUseCase(users, invites, hasher, discardRecorder), useCases.NewAcceptInviteUseCase(users, invites, hasher, discardRecorder),
jwtauth.New("HS256", []byte("test-secret"), nil), authtoken.New([]byte("test-secret")),
true, true,
) )
router := chi.NewRouter() router := chi.NewRouter()
+9 -14
View File
@@ -17,9 +17,8 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v3/transform"
) )
const ( const (
@@ -39,7 +38,7 @@ type acceptInviteFixture struct {
mail *mockMailRepo mail *mockMailRepo
hasher *stubHasher hasher *stubHasher
router *chi.Mux router *chi.Mux
token *jwtauth.JWTAuth token *authtoken.Auth
} }
func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture { func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture {
@@ -50,7 +49,7 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
invites: newInviteFakeRepo(), invites: newInviteFakeRepo(),
mail: newMockMailRepo(), mail: newMockMailRepo(),
hasher: &stubHasher{}, hasher: &stubHasher{},
token: jwtauth.New("HS256", []byte("test-secret"), nil), token: authtoken.New([]byte("test-secret")),
} }
f.invites.users = f.users f.invites.users = f.users
getUser := useCases.NewGetUserUseCase(f.users) getUser := useCases.NewGetUserUseCase(f.users)
@@ -85,15 +84,15 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
r.Post("/invite/accept", acceptHandler.AcceptInvite) r.Post("/invite/accept", acceptHandler.AcceptInvite)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token)) r.Use(authtoken.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser)) r.Use(middleware.RequireCurrentToken(getUser))
r.Get("/me", meHandler.Me) r.Get("/me", meHandler.Me)
}) })
}) })
f.router.Route("/v1/user", func(r chi.Router) { f.router.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token)) r.Use(authtoken.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser)) r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser)) r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", listHandler.ListUsers) r.Get("/", listHandler.ListUsers)
@@ -258,12 +257,10 @@ func TestAcceptInvitationHttp(t *testing.T) {
if lifetime := time.Until(cookie.Expires); lifetime < 23*time.Hour || lifetime > 24*time.Hour+time.Minute { if lifetime := time.Until(cookie.Expires); lifetime < 23*time.Hour || lifetime > 24*time.Hour+time.Minute {
t.Errorf("expected a 24 hour cookie, got %v", lifetime) t.Errorf("expected a 24 hour cookie, got %v", lifetime)
} }
decoded, err := f.token.Decode(cookie.Value) claims, err := f.token.Decode(cookie.Value)
if err != nil { if err != nil {
t.Fatalf("cookie does not decode: %v", err) t.Fatalf("cookie does not decode: %v", err)
} }
claims := map[string]interface{}{}
_ = transform.AsMap(decoded, claims)
if claims["user_id"] != float64(created.ID) || claims["token_version"] != float64(created.TokenVersion) { if claims["user_id"] != float64(created.ID) || claims["token_version"] != float64(created.TokenVersion) {
t.Errorf("unexpected claims %v", claims) t.Errorf("unexpected claims %v", claims)
} }
@@ -414,12 +411,10 @@ func TestAcceptInvitationHttp(t *testing.T) {
} }
created, _ := f.userNamed("lea") created, _ := f.userNamed("lea")
cookie := jwtCookieOf(rec) cookie := jwtCookieOf(rec)
decoded, err := f.token.Decode(cookie.Value) claims, err := f.token.Decode(cookie.Value)
if err != nil { if err != nil {
t.Fatalf("cookie does not decode: %v", err) t.Fatalf("cookie does not decode: %v", err)
} }
claims := map[string]interface{}{}
_ = transform.AsMap(decoded, claims)
if claims["user_id"] != float64(created.ID) || created.ID == 7 { if claims["user_id"] != float64(created.ID) || created.ID == 7 {
t.Errorf("cookie must carry the new user's id, got %v", claims["user_id"]) t.Errorf("cookie must carry the new user's id, got %v", claims["user_id"])
} }
+9 -9
View File
@@ -13,11 +13,11 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newAccountVerificationRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { func newAccountVerificationRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
hasher := &stubHasher{} hasher := &stubHasher{}
verificationHandler := auth.NewVerificationHandler( verificationHandler := auth.NewVerificationHandler(
useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder), useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder),
@@ -29,8 +29,8 @@ func newAccountVerificationRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth
r.Route("/v1", func(r chi.Router) { r.Route("/v1", func(r chi.Router) {
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Post("/verify", verificationHandler.Verify) r.Post("/verify", verificationHandler.Verify)
}) })
}) })
@@ -51,11 +51,11 @@ func newAccountVerificationTestUserRepo() *mockUserRepo {
return repo return repo
} }
func sendVerifyRequest(router *chi.Mux, token *jwtauth.JWTAuth, claims map[string]any, code string) *httptest.ResponseRecorder { func sendVerifyRequest(router *chi.Mux, token *authtoken.Auth, claims map[string]any, code string) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]string{"code": code}) body, _ := json.Marshal(map[string]string{"code": code})
req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body))
if claims != nil { if claims != nil {
_, tokenString, _ := token.Encode(claims) tokenString, _ := token.Encode(claims)
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
} }
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -65,7 +65,7 @@ func sendVerifyRequest(router *chi.Mux, token *jwtauth.JWTAuth, claims map[strin
func TestAccountVerificationHttp(t *testing.T) { func TestAccountVerificationHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
t.Run("A logged-in user verifies their account with the correct emailed code", func(t *testing.T) { t.Run("A logged-in user verifies their account with the correct emailed code", func(t *testing.T) {
repo := newAccountVerificationTestUserRepo() repo := newAccountVerificationTestUserRepo()
@@ -200,7 +200,7 @@ func TestAccountVerificationHttp(t *testing.T) {
router := newAccountVerificationRouter(repo, token) router := newAccountVerificationRouter(repo, token)
req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader([]byte("not-json"))) req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader([]byte("not-json")))
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)}) tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
@@ -224,7 +224,7 @@ func TestAccountVerificationHttp(t *testing.T) {
} }
}) })
t.Run("A request with no jwt cookie is answered 401 by jwtauth itself", func(t *testing.T) { t.Run("A request with no jwt cookie is answered 401 by the authenticator itself", func(t *testing.T) {
repo := newAccountVerificationTestUserRepo() repo := newAccountVerificationTestUserRepo()
router := newAccountVerificationRouter(repo, token) router := newAccountVerificationRouter(repo, token)
+5 -5
View File
@@ -23,8 +23,8 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
const ( const (
@@ -44,7 +44,7 @@ type activityHTTPEnv struct {
keys *mockAPIKeyRepo keys *mockAPIKeyRepo
articles *mockArticleRepo articles *mockArticleRepo
mail *mockMailRepo mail *mockMailRepo
token *jwtauth.JWTAuth token *authtoken.Auth
generator *hashing.SHA256KeyGenerator generator *hashing.SHA256KeyGenerator
router http.Handler router http.Handler
} }
@@ -59,7 +59,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
keys: newMockAPIKeyRepo(), keys: newMockAPIKeyRepo(),
articles: newMockArticleRepo(), articles: newMockArticleRepo(),
mail: newMockMailRepo(), mail: newMockMailRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil), token: authtoken.New([]byte("test-secret")),
generator: hashing.NewSHA256KeyGenerator(), generator: hashing.NewSHA256KeyGenerator(),
} }
e.users.users[activityOwnerID] = user.User{ID: activityOwnerID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleOwner} e.users.users[activityOwnerID] = user.User{ID: activityOwnerID, Username: "marta", DisplayName: "Marta Alves", Email: "marta@example.com", Password: "hashed:" + activityPassword, IsVerified: true, Role: user.RoleOwner}
@@ -99,7 +99,7 @@ type activityRequest func(*http.Request)
func asUser(e *activityHTTPEnv, id uint32) activityRequest { func asUser(e *activityHTTPEnv, id uint32) activityRequest {
return func(r *http.Request) { return func(r *http.Request) {
_, tokenString, _ := e.token.Encode(map[string]any{"user_id": id, "token_version": 0}) tokenString, _ := e.token.Encode(map[string]any{"user_id": id, "token_version": 0})
r.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) r.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
} }
} }
@@ -1037,7 +1037,7 @@ func TestActivityLogBackendAuditContext(t *testing.T) {
t.Helper() t.Helper()
probe := &auditOriginProbe{} probe := &auditOriginProbe{}
r := chi.NewRouter() r := chi.NewRouter()
r.Use(jwtauth.Verifier(e.token)) r.Use(authtoken.Verifier(e.token))
r.Use(middleware.APIKeyAuth(useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, e.clock), 100, time.Minute)) r.Use(middleware.APIKeyAuth(useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, e.clock), 100, time.Minute))
r.Use(middleware.AuditContext(useCases.NewGetUserUseCase(e.users), trusted)) r.Use(middleware.AuditContext(useCases.NewGetUserUseCase(e.users), trusted))
r.Get("/probe", func(w http.ResponseWriter, req *http.Request) { r.Get("/probe", func(w http.ResponseWriter, req *http.Request) {
+4 -4
View File
@@ -27,8 +27,8 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
const ( const (
@@ -51,7 +51,7 @@ type apiKeyEnv struct {
mail *mockMailRepo mail *mockMailRepo
feed *fakeReleaseFeed feed *fakeReleaseFeed
updates *fakeUpdateStore updates *fakeUpdateStore
token *jwtauth.JWTAuth token *authtoken.Auth
generator *hashing.SHA256KeyGenerator generator *hashing.SHA256KeyGenerator
now time.Time now time.Time
settings config.Settings settings config.Settings
@@ -76,7 +76,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
images: newMockImageRepo(), images: newMockImageRepo(),
invites: newInviteFakeRepo(), invites: newInviteFakeRepo(),
updates: &fakeUpdateStore{}, updates: &fakeUpdateStore{},
token: jwtauth.New("HS256", []byte("test-secret"), nil), token: authtoken.New([]byte("test-secret")),
generator: hashing.NewSHA256KeyGenerator(), generator: hashing.NewSHA256KeyGenerator(),
now: time.Date(2026, time.October, 4, 12, 0, 0, 0, time.UTC), now: time.Date(2026, time.October, 4, 12, 0, 0, 0, time.UTC),
settings: config.Settings{AuthRateLimit: config.AuthRateLimitSettings{MaxAttempts: 1000, Window: time.Minute}}, settings: config.Settings{AuthRateLimit: config.AuthRateLimitSettings{MaxAttempts: 1000, Window: time.Minute}},
@@ -172,7 +172,7 @@ type apiKeyRequest struct {
func (e *apiKeyEnv) cookie(t *testing.T, userId uint32, tokenVersion uint32) *http.Cookie { func (e *apiKeyEnv) cookie(t *testing.T, userId uint32, tokenVersion uint32) *http.Cookie {
t.Helper() t.Helper()
_, tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion}) tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil { if err != nil {
t.Fatalf("failed to encode jwt: %v", err) t.Fatalf("failed to encode jwt: %v", err)
} }
+5 -5
View File
@@ -15,8 +15,8 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
type fakeBodyRenderer struct { type fakeBodyRenderer struct {
@@ -40,7 +40,7 @@ const (
bodyFormatArticleOneJSON = `[{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},{"type":"paragraph","children":[{"type":"text","text":"Replace the ","marks":[]},{"type":"text","text":"old","marks":[{"type":"bold"}]},{"type":"text","text":" deck.","marks":[]}]}]` bodyFormatArticleOneJSON = `[{"type":"heading","level":2,"children":[{"type":"text","text":"Plan","marks":[]}]},{"type":"paragraph","children":[{"type":"text","text":"Replace the ","marks":[]},{"type":"text","text":"old","marks":[{"type":"bold"}]},{"type":"text","text":" deck.","marks":[]}]}]`
) )
func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *jwtauth.JWTAuth) *chi.Mux { func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, renderer domainArticle.BodyRenderer, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
@@ -54,11 +54,11 @@ func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, r
r := chi.NewRouter() r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) { r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) { r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Get("/", articleHandler.ListArticles) r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle) r.Get("/{id}", articleHandler.GetArticle)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle) r.Post("/", articleHandler.PostArticle)
r.Patch("/{id}", articleHandler.UpdateArticleBody) r.Patch("/{id}", articleHandler.UpdateArticleBody)
@@ -145,7 +145,7 @@ func assertSameJSON(t *testing.T, actual, expected string) {
func TestArticleBodyFormatHttp(t *testing.T) { func TestArticleBodyFormatHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
newRouter := func(repo *mockArticleRepo, users *mockUserRepo, renderer domainArticle.BodyRenderer) *chi.Mux { newRouter := func(repo *mockArticleRepo, users *mockUserRepo, renderer domainArticle.BodyRenderer) *chi.Mux {
return newBodyFormatRouter(repo, users, renderer, token) return newBodyFormatRouter(repo, users, renderer, token)
} }
+6 -6
View File
@@ -14,11 +14,11 @@ import (
"strings" "strings"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux { func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(newVerifiedUserRepo()) getUserUseCase := useCases.NewGetUserUseCase(newVerifiedUserRepo())
renderer := markdown.NewConverter() renderer := markdown.NewConverter()
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
@@ -34,9 +34,9 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
r.Route("/v1", func(r chi.Router) { r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) { r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle) r.Post("/", articleHandler.PostArticle)
r.Get("/{id}", articleHandler.GetArticle) r.Get("/{id}", articleHandler.GetArticle)
@@ -48,7 +48,7 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
return r return r
} }
func serveUnlimitedBody(t *testing.T, router *chi.Mux, token *jwtauth.JWTAuth, method, target, payload string) *httptest.ResponseRecorder { func serveUnlimitedBody(t *testing.T, router *chi.Mux, token *authtoken.Auth, method, target, payload string) *httptest.ResponseRecorder {
t.Helper() t.Helper()
req := newEditArticleBodyRequest(method, target, payload) req := newEditArticleBodyRequest(method, target, payload)
setEditArticleBodyJwtCookie(t, req, token) setEditArticleBodyJwtCookie(t, req, token)
@@ -59,7 +59,7 @@ func serveUnlimitedBody(t *testing.T, router *chi.Mux, token *jwtauth.JWTAuth, m
func TestArticleBodyUnlimitedHttp(t *testing.T) { func TestArticleBodyUnlimitedHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
const title = "Choosing tiles for a small bathroom" const title = "Choosing tiles for a small bathroom"
createCases := []struct { createCases := []struct {
+7 -7
View File
@@ -14,11 +14,11 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux { func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
userRepo := newVerifiedUserRepo() userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
@@ -35,10 +35,10 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWT
r.Route("/v1", func(r chi.Router) { r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) { r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Get("/", articleHandler.ListArticles) r.Get("/", articleHandler.ListArticles)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Get("/{id}", articleHandler.GetArticle) r.Get("/{id}", articleHandler.GetArticle)
r.Post("/", articleHandler.PostArticle) r.Post("/", articleHandler.PostArticle)
@@ -59,9 +59,9 @@ func newPublishingTestArticleRepo() *mockArticleRepo {
return repo return repo
} }
func setPublishingJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth) { func setPublishingJwtCookie(t *testing.T, req *http.Request, token *authtoken.Auth) {
t.Helper() t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)}) tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
if err != nil { if err != nil {
t.Fatalf("failed to encode jwt: %v", err) t.Fatalf("failed to encode jwt: %v", err)
} }
@@ -70,7 +70,7 @@ func setPublishingJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTA
func TestArticlePublishingHttp(t *testing.T) { func TestArticlePublishingHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
t.Run("Publishing an offline article makes it online", func(t *testing.T) { t.Run("Publishing an offline article makes it online", func(t *testing.T) {
repo := newPublishingTestArticleRepo() repo := newPublishingTestArticleRepo()
+6 -6
View File
@@ -17,11 +17,11 @@ import (
"strings" "strings"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux { func newArticleTitleRulesRouter(repo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
userRepo := newVerifiedUserRepo() userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
@@ -37,8 +37,8 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
r := chi.NewRouter() r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) { r.Route("/v1", func(r chi.Router) {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Route("/article", func(r chi.Router) { r.Route("/article", func(r chi.Router) {
r.Post("/", articleHandler.PostArticle) r.Post("/", articleHandler.PostArticle)
@@ -48,7 +48,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *jwtauth.JWTAuth) *
return r return r
} }
func postArticleRequest(t *testing.T, token *jwtauth.JWTAuth, title string, body string) *http.Request { func postArticleRequest(t *testing.T, token *authtoken.Auth, title string, body string) *http.Request {
t.Helper() t.Helper()
payload, err := json.Marshal(map[string]string{"title": title, "body": body}) payload, err := json.Marshal(map[string]string{"title": title, "body": body})
if err != nil { if err != nil {
@@ -122,7 +122,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
func TestArticleTitleRunesOverHttp(t *testing.T) { func TestArticleTitleRunesOverHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
cases := []struct { cases := []struct {
length int length int
+4 -4
View File
@@ -9,13 +9,13 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
) )
func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux { func newArticlesListRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
userRepo := newVerifiedUserRepo() userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
@@ -31,7 +31,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
r := chi.NewRouter() r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) { r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) { r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Get("/", articleHandler.ListArticles) r.Get("/", articleHandler.ListArticles)
}) })
}) })
@@ -40,7 +40,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth)
func TestArticlesListHttp(t *testing.T) { func TestArticlesListHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
t.Run("GET /v1/article/ items carry no images field now that images belong to the library", func(t *testing.T) { t.Run("GET /v1/article/ items carry no images field now that images belong to the library", func(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
+2 -2
View File
@@ -15,7 +15,7 @@ import (
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"errors" "errors"
"github.com/go-chi/jwtauth/v5" "RenewCMS/api/pkg/authtoken"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
) )
@@ -71,7 +71,7 @@ func itemIds(envelope paginationEnvelope) []int {
func TestArticlesPaginationApiHttp(t *testing.T) { func TestArticlesPaginationApiHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
get := func(t *testing.T, repo *mockArticleRepo, query string, verified bool) (*httptest.ResponseRecorder, paginationEnvelope) { get := func(t *testing.T, repo *mockArticleRepo, query string, verified bool) (*httptest.ResponseRecorder, paginationEnvelope) {
t.Helper() t.Helper()
+162
View File
@@ -0,0 +1,162 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/authtoken"
"net/http"
"net/http/httptest"
"testing"
)
type authTokenProbe struct {
reached bool
claims authtoken.Claims
err error
}
func authTokenRouter(auth *authtoken.Auth, probe *authTokenProbe, guarded bool) http.Handler {
final := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
probe.reached = true
probe.claims, probe.err = authtoken.FromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
if guarded {
return authtoken.Verifier(auth)(authtoken.Authenticator(final))
}
return authtoken.Verifier(auth)(final)
}
func serveAuthToken(handler http.Handler, configure func(*http.Request)) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/", nil)
configure(req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func TestAuthTokenVerifierAndAuthenticator(t *testing.T) {
auth := authtoken.New([]byte("test-secret"))
valid, _ := auth.Encode(map[string]any{"user_id": uint32(5)})
other, _ := auth.Encode(map[string]any{"user_id": uint32(9)})
t.Run("A valid jwt cookie reaches the handler with its claims", func(t *testing.T) {
probe := &authTokenProbe{}
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "jwt", Value: valid})
})
if rec.Code != http.StatusOK || probe.claims["user_id"] != float64(5) {
t.Errorf("expected 200 with user 5, got %d and %v", rec.Code, probe.claims)
}
})
t.Run("A bearer header in any letter case is accepted", func(t *testing.T) {
for _, scheme := range []string{"Bearer ", "BEARER ", "bearer "} {
probe := &authTokenProbe{}
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
r.Header.Set("Authorization", scheme+valid)
})
if rec.Code != http.StatusOK {
t.Errorf("scheme %q: expected 200, got %d", scheme, rec.Code)
}
}
})
t.Run("The bearer header wins over the cookie", func(t *testing.T) {
probe := &authTokenProbe{}
serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
r.Header.Set("Authorization", "Bearer "+valid)
r.AddCookie(&http.Cookie{Name: "jwt", Value: other})
})
if probe.claims["user_id"] != float64(5) {
t.Errorf("expected the header token, got %v", probe.claims)
}
})
t.Run("A malformed Authorization header falls back to the cookie", func(t *testing.T) {
for _, header := range []string{"Bearer", "Bearer ", "Basic abc", "Token " + valid} {
probe := &authTokenProbe{}
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
r.Header.Set("Authorization", header)
r.AddCookie(&http.Cookie{Name: "jwt", Value: valid})
})
if rec.Code != http.StatusOK {
t.Errorf("header %q: expected the cookie to be used, got %d", header, rec.Code)
}
}
})
t.Run("The authenticator answers 401 with the standard message when no token is sent", func(t *testing.T) {
probe := &authTokenProbe{}
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(*http.Request) {})
if rec.Code != http.StatusUnauthorized || probe.reached {
t.Fatalf("expected a blocked 401, got %d reached=%v", rec.Code, probe.reached)
}
if got := rec.Body.String(); got != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("unexpected body %q", got)
}
})
t.Run("The authenticator rejects a forged token", func(t *testing.T) {
probe := &authTokenProbe{}
forged, _ := authtoken.New([]byte("another-secret")).Encode(map[string]any{"user_id": 1})
rec := serveAuthToken(authTokenRouter(auth, probe, true), func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "jwt", Value: forged})
})
if rec.Code != http.StatusUnauthorized || probe.reached {
t.Errorf("expected a blocked 401, got %d reached=%v", rec.Code, probe.reached)
}
})
t.Run("Without the authenticator the handler sees the verification error", func(t *testing.T) {
probe := &authTokenProbe{}
serveAuthToken(authTokenRouter(auth, probe, false), func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "jwt", Value: "garbage"})
})
if !probe.reached || probe.err == nil || probe.claims != nil {
t.Errorf("expected an error and no claims, got %v / %v", probe.claims, probe.err)
}
})
t.Run("A context that never passed the verifier reports no token", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if _, err := authtoken.FromContext(req.Context()); err != authtoken.ErrNoToken {
t.Errorf("expected ErrNoToken, got %v", err)
}
})
t.Run("A context carrying nil claims and no error still reports no token", func(t *testing.T) {
ctx := authtoken.NewContext(httptest.NewRequest(http.MethodGet, "/", nil).Context(), nil, nil)
if _, err := authtoken.FromContext(ctx); err != authtoken.ErrNoToken {
t.Errorf("expected ErrNoToken, got %v", err)
}
})
t.Run("A context injected with claims is authenticated", func(t *testing.T) {
probe := &authTokenProbe{}
handler := authtoken.Authenticator(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { probe.reached = true }))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req = req.WithContext(authtoken.NewContext(req.Context(), authtoken.Claims{"user_id": float64(1)}, nil))
handler.ServeHTTP(httptest.NewRecorder(), req)
if !probe.reached {
t.Error("expected the handler to be reached")
}
})
}
+181
View File
@@ -0,0 +1,181 @@
package test
import (
"RenewCMS/api/pkg/authtoken"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"strconv"
"strings"
"testing"
"time"
)
var authTokenEpoch = time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC)
func authTokenAt(now time.Time) *authtoken.Auth {
return authtoken.New([]byte("test-secret"), authtoken.WithClock(func() time.Time { return now }))
}
func signedAuthTokenSegments(secret string, header string, payload string) string {
encode := base64.RawURLEncoding.EncodeToString
signingInput := encode([]byte(header)) + "." + encode([]byte(payload))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingInput))
return signingInput + "." + encode(mac.Sum(nil))
}
func TestAuthTokenEncodeDecode(t *testing.T) {
t.Run("A token round-trips its claims", func(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
encoded, err := auth.Encode(map[string]any{"user_id": uint32(7), "token_version": uint32(3)})
if err != nil {
t.Fatalf("encode failed: %v", err)
}
claims, err := auth.Decode(encoded)
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if claims["user_id"] != float64(7) || claims["token_version"] != float64(3) {
t.Errorf("unexpected claims %v", claims)
}
})
t.Run("Encoding stamps iat and an exp one session lifetime ahead", func(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
claims, _ := auth.Decode(encoded)
if claims["iat"] != float64(authTokenEpoch.Unix()) {
t.Errorf("expected iat %d, got %v", authTokenEpoch.Unix(), claims["iat"])
}
if claims["exp"] != float64(authTokenEpoch.Add(authtoken.SessionLifetime).Unix()) {
t.Errorf("unexpected exp %v", claims["exp"])
}
})
t.Run("Encoding keeps an explicit exp", func(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
explicit := authTokenEpoch.Add(time.Minute).Unix()
encoded, _ := auth.Encode(map[string]any{"exp": explicit})
claims, _ := auth.Decode(encoded)
if claims["exp"] != float64(explicit) {
t.Errorf("expected exp %d, got %v", explicit, claims["exp"])
}
})
t.Run("Encoding does not mutate the caller's claims", func(t *testing.T) {
input := map[string]any{"user_id": 1}
_, _ = authTokenAt(authTokenEpoch).Encode(input)
if len(input) != 1 {
t.Errorf("input was mutated: %v", input)
}
})
t.Run("Encoding nil claims still yields a decodable token", func(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
encoded, err := auth.Encode(nil)
if err != nil {
t.Fatalf("encode failed: %v", err)
}
if _, err := auth.Decode(encoded); err != nil {
t.Errorf("decode failed: %v", err)
}
})
t.Run("Encoding reports claims that cannot be serialized", func(t *testing.T) {
if _, err := authTokenAt(authTokenEpoch).Encode(map[string]any{"bad": make(chan int)}); err == nil {
t.Error("expected an error")
}
})
}
func TestAuthTokenDecodeRejections(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
valid, _ := auth.Encode(map[string]any{"user_id": 1})
segments := strings.Split(valid, ".")
header := `{"alg":"HS256","typ":"JWT"}`
cases := map[string]struct {
token string
want error
}{
"empty": {"", authtoken.ErrNoToken},
"one segment": {"abc", authtoken.ErrInvalid},
"four segments": {valid + ".x", authtoken.ErrInvalid},
"not base64 header": {"!!!." + segments[1] + "." + segments[2], authtoken.ErrInvalid},
"signed with another secret": {signedAuthTokenSegments("other", header, `{"user_id":1}`), authtoken.ErrInvalid},
"alg none": {signedAuthTokenSegments("test-secret", `{"alg":"none"}`, `{"user_id":1}`), authtoken.ErrInvalid},
"alg HS512": {signedAuthTokenSegments("test-secret", `{"alg":"HS512"}`, `{"user_id":1}`), authtoken.ErrInvalid},
"alg missing": {signedAuthTokenSegments("test-secret", `{}`, `{"user_id":1}`), authtoken.ErrInvalid},
"tampered payload": {segments[0] + "." + base64.RawURLEncoding.EncodeToString([]byte(`{"user_id":2}`)) + "." + segments[2], authtoken.ErrInvalid},
"truncated signature": {segments[0] + "." + segments[1] + "." + segments[2][:10], authtoken.ErrInvalid},
"empty signature": {segments[0] + "." + segments[1] + ".", authtoken.ErrInvalid},
"payload not an object": {signedAuthTokenSegments("test-secret", header, `[1]`), authtoken.ErrInvalid},
"payload null": {signedAuthTokenSegments("test-secret", header, `null`), authtoken.ErrInvalid},
"exp not a number": {signedAuthTokenSegments("test-secret", header, `{"exp":"soon"}`), authtoken.ErrInvalid},
"nbf not a number": {signedAuthTokenSegments("test-secret", header, `{"nbf":"soon"}`), authtoken.ErrInvalid},
"expired": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`), authtoken.ErrExpired},
"expiring exactly now": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Unix())+`}`), authtoken.ErrExpired},
"not yet valid": {signedAuthTokenSegments("test-secret", header, `{"nbf":`+authTokenUnix(authTokenEpoch.Add(time.Second).Unix())+`}`), authtoken.ErrNotYetValid},
"larger than the size budget": {strings.Repeat("a", 5000), authtoken.ErrInvalid},
}
for name, tc := range cases {
t.Run("Rejects "+name, func(t *testing.T) {
claims, err := auth.Decode(tc.token)
if !errors.Is(err, tc.want) {
t.Errorf("expected %v, got %v", tc.want, err)
}
if claims != nil {
t.Errorf("expected no claims, got %v", claims)
}
})
}
t.Run("Accepts a token with no exp so sessions issued before expiry existed keep working", func(t *testing.T) {
legacy := signedAuthTokenSegments("test-secret", header, `{"user_id":4,"token_version":1}`)
claims, err := auth.Decode(legacy)
if err != nil || claims["user_id"] != float64(4) {
t.Errorf("expected legacy token accepted, got %v, %v", claims, err)
}
})
t.Run("Accepts a token whose nbf has passed", func(t *testing.T) {
token := signedAuthTokenSegments("test-secret", header, `{"nbf":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`)
if _, err := auth.Decode(token); err != nil {
t.Errorf("expected acceptance, got %v", err)
}
})
t.Run("A token expires once the clock passes its lifetime", func(t *testing.T) {
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
justBefore := authTokenAt(authTokenEpoch.Add(authtoken.SessionLifetime - time.Second))
justAfter := authTokenAt(authTokenEpoch.Add(authtoken.SessionLifetime))
if _, err := justBefore.Decode(encoded); err != nil {
t.Errorf("expected valid before expiry, got %v", err)
}
if _, err := justAfter.Decode(encoded); !errors.Is(err, authtoken.ErrExpired) {
t.Errorf("expected expired, got %v", err)
}
})
}
func authTokenUnix(value int64) string {
return strconv.FormatInt(value, 10)
}
@@ -11,11 +11,11 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *jwtauth.JWTAuth) *chi.Mux { func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *authtoken.Auth) *chi.Mux {
hasher := &stubHasher{} hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
@@ -34,8 +34,8 @@ func newCancelRegistrationRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo,
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
r.Post("/register", loginHandler.Register) r.Post("/register", loginHandler.Register)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Post("/verify", verificationHandler.Verify) r.Post("/verify", verificationHandler.Verify)
r.Delete("/register", verificationHandler.CancelRegistration) r.Delete("/register", verificationHandler.CancelRegistration)
}) })
@@ -56,10 +56,10 @@ func newCancelRegistrationTestUserRepo(isVerified bool) *mockUserRepo {
return repo return repo
} }
func sendCancelRegistrationRequest(router *chi.Mux, token *jwtauth.JWTAuth, claims map[string]any) *httptest.ResponseRecorder { func sendCancelRegistrationRequest(router *chi.Mux, token *authtoken.Auth, claims map[string]any) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodDelete, "/v1/auth/register", nil) req := httptest.NewRequest(http.MethodDelete, "/v1/auth/register", nil)
if claims != nil { if claims != nil {
_, tokenString, _ := token.Encode(claims) tokenString, _ := token.Encode(claims)
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
} }
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -83,7 +83,7 @@ func jwtSetCookie(t *testing.T, rec *httptest.ResponseRecorder) (*http.Cookie, b
func TestCancelPendingRegistrationHttp(t *testing.T) { func TestCancelPendingRegistrationHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
t.Run("An unverified user cancels their pending registration", func(t *testing.T) { t.Run("An unverified user cancels their pending registration", func(t *testing.T) {
repo := newCancelRegistrationTestUserRepo(false) repo := newCancelRegistrationTestUserRepo(false)
+6 -6
View File
@@ -7,12 +7,12 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/go-chi/jwtauth/v5" "RenewCMS/api/pkg/authtoken"
) )
func requestWithJwtClaims(t *testing.T, token *jwtauth.JWTAuth, claimsToEncode map[string]any) *http.Request { func requestWithJwtClaims(t *testing.T, token *authtoken.Auth, claimsToEncode map[string]any) *http.Request {
t.Helper() t.Helper()
_, tokenString, err := token.Encode(claimsToEncode) tokenString, err := token.Encode(claimsToEncode)
if err != nil { if err != nil {
t.Fatalf("failed to encode jwt: %v", err) t.Fatalf("failed to encode jwt: %v", err)
} }
@@ -21,11 +21,11 @@ func requestWithJwtClaims(t *testing.T, token *jwtauth.JWTAuth, claimsToEncode m
t.Fatalf("failed to decode jwt: %v", err) t.Fatalf("failed to decode jwt: %v", err)
} }
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/", nil)
return req.WithContext(jwtauth.NewContext(req.Context(), decoded, nil)) return req.WithContext(authtoken.NewContext(req.Context(), decoded, nil))
} }
func TestClaimsTokenVersion(t *testing.T) { func TestClaimsTokenVersion(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
t.Run("returns the numeric token_version claim", func(t *testing.T) { t.Run("returns the numeric token_version claim", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(3)}) req := requestWithJwtClaims(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(3)})
@@ -68,7 +68,7 @@ func TestClaimsTokenVersion(t *testing.T) {
} }
func TestClaimsUserId(t *testing.T) { func TestClaimsUserId(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
t.Run("returns the numeric user_id claim", func(t *testing.T) { t.Run("returns the numeric user_id claim", func(t *testing.T) {
req := requestWithJwtClaims(t, token, map[string]any{"user_id": uint32(7)}) req := requestWithJwtClaims(t, token, map[string]any{"user_id": uint32(7)})
+2 -2
View File
@@ -11,7 +11,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/go-chi/jwtauth/v5" "RenewCMS/api/pkg/authtoken"
) )
type containerHarness struct { type containerHarness struct {
@@ -28,7 +28,7 @@ func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *co
rig.helper.installed = helperInstalled rig.helper.installed = helperInstalled
helper := infraupdate.NewContainerHelperDetector(rig.helper, container) helper := infraupdate.NewContainerHelperDetector(rig.helper, container)
now := func() time.Time { return updateTestCheckedAt } now := func() time.Time { return updateTestCheckedAt }
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
feed := &fakeReleaseFeed{release: update.Release{ feed := &fakeReleaseFeed{release: update.Release{
Tag: "v2.3.0-beta", Tag: "v2.3.0-beta",
URL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta", URL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta",
+225
View File
@@ -0,0 +1,225 @@
package test
import (
"RenewCMS/api/middleware"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type corsExchange struct {
rec *httptest.ResponseRecorder
reached bool
}
func corsRequest(origins []string, method string, headers map[string]string) corsExchange {
exchange := corsExchange{}
handler := middleware.CORS(origins)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
exchange.reached = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(method, "/v1/article", nil)
for name, value := range headers {
req.Header.Set(name, value)
}
exchange.rec = httptest.NewRecorder()
handler.ServeHTTP(exchange.rec, req)
return exchange
}
func corsPreflight(origins []string, origin string, method string, requestHeaders string) corsExchange {
headers := map[string]string{"Origin": origin, "Access-Control-Request-Method": method}
if requestHeaders != "" {
headers["Access-Control-Request-Headers"] = requestHeaders
}
return corsRequest(origins, http.MethodOptions, headers)
}
func TestCORSActualRequests(t *testing.T) {
origins := []string{"https://app.example.com", "https://*.preview.example.com"}
t.Run("An allowed origin is echoed and the exposed headers are listed", func(t *testing.T) {
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://app.example.com"})
if got.rec.Header().Get("Access-Control-Allow-Origin") != "https://app.example.com" {
t.Errorf("unexpected allow-origin %q", got.rec.Header().Get("Access-Control-Allow-Origin"))
}
if got.rec.Header().Get("Access-Control-Expose-Headers") != "Link, X-Total-Count" {
t.Errorf("unexpected expose-headers %q", got.rec.Header().Get("Access-Control-Expose-Headers"))
}
if !got.reached {
t.Error("the request must reach the handler")
}
})
t.Run("Credentials are never allowed", func(t *testing.T) {
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://app.example.com"})
if got.rec.Header().Get("Access-Control-Allow-Credentials") != "" {
t.Error("Access-Control-Allow-Credentials must not be set")
}
})
t.Run("The match is case-insensitive", func(t *testing.T) {
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "HTTPS://APP.EXAMPLE.COM"})
if got.rec.Header().Get("Access-Control-Allow-Origin") == "" {
t.Error("expected the origin to be allowed")
}
})
t.Run("A wildcard subdomain pattern matches", func(t *testing.T) {
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://pr-12.preview.example.com"})
if got.rec.Header().Get("Access-Control-Allow-Origin") != "https://pr-12.preview.example.com" {
t.Errorf("unexpected allow-origin %q", got.rec.Header().Get("Access-Control-Allow-Origin"))
}
})
t.Run("A wildcard pattern does not match a look-alike", func(t *testing.T) {
for _, origin := range []string{"https://preview.example.com", "https://x.preview.example.com.evil.net", "http://x.preview.example.com"} {
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": origin})
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" {
t.Errorf("origin %q must not be allowed", origin)
}
}
})
t.Run("An unknown origin gets no CORS headers but still reaches the handler", func(t *testing.T) {
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://evil.example.net"})
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" || !got.reached {
t.Errorf("unexpected exchange %v reached=%v", got.rec.Header(), got.reached)
}
})
t.Run("A request with no Origin is untouched", func(t *testing.T) {
got := corsRequest(origins, http.MethodGet, nil)
if got.rec.Header().Get("Vary") != "" || got.rec.Header().Get("Access-Control-Allow-Origin") != "" || !got.reached {
t.Errorf("unexpected headers %v", got.rec.Header())
}
})
t.Run("Responses vary on Origin so shared caches stay correct", func(t *testing.T) {
got := corsRequest(origins, http.MethodGet, map[string]string{"Origin": "https://evil.example.net"})
if got.rec.Header().Get("Vary") != "Origin" {
t.Errorf("unexpected Vary %q", got.rec.Header().Get("Vary"))
}
})
t.Run("A lone star allows any origin", func(t *testing.T) {
got := corsRequest([]string{"*"}, http.MethodGet, map[string]string{"Origin": "https://anything.example.net"})
if got.rec.Header().Get("Access-Control-Allow-Origin") != "https://anything.example.net" {
t.Errorf("unexpected allow-origin %q", got.rec.Header().Get("Access-Control-Allow-Origin"))
}
})
t.Run("Blank entries never match the empty origin", func(t *testing.T) {
got := corsRequest([]string{"", " "}, http.MethodGet, map[string]string{"Origin": "https://app.example.com"})
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" {
t.Error("blank entries must be ignored")
}
})
}
func TestCORSPreflight(t *testing.T) {
origins := []string{"https://app.example.com"}
t.Run("An allowed preflight is answered 204 without reaching the handler", func(t *testing.T) {
got := corsPreflight(origins, "https://app.example.com", http.MethodPatch, "Content-Type, Authorization")
header := got.rec.Header()
if got.rec.Code != http.StatusNoContent || got.reached {
t.Fatalf("expected 204 and no handler, got %d reached=%v", got.rec.Code, got.reached)
}
if header.Get("Access-Control-Allow-Origin") != "https://app.example.com" {
t.Errorf("unexpected allow-origin %q", header.Get("Access-Control-Allow-Origin"))
}
if header.Get("Access-Control-Allow-Methods") != "PATCH" {
t.Errorf("unexpected allow-methods %q", header.Get("Access-Control-Allow-Methods"))
}
if header.Get("Access-Control-Max-Age") != "300" {
t.Errorf("unexpected max-age %q", header.Get("Access-Control-Max-Age"))
}
for _, allowed := range []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"} {
if !strings.Contains(header.Get("Access-Control-Allow-Headers"), allowed) {
t.Errorf("allow-headers misses %s: %q", allowed, header.Get("Access-Control-Allow-Headers"))
}
}
})
t.Run("Every supported method is allowed", func(t *testing.T) {
for _, method := range []string{"GET", "POST", "PUT", "PATCH", "DELETE"} {
got := corsPreflight(origins, "https://app.example.com", method, "")
if got.rec.Header().Get("Access-Control-Allow-Methods") != method {
t.Errorf("method %s not allowed", method)
}
}
})
t.Run("The requested method is matched case-insensitively", func(t *testing.T) {
got := corsPreflight(origins, "https://app.example.com", "patch", "")
if got.rec.Header().Get("Access-Control-Allow-Methods") != "PATCH" {
t.Errorf("unexpected allow-methods %q", got.rec.Header().Get("Access-Control-Allow-Methods"))
}
})
t.Run("A method outside the list is refused", func(t *testing.T) {
got := corsPreflight(origins, "https://app.example.com", "TRACE", "")
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" || got.rec.Header().Get("Access-Control-Allow-Methods") != "" {
t.Errorf("unexpected headers %v", got.rec.Header())
}
})
t.Run("A header outside the list is refused", func(t *testing.T) {
got := corsPreflight(origins, "https://app.example.com", "GET", "Content-Type, X-Evil")
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" {
t.Errorf("unexpected headers %v", got.rec.Header())
}
})
t.Run("Requested headers are matched case-insensitively and tolerate spacing", func(t *testing.T) {
got := corsPreflight(origins, "https://app.example.com", "GET", "content-type ,AUTHORIZATION,")
if got.rec.Header().Get("Access-Control-Allow-Origin") == "" {
t.Error("expected the preflight to be allowed")
}
})
t.Run("An unknown origin is refused", func(t *testing.T) {
got := corsPreflight(origins, "https://evil.example.net", "GET", "")
if got.rec.Header().Get("Access-Control-Allow-Origin") != "" || got.reached {
t.Errorf("unexpected exchange %v reached=%v", got.rec.Header(), got.reached)
}
})
t.Run("The preflight response varies on every request header it depends on", func(t *testing.T) {
got := corsPreflight(origins, "https://app.example.com", "GET", "")
vary := strings.Join(got.rec.Header().Values("Vary"), ",")
for _, field := range []string{"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"} {
if !strings.Contains(vary, field) {
t.Errorf("Vary misses %s: %q", field, vary)
}
}
})
t.Run("An OPTIONS request without a requested method is treated as a normal request", func(t *testing.T) {
got := corsRequest(origins, http.MethodOptions, map[string]string{"Origin": "https://app.example.com"})
if !got.reached {
t.Error("expected the handler to be reached")
}
})
}
@@ -27,8 +27,8 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
const supportRecipeKey = "recipe" const supportRecipeKey = "recipe"
@@ -377,7 +377,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
t.Run("The built-in routes are read-only and restricted", func(t *testing.T) { t.Run("The built-in routes are read-only and restricted", func(t *testing.T) {
env := newSupportEnv(t) env := newSupportEnv(t)
stale := func(req *http.Request) { stale := func(req *http.Request) {
_, tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0}) tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
} }
apiKey := env.issueKey(ctAdminId) apiKey := env.issueKey(ctAdminId)
@@ -414,7 +414,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
validator.InitValidator() validator.InitValidator()
users := newMockUserRepo() users := newMockUserRepo()
users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin} users.users[ctAdminId] = user.User{ID: ctAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin}
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
now := func() time.Time { return time.Now() } now := func() time.Time { return time.Now() }
generator := hashing.NewSHA256KeyGenerator() generator := hashing.NewSHA256KeyGenerator()
container := &api.UseCases{ container := &api.UseCases{
@@ -428,7 +428,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
} }
router := chi.NewRouter() router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{})) router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(ctAdminId), "token_version": 0}) tokenString, _ := token.Encode(map[string]any{"user_id": uint32(ctAdminId), "token_version": 0})
for _, path := range []string{"/v1/builtin-type", "/v1/builtin-type/site"} { for _, path := range []string{"/v1/builtin-type", "/v1/builtin-type/site"} {
req := httptest.NewRequest(http.MethodGet, path, nil) req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
@@ -576,7 +576,7 @@ func TestUserLookupHTTP(t *testing.T) {
env := newSupportEnv(t) env := newSupportEnv(t)
apiKey := env.issueKey(ctAdminId) apiKey := env.issueKey(ctAdminId)
stale := func(req *http.Request) { stale := func(req *http.Request) {
_, tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0}) tokenString, _ := env.token.Encode(map[string]any{"user_id": uint32(ctStaleId), "token_version": 0})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
} }
cases := []struct { cases := []struct {
+4 -4
View File
@@ -19,8 +19,8 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
const ( const (
@@ -238,7 +238,7 @@ type contentTypeEnv struct {
users *mockUserRepo users *mockUserRepo
keys *mockAPIKeyRepo keys *mockAPIKeyRepo
generator *hashing.SHA256KeyGenerator generator *hashing.SHA256KeyGenerator
token *jwtauth.JWTAuth token *authtoken.Auth
clock time.Time clock time.Time
router http.Handler router http.Handler
} }
@@ -254,7 +254,7 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
users: newMockUserRepo(), users: newMockUserRepo(),
keys: newMockAPIKeyRepo(), keys: newMockAPIKeyRepo(),
generator: hashing.NewSHA256KeyGenerator(), generator: hashing.NewSHA256KeyGenerator(),
token: jwtauth.New("HS256", []byte("test-secret"), nil), token: authtoken.New([]byte("test-secret")),
clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC), clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC),
} }
env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", IsVerified: true, Role: user.RoleOwner} env.users.users[ctOwnerId] = user.User{ID: ctOwnerId, Username: "marta", IsVerified: true, Role: user.RoleOwner}
@@ -313,7 +313,7 @@ func (e *contentTypeEnv) asUser(t *testing.T, userId uint32) func(*http.Request)
if userId == 0 { if userId == 0 {
return return
} }
_, tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": 0}) tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": 0})
if err != nil { if err != nil {
t.Fatalf("failed to encode jwt: %v", err) t.Fatalf("failed to encode jwt: %v", err)
} }
+157
View File
@@ -0,0 +1,157 @@
package test
import (
"RenewCMS/internal/infrastructure/config"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseDotenv(t *testing.T) {
cases := map[string]struct {
input string
want map[string]string
}{
"plain assignment": {"A=1", map[string]string{"A": "1"}},
"spaces around the equals sign": {"A = 1", map[string]string{"A": "1"}},
"export prefix": {"export A=1", map[string]string{"A": "1"}},
"empty value": {"A=", map[string]string{"A": ""}},
"blank and comment lines": {"\n# note\n \nA=1\n # indented\n", map[string]string{"A": "1"}},
"inline comment on an unquoted value": {"A=value # trailing", map[string]string{"A": "value"}},
"hash without a leading space": {"A=pass#word", map[string]string{"A": "pass#word"}},
"equals sign inside the value": {"A=b=c", map[string]string{"A": "b=c"}},
"URL value": {"A=http://localhost:5173/x?y=1", map[string]string{"A": "http://localhost:5173/x?y=1"}},
"double quoted keeps spaces and hash": {`A=" a # b "`, map[string]string{"A": " a # b "}},
"double quoted escapes": {`A="l1\nl2\t\"q\" \\ \x"`, map[string]string{"A": "l1\nl2\t\"q\" \\ \\x"}},
"double quoted then comment": {`A="v" # note`, map[string]string{"A": "v"}},
"single quoted is literal": {`A='a\nb # c'`, map[string]string{"A": `a\nb # c`}},
"dollar signs are not expanded": {"A=pa$$word${B}", map[string]string{"A": "pa$$word${B}"}},
"dotted names": {"a.b_c1=2", map[string]string{"a.b_c1": "2"}},
"windows line endings": {"A=1\r\nB=2\r\n", map[string]string{"A": "1", "B": "2"}},
"byte order mark": {"\xef\xbb\xbfA=1", map[string]string{"A": "1"}},
"last duplicate wins": {"A=1\nA=2", map[string]string{"A": "2"}},
"no trailing newline": {"A=1\nB=2", map[string]string{"A": "1", "B": "2"}},
}
for name, tc := range cases {
t.Run("Parses "+name, func(t *testing.T) {
got, err := config.ParseDotenv(strings.NewReader(tc.input))
if err != nil {
t.Fatalf("unexpected error %v", err)
}
if len(got) != len(tc.want) {
t.Fatalf("expected %v, got %v", tc.want, got)
}
for key, value := range tc.want {
if got[key] != value {
t.Errorf("%s: expected %q, got %q", key, value, got[key])
}
}
})
}
rejected := map[string]string{
"a line with no equals sign": "JUST_A_WORD",
"an empty name": "=value",
"a name with a space": "MY VAR=1",
"a name with a dash": "MY-VAR=1",
"an unterminated double quote": `A="open`,
"a trailing backslash": `A="open\`,
"an unterminated single quote": `A='open`,
}
for name, input := range rejected {
t.Run("Rejects "+name+" and names the line", func(t *testing.T) {
_, err := config.ParseDotenv(strings.NewReader("OK=1\n" + input))
if err == nil || !strings.Contains(err.Error(), "line 2") {
t.Errorf("expected a line 2 error, got %v", err)
}
})
}
t.Run("Rejects a line longer than the budget", func(t *testing.T) {
_, err := config.ParseDotenv(strings.NewReader("A=" + strings.Repeat("x", 2<<20)))
if err == nil {
t.Error("expected an error")
}
})
}
func TestLoadDotenv(t *testing.T) {
write := func(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), ".env")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
t.Run("Sets variables that are not already set", func(t *testing.T) {
t.Setenv("DOTENV_TEST_NEW", "")
_ = os.Unsetenv("DOTENV_TEST_NEW")
err := config.LoadDotenv(write(t, "DOTENV_TEST_NEW=from-file"))
if err != nil || os.Getenv("DOTENV_TEST_NEW") != "from-file" {
t.Errorf("expected from-file, got %q (%v)", os.Getenv("DOTENV_TEST_NEW"), err)
}
_ = os.Unsetenv("DOTENV_TEST_NEW")
})
t.Run("Never overrides a variable already in the environment", func(t *testing.T) {
t.Setenv("DOTENV_TEST_KEEP", "from-env")
_ = config.LoadDotenv(write(t, "DOTENV_TEST_KEEP=from-file"))
if os.Getenv("DOTENV_TEST_KEEP") != "from-env" {
t.Errorf("environment was overridden: %q", os.Getenv("DOTENV_TEST_KEEP"))
}
})
t.Run("Keeps an explicitly empty environment variable", func(t *testing.T) {
t.Setenv("DOTENV_TEST_EMPTY", "")
_ = config.LoadDotenv(write(t, "DOTENV_TEST_EMPTY=from-file"))
if value, set := os.LookupEnv("DOTENV_TEST_EMPTY"); !set || value != "" {
t.Errorf("expected an empty value, got %q set=%v", value, set)
}
})
t.Run("Applies nothing when any line is malformed", func(t *testing.T) {
_ = os.Unsetenv("DOTENV_TEST_ATOMIC")
err := config.LoadDotenv(write(t, "DOTENV_TEST_ATOMIC=1\nbroken line"))
if err == nil {
t.Fatal("expected an error")
}
if _, set := os.LookupEnv("DOTENV_TEST_ATOMIC"); set {
t.Error("a partially applied file must not leak variables")
}
})
t.Run("Names the file in a parse error", func(t *testing.T) {
path := write(t, "broken line")
err := config.LoadDotenv(path)
if err == nil || !strings.Contains(err.Error(), path) {
t.Errorf("expected the path in the error, got %v", err)
}
})
t.Run("Reports a missing file as fs.ErrNotExist", func(t *testing.T) {
err := config.LoadDotenv(filepath.Join(t.TempDir(), "absent.env"))
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("expected fs.ErrNotExist, got %v", err)
}
})
}
+7 -7
View File
@@ -15,11 +15,11 @@ import (
"strings" "strings"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux { func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
userRepo := newVerifiedUserRepo() userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
@@ -36,9 +36,9 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAu
r.Route("/v1", func(r chi.Router) { r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) { r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Patch("/{id}", articleHandler.UpdateArticleBody) r.Patch("/{id}", articleHandler.UpdateArticleBody)
}) })
@@ -55,9 +55,9 @@ func newEditArticleBodyTestRepo() *mockArticleRepo {
return repo return repo
} }
func setEditArticleBodyJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth) { func setEditArticleBodyJwtCookie(t *testing.T, req *http.Request, token *authtoken.Auth) {
t.Helper() t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)}) tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)})
if err != nil { if err != nil {
t.Fatalf("failed to encode jwt: %v", err) t.Fatalf("failed to encode jwt: %v", err)
} }
@@ -70,7 +70,7 @@ func newEditArticleBodyRequest(method, target string, body string) *http.Request
func TestEditArticleBodyHttp(t *testing.T) { func TestEditArticleBodyHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
t.Run("Editing an existing article's body updates only the body", func(t *testing.T) { t.Run("Editing an existing article's body updates only the body", func(t *testing.T) {
repo := newEditArticleBodyTestRepo() repo := newEditArticleBodyTestRepo()
+12 -12
View File
@@ -13,11 +13,11 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux { func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors())) meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors()))
verificationHandler := auth.NewVerificationHandler( verificationHandler := auth.NewVerificationHandler(
@@ -29,8 +29,8 @@ func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *
r := chi.NewRouter() r := chi.NewRouter()
r.Route("/v1/auth", func(r chi.Router) { r.Route("/v1/auth", func(r chi.Router) {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUserUseCase)) r.Use(middleware.RequireCurrentToken(getUserUseCase))
r.Get("/me", meHandler.Me) r.Get("/me", meHandler.Me)
r.Delete("/register", verificationHandler.CancelRegistration) r.Delete("/register", verificationHandler.CancelRegistration)
@@ -39,9 +39,9 @@ func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *
return r return r
} }
func mintGetCurrentUserCookie(t *testing.T, token *jwtauth.JWTAuth, userId uint32, tokenVersion uint32) *http.Cookie { func mintGetCurrentUserCookie(t *testing.T, token *authtoken.Auth, userId uint32, tokenVersion uint32) *http.Cookie {
t.Helper() t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion}) tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil { if err != nil {
t.Fatalf("failed to encode jwt: %v", err) t.Fatalf("failed to encode jwt: %v", err)
} }
@@ -62,7 +62,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
t.Run("A verified user fetches their own profile", func(t *testing.T) { t.Run("A verified user fetches their own profile", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ userRepo.users[7] = user.User{
ID: 7, ID: 7,
@@ -92,7 +92,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
}) })
t.Run("An unverified user still fetches their own profile, with is_verified false, not a 403", func(t *testing.T) { t.Run("An unverified user still fetches their own profile, with is_verified false, not a 403", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[8] = user.User{ userRepo.users[8] = user.User{
ID: 8, ID: 8,
@@ -122,7 +122,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
}) })
t.Run("No jwt cookie at all is refused", func(t *testing.T) { t.Run("No jwt cookie at all is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0} userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
@@ -135,7 +135,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
}) })
t.Run("A jwt whose token_version no longer matches the stored user is refused", func(t *testing.T) { t.Run("A jwt whose token_version no longer matches the stored user is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1} userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
@@ -152,7 +152,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
}) })
t.Run("A jwt minted for a since-deleted account is refused", func(t *testing.T) { t.Run("A jwt minted for a since-deleted account is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: false, TokenVersion: 0} userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: false, TokenVersion: 0}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
@@ -173,7 +173,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
}) })
t.Run("The route is never rate-limited, unlike login and password reset", func(t *testing.T) { t.Run("The route is never rate-limited, unlike login and password reset", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0} userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token) router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
+3 -3
View File
@@ -15,8 +15,8 @@ import (
"strings" "strings"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
var errStubHashFailure = errors.New("stub hash failure") var errStubHashFailure = errors.New("stub hash failure")
@@ -153,7 +153,7 @@ func TestBcryptHasherHashAndCompare(t *testing.T) {
} }
func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux { func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder), useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
@@ -170,7 +170,7 @@ func newHasherRouter(userRepo *mockUserRepo, hasher *stubHasher) *chi.Mux {
} }
func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux { func newRealHasherRouter(userRepo *mockUserRepo) *chi.Mux {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
+4 -4
View File
@@ -20,9 +20,9 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware" chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/jwtauth/v5"
) )
const ( const (
@@ -49,7 +49,7 @@ type variantEnv struct {
articles *mockArticleRepo articles *mockArticleRepo
keys *mockAPIKeyRepo keys *mockAPIKeyRepo
logs *bytes.Buffer logs *bytes.Buffer
token *jwtauth.JWTAuth token *authtoken.Auth
now time.Time now time.Time
usage *mockVariantUsageRepo usage *mockVariantUsageRepo
tracker *usage.Tracker tracker *usage.Tracker
@@ -82,7 +82,7 @@ func newVariantEnvLimits(t *testing.T, renderer *countingRenderer, limits useCas
articles: newMockArticleRepo(), articles: newMockArticleRepo(),
keys: newMockAPIKeyRepo(), keys: newMockAPIKeyRepo(),
logs: &bytes.Buffer{}, logs: &bytes.Buffer{},
token: jwtauth.New("HS256", []byte("test-secret"), nil), token: authtoken.New([]byte("test-secret")),
now: time.Date(2026, time.October, 8, 9, 0, 0, 0, time.UTC), now: time.Date(2026, time.October, 8, 9, 0, 0, 0, time.UTC),
usage: newMockVariantUsageRepo(), usage: newMockVariantUsageRepo(),
} }
@@ -152,7 +152,7 @@ func (e *variantEnv) issueAPIKey() string {
func (e *variantEnv) cookie(userId uint32, tokenVersion uint32) *http.Cookie { func (e *variantEnv) cookie(userId uint32, tokenVersion uint32) *http.Cookie {
e.t.Helper() e.t.Helper()
_, signed, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion}) signed, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil { if err != nil {
e.t.Fatalf("failed to encode jwt: %v", err) e.t.Fatalf("failed to encode jwt: %v", err)
} }
+6 -6
View File
@@ -21,8 +21,8 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
const invitePublicURL = "https://cms.example.com" const invitePublicURL = "https://cms.example.com"
@@ -137,7 +137,7 @@ type inviteFixture struct {
mail *mockMailRepo mail *mockMailRepo
hasher *stubHasher hasher *stubHasher
router *chi.Mux router *chi.Mux
token *jwtauth.JWTAuth token *authtoken.Auth
} }
func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture { func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
@@ -148,7 +148,7 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
invites: newInviteFakeRepo(), invites: newInviteFakeRepo(),
mail: newMockMailRepo(), mail: newMockMailRepo(),
hasher: &stubHasher{}, hasher: &stubHasher{},
token: jwtauth.New("HS256", []byte("test-secret"), nil), token: authtoken.New([]byte("test-secret")),
} }
getUser := useCases.NewGetUserUseCase(f.users) getUser := useCases.NewGetUserUseCase(f.users)
handler := userhandler.NewInviteHandler( handler := userhandler.NewInviteHandler(
@@ -159,8 +159,8 @@ func newInviteFixture(t *testing.T, maxAttempts int) *inviteFixture {
) )
f.router = chi.NewRouter() f.router = chi.NewRouter()
f.router.Route("/v1/user", func(r chi.Router) { f.router.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token)) r.Use(authtoken.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser)) r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser)) r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", handler.ListUsers) r.Get("/", handler.ListUsers)
@@ -535,7 +535,7 @@ func TestInvitationsDoNotChangeRegistration(t *testing.T) {
newRegistration := func(users *mockUserRepo, mail *mockMailRepo) *chi.Mux { newRegistration := func(users *mockUserRepo, mail *mockMailRepo) *chi.Mux {
hasher := &stubHasher{} hasher := &stubHasher{}
handler := auth.NewLoginHandler( handler := auth.NewLoginHandler(
jwtauth.New("HS256", []byte("test-secret"), nil), authtoken.New([]byte("test-secret")),
useCases.NewCreateUserUseCase(users, hasher, mail, invitePublicURL, discardRecorder), useCases.NewCreateUserUseCase(users, hasher, mail, invitePublicURL, discardRecorder),
useCases.NewAuthenticateUserUseCase(users, hasher, discardRecorder), useCases.NewAuthenticateUserUseCase(users, hasher, discardRecorder),
true, true,
+5 -5
View File
@@ -19,9 +19,9 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
) )
@@ -34,7 +34,7 @@ type inviteManagementFixture struct {
mail *mockMailRepo mail *mockMailRepo
hasher *stubHasher hasher *stubHasher
router *chi.Mux router *chi.Mux
token *jwtauth.JWTAuth token *authtoken.Auth
} }
func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagementFixture { func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagementFixture {
@@ -45,7 +45,7 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement
invites: newInviteFakeRepo(), invites: newInviteFakeRepo(),
mail: newMockMailRepo(), mail: newMockMailRepo(),
hasher: &stubHasher{}, hasher: &stubHasher{},
token: jwtauth.New("HS256", []byte("test-secret"), nil), token: authtoken.New([]byte("test-secret")),
} }
f.invites.users = f.users f.invites.users = f.users
f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin} f.users.users[9] = user.User{ID: 9, Username: "jonas", Email: "jonas@renew.example", IsVerified: true, Role: user.RoleAdmin}
@@ -71,8 +71,8 @@ func newInviteManagementFixture(t *testing.T, maxAttempts int) *inviteManagement
f.router.Get("/v1/auth/invite", acceptHandler.GetInvite) f.router.Get("/v1/auth/invite", acceptHandler.GetInvite)
f.router.Post("/v1/auth/invite/accept", acceptHandler.AcceptInvite) f.router.Post("/v1/auth/invite/accept", acceptHandler.AcceptInvite)
f.router.Route("/v1/user", func(r chi.Router) { f.router.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token)) r.Use(authtoken.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser)) r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser)) r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", handler.ListUsers) r.Get("/", handler.ListUsers)
@@ -15,9 +15,9 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware" chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/jwtauth/v5"
) )
type countingAuthenticateUserRepo struct { type countingAuthenticateUserRepo struct {
@@ -33,7 +33,7 @@ func (r *countingAuthenticateUserRepo) GetByUsername(username string) (user.User
func newRateLimitedAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *mockMailRepo, maxAttempts int, window time.Duration) *chi.Mux { func newRateLimitedAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *mockMailRepo, maxAttempts int, window time.Duration) *chi.Mux {
t.Helper() t.Helper()
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
+3 -3
View File
@@ -11,11 +11,11 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newLoginLogoutCookieRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *jwtauth.JWTAuth) *chi.Mux { func newLoginLogoutCookieRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *authtoken.Auth) *chi.Mux {
hasher := &stubHasher{} hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
@@ -32,7 +32,7 @@ func newLoginLogoutCookieRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo,
func TestLoginSetsA24HourJwtCookieAndLogoutClearsIt(t *testing.T) { func TestLoginSetsA24HourJwtCookieAndLogoutClearsIt(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
t.Run("Login sets a jwt cookie that expires about 24 hours from now", func(t *testing.T) { t.Run("Login sets a jwt cookie that expires about 24 hours from now", func(t *testing.T) {
repo := newMockUserRepo() repo := newMockUserRepo()
+4 -4
View File
@@ -18,8 +18,8 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
const ( const (
@@ -38,7 +38,7 @@ type mediaEnv struct {
purgeSettings *mockPurgeSettingsRepo purgeSettings *mockPurgeSettingsRepo
clock time.Time clock time.Time
logs *bytes.Buffer logs *bytes.Buffer
token *jwtauth.JWTAuth token *authtoken.Auth
router http.Handler router http.Handler
} }
@@ -52,7 +52,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
variants: newMockVariantFileRepo(), variants: newMockVariantFileRepo(),
storage: newMockImageStorage(), storage: newMockImageStorage(),
users: newMockUserRepo(), users: newMockUserRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil), token: authtoken.New([]byte("test-secret")),
purgeSettings: newMockPurgeSettingsRepo(), purgeSettings: newMockPurgeSettingsRepo(),
clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC), clock: time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC),
logs: &bytes.Buffer{}, logs: &bytes.Buffer{},
@@ -93,7 +93,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
func (e *mediaEnv) cookie(t *testing.T, userId uint32, tokenVersion uint32) *http.Cookie { func (e *mediaEnv) cookie(t *testing.T, userId uint32, tokenVersion uint32) *http.Cookie {
t.Helper() t.Helper()
_, tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion}) tokenString, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil { if err != nil {
t.Fatalf("failed to encode jwt: %v", err) t.Fatalf("failed to encode jwt: %v", err)
} }
+4 -4
View File
@@ -30,8 +30,8 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
const ( const (
@@ -71,7 +71,7 @@ type hardeningEnv struct {
settings *mockPurgeSettingsRepo settings *mockPurgeSettingsRepo
logs *bytes.Buffer logs *bytes.Buffer
users *mockUserRepo users *mockUserRepo
token *jwtauth.JWTAuth token *authtoken.Auth
router http.Handler router http.Handler
} }
@@ -90,7 +90,7 @@ func newHardeningEnv(t *testing.T) *hardeningEnv {
settings: newMockPurgeSettingsRepo(), settings: newMockPurgeSettingsRepo(),
logs: &bytes.Buffer{}, logs: &bytes.Buffer{},
users: newMockUserRepo(), users: newMockUserRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil), token: authtoken.New([]byte("test-secret")),
} }
env.users.users[hardeningAdminID] = user.User{ID: hardeningAdminID, Username: "marta", IsVerified: true, Role: user.RoleAdmin} env.users.users[hardeningAdminID] = user.User{ID: hardeningAdminID, Username: "marta", IsVerified: true, Role: user.RoleAdmin}
env.transactor = newMergeTransactor(env.images, env.articles, env.sites, env.authors, env.entries) env.transactor = newMergeTransactor(env.images, env.articles, env.sites, env.authors, env.entries)
@@ -124,7 +124,7 @@ func (e *hardeningEnv) send(t *testing.T, method string, path string, body strin
if body != "" { if body != "" {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} }
_, tokenString, err := e.token.Encode(map[string]any{"user_id": hardeningAdminID, "token_version": 0}) tokenString, err := e.token.Encode(map[string]any{"user_id": hardeningAdminID, "token_version": 0})
if err != nil { if err != nil {
t.Fatalf("failed to encode jwt: %v", err) t.Fatalf("failed to encode jwt: %v", err)
} }
+6 -6
View File
@@ -18,9 +18,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -123,7 +123,7 @@ func TestUserRepositoryReportsMissingUserAsDomainError(t *testing.T) {
}) })
} }
func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
@@ -144,7 +144,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
r := chi.NewRouter() r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) { r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) { r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Get("/{id}", articleHandler.GetArticle) r.Get("/{id}", articleHandler.GetArticle)
}) })
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
@@ -154,9 +154,9 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
return r return r
} }
func setJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth, userId uint32) { func setJwtCookie(t *testing.T, req *http.Request, token *authtoken.Auth, userId uint32) {
t.Helper() t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": userId}) tokenString, err := token.Encode(map[string]any{"user_id": userId})
if err != nil { if err != nil {
t.Fatalf("failed to encode jwt: %v", err) t.Fatalf("failed to encode jwt: %v", err)
} }
@@ -165,7 +165,7 @@ func setJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth, userI
func TestNotFoundErrorsHttp(t *testing.T) { func TestNotFoundErrorsHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
newArticleRepo := func() *mockArticleRepo { newArticleRepo := func() *mockArticleRepo {
repo := newMockArticleRepo() repo := newMockArticleRepo()
+5 -5
View File
@@ -14,8 +14,8 @@ import (
"strings" "strings"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
validator "RenewCMS/api/pkg/validator" validator "RenewCMS/api/pkg/validator"
) )
@@ -37,7 +37,7 @@ func (w *erroringResponseWriter) Write([]byte) (int, error) {
func (w *erroringResponseWriter) WriteHeader(int) {} func (w *erroringResponseWriter) WriteHeader(int) {}
func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux { func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
userRepo := newVerifiedUserRepo() userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
@@ -53,13 +53,13 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *jwtauth.
r := chi.NewRouter() r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) { r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) { r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Get("/", articleHandler.ListArticles) r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle) r.Get("/{id}", articleHandler.GetArticle)
}) })
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle) r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle) r.Delete("/{id}", articleHandler.DeleteArticle)
@@ -78,7 +78,7 @@ func seedPublicArticleListingRepo() *mockArticleRepo {
func TestPublicArticleListingHttp(t *testing.T) { func TestPublicArticleListingHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
t.Run("An anonymous visitor only sees online articles", func(t *testing.T) { t.Run("An anonymous visitor only sees online articles", func(t *testing.T) {
router := newPublicArticleListingRouter(seedPublicArticleListingRepo(), token) router := newPublicArticleListingRouter(seedPublicArticleListingRepo(), token)
+7 -7
View File
@@ -17,11 +17,11 @@ import (
"strings" "strings"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
@@ -35,11 +35,11 @@ func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUser
r := chi.NewRouter() r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) { r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) { r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Get("/", articleHandler.ListArticles) r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle) r.Get("/{id}", articleHandler.GetArticle)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle) r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle) r.Delete("/{id}", articleHandler.DeleteArticle)
@@ -65,9 +65,9 @@ func newPublicArticleReadUserRepo(isVerified bool) *mockUserRepo {
return repo return repo
} }
func mintPublicArticleReadCookie(t *testing.T, token *jwtauth.JWTAuth, userId uint32) *http.Cookie { func mintPublicArticleReadCookie(t *testing.T, token *authtoken.Auth, userId uint32) *http.Cookie {
t.Helper() t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": userId}) tokenString, err := token.Encode(map[string]any{"user_id": userId})
if err != nil { if err != nil {
t.Fatalf("failed to encode jwt: %v", err) t.Fatalf("failed to encode jwt: %v", err)
} }
@@ -100,7 +100,7 @@ func assertPublicArticleReadNotFound(t *testing.T, rec *httptest.ResponseRecorde
func TestPublicArticleReadHttp(t *testing.T) { func TestPublicArticleReadHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
t.Run("An anonymous visitor can read an online article", func(t *testing.T) { t.Run("An anonymous visitor can read an online article", func(t *testing.T) {
router := newPublicArticleReadRouter(seedPublicArticleReadRepo(), newMockUserRepo(), token) router := newPublicArticleReadRouter(seedPublicArticleReadRepo(), newMockUserRepo(), token)
@@ -14,8 +14,8 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
type sequentialHasher struct { type sequentialHasher struct {
@@ -38,7 +38,7 @@ func (s *sequentialHasher) Compare(hashed string, value string) error {
} }
func newRegistrationGateRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, publicURL string) *chi.Mux { func newRegistrationGateRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, publicURL string) *chi.Mux {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
hasher := &stubHasher{} hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
@@ -11,12 +11,12 @@ import (
"strings" "strings"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newRegistrationStatusRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo) (*chi.Mux, *useCases.CreateUserUseCase) { func newRegistrationStatusRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo) (*chi.Mux, *useCases.CreateUserUseCase) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
hasher := &stubHasher{} hasher := &stubHasher{}
createUseCase := useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder) createUseCase := useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder)
loginHandler := auth.NewLoginHandler(token, createUseCase, useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true) loginHandler := auth.NewLoginHandler(token, createUseCase, useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder), true)
+3 -3
View File
@@ -18,13 +18,13 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newSecureCookieAuthRouter(t *testing.T, isProduction bool) (*chi.Mux, *mockUserRepo) { func newSecureCookieAuthRouter(t *testing.T, isProduction bool) (*chi.Mux, *mockUserRepo) {
t.Helper() t.Helper()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
hashed, err := hasher.Hash("hunter2example") hashed, err := hasher.Hash("hunter2example")
@@ -125,7 +125,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
imageRepo := newMockImageRepo() imageRepo := newMockImageRepo()
mailRepo := newMockMailRepo() mailRepo := newMockMailRepo()
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
return &api.UseCases{ return &api.UseCases{
Token: token, Token: token,
+9 -9
View File
@@ -11,11 +11,11 @@ import (
"strings" "strings"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newSettingsRouter(t *testing.T, userRepo *mockUserRepo, token *jwtauth.JWTAuth, version string) *chi.Mux { func newSettingsRouter(t *testing.T, userRepo *mockUserRepo, token *authtoken.Auth, version string) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
settingsHandler := settings.NewHandler( settingsHandler := settings.NewHandler(
useCases.NewGetSettingsUseCase(version), useCases.NewGetSettingsUseCase(version),
@@ -25,8 +25,8 @@ func newSettingsRouter(t *testing.T, userRepo *mockUserRepo, token *jwtauth.JWTA
r := chi.NewRouter() r := chi.NewRouter()
r.Route("/v1/settings", func(r chi.Router) { r.Route("/v1/settings", func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUserUseCase)) r.Use(middleware.RequireCurrentToken(getUserUseCase))
r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Get("/", settingsHandler.GetSettings) r.Get("/", settingsHandler.GetSettings)
@@ -43,7 +43,7 @@ func TestSettingsHttp(t *testing.T) {
for _, version := range []string{"v2.2.0-beta", "dev"} { for _, version := range []string{"v2.2.0-beta", "dev"} {
t.Run("A verified caller gets the running build's version: "+version, func(t *testing.T) { t.Run("A verified caller gets the running build's version: "+version, func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0} userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
router := newSettingsRouter(t, userRepo, token, version) router := newSettingsRouter(t, userRepo, token, version)
@@ -61,7 +61,7 @@ func TestSettingsHttp(t *testing.T) {
} }
t.Run("The response does not depend on the environment at request time", func(t *testing.T) { t.Run("The response does not depend on the environment at request time", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0} userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta") router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
@@ -79,7 +79,7 @@ func TestSettingsHttp(t *testing.T) {
}) })
t.Run("A request without a jwt cookie is refused", func(t *testing.T) { t.Run("A request without a jwt cookie is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
router := newSettingsRouter(t, newMockUserRepo(), token, "v2.2.0-beta") router := newSettingsRouter(t, newMockUserRepo(), token, "v2.2.0-beta")
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", nil) rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/settings", nil)
@@ -93,7 +93,7 @@ func TestSettingsHttp(t *testing.T) {
}) })
t.Run("A token whose version no longer matches the account is refused", func(t *testing.T) { t.Run("A token whose version no longer matches the account is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1} userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1}
router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta") router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
@@ -110,7 +110,7 @@ func TestSettingsHttp(t *testing.T) {
}) })
t.Run("An unverified account is refused", func(t *testing.T) { t.Run("An unverified account is refused", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo() userRepo := newMockUserRepo()
userRepo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, TokenVersion: 0} userRepo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, TokenVersion: 0}
router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta") router := newSettingsRouter(t, userRepo, token, "v2.2.0-beta")
+284
View File
@@ -0,0 +1,284 @@
package test
import (
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/useCases"
"io"
"mime"
"mime/quotedprintable"
"net/mail"
"strings"
"testing"
"time"
)
var smtpMailerClock = time.Date(2026, time.March, 1, 12, 0, 0, 0, time.UTC)
func smtpMailerFor(server *fakeSMTPServer, settings config.SmtpSettings) *mailer.MailRepository {
if settings.Host == "" {
settings.Host = "127.0.0.1"
}
settings.Port = server.port()
if settings.Sender == "" {
settings.Sender = "notify@example.com"
}
return mailer.NewMailRepository(settings, mailer.WithTLSConfig(server.clientTLS()), mailer.WithClock(func() time.Time { return smtpMailerClock }))
}
func verificationData() useCases.MailValidationData {
return useCases.MailValidationData{Host: "https://cms.example.com", VerificationCode: "abc123"}
}
func parseDeliveredMail(t *testing.T, raw string) (*mail.Message, string) {
t.Helper()
message, err := mail.ReadMessage(strings.NewReader(raw))
if err != nil {
t.Fatalf("delivered data is not a valid message: %v\n%s", err, raw)
}
decoded, err := io.ReadAll(quotedprintable.NewReader(message.Body))
if err != nil {
t.Fatalf("body is not valid quoted-printable: %v", err)
}
return message, string(decoded)
}
func TestSMTPMailerDelivery(t *testing.T) {
t.Run("Delivers a well-formed HTML message to a plain local server", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{})
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
if err != nil {
t.Fatalf("send failed: %v", err)
}
sessions := server.received()
if len(sessions) != 1 || sessions[0].from != "notify@example.com" || sessions[0].to != "reader@example.org" {
t.Fatalf("unexpected envelope %+v", sessions)
}
message, body := parseDeliveredMail(t, sessions[0].data)
if got := message.Header.Get("From"); got != `"RenewCMS" <notify@example.com>` {
t.Errorf("unexpected From %q", got)
}
if got := message.Header.Get("To"); got != "<reader@example.org>" {
t.Errorf("unexpected To %q", got)
}
subject, _ := new(mime.WordDecoder).DecodeHeader(message.Header.Get("Subject"))
if subject != "Verify your RenewCMS account" {
t.Errorf("unexpected Subject %q", subject)
}
if got := message.Header.Get("Content-Type"); got != "text/html; charset=UTF-8" {
t.Errorf("unexpected Content-Type %q", got)
}
if got := message.Header.Get("MIME-Version"); got != "1.0" {
t.Errorf("unexpected MIME-Version %q", got)
}
if !strings.Contains(body, "https://cms.example.com/register/validate?c=abc123") {
t.Errorf("verification link missing from body %q", body)
}
})
t.Run("Stamps a Date from the clock and a unique Message-ID on the sender's domain", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{})
repo := smtpMailerFor(server, config.SmtpSettings{})
_ = repo.Send("a@example.org", "verification", verificationData())
_ = repo.Send("a@example.org", "verification", verificationData())
sessions := server.received()
first, _ := parseDeliveredMail(t, sessions[0].data)
second, _ := parseDeliveredMail(t, sessions[1].data)
date, err := first.Header.Date()
if err != nil || !date.Equal(smtpMailerClock) {
t.Errorf("expected Date %v, got %v (%v)", smtpMailerClock, date, err)
}
id := first.Header.Get("Message-ID")
if !strings.HasPrefix(id, "<") || !strings.HasSuffix(id, "@example.com>") {
t.Errorf("unexpected Message-ID %q", id)
}
if id == second.Header.Get("Message-ID") {
t.Error("Message-IDs must be unique")
}
})
t.Run("Encodes a non-ASCII subject and body without corrupting them", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{})
repo := smtpMailerFor(server, config.SmtpSettings{})
err := repo.Send("reader@example.org", "verification", verificationData())
if err != nil {
t.Fatal(err)
}
raw := server.received()[0].data
headerBlock, _, _ := strings.Cut(raw, "\r\n\r\n")
for _, r := range headerBlock {
if r > 127 {
t.Fatalf("header contains a raw non-ASCII rune %q", r)
}
}
})
t.Run("Authenticates with PLAIN over STARTTLS", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true, authMechanisms: "PLAIN LOGIN"})
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"}
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
if err != nil {
t.Fatalf("send failed: %v", err)
}
session := server.received()[0]
if !session.usedTLS || session.username != "user@example.com" || session.password != "s3cret" {
t.Errorf("unexpected session %+v", session)
}
})
t.Run("Falls back to LOGIN when PLAIN is not offered", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true, authMechanisms: "LOGIN"})
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"}
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
if err != nil {
t.Fatalf("send failed: %v", err)
}
session := server.received()[0]
if session.username != "user@example.com" || session.password != "s3cret" {
t.Errorf("unexpected credentials %+v", session)
}
})
t.Run("Skips authentication when no login is configured", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{authMechanisms: "PLAIN"})
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
if err != nil {
t.Fatal(err)
}
for _, command := range server.received()[0].commands {
if strings.HasPrefix(strings.ToUpper(command), "AUTH") {
t.Errorf("unexpected %q", command)
}
}
})
for _, mechanisms := range []string{"PLAIN", "LOGIN"} {
t.Run("Refuses to send "+mechanisms+" credentials in clear text to a non-loopback server", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{authMechanisms: mechanisms, listenAddress: "127.0.0.2:0"})
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret", Host: "127.0.0.2"}
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
if err == nil {
t.Fatal("expected an error")
}
if session := server.received()[0]; session.password != "" || session.data != "" {
t.Errorf("credentials or data leaked: %+v", session)
}
})
}
t.Run("Fails when credentials are configured but the server offers no AUTH", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{})
settings := config.SmtpSettings{Login: "user@example.com", Password: "s3cret"}
err := smtpMailerFor(server, settings).Send("reader@example.org", "verification", verificationData())
if err == nil {
t.Fatal("expected an error")
}
if len(server.received()[0].data) != 0 {
t.Error("no message may be delivered")
}
})
t.Run("Fails the STARTTLS handshake against an untrusted certificate", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{startTLS: true})
repo := mailer.NewMailRepository(config.SmtpSettings{Host: "127.0.0.1", Port: server.port(), Sender: "notify@example.com"})
err := repo.Send("reader@example.org", "verification", verificationData())
if err == nil {
t.Fatal("expected a certificate error")
}
if len(server.received()[0].data) != 0 {
t.Error("no message may be delivered")
}
})
t.Run("Uses implicit TLS on port 465", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{implicitTLS: true, listenAddress: "127.0.0.1:465"})
repo := mailer.NewMailRepository(config.SmtpSettings{Host: "127.0.0.1", Port: 465, Sender: "notify@example.com"}, mailer.WithTLSConfig(server.clientTLS()))
err := repo.Send("reader@example.org", "verification", verificationData())
if err != nil {
t.Fatalf("send failed: %v", err)
}
if !server.received()[0].usedTLS {
t.Error("expected a TLS session")
}
})
t.Run("Surfaces a rejected recipient", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{rejectRcpt: true})
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
if err == nil {
t.Fatal("expected an error")
}
})
t.Run("Surfaces a rejected DATA command", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{rejectData: true})
err := smtpMailerFor(server, config.SmtpSettings{}).Send("reader@example.org", "verification", verificationData())
if err == nil {
t.Fatal("expected an error")
}
})
t.Run("Surfaces an unreachable server", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{})
repo := smtpMailerFor(server, config.SmtpSettings{})
_ = server.listener.Close()
if err := repo.Send("reader@example.org", "verification", verificationData()); err == nil {
t.Fatal("expected an error")
}
})
}
func TestSMTPMailerRejectsUnsafeInput(t *testing.T) {
cases := map[string]struct {
sender string
receiver string
template string
}{
"receiver with an injected header": {"notify@example.com", "a@example.org\r\nBcc: evil@example.net", "verification"},
"receiver that is not an address": {"notify@example.com", "not-an-address", "verification"},
"empty receiver": {"notify@example.com", "", "verification"},
"sender that is not an address": {"nope", "a@example.org", "verification"},
"unknown template": {"notify@example.com", "a@example.org", "does-not-exist"},
}
for name, tc := range cases {
t.Run("Rejects a "+name+" before opening a connection", func(t *testing.T) {
server := newFakeSMTPServer(t, fakeSMTPOptions{})
repo := smtpMailerFor(server, config.SmtpSettings{Sender: tc.sender})
err := repo.Send(tc.receiver, tc.template, verificationData())
if err == nil {
t.Fatal("expected an error")
}
if len(server.received()) != 0 {
t.Errorf("no connection may be made, got %d", len(server.received()))
}
})
}
}
+261
View File
@@ -0,0 +1,261 @@
package test
import (
"bufio"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"math/big"
"net"
"strings"
"sync"
"testing"
"time"
)
type fakeSMTPOptions struct {
authMechanisms string
startTLS bool
rejectRcpt bool
rejectData bool
implicitTLS bool
listenAddress string
}
type fakeSMTPSession struct {
commands []string
from string
to string
data string
username string
password string
usedTLS bool
}
type fakeSMTPServer struct {
listener net.Listener
options fakeSMTPOptions
cert tls.Certificate
roots *x509.CertPool
mu sync.Mutex
sessions []*fakeSMTPSession
wg sync.WaitGroup
}
func newFakeSMTPServer(t *testing.T, options fakeSMTPOptions) *fakeSMTPServer {
t.Helper()
address := options.listenAddress
if address == "" {
address = "127.0.0.1:0"
}
listener, err := net.Listen("tcp", address)
if err != nil {
t.Skipf("cannot listen on %s: %v", address, err)
}
cert, roots := selfSignedLoopbackCert(t)
server := &fakeSMTPServer{listener: listener, options: options, cert: cert, roots: roots}
server.wg.Add(1)
go server.acceptLoop()
t.Cleanup(func() {
_ = listener.Close()
server.wg.Wait()
})
return server
}
func selfSignedLoopbackCert(t *testing.T) (tls.Certificate, *x509.CertPool) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "127.0.0.1"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
parsed, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AddCert(parsed)
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, roots
}
func (s *fakeSMTPServer) port() int {
return s.listener.Addr().(*net.TCPAddr).Port
}
func (s *fakeSMTPServer) clientTLS() *tls.Config {
return &tls.Config{RootCAs: s.roots}
}
func (s *fakeSMTPServer) received() []*fakeSMTPSession {
s.mu.Lock()
defer s.mu.Unlock()
return append([]*fakeSMTPSession(nil), s.sessions...)
}
func (s *fakeSMTPServer) acceptLoop() {
defer s.wg.Done()
for {
connection, err := s.listener.Accept()
if err != nil {
return
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
s.serve(connection)
}()
}
}
func (s *fakeSMTPServer) serve(connection net.Conn) {
defer func() { _ = connection.Close() }()
_ = connection.SetDeadline(time.Now().Add(10 * time.Second))
session := &fakeSMTPSession{}
s.mu.Lock()
s.sessions = append(s.sessions, session)
s.mu.Unlock()
if s.options.implicitTLS {
connection = tls.Server(connection, &tls.Config{Certificates: []tls.Certificate{s.cert}})
session.usedTLS = true
}
reader := bufio.NewReader(connection)
reply := func(line string) { _, _ = connection.Write([]byte(line + "\r\n")) }
reply("220 fake ESMTP")
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
s.record(session, line)
verb := strings.ToUpper(strings.Fields(line + " ")[0])
switch verb {
case "EHLO":
s.replyEhlo(reply, session)
case "STARTTLS":
reply("220 ready")
connection = tls.Server(connection, &tls.Config{Certificates: []tls.Certificate{s.cert}})
reader = bufio.NewReader(connection)
reply = func(line string) { _, _ = connection.Write([]byte(line + "\r\n")) }
session.usedTLS = true
case "AUTH":
if !s.authenticate(line, reader, reply, session) {
return
}
case "MAIL":
session.from = smtpAngleContent(line, "<", ">")
reply("250 ok")
case "RCPT":
if s.options.rejectRcpt {
reply("550 no such user")
continue
}
session.to = smtpAngleContent(line, "<", ">")
reply("250 ok")
case "DATA":
if s.options.rejectData {
reply("554 refused")
continue
}
reply("354 go ahead")
session.data = readSMTPData(reader)
reply("250 queued")
case "QUIT":
reply("221 bye")
return
default:
reply("250 ok")
}
}
}
func (s *fakeSMTPServer) record(session *fakeSMTPSession, line string) {
s.mu.Lock()
session.commands = append(session.commands, line)
s.mu.Unlock()
}
func (s *fakeSMTPServer) replyEhlo(reply func(string), session *fakeSMTPSession) {
lines := []string{"250-fake"}
if s.options.startTLS && !session.usedTLS {
lines = append(lines, "250-STARTTLS")
}
if s.options.authMechanisms != "" {
lines = append(lines, "250-AUTH "+s.options.authMechanisms)
}
lines = append(lines, "250 8BITMIME")
for _, line := range lines {
reply(line)
}
}
func (s *fakeSMTPServer) authenticate(line string, reader *bufio.Reader, reply func(string), session *fakeSMTPSession) bool {
fields := strings.Fields(line)
if len(fields) < 2 {
reply("501 syntax")
return false
}
switch strings.ToUpper(fields[1]) {
case "PLAIN":
raw, _ := base64.StdEncoding.DecodeString(fields[2])
parts := strings.Split(string(raw), "\x00")
session.username, session.password = parts[1], parts[2]
case "LOGIN":
reply("334 " + base64.StdEncoding.EncodeToString([]byte("Username:")))
username, _ := reader.ReadString('\n')
reply("334 " + base64.StdEncoding.EncodeToString([]byte("Password:")))
password, _ := reader.ReadString('\n')
session.username = decodeBase64Line(username)
session.password = decodeBase64Line(password)
default:
reply("504 unsupported")
return false
}
reply("235 ok")
return true
}
func decodeBase64Line(line string) string {
raw, _ := base64.StdEncoding.DecodeString(strings.TrimSpace(line))
return string(raw)
}
func smtpAngleContent(line string, open string, close string) string {
start := strings.Index(line, open)
end := strings.LastIndex(line, close)
if start < 0 || end <= start {
return ""
}
return line[start+1 : end]
}
func readSMTPData(reader *bufio.Reader) string {
var data strings.Builder
for {
line, err := reader.ReadString('\n')
if err != nil || line == ".\r\n" {
return data.String()
}
data.WriteString(strings.TrimPrefix(line, "."))
}
}
+15 -15
View File
@@ -16,11 +16,11 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *jwtauth.JWTAuth) *chi.Mux { func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockArticleRepo, imageRepo *mockImageRepo, mailRepo *mockMailRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
@@ -50,8 +50,8 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
r.Route("/v1", func(r chi.Router) { r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) { r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUserUseCase)) r.Use(middleware.RequireCurrentToken(getUserUseCase))
r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle) r.Post("/", articleHandler.PostArticle)
@@ -63,8 +63,8 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
r.Post("/password-reset", passwordResetHandler.RequestPasswordReset) r.Post("/password-reset", passwordResetHandler.RequestPasswordReset)
r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword) r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUserUseCase)) r.Use(middleware.RequireCurrentToken(getUserUseCase))
r.Post("/verify", verificationHandler.Verify) r.Post("/verify", verificationHandler.Verify)
r.Delete("/register", verificationHandler.CancelRegistration) r.Delete("/register", verificationHandler.CancelRegistration)
@@ -90,9 +90,9 @@ func newStaleTokenAliceRepo(isVerified bool) *mockUserRepo {
return repo return repo
} }
func mintStaleTokenCookie(t *testing.T, token *jwtauth.JWTAuth, claims map[string]any) *http.Cookie { func mintStaleTokenCookie(t *testing.T, token *authtoken.Auth, claims map[string]any) *http.Cookie {
t.Helper() t.Helper()
_, tokenString, err := token.Encode(claims) tokenString, err := token.Encode(claims)
if err != nil { if err != nil {
t.Fatalf("failed to encode jwt: %v", err) t.Fatalf("failed to encode jwt: %v", err)
} }
@@ -164,7 +164,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
t.Run("A jwt minted before a password reset is refused afterward, but a fresh login's jwt is accepted", func(t *testing.T) { t.Run("A jwt minted before a password reset is refused afterward, but a fresh login's jwt is accepted", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newStaleTokenAliceRepo(true) userRepo := newStaleTokenAliceRepo(true)
articleRepo := newMockArticleRepo() articleRepo := newMockArticleRepo()
router := newStaleTokenInvalidationRouter(userRepo, articleRepo, newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token) router := newStaleTokenInvalidationRouter(userRepo, articleRepo, newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
@@ -211,7 +211,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
}) })
t.Run("A stale jwt is also refused on the auth group's own protected routes, not just article routes", func(t *testing.T) { t.Run("A stale jwt is also refused on the auth group's own protected routes, not just article routes", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newStaleTokenAliceRepo(false) userRepo := newStaleTokenAliceRepo(false)
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token) router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
preResetCookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(0)}) preResetCookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(0)})
@@ -242,7 +242,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
}) })
t.Run("Resetting the password twice invalidates both the original jwt and the jwt from the interim login", func(t *testing.T) { t.Run("Resetting the password twice invalidates both the original jwt and the jwt from the interim login", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newStaleTokenAliceRepo(true) userRepo := newStaleTokenAliceRepo(true)
articleRepo := newMockArticleRepo() articleRepo := newMockArticleRepo()
router := newStaleTokenInvalidationRouter(userRepo, articleRepo, newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token) router := newStaleTokenInvalidationRouter(userRepo, articleRepo, newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
@@ -298,7 +298,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
}) })
t.Run("Resetting one user's password never invalidates another user's jwt", func(t *testing.T) { t.Run("Resetting one user's password never invalidates another user's jwt", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newStaleTokenAliceRepo(true) userRepo := newStaleTokenAliceRepo(true)
userRepo.users[9] = user.User{ID: 9, Username: "bob", IsVerified: true, TokenVersion: 0} userRepo.users[9] = user.User{ID: 9, Username: "bob", IsVerified: true, TokenVersion: 0}
articleRepo := newMockArticleRepo() articleRepo := newMockArticleRepo()
@@ -329,7 +329,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
} }
for _, tc := range cancelledRegistrationCases { for _, tc := range cancelledRegistrationCases {
t.Run("A cancelled registration still refuses a jwt minted before the cancellation, on every protected route: "+tc.method+" "+tc.path, func(t *testing.T) { t.Run("A cancelled registration still refuses a jwt minted before the cancellation, on every protected route: "+tc.method+" "+tc.path, func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newStaleTokenAliceRepo(false) userRepo := newStaleTokenAliceRepo(false)
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token) router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
preCancelCookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(0)}) preCancelCookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": uint32(0)})
@@ -350,7 +350,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
} }
t.Run("A jwt with no token_version claim at all is refused, treated the same as a mismatch", func(t *testing.T) { t.Run("A jwt with no token_version claim at all is refused, treated the same as a mismatch", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newStaleTokenAliceRepo(true) userRepo := newStaleTokenAliceRepo(true)
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token) router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
cookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7)}) cookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7)})
@@ -365,7 +365,7 @@ func TestStaleTokenInvalidationHttp(t *testing.T) {
}) })
t.Run("A jwt whose token_version claim is a string, not a number, is refused instead of crashing", func(t *testing.T) { t.Run("A jwt whose token_version claim is a string, not a number, is refused instead of crashing", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
userRepo := newStaleTokenAliceRepo(true) userRepo := newStaleTokenAliceRepo(true)
router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token) router := newStaleTokenInvalidationRouter(userRepo, newMockArticleRepo(), newMockImageRepo(), newMockMailRepo(), &stubHasher{}, token)
cookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": "0"}) cookie := mintStaleTokenCookie(t, token, map[string]any{"user_id": uint32(7), "token_version": "0"})
@@ -13,14 +13,14 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newTrustedProxyAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *mockMailRepo, trustedProxies []string) *chi.Mux { func newTrustedProxyAuthRouter(t *testing.T, userRepo user.Repository, mailRepo *mockMailRepo, trustedProxies []string) *chi.Mux {
t.Helper() t.Helper()
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder), useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
@@ -16,8 +16,8 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newMigratedUserRepository(t *testing.T) *persistence.UserRepository { func newMigratedUserRepository(t *testing.T) *persistence.UserRepository {
@@ -151,7 +151,7 @@ func TestUnverifiedUsersAndReregistrationRepository(t *testing.T) {
}) })
} }
func newUnverifiedRegistrationRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { func newUnverifiedRegistrationRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
hasher := hashing.NewBcryptHasher() hasher := hashing.NewBcryptHasher()
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
@@ -184,7 +184,7 @@ func registerViaHttp(t *testing.T, router *chi.Mux, username, password, email st
func TestUnverifiedUsersAndReregistrationHttp(t *testing.T) { func TestUnverifiedUsersAndReregistrationHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
t.Run("Two unverified users can register one after another without a reset-code collision", func(t *testing.T) { t.Run("Two unverified users can register one after another without a reset-code collision", func(t *testing.T) {
repo := newMockUserRepo() repo := newMockUserRepo()
+2 -2
View File
@@ -23,7 +23,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/go-chi/jwtauth/v5" "RenewCMS/api/pkg/authtoken"
) )
const downloadPath = "/update/download" const downloadPath = "/update/download"
@@ -31,7 +31,7 @@ const downloadPath = "/update/download"
func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness { func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness {
t.Helper() t.Helper()
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
users := newUpdateUserRepo() users := newUpdateUserRepo()
feed := &fakeReleaseFeed{} feed := &fakeReleaseFeed{}
now := func() time.Time { return updateTestCheckedAt } now := func() time.Time { return updateTestCheckedAt }
+4 -4
View File
@@ -16,7 +16,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/go-chi/jwtauth/v5" "RenewCMS/api/pkg/authtoken"
) )
const ( const (
@@ -32,7 +32,7 @@ var updateStatusKeys = []string{"available", "can_apply", "checked_at", "contain
type updateHarness struct { type updateHarness struct {
router http.Handler router http.Handler
token *jwtauth.JWTAuth token *authtoken.Auth
feed *fakeReleaseFeed feed *fakeReleaseFeed
store *fakeUpdateStore store *fakeUpdateStore
} }
@@ -58,7 +58,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness {
Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}}, Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}},
}} }}
store := &fakeUpdateStore{} store := &fakeUpdateStore{}
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
now := func() time.Time { return updateTestCheckedAt } now := func() time.Time { return updateTestCheckedAt }
container := &api.UseCases{ container := &api.UseCases{
Token: token, Token: token,
@@ -74,7 +74,7 @@ func (h *updateHarness) send(t *testing.T, method, path string, userId uint32, t
t.Helper() t.Helper()
req := httptest.NewRequest(method, path, nil) req := httptest.NewRequest(method, path, nil)
if userId != 0 { if userId != 0 {
_, tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion}) tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+6 -6
View File
@@ -20,14 +20,14 @@ import (
"testing" "testing"
"time" "time"
"github.com/go-chi/jwtauth/v5" "RenewCMS/api/pkg/authtoken"
) )
const restartPath = "/update/restart" const restartPath = "/update/restart"
type restartHTTP struct { type restartHTTP struct {
router http.Handler router http.Handler
token *jwtauth.JWTAuth token *authtoken.Auth
dataDir string dataDir string
markerPath string markerPath string
resultPath string resultPath string
@@ -44,7 +44,7 @@ func newRestartHTTP(t *testing.T) *restartHTTP {
validator.InitValidator() validator.InitValidator()
dataDir := t.TempDir() dataDir := t.TempDir()
h := &restartHTTP{ h := &restartHTTP{
token: jwtauth.New("HS256", []byte("test-secret"), nil), token: authtoken.New([]byte("test-secret")),
dataDir: dataDir, dataDir: dataDir,
markerPath: filepath.Join(dataDir, update.RestartRequestFileName), markerPath: filepath.Join(dataDir, update.RestartRequestFileName),
resultPath: filepath.Join(dataDir, update.ResultFileName), resultPath: filepath.Join(dataDir, update.ResultFileName),
@@ -86,7 +86,7 @@ func (h *restartHTTP) request(t *testing.T, method string, target string, userId
t.Helper() t.Helper()
req := httptest.NewRequest(method, target, strings.NewReader(body)) req := httptest.NewRequest(method, target, strings.NewReader(body))
if userId != 0 { if userId != 0 {
_, tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion}) tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -554,7 +554,7 @@ func TestRestartResultLivesUntilTheNextJob(t *testing.T) {
func TestHealthzIsUnchanged(t *testing.T) { func TestHealthzIsUnchanged(t *testing.T) {
t.Run("Healthz is unchanged", func(t *testing.T) { t.Run("Healthz is unchanged", func(t *testing.T) {
container := &api.UseCases{ container := &api.UseCases{
Token: jwtauth.New("HS256", []byte("test-secret"), nil), Token: authtoken.New([]byte("test-secret")),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())), OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())),
} }
router := api.InitRoutes(container, config.Settings{Environment: "production"}) router := api.InitRoutes(container, config.Settings{Environment: "production"})
@@ -615,7 +615,7 @@ func TestRunningInfoFileAtStartup(t *testing.T) {
t.Run("the file is not served by any route", func(t *testing.T) { t.Run("the file is not served by any route", func(t *testing.T) {
container := &api.UseCases{ container := &api.UseCases{
Token: jwtauth.New("HS256", []byte("test-secret"), nil), Token: authtoken.New([]byte("test-secret")),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())), OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())),
} }
router := api.InitRoutes(container, config.Settings{Environment: "production"}) router := api.InitRoutes(container, config.Settings{Environment: "production"})
+9 -9
View File
@@ -16,13 +16,13 @@ import (
"strings" "strings"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
var displayNameResponseKeys = []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"} var displayNameResponseKeys = []string{"id", "username", "email", "is_verified", "created_at", "updated_at", "display_name", "role"}
func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { func newDisplayNameRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
hasher := &stubHasher{} hasher := &stubHasher{}
getUser := useCases.NewGetUserUseCase(userRepo) getUser := useCases.NewGetUserUseCase(userRepo)
update := useCases.NewUpdateUserProfileUseCase(userRepo, discardRecorder) update := useCases.NewUpdateUserProfileUseCase(userRepo, discardRecorder)
@@ -37,8 +37,8 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.M
r := chi.NewRouter() r := chi.NewRouter()
r.Route("/v1/user", func(r chi.Router) { r.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser)) r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser)) r.Use(middleware.RequireVerifiedUser(getUser))
r.Patch("/me", handler.UpdateMe) r.Patch("/me", handler.UpdateMe)
@@ -46,8 +46,8 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.M
r.Route("/v1/auth", func(r chi.Router) { r.Route("/v1/auth", func(r chi.Router) {
r.Post("/login", loginHandler.Login) r.Post("/login", loginHandler.Login)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser)) r.Use(middleware.RequireCurrentToken(getUser))
r.Get("/me", meHandler.Me) r.Get("/me", meHandler.Me)
}) })
@@ -55,9 +55,9 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.M
return r return r
} }
func displayNameCookie(t *testing.T, token *jwtauth.JWTAuth, userId uint32, tokenVersion uint32) *http.Cookie { func displayNameCookie(t *testing.T, token *authtoken.Auth, userId uint32, tokenVersion uint32) *http.Cookie {
t.Helper() t.Helper()
_, tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion}) tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil { if err != nil {
t.Fatalf("failed to encode jwt: %v", err) t.Fatalf("failed to encode jwt: %v", err)
} }
@@ -205,7 +205,7 @@ func TestUpdateUserProfileUseCase(t *testing.T) {
func TestUserDisplayNameHttp(t *testing.T) { func TestUserDisplayNameHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
patch := func(t *testing.T, repo *mockUserRepo, body string) *httptest.ResponseRecorder { patch := func(t *testing.T, repo *mockUserRepo, body string) *httptest.ResponseRecorder {
t.Helper() t.Helper()
+7 -7
View File
@@ -13,11 +13,11 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *jwtauth.JWTAuth) *chi.Mux { func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, token *authtoken.Auth) *chi.Mux {
hasher := &stubHasher{} hasher := &stubHasher{}
loginHandler := auth.NewLoginHandler( loginHandler := auth.NewLoginHandler(
token, token,
@@ -44,8 +44,8 @@ func newUserFieldHygieneRouter(userRepo *mockUserRepo, mailRepo *mockMailRepo, t
r.Post("/password-reset", passwordResetHandler.RequestPasswordReset) r.Post("/password-reset", passwordResetHandler.RequestPasswordReset)
r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword) r.Post("/password-reset/confirm", passwordResetHandler.ResetPassword)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Post("/verify", verificationHandler.Verify) r.Post("/verify", verificationHandler.Verify)
}) })
}) })
@@ -94,7 +94,7 @@ func assertNoForbiddenKeys(t *testing.T, body []byte) {
func TestUserResponseFieldHygiene(t *testing.T) { func TestUserResponseFieldHygiene(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
t.Run("A successful verification response carries only public fields", func(t *testing.T) { t.Run("A successful verification response carries only public fields", func(t *testing.T) {
repo := newMockUserRepo() repo := newMockUserRepo()
@@ -111,7 +111,7 @@ func TestUserResponseFieldHygiene(t *testing.T) {
body, _ := json.Marshal(map[string]string{"code": "abc123"}) body, _ := json.Marshal(map[string]string{"code": "abc123"})
req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body))
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)}) tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
@@ -164,7 +164,7 @@ func TestUserResponseFieldHygiene(t *testing.T) {
body, _ := json.Marshal(map[string]string{"code": "wrong-code"}) body, _ := json.Marshal(map[string]string{"code": "wrong-code"})
req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/v1/auth/verify", bytes.NewReader(body))
_, tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)}) tokenString, _ := token.Encode(map[string]any{"user_id": uint32(7)})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
router.ServeHTTP(rec, req) router.ServeHTTP(rec, req)
+10 -10
View File
@@ -16,9 +16,9 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
) )
@@ -33,7 +33,7 @@ type userRolesFixture struct {
invites *inviteFakeRepo invites *inviteFakeRepo
mail *mockMailRepo mail *mockMailRepo
router *chi.Mux router *chi.Mux
token *jwtauth.JWTAuth token *authtoken.Auth
} }
func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture { func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
@@ -43,7 +43,7 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
users: newMockUserRepo(), users: newMockUserRepo(),
invites: newInviteFakeRepo(), invites: newInviteFakeRepo(),
mail: newMockMailRepo(), mail: newMockMailRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil), token: authtoken.New([]byte("test-secret")),
} }
f.invites.users = f.users f.invites.users = f.users
hasher := &stubHasher{} hasher := &stubHasher{}
@@ -73,15 +73,15 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
r.Post("/register", loginHandler.Register) r.Post("/register", loginHandler.Register)
r.Post("/invite/accept", acceptHandler.AcceptInvite) r.Post("/invite/accept", acceptHandler.AcceptInvite)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token)) r.Use(authtoken.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser)) r.Use(middleware.RequireCurrentToken(getUser))
r.Get("/me", meHandler.Me) r.Get("/me", meHandler.Me)
}) })
}) })
f.router.Route("/v1/user", func(r chi.Router) { f.router.Route("/v1/user", func(r chi.Router) {
r.Use(jwtauth.Verifier(f.token)) r.Use(authtoken.Verifier(f.token))
r.Use(jwtauth.Authenticator(f.token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUser)) r.Use(middleware.RequireCurrentToken(getUser))
r.Use(middleware.RequireVerifiedUser(getUser)) r.Use(middleware.RequireVerifiedUser(getUser))
r.Get("/", inviteHandler.ListUsers) r.Get("/", inviteHandler.ListUsers)
@@ -386,12 +386,12 @@ func TestUserRolesUseCases(t *testing.T) {
func TestRequireOwnerRepositoryFailure(t *testing.T) { func TestRequireOwnerRepositoryFailure(t *testing.T) {
t.Run("An unexpected repository error is a 500, not a 403", func(t *testing.T) { t.Run("An unexpected repository error is a 500, not a 403", func(t *testing.T) {
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
repo := newMockUserRepo() repo := newMockUserRepo()
repo.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner} repo.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner}
r := chi.NewRouter() r := chi.NewRouter()
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireOwner(useCases.NewGetUserUseCase(repo))) r.Use(middleware.RequireOwner(useCases.NewGetUserUseCase(repo)))
r.Post("/", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) r.Post("/", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
repo.fail.failNext("Get") repo.fail.failNext("Get")
+7 -7
View File
@@ -14,11 +14,11 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *jwtauth.JWTAuth) *chi.Mux { func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler( articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()), useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
@@ -32,11 +32,11 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo
r := chi.NewRouter() r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) { r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) { r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Get("/", articleHandler.ListArticles) r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle) r.Get("/{id}", articleHandler.GetArticle)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle) r.Post("/", articleHandler.PostArticle)
r.Delete("/{id}", articleHandler.DeleteArticle) r.Delete("/{id}", articleHandler.DeleteArticle)
@@ -61,9 +61,9 @@ func seedVerifiedAccountRequiredUserRepo(isVerified bool) *mockUserRepo {
return repo return repo
} }
func mintVerifiedAccountRequiredCookie(t *testing.T, token *jwtauth.JWTAuth, claims map[string]any) *http.Cookie { func mintVerifiedAccountRequiredCookie(t *testing.T, token *authtoken.Auth, claims map[string]any) *http.Cookie {
t.Helper() t.Helper()
_, tokenString, err := token.Encode(claims) tokenString, err := token.Encode(claims)
if err != nil { if err != nil {
t.Fatalf("failed to encode jwt: %v", err) t.Fatalf("failed to encode jwt: %v", err)
} }
@@ -81,7 +81,7 @@ func newVerifiedAccountRequiredRequest(method, target string, body string) *http
func TestVerifiedAccountRequiredHttp(t *testing.T) { func TestVerifiedAccountRequiredHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
protectedRouteCases := []struct { protectedRouteCases := []struct {
method string method string
+5 -5
View File
@@ -17,8 +17,8 @@ import (
"testing" "testing"
"time" "time"
"RenewCMS/api/pkg/authtoken"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -208,7 +208,7 @@ func TestTheFakesMirrorTheRealRepositoriesOnEveryWriteNotFoundPath(t *testing.T)
} }
} }
func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux { func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
userRepo := newVerifiedUserRepo() userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo) getUserUseCase := useCases.NewGetUserUseCase(userRepo)
@@ -224,8 +224,8 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
r := chi.NewRouter() r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) { r.Route("/v1", func(r chi.Router) {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token)) r.Use(authtoken.Verifier(token))
r.Use(jwtauth.Authenticator(token)) r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase)) r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Route("/article", func(r chi.Router) { r.Route("/article", func(r chi.Router) {
r.Delete("/{id}", articleHandler.DeleteArticle) r.Delete("/{id}", articleHandler.DeleteArticle)
@@ -237,7 +237,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth
func TestWriteNotFoundErrorsHttp(t *testing.T) { func TestWriteNotFoundErrorsHttp(t *testing.T) {
validator.InitValidator() validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil) token := authtoken.New([]byte("test-secret"))
t.Run("Deleting an unknown article answers 404, not a silent success or a 500", func(t *testing.T) { t.Run("Deleting an unknown article answers 404, not a silent success or a 500", func(t *testing.T) {
articleRepo := newMockArticleRepo() articleRepo := newMockArticleRepo()