Compare commits

..
217 changed files with 10796 additions and 213 deletions

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ CORS_ALLOWED_ORIGINS=
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES=
SMTP_ALLOW_INSECURE=false
UPDATE_FEED_URL=https://git.floriansylvain.fr
RENEWCMS_CONTAINER=false
IMAGE_VARIANT_MAX_SOURCE_BYTES=10485760
+1
View File
@@ -47,6 +47,7 @@ task dev # API with hot reload + Vite HMR → http://localhost:51
| [docs/deployment.md](docs/deployment.md) | Building release binaries, running as a service, HTTPS, backups, upgrades |
| [docs/docker.md](docs/docker.md) | Running RenewCMS in Docker: image, Compose, data volume, backups, reverse proxy, tags, upgrades, publishing |
| [docs/configuration.md](docs/configuration.md) | Every environment variable |
| [docs/privacy.md](docs/privacy.md) | Personal data stored, retention, cookies, outbound connections and GDPR requests |
| [docs/architecture.md](docs/architecture.md) | Backend layering and how the SPA is embedded |
| [docs/performance.md](docs/performance.md) | Measured costs, such as the `?seo=` article filter |
| [docs/api.md](docs/api.md) | API reference: authentication, API keys, routes and the activity log |
+1 -1
View File
@@ -231,7 +231,7 @@ tasks:
desc: Type-check and build the SPA into {{.DIST_DIR}}
dir: web
deps: [web:install]
sources: [src/**/*, public/**/*, index.html, vite.config.ts, tsconfig*.json, package.json, bun.lock]
sources: [src/**/*, public/**/*, vite/**/*, index.html, vite.config.ts, tsconfig*.json, package.json, bun.lock]
generates: ['../{{.DIST_DIR}}/index.html']
cmds:
- bun run build
+8 -3
View File
@@ -12,6 +12,7 @@ import (
domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail"
domainPurge "RenewCMS/internal/domain/purge"
domainSession "RenewCMS/internal/domain/session"
domainSite "RenewCMS/internal/domain/site"
domainTransaction "RenewCMS/internal/domain/transaction"
domainUpdate "RenewCMS/internal/domain/update"
@@ -46,6 +47,7 @@ type UseCases struct {
Token *authtoken.Auth
PurgeScheduler *scheduler.Runner
ActivityCleanupScheduler *scheduler.Runner
RetentionScheduler *scheduler.Runner
CreateArticleUseCase *useCases.CreateArticleUseCase
GetArticleUseCase *useCases.GetArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase
@@ -139,7 +141,7 @@ type UseCases struct {
func (c *UseCases) RunSchedulers(ctx context.Context) {
var running sync.WaitGroup
for _, runner := range []*scheduler.Runner{c.PurgeScheduler, c.ActivityCleanupScheduler} {
for _, runner := range []*scheduler.Runner{c.PurgeScheduler, c.ActivityCleanupScheduler, c.RetentionScheduler} {
if runner == nil {
continue
}
@@ -168,6 +170,7 @@ type Repositories struct {
VariantRenderer domainImage.VariantRenderer
InviteRepo domainInvite.Repository
APIKeyRepo domainAPIKey.Repository
SessionRepo domainSession.Repository
PurgeSettingsRepo domainPurge.Repository
AuditRepo domainAudit.Repository
AuditSettingsRepo domainAudit.SettingsRepository
@@ -311,6 +314,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
VariantRenderer: imaging.NewVariantRenderer(),
InviteRepo: persistence.NewInviteRepository(db),
APIKeyRepo: persistence.NewAPIKeyRepository(db),
SessionRepo: persistence.NewRevokedSessionRepository(db),
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
AuditRepo: persistence.NewAuditEventRepository(db),
AuditSettingsRepo: persistence.NewAuditSettingsRepository(db),
@@ -346,7 +350,8 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
return &UseCases{
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
Token: authtoken.New([]byte(settings.JWTSecret)),
RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()),
Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now))),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
@@ -395,7 +400,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder, imaging.NewMetadataStripper()),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo, repos.VariantFileRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
+5
View File
@@ -506,6 +506,11 @@ func isVerifiedCaller(r *http.Request, getUser *useCases.GetUserUseCase) bool {
return false
}
tokenVersion, ok := claims.TokenVersion(r)
if !ok || tokenVersion != currentUser.TokenVersion {
return false
}
return currentUser.IsVerified
}
+2
View File
@@ -95,6 +95,8 @@ func writeAcceptInviteError(w http.ResponseWriter, err error) {
http.Error(w, apperr.UsernameTakenErrorMessage, http.StatusConflict)
case errors.Is(err, invite.ErrEmailHasAccount):
http.Error(w, apperr.EmailHasAccountErrorMessage, http.StatusConflict)
case errors.Is(err, user.ErrPasswordTooLong):
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
+15 -3
View File
@@ -41,12 +41,13 @@ func NewLoginHandler(token *authtoken.Auth, create *useCases.CreateUserUseCase,
func removeJwtCookie(w http.ResponseWriter, isProduction bool) {
http.SetCookie(w, &http.Cookie{
Name: "jwt",
Name: authtoken.CookieName,
Value: "",
Expires: time.Now(),
MaxAge: -1,
Secure: isProduction,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
})
}
@@ -57,11 +58,12 @@ func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction boo
return err
}
http.SetCookie(w, &http.Cookie{
Name: "jwt",
Name: authtoken.CookieName,
Value: tokenString,
Expires: time.Now().Add(authtoken.SessionLifetime),
Secure: isProduction,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
})
return nil
@@ -122,6 +124,10 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
http.Error(w, apperr.RegistrationClosedErrorMessage, http.StatusForbidden)
return
}
if errors.Is(err, user.ErrPasswordTooLong) {
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, apperr.RegistrationFailedErrorMessage, http.StatusInternalServerError)
return
}
@@ -139,8 +145,14 @@ func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request
}
func (h *LoginHandler) Logout(w http.ResponseWriter, r *http.Request) {
h.AuthenticateUseCase.Logout(r.Context())
removeJwtCookie(w, h.IsProduction)
if claims, err := authtoken.FromContext(r.Context()); err == nil {
if err := h.Token.Revoke(claims); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
h.AuthenticateUseCase.Logout(r.Context())
message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"})
_, _ = w.Write(message)
}
@@ -72,6 +72,10 @@ func (h *PasswordResetHandler) ResetPassword(w http.ResponseWriter, r *http.Requ
http.Error(w, apperr.PasswordResetFailedErrorMessage, http.StatusForbidden)
return
}
if errors.Is(err, user.ErrPasswordTooLong) {
http.Error(w, apperr.PasswordTooLongErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
@@ -92,6 +92,8 @@ func writeContentTypeError(w http.ResponseWriter, err error) {
http.Error(w, apperr.ContentTypeKeyTakenErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrHasEntries):
http.Error(w, apperr.ContentTypeHasEntriesErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrReferenced):
http.Error(w, apperr.ContentTypeReferencedErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrSchemaIncompatible):
http.Error(w, apperr.SchemaIncompatibleErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrInvalidName):
+45 -1
View File
@@ -43,6 +43,49 @@ type entryResponse struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Media map[string]any `json:"media"`
Relations map[string]any `json:"relations"`
}
type firstFieldBody struct {
Key string `json:"key"`
Type contenttype.FieldType `json:"type"`
Value any `json:"value"`
}
type relationBody struct {
ID uint32 `json:"id"`
ContentType string `json:"content_type"`
FirstField firstFieldBody `json:"first_field"`
}
type missingRelationBody struct {
ID uint32 `json:"id"`
Missing bool `json:"missing"`
}
func toRelationItem(item useCases.EntryRelation) any {
if item.Missing {
return missingRelationBody{ID: item.ID, Missing: true}
}
return relationBody{ID: item.ID, ContentType: item.ContentType, FirstField: firstFieldBody{Key: item.FirstField.Key, Type: item.FirstField.Type, Value: item.FirstField.Value}}
}
func toRelationsResponse(relations map[string]useCases.EntryRelations) map[string]any {
body := make(map[string]any, len(relations))
for key, held := range relations {
if !held.Many {
if len(held.Items) > 0 {
body[key] = toRelationItem(held.Items[0])
}
continue
}
items := make([]any, 0, len(held.Items))
for _, item := range held.Items {
items = append(items, toRelationItem(item))
}
body[key] = items
}
return body
}
type mediaBody struct {
@@ -81,6 +124,7 @@ func toResponse(typed useCases.TypedEntry) entryResponse {
CreatedAt: typed.CreatedAt,
UpdatedAt: typed.UpdatedAt,
Media: toMediaResponse(typed.Media),
Relations: toRelationsResponse(typed.Relations),
}
}
@@ -181,7 +225,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort})
page, err := h.ListUseCase.ListPage(useCases.ListEntriesCommand{ContentTypeKey: chi.URLParam(r, "key"), Page: query.Page, Size: query.Size, Sort: query.Sort, Query: r.URL.Query().Get("q")})
if err != nil {
writeEntryError(w, err)
return
+36
View File
@@ -0,0 +1,36 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"net/url"
)
func CrossOriginProtection(publicURL string) func(http.Handler) http.Handler {
protection := http.NewCrossOriginProtection()
if origin, ok := originOf(publicURL); ok {
_ = protection.AddTrustedOrigin(origin)
}
protection.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, apperr.CrossOriginErrorMessage, http.StatusForbidden)
}))
return func(next http.Handler) http.Handler {
protected := protection.Handler(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
next.ServeHTTP(w, r)
return
}
protected.ServeHTTP(w, r)
})
}
}
func originOf(rawURL string) (string, bool) {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", false
}
return parsed.Scheme + "://" + parsed.Host, true
}
+34 -12
View File
@@ -14,8 +14,19 @@ type rateLimitWindow struct {
count int
}
type rateLimitCounters struct {
mu sync.Mutex
window time.Duration
windows map[string]*rateLimitWindow
lastSweep time.Time
}
func NewRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
return newKeyedRateLimiter(maxAttempts, window, func(r *http.Request) string { return r.RemoteAddr })
return newKeyedRateLimiter(maxAttempts, window, connectionHostKey)
}
func connectionHostKey(r *http.Request) string {
return hostOf(r.RemoteAddr)
}
func NewUserRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
@@ -28,12 +39,11 @@ func userRateLimitKey(r *http.Request) string {
}
func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http.Request) string) func(http.Handler) http.Handler {
var mu sync.Mutex
windows := make(map[string]*rateLimitWindow)
counters := &rateLimitCounters{window: window, windows: make(map[string]*rateLimitWindow)}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
retryAfter, allowed := recordAttempt(&mu, windows, keyOf(r), maxAttempts, window)
retryAfter, allowed := counters.recordAttempt(keyOf(r), maxAttempts, time.Now())
if !allowed {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
@@ -44,24 +54,36 @@ func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http
}
}
func recordAttempt(mu *sync.Mutex, windows map[string]*rateLimitWindow, key string, maxAttempts int, window time.Duration) (int, bool) {
mu.Lock()
defer mu.Unlock()
func (c *rateLimitCounters) recordAttempt(key string, maxAttempts int, now time.Time) (int, bool) {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
current, exists := windows[key]
if !exists || now.Sub(current.start) >= window {
c.sweepExpired(now)
current, exists := c.windows[key]
if !exists || now.Sub(current.start) >= c.window {
current = &rateLimitWindow{start: now}
windows[key] = current
c.windows[key] = current
}
current.count++
if current.count > maxAttempts {
return retryAfterSeconds(window - now.Sub(current.start)), false
return retryAfterSeconds(c.window - now.Sub(current.start)), false
}
return 0, true
}
func (c *rateLimitCounters) sweepExpired(now time.Time) {
if now.Sub(c.lastSweep) < c.window {
return
}
c.lastSweep = now
for key, current := range c.windows {
if now.Sub(current.start) >= c.window {
delete(c.windows, key)
}
}
}
func retryAfterSeconds(remaining time.Duration) int {
seconds := int(remaining / time.Second)
if remaining%time.Second > 0 {
+2 -2
View File
@@ -4,8 +4,8 @@ import "net/http"
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self' 'wasm-unsafe-eval'; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " +
"style-src 'self' 'unsafe-inline'; " +
"font-src 'self'; " +
"img-src 'self' data: blob: https:; " +
"connect-src 'self'; " +
"object-src 'none'; " +
+3
View File
@@ -16,6 +16,7 @@ const VerificationRequiredErrorMessage = "You must verify your account to access
const PasswordResetRequestedMessage = "If an account exists for that email, a password reset link has been sent."
const PasswordResetFailedErrorMessage = "The password reset code is invalid or has expired."
const TooManyRequestsErrorMessage = "Too many requests. Please try again later."
const CrossOriginErrorMessage = "Cross-origin requests are not allowed."
const EmailHasAccountErrorMessage = "This address already has an account."
const PendingInviteErrorMessage = "There's already a pending invite for this address."
const InviteNotSentErrorMessage = "Couldn't send the invite. Try again in a moment."
@@ -25,6 +26,7 @@ const InviteInvalidErrorMessage = "This invite link doesn't work."
const InviteExpiredErrorMessage = "This invite has expired."
const OwnerRequiredErrorMessage = "Only the owner can do this."
const UsernameTakenErrorMessage = "That username is taken. Try another."
const PasswordTooLongErrorMessage = "The password must be 72 bytes or fewer."
const MediaQueryErrorMessage = "The media query is invalid."
const MediaFilenameErrorMessage = "Enter a filename."
const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again."
@@ -90,6 +92,7 @@ const SlugSuggestionHeader = "X-Slug-Suggestion"
const ContentTypeNotFoundErrorMessage = "content_type_not_found"
const ContentTypeKeyTakenErrorMessage = "content_type_key_taken"
const ContentTypeHasEntriesErrorMessage = "content_type_has_entries"
const ContentTypeReferencedErrorMessage = "content_type_referenced"
const InvalidContentTypeKeyErrorMessage = "invalid_content_type_key"
const InvalidIconErrorMessage = "invalid_icon"
const BuiltinTypeNotFoundErrorMessage = "builtin_type_not_found"
+58 -3
View File
@@ -2,6 +2,7 @@ package authtoken
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
@@ -26,8 +27,14 @@ var (
ErrInvalid = errors.New("token is invalid")
ErrExpired = errors.New("token is expired")
ErrNotYetValid = errors.New("token is not yet valid")
ErrRevoked = errors.New("token is revoked")
)
type RevocationStore interface {
Revoke(id string, expiresAt time.Time) error
IsRevoked(id string) (bool, error)
}
type Claims map[string]any
type header struct {
@@ -36,8 +43,9 @@ type header struct {
}
type Auth struct {
secret []byte
clock func() time.Time
secret []byte
clock func() time.Time
revocations RevocationStore
}
type Option func(*Auth)
@@ -46,6 +54,10 @@ func WithClock(clock func() time.Time) Option {
return func(a *Auth) { a.clock = clock }
}
func WithRevocationStore(store RevocationStore) Option {
return func(a *Auth) { a.revocations = store }
}
func New(secret []byte, options ...Option) *Auth {
auth := &Auth{secret: secret, clock: time.Now}
for _, option := range options {
@@ -66,6 +78,9 @@ func (a *Auth) Encode(claims map[string]any) (string, error) {
if _, ok := payload["exp"]; !ok {
payload["exp"] = now.Add(SessionLifetime).Unix()
}
if _, ok := payload["jti"]; !ok {
payload["jti"] = rand.Text()
}
headerSegment, err := encodeSegment(header{Algorithm: algorithmHS256, Type: tokenType})
if err != nil {
@@ -111,9 +126,46 @@ func (a *Auth) Decode(token string) (Claims, error) {
if err := a.validateTimes(claims); err != nil {
return nil, err
}
if err := a.checkRevocation(claims); err != nil {
return nil, err
}
return claims, nil
}
func (a *Auth) Revoke(claims Claims) error {
id, ok := claims["jti"].(string)
if !ok || a.revocations == nil {
return nil
}
expiry, present, err := numericDate(claims, "exp")
if err != nil || !present {
return ErrInvalid
}
return a.revocations.Revoke(id, expiry)
}
func (a *Auth) checkRevocation(claims Claims) error {
raw, present := claims["jti"]
if !present {
return nil
}
id, ok := raw.(string)
if !ok {
return ErrInvalid
}
if a.revocations == nil {
return nil
}
revoked, err := a.revocations.IsRevoked(id)
if err != nil {
return errors.Join(ErrInvalid, err)
}
if revoked {
return ErrRevoked
}
return nil
}
func (a *Auth) validateTimes(claims Claims) error {
now := a.clock()
@@ -121,7 +173,10 @@ func (a *Auth) validateTimes(claims Claims) error {
if err != nil {
return err
}
if present && !now.Before(expiry) {
if !present {
return ErrInvalid
}
if !now.Before(expiry) {
return ErrExpired
}
+2 -2
View File
@@ -8,7 +8,7 @@ import (
)
const (
cookieName = "jwt"
CookieName = "jwt"
bearerPrefix = "bearer "
)
@@ -71,7 +71,7 @@ func tokenFromHeader(r *http.Request) string {
}
func tokenFromCookie(r *http.Request) string {
cookie, err := r.Cookie(cookieName)
cookie, err := r.Cookie(CookieName)
if err != nil {
return ""
}
+1
View File
@@ -490,6 +490,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
apiRouter := chi.NewRouter()
apiRouter.Use(middleware.SecurityHeaders(settings.IsProduction()))
enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins)
apiRouter.Use(middleware.CrossOriginProtection(settings.PublicURL))
apiRouter.Use(chimiddleware.GetHead)
apiRouter.Get("/healthz", GetHealth)
apiRouter.Mount("/v1", backend)
+22 -21
View File
@@ -41,27 +41,28 @@ var requiredEnvVars = []string{
}
var envDefaults = map[string]string{
"ENVIRONMENT": "production",
"PORT": "8080",
"DB_FILE": "./data/renewcms.db",
config.EnvDatabaseDriver: config.DefaultDatabaseDriver,
"DB_TLS": config.DefaultDatabaseTLS,
"DB_SSLMODE": config.DefaultDatabaseSSLMode,
"DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns,
"DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns,
"DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds,
"UPLOAD_DIR": "./data/uploads",
config.EnvStorageDriver: config.DefaultStorageDriver,
"S3_REGION": config.DefaultS3Region,
"S3_PATH_STYLE": config.DefaultS3PathStyle,
"S3_TLS": config.DefaultS3TLS,
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
"TRUSTED_PROXIES": "",
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
"RENEWCMS_CONTAINER": "false",
config.EnvImageVariantMaxSourceBytes: config.DefaultImageVariantMaxSourceBytes,
config.EnvImageVariantMaxSourcePixels: config.DefaultImageVariantMaxSourcePixels,
"ENVIRONMENT": "production",
"PORT": "8080",
"DB_FILE": "./data/renewcms.db",
config.EnvDatabaseDriver: config.DefaultDatabaseDriver,
"DB_TLS": config.DefaultDatabaseTLS,
"DB_SSLMODE": config.DefaultDatabaseSSLMode,
"DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns,
"DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns,
"DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds,
"UPLOAD_DIR": "./data/uploads",
config.EnvStorageDriver: config.DefaultStorageDriver,
"S3_REGION": config.DefaultS3Region,
"S3_PATH_STYLE": config.DefaultS3PathStyle,
"S3_TLS": config.DefaultS3TLS,
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
"TRUSTED_PROXIES": "",
config.EnvSmtpAllowInsecure: config.DefaultSmtpAllowInsecure,
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
"RENEWCMS_CONTAINER": "false",
config.EnvImageVariantMaxSourceBytes: config.DefaultImageVariantMaxSourceBytes,
config.EnvImageVariantMaxSourcePixels: config.DefaultImageVariantMaxSourcePixels,
config.EnvImageVariantMaxConcurrentRenders: config.DefaultImageVariantMaxConcurrentRenders,
}
+6
View File
@@ -21,6 +21,12 @@ for example `https://cms.example/v1/article/`.
or revoked key answers 401, even on public routes. The site writes (`POST`, `PATCH` and `DELETE`
under `/v1/site`) and the author writes (`POST`, `PATCH` and `DELETE` under `/v1/author`, `POST /v1/author/me` included) are session only too.
- Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header.
- A session lasts 24 hours. `POST /v1/auth/logout` revokes it on the server, so a copied cookie
stops working at once; other sessions of the same user stay valid. A password reset ends every
session and revokes every key of that user.
- A browser `POST`, `PUT`, `PATCH` or `DELETE` sent from another origin without an
`Authorization` header answers 403 "Cross-origin requests are not allowed." This blocks
cross-site request forgery with the cookie; scripts on other origins must send their key.
## Routes a key can call (30)
| Group | Routes |
+8
View File
@@ -55,6 +55,14 @@ The use cases `GetPurgeSettings`, `UpdatePurgeSettings`, `PreviewPurge` and `Run
| `GET /v1/media/purge-preview` | Groups, copies, distinct articles, the first 5 groups, `more` and the fingerprint. |
| `POST /v1/media/purge` | Runs the purge. Optional `fingerprint`; 409 when stale or when a run is in progress. |
### Sessions and personal data retention
A session is an HS256 token from `api/pkg/authtoken`. `Encode` adds a random `jti` and an `exp` 24 hours ahead; `Decode` refuses a token without `exp`. `authtoken.WithRevocationStore` plugs in `useCases.SessionRevocationUseCase`, which stores a `session.Revocation` (`jti`, `exp`) through the `internal/domain/session` `Repository` (`persistence.RevokedSessionRepository`, table `revoked_sessions`). `LoginHandler.Logout` revokes the presented token; from then on `Decode` answers `ErrRevoked` for it, and a store that cannot be read makes every token invalid rather than valid. Revoking every session of a user stays the job of `token_version`, bumped by a password reset.
`useCases.EnforceRetentionUseCase.Tick(now)` deletes revocations past their expiry, unverified accounts 7 days after their code expired (`user.UnverifiedRetention`) and invites 30 days after they expired or were accepted (`invite.Retention`), and returns a `retention.Result`. `scheduler.NewSystemRetentionRunner` calls it one minute after startup and then hourly, logs one line only when something was removed, and stops with the other runners on shutdown.
Uploads go through the `image.MetadataStripper` port before they are measured for size, hashed and stored. `imaging.MetadataStripper` walks the JPEG segments, PNG chunks and WebP RIFF chunks without re-encoding pixels, drops metadata, keeps colour profiles and re-adds an orientation-only EXIF block when the photo was rotated. A container it cannot walk fails the upload with `image.ErrImageUnreadable`.
### Activity log
`internal/domain/audit` holds the append-only `Event` (actor, target and failure-reason snapshots, outcome, security flag, source, ip, user agent, `params` and `changes`), the closed vocabularies (actor type, category, outcome, source, change kind), the `Filter`, `Summary` and `Settings` types, the `Repository` interface (`Append`, `List`, `Summary`, `DeleteOlderThan`: no way to edit or delete one event), the `SettingsRepository` (`Get`, `SaveRetention`, `SaveLastCleanup`; no row means 90 days) and the sentinels `ErrInvalidFilter`, `ErrInvalidRetention`, `ErrInvalidEvent` and `ErrActorNotAllowed`. The `Recorder` port is `Record(ctx, event)`; it returns nothing, so a caller can never fail on it.
+2 -1
View File
@@ -11,7 +11,7 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
| Name | Description | Example |
|-----------------|---------------------------------------------------------------------------------------------------------------------|----------------------------|
| `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. It is also the CMS's own address: at the first migration, when the sites table is empty, it seeds the default site (name "Default", key "default") with its scheme and host (path dropped, non-loopback `http` upgraded to `https`); an unparseable value stops the migration. It is never read for sites afterwards. | `https://cms.example.com` |
| `JWT_SECRET` | Secret used to sign the auth JWT. Use a long random value; changing it logs everyone out. | `openssl rand -hex 32` |
| `JWT_SECRET` | Secret used to sign the auth JWT. It must be at least 32 bytes, and the example values from `.env.example` and `docker.env.example` are refused: the server stops at startup with `JWT_SECRET is too weak` and how to generate one. Changing it logs everyone out. | `openssl rand -hex 32` |
| `SMTP_LOGIN` | SMTP username used to authenticate with the server. Some providers (e.g. Brevo) issue a login distinct from the sender address. | `noreply@example.com` |
| `SMTP_SENDER` | Sender address used in the `From` header. Must be an address/domain the SMTP provider allows that login to send as. | `noreply@example.com` |
| `SMTP_PASSWORD` | SMTP password. | |
@@ -32,6 +32,7 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
| `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_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
| `SMTP_ALLOW_INSECURE` | `false` | `true` lets RenewCMS deliver mail without encryption to a server on another host that does not offer STARTTLS (a relay on a private network, a local Mailpit container). By default such delivery fails with an error naming this variable; port `465` always uses implicit TLS and `localhost`, `127.0.0.1` and `::1` are always allowed in clear text. Credentials (`SMTP_LOGIN`/`SMTP_PASSWORD`) are never sent unencrypted to another host, whatever this says. Any value other than `true` or `false` (case-insensitive) stops the server at boot. |
| `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). |
| `RENEWCMS_CONTAINER` | `false` | Set to `true` (case-insensitive; any other value means `false`) when the app runs in a container. Settings then tells the owner to update by pulling a new image instead of offering browser updates, and `can_apply` is always `false`. The Dockerfile and `docker-compose.yml` set it to `true`. Update checks still work. |
| `IMAGE_VARIANT_MAX_SOURCE_BYTES` | `10485760` | Largest stored image, in bytes, that an [image variant](image-variants.md) may be generated from. A request for a bigger source answers 413 with a plain-text body. The original is never affected, and SVG and animated GIF are always served as they are. A value that is not a whole number greater than zero stops the server at boot with an error naming the variable. |
+1 -1
View File
@@ -274,7 +274,7 @@ Either way, set `TRUSTED_PROXIES` (see [configuration.md](configuration.md)) to
RenewCMS sends `Content-Security-Policy` (including `frame-ancestors 'none'`), `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` and `Cross-Origin-Opener-Policy` itself, so the proxy doesn't need to add them. With `ENVIRONMENT=production` it also sends `Strict-Transport-Security: max-age=31536000` (browsers ignore it over plain HTTP, so serve the site over HTTPS only). Don't add the same headers in the proxy, or they will be duplicated.
The CSP allows Google Fonts (`fonts.googleapis.com`, `fonts.gstatic.com`) and `https:` images, because the SPA loads its fonts from Google and articles may embed remote images. `style-src` keeps `'unsafe-inline'` for the article editor; `script-src` is `'self'` only.
The CSP serves fonts from `'self'` only (the SPA bundles its fonts, so no visitor IP address is sent to a font CDN) and allows `https:` images, because articles may embed remote images. `style-src` keeps `'unsafe-inline'` for the article editor; `script-src` is `'self'` only.
## Using MySQL or MariaDB
+77
View File
@@ -0,0 +1,77 @@
# Privacy and personal data
This page is for operators: whoever runs a RenewCMS instance is the data controller for it under the GDPR. It lists what the software stores about people, where, for how long, and what it sends elsewhere, so you can fill in your record of processing (art. 30), write your privacy notice (art. 13) and answer requests from the people concerned. It describes the software, not your obligations; it is not legal advice.
## Who is concerned
- **Back-office users**: the owner and the admins they invite.
- **Invitees** who have not accepted yet.
- **People named in content**: authors, and anyone your editors write about or photograph.
- **Anyone who performs an audited action** (logging in, failing to log in, changing anything) has their IP address and browser recorded in the activity log.
Readers of your public site are not tracked by RenewCMS: the public routes set no cookie and log nothing in production.
## What is stored, and for how long
| Data | Where | Kept |
|---|---|---|
| Username, email, display name, role, bcrypt password hash | `users` table | Until the account is deleted (see [Gaps](#gaps)). |
| Verification and password-reset codes | `users` table, bcrypt-hashed | Verification: until the account is verified. Reset: one hour, then useless; cleared when used. |
| Unverified sign-ups | `users` table | Deleted automatically 7 days after their verification code expired. |
| Invites: invitee email, hashed code, inviter | `invites` table | Deleted automatically 30 days after they expired or were accepted. |
| Sessions | The `jwt` cookie in the user's browser, valid 24 hours | Logging out revokes the session on the server; the revocation (a random id and an expiry, nothing else) is deleted once the session would have expired anyway. |
| API keys: name, prefix, SHA-256 hash, creator, last use | `api_keys` table | Until revoked. A revoked key stays listed. |
| Activity log: who (user id and name, API key name, or the username typed at a failed login), what, when, IP address, browser (user agent, truncated to 255 characters) | `audit_events` table | Settings → Activity: 30, 90 (default), 365 days or forever, and an optional size cap. Cleaned up once a night, at 03:00 server time. |
| Authors: name, slug, bio, avatar, website, social links; the linked user's email is shown to admins | `authors` table | Until deleted. |
| Articles and custom entries | `articles`, `entries` tables | Deleted articles are soft-deleted: the row and its content stay in the database (see [Gaps](#gaps)). |
| Uploaded images | `UPLOAD_DIR` or the S3 bucket, plus the `images` table (file name, size, uploader) | Until deleted from the media library. Deleted images are soft-deleted in the table; the file itself is removed. |
### Photo metadata
Since this version, uploads are cleaned before they are stored. JPEG, PNG and WebP files lose their EXIF (GPS position, camera serial number, date), XMP, IPTC, comments and text chunks; only the orientation is kept, so rotated photos still display correctly. The pixels are not re-encoded. SVG files are stored as uploaded. **Images uploaded with an earlier version are not cleaned retroactively**: if they may carry GPS positions, download, clean and re-upload them, or merge them into a cleaned copy from the media library.
### Activity log retention
For security logs, the CNIL generally recommends keeping access logs between six months and a year. The default of 90 days is shorter; choose 365 days in Settings if that suits your purpose. Avoid "Forever": it keeps IP addresses indefinitely, which the storage-limitation principle rarely justifies.
## Cookies and browser storage
- `jwt`: the session cookie. HttpOnly, `SameSite=Lax`, `Secure` in production, 24 hours. Strictly necessary for the back office, so it needs no consent (ePrivacy art. 5(3); in France, Loi Informatique et Libertés art. 82).
- `localStorage` on the user's device holds interface choices only: language (`renewcms-language`), page sizes, open or collapsed panels, dismissed hints and columns. `sessionStorage` holds unsaved article drafts until the tab closes. None of it is sent to the server or to anyone else, and it needs no consent either.
RenewCMS sets no analytics, advertising or third-party cookie, so no consent banner is needed for the back office. Your public site may need one if *it* adds trackers.
## What leaves the server
| Destination | When | What |
|---|---|---|
| Your SMTP provider (`SMTP_HOST`) | Sign-up, invite, password reset | Recipient address, the message (with a one-time link). STARTTLS or implicit TLS is required for any non-loopback host unless you set `SMTP_ALLOW_INSECURE=true`. |
| Your S3 provider, if `STORAGE_DRIVER=s3` | Every upload and image read | The image files. |
| `UPDATE_FEED_URL` (default `https://git.floriansylvain.fr`) | Only when the owner clicks "Check for updates" or downloads one | The server's IP address and a standard HTTP request. No account or content data. |
The back office loads nothing from third parties: fonts, icons and scripts are served by RenewCMS itself, and the Content-Security-Policy forbids any other source. Pick EU-based SMTP and storage providers, or make sure transfer safeguards (art. 44 and following) cover them, and list them as processors.
## Security measures you can cite (art. 32)
- Passwords are hashed with bcrypt (cost 12). One-time codes are random UUIDs, stored hashed, single-use and short-lived. API keys are 256-bit random values stored as SHA-256 hashes.
- Login, password-reset and invite routes are rate-limited per client IP; failed logins are recorded as security events.
- Sessions are revocable: logout ends one session, a password reset ends all of them and revokes the user's API keys.
- Cross-site writes are refused, cookies are `SameSite=Lax`, and a strict Content-Security-Policy, `X-Frame-Options: DENY` and HSTS (in production) are sent.
- The server refuses to start with a short or example `JWT_SECRET`.
- Release binaries are signed (ed25519) and checked before a browser update is installed.
## Answering requests from the people concerned
- **Access and portability (art. 15 and 20)**: there is no self-service export yet. The data of a user is their row in `users`, their author profile, their API keys and the activity events where they are the actor; an owner can export the activity log as CSV from the Activity page and filter it by person.
- **Rectification (art. 16)**: users can change their display name and author profile themselves. Changing an email address currently needs a database edit.
- **Erasure (art. 17)**: an unverified account can cancel its own registration. Deleting a verified user has no button yet; see [Gaps](#gaps).
## Gaps
These are known and tracked; until they are fixed, handle them by hand.
- No way to delete a verified user or remove an admin from the interface.
- No self-service export or email change.
- Soft-deleted articles and images keep their rows indefinitely.
- The username typed at a failed login is stored as typed, so a password typed in the wrong field can end up in the activity log; clean such events by hand if it happens.
- Images uploaded before metadata stripping keep their metadata.
+2
View File
@@ -42,3 +42,5 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
| `v3.3.0-beta` | Sites, authors and search and sharing |
| `v3.4.0-beta` | Custom content types |
| `v3.4.1-beta` | Media field and security updates |
| `v3.4.2-beta` | Security and privacy hardening |
| `v3.5.0-beta` | Relation field |
+39
View File
@@ -0,0 +1,39 @@
This release hardens sign-in, sessions and mail against common attacks, and reduces the personal data RenewCMS keeps or shares, following a security, GDPR and CNIL review.
## Improvements and fixes
- Signing out now ends the session on the server. A copied session cookie stops working at once, while the same user's other sessions keep working.
- The limit on login, password reset and invitation attempts now applies per client address. Before, opening a new connection reset the count, so the limit could be bypassed.
- Requests that change data and come from another website are refused, and the session cookie is now `SameSite=Lax`. A malicious page can no longer act on behalf of a signed-in user.
- A session made stale by a password reset can no longer read draft articles.
- Signing in with an unknown username now takes as long as signing in with a wrong password, so response times no longer reveal which usernames exist.
- A password longer than 72 bytes is refused with a clear message, where it used to cause a server error.
- Uploaded JPEG, PNG and WebP images lose their hidden metadata (GPS position, camera serial number, dates, comments) before they are stored. Pixels and colour profiles are untouched, and rotated photos keep their orientation.
- The back office no longer loads its fonts from Google. The fonts ship with RenewCMS, so no visitor IP address is sent to a third party.
- Unverified sign-ups are deleted 7 days after their verification code expired, and invitations 30 days after they expired or were accepted.
- Exported activity CSV files also neutralise cells starting with a tab or a carriage return, which spreadsheets could otherwise treat as formulas.
## API
- Changed: `POST`, `PUT`, `PATCH` and `DELETE` requests sent by a browser from another origin without an `Authorization` header answer `403` "Cross-origin requests are not allowed." Scripts on other origins must send their API key.
- Changed: `POST /v1/auth/logout` revokes the presented session. It answers `500` only when the revocation cannot be stored, and clears the cookie either way.
- `POST /v1/auth/register`, `POST /v1/auth/password-reset/confirm` and `POST /v1/auth/invite/accept` answer `400` "The password must be 72 bytes or fewer." for longer passwords.
## Under the hood
- A new `docs/privacy.md` lists the personal data RenewCMS stores, how long it is kept, the cookies, what leaves the server and how to answer access or erasure requests. It is meant to help operators with their record of processing and privacy notice.
- Cross-origin protection uses the Go standard library, so no dependency was added.
- New feature specs and tests cover the hardening, in `features/security_privacy_hardening.feature`.
## Upgrading
- Replace the binary, or pull the new image. Back up the database first as usual.
- A new `revoked_sessions` table is created automatically at startup.
- The server now refuses to start when `JWT_SECRET` is shorter than 32 bytes or is still an example value. Generate one with `openssl rand -hex 32`. Changing it signs everyone out.
- Mail to an SMTP server on another host now requires encryption (STARTTLS, or port 465). If your relay offers neither, set the new `SMTP_ALLOW_INSECURE=true`. Credentials are still never sent unencrypted.
- Sessions created before v3.4.1-beta, which carry no expiry, are refused. Those users sign in once more.
## Known limitations
- Images uploaded before this release keep their metadata. Re-upload them if they may contain GPS positions.
- Verified users still cannot be deleted from the interface, and there is no self-service data export yet.
+40
View File
@@ -0,0 +1,40 @@
This release adds a Relation field to custom content types, so an entry can point at entries of another type.
## New
**Relation field**
- A content type can now have a Relation field that links to another content type. Three kinds are available: one to one (a target can be used by only one entry), one to many (one target per entry) and many to many (up to 50 targets per entry).
- Entries are linked through a searchable picker in the entry form. Chosen targets appear as chips labelled with the first field of the target entry, or "Entry N" when that field is empty.
- Entry lists and cards show the linked entries of each relation column.
- When a linked entry is deleted afterwards, the relation is shown as missing instead of breaking the page. A required relation with a missing target must be fixed before the entry can be saved.
- The schema builder previews the inverse side of a relation, and the schema review dialog explains the impact of adding or removing one.
## Improvements and fixes
- Deleting a content type that another type points at is refused, and the delete dialog tells you which types depend on it.
- Adding a required relation to a type that already has entries is refused with the usual schema incompatibility message.
- The right side panel of the article editor no longer overflows vertically on short screens.
- The web app is split into smaller chunks, so the first page load transfers less code.
## API
- Changed: every entry answer (`POST`, `PUT` and `GET` of one entry, and each item of a list) carries a top-level `relations` object keyed by relation field key. Each value holds `id`, `content_type` and `first_field` for an existing target, or `id` and `missing: true` for a deleted one. Many to many fields return an array.
- `GET /v1/content-type/{key}/entry` accepts `q`, a text search over the entries of the type, applied before pagination. `total` reflects the filtered count.
- `POST /v1/content-type` and `PUT /v1/content-type/{key}` accept `type: "relation"` fields with `relation: {"content_type", "kind"}`. A bad definition answers `422` `invalid_field`.
- Entry writes answer `422` `invalid_entry: <key>: required` or `invalid_entry: <key>: type`, and `409` `invalid_entry: <key>: unique` for a one to one target already taken.
- `DELETE /v1/content-type/{key}` answers `409` `content_type_referenced` while another type has a relation to it.
## Under the hood
- New feature specs and tests cover the backend and the web app, in `features/relation_field_type.feature` and `features/relation_field_spa.feature`.
- Vendor chunk splitting is configured in `web/vite/vendorChunks.ts` and covered by a spec.
## Upgrading
- Replace the binary, or pull the new image. Back up the database first as usual.
- No schema migration and no new environment variable. Existing content types and entries are unchanged.
## Known limitations
- Relation search filters entries in memory, so it is meant for types with a moderate number of entries.
- The inverse side of a relation is only shown in the builder preview. The target type's form, list and API do not list the entries pointing at it.
+2
View File
@@ -358,6 +358,8 @@ Feature: The back-office keeps an append-only activity log that owners can read
| target | +1+1 | '+1+1 |
| target | -2 | '-2 |
| actor | @SUM(A1) | '@SUM(A1) |
| target | \t=cmd | '\t=cmd |
| target | \r=cmd | '\r=cmd |
| target | Spring release | Spring release |
| target | a=b | a=b |
+6 -4
View File
@@ -53,12 +53,14 @@ Feature: Password and code hashing go through a domain Hasher interface
Then the result is the error user.ErrInvalidCredentials
And errors.Is(err, user.ErrInvalidCredentials) is true
Scenario: Authenticating an unknown username never calls the hasher
Scenario: Authenticating an unknown username spends the same hashing work as a wrong password
Given a user repository with no user "bob"
When AuthenticateUserUseCase.Authenticate is called with username "bob" and password
When AuthenticateUserUseCase.Authenticate is called twice with username "bob" and password
"hunter2example"
Then the result is the error user.ErrUserNotFound
And the hasher's Compare is never called
Then each result is the error user.ErrUserNotFound
And the hasher's Compare is called once per attempt, against a decoy hash of a random value
that no password can match, so response time does not reveal whether a username exists
And the decoy is hashed once and reused for every later unknown username
Scenario Outline: Login over HTTP still can't distinguish a bad password from an unknown username
Given a user repository containing a user "alice" with stored password "hashed:hunter2example"
+4 -3
View File
@@ -29,9 +29,10 @@ Package: `test` (black-box, alongside the existing `article_test.go` / `user_tes
assert the returned user matches and `err` is `nil`.
- `Authenticate("alice", "wrongpassword12")` with `fakeHasher.Compare` returning an error
→ assert `errors.Is(err, user.ErrInvalidCredentials)` is `true`.
- `Authenticate("bob", "hunter2example")` with no "bob" in the repo → assert
`errors.Is(err, user.ErrUserNotFound)` is `true` and `fakeHasher`'s `Compare` was never
invoked (record a `compareCalled` flag).
- `Authenticate("bob", "hunter2example")` twice with no "bob" in the repo → assert
`errors.Is(err, user.ErrUserNotFound)` is `true` each time, that `fakeHasher`'s `Compare`
ran once per attempt (against the decoy hash), and that `Hash` ran exactly once overall
(the decoy is computed lazily and reused).
## HTTP checks (real BcryptHasher, in-memory fake repo, mirroring api/router.go)
8. Call `validator.InitValidator()`.
@@ -16,7 +16,8 @@ Feature: Login and password-reset requests are rate-limited per client IP
(github.com/go-chi/chi/v5/middleware) runs first and rewrites r.RemoteAddr from
X-Forwarded-For/X-Real-IP when present (matching the nginx reverse proxy setup in
docs/deployment.md that already sets X-Forwarded-For), falling back to the raw RemoteAddr
otherwise; RateLimiter keys its counters on r.RemoteAddr
otherwise; RateLimiter keys its counters on the host portion of r.RemoteAddr (the source
port is ignored, so opening a new connection never yields a fresh counter)
And RateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler keeps
an in-memory, mutex-guarded fixed-window counter per (route, client IP) pair: the first
request from a client opens a window starting now; each subsequent request from the same
+3 -2
View File
@@ -26,8 +26,9 @@ Feature: Pending registration screen
you'll log in there first. Can't find it? Check your spam folder. If the link has expired,
cancel your registration and start again.") is rendered through ResultPanel's default slot
as a second paragraph below the description, per components.md's "Slot default for extras"
And "Log out" calls POST /v1/auth/logout, which per loginHandler.Logout always responds 200 and
clears the jwt cookie; on its response, PendingPage calls session.clear() and navigates to
And "Log out" calls POST /v1/auth/logout, which per loginHandler.Logout clears the jwt cookie
and responds 200 (500 only when the session cannot be revoked server-side; the cookie is
cleared either way); on its response, PendingPage calls session.clear() and navigates to
/login, disabling both actions while the call is in flight
And "Cancel registration" opens ConfirmDialog (built by this slice; components.md documents it
but no file exists yet) via useOverlay().create(ConfirmDialog).open(), with title "Cancel
+380
View File
@@ -0,0 +1,380 @@
Feature: Relation field in the SPA
As an owner or admin building a content type, and as a verified user filling an entry
I want a Relation field that links an entry to entries of another content type
So that structured content can point at other structured content
Background:
Given the SPA calls the API with relative URLs under /v1 and the "jwt" HttpOnly cookie
And API errors are plain text
And this slice changes only web/ (and web/visual); the backend of features/relation_field_type.feature is done
And the design is docs/design/screens/schema-later.md section 7 (3 states) and the board group "7 Relations" of "RenewCMS Screens Schema.dc.html" with SchemaFrame.dc.html; the frame markup wins over prose for layout
And the registry is lib/fieldTypes with lib/entryEditors, the single source for the builder, the list cells and the entry form; no screen branches on the type name "relation" where a registry property can say it
And the tags "Later" and "Relation columns: later" / "Relation field: later" drawn on the board are not rendered; the feature ships
And a relation value is an id (uint32 of at least 1) or null for one_to_one and one_to_many, and an array of ids ([] when empty, at most 50) for many_to_many
And a field loaded from the API has "relation": {"content_type","kind"} with kind "one_to_one", "one_to_many" or "many_to_many"
And every entry answer (POST 201, PUT 200, GET one, each list item) carries a top-level "relations" object keyed by relation field key: {"id","content_type","first_field":{"key","type","value"}} for an existing target, or {"id","missing":true}; an array of these for many_to_many; the label of a target is its first_field formatted by the same rule as the entry list, and "Entry N" when null
And the API refuses with 422 "invalid_entry: <key>: required", "invalid_entry: <key>: type", 409 "invalid_entry: <key>: unique", 409 "schema_incompatible" for a new required relation on a type with entries, 422 "invalid_field" for a bad definition, and 409 "content_type_referenced" when deleting a target type
And the picker searches GET /v1/content-type/{target}/entry?q=&page=&size= and reads total from the answer
And the inverse side is shown in the builder preview only; the target type's form, list and API are unchanged
And sentence case, buttons are verbs, every string goes through t() and has a French entry in i18n/messages.ts
And a copy tagged "(proposed copy)" is not in the design and is the coder's single point of change
And the sample data is the board's: Event with Venue (one_to_many) and Speakers (many_to_many); venues "Rue Verte hall" (Lyon · 120 seats), "Rue Verte yard" (Lyon · outdoor), "Rue du Port studio" (Nantes · 24 seats); speakers "Ines Duarte" and "Jonas Pereira"; entries 42 "Lime plaster workshop", 41 "Heat pumps: what the numbers say", 40 "Open day at the Rue Verte site"
Scenario Outline: Relation in the registry
Given the registry entry for "relation"
Then <property> is <value>
Examples:
| property | value |
| type name in the API | relation (the eighth entry, after media) |
| label | Relation |
| description | Links to another type's entries (proposed copy) |
| icon | lucide:link |
| control | RelationField |
| pairs with a neighbour | no, full width |
| empty value | null for one_to_one and one_to_many, [] for many_to_many |
| initial on create | the empty value; a relation has no default, so no "Default" tag ever shows |
| initial on edit | the stored id or ids |
| supports unique | no |
| supports limits | no |
| supports default | no |
| supports choices | no |
| counter | none |
| list cell | chips: one chip, two chips and "+N", or "–"; not sortable |
| sample in the API preview | null for a single kind, [] for many_to_many |
Scenario: A loaded relation field is not shown as Text
Given GET /v1/content-type/events returns a field {"key":"venue","label":"Venue","type":"relation","required":false,"unique":false,"default":null,"min":null,"max":null,"options":[],"relation":{"content_type":"venues","kind":"one_to_many"}}
Then the builder, the entry form and the list resolve it to the relation entry of the registry, never to the fallback type "text"
Scenario Outline: Client validation of a relation value
Given a <kind> relation "Venue" that is <required>
When the value is <value>
Then the rule is <rule>
Examples:
| kind | required | value | rule |
| single | required | null | required |
| single | optional | null | none |
| single | required | 3 | none |
| single | optional | 0 | type |
| single | optional | "3" | type |
| single | optional | [3] | type |
| many_to_many | required | [] | required |
| many_to_many | optional | [] | none |
| many_to_many | optional | [11, 12] | none |
| many_to_many | optional | [11, 11] | type |
| many_to_many | optional | 11 | type |
| many_to_many | optional | 51 ids | type |
Scenario Outline: Messages of a relation field
Given a relation field labelled "Venue" whose related type is named "Venues"
Then the message of the rule <rule> reads <message>
Examples:
| rule | message |
| required | Choose a Venue. (proposed copy) |
| type | Venue must point to an existing entry. (proposed copy) |
| unique | That Venue is already used by another entry. (proposed copy) |
| min | not produced for relation |
| max | not produced for relation |
| options | not produced for relation |
Scenario: The type gallery gains Relation
Given the inspector of a field that can still change type
Then the tile "Relation" with the link icon is the last tile, selectable like the others
And on a saved field the other tiles are dimmed to 40 % and not selectable, as for any locked type
Scenario: Adding a relation field
Given Events and a second content type Venues exist
When the user activates "Add field", types the label "Venue" and chooses the tile "Relation"
Then the new row shows the badge "New", the key "venue" (Auto, editable) and the chip reads "Unsaved changes"
And the inspector reads "Field: Venue" and shows, after the Type tiles and in this order, the select "Related content type" with the help "Choose the content type this field points to." and the group "How are they connected?"
And no related type is chosen yet and "Save" cannot send a relation without one (proposed copy for the message: "Choose a related content type.")
Scenario: The related content type select
Given the user is editing Events
Then the select lists the other content types by name, and Events itself, each with its key as a hint (proposed)
And choosing Venues sets the sentences of the preview to "Venue" and "Events"
Scenario: Three shape cards (design "Relation in the builder")
Given the related type is Venues, the owner Events
Then three cards are shown in a row (one column on mobile), each with a diagram of squares and a line, a title and a sentence
| card | sentence |
| One to one | Each Event has one Venue, and each Venue has one Event. |
| One to many | Each Event has one Venue. A Venue has many Events. |
| Many to many | Each Event has many Venues, and each Venue has many Events. |
And the selected card has the primary border and the sand fill with --rc-on-sand text, the others the accented border
And the cards are a radio group, one is selected at a time, and a new relation starts with "One to many" selected (proposed default)
Scenario: The preview of both sides
Given "One to many" is selected with Event and Venue
Then a block with the elevated background reads "Each Event has [one] Venue" and "Each Venue has [many] Events" followed by the muted text `· shown on Venue as the field "Events"`
And the bracketed words are drawn as selects showing the chosen kind and follow the selected card
And below it two cards "Event" (Venue: chip "Rue Verte hall") and "Venue" (Events: chips "Open day", "Lime plaster workshop" and "+1") illustrate both sides, side by side on desktop and stacked on mobile
And the inverse field is not stored anywhere and nothing about it is sent
Scenario Outline: The preview follows the kind
Given the owner Event and the related type Venue
When the user selects <card>
Then the sentences read <owner side> and <inverse side>
Examples:
| card | owner side | inverse side |
| One to one | Each Event has one Venue | Each Venue has one Event |
| One to many | Each Event has one Venue | Each Venue has many Events |
| Many to many | Each Event has many Venues| Each Venue has many Events |
Scenario: Plural and names come from the types
Given the owner "Events" and the related type "Venues"
Then "Event"/"Events" and "Venue"/"Venues" in the sentences are the singular and plural forms drawn from the type names (proposed rule: singular is the name without a final "s"; the field label is used for the singular of the related side)
Scenario: The PUT and POST body of a relation field
Given a relation "Venue" to "venues", kind "One to many", Required off
When the type is saved
Then the field is sent as {"key":"venue","label":"Venue","type":"relation","required":false,"unique":false,"default":null,"min":null,"max":null,"options":[],"relation":{"content_type":"venues","kind":"one_to_many"}}
And every other field is sent with "relation": null
And a round trip of the loaded field to the payload produces no change
Scenario: The inspector of a saved relation (locked) (state not drawn; proposed copy)
Given Events with a saved relation "Venue" as its field
When the user selects its row
Then the Label input and the Required switch are editable
And the Key is locked with "Used in the API, so it can't change."
And the legend of the tiles reads "Type" with a lock and "Can't change after saving", Relation is selected and the other tiles are dimmed to 40 %
And "Related content type" and the three cards are shown disabled with the selected card kept, under the note "The related type and the connection can't change after saving. (proposed copy)"
Scenario: A required relation added to a type with entries is refused first
Given Events with 12 entries and a new relation "Venue" that is required
When the user activates Save
Then no PUT is sent and the dialog "Review changes to Events" opens
And the row "Add Venue" has the warning badge "Can't be applied" and the text "Relation, required. A relation can't have a default, so the 12 existing entries would have no Venue. Make Venue optional to add it. (proposed copy)"
And "Back to editing" is enabled and "Apply changes" is disabled, so pressing it sends nothing
And an optional relation shows the clean row "Relation, optional. 12 entries are unaffected. (proposed copy)" with "Apply changes" enabled
Scenario: The server answers on the builder
Given the user applies a type with a relation
When the server answers <status> "<body>"
Then <result>
Examples:
| status | body | result |
| 409 | schema_incompatible | the existing "Nothing was changed" refusal flow runs unchanged |
| 422 | invalid_field | the existing invalid-field failure of the builder is shown, the edits stay |
Scenario: Deleting a type that another type points at (proposed copy)
Given Venues is the target of the relation on Events and Venues has no entries
When the user confirms the delete of Venues
Then the API answers 409 "content_type_referenced"
And the existing delete failure surface shows the title "You can't delete Venues yet" and the text "Events has a field that points to Venues. Remove that field first. (proposed copy)"
And the type is still listed
Scenario: The builder is for owners and admins
Given a verified user who is neither owner nor admin
Then the builder keeps its existing "You don't have access to Schema" state and no relation control is rendered
Scenario: Where the controls sit
Given Events with the fields Title, Venue and Speakers
Then the controls follow the schema order, full width in the 760 px column, each with its label ("Venue", "Speakers") in weight 500
Scenario: Single relation, empty, focused search (design "Venue")
Given /content/events/42 whose venue is null
When the user focuses the "Venue" control and types "rue"
Then the input shows the search icon, the typed text and "Search to add"
And a popover list under the input shows rows of at least 40 px (48 on mobile) with the title in weight 500 and a second muted 12/18 line, each row a button
| title | second line |
| Rue Verte hall | Lyon · 120 seats |
| Rue Verte yard | Lyon · outdoor |
| Rue du Port studio | Nantes · 24 seats|
And the first row has the elevated background
And the title is the first field of the related type and the second line is the next fields formatted as the entry list does, joined by " · " (up to 2 fields, proposed rule)
Scenario: Picking a single target
When the user activates the row "Rue Verte hall"
Then the list closes, the control shows one chip "Rue Verte hall" with an x, the value becomes 3, and the chip of the page reads "Unsaved changes"
And the live region says "Venue set to Rue Verte hall. (proposed copy)" and the focus stays in the control
And the chip's x removes it, sets the value to null, and says "Venue cleared. (proposed copy)"
Scenario: Many relation (design "Speakers")
Given Events with speakers [11, 12]
Then the control is a bordered box (1 px accented, min height 40, 44 on mobile) holding the chips "Ines Duarte" and "Jonas Pereira", each 24 px high (32 on mobile) with an x, then the muted text "Search to add"
And the help line under it reads "An event can have many speakers."
And a chip is a sand chip with --rc-on-sand text, weight 500
Scenario: Adding and removing in a many relation
When the user types "ines" in the control and activates the row "Ines Duarte"
Then the chip is added at the end in the chosen order and the row no longer appears in later results
When the user activates the x of a chip, or presses Backspace in the empty input
Then that chip is removed and the live region says "Ines Duarte removed from Speakers. (proposed copy)"
And a 51st target cannot be added: the search shows "You can add up to 50. (proposed copy)" and no row is selectable
Scenario: One to one targets already used (proposed copy)
Given a one_to_one relation
When the server answers 409 "invalid_entry: room: unique"
Then "That Venue is already used by another entry." shows under the control with a circle-alert icon, the error border applies, the chip reads "Not saved" and the focus moves to the control
Scenario: The search request
When the user types "rue"
Then the input is trimmed and debounced, and only GET /v1/content-type/venues/entry?q=rue&page=1&size=<page size> is sent
And the response replaces the list and a stale answer never overwrites a newer one
And with an empty input the first page of the type is listed without q (proposed)
Scenario: Picker loading (not drawn; proposed copy)
Given the search has not answered
Then after 150 ms the list shows three skeleton rows of the same height, with "Searching… (proposed copy)" announced politely, and no row is selectable
Scenario: No match (not drawn; proposed copy)
Given the search answers total 0 for "zzz"
Then the list shows `No Venues match "zzz"` (proposed copy) and the action "Clear search" that empties the input
And when the related type has no entries at all the list reads "No Venues yet. Add one in Content, then come back. (proposed copy)"
Scenario Outline: Search failures (not drawn; proposed copy)
Given the search answers <outcome>
Then <result>
Examples:
| outcome | result |
| 500 | the list shows "Couldn't load Venues" / "Check your connection and try again." with "Try again" (proposed copy) |
| a network error | the same |
| 429 | the existing rate-limit countdown alert |
| 401 | the global redirect, no local copy |
| 404 | "Couldn't load Venues" as for 500, since the related type may have been deleted (proposed copy) |
Scenario: Load more in the list
Given the search has more results than one page
When the user scrolls to the end of the list or activates "Load more" (proposed copy)
Then the next page is appended and the focus stays on the active row
Scenario: Keyboard
Given the control is focused and the list is open
Then ArrowDown and ArrowUp move the active row, Enter picks it, Esc closes the list and keeps the typed text, Tab leaves the control
And in a many relation Backspace on an empty input removes the last chip and chips are reachable with ArrowLeft
Scenario: A missing target (not drawn; proposed copy)
Given relations.venue is {"id":3,"missing":true}
Then the control shows a dashed chip "Entry no longer exists (proposed copy)" with an x and the help "You can still save. The entry keeps the link until you change it. (proposed copy)" when the field is optional
And when the field is required the help reads "Venue is required. Choose another to save. (proposed copy)", and saving with the missing target sends no request, shows the required message and focuses the control
And saving an optional field with the missing target sends the same id
Scenario: Required and empty
Given a required relation with no target
When the user activates "Save"
Then no request is sent, the error "Choose a Venue." replaces the help line, the border is --ui-error, the chip reads "Not saved" and the focus goes to the control
And only the first field in schema order with an error takes the focus
When the user picks a target
Then the error goes and the chip reads "Unsaved changes"
Scenario: Saving
Given the PUT has not answered
Then the form is dimmed to 70 %, the chips and the input are not operable and the list cannot open
And Save reads "Saving…"
Scenario Outline: What the control shows and sends
Given the loaded value <loaded> with relations <relations>
When the user <action> and activates "Save"
Then the control shows <shown>
And the data holds <sent>
Examples:
| loaded | relations | action | shown | sent |
| null | none | saves with nothing changed | no chip | "venue": null |
| 3 | existing venue 3 | saves with nothing changed | the chip "Rue Verte hall"| "venue": 3 |
| 3 | missing venue 3 | saves with nothing changed | the missing chip | "venue": 3 |
| 3 | existing venue 3 | picks "Rue Verte yard" | the chip "Rue Verte yard"| "venue": 4 |
| 3 | existing venue 3 | activates the x | no chip | "venue": null |
| [11] | speaker 11 | adds Jonas Pereira | two chips | "speakers": [11, 12] |
| [11,12] | speakers 11, 12 | removes both | no chip | "speakers": [] |
Scenario: Create payload with relations
Given /content/events/new with Title "Open day", Venue left empty and Speakers left empty
When the user activates "Save"
Then POST /v1/content-type/events/entry is sent once with "venue": null and "speakers": [] among the other keys, never absent
And with Rue Verte hall picked it holds "venue": 3, a JSON number
Scenario: Right after a pick the control uses the picked row
Given the user picks "Rue Verte yard" from the results
Then before any save the chip shows its title from the result and no extra request is made
When the PUT answers 200 with relations.venue
Then the control is rebuilt from the answered relations object
Scenario: Dirty tracking of a relation value
Given an entry whose venue is 3
When the user picks 4 then picks 3 again
Then the chip reads "Saved", "Save" is disabled and the leave guard does not prompt
And for a many relation the same ids in another order count as changed
Scenario Outline: Server answers for a relation field
Given the client found no error and the PUT or POST is sent
When the server answers <status> "<body>"
Then <result>
Examples:
| status | body | result |
| 422 | invalid_entry: venue: required | "Choose a Venue." under the control, error border, chip "Not saved", focus on the control |
| 422 | invalid_entry: venue: type | "Venue must point to an existing entry." (proposed copy) under the control, chip "Not saved" |
| 409 | invalid_entry: venue: unique | the one to one copy above |
| 422 | invalid_entry: venue: unknown_field | the read-only reload alert of features/content_entry_editor.feature, same as for media |
Scenario: Layout of the generated form with a relation
Given a boolean, a number or a date field before the relation
Then the relation never pairs and is full width at every viewport
Scenario: Mobile form
Given a 390 px viewport
Then chips are 32 px high, rows are 48 px high, the input is 44 px high, and the list opens as a bottom sheet (proposed) or under the input as drawn
Scenario Outline: The relation cell (design "Relation in the list")
Given the entry list of Events with the columns Title, Starts on, Venue and Speakers
And the item's relations <relations>
Then the cell shows <cell>
Examples:
| relations | cell |
| venue 3 "Rue Verte hall" | one chip "Rue Verte hall" (20 px high, 6 px padding) |
| speakers 11, 12 | two chips "Ines Duarte" and "Jonas Pereira" |
| speakers 11, 12 and two more | two chips and the muted 12 px text "+2" |
| no venue | the muted "–" |
| an empty speakers list | the muted "–" |
| a missing venue | a dashed chip "Entry no longer exists" (proposed copy) |
Scenario: Columns of the board (desktop)
Given the items 42, 41 and 40
Then the grid reads ID, Title, Starts on, Venue, Speakers with the columns 48 px, 1.4fr, 110 px, 200 px, 200 px
And row 42 shows "Rue Verte hall" and "Ines Duarte", "Jonas Pereira", "+2"; row 41 shows "–" and "Jonas Pereira"; row 40 shows "Rue Verte yard" and "–"
And rows stay 48 px high and the header is 36 px high on the elevated background
And the note "One chip for a single relation. Up to two chips and a "+N" for many. Empty shows a dash." is a design annotation and is not rendered
Scenario: The mobile card
Given a 390 px viewport and Venue and Speakers among the columns
Then each card lists the title, then a line "Venue" (12 px muted) followed by its chips, then a line "Speakers" followed by its chips (wrapping), and "–" for none
And the card keeps its minimum height
Scenario: Columns menu
When the user opens "Columns"
Then "Venue" and "Speakers" are listed with the link icon, their keys, and a checkbox like other fields
And the cap of 6, Reset and the stored choice work as for any field
And a relation column is not sortable and has no sort icon and no aria-sort
Scenario: Cell data comes from the item
Then the chips are read from each item's own "relations" object with no extra request
And the plain text of a relation cell (card title, page title) reads the labels joined by ", " or "–", never the numeric id
Scenario: Relation as the first column
Given a type whose first field is a relation
Then the first column still links to the editor and the chips are the link content
And the page title and breadcrumb of such an entry read "Entry 42"
Scenario: Accessibility of the control
Then the control is a combobox with role="combobox", aria-expanded, aria-controls and aria-activedescendant on the input, labelled by the field label
And the list is role="listbox" with role="option" rows
And chips are a group labelled by the field label, each x is a button named "Remove Ines Duarte from Speakers"
And required adds aria-required, the error text has an id and aria-invalid and aria-describedby point at it then at the help
And one polite live region announces added, removed and cleared values and the result count ("3 results" / "No results", proposed copy)
And mobile targets are 44 px
Scenario: Accessibility of the builder
Then the shape cards are a radio group with a visible selected state that is not only a colour, the disabled locked controls are announced as disabled, and "Can't be applied" is a text badge
+59
View File
@@ -0,0 +1,59 @@
# QA procedure: relation field in the SPA
Two parts. Part 1 is automated (Vitest component tests, `task test:web`). Part 2 is the visual gate (`task visual:check`). Part 3 is manual. The Go side is in `relation_field_type.qa.md`. Until `task test:web` exists, SPA tests are reported NOT RUN, never passing.
## Part 1: automated (Vitest + Vue Test Utils)
Mock `/v1` per test with the shapes below; no live server. A field is `{"key","label","type":"relation","required","unique":false,"default":null,"min":null,"max":null,"options":[],"relation":{"content_type","kind"}}` and every other field carries `"relation": null`.
Registry
1. `lib/fieldTypes`: the relation entry has the properties of the registry outline (label, icon `lucide:link`, control `RelationField`, no unique/limits/default/choices, empty value per kind, sample null or []). An unknown type still falls back to text; a relation never does.
2. Client validation table (single and many, required/optional, 0, "3", [3], duplicates, 51 ids) and the message map including the "not produced" rules.
Builder (one test per state)
3. Type gallery: Relation is the last tile; locked on a saved field with the other tiles dimmed.
4. New relation: the related-type select lists the other types (mock `GET /v1/content-type`), the help text, the three cards with the exact sentences, default "One to many" (proposed); choosing a type updates the sentences.
5. Preview outline: the kind change updates both sentences and the line `· shown on Venue as the field "Events"`; the example cards "Event" and "Venue" show the board's chips (Rue Verte hall, Open day, Lime plaster workshop, +1). Nothing about the inverse is in any request.
6. Save body: relation sent as `{"content_type","kind"}`, others `"relation": null`; a loaded field round-trips unchanged; no related type chosen blocks Save with the proposed message.
7. Saved relation: key, tiles, related type and cards are disabled; Label and Required editable; the proposed lock note shows.
8. Review: required relation + entries > 0 shows the flagged row and disabled "Apply changes" with no PUT; optional shows the clean row; required with 0 entries sends at once; making an optional relation required shows "Entries may not fit" with Apply enabled.
9. Server answers: 409 `schema_incompatible` and 422 `invalid_field` map to the existing flows.
10. Delete refused: 409 `content_type_referenced` shows the proposed copy and the type stays listed.
11. A non-owner/admin sees no builder.
Entry form (one test per state)
12. Single control: type "rue" -> only `GET /v1/content-type/venues/entry?q=rue&page=1&size=...` is sent (debounced, trimmed, stale answers ignored); rows show title and second line; pick sets the value to 3, one chip, "Unsaved changes", live message.
13. Many control: chips for [11, 12], "Search to add", the help line; add keeps order; x and Backspace remove; picked ids are not offered again; the 51st is blocked with the proposed message.
14. States: loading (skeleton after 150 ms), no match with "Clear search", empty type, 500, network error, 429 countdown, 401 redirect, 404 (all with the proposed copy where tagged), "Try again" re-sends only the search.
15. Missing target (optional and required), saving, keyboard (arrows, Enter, Esc, Tab, Backspace), required-empty error and focus order (first invalid field only).
16. Outline "What the control shows and sends" (all 7 rows) and the create payload with `venue: null`, `speakers: []` and with a picked number.
17. Dirty tracking: back to the original value gives "Saved"; reordered many ids count as changed.
18. Server answers table: `required`, `type`, `unique`, `unknown_field` (reload alert), each with the right copy, chip and focus.
19. Layout: a relation after boolean/number/date never pairs.
List
20. Cell outline (one chip, two chips, two chips + "+2", dash for null, dash for [], missing chip) from each item's `relations` with no extra request; relation columns are not sortable (no sort icon, no `aria-sort`).
21. Desktop grid for items 42/41/40 matches the board texts; the mobile card shows the labelled lines; the Columns menu lists Venue and Speakers with the link icon and the cap of 6.
22. Plain text of a relation cell never shows the numeric id; a first-field relation keeps the link and the "Entry N" title.
Accessibility
23. Combobox/listbox roles and attributes, chip group label, "Remove X from Y" button names, required/invalid/describedby wiring, the live region texts.
## Part 2: visual (`task visual:check -- <screen>`)
Hard gate: each capture within 10 % at 1440x900 and 390x844, light and dark. Add setups in `web/visual/states.ts` with the board's own sample data; no clock-sensitive state exists in this group.
| Screen | State | Board sample data the mock must reuse |
| --- | --- | --- |
| schema | Relation in the builder | Events with Venue (label), key `venue`, related type Venue, "One to many" selected, preview sentences, example cards with chips "Rue Verte hall", "Open day", "Lime plaster workshop", "+1" |
| entry (content/events/42) | Relation in the entry form | Title "Lime plaster workshop" (21 / 80); Venue search "rue" with rows "Rue Verte hall / Lyon · 120 seats", "Rue Verte yard / Lyon · outdoor", "Rue du Port studio / Nantes · 24 seats"; Speakers chips "Ines Duarte", "Jonas Pereira"; help "An event can have many speakers." |
| content (events) | Relation in the list | Items 42 "Lime plaster workshop" 21 Nov 2026 (Rue Verte hall; Ines Duarte, Jonas Pereira, +2), 41 "Heat pumps: what the numbers say" 3 Nov 2026 (–; Jonas Pereira), 40 "Open day at the Rue Verte site" 12 Nov 2026 (Rue Verte yard; –) |
Notes:
- The board's states are drawn with a "Later" badge; the SPA does not render it. Expect a pixel difference at the badge. If the 10 % gate fails because of it, the user decides on a documented `exception` (`approvedBy`); the QA never adds one.
- The board's builder shows a four-tile gallery plus Relation; the SPA shows the full gallery. The mismatch is reported to the user, not worked around.
- States tagged "(proposed copy)" (loading, no match, error, missing chip, locked inspector, review refusal, delete refusal) are not on the board and get Vitest tests only, no visual capture.
## Part 3: manual (screenshots can't cover)
- Keyboard and focus order across the combobox, chips and list; screen reader announcements of the live region.
- Hover and motion of the popover and of the chips' x; the mobile bottom-sheet versus under-input choice.
- Real SQLite end to end with the built binary: build the type pair, pick, save, reload, delete a target and see the missing chip.
- `bunx playwright install chromium` the first time on a machine.
+235
View File
@@ -0,0 +1,235 @@
Feature: Relation field type for custom content types
As a site owner or admin
I want a content type field that links an entry to entries of another content type
So that structured content can refer to other structured content
Background:
Given internal/domain/contenttype adds the field type "relation" next to text, richtext, number, boolean, date, select and media
And a relation field carries "relation": {"content_type": <key of an existing type>, "kind": "one_to_one" | "one_to_many" | "many_to_many"}
And "one_to_many" reads from the owner: each owner entry has one target, a target has many owner entries
And every field response gains the key "relation", null for the other types, so a field has exactly ten keys
And a relation field accepts only label, key, required and relation; unique, default, min, max and options are refused with ErrInvalidField
And the target type must exist, else ErrInvalidField; a self relation is allowed for one_to_many and many_to_many and refused for one_to_one
And the connection is stored once, on the owner field; the inverse side is derived, never stored, never added to the target schema, and the target type's schema, entry responses, list and routes are unchanged
And the owner value is an id (uint32, at least 1) or null for one_to_one and one_to_many, and an array of ids ([] when empty, at most 50, no duplicates, order kept) for many_to_many
And entry.Validate stays pure; the existence of target entries is checked by CreateEntry and UpdateEntry through entry.Repository
And a changed value means an id not equal to the one in the stored document for that key; on POST every id is changed; on PUT an unchanged id is not checked for existence unless the field is required
And a changed id must be an existing entry of the target type, else 422 "invalid_entry: <key>: type"
And a required relation is never saved empty: null, absent or [] give 422 "invalid_entry: <key>: required", and so does an unchanged id whose entry is gone
And a one_to_one id already held by another entry of the owner type is refused with 409 "invalid_entry: <key>: unique" (entry.ErrUniqueViolation)
And rules are checked in field order for Validate, then target existence in field order, then unique, and the body names one field per answer
And an entry response keeps its eight keys and adds "relations", always present ({} when no relation is set)
And "relations"[key] is {"id","content_type","first_field":{"key","type","value"}} for an existing target, or {"id","missing":true} when it is gone; for many_to_many it is an array of these in stored order
And first_field is the first field of the target type in schema order with its raw stored value, never formatted by the server
And the same "relations" object is on POST 201, PUT 200, GET one and every item of the list
And GET /v1/content-type/{key}/entry accepts ?q=, a trimmed case-insensitive substring of the display text of the first field, filtered in Go on the unpaged list before pagination, so total counts the matches; an empty q means no filter
And deleting an entry that other entries reference is allowed and leaves them untouched
And DELETE /v1/content-type/{key} on a type that is the target of a relation field is refused with 409 "content_type_referenced" (contenttype.ErrReferenced, new apperr string), even with no entries
And a saved relation field's type, target and kind never change: a change is refused with 422 "invalid_field"
And a new required relation on a type with entries gives 409 "schema_incompatible"
And no repository method is added; the code works on SQLite, MySQL/MariaDB and PostgreSQL with no JSON operators
And the key-set assertions of the earlier custom content type, entry and media features and tests are amended (field 9 to 10 keys, entry 8 to 9 keys)
And types: "venue" has title (text, required) and city (text); "event" has title (text, required) then venue (relation to venue, one_to_many, label "Venue") then speakers (relation to speaker, many_to_many); "speaker" has name (text, required)
And entries: venue 3 "Rue Verte hall", venue 4 "Rue Verte yard"; speaker 11 "Ines Duarte", speaker 12 "Jonas Pereira"
And users: admin id 2 (verified) can write types; any verified user can write entries
Scenario: Creating a content type with a relation field
Given a valid jwt cookie for admin 2 and the type "venue"
When the client sends POST /v1/content-type with fields title (text, required) and
{"key":"venue","label":"Venue","type":"relation","required":false,"unique":false,"default":null,"min":null,"max":null,"options":[],"relation":{"content_type":"venue","kind":"one_to_many"}}
Then the response status is 201
And the venue field comes back with that relation object and the other fields have "relation": null
Scenario Outline: A relation field with a bad definition is refused
Given a valid jwt cookie for admin 2
When the client sends POST /v1/content-type with a relation field that has <defect>
Then the response status is 422
And the response body is "invalid_field"
And no content type is stored
Examples:
| defect |
| relation null |
| content_type "nope" (unknown type) |
| content_type "" or missing |
| kind "many_to_one" |
| kind missing |
| a relation object on a text field |
| unique true |
| default 3 |
| min 1 |
| max 5 |
| options ["a"] |
| a one_to_one self relation |
Scenario: The shape of a saved relation never changes
Given the type "event" has a saved relation "venue" (one_to_many to venue) and at least one entry
When admin 2 sends PUT /v1/content-type/event changing its kind to many_to_many
Then the response is 422 invalid_field and the schema is unchanged
When admin 2 sends PUT changing its target to speaker
Then the response is 422 invalid_field and the schema is unchanged
When admin 2 sends PUT turning it into a text field
Then the response is 422 invalid_field and the schema is unchanged
Scenario Outline: Adding a relation field to a type that has entries
Given the type "event" has 12 entries
When admin 2 sends PUT adding a relation field "venue" that is <required>
Then the response status is <status> and the body is <message>
Examples:
| required | status | message |
| required | 409 | schema_incompatible |
| optional | 200 | |
Scenario: Deleting a type that another type points at
Given the type "venue" has no entries and "event" has a relation to it
When admin 2 sends DELETE /v1/content-type/venue
Then the response status is 409 with "content_type_referenced" and venue still exists
When the relation field is removed from "event" and DELETE is sent again
Then the response status is 204
Scenario: Creating an entry with a single target
When a verified user sends POST /v1/content-type/event/entry with {"data":{"title":"Lime plaster workshop","venue":3}}
Then the response status is 201 and data.venue is 3
And relations is {"venue":{"id":3,"content_type":"venue","first_field":{"key":"title","type":"text","value":"Rue Verte hall"}}}
Scenario: Creating an entry with many targets
When the client sends POST with {"data":{"title":"A","speakers":[12,11]}}
Then data.speakers is [12,11] and relations.speakers lists speaker 12 then speaker 11
Scenario Outline: Empty optional relations
When the client sends POST with <data>
Then the response status is 201, <stored>, and relations is {}
Examples:
| data | stored |
| {"data":{"title":"A","venue":null}} | data.venue is null |
| {"data":{"title":"A"}} | data.venue is absent |
| {"data":{"title":"A","speakers":[]}} | data.speakers is [] |
Scenario Outline: Values that break a relation are refused and store nothing
Given "venue" is <required> and "speakers" is many_to_many
When the client sends POST with the data <data>
Then the response status is <status> and the body is <message>
And no entry exists
Examples:
| required | data | status | message |
| required | {"title":"A"} | 422 | invalid_entry: venue: required |
| required | {"title":"A","venue":null} | 422 | invalid_entry: venue: required |
| optional | {"title":"A","venue":999} | 422 | invalid_entry: venue: type |
| optional | {"title":"A","venue":"3"} | 422 | invalid_entry: venue: type |
| optional | {"title":"A","venue":0} | 422 | invalid_entry: venue: type |
| optional | {"title":"A","venue":3.5} | 422 | invalid_entry: venue: type |
| optional | {"title":"A","venue":[3]} | 422 | invalid_entry: venue: type |
| optional | {"title":"A","speakers":11} | 422 | invalid_entry: speakers: type |
| optional | {"title":"A","speakers":[11,11]} | 422 | invalid_entry: speakers: type |
| optional | {"title":"A","speakers":[11,999]}| 422 | invalid_entry: speakers: type |
| optional | {"title":"A","speakers":[3]} | 422 | invalid_entry: speakers: type |
| optional | speakers with 51 ids | 422 | invalid_entry: speakers: type |
Scenario: A required many relation is never empty
Given "speakers" is required
When the client sends POST with speakers [] or with the key absent
Then the response is 422 invalid_entry: speakers: required
Scenario: One to one keeps targets exclusive
Given the type "talk" has a one_to_one relation "room" to "venue" and a talk already holds venue 3
When the client sends POST with room 3
Then the response is 409 "invalid_entry: room: unique" and nothing is stored
When the holder is updated with room 3 unchanged
Then the response is 200
When the holder is deleted and the POST is sent again
Then the response is 201
Scenario: Replacing and clearing targets
Given the entry 5 holds venue 3 and speakers [11]
When a verified user sends PUT with venue 4 and speakers [11,12]
Then data and relations reflect venue 4 and speakers 11, 12
When the user sends PUT with venue null and speakers []
Then relations is {}
Scenario: A target deleted after the entry was saved
Given the entry 5 holds venue 3 and speakers [11,12]
When entry 3 of venue and entry 12 of speaker are deleted (both answer 204)
And the client sends GET /v1/content-type/event/entry/5
Then data is unchanged
And relations.venue is {"id":3,"missing":true}
And relations.speakers is [{"id":11,"content_type":"speaker","first_field":{"key":"name","type":"text","value":"Ines Duarte"}},{"id":12,"missing":true}]
And the entry 5 was not modified (updated_at and updated_by unchanged)
Scenario Outline: Unchanged and changed ids when the target is gone
Given the entry 5 holds venue 3 and venue 3 was deleted
When the user sends PUT with venue <value>
Then the response status is <status> and the body is <message>
Examples:
| value | status | message |
| 3 | 200 | |
| 999 | 422 | invalid_entry: venue: type |
| 4 | 200 | |
| null | 200 | |
Scenario Outline: A required relation with a missing target can't be saved as is
Given venue is required, entry 5 holds venue 3 and venue 3 was deleted
When the user sends PUT with venue <value>
Then the status is <status>, the body is <message> and the stored entry is <outcome>
Examples:
| value | status | message | outcome |
| 3 | 422 | invalid_entry: venue: required | unchanged |
| null | 422 | invalid_entry: venue: required | unchanged |
| 4 | 200 | | updated |
Scenario: A create always treats the id as changed
Given venue 3 was deleted
When the client sends POST with venue 3
Then the response is 422 invalid_entry: venue: type
Scenario: Two broken fields name only one
Given a body with a required text left empty and a relation to a missing target
Then the answer names one field: Validate rules first in field order, then existence, then unique
Scenario: Entry list responses carry relations per item
Given 3 events: one with a venue and two speakers, one with a deleted venue, one with nothing
When the client sends GET /v1/content-type/event/entry
Then each item keeps its eight keys and adds "relations"
And items, total, page and size are unchanged
And a type without a relation field returns "relations": {} and makes no target repository call
Scenario Outline: Searching the entries of a type
Given the type "venue" has entries titled "Rue Verte hall", "Rue Verte yard", "Rue du Port studio"
When the client sends GET /v1/content-type/venue/entry?q=<q>
Then total is <total>
Examples:
| q | total |
| rue | 3 |
| RUE VERTE | 2 |
| yard | 1 |
| nothing | 0 |
| | 3 |
Scenario: Search is paginated after filtering
Given 30 venues match "rue"
When the client sends GET /v1/content-type/venue/entry?q=rue&page=2&size=10
Then total is 30, page is 2, size is 10 and items holds the second ten matches in the requested sort
Scenario: Deleting an entry that is referenced
Given 4 events hold venue 3
When a verified user sends DELETE /v1/content-type/venue/entry/3
Then the response status is 204 and the 4 events are not modified
Scenario: The target type, entries and API are unchanged
Given "event" has a one_to_many relation to "venue"
When the client sends GET /v1/content-type/venue and GET /v1/content-type/venue/entry/3
Then neither response carries the inverse field "Events" and the venue field list is the one saved
Scenario: Use case level
Given fake contenttype and entry repositories
Then contenttype.ValidateFields returns ErrInvalidField for each refused definition
And entry.Validate returns errors.Is(err, entry.ErrInvalidEntry) with Key and Rule "required" or "type" for the shapes above
And CreateEntry and UpdateEntry return the same for a missing target and perform no Create or Update
And UpdateContentType returns contenttype.ErrSchemaIncompatible for a new required relation and ErrInvalidField for a changed shape
And DeleteContentType returns contenttype.ErrReferenced for a target type
+48
View File
@@ -0,0 +1,48 @@
# QA procedure: relation field type (API only)
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP, no SPA.
## Setup shared by every case
1. Call `validator.InitValidator()` once per test.
2. Reuse `mockContentTypeRepo` and `mockEntryRepo` of the custom content type and media pairs. Fake names stay unique across the package. The entry fake needs `createCalls`, `updateCalls`, a `getCalls` counter per content type and a `deleteEntry(contentTypeID, id)` helper. No repository method is added.
3. Build `NewHandler(...)` for the content type and entry handlers over use cases wired to the fakes. Mount them on a fresh `chi.NewRouter()` mirroring api/router.go under `/v1/content-type`, with `jwtauth.Verifier`, `jwtauth.Authenticator` and a test `jwtauth.New("HS256", []byte("test-secret"), nil)`, and `RequireOwnerOrAdmin` on the type write routes. Prefer `api.InitBackendRoutes` with a partial `*api.UseCases` if it works.
4. Seed admin 2 and a plain verified user. Seed the types venue, speaker, event and talk, and the entries venue 3, 4 and speaker 11, 12 from the feature.
5. Inject a fake clock so `updated_at` > `created_at`. Decode bodies into `map[string]any` and compare numbers as `float64`.
## Schema
6. POST a valid relation field -> 201; `relation` is the object sent; every other field has `"relation": null`; every field has exactly ten keys.
7. Refusal table (null relation, unknown target, empty target, bad kind, missing kind, relation on a text field, unique/default/min/max/options, one_to_one self) -> 422 `invalid_field`, repo empty.
8. Locking: kind, target and type changes on a saved relation -> 422 `invalid_field`, schema byte-identical. A type with no entries keeps today's free type change for non-saved shapes.
9. Type with 12 entries: add required relation -> 409 `schema_incompatible`, unchanged; add optional -> 200 and the old entries read with `relations` `{}`.
10. DELETE a target type with no entries -> 409 `content_type_referenced` (plain text); after removing the relation field -> 204. A type with entries is still 409 `content_type_has_entries`.
## Entry writes
11. Single and many targets -> 201, `data` as sent (array order kept), `relations` as in the feature. The target object has exactly `id`, `content_type`, `first_field`; `first_field` has exactly `key`, `type`, `value`, with the raw stored value.
12. Empty optional (null, absent, []) -> 201, `relations` is `{}` (assert JSON `{}`, not null). A required many field with [] or absent -> 422 required.
13. Refusal table -> exact status and body, `createCalls == 0`. The 51-id case is built in the test.
14. A venue id in a speaker field -> 422 type.
15. One-to-one exclusivity: 409 `invalid_entry: room: unique`, nothing stored; PUT of the holder unchanged -> 200; after deleting the holder, POST -> 201.
16. PUT replace/clear -> 200 with updated `relations`.
17. Dangling targets: `deleteEntry` venue 3 and speaker 12 -> GET entry 5 shows `{"id":3,"missing":true}` and the mixed array; `updated_at`/`updated_by` unchanged. PUT unchanged id -> 200; 999 -> 422 type; other id or null -> 200.
18. Required relation with a gone target: PUT same id -> 422 required; null -> 422 required; other id -> 200. POST with a deleted id -> 422 type.
19. Ordering: two bad fields name one only (Validate in field order, then existence, then unique).
## Reads
20. List with 3 events (full, dangling, empty) -> every item has the 8 old keys plus `relations`; `total`, `page`, `size` unchanged; `items` is `[]` for an empty type.
21. A type without a relation field -> `relations` `{}` and zero Get calls on target types. 100 entries pointing at one target call Get at most once for that id.
22. `?q=` table (case-insensitive, trimmed, empty = all, none = 0); `total` counts matches; page 2 size 10 of 30 matches returns the second ten; invalid `page`/`size`/`sort` keep their existing errors.
23. GET venue and GET a venue entry after the relation exists on event -> no inverse field, same keys as before.
## Delete
24. DELETE a referenced venue entry -> 204; the referencing events are byte-identical and no Update is recorded.
## Use case level (no HTTP)
25. `ValidateFields`, `entry.Validate`, `CreateEntry`/`UpdateEntry`, `UpdateContentType`, `DeleteContentType` as in the last scenario (`errors.Is`).
## Existing tests to amend
26. Key-set assertions in the custom content type, entry and media specs and tests: field 9 -> 10 keys, entry 8 -> 9 keys. The coder owns this.
## Manual verification (not exercised by the Go tests)
- Real SQLite, then MySQL/MariaDB and PostgreSQL via the driver tasks: create venue and event with a relation, save entries, delete the venue entry, reload the event and confirm the dangling id and `missing`.
- Time `GET .../entry?q=` against a type with 1,000 entries (the filter loads the whole type).
- The SPA is the second pair and is not checked here.
+190
View File
@@ -0,0 +1,190 @@
Feature: Sessions, uploads and stored personal data are hardened against forgery, replay and over-retention
As the operator of a RenewCMS instance
I want cross-site writes refused, logged-out sessions dead, weak secrets rejected, mail encrypted,
photo metadata removed and stale personal data deleted on a schedule
So that the instance meets the security-of-processing and storage-limitation duties of the GDPR
(art. 5(1)(c), 5(1)(e), 25 and 32) without the operator having to configure anything
Background:
Given api.InitRoutes wraps every route in middleware.CrossOriginProtection(settings.PublicURL),
built on the standard library's http.CrossOriginProtection, after CORS and before any router
And the jwt cookie is set and cleared with HttpOnly, SameSite=Lax, Path "/" and, in
production, Secure
Rule: Cross-site writes are refused
Scenario Outline: An unsafe cross-origin browser request is refused before any handler runs
Given a request <method> /v1/user/invite carrying the jwt cookie and the headers <headers>
Then the response is 403 with the body "Cross-origin requests are not allowed."
And the handler never runs
Examples:
| method | headers |
| POST | Sec-Fetch-Site: cross-site, Origin: https://evil.example |
| POST | Sec-Fetch-Site: same-site, Origin: https://blog.example.com |
| DELETE | Sec-Fetch-Site: cross-site, Origin: https://evil.example |
| POST | Origin: https://evil.example (an older browser, no Sec-Fetch-Site) |
Scenario Outline: Requests that cannot be forged are let through
Given a request <request>
Then the handler runs
Examples:
| request |
| POST with Sec-Fetch-Site: same-origin |
| POST with Sec-Fetch-Site: none |
| POST with neither Sec-Fetch-Site nor Origin (a non-browser client) |
| GET, HEAD or OPTIONS from any origin |
| POST from another origin carrying an Authorization header (an API key or bearer jwt) |
| POST whose Origin is the origin of PUBLIC_URL while a proxy rewrote Host |
Scenario: A PUBLIC_URL that cannot be parsed still leaves the protection on
Given PUBLIC_URL is not a URL
When a cross-site POST arrives
Then it is refused with 403
Rule: Logging out kills the session server-side
Scenario: Every session token carries a unique id and an expiry
When authtoken.Encode issues a token without "jti" or "exp"
Then it adds a random "jti" (crypto/rand.Text) and "exp" 24 hours after "iat"
And authtoken.Decode refuses a token without "exp" and a token whose "jti" is not a string
with ErrInvalid
Scenario: The token presented at logout is refused afterwards, other sessions are not
Given alice logged in twice, getting tokens A and B
When she sends POST /v1/auth/logout with token A
Then the response is 200 and clears the cookie
And a session.Revocation for A's jti, expiring at A's exp, is stored in revoked_sessions
And GET /v1/auth/me with token A answers 401 and with token B answers 200
Scenario: Logout without a valid session stores nothing
When POST /v1/auth/logout arrives without a cookie, or with an expired or revoked one
Then the response is 200, the cookie is cleared and nothing is stored
Scenario: A revocation that cannot be stored is reported
Given the revoked_sessions store refuses writes
When alice logs out
Then the response is 500 and the cookie is still cleared
Scenario: A token whose revocation cannot be checked is refused
Given the revoked_sessions store cannot be read
Then any request authenticated by a jwt is treated as unauthenticated
Rule: Only a current session sees drafts
Scenario: A verified user's stale token reads like an anonymous visitor
Given alice is verified and her token_version is 1
When GET /v1/article/ arrives with a token carrying token_version 0
Then only online articles are listed
Rule: Passwords fit bcrypt
Scenario Outline: A password over 72 bytes is refused with 400
When <request> carries a password of 73 bytes, or of 37 two-byte characters
Then the response is 400 with the body "The password must be 72 bytes or fewer."
And nothing is created or changed
Examples:
| request |
| POST /v1/auth/register |
| POST /v1/auth/password-reset/confirm (valid code) |
| POST /v1/auth/invite/accept (valid code) |
Scenario: A wrong reset code still wins over a long password
When POST /v1/auth/password-reset/confirm carries a wrong code and a 73-byte password
Then the response is 403 as for any wrong code
Rule: The server refuses to start with a guessable signing secret
Scenario Outline: A weak JWT_SECRET stops startup
Given JWT_SECRET is <value>
Then config.NewSettingsFromEnv fails with config.ErrWeakJWTSecret
And the message says to generate one with `openssl rand -hex 32`
Examples:
| value |
| empty |
| shorter than 32 bytes |
| "change-me-to-a-long-random-string" |
| "replace-with-output-of-openssl-rand-hex-32" |
Rule: Mail is never sent in clear text over the network by default
Scenario: A non-loopback SMTP server without STARTTLS is refused
Given SMTP_PORT is not 465, SMTP_HOST is not localhost, 127.0.0.1 or ::1, and the server does
not offer STARTTLS
When a mail is sent
Then delivery fails before MAIL FROM with an error naming SMTP_ALLOW_INSECURE
Scenario: The operator can opt out for a trusted relay
Given SMTP_ALLOW_INSECURE is "true"
When a mail is sent to such a server without credentials
Then it is delivered in clear text
And credentials are still never sent in clear text
Rule: Uploaded photos lose their metadata
Scenario Outline: Metadata is removed before the bytes are stored, hashed or measured for size
When a <format> image is uploaded
Then the stored bytes no longer contain <removed>
And they still contain <kept>
And the image's size and sha256 describe the stored bytes, and its pixels are unchanged
Examples:
| format | removed | kept |
| JPEG | EXIF, XMP, IPTC (APP13), comments, MPF and other APPn segments, trailing images | JFIF, ICC profile, Adobe |
| PNG | eXIf, tEXt, zTXt, iTXt, tIME and data after IEND | every other chunk |
| WebP | EXIF and XMP chunks (their VP8X flags cleared) and data after the RIFF size | every other chunk |
Scenario: A rotated photo keeps only its orientation
Given an uploaded JPEG, PNG or WebP whose EXIF orientation is 2 to 8
Then the stored file carries a minimal EXIF block holding only that orientation (after SOI or
JFIF for JPEG, after IHDR for PNG, at the end with the EXIF flag set for WebP)
Scenario: An image whose container cannot be walked is refused
When a JPEG, PNG or WebP with a truncated header, a chunk longer than the file or no IEND
(PNG) is uploaded
Then the upload fails with image.ErrImageUnreadable and nothing is stored
And a JPEG cut off inside its scan data is kept as it is
Scenario: SVG is stored as uploaded
When an SVG is uploaded
Then its bytes are stored unchanged
Rule: Stale personal data is deleted on a schedule
Scenario: The retention job runs hourly and logs only when it removed something
Given scheduler.NewSystemRetentionRunner runs useCases.EnforceRetentionUseCase a minute after
startup and then every hour
When a run removes something
Then the log reads "scheduler: personal data retention completed: revoked_sessions=<n>
unverified_users=<n> invites=<n>"
And a run that removes nothing logs nothing, and a failed run logs "... failed: <error>"
Scenario Outline: What is removed
Given <record>
Then the retention job <outcome>
Examples:
| record | outcome |
| a revoked session whose exp has passed | deletes it |
| an unverified account whose verification code expired more than 7 days ago | deletes it |
| an unverified account without a code expiry created more than 7 days ago | deletes it |
| an invite that expired more than 30 days ago | deletes it |
| an invite accepted more than 30 days ago | deletes it |
| a verified account, a pending invite or an account still inside its 7 days | keeps it |
Scenario: A failure stops the run and keeps the counts of what was done
Given deleting a user fails
Then the run returns the error with the sessions already purged counted and touches no invite
Rule: The back office loads nothing from third parties
Scenario: Fonts are bundled
Given the SPA's fonts (IBM Plex Sans, IBM Plex Mono, Source Serif 4) live in
web/src/assets/fonts with their OFL licences
Then the Content-Security-Policy allows "font-src 'self'" and "style-src 'self'
'unsafe-inline'" with no Google Fonts host
And loading the back office sends no request to fonts.googleapis.com or fonts.gstatic.com
+84
View File
@@ -0,0 +1,84 @@
# QA: security and privacy hardening
Everything below runs in `task test` (package `test`, `-tags nofrontend`) unless marked manual.
## Cross-origin protection and cookie flags
1. `test/cross_origin_protection_http_test.go`: wrap a counting handler in
`middleware.CrossOriginProtection("https://cms.example.com")`.
- Refused with 403 and `apperr.CrossOriginErrorMessage`, handler not reached: a cross-site POST, a
same-site POST from `https://blog.example.com`, a cross-site DELETE, and a POST with only
`Origin: https://evil.example`.
- Allowed: same-origin POST, `Sec-Fetch-Site: none`, no headers, cross-site GET and OPTIONS, a
cross-site POST with `Authorization: Bearer rcms_key`, and a POST whose `Origin` is the public
URL while `Host` is `127.0.0.1:8080`.
- With `PUBLIC_URL` `::not a url`, a cross-site POST is still 403.
2. `test/session_revocation_http_test.go` drives `api.InitRoutes`: the login cookie is HttpOnly,
Secure and `SameSite=Lax`, and a cross-site `POST /v1/auth/logout` is 403 and revokes nothing.
## Session revocation
3. `test/authtoken_test.go` (`TestAuthTokenRevocation`, `TestAuthTokenDecodeRejections`): distinct
`jti` per token; explicit `jti` kept; a revoked token gives `ErrRevoked` and is stored until its
`exp`; other tokens stay valid; store read failure gives `ErrInvalid`; store write failure is
returned by `Revoke`; no `jti` is a no-op; no `exp` refuses; tokens without `exp` or with a
non-string `jti` are refused by `Decode`.
4. `TestSessionRevocationUseCase`: an already-expired session is not stored; an empty or 65-byte id
is `session.ErrInvalidSessionID`; 64 bytes is accepted.
5. `TestRevokedSessionRepository` on a migrated SQLite file: revoking twice is harmless,
`DeleteExpired(now)` removes rows whose `expires_at <= now` and reports the count.
6. `TestLogoutRevokesTheSession` through `api.InitRoutes`: two logins, logout with the first, `/me`
with it is 401 and with the second is 200; logout without a cookie is 200 and stores nothing;
a failing write gives 500 with the cookie cleared; a failing read makes `/me` 401.
7. `test/verified_account_required_http_test.go`: a verified user whose `token_version` moved on
only sees online articles.
## Passwords
8. `test/password_length_limit_test.go`: 72 bytes accepted, 73 bytes and 37 × "é" refused by
`user.ValidatePassword`; register, reset (valid code) and invite accept answer 400 with
`apperr.PasswordTooLongErrorMessage` and change nothing; a wrong reset code still answers 403.
9. `test/hasher_test.go`: an unknown username compares against a decoy hash once per attempt and
hashes the decoy once.
## Configuration and mail
10. `test/runtime_settings_from_env_test.go`: empty, short, 31-byte and both example
`JWT_SECRET` values fail with `config.ErrWeakJWTSecret` and mention `openssl rand -hex 32`;
32 bytes passes. `SMTP_ALLOW_INSECURE` reads `""`/`false` as false, `true`/`TRUE` as true, and
`maybe` fails naming the variable.
11. `test/smtp_mailer_test.go` against the fake server on `127.0.0.2`: no STARTTLS and no opt-out
fails before `MAIL FROM` naming `SMTP_ALLOW_INSECURE`; with the opt-out the message is
delivered in clear text; with the opt-out and credentials, nothing is sent.
## Upload metadata
12. `test/image_metadata_stripping_test.go` builds JPEG, PNG and WebP files carrying a GPS-like
marker in EXIF, XMP, IPTC, comments, text chunks, MPF and trailing data. Assert the marker is
gone, ICC is kept, pixels decode identically, the orientation-only EXIF appears only when the
orientation is not 1 and sits in the documented place, WebP flags and RIFF size are correct,
a plain WebP and an SVG come back unchanged, and each malformed container is
`ErrImageUnreadable`.
13. `TestUploadStoresTheStrippedImage`: the stored bytes carry no marker and `Size`/`SHA256` match
them. `TestUploadRefusesAnImageWhoseContainerIsBroken`: nothing is stored.
## Retention
14. `test/personal_data_retention_test.go`: the user and invite rules at their boundaries; one run
over a seeded world removes exactly one expired revocation, one abandoned account and two
invites; a second run removes nothing; a session store failure touches nothing else; a user
or invite deletion failure returns the partial counts; the runner logs counts only when
something was removed and logs failures.
## Fonts
15. `test/security_headers_test.go`: the CSP contains `font-src 'self';` and no Google host.
16. Manual: `task build:web`, open the back office with the network panel; no request leaves for
`fonts.googleapis.com` or `fonts.gstatic.com`, and `task visual:check -- log-in` passes.
## Manual verification
- Behind the real reverse proxy, log in, copy the `jwt` cookie, log out, then replay the cookie
with `curl`: `/v1/auth/me` answers 401.
- With an SMTP relay that lacks STARTTLS on a non-loopback host, sending fails until
`SMTP_ALLOW_INSECURE=true` is set.
@@ -10,7 +10,7 @@ Feature: The rate limiter keys on the real connection address, trusting a forwar
Background:
Given POST /v1/auth/login and POST /v1/auth/password-reset are each wrapped, inside
NewAuthRouter, by a middleware that runs before RateLimiter (api/middleware/rateLimit.go),
which keys its counters on r.RemoteAddr
which keys its counters on the host portion of r.RemoteAddr, never on its source port
And this slice replaces chimiddleware.RealIP on those two route groups with
TrustedProxyRealIP(trustedProxies []string) func(http.Handler) http.Handler
(api/middleware/trustedProxyRealIP.go)
@@ -96,3 +96,23 @@ Feature: The rate limiter keys on the real connection address, trusting a forwar
X-Real-IP header
Then the first response is not rejected by rate limiting and the second is 429 — both are
counted against "10.0.0.1" itself
Scenario: New connections from the same address share one counter
Given TRUSTED_PROXIES is "" and AUTH_RATE_LIMIT_MAX_ATTEMPTS is 1
When a request arrives with connection address "203.0.113.7:40000"
And a second request arrives with connection address "203.0.113.7:40001"
Then the first response is not rejected by rate limiting and the second is 429, because the
counter is keyed on "203.0.113.7" and the source port is ignored
Scenario: A forwarded client keeps one counter across the proxy's connections
Given TRUSTED_PROXIES is "10.0.0.1" and AUTH_RATE_LIMIT_MAX_ATTEMPTS is 1
When a request arrives with connection address "10.0.0.1:51000" and header
X-Real-IP: "198.51.100.9"
And a second request arrives with connection address "10.0.0.1:51001" and the same header
Then the first response is not rejected by rate limiting and the second is 429
Scenario: Expired counters do not accumulate
Given requests from many distinct addresses
When a full window has passed since a counter started
Then the next request evicts every counter whose window has ended, so memory stays bounded by
the addresses seen within one window
@@ -56,6 +56,14 @@ setup (`mockUserRepo`, `stubHasher`, `mockMailRepo`, the full `useCases` set, `a
no forwarding header at all. Assert not `429`. Send a second, identical request. Assert `429` —
a trusted proxy with nothing to forward falls back to its own address as the key.
14. `trustedProxies = nil`, limit 5. Send 100 requests from `203.0.113.7` on ports `40000`–`40099`.
Assert exactly 95 are `429` — the source port never yields a fresh counter
(`test/rate_limit_connection_key_http_test.go`).
15. Same for IPv6: `[2001:db8::1]:50000` then `[2001:db8::1]:50001` with limit 1 → `200` then `429`.
16. `trustedProxies = ["10.0.0.1"]`, limit 1. `10.0.0.1:51000` and `10.0.0.1:51001`, both with
`X-Real-IP: 198.51.100.9` → `200` then `429`.
17. Limit 1, window 20 ms: `200`, `429`, wait 30 ms, `200` — an expired counter starts over.
## Manual verification (not exercised by the Go test)
- Real behaviour behind the documented nginx and Caddy reverse proxies (`docs/deployment.md`) —
@@ -13,6 +13,7 @@ var (
ErrInvalidIcon = errors.New("invalid content type icon")
ErrInvalidField = errors.New("invalid field")
ErrHasEntries = errors.New("the content type still has entries")
ErrReferenced = errors.New("the content type is the target of a relation field")
ErrSchemaIncompatible = errors.New("the schema is incompatible with the stored entries")
)
@@ -26,6 +27,7 @@ const (
TypeDate FieldType = "date"
TypeSelect FieldType = "select"
TypeMedia FieldType = "media"
TypeRelation FieldType = "relation"
)
const (
@@ -54,6 +56,7 @@ type Field struct {
Min any `json:"min"`
Max any `json:"max"`
Options []string `json:"options"`
Relation *Relation `json:"relation"`
}
type ContentType struct {
@@ -21,6 +21,7 @@ var kinds = map[FieldType]kind{
TypeDate: {accepts: isDate, measure: dateDays, bound: dateDays, unique: true},
TypeSelect: {accepts: isString, unique: true},
TypeMedia: {accepts: isMediaID},
TypeRelation: {},
}
func MediaID(value any) (uint32, bool) {
+2 -2
View File
@@ -63,7 +63,7 @@ func (f Field) validSchema() bool {
if f.Unique && !fieldKind.unique {
return false
}
return f.validOptions() && f.validBounds(fieldKind) && f.validDefault()
return f.validRelation() && f.validOptions() && f.validBounds(fieldKind) && f.validDefault()
}
func (f Field) validOptions() bool {
@@ -109,7 +109,7 @@ func (f Field) validDefault() bool {
if f.Default == nil {
return true
}
if f.Type == TypeMedia {
if f.Type == TypeMedia || f.Type == TypeRelation {
return false
}
return !f.Unique && f.Violation(f.Default) == ""
@@ -3,6 +3,9 @@ package contenttype
import "slices"
func (f Field) Violation(value any) string {
if f.Type == TypeRelation {
return f.relationViolation(value)
}
fieldKind, known := kinds[f.Type]
if !known || !fieldKind.accepts(value) {
return RuleType
@@ -0,0 +1,134 @@
package contenttype
const MaxRelationIDs = 50
type RelationKind string
const (
RelationOneToOne RelationKind = "one_to_one"
RelationOneToMany RelationKind = "one_to_many"
RelationManyToMany RelationKind = "many_to_many"
)
type Relation struct {
ContentType string `json:"content_type"`
Kind RelationKind `json:"kind"`
}
func (r Relation) validKind() bool {
return r.Kind == RelationOneToOne || r.Kind == RelationOneToMany || r.Kind == RelationManyToMany
}
func (f Field) validRelation() bool {
if f.Type != TypeRelation {
return f.Relation == nil
}
return f.Relation != nil && f.Relation.ContentType != "" && f.Relation.validKind()
}
func (f Field) IsRelation() bool {
return f.Type == TypeRelation && f.Relation != nil
}
func (f Field) HoldsManyRelations() bool {
return f.IsRelation() && f.Relation.Kind == RelationManyToMany
}
func (f Field) IsExclusiveRelation() bool {
return f.IsRelation() && f.Relation.Kind == RelationOneToOne
}
func (f Field) RelationIDs(value any) ([]uint32, bool) {
if value == nil {
return []uint32{}, true
}
if !f.HoldsManyRelations() {
id, ok := MediaID(value)
if !ok {
return nil, false
}
return []uint32{id}, true
}
return manyRelationIDs(value)
}
func manyRelationIDs(value any) ([]uint32, bool) {
var items []any
switch typed := value.(type) {
case []any:
items = typed
case []uint32:
for _, id := range typed {
items = append(items, id)
}
default:
return nil, false
}
if len(items) > MaxRelationIDs {
return nil, false
}
ids := make([]uint32, 0, len(items))
seen := make(map[uint32]bool, len(items))
for _, item := range items {
id, ok := MediaID(item)
if !ok || seen[id] {
return nil, false
}
seen[id] = true
ids = append(ids, id)
}
return ids, true
}
func (f Field) relationViolation(value any) string {
if _, ok := f.RelationIDs(value); !ok {
return RuleType
}
return ""
}
func EmptyRelation(field Field, value any) bool {
if !field.HoldsManyRelations() {
return false
}
ids, ok := field.RelationIDs(value)
return ok && len(ids) == 0
}
type RelationRef struct {
Field Field
IDs []uint32
}
func RelationRefs(fields []Field, data map[string]any) []RelationRef {
var refs []RelationRef
for _, field := range RelationFields(fields) {
if ids, ok := field.RelationIDs(data[field.Key]); ok && len(ids) > 0 {
refs = append(refs, RelationRef{Field: field, IDs: ids})
}
}
return refs
}
func RelationFields(fields []Field) []Field {
var relations []Field
for _, field := range fields {
if field.IsRelation() {
relations = append(relations, field)
}
}
return relations
}
func ReferencesType(fields []Field, key string) bool {
for _, field := range RelationFields(fields) {
if field.Relation.ContentType == key {
return true
}
}
return false
}
func SameRelation(left Field, right Field) bool {
return left.IsRelation() && right.IsRelation() && *left.Relation == *right.Relation
}
+40 -2
View File
@@ -2,8 +2,11 @@ package entry
import (
"RenewCMS/internal/domain/contenttype"
"fmt"
"reflect"
"sort"
"strconv"
"strings"
)
func SameValue(left any, right any) bool {
@@ -49,7 +52,7 @@ func Validate(fields []contenttype.Field, data map[string]any) error {
func validateField(field contenttype.Field, data map[string]any) error {
value := data[field.Key]
if value == nil {
if value == nil || contenttype.EmptyRelation(field, value) {
if field.Required {
return InvalidField(field.Key, RuleRequired)
}
@@ -82,7 +85,7 @@ func firstUnknownField(fields []contenttype.Field, data map[string]any) error {
func UniqueValues(fields []contenttype.Field, data map[string]any) []UniqueValue {
var values []UniqueValue
for _, field := range fields {
if value := data[field.Key]; field.Unique && value != nil {
if value := data[field.Key]; (field.Unique || field.IsExclusiveRelation()) && value != nil {
values = append(values, UniqueValue{Key: field.Key, Value: value})
}
}
@@ -103,3 +106,38 @@ func MissingMedia(field contenttype.Field, unchanged bool) error {
}
return InvalidField(field.Key, contenttype.RuleType)
}
func RelationIDs(fields []contenttype.Field, data map[string]any) map[string]map[uint32]bool {
ids := map[string]map[uint32]bool{}
for _, ref := range contenttype.RelationRefs(fields, data) {
held := make(map[uint32]bool, len(ref.IDs))
for _, id := range ref.IDs {
held[id] = true
}
ids[ref.Field.Key] = held
}
return ids
}
func MissingRelation(field contenttype.Field, unchanged bool) error {
return MissingMedia(field, unchanged)
}
func MatchesQuery(fields []contenttype.Field, data map[string]any, query string) bool {
if len(fields) == 0 {
return false
}
return strings.Contains(strings.ToLower(displayText(data[fields[0].Key])), strings.ToLower(query))
}
func displayText(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return typed
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
}
return fmt.Sprint(value)
}
@@ -0,0 +1,5 @@
package image
type MetadataStripper interface {
Strip(contentType string, data []byte) ([]byte, error)
}
+12
View File
@@ -15,6 +15,8 @@ var ErrInviteAlreadyAccepted = errors.New("invite already accepted")
const Lifetime = 3 * 24 * time.Hour
const Retention = 30 * 24 * time.Hour
type Status string
const (
@@ -42,3 +44,13 @@ func (i Invite) StatusAt(now time.Time) Status {
}
return StatusExpired
}
func (i Invite) IsStaleAt(now time.Time) bool {
switch i.StatusAt(now) {
case StatusAccepted:
return now.After(i.AcceptedAt.Add(Retention))
case StatusExpired:
return now.After(i.ExpiresAt.Add(Retention))
}
return false
}
@@ -0,0 +1,11 @@
package retention
type Result struct {
RevokedSessions int
UnverifiedUsers int
Invites int
}
func (r Result) Total() int {
return r.RevokedSessions + r.UnverifiedUsers + r.Invites
}
+22
View File
@@ -0,0 +1,22 @@
package session
import (
"errors"
"time"
)
var ErrInvalidSessionID = errors.New("invalid session id")
const MaxIDLength = 64
type Revocation struct {
ID string
ExpiresAt time.Time
}
func ValidateID(id string) error {
if id == "" || len(id) > MaxIDLength {
return ErrInvalidSessionID
}
return nil
}
@@ -0,0 +1,9 @@
package session
import "time"
type Repository interface {
Revoke(revocation Revocation) error
IsRevoked(id string) (bool, error)
DeleteExpired(now time.Time) (int, error)
}
+26
View File
@@ -18,9 +18,19 @@ var ErrInvalidDisplayName = errors.New("invalid display name")
var ErrUsernameTaken = errors.New("username taken")
var ErrInvalidLookup = errors.New("invalid user lookup")
var ErrVerificationEmailNotSent = errors.New("verification email not sent")
var ErrPasswordTooLong = errors.New("password too long")
const MaxLookupIDs = 100
const MaxPasswordBytes = 72
func ValidatePassword(password string) error {
if len(password) > MaxPasswordBytes {
return ErrPasswordTooLong
}
return nil
}
type Role string
const (
@@ -45,6 +55,22 @@ type User struct {
UpdatedAt time.Time `json:"updated_at"`
}
const UnverifiedRetention = 7 * 24 * time.Hour
func (u User) IsStaleUnverifiedAt(now time.Time) bool {
if u.IsVerified {
return false
}
deadline := u.VerificationExpiration
if deadline.IsZero() {
deadline = u.CreatedAt
}
if deadline.IsZero() {
return false
}
return now.After(deadline.Add(UnverifiedRetention))
}
func (u User) NameOrUsername() string {
if trimmed := strings.TrimSpace(u.DisplayName); trimmed != "" {
return trimmed
@@ -0,0 +1,39 @@
package config
import (
"errors"
"fmt"
"os"
)
const (
EnvJWTSecret = "JWT_SECRET"
MinJWTSecretBytes = 32
EnvSmtpAllowInsecure = "SMTP_ALLOW_INSECURE"
DefaultSmtpAllowInsecure = "false"
)
var ErrWeakJWTSecret = errors.New("JWT_SECRET is too weak")
var placeholderJWTSecrets = map[string]bool{
"change-me-to-a-long-random-string": true,
"replace-with-output-of-openssl-rand-hex-32": true,
}
func ValidateJWTSecret(secret string) error {
if placeholderJWTSecrets[secret] {
return fmt.Errorf("%w: it is still the example value; generate one with `openssl rand -hex 32`", ErrWeakJWTSecret)
}
if len(secret) < MinJWTSecretBytes {
return fmt.Errorf("%w: it must be at least %d bytes long; generate one with `openssl rand -hex 32`", ErrWeakJWTSecret, MinJWTSecretBytes)
}
return nil
}
func jwtSecretFromEnv() (string, error) {
secret := os.Getenv(EnvJWTSecret)
if err := ValidateJWTSecret(secret); err != nil {
return "", err
}
return secret, nil
}
+23 -11
View File
@@ -9,11 +9,12 @@ import (
)
type SmtpSettings struct {
Login string
Sender string
Password string
Host string
Port int
Login string
Sender string
Password string
Host string
Port int
AllowInsecure bool
}
type AuthRateLimitSettings struct {
@@ -55,11 +56,21 @@ func (s Settings) IsProduction() bool {
}
func NewSettingsFromEnv() (Settings, error) {
jwtSecret, err := jwtSecretFromEnv()
if err != nil {
return Settings{}, err
}
smtpPort, err := intEnv("SMTP_PORT")
if err != nil {
return Settings{}, err
}
smtpAllowInsecure, err := boolEnv(EnvSmtpAllowInsecure, DefaultSmtpAllowInsecure)
if err != nil {
return Settings{}, err
}
authRateLimitMaxAttempts, err := intEnv("AUTH_RATE_LIMIT_MAX_ATTEMPTS")
if err != nil {
return Settings{}, err
@@ -100,18 +111,19 @@ func NewSettingsFromEnv() (Settings, error) {
CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")),
PublicURL: os.Getenv("PUBLIC_URL"),
UploadDir: os.Getenv("UPLOAD_DIR"),
JWTSecret: os.Getenv("JWT_SECRET"),
JWTSecret: jwtSecret,
DBFile: os.Getenv("DB_FILE"),
Database: database,
Storage: storage,
DataDirPath: os.Getenv("DATA_DIR"),
Port: os.Getenv("PORT"),
Smtp: SmtpSettings{
Login: os.Getenv("SMTP_LOGIN"),
Sender: os.Getenv("SMTP_SENDER"),
Password: os.Getenv("SMTP_PASSWORD"),
Host: os.Getenv("SMTP_HOST"),
Port: smtpPort,
Login: os.Getenv("SMTP_LOGIN"),
Sender: os.Getenv("SMTP_SENDER"),
Password: os.Getenv("SMTP_PASSWORD"),
Host: os.Getenv("SMTP_HOST"),
Port: smtpPort,
AllowInsecure: smtpAllowInsecure,
},
AuthRateLimit: AuthRateLimitSettings{
MaxAttempts: authRateLimitMaxAttempts,
@@ -0,0 +1,75 @@
package imaging
import (
"bytes"
"encoding/binary"
)
const (
tiffOrientationTag = 0x0112
tiffTypeShort = 3
tiffHeaderSize = 8
tiffEntrySize = 12
tiffMagic = 42
uprightOrientation = 1
maxOrientation = 8
)
var exifHeader = []byte("Exif\x00\x00")
func exifOrientation(payload []byte) (uint16, bool) {
tiff := bytes.TrimPrefix(payload, exifHeader)
if len(tiff) < tiffHeaderSize {
return 0, false
}
var order binary.ByteOrder
switch string(tiff[:2]) {
case "II":
order = binary.LittleEndian
case "MM":
order = binary.BigEndian
default:
return 0, false
}
if order.Uint16(tiff[2:]) != tiffMagic {
return 0, false
}
ifd := int(order.Uint32(tiff[4:]))
if ifd < tiffHeaderSize || ifd+2 > len(tiff) {
return 0, false
}
count := int(order.Uint16(tiff[ifd:]))
for i := range count {
entry := ifd + 2 + i*tiffEntrySize
if entry+tiffEntrySize > len(tiff) {
return 0, false
}
if order.Uint16(tiff[entry:]) != tiffOrientationTag || order.Uint16(tiff[entry+2:]) != tiffTypeShort {
continue
}
value := order.Uint16(tiff[entry+8:])
if value < uprightOrientation || value > maxOrientation {
return 0, false
}
return value, true
}
return 0, false
}
func needsOrientation(orientation uint16) bool {
return orientation > uprightOrientation
}
func orientationOnlyTIFF(orientation uint16) []byte {
tiff := make([]byte, 0, tiffHeaderSize+2+tiffEntrySize+4)
tiff = append(tiff, 'M', 'M')
tiff = binary.BigEndian.AppendUint16(tiff, tiffMagic)
tiff = binary.BigEndian.AppendUint32(tiff, tiffHeaderSize)
tiff = binary.BigEndian.AppendUint16(tiff, 1)
tiff = binary.BigEndian.AppendUint16(tiff, tiffOrientationTag)
tiff = binary.BigEndian.AppendUint16(tiff, tiffTypeShort)
tiff = binary.BigEndian.AppendUint32(tiff, 1)
tiff = binary.BigEndian.AppendUint16(tiff, orientation)
tiff = binary.BigEndian.AppendUint16(tiff, 0)
return binary.BigEndian.AppendUint32(tiff, 0)
}
@@ -0,0 +1,168 @@
package imaging
import (
"bytes"
"encoding/binary"
)
const (
jpegMarkerPrefix = 0xFF
jpegSOI = 0xD8
jpegEOI = 0xD9
jpegSOS = 0xDA
jpegTEM = 0x01
jpegRST0 = 0xD0
jpegRST7 = 0xD7
jpegAPP0 = 0xE0
jpegAPP1 = 0xE1
jpegAPP2 = 0xE2
jpegAPP14 = 0xEE
jpegAPP15 = 0xEF
jpegCOM = 0xFE
jpegStuffedZero = 0x00
)
var iccProfileSignature = []byte("ICC_PROFILE\x00")
type jpegSegment struct {
marker byte
payload []byte
}
func stripJPEG(data []byte) ([]byte, error) {
if len(data) < 4 || data[0] != jpegMarkerPrefix || data[1] != jpegSOI {
return nil, errMalformedContainer
}
out := []byte{jpegMarkerPrefix, jpegSOI}
insertAt := len(out)
orientation := uint16(uprightOrientation)
first := true
for pos := 2; ; {
marker, next, err := readJPEGMarker(data, pos)
if err != nil {
return nil, err
}
pos = next
if marker == jpegEOI {
out = append(out, jpegMarkerPrefix, jpegEOI)
return withOrientationSegment(out, insertAt, orientation), nil
}
if isStandaloneJPEGMarker(marker) {
out = append(out, jpegMarkerPrefix, marker)
continue
}
segment, next, err := readJPEGSegment(data, pos, marker)
if err != nil {
return nil, err
}
pos = next
if isJPEGMetadata(segment) {
if found, ok := jpegExifOrientation(segment); ok {
orientation = found
}
} else {
out = appendJPEGSegment(out, segment)
if first && segment.marker == jpegAPP0 {
insertAt = len(out)
}
}
first = false
if marker == jpegSOS {
end := jpegScanEnd(data, pos)
out = append(out, data[pos:end]...)
if end == len(data) {
return withOrientationSegment(out, insertAt, orientation), nil
}
pos = end
}
}
}
func readJPEGMarker(data []byte, pos int) (byte, int, error) {
if pos >= len(data) || data[pos] != jpegMarkerPrefix {
return 0, 0, errMalformedContainer
}
for pos < len(data) && data[pos] == jpegMarkerPrefix {
pos++
}
if pos >= len(data) {
return 0, 0, errMalformedContainer
}
return data[pos], pos + 1, nil
}
func readJPEGSegment(data []byte, pos int, marker byte) (jpegSegment, int, error) {
if pos+2 > len(data) {
return jpegSegment{}, 0, errMalformedContainer
}
length := int(binary.BigEndian.Uint16(data[pos:]))
if length < 2 || pos+length > len(data) {
return jpegSegment{}, 0, errMalformedContainer
}
return jpegSegment{marker: marker, payload: data[pos+2 : pos+length]}, pos + length, nil
}
func jpegScanEnd(data []byte, pos int) int {
for pos < len(data) {
if data[pos] != jpegMarkerPrefix || pos+1 >= len(data) {
pos++
continue
}
next := data[pos+1]
if next == jpegStuffedZero || isRestartMarker(next) {
pos += 2
continue
}
return pos
}
return len(data)
}
func isStandaloneJPEGMarker(marker byte) bool {
return marker == jpegTEM || isRestartMarker(marker)
}
func isRestartMarker(marker byte) bool {
return marker >= jpegRST0 && marker <= jpegRST7
}
func isJPEGMetadata(segment jpegSegment) bool {
if segment.marker == jpegCOM {
return true
}
if segment.marker == jpegAPP2 {
return !bytes.HasPrefix(segment.payload, iccProfileSignature)
}
if segment.marker == jpegAPP0 || segment.marker == jpegAPP14 {
return false
}
return segment.marker >= jpegAPP1 && segment.marker <= jpegAPP15
}
func jpegExifOrientation(segment jpegSegment) (uint16, bool) {
if segment.marker != jpegAPP1 || !bytes.HasPrefix(segment.payload, exifHeader) {
return 0, false
}
return exifOrientation(segment.payload)
}
func appendJPEGSegment(out []byte, segment jpegSegment) []byte {
out = append(out, jpegMarkerPrefix, segment.marker)
out = binary.BigEndian.AppendUint16(out, uint16(len(segment.payload)+2))
return append(out, segment.payload...)
}
func withOrientationSegment(out []byte, insertAt int, orientation uint16) []byte {
if !needsOrientation(orientation) {
return out
}
payload := append(bytes.Clone(exifHeader), orientationOnlyTIFF(orientation)...)
segment := appendJPEGSegment(nil, jpegSegment{marker: jpegAPP1, payload: payload})
return append(out[:insertAt:insertAt], append(segment, out[insertAt:]...)...)
}
@@ -0,0 +1,35 @@
package imaging
import (
domain "RenewCMS/internal/domain/image"
"errors"
)
var errMalformedContainer = errors.New("malformed image container")
type MetadataStripper struct{}
var _ domain.MetadataStripper = &MetadataStripper{}
func NewMetadataStripper() *MetadataStripper {
return &MetadataStripper{}
}
func (s *MetadataStripper) Strip(contentType string, data []byte) ([]byte, error) {
var stripped []byte
var err error
switch contentType {
case "image/jpeg":
stripped, err = stripJPEG(data)
case "image/png":
stripped, err = stripPNG(data)
case "image/webp":
stripped, err = stripWebP(data)
default:
return data, nil
}
if err != nil {
return nil, errors.Join(domain.ErrImageUnreadable, err)
}
return stripped, nil
}
@@ -0,0 +1,86 @@
package imaging
import (
"bytes"
"encoding/binary"
"hash/crc32"
)
const (
pngChunkHeaderSize = 8
pngChunkCRCSize = 4
pngIHDR = "IHDR"
pngIEND = "IEND"
pngEXIF = "eXIf"
)
var pngSignature = []byte("\x89PNG\r\n\x1a\n")
var pngMetadataChunks = map[string]bool{
pngEXIF: true,
"tEXt": true,
"zTXt": true,
"iTXt": true,
"tIME": true,
}
func stripPNG(data []byte) ([]byte, error) {
if !bytes.HasPrefix(data, pngSignature) {
return nil, errMalformedContainer
}
out := bytes.Clone(pngSignature)
orientation := uint16(uprightOrientation)
insertAt := 0
for pos := len(pngSignature); pos < len(data); {
available := len(data) - pos - pngChunkHeaderSize - pngChunkCRCSize
if available < 0 {
return nil, errMalformedContainer
}
size := binary.BigEndian.Uint32(data[pos:])
if uint64(size) > uint64(available) {
return nil, errMalformedContainer
}
kind := string(data[pos+4 : pos+pngChunkHeaderSize])
payload := data[pos+pngChunkHeaderSize : pos+pngChunkHeaderSize+int(size)]
end := pos + pngChunkHeaderSize + len(payload) + pngChunkCRCSize
if pngMetadataChunks[kind] {
if kind == pngEXIF {
if found, ok := exifOrientation(payload); ok {
orientation = found
}
}
} else {
out = append(out, data[pos:end]...)
if kind == pngIHDR {
insertAt = len(out)
}
}
pos = end
if kind == pngIEND {
return withPNGOrientation(out, insertAt, orientation)
}
}
return nil, errMalformedContainer
}
func withPNGOrientation(out []byte, insertAt int, orientation uint16) ([]byte, error) {
if !needsOrientation(orientation) {
return out, nil
}
if insertAt == 0 {
return nil, errMalformedContainer
}
chunk := pngChunk(pngEXIF, orientationOnlyTIFF(orientation))
return append(out[:insertAt:insertAt], append(chunk, out[insertAt:]...)...), nil
}
func pngChunk(kind string, payload []byte) []byte {
chunk := binary.BigEndian.AppendUint32(nil, uint32(len(payload)))
chunk = append(chunk, kind...)
chunk = append(chunk, payload...)
return binary.BigEndian.AppendUint32(chunk, crc32.ChecksumIEEE(chunk[4:]))
}
@@ -0,0 +1,88 @@
package imaging
import (
"bytes"
"encoding/binary"
)
const (
riffHeaderSize = 12
riffChunkHeaderSize = 8
webpVP8X = "VP8X"
webpEXIF = "EXIF"
webpXMP = "XMP "
webpEXIFFlag = 0x08
webpXMPFlag = 0x04
)
func stripWebP(data []byte) ([]byte, error) {
if len(data) < riffHeaderSize || string(data[:4]) != "RIFF" || string(data[8:12]) != "WEBP" {
return nil, errMalformedContainer
}
declared := int(binary.LittleEndian.Uint32(data[4:])) + riffChunkHeaderSize
if declared > len(data) || declared < riffHeaderSize {
return nil, errMalformedContainer
}
data = data[:declared]
var chunks [][]byte
orientation := uint16(uprightOrientation)
for pos := riffHeaderSize; pos < len(data); {
available := len(data) - pos - riffChunkHeaderSize
if available < 0 {
return nil, errMalformedContainer
}
size := binary.LittleEndian.Uint32(data[pos+4:])
padded := uint64(size) + uint64(size%2)
if padded > uint64(available) {
return nil, errMalformedContainer
}
kind := string(data[pos : pos+4])
payload := data[pos+riffChunkHeaderSize : pos+riffChunkHeaderSize+int(size)]
end := pos + riffChunkHeaderSize + int(padded)
switch kind {
case webpEXIF:
if found, ok := exifOrientation(payload); ok {
orientation = found
}
case webpXMP:
default:
chunks = append(chunks, bytes.Clone(data[pos:end]))
}
pos = end
}
if len(chunks) == 0 {
return nil, errMalformedContainer
}
if string(chunks[0][:4]) == webpVP8X {
if len(chunks[0]) <= riffChunkHeaderSize {
return nil, errMalformedContainer
}
flags := &chunks[0][riffChunkHeaderSize]
*flags &^= webpEXIFFlag | webpXMPFlag
if needsOrientation(orientation) {
*flags |= webpEXIFFlag
chunks = append(chunks, riffChunk(webpEXIF, orientationOnlyTIFF(orientation)))
}
}
return riffContainer(chunks), nil
}
func riffChunk(kind string, payload []byte) []byte {
chunk := append([]byte(kind), binary.LittleEndian.AppendUint32(nil, uint32(len(payload)))...)
chunk = append(chunk, payload...)
if len(payload)%2 == 1 {
chunk = append(chunk, 0)
}
return chunk
}
func riffContainer(chunks [][]byte) []byte {
body := []byte("WEBP")
for _, chunk := range chunks {
body = append(body, chunk...)
}
out := append([]byte("RIFF"), binary.LittleEndian.AppendUint32(nil, uint32(len(body)))...)
return append(out, body...)
}
+14 -4
View File
@@ -81,17 +81,27 @@ func (t transport) connect() (*smtp.Client, error) {
return client, nil
}
var errTLSUnavailable = errors.New("smtp server does not offer STARTTLS; set SMTP_ALLOW_INSECURE=true to send without encryption")
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
}
if err := t.startTLS(client); err != nil {
return err
}
}
return t.authenticate(client)
}
func (t transport) startTLS(client *smtp.Client) error {
if supported, _ := client.Extension("STARTTLS"); supported {
return client.StartTLS(t.tlsConfig)
}
if t.settings.AllowInsecure || isLoopback(t.settings.Host) {
return nil
}
return errTLSUnavailable
}
func (t transport) authenticate(client *smtp.Client) error {
if t.settings.Login == "" {
return nil
@@ -7,7 +7,7 @@ import (
)
func Migrate(db *gorm.DB, publicURL string) error {
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{})
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{})
if err != nil {
return err
}
@@ -0,0 +1,10 @@
package mappers
import (
"RenewCMS/internal/domain/session"
"RenewCMS/internal/infrastructure/persistence/models"
)
func RevokedSessionToModel(r session.Revocation) models.RevokedSession {
return models.RevokedSession{ID: r.ID, ExpiresAt: r.ExpiresAt.UTC()}
}
@@ -0,0 +1,8 @@
package models
import "time"
type RevokedSession struct {
ID string `gorm:"primaryKey;size:64"`
ExpiresAt time.Time `gorm:"index;not null"`
}
@@ -0,0 +1,39 @@
package persistence
import (
domainSession "RenewCMS/internal/domain/session"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type RevokedSessionRepository struct {
db *gorm.DB
}
func NewRevokedSessionRepository(db *gorm.DB) *RevokedSessionRepository {
return &RevokedSessionRepository{db}
}
func (s *RevokedSessionRepository) Revoke(revocation domainSession.Revocation) error {
model := mappers.RevokedSessionToModel(revocation)
return s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&model).Error
}
func (s *RevokedSessionRepository) IsRevoked(id string) (bool, error) {
var count int64
if err := s.db.Model(&entity.RevokedSession{}).Where("id = ?", id).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (s *RevokedSessionRepository) DeleteExpired(now time.Time) (int, error) {
result := s.db.Where("expires_at <= ?", now.UTC()).Delete(&entity.RevokedSession{})
return int(result.RowsAffected), result.Error
}
var _ domainSession.Repository = &RevokedSessionRepository{}
+28 -2
View File
@@ -3,6 +3,7 @@ package scheduler
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/purge"
"RenewCMS/internal/domain/retention"
"context"
"errors"
"fmt"
@@ -23,6 +24,10 @@ type CleanupJob interface {
Tick(now time.Time) (audit.CleanupResult, bool, error)
}
type RetentionJob interface {
Tick(now time.Time) (retention.Result, error)
}
type task interface {
tick(ctx context.Context, now time.Time) (summary string, ran bool, err error)
}
@@ -49,6 +54,16 @@ func (c cleanupTask) tick(_ context.Context, now time.Time) (string, bool, error
return fmt.Sprintf("removed=%d", result.Removed), ran, err
}
type retentionTask struct {
job RetentionJob
}
func (r retentionTask) tick(_ context.Context, now time.Time) (string, bool, error) {
result, err := r.job.Tick(now)
summary := fmt.Sprintf("revoked_sessions=%d unverified_users=%d invites=%d", result.RevokedSessions, result.UnverifiedUsers, result.Invites)
return summary, result.Total() > 0, err
}
type Runner struct {
label string
task task
@@ -60,8 +75,9 @@ type Runner struct {
}
const (
purgeLabel = "automatic purge"
cleanupLabel = "activity cleanup"
purgeLabel = "automatic purge"
cleanupLabel = "activity cleanup"
retentionLabel = "personal data retention"
)
func newRunner(label string, task task, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner {
@@ -76,6 +92,16 @@ func NewCleanupRunner(job CleanupJob, now func() time.Time, ticks <-chan time.Ti
return newRunner(cleanupLabel, cleanupTask{job}, now, ticks, startup, logger)
}
func NewRetentionRunner(job RetentionJob, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner {
return newRunner(retentionLabel, retentionTask{job}, now, ticks, startup, logger)
}
func NewSystemRetentionRunner(job RetentionJob, logger *log.Logger) *Runner {
return withSystemTimers(func(now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time) *Runner {
return NewRetentionRunner(job, now, ticks, startup, logger)
})
}
func NewSystemRunner(job Job, logger *log.Logger) *Runner {
return withSystemTimers(func(now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time) *Runner {
return NewRunner(job, now, ticks, startup, logger)
@@ -66,6 +66,10 @@ func (a *AcceptInviteUseCase) accept(command AcceptInviteCommand, pending invite
return user.User{}, err
}
if err := user.ValidatePassword(command.Password); err != nil {
return user.User{}, err
}
hashedPassword, err := a.hasher.Hash(command.Password)
if err != nil {
return user.User{}, err
@@ -5,17 +5,21 @@ import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/user"
"context"
"crypto/rand"
"errors"
"sync"
)
type AuthenticateUserUseCase struct {
userRepository user.Repository
hasher hash.Hasher
recorder audit.Recorder
decoyOnce sync.Once
decoyHash string
}
func NewAuthenticateUserUseCase(userRepository user.Repository, hasher hash.Hasher, recorder audit.Recorder) *AuthenticateUserUseCase {
return &AuthenticateUserUseCase{userRepository, hasher, recorder}
return &AuthenticateUserUseCase{userRepository: userRepository, hasher: hasher, recorder: recorder}
}
func (a *AuthenticateUserUseCase) Authenticate(ctx context.Context, username string, password string) (user.User, error) {
@@ -34,6 +38,9 @@ func (a *AuthenticateUserUseCase) Authenticate(ctx context.Context, username str
func (a *AuthenticateUserUseCase) verifyCredentials(username string, password string) (user.User, error) {
dbUser, err := a.userRepository.GetByUsername(username)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
a.compareWithDecoy(password)
}
return user.User{}, err
}
@@ -44,6 +51,15 @@ func (a *AuthenticateUserUseCase) verifyCredentials(username string, password st
return dbUser, nil
}
func (a *AuthenticateUserUseCase) compareWithDecoy(password string) {
a.decoyOnce.Do(func() {
a.decoyHash, _ = a.hasher.Hash(rand.Text())
})
if a.decoyHash != "" {
_ = a.hasher.Compare(a.decoyHash, password)
}
}
func (a *AuthenticateUserUseCase) recordRejectedLogin(ctx context.Context, username string, cause error) {
if !errors.Is(cause, user.ErrUserNotFound) && !errors.Is(cause, user.ErrInvalidCredentials) {
return
@@ -37,6 +37,9 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
if err != nil {
return contenttype.ContentType{}, err
}
if err := ensureRelationTargets(u.contentTypes, command.Key, fields); err != nil {
return contenttype.ContentType{}, err
}
if err := u.ensureKeyFree(command.Key); err != nil {
return contenttype.ContentType{}, err
}
@@ -36,6 +36,10 @@ func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, err
if err := ensureMediaExists(u.images, owner, data, nil); err != nil {
return TypedEntry{}, err
}
resolvers := newEntryResolvers(u.images, u.contentTypes, u.entries)
if err := ensureRelationsExist(resolvers.relations, owner, data, nil); err != nil {
return TypedEntry{}, err
}
if err := ensureUnique(u.entries, owner, data, 0); err != nil {
return TypedEntry{}, err
}
@@ -51,5 +55,5 @@ func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, err
if err != nil {
return TypedEntry{}, err
}
return typedEntry(owner, stored, newMediaResolver(u.images))
return typedEntry(owner, stored, resolvers)
}
@@ -29,10 +29,11 @@ type CreateImageUseCase struct {
imageRepository image.Repository
storage image.Storage
recorder audit.Recorder
stripper image.MetadataStripper
}
func NewCreateImageUseCase(imageRepository image.Repository, storage image.Storage, recorder audit.Recorder) *CreateImageUseCase {
return &CreateImageUseCase{imageRepository, storage, recorder}
func NewCreateImageUseCase(imageRepository image.Repository, storage image.Storage, recorder audit.Recorder, stripper image.MetadataStripper) *CreateImageUseCase {
return &CreateImageUseCase{imageRepository, storage, recorder, stripper}
}
func (g *CreateImageUseCase) CreateImage(ctx context.Context, input image.ImageInput) (image.Image, error) {
@@ -74,6 +75,11 @@ func (g *CreateImageUseCase) create(input image.ImageInput) (image.Image, error)
return image.Image{}, err
}
data, err = g.stripper.Strip(input.ContentType, data)
if err != nil {
return image.Image{}, err
}
checksum := sha256.Sum256(data)
storedName, err := g.storage.Put(input.ContentType, bytes.NewReader(data))
if err != nil {
@@ -55,6 +55,10 @@ func (g *CreateUserUseCase) create(createUser CreateUserCommand) (user.User, err
return user.User{}, user.ErrRegistrationClosed
}
if err := user.ValidatePassword(createUser.Password); err != nil {
return user.User{}, err
}
rawUuid := uuid.NewString()
hashedPassword, err := g.hasher.Hash(createUser.Password)
@@ -19,6 +19,15 @@ func (u *DeleteContentTypeUseCase) Delete(key string) error {
if err != nil {
return err
}
types, err := u.contentTypes.List()
if err != nil {
return err
}
for _, other := range types {
if other.ID != target.ID && contenttype.ReferencesType(other.Fields, target.Key) {
return contenttype.ErrReferenced
}
}
count, err := u.entries.CountByContentType(target.ID)
if err != nil {
return err
@@ -0,0 +1,61 @@
package useCases
import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/retention"
"RenewCMS/internal/domain/session"
"RenewCMS/internal/domain/user"
"time"
)
type EnforceRetentionUseCase struct {
users user.Repository
invites invite.Repository
sessions session.Repository
}
func NewEnforceRetentionUseCase(users user.Repository, invites invite.Repository, sessions session.Repository) *EnforceRetentionUseCase {
return &EnforceRetentionUseCase{users, invites, sessions}
}
func (e *EnforceRetentionUseCase) Tick(now time.Time) (retention.Result, error) {
var result retention.Result
var err error
if result.RevokedSessions, err = e.sessions.DeleteExpired(now); err != nil {
return result, err
}
if result.UnverifiedUsers, err = e.deleteStaleUnverifiedUsers(now); err != nil {
return result, err
}
result.Invites, err = e.deleteStaleInvites(now)
return result, err
}
func (e *EnforceRetentionUseCase) deleteStaleUnverifiedUsers(now time.Time) (int, error) {
removed := 0
for _, candidate := range e.users.GetAll() {
if !candidate.IsStaleUnverifiedAt(now) {
continue
}
if err := e.users.Delete(candidate.ID); err != nil {
return removed, err
}
removed++
}
return removed, nil
}
func (e *EnforceRetentionUseCase) deleteStaleInvites(now time.Time) (int, error) {
removed := 0
for _, candidate := range e.invites.GetAll() {
if !candidate.IsStaleAt(now) {
continue
}
if err := e.invites.Delete(candidate.ID); err != nil {
return removed, err
}
removed++
}
return removed, nil
}
@@ -11,7 +11,7 @@ import (
const exportBatchSize = 500
const formulaGuard = "'"
const formulaStarts = "=+-@"
const formulaStarts = "=+-@\t\r"
var exportHeader = []string{"when", "actor", "action", "target", "outcome", "source", "ip"}
+1 -1
View File
@@ -25,5 +25,5 @@ func (u *GetEntryUseCase) Get(contentTypeKey string, id uint32) (TypedEntry, err
if err != nil {
return TypedEntry{}, err
}
return typedEntry(owner, found, newMediaResolver(u.images))
return typedEntry(owner, found, newEntryResolvers(u.images, u.contentTypes, u.entries))
}
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image"
"strings"
)
type ListEntriesUseCase struct {
@@ -17,6 +18,7 @@ type ListEntriesCommand struct {
Page int
Size int
Sort entry.Sort
Query string
}
type EntryPage struct {
@@ -41,12 +43,12 @@ func (u *ListEntriesUseCase) ListPage(command ListEntriesCommand) (EntryPage, er
if err != nil {
return EntryPage{}, err
}
found, err := u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Page: command.Page, Size: command.Size, Sort: command.Sort})
found, err := u.page(owner, command)
if err != nil {
return EntryPage{}, err
}
items := make([]TypedEntry, 0, len(found.Items))
resolver := newMediaResolver(u.images)
resolver := newEntryResolvers(u.images, u.contentTypes, u.entries)
for _, record := range found.Items {
typed, err := typedEntry(owner, record, resolver)
if err != nil {
@@ -56,3 +58,25 @@ func (u *ListEntriesUseCase) ListPage(command ListEntriesCommand) (EntryPage, er
}
return EntryPage{Items: items, Total: found.Total, Page: command.Page, Size: command.Size}, nil
}
func (u *ListEntriesUseCase) page(owner contenttype.ContentType, command ListEntriesCommand) (entry.PageResult, error) {
query := strings.TrimSpace(command.Query)
if query == "" {
return u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Page: command.Page, Size: command.Size, Sort: command.Sort})
}
all, err := u.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Unpaged: true, Sort: command.Sort})
if err != nil {
return entry.PageResult{}, err
}
matching := make([]entry.Entry, 0, len(all.Items))
for _, record := range all.Items {
if entry.MatchesQuery(owner.Fields, record.Data, query) {
matching = append(matching, record)
}
}
pageNumber := max(command.Page, 1)
size := max(command.Size, 0)
start := min((pageNumber-1)*size, len(matching))
end := min(start+size, len(matching))
return entry.PageResult{Items: matching[start:end], Total: len(matching)}, nil
}
@@ -35,6 +35,10 @@ func (r *ResetPasswordUseCase) ResetPassword(ctx context.Context, email string,
return user.ErrPasswordResetCodeExpired
}
if err := user.ValidatePassword(newPassword); err != nil {
return err
}
hashedPassword, err := r.hasher.Hash(newPassword)
if err != nil {
return err
@@ -0,0 +1,32 @@
package useCases
import (
"RenewCMS/internal/domain/session"
"time"
)
type SessionRevocationUseCase struct {
sessions session.Repository
now func() time.Time
}
func NewSessionRevocationUseCase(sessions session.Repository, now func() time.Time) *SessionRevocationUseCase {
return &SessionRevocationUseCase{sessions, now}
}
func (s *SessionRevocationUseCase) Revoke(id string, expiresAt time.Time) error {
if err := session.ValidateID(id); err != nil {
return err
}
if !expiresAt.After(s.now()) {
return nil
}
return s.sessions.Revoke(session.Revocation{ID: id, ExpiresAt: expiresAt})
}
func (s *SessionRevocationUseCase) IsRevoked(id string) (bool, error) {
if err := session.ValidateID(id); err != nil {
return false, err
}
return s.sessions.IsRevoked(id)
}
@@ -39,6 +39,12 @@ func (u *UpdateContentTypeUseCase) Update(command UpdateContentTypeCommand) (con
if err != nil {
return contenttype.ContentType{}, err
}
if relationShapeChanged(current.Fields, fields) {
return contenttype.ContentType{}, contenttype.ErrInvalidField
}
if err := ensureRelationTargets(u.contentTypes, current.Key, fields); err != nil {
return contenttype.ContentType{}, err
}
stored, err := u.storedEntries(current.ID)
if err != nil {
return contenttype.ContentType{}, err
@@ -40,6 +40,10 @@ func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, err
if err := ensureMediaExists(u.images, owner, command.Data, current.Data); err != nil {
return TypedEntry{}, err
}
resolvers := newEntryResolvers(u.images, u.contentTypes, u.entries)
if err := ensureRelationsExist(resolvers.relations, owner, command.Data, current.Data); err != nil {
return TypedEntry{}, err
}
if err := ensureUnique(u.entries, owner, command.Data, current.ID); err != nil {
return TypedEntry{}, err
}
@@ -50,5 +54,5 @@ func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, err
if err != nil {
return TypedEntry{}, err
}
return typedEntry(owner, stored, newMediaResolver(u.images))
return typedEntry(owner, stored, resolvers)
}
@@ -44,6 +44,7 @@ var failureReasons = []failureReason{
{user.ErrVerificationCodeExpired, "The verification code has expired."},
{user.ErrInvalidDisplayName, "That display name is too long."},
{user.ErrUsernameTaken, "That username is already taken."},
{user.ErrPasswordTooLong, "The password is longer than 72 bytes."},
{invite.ErrEmailHasAccount, "That email already has an account."},
{invite.ErrPendingInvite, "An invitation is already pending for that email."},
{invite.ErrInviteEmailNotSent, "The invitation email could not be sent."},
@@ -0,0 +1,168 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"errors"
)
type EntryFirstField struct {
Key string
Type contenttype.FieldType
Value any
}
type EntryRelation struct {
ID uint32
Missing bool
ContentType string
FirstField EntryFirstField
}
type EntryRelations struct {
Many bool
Items []EntryRelation
}
type targetEntryKey struct {
contentType string
id uint32
}
type relationResolver struct {
contentTypes contenttype.Repository
entries entry.Repository
types map[string]*contenttype.ContentType
found map[targetEntryKey]EntryRelation
}
func newRelationResolver(contentTypes contenttype.Repository, entries entry.Repository) *relationResolver {
return &relationResolver{contentTypes: contentTypes, entries: entries, types: map[string]*contenttype.ContentType{}, found: map[targetEntryKey]EntryRelation{}}
}
func (r *relationResolver) resolve(owner contenttype.ContentType, record entry.Entry) (map[string]EntryRelations, error) {
resolved := map[string]EntryRelations{}
for _, ref := range contenttype.RelationRefs(owner.Fields, record.Data) {
items := make([]EntryRelation, 0, len(ref.IDs))
for _, id := range ref.IDs {
item, err := r.lookup(ref.Field.Relation.ContentType, id)
if err != nil {
return nil, err
}
items = append(items, item)
}
resolved[ref.Field.Key] = EntryRelations{Many: ref.Field.HoldsManyRelations(), Items: items}
}
return resolved, nil
}
func (r *relationResolver) targetType(key string) (*contenttype.ContentType, error) {
if cached, found := r.types[key]; found {
return cached, nil
}
target, err := r.contentTypes.GetByKey(key)
if errors.Is(err, contenttype.ErrContentTypeNotFound) {
r.types[key] = nil
return nil, nil
}
if err != nil {
return nil, err
}
r.types[key] = &target
return &target, nil
}
func (r *relationResolver) lookup(typeKey string, id uint32) (EntryRelation, error) {
cacheKey := targetEntryKey{typeKey, id}
if cached, found := r.found[cacheKey]; found {
return cached, nil
}
target, err := r.fetch(typeKey, id)
if err != nil {
return EntryRelation{}, err
}
r.found[cacheKey] = target
return target, nil
}
func (r *relationResolver) fetch(typeKey string, id uint32) (EntryRelation, error) {
missing := EntryRelation{ID: id, Missing: true}
target, err := r.targetType(typeKey)
if err != nil || target == nil || len(target.Fields) == 0 {
return missing, err
}
record, err := r.entries.Get(target.ID, id)
if errors.Is(err, entry.ErrEntryNotFound) {
return missing, nil
}
if err != nil {
return EntryRelation{}, err
}
first := target.Fields[0]
return EntryRelation{ID: id, ContentType: typeKey, FirstField: EntryFirstField{Key: first.Key, Type: first.Type, Value: record.Data[first.Key]}}, nil
}
func (r *relationResolver) exists(typeKey string, id uint32) (bool, error) {
target, err := r.targetType(typeKey)
if err != nil || target == nil || len(target.Fields) == 0 {
return false, err
}
_, err = r.entries.Get(target.ID, id)
if errors.Is(err, entry.ErrEntryNotFound) {
return false, nil
}
return err == nil, err
}
func ensureRelationsExist(resolver *relationResolver, owner contenttype.ContentType, data map[string]any, stored map[string]any) error {
storedIDs := entry.RelationIDs(owner.Fields, stored)
for _, ref := range contenttype.RelationRefs(owner.Fields, data) {
for _, id := range ref.IDs {
unchanged := stored != nil && storedIDs[ref.Field.Key][id]
if unchanged && !ref.Field.Required {
continue
}
exists, err := resolver.exists(ref.Field.Relation.ContentType, id)
if err != nil {
return err
}
if !exists {
return entry.MissingRelation(ref.Field, unchanged)
}
}
}
return nil
}
func ensureRelationTargets(contentTypes contenttype.Repository, ownerKey string, fields []contenttype.Field) error {
for _, field := range contenttype.RelationFields(fields) {
target := field.Relation.ContentType
if target == ownerKey {
if field.IsExclusiveRelation() {
return contenttype.ErrInvalidField
}
continue
}
_, err := contentTypes.GetByKey(target)
if errors.Is(err, contenttype.ErrContentTypeNotFound) {
return contenttype.ErrInvalidField
}
if err != nil {
return err
}
}
return nil
}
func relationShapeChanged(previous []contenttype.Field, next []contenttype.Field) bool {
nextByKey := make(map[string]contenttype.Field, len(next))
for _, field := range next {
nextByKey[field.Key] = field
}
for _, field := range contenttype.RelationFields(previous) {
if replacement, kept := nextByKey[field.Key]; kept && !contenttype.SameRelation(field, replacement) {
return true
}
}
return false
}
@@ -11,15 +11,29 @@ type TypedEntry struct {
entry.Entry
ContentTypeKey string
Media map[string]EntryMedia
Relations map[string]EntryRelations
}
func typedEntry(owner contenttype.ContentType, record entry.Entry, resolver *mediaResolver) (TypedEntry, error) {
type entryResolvers struct {
media *mediaResolver
relations *relationResolver
}
func newEntryResolvers(images image.Repository, contentTypes contenttype.Repository, entries entry.Repository) *entryResolvers {
return &entryResolvers{media: newMediaResolver(images), relations: newRelationResolver(contentTypes, entries)}
}
func typedEntry(owner contenttype.ContentType, record entry.Entry, resolvers *entryResolvers) (TypedEntry, error) {
record.Data = entry.Project(owner.Fields, record.Data)
media, err := resolver.resolve(owner, record)
media, err := resolvers.media.resolve(owner, record)
if err != nil {
return TypedEntry{}, err
}
return TypedEntry{Entry: record, ContentTypeKey: owner.Key, Media: media}, nil
relations, err := resolvers.relations.resolve(owner, record)
if err != nil {
return TypedEntry{}, err
}
return TypedEntry{Entry: record, ContentTypeKey: owner.Key, Media: media, Relations: relations}, nil
}
func ensureMediaExists(images image.Repository, owner contenttype.ContentType, data map[string]any, stored map[string]any) error {
+2
View File
@@ -370,6 +370,8 @@ func TestActivityExportUseCase(t *testing.T) {
{"target", "+1+1", "'+1+1"},
{"target", "-2", "'-2"},
{"actor", "@SUM(A1)", "'@SUM(A1)"},
{"target", "\t=cmd", "'\t=cmd"},
{"target", "\r=cmd", "'\r=cmd"},
{"target", "Spring release", "Spring release"},
{"target", "a=b", "a=b"},
}
+2 -1
View File
@@ -12,6 +12,7 @@ import (
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"crypto/sha256"
"encoding/hex"
@@ -125,7 +126,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites),
GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher, recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder, imaging.NewMetadataStripper()),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo(), plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
+176 -12
View File
@@ -126,7 +126,9 @@ func TestAuthTokenDecodeRejections(t *testing.T) {
"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},
"not yet valid": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"nbf":`+authTokenUnix(authTokenEpoch.Add(time.Second).Unix())+`}`), authtoken.ErrNotYetValid},
"without exp": {signedAuthTokenSegments("test-secret", header, `{"user_id":4,"token_version":1}`), authtoken.ErrInvalid},
"jti not a string": {signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"jti":7}`), authtoken.ErrInvalid},
"larger than the size budget": {strings.Repeat("a", 5000), authtoken.ErrInvalid},
}
@@ -143,18 +145,8 @@ func TestAuthTokenDecodeRejections(t *testing.T) {
})
}
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())+`}`)
token := signedAuthTokenSegments("test-secret", header, `{"exp":`+authTokenUnix(authTokenEpoch.Add(time.Hour).Unix())+`,"nbf":`+authTokenUnix(authTokenEpoch.Add(-time.Second).Unix())+`}`)
if _, err := auth.Decode(token); err != nil {
t.Errorf("expected acceptance, got %v", err)
@@ -179,3 +171,175 @@ func TestAuthTokenDecodeRejections(t *testing.T) {
func authTokenUnix(value int64) string {
return strconv.FormatInt(value, 10)
}
type memoryRevocationStore struct {
revoked map[string]time.Time
failure error
}
func newMemoryRevocationStore() *memoryRevocationStore {
return &memoryRevocationStore{revoked: map[string]time.Time{}}
}
func (m *memoryRevocationStore) Revoke(id string, expiresAt time.Time) error {
if m.failure != nil {
return m.failure
}
m.revoked[id] = expiresAt
return nil
}
func (m *memoryRevocationStore) IsRevoked(id string) (bool, error) {
if m.failure != nil {
return false, m.failure
}
_, ok := m.revoked[id]
return ok, nil
}
func TestAuthTokenRevocation(t *testing.T) {
newAuth := func(store *memoryRevocationStore) *authtoken.Auth {
return authtoken.New([]byte("test-secret"), authtoken.WithClock(func() time.Time { return authTokenEpoch }), authtoken.WithRevocationStore(store))
}
t.Run("Every encoded token carries a distinct jti", func(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
first, _ := auth.Encode(map[string]any{"user_id": 1})
second, _ := auth.Encode(map[string]any{"user_id": 1})
firstClaims, _ := auth.Decode(first)
secondClaims, _ := auth.Decode(second)
id, ok := firstClaims["jti"].(string)
if !ok || id == "" {
t.Fatalf("expected a jti, got %v", firstClaims["jti"])
}
if id == secondClaims["jti"] {
t.Errorf("expected distinct jti values, both were %q", id)
}
})
t.Run("An explicit jti is kept", func(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
encoded, _ := auth.Encode(map[string]any{"jti": "fixed-id"})
claims, _ := auth.Decode(encoded)
if claims["jti"] != "fixed-id" {
t.Errorf("expected jti fixed-id, got %v", claims["jti"])
}
})
t.Run("A revoked token is rejected until it would have expired anyway", func(t *testing.T) {
store := newMemoryRevocationStore()
auth := newAuth(store)
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
claims, err := auth.Decode(encoded)
if err != nil {
t.Fatal(err)
}
if err := auth.Revoke(claims); err != nil {
t.Fatal(err)
}
if _, err := auth.Decode(encoded); !errors.Is(err, authtoken.ErrRevoked) {
t.Errorf("expected ErrRevoked, got %v", err)
}
if got := store.revoked[claims["jti"].(string)]; !got.Equal(authTokenEpoch.Add(authtoken.SessionLifetime)) {
t.Errorf("expected the revocation to last until the token's exp, got %v", got)
}
})
t.Run("Revoking one token leaves the others valid", func(t *testing.T) {
auth := newAuth(newMemoryRevocationStore())
revoked, _ := auth.Encode(map[string]any{"user_id": 1})
kept, _ := auth.Encode(map[string]any{"user_id": 1})
claims, _ := auth.Decode(revoked)
_ = auth.Revoke(claims)
if _, err := auth.Decode(kept); err != nil {
t.Errorf("expected the other token to stay valid, got %v", err)
}
})
t.Run("A token is rejected when the revocation store cannot answer", func(t *testing.T) {
store := newMemoryRevocationStore()
auth := newAuth(store)
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
store.failure = errors.New("database unavailable")
if _, err := auth.Decode(encoded); !errors.Is(err, authtoken.ErrInvalid) {
t.Errorf("expected ErrInvalid, got %v", err)
}
})
t.Run("Revoking surfaces a store failure", func(t *testing.T) {
store := newMemoryRevocationStore()
auth := newAuth(store)
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
claims, _ := auth.Decode(encoded)
store.failure = errors.New("database unavailable")
if err := auth.Revoke(claims); err == nil {
t.Error("expected an error")
}
})
t.Run("Revoking claims without a jti is a no-op", func(t *testing.T) {
store := newMemoryRevocationStore()
if err := newAuth(store).Revoke(authtoken.Claims{"user_id": float64(1), "exp": float64(authTokenEpoch.Unix())}); err != nil {
t.Fatal(err)
}
if len(store.revoked) != 0 {
t.Errorf("expected nothing revoked, got %v", store.revoked)
}
})
t.Run("Revoking claims without an exp is refused", func(t *testing.T) {
if err := newAuth(newMemoryRevocationStore()).Revoke(authtoken.Claims{"jti": "abc"}); !errors.Is(err, authtoken.ErrInvalid) {
t.Errorf("expected ErrInvalid, got %v", err)
}
})
t.Run("Without a store, revoking is a no-op and tokens stay valid", func(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
encoded, _ := auth.Encode(map[string]any{"user_id": 1})
claims, _ := auth.Decode(encoded)
if err := auth.Revoke(claims); err != nil {
t.Fatal(err)
}
if _, err := auth.Decode(encoded); err != nil {
t.Errorf("expected the token to stay valid, got %v", err)
}
})
}
func TestAuthTokenSizeBudget(t *testing.T) {
auth := authTokenAt(authTokenEpoch)
sized := func(target int) string {
for padding := 0; padding < 4096; padding++ {
encoded, _ := auth.Encode(map[string]any{"jti": "fixed", "pad": strings.Repeat("a", padding)})
if len(encoded) == target {
return encoded
}
}
t.Fatalf("no token of exactly %d bytes", target)
return ""
}
t.Run("A token of exactly 4096 bytes is accepted", func(t *testing.T) {
if _, err := auth.Decode(sized(4096)); err != nil {
t.Errorf("expected acceptance, got %v", err)
}
})
t.Run("A token of 4097 bytes is refused before it is parsed", func(t *testing.T) {
if _, err := auth.Decode(sized(4097)); !errors.Is(err, authtoken.ErrInvalid) {
t.Errorf("expected ErrInvalid, got %v", err)
}
})
}
@@ -79,6 +79,7 @@ func TestContainerUpdateGuidanceHTTP(t *testing.T) {
}{{"true", true}, {"TRUE", true}, {"false", false}, {"", false}, {"yes", false}}
for _, tc := range cases {
t.Run("value "+tc.value, func(t *testing.T) {
t.Setenv("JWT_SECRET", testJWTSecret)
t.Setenv("SMTP_PORT", "1")
t.Setenv("AUTH_RATE_LIMIT_MAX_ATTEMPTS", "1")
t.Setenv("AUTH_RATE_LIMIT_WINDOW_SECONDS", "1")
+105
View File
@@ -0,0 +1,105 @@
package test
import (
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func newCrossOriginProtectedHandler(publicURL string) (http.Handler, *int) {
reached := 0
handler := middleware.CrossOriginProtection(publicURL)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
reached++
w.WriteHeader(http.StatusOK)
}))
return handler, &reached
}
func sendCrossOriginRequest(handler http.Handler, method string, headers map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "https://cms.example.com/v1/user/invite", strings.NewReader(`{"email":"attacker@evil.example"}`))
req.Header.Set("Content-Type", "text/plain")
req.AddCookie(&http.Cookie{Name: "jwt", Value: "session"})
for name, value := range headers {
req.Header.Set(name, value)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func TestCrossOriginProtection(t *testing.T) {
refused := []struct {
name string
method string
headers map[string]string
}{
{"a cross-site form POST", http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}},
{"a same-site but cross-origin POST from a sibling subdomain", http.MethodPost, map[string]string{"Sec-Fetch-Site": "same-site", "Origin": "https://blog.example.com"}},
{"a cross-site DELETE", http.MethodDelete, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}},
{"an old browser's cross-origin POST without Sec-Fetch-Site", http.MethodPost, map[string]string{"Origin": "https://evil.example"}},
}
for _, tc := range refused {
t.Run("Refuses "+tc.name, func(t *testing.T) {
handler, reached := newCrossOriginProtectedHandler("https://cms.example.com")
rec := sendCrossOriginRequest(handler, tc.method, tc.headers)
if rec.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), apperr.CrossOriginErrorMessage) {
t.Errorf("expected the cross-origin message, got %q", rec.Body.String())
}
if *reached != 0 {
t.Error("the handler must not run")
}
})
}
allowed := []struct {
name string
method string
headers map[string]string
}{
{"a same-origin POST from the SPA", http.MethodPost, map[string]string{"Sec-Fetch-Site": "same-origin", "Origin": "https://cms.example.com"}},
{"a request typed in the address bar", http.MethodPost, map[string]string{"Sec-Fetch-Site": "none"}},
{"a non-browser client with neither header", http.MethodPost, nil},
{"a cross-site GET", http.MethodGet, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}},
{"a cross-site preflight", http.MethodOptions, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"}},
{"a cross-origin POST carrying an explicit Authorization header", http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://shop.example", "Authorization": "Bearer rcms_key"}},
{"an old browser's POST from the public URL behind a proxy that rewrites Host", http.MethodPost, map[string]string{"Origin": "https://cms.example.com", "Host": "127.0.0.1:8080"}},
}
for _, tc := range allowed {
t.Run("Allows "+tc.name, func(t *testing.T) {
handler, reached := newCrossOriginProtectedHandler("https://cms.example.com")
req := httptest.NewRequest(tc.method, "https://cms.example.com/v1/user/invite", nil)
for name, value := range tc.headers {
if name == "Host" {
req.Host = value
continue
}
req.Header.Set(name, value)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || *reached != 1 {
t.Errorf("expected the handler to run, got %d (reached %d)", rec.Code, *reached)
}
})
}
t.Run("An unparsable public URL still protects against cross-site requests", func(t *testing.T) {
handler, reached := newCrossOriginProtectedHandler("::not a url")
rec := sendCrossOriginRequest(handler, http.MethodPost, map[string]string{"Sec-Fetch-Site": "cross-site", "Origin": "https://evil.example"})
if rec.Code != http.StatusForbidden || *reached != 0 {
t.Errorf("expected 403 without reaching the handler, got %d (reached %d)", rec.Code, *reached)
}
})
}
@@ -35,7 +35,7 @@ const supportRecipeKey = "recipe"
var supportContentTypeKeys = []string{"created_at", "fields", "icon", "id", "key", "name", "updated_at"}
var supportFieldKeys = []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"}
var supportFieldKeys = []string{"default", "key", "label", "max", "min", "options", "relation", "required", "type", "unique"}
func newSupportEnv(t *testing.T) *contentTypeEnv {
t.Helper()
+2 -2
View File
@@ -12,8 +12,8 @@ import (
var (
contentTypeKeys = []string{"created_at", "fields", "icon", "id", "key", "name", "updated_at"}
fieldKeys = []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"}
entryKeys = []string{"content_type", "created_at", "created_by", "data", "id", "media", "updated_at", "updated_by"}
fieldKeys = []string{"default", "key", "label", "max", "min", "options", "relation", "required", "type", "unique"}
entryKeys = []string{"content_type", "created_at", "created_by", "data", "id", "media", "relations", "updated_at", "updated_by"}
)
func TestCreatingAContentType(t *testing.T) {
+1 -1
View File
@@ -628,7 +628,7 @@ func TestContentTypeStoredFormat(t *testing.T) {
keys = append(keys, key)
}
sort.Strings(keys)
expected := []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"}
expected := []string{"default", "key", "label", "max", "min", "options", "relation", "required", "type", "unique"}
if !reflect.DeepEqual(keys, expected) {
t.Errorf("stored keys %v", keys)
}
+27 -6
View File
@@ -106,17 +106,38 @@ func TestAuthenticateUserUseCase(t *testing.T) {
}
})
t.Run("unknown username never calls the hasher", func(t *testing.T) {
t.Run("unknown username spends the same hashing work as a wrong password", func(t *testing.T) {
repo := newMockUserRepo()
hasher := &spyHasher{}
uc := useCases.NewAuthenticateUserUseCase(repo, hasher, discardRecorder)
_, err := uc.Authenticate(testCtx, "bob", "hunter2example")
if !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("expected user.ErrUserNotFound, got %v", err)
for attempt := 1; attempt <= 2; attempt++ {
_, err := uc.Authenticate(testCtx, "bob", "hunter2example")
if !errors.Is(err, user.ErrUserNotFound) {
t.Errorf("attempt %d: expected user.ErrUserNotFound, got %v", attempt, err)
}
if hasher.compareCalls != attempt {
t.Errorf("attempt %d: expected %d Compare calls, got %d", attempt, attempt, hasher.compareCalls)
}
}
if hasher.compareCalls != 0 {
t.Errorf("expected the hasher's Compare to never be called, got %d calls", hasher.compareCalls)
if hasher.hashCalls != 1 {
t.Errorf("expected the decoy to be hashed once, got %d Hash calls", hasher.hashCalls)
}
})
t.Run("a repository failure other than not-found skips the decoy", func(t *testing.T) {
repo := newMockUserRepo()
repo.fail.failNextWith("GetByUsername", errStubHashFailure)
hasher := &spyHasher{}
uc := useCases.NewAuthenticateUserUseCase(repo, hasher, discardRecorder)
_, err := uc.Authenticate(testCtx, "bob", "hunter2example")
if !errors.Is(err, errStubHashFailure) {
t.Errorf("expected the repository error, got %v", err)
}
if hasher.compareCalls != 0 || hasher.hashCalls != 0 {
t.Errorf("expected no hashing, got %d Compare and %d Hash calls", hasher.compareCalls, hasher.hashCalls)
}
})
}
+727
View File
@@ -0,0 +1,727 @@
package test
import (
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"hash/crc32"
"image"
"image/color"
"image/jpeg"
"image/png"
"strings"
"testing"
"github.com/gen2brain/webp"
xwebp "golang.org/x/image/webp"
)
const metadataSecret = "GPS-48.8584N-2.2945E"
func metadataTestImage() *image.NRGBA {
img := image.NewNRGBA(image.Rect(0, 0, 8, 6))
for y := 0; y < 6; y++ {
for x := 0; x < 8; x++ {
img.Set(x, y, color.NRGBA{R: uint8(x * 30), G: uint8(y * 40), B: 90, A: 255})
}
}
return img
}
type exifByteOrder interface {
binary.ByteOrder
binary.AppendByteOrder
}
func exifTIFF(order exifByteOrder, orientation uint16) []byte {
marker := []byte("II")
if order.String() == binary.BigEndian.String() {
marker = []byte("MM")
}
text := append([]byte(metadataSecret), 0)
if len(text)%2 == 1 {
text = append(text, 0)
}
ifd := uint32(8 + len(text))
tiff := append(marker, 0, 0)
order.PutUint16(tiff[2:], 42)
tiff = order.AppendUint32(tiff, ifd)
tiff = append(tiff, text...)
tiff = order.AppendUint16(tiff, 2)
tiff = order.AppendUint16(tiff, 0x010F)
tiff = order.AppendUint16(tiff, 2)
tiff = order.AppendUint32(tiff, uint32(len(metadataSecret)+1))
tiff = order.AppendUint32(tiff, 8)
tiff = order.AppendUint16(tiff, 0x0112)
tiff = order.AppendUint16(tiff, 3)
tiff = order.AppendUint32(tiff, 1)
tiff = order.AppendUint16(tiff, orientation)
tiff = order.AppendUint16(tiff, 0)
return order.AppendUint32(tiff, 0)
}
func jpegSegmentBytes(marker byte, payload []byte) []byte {
segment := []byte{0xFF, marker}
segment = binary.BigEndian.AppendUint16(segment, uint16(len(payload)+2))
return append(segment, payload...)
}
func encodedJPEG(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
if err := jpeg.Encode(&buf, metadataTestImage(), &jpeg.Options{Quality: 90}); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func jpegWithMetadata(t *testing.T, orientation uint16, extra ...[]byte) []byte {
t.Helper()
plain := encodedJPEG(t)
out := append([]byte{}, plain[:2]...)
for _, segment := range extra {
out = append(out, segment...)
}
out = append(out, jpegSegmentBytes(0xE1, append([]byte("Exif\x00\x00"), exifTIFF(binary.LittleEndian, orientation)...))...)
out = append(out, jpegSegmentBytes(0xE1, []byte("http://ns.adobe.com/xap/1.0/\x00<x:xmpmeta>"+metadataSecret+"</x:xmpmeta>"))...)
out = append(out, jpegSegmentBytes(0xED, []byte("Photoshop 3.0\x008BIM"+metadataSecret))...)
out = append(out, jpegSegmentBytes(0xFE, []byte("comment "+metadataSecret))...)
out = append(out, jpegSegmentBytes(0xE2, []byte("MPF\x00"+metadataSecret))...)
out = append(out, jpegSegmentBytes(0xE2, []byte("ICC_PROFILE\x00\x01\x01profile-bytes"))...)
out = append(out, plain[2:]...)
return append(out, []byte("\xFF\xD8trailing image "+metadataSecret+"\xFF\xD9")...)
}
func pngChunkBytes(kind string, payload []byte) []byte {
chunk := binary.BigEndian.AppendUint32(nil, uint32(len(payload)))
chunk = append(chunk, kind...)
chunk = append(chunk, payload...)
return binary.BigEndian.AppendUint32(chunk, crc32.ChecksumIEEE(chunk[4:]))
}
func encodedPNG(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, metadataTestImage()); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func pngWithMetadata(t *testing.T, orientation uint16) []byte {
t.Helper()
plain := encodedPNG(t)
afterIHDR := 8 + 8 + 13 + 4
out := append([]byte{}, plain[:afterIHDR]...)
out = append(out, pngChunkBytes("eXIf", exifTIFF(binary.BigEndian, orientation))...)
out = append(out, pngChunkBytes("tEXt", []byte("Comment\x00"+metadataSecret))...)
out = append(out, pngChunkBytes("iTXt", []byte("XML:com.adobe.xmp\x00\x00\x00\x00\x00"+metadataSecret))...)
out = append(out, pngChunkBytes("zTXt", []byte("Author\x00\x00"+metadataSecret))...)
out = append(out, pngChunkBytes("tIME", []byte{0x07, 0xEA, 10, 22, 9, 0, 0})...)
out = append(out, plain[afterIHDR:]...)
return append(out, []byte("trailing "+metadataSecret)...)
}
func riffChunkBytes(kind string, payload []byte) []byte {
chunk := append([]byte(kind), binary.LittleEndian.AppendUint32(nil, uint32(len(payload)))...)
chunk = append(chunk, payload...)
if len(payload)%2 == 1 {
chunk = append(chunk, 0)
}
return chunk
}
func webpWithMetadata(t *testing.T, orientation uint16) []byte {
t.Helper()
var buf bytes.Buffer
if err := webp.Encode(&buf, metadataTestImage(), webp.Options{Lossless: true}); err != nil {
t.Fatal(err)
}
simple := buf.Bytes()
vp8x := []byte{0x08 | 0x04, 0, 0, 0}
vp8x = append(vp8x, 7, 0, 0, 5, 0, 0)
body := []byte("WEBP")
body = append(body, riffChunkBytes("VP8X", vp8x)...)
body = append(body, simple[12:]...)
body = append(body, riffChunkBytes("EXIF", exifTIFF(binary.LittleEndian, orientation))...)
body = append(body, riffChunkBytes("XMP ", []byte("<x:xmpmeta>"+metadataSecret+"</x:xmpmeta>!"))...)
out := append([]byte("RIFF"), binary.LittleEndian.AppendUint32(nil, uint32(len(body)))...)
out = append(out, body...)
return append(out, []byte("trailing "+metadataSecret)...)
}
func assertSamePixels(t *testing.T, want image.Image, got image.Image) {
t.Helper()
if want.Bounds() != got.Bounds() {
t.Fatalf("bounds changed: %v vs %v", want.Bounds(), got.Bounds())
}
for y := want.Bounds().Min.Y; y < want.Bounds().Max.Y; y++ {
for x := want.Bounds().Min.X; x < want.Bounds().Max.X; x++ {
if want.At(x, y) != got.At(x, y) {
t.Fatalf("pixel (%d,%d) changed: %v vs %v", x, y, want.At(x, y), got.At(x, y))
}
}
}
}
func assertNoSecret(t *testing.T, data []byte) {
t.Helper()
if bytes.Contains(data, []byte(metadataSecret)) {
t.Errorf("the metadata secret survived stripping")
}
}
func orientationOnlyEntry(orientation uint16) []byte {
entry := []byte{0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01}
return binary.BigEndian.AppendUint16(entry, orientation)
}
func TestMetadataStripperJPEG(t *testing.T) {
stripper := imaging.NewMetadataStripper()
t.Run("Drops EXIF, XMP, IPTC, comments, MPF and trailing images but keeps the pixels and the ICC profile", func(t *testing.T) {
original := jpegWithMetadata(t, 1)
stripped, err := stripper.Strip("image/jpeg", original)
if err != nil {
t.Fatal(err)
}
assertNoSecret(t, stripped)
if !bytes.Contains(stripped, []byte("ICC_PROFILE\x00")) {
t.Error("the ICC profile must be kept")
}
if bytes.Contains(stripped, []byte("Exif\x00\x00")) {
t.Error("an upright image needs no EXIF at all")
}
if !bytes.HasSuffix(stripped, []byte{0xFF, 0xD9}) || bytes.Count(stripped, []byte{0xFF, 0xD8}) != 1 {
t.Error("the output must end at the primary image's EOI")
}
want, _ := jpeg.Decode(bytes.NewReader(encodedJPEG(t)))
got, err := jpeg.Decode(bytes.NewReader(stripped))
if err != nil {
t.Fatalf("the stripped JPEG does not decode: %v", err)
}
assertSamePixels(t, want, got)
})
t.Run("Keeps only the orientation of a rotated photo, right after SOI", func(t *testing.T) {
stripped, err := stripper.Strip("image/jpeg", jpegWithMetadata(t, 6))
if err != nil {
t.Fatal(err)
}
assertNoSecret(t, stripped)
if !bytes.Equal(stripped[2:4], []byte{0xFF, 0xE1}) || !bytes.Contains(stripped[:64], orientationOnlyEntry(6)) {
t.Errorf("expected an orientation-only APP1 after SOI, got % x", stripped[:48])
}
if _, err := jpeg.Decode(bytes.NewReader(stripped)); err != nil {
t.Errorf("the stripped JPEG does not decode: %v", err)
}
})
t.Run("Puts the orientation after a leading JFIF header", func(t *testing.T) {
jfif := jpegSegmentBytes(0xE0, []byte("JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"))
stripped, err := stripper.Strip("image/jpeg", jpegWithMetadata(t, 8, jfif))
if err != nil {
t.Fatal(err)
}
next := 2 + len(jfif)
if !bytes.Equal(stripped[2:next], jfif) || !bytes.Equal(stripped[next:next+2], []byte{0xFF, 0xE1}) {
t.Errorf("expected JFIF then the orientation APP1, got % x", stripped[:next+4])
}
})
t.Run("A JPEG cut off inside its scan keeps what it has", func(t *testing.T) {
plain := encodedJPEG(t)
truncated := plain[:len(plain)-20]
stripped, err := stripper.Strip("image/jpeg", truncated)
if err != nil || !bytes.Equal(stripped, truncated) {
t.Errorf("expected the truncated scan kept as is, got %d bytes, %v", len(stripped), err)
}
})
malformed := map[string][]byte{
"not a JPEG": []byte("GIF89a"),
"a segment longer than the file": {0xFF, 0xD8, 0xFF, 0xE1, 0x10, 0x00, 'E'},
"a segment length below two": {0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x01},
"a missing marker": {0xFF, 0xD8, 0x00, 0x00},
"a header that stops early": {0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0, 0},
}
for name, data := range malformed {
t.Run("Refuses "+name, func(t *testing.T) {
if _, err := stripper.Strip("image/jpeg", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
}
}
func TestMetadataStripperPNG(t *testing.T) {
stripper := imaging.NewMetadataStripper()
t.Run("Drops text, time and EXIF chunks and trailing data but keeps the pixels", func(t *testing.T) {
stripped, err := stripper.Strip("image/png", pngWithMetadata(t, 1))
if err != nil {
t.Fatal(err)
}
assertNoSecret(t, stripped)
for _, kind := range []string{"eXIf", "tEXt", "iTXt", "zTXt", "tIME"} {
if bytes.Contains(stripped, []byte(kind)) {
t.Errorf("%s survived", kind)
}
}
if !bytes.Equal(stripped, encodedPNG(t)) {
t.Error("expected exactly the plain PNG back")
}
})
t.Run("Keeps only the orientation in an eXIf chunk right after IHDR", func(t *testing.T) {
stripped, err := stripper.Strip("image/png", pngWithMetadata(t, 3))
if err != nil {
t.Fatal(err)
}
assertNoSecret(t, stripped)
afterIHDR := 8 + 8 + 13 + 4
if string(stripped[afterIHDR+4:afterIHDR+8]) != "eXIf" || !bytes.Contains(stripped, orientationOnlyEntry(3)) {
t.Errorf("expected an orientation-only eXIf after IHDR")
}
want, _ := png.Decode(bytes.NewReader(encodedPNG(t)))
got, err := png.Decode(bytes.NewReader(stripped))
if err != nil {
t.Fatalf("the stripped PNG does not decode: %v", err)
}
assertSamePixels(t, want, got)
})
plain := func(t *testing.T) []byte { return encodedPNG(t) }
malformed := map[string]func(t *testing.T) []byte{
"not a PNG": func(*testing.T) []byte { return []byte("GIF89a") },
"a PNG without IEND": func(t *testing.T) []byte { p := plain(t); return p[:len(p)-12] },
"a chunk longer than the file": func(t *testing.T) []byte { p := plain(t); binary.BigEndian.PutUint32(p[8:], 1<<30); return p },
"a chunk header that stops early": func(t *testing.T) []byte { return append(plain(t)[:8], 0, 0, 0) },
}
for name, build := range malformed {
t.Run("Refuses "+name, func(t *testing.T) {
if _, err := stripper.Strip("image/png", build(t)); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
}
}
func TestMetadataStripperWebP(t *testing.T) {
stripper := imaging.NewMetadataStripper()
t.Run("Drops XMP and EXIF, clears their flags and keeps the pixels", func(t *testing.T) {
original := webpWithMetadata(t, 1)
stripped, err := stripper.Strip("image/webp", original)
if err != nil {
t.Fatal(err)
}
assertNoSecret(t, stripped)
if bytes.Contains(stripped, []byte("EXIF")) || bytes.Contains(stripped, []byte("XMP ")) {
t.Error("metadata chunks survived")
}
if flags := stripped[20]; flags&0x0C != 0 {
t.Errorf("expected the EXIF and XMP flags cleared, got %#x", flags)
}
if size := binary.LittleEndian.Uint32(stripped[4:]); int(size) != len(stripped)-8 {
t.Errorf("RIFF size %d does not match %d bytes", size, len(stripped)-8)
}
want, _ := xwebp.Decode(bytes.NewReader(original))
got, err := xwebp.Decode(bytes.NewReader(stripped))
if err != nil {
t.Fatalf("the stripped WebP does not decode: %v", err)
}
assertSamePixels(t, want, got)
})
t.Run("Keeps only the orientation of a rotated image", func(t *testing.T) {
stripped, err := stripper.Strip("image/webp", webpWithMetadata(t, 8))
if err != nil {
t.Fatal(err)
}
assertNoSecret(t, stripped)
if flags := stripped[20]; flags&0x0C != 0x08 {
t.Errorf("expected only the EXIF flag set, got %#x", flags)
}
if !bytes.Contains(stripped, orientationOnlyEntry(8)) {
t.Error("expected an orientation-only EXIF chunk")
}
if _, err := xwebp.Decode(bytes.NewReader(stripped)); err != nil {
t.Errorf("the stripped WebP does not decode: %v", err)
}
})
t.Run("A simple WebP without extended header comes back unchanged", func(t *testing.T) {
var buf bytes.Buffer
if err := webp.Encode(&buf, metadataTestImage(), webp.Options{Lossless: true}); err != nil {
t.Fatal(err)
}
stripped, err := stripper.Strip("image/webp", buf.Bytes())
if err != nil || !bytes.Equal(stripped, buf.Bytes()) {
t.Errorf("expected the same bytes back, got %v", err)
}
})
malformed := map[string][]byte{
"not a WebP": []byte("RIFF\x04\x00\x00\x00WAVE"),
"a RIFF size beyond the file": []byte("RIFF\xff\x00\x00\x00WEBP"),
"a chunk longer than the file": append([]byte("RIFF\x10\x00\x00\x00WEBPVP8L"), 0xff, 0, 0, 0),
"a container with no chunks": []byte("RIFF\x04\x00\x00\x00WEBP"),
"an empty extended header": append([]byte("RIFF\x0c\x00\x00\x00WEBPVP8X"), 0, 0, 0, 0),
"a chunk header that stops early": []byte("RIFF\x08\x00\x00\x00WEBPVP8X"),
}
for name, data := range malformed {
t.Run("Refuses "+name, func(t *testing.T) {
if _, err := stripper.Strip("image/webp", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
}
}
func TestMetadataStripperLeavesSVGAlone(t *testing.T) {
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><metadata>keep</metadata></svg>`)
stripped, err := imaging.NewMetadataStripper().Strip("image/svg+xml", svg)
if err != nil || !bytes.Equal(stripped, svg) {
t.Errorf("expected the SVG untouched, got %v", err)
}
}
func TestUploadStoresTheStrippedImage(t *testing.T) {
repo := newMockImageRepo()
store := newMockImageStorage()
uc := useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper())
original := jpegWithMetadata(t, 6)
created, err := uc.CreateImage(testCtx, domainImage.ImageInput{
Content: bytes.NewReader(original),
Filename: "holiday.jpg",
Size: int64(len(original)),
ContentType: "image/jpeg",
})
if err != nil {
t.Fatal(err)
}
stored := store.lastContent
assertNoSecret(t, stored)
if created.Size != int64(len(stored)) {
t.Errorf("expected the recorded size %d to match the stored %d bytes", created.Size, len(stored))
}
sum := sha256.Sum256(stored)
if created.SHA256 != hex.EncodeToString(sum[:]) {
t.Error("expected the checksum of the stored bytes")
}
if created.Width != 8 || created.Height != 6 {
t.Errorf("expected 8x6, got %dx%d", created.Width, created.Height)
}
}
func TestUploadRefusesAnImageWhoseContainerIsBroken(t *testing.T) {
store := newMockImageStorage()
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), store, discardRecorder, imaging.NewMetadataStripper())
broken := encodedPNG(t)
broken = broken[:len(broken)-12]
_, err := uc.CreateImage(testCtx, domainImage.ImageInput{Content: bytes.NewReader(broken), Filename: "broken.png", ContentType: "image/png"})
if !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
if store.putCalls != 0 {
t.Error("nothing may be stored")
}
}
func hexBytes(t *testing.T, value string) []byte {
t.Helper()
decoded, err := hex.DecodeString(strings.ReplaceAll(value, " ", ""))
if err != nil {
t.Fatal(err)
}
return decoded
}
func jpegWithExif(tiff []byte) []byte {
out := []byte{0xFF, 0xD8}
out = append(out, jpegSegmentBytes(0xE1, append([]byte("Exif\x00\x00"), tiff...))...)
return append(out, 0xFF, 0xD9)
}
func TestMetadataStripperJPEGStructure(t *testing.T) {
stripper := imaging.NewMetadataStripper()
kept := map[string]string{
"the smallest JPEG, SOI then EOI": "FFD8 FFD9",
"standalone TEM and RST markers between segments": "FFD8 FF01 FFD0 FFD7 FFD9",
"restart markers and stuffed bytes inside a scan": "FFD8 FFDA 0002 11 FF00 22 FFD0 33 FFD7 44 FFD9",
"a scan cut off right after a 0xFF byte": "FFD8 FFDA 0002 11 22 FF",
"a scan cut off right after a stuffed byte": "FFD8 FFDA 0002 11 FF00",
"an Adobe APP14 segment": "FFD8 FFEE 0007 41646F6265 FFD9",
"an ICC profile in APP2": "FFD8 FFE2 000E 4943435F50524F46494C4500 FFD9",
"a quantisation table, which is not metadata at all": "FFD8 FFDB 0003 00 FFD9",
}
for name, value := range kept {
t.Run("Keeps "+name, func(t *testing.T) {
data := hexBytes(t, value)
stripped, err := stripper.Strip("image/jpeg", data)
if err != nil || !bytes.Equal(stripped, data) {
t.Errorf("expected the bytes back unchanged, got % X, %v", stripped, err)
}
})
}
removed := map[string]string{
"fill bytes before a marker": "FFD8 FFFFFF FFD9",
"an empty comment": "FFD8 FFFE 0002 FFD9",
"an APP3 segment": "FFD8 FFE3 0003 00 FFD9",
"an APP12 segment": "FFD8 FFEC 0003 00 FFD9",
"an APP15 segment": "FFD8 FFEF 0003 00 FFD9",
"a FlashPix APP2 segment": "FFD8 FFE2 0006 46505852 FFD9",
"an XMP APP1 segment": "FFD8 FFE1 0006 68747470 FFD9",
}
for name, value := range removed {
t.Run("Drops "+name, func(t *testing.T) {
stripped, err := stripper.Strip("image/jpeg", hexBytes(t, value))
if err != nil || !bytes.Equal(stripped, []byte{0xFF, 0xD8, 0xFF, 0xD9}) {
t.Errorf("expected only SOI and EOI, got % X, %v", stripped, err)
}
})
}
refused := map[string]string{
"only three bytes": "FFD8 FF",
"fill bytes up to the end": "FFD8 FFFF",
"a segment length cut in half": "FFD8 FFE1 00",
"a segment whose length runs past EOF": "FFD8 FFE1 0004 00",
"no EOI after the header": "FFD8 FFDB 0003 00",
}
for name, value := range refused {
t.Run("Refuses "+name, func(t *testing.T) {
if _, err := stripper.Strip("image/jpeg", hexBytes(t, value)); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
}
}
func TestMetadataStripperReadsOnlyAWellFormedOrientation(t *testing.T) {
stripper := imaging.NewMetadataStripper()
valid := exifTIFF(binary.BigEndian, 8)
ifd := int(binary.BigEndian.Uint32(valid[4:]))
withValue := func(value uint16) []byte {
tiff := bytes.Clone(valid)
binary.BigEndian.PutUint16(tiff[ifd+2+12+8:], value)
return tiff
}
withType := func(kind uint16) []byte {
tiff := bytes.Clone(valid)
binary.BigEndian.PutUint16(tiff[ifd+2+12+2:], kind)
return tiff
}
withIFD := func(offset uint32) []byte {
tiff := bytes.Clone(valid)
binary.BigEndian.PutUint32(tiff[4:], offset)
return tiff
}
withCount := func(count uint16) []byte {
tiff := bytes.Clone(valid)
binary.BigEndian.PutUint16(tiff[ifd:], count)
return tiff[:ifd+2+12]
}
cases := []struct {
name string
tiff []byte
want uint16
}{
{"a valid big-endian orientation", valid, 8},
{"a valid little-endian orientation", exifTIFF(binary.LittleEndian, 5), 5},
{"the smallest orientation that needs rotating", withValue(2), 2},
{"an orientation of 0", withValue(0), 0},
{"an orientation of 9", withValue(9), 0},
{"an orientation stored as a LONG", withType(4), 0},
{"a TIFF shorter than its header", valid[:7], 0},
{"an unknown byte order", append([]byte("XX"), valid[2:]...), 0},
{"a wrong magic number", append([]byte("MM\x00\x2B"), valid[4:]...), 0},
{"an IFD offset inside the header", withIFD(4), 0},
{"an IFD offset on the last byte", withIFD(uint32(len(valid) - 1)), 0},
{"an IFD offset two bytes before the end", withIFD(uint32(len(valid) - 2)), 0},
{"an entry count larger than the entries present", withCount(3), 0},
{"a TIFF cut inside the orientation entry", valid[:ifd+2+12+11], 0},
{"a TIFF that ends right after the orientation entry", valid[:ifd+2+24], 8},
{"an IFD that starts right after the header", hexBytes(t, "4D4D002A 00000008 0001 0112 0003 00000001 0003 0000 00000000"), 3},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
stripped, err := stripper.Strip("image/jpeg", jpegWithExif(tc.tiff))
if err != nil {
t.Fatal(err)
}
hasOrientation := bytes.Contains(stripped, []byte("Exif\x00\x00"))
if tc.want == 0 && hasOrientation {
t.Errorf("expected no orientation kept, got % X", stripped)
}
if tc.want != 0 && !bytes.Contains(stripped, orientationOnlyEntry(tc.want)) {
t.Errorf("expected orientation %d kept, got % X", tc.want, stripped)
}
})
}
}
func TestMetadataStripperPNGStructure(t *testing.T) {
stripper := imaging.NewMetadataStripper()
plain := encodedPNG(t)
iend := plain[len(plain)-12:]
body := plain[:len(plain)-12]
t.Run("Keeps an empty ancillary chunk it does not know", func(t *testing.T) {
data := append(append(bytes.Clone(body), pngChunkBytes("abCd", nil)...), iend...)
stripped, err := stripper.Strip("image/png", data)
if err != nil || !bytes.Equal(stripped, data) {
t.Errorf("expected the bytes back unchanged, got %v", err)
}
})
t.Run("Refuses a chunk one byte longer than the file", func(t *testing.T) {
data := bytes.Clone(plain)
binary.BigEndian.PutUint32(data[len(data)-12:], 1)
if _, err := stripper.Strip("image/png", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
t.Run("Refuses an IEND missing its last CRC byte", func(t *testing.T) {
if _, err := stripper.Strip("image/png", plain[:len(plain)-1]); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
t.Run("Puts a kept orientation right after IHDR even when IHDR is followed by other chunks", func(t *testing.T) {
afterIHDR := 8 + 8 + 13 + 4
data := append(bytes.Clone(plain[:afterIHDR]), pngChunkBytes("gAMA", []byte{0, 0, 0xB1, 0x8F})...)
data = append(data, pngChunkBytes("eXIf", exifTIFF(binary.LittleEndian, 6))...)
data = append(data, plain[afterIHDR:]...)
stripped, err := stripper.Strip("image/png", data)
if err != nil {
t.Fatal(err)
}
if string(stripped[afterIHDR+4:afterIHDR+8]) != "eXIf" || string(stripped[afterIHDR+12+26+4:afterIHDR+12+26+8]) != "gAMA" {
t.Errorf("expected eXIf then gAMA after IHDR")
}
if _, err := png.Decode(bytes.NewReader(stripped)); err != nil {
t.Errorf("the stripped PNG does not decode: %v", err)
}
})
t.Run("Refuses a rotated PNG with no IHDR to put the orientation after", func(t *testing.T) {
data := append(bytes.Clone(pngSignatureBytes), pngChunkBytes("eXIf", exifTIFF(binary.BigEndian, 6))...)
data = append(data, pngChunkBytes("IEND", nil)...)
if _, err := stripper.Strip("image/png", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
}
var pngSignatureBytes = []byte("\x89PNG\r\n\x1a\n")
func riffChunkKinds(t *testing.T, data []byte) []string {
t.Helper()
if int(binary.LittleEndian.Uint32(data[4:]))+8 != len(data) {
t.Fatalf("RIFF size %d does not match %d bytes", binary.LittleEndian.Uint32(data[4:]), len(data)-8)
}
var kinds []string
for pos := 12; pos < len(data); {
size := int(binary.LittleEndian.Uint32(data[pos+4:]))
kinds = append(kinds, string(data[pos:pos+4]))
pos += 8 + size + size%2
if pos > len(data) {
t.Fatalf("chunk %s runs past the end", kinds[len(kinds)-1])
}
}
return kinds
}
func TestMetadataStripperWebPStructure(t *testing.T) {
stripper := imaging.NewMetadataStripper()
t.Run("The rotated output is a well-formed chain of chunks", func(t *testing.T) {
stripped, err := stripper.Strip("image/webp", webpWithMetadata(t, 6))
if err != nil {
t.Fatal(err)
}
if got := strings.Join(riffChunkKinds(t, stripped), ","); got != "VP8X,VP8L,EXIF" {
t.Errorf("expected VP8X,VP8L,EXIF, got %s", got)
}
})
t.Run("An upright output keeps only the image chunks", func(t *testing.T) {
stripped, err := stripper.Strip("image/webp", webpWithMetadata(t, 1))
if err != nil {
t.Fatal(err)
}
if got := strings.Join(riffChunkKinds(t, stripped), ","); got != "VP8X,VP8L" {
t.Errorf("expected VP8X,VP8L, got %s", got)
}
})
t.Run("Keeps an empty chunk it does not know at the very end", func(t *testing.T) {
original := webpWithMetadata(t, 1)
declared := int(binary.LittleEndian.Uint32(original[4:])) + 8
data := append(bytes.Clone(original[:declared]), riffChunkBytes("ABCD", nil)...)
binary.LittleEndian.PutUint32(data[4:], uint32(len(data)-8))
stripped, err := stripper.Strip("image/webp", data)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(riffChunkKinds(t, stripped), ","); got != "VP8X,VP8L,ABCD" {
t.Errorf("expected VP8X,VP8L,ABCD, got %s", got)
}
})
t.Run("Refuses an odd-sized chunk missing its padding byte", func(t *testing.T) {
original := webpWithMetadata(t, 1)
declared := int(binary.LittleEndian.Uint32(original[4:])) + 8
data := append(bytes.Clone(original[:declared]), "ABCD\x01\x00\x00\x00X"...)
binary.LittleEndian.PutUint32(data[4:], uint32(len(data)-8))
if _, err := stripper.Strip("image/webp", data); !errors.Is(err, domainImage.ErrImageUnreadable) {
t.Errorf("expected ErrImageUnreadable, got %v", err)
}
})
}
+2 -1
View File
@@ -2,6 +2,7 @@ package test
import (
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"io"
@@ -280,7 +281,7 @@ var _ imageDomain.Repository = &mockImageRepo{}
func TestImageUseCases(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo())
t.Run("Create Image", func(t *testing.T) {
+6 -5
View File
@@ -2,6 +2,7 @@ package test
import (
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
@@ -39,7 +40,7 @@ func TestImageUploadRules(t *testing.T) {
for _, contentType := range []string{"image/png", "image/jpeg", "image/webp", "image/svg+xml"} {
t.Run(contentType, func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
result, err := createUc.CreateImage(testCtx, inputOf(paddedTo(validBytesFor(t, contentType), int(imageDomain.MaxImageSize)), contentType))
@@ -58,7 +59,7 @@ func TestImageUploadRules(t *testing.T) {
t.Run("CreateImage rejects a disallowed content type before reaching the repository", func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
_, err := createUc.CreateImage(testCtx, inputOf([]byte("%PDF"), "application/pdf"))
@@ -72,7 +73,7 @@ func TestImageUploadRules(t *testing.T) {
t.Run("CreateImage rejects a file that exceeds the size limit", func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
_, err := createUc.CreateImage(testCtx, inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)+1), "image/png"))
@@ -86,7 +87,7 @@ func TestImageUploadRules(t *testing.T) {
t.Run("A file exactly at the size limit is accepted, one byte over is not", func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
_, err := createUc.CreateImage(testCtx, inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)), "image/png"))
if err != nil {
@@ -108,7 +109,7 @@ func TestImageUploadRules(t *testing.T) {
t.Run("A repository failure unrelated to type or size still propagates unchanged", func(t *testing.T) {
repo := newMockImageRepo()
repo.fail.failNextWith("Create", errRepositoryFailure)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
_, err := createUc.CreateImage(testCtx, inputOf(pngBytes(t, 3, 2), "image/png"))
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/usage"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
@@ -128,7 +129,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
PurgeVariantCacheUseCase: useCases.NewPurgeVariantCacheUseCase(e.variants, e.images, e.files, e.files, e.storage, clock, recorder),
VariantUsageTracker: e.tracker,
CreateImageUseCase: useCases.NewCreateImageUseCase(e.images, e.storage, recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(e.images, e.storage, recorder, imaging.NewMetadataStripper()),
ListMediaUseCase: useCases.NewListMediaUseCase(e.images, e.articles, e.users, e.files, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(e.images, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
+2 -1
View File
@@ -7,6 +7,7 @@ import (
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
@@ -73,7 +74,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder, imaging.NewMetadataStripper()),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
+4 -3
View File
@@ -3,6 +3,7 @@ package test
import (
"RenewCMS/api/pkg/apperr"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
@@ -992,7 +993,7 @@ func TestMediaMerging(t *testing.T) {
func TestMediaSentinels(t *testing.T) {
t.Run("Create reports unreadable bytes as ErrImageUnreadable", func(t *testing.T) {
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder)
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
for _, contentType := range domainImage.AllowedContentTypes() {
_, err := uc.CreateImage(testCtx, inputOf([]byte("not an image"), contentType))
@@ -1003,7 +1004,7 @@ func TestMediaSentinels(t *testing.T) {
})
t.Run("An svg without an svg root is unreadable", func(t *testing.T) {
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder)
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
_, err := uc.CreateImage(testCtx, inputOf([]byte("<html/>"), "image/svg+xml"))
@@ -1013,7 +1014,7 @@ func TestMediaSentinels(t *testing.T) {
})
t.Run("Bytes of another raster format than the declared type are unreadable", func(t *testing.T) {
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder)
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder, imaging.NewMetadataStripper())
_, err := uc.CreateImage(testCtx, inputOf(jpegBytes(t, 3, 2), "image/png"))
+7 -6
View File
@@ -2,6 +2,7 @@ package test
import (
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
"RenewCMS/internal/infrastructure/storage"
@@ -35,7 +36,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) {
t.Run("The row points at the stored name and the blob holds the bytes", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper()))
if !strings.HasPrefix(created.Path, domainImage.PublicPathPrefix) {
t.Errorf("unexpected path %q", created.Path)
@@ -49,7 +50,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
store.failPut = errRepositoryFailure
_, err := useCases.NewCreateImageUseCase(repo, store, discardRecorder).CreateImage(testCtx, pngUpload(t))
_, err := useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper()).CreateImage(testCtx, pngUpload(t))
if !errors.Is(err, errRepositoryFailure) || repo.createCalls != 0 {
t.Errorf("got %v with %d Create calls", err, repo.createCalls)
@@ -60,7 +61,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
repo.fail.failNextWith("Create", errRepositoryFailure)
_, err := useCases.NewCreateImageUseCase(repo, store, discardRecorder).CreateImage(testCtx, pngUpload(t))
_, err := useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper()).CreateImage(testCtx, pngUpload(t))
if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected the repository failure, got %v", err)
@@ -74,7 +75,7 @@ func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) {
func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
t.Run("Both disappear", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper()))
if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()).Delete(testCtx, created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
@@ -96,7 +97,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
t.Run("A blob failure keeps the row so the delete can be retried", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper()))
store.failDelete = errRepositoryFailure
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()).Delete(testCtx, created.ID)
@@ -109,7 +110,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
func TestMergeMediaRemovesTheMergedBlobs(t *testing.T) {
repo, articles, store := newMockImageRepo(), newMockArticleRepo(), newMockImageStorage()
create := useCases.NewCreateImageUseCase(repo, store, discardRecorder)
create := useCases.NewCreateImageUseCase(repo, store, discardRecorder, imaging.NewMetadataStripper())
kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create)
result, err := newTestMergeMedia(repo, articles, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
+1 -1
View File
@@ -447,7 +447,7 @@ func startupEnvironment(t *testing.T, extra ...string) []string {
dataDir := t.TempDir()
return append(emptyEnvironment(),
append([]string{
"ENVIRONMENT=development", "PORT=" + freeTCPPort(t), "PUBLIC_URL=http://localhost", "JWT_SECRET=test-secret-for-startup",
"ENVIRONMENT=development", "PORT=" + freeTCPPort(t), "PUBLIC_URL=http://localhost", "JWT_SECRET=" + testJWTSecret,
"SMTP_LOGIN=a@example.com", "SMTP_SENDER=a@example.com", "SMTP_PASSWORD=x", "SMTP_HOST=localhost", "SMTP_PORT=2525",
"DB_FILE=" + filepath.Join(dataDir, "renewcms.db"), "UPLOAD_DIR=" + filepath.Join(dataDir, "uploads"),
"UPDATE_FEED_URL=https://git.example",
+1 -1
View File
@@ -156,7 +156,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
func setJwtCookie(t *testing.T, req *http.Request, token *authtoken.Auth, userId uint32) {
t.Helper()
tokenString, err := token.Encode(map[string]any{"user_id": userId})
tokenString, err := token.Encode(map[string]any{"user_id": userId, "token_version": 0})
if err != nil {
t.Fatalf("failed to encode jwt: %v", err)
}
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff. Show more