Compare commits

...
5 Commits
104 changed files with 8165 additions and 488 deletions

No files matched your search

+21
View File
@@ -16,3 +16,24 @@ AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES=
UPDATE_FEED_URL=https://git.floriansylvain.fr
RENEWCMS_CONTAINER=false
DB_DRIVER=sqlite
DATA_DIR=
DB_HOST=
DB_PORT=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_TLS=false
DB_SSLMODE=disable
DB_MAX_OPEN_CONNS=10
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME_SECONDS=300
STORAGE_DRIVER=local
S3_BUCKET=
S3_REGION=us-east-1
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_KEY_PREFIX=
S3_PATH_STYLE=false
S3_TLS=true
+122
View File
@@ -10,6 +10,7 @@ vars:
env:
CGO_ENABLED: '0'
__COMPAT_LAYER: RunAsInvoker
tasks:
default:
@@ -46,6 +47,127 @@ tasks:
cmds:
- go test -tags nofrontend ./test/... -count=1 {{.CLI_ARGS}}
test:mysql:
desc: Run the MySQL tests against real servers (mysql:8.4 then mariadb:11 in Docker, removed afterwards)
cmds:
- cmd: |
PASSWORD=renewcms-test-root
STATUS=0
run_engine() {
NAME="renewcms-test-$1"
IMAGE="$2"
docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -e MYSQL_ROOT_PASSWORD="$PASSWORD" -p 127.0.0.1::3306 "$IMAGE" >/dev/null || return 1
MAPPING="$(docker port "$NAME" 3306/tcp)"
PORT="${MAPPING##*:}"
READY=no
TRIES=0
while [ "$TRIES" -lt 90 ]; do
if docker exec "$NAME" sh -c "\$(command -v mariadb || command -v mysql) -h127.0.0.1 -uroot -p$PASSWORD -e 'SELECT 1'" >/dev/null 2>&1; then
READY=yes
break
fi
TRIES=$((TRIES + 1))
sleep 2
done
if [ "$READY" != yes ]; then
echo "$IMAGE did not become ready" >&2
docker logs "$NAME" 2>&1 | tail -n 20 >&2
docker rm -f "$NAME" >/dev/null 2>&1
return 1
fi
echo "== $IMAGE on 127.0.0.1:$PORT"
RENEWCMS_TEST_MYSQL_DSN="root:$PASSWORD@tcp(127.0.0.1:$PORT)/mysql" go test -tags nofrontend ./test/... -run MySQL -count=1 {{.CLI_ARGS}}
RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1
return $RESULT
}
run_engine mysql mysql:8.4 || STATUS=1
run_engine mariadb mariadb:11 || STATUS=1
exit $STATUS
silent: true
test:postgres:
desc: Run the PostgreSQL tests against real servers (postgres:16 then postgres:17 in Docker, removed afterwards)
cmds:
- cmd: |
PASSWORD=renewcms-test-postgres
STATUS=0
run_engine() {
NAME="renewcms-test-postgres-$1"
IMAGE="postgres:$1"
docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -e POSTGRES_PASSWORD="$PASSWORD" -p 127.0.0.1::5432 "$IMAGE" >/dev/null || return 1
MAPPING="$(docker port "$NAME" 5432/tcp | head -n 1)"
PORT="${MAPPING##*:}"
READY=no
TRIES=0
while [ "$TRIES" -lt 90 ]; do
if docker exec "$NAME" pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && docker exec "$NAME" psql -h 127.0.0.1 -U postgres -c 'SELECT 1' >/dev/null 2>&1; then
READY=yes
break
fi
TRIES=$((TRIES + 1))
sleep 2
done
if [ "$READY" != yes ]; then
echo "$IMAGE did not become ready" >&2
docker logs "$NAME" 2>&1 | tail -n 20 >&2
docker rm -f "$NAME" >/dev/null 2>&1
return 1
fi
echo "== $IMAGE on 127.0.0.1:$PORT"
RENEWCMS_TEST_POSTGRES_DSN="postgres://postgres:$PASSWORD@127.0.0.1:$PORT/postgres?sslmode=disable" go test -tags nofrontend ./test/... -run Postgres -count=1 {{.CLI_ARGS}}
RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1
return $RESULT
}
run_engine 16 || STATUS=1
run_engine 17 || STATUS=1
exit $STATUS
silent: true
test:s3:
desc: Run the S3 storage integration test against a throwaway SeaweedFS in Docker (skipped when Docker is unavailable)
cmds:
- cmd: |
if ! docker info >/dev/null 2>&1; then
echo "Docker is not available, skipping the S3 integration test"
exit 0
fi
export MSYS_NO_PATHCONV=1
NAME=renewcms-test-seaweedfs
IMAGE=chrislusf/seaweedfs:4.48
BUCKET=renewcms-test
ACCESS_KEY=renewcms-test
SECRET_KEY=renewcms-test-secret
docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -e AWS_ACCESS_KEY_ID="$ACCESS_KEY" -e AWS_SECRET_ACCESS_KEY="$SECRET_KEY" -p 127.0.0.1::8333 "$IMAGE" server -dir=/data -s3 -s3.port=8333 >/dev/null || exit 1
MAPPING="$(docker port "$NAME" 8333/tcp | head -n 1)"
PORT="${MAPPING##*:}"
READY=no
TRIES=0
while [ "$TRIES" -lt 60 ]; do
if docker run --rm --network "container:$NAME" --entrypoint sh "$IMAGE" -c "echo 's3.bucket.create -name $BUCKET' | timeout 30 weed shell -master=127.0.0.1:9333 && echo s3.bucket.list | timeout 30 weed shell -master=127.0.0.1:9333 | grep -q $BUCKET" >/dev/null 2>&1; then
READY=yes
break
fi
TRIES=$((TRIES + 1))
sleep 2
done
if [ "$READY" != yes ]; then
echo "SeaweedFS did not become ready" >&2
docker logs "$NAME" 2>&1 | tail -n 20 >&2
docker rm -f "$NAME" >/dev/null 2>&1
exit 1
fi
echo "== SeaweedFS on 127.0.0.1:$PORT"
RENEWCMS_TEST_S3_ENDPOINT="http://127.0.0.1:$PORT" RENEWCMS_TEST_S3_BUCKET="$BUCKET" RENEWCMS_TEST_S3_ACCESS_KEY="$ACCESS_KEY" RENEWCMS_TEST_S3_SECRET_KEY="$SECRET_KEY" go test -tags nofrontend ./test/... -run S3StorageIntegration -count=1 -v {{.CLI_ARGS}}
RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1
exit $RESULT
silent: true
cover:
desc: Go test coverage of the use cases → tmp/coverage.out
cmds:
+109 -43
View File
@@ -13,13 +13,13 @@ import (
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/persistence/models"
"RenewCMS/internal/infrastructure/persistence/database"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/scheduler"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"crypto/ed25519"
"fmt"
"log"
"net/http"
"os"
@@ -28,9 +28,7 @@ import (
"sync"
"time"
"github.com/glebarez/sqlite"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm"
)
type UseCases struct {
@@ -60,6 +58,7 @@ type UseCases struct {
AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
OpenMediaUseCase *useCases.OpenMediaUseCase
ListMediaUseCase *useCases.ListMediaUseCase
RenameMediaUseCase *useCases.RenameMediaUseCase
DeleteMediaUseCase *useCases.DeleteMediaUseCase
@@ -103,7 +102,6 @@ type Repositories struct {
}
const (
sqliteBusyTimeoutMs = 5000
updateStatusFileName = "update-status.json"
updateJobFileName = "update-job.json"
updateHelperFileName = "update-helper.json"
@@ -111,32 +109,94 @@ const (
backupsDirName = "backups"
)
func SQLiteDSN(dbName string) string {
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs)
func imageStorageConfig(settings config.Settings) storage.Config {
if settings.Storage.Driver == config.StorageDriverS3 {
s3 := settings.Storage.S3
return storage.Config{
Driver: storage.DriverS3,
S3: storage.S3Config{
Bucket: s3.Bucket,
Region: s3.Region,
Endpoint: s3.Endpoint,
AccessKeyID: s3.AccessKeyID,
SecretAccessKey: s3.SecretAccessKey,
KeyPrefix: s3.KeyPrefix,
PathStyle: s3.PathStyle,
TLS: s3.TLS,
},
}
}
return storage.Config{Driver: storage.DriverLocal, Location: settings.UploadDir}
}
func getDb(dbName string, uploadDir string) *gorm.DB {
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil {
panic("Unable to create necessary subdirectories: " + err.Error())
func openImageStorage(settings config.Settings) domainImage.Storage {
imageStorage, err := storage.Open(imageStorageConfig(settings))
if err != nil {
panic("Unable to open the image storage: " + err.Error())
}
db, err := gorm.Open(sqlite.Open(SQLiteDSN(dbName)), &gorm.Config{})
return imageStorage
}
func databaseConfig(settings config.Settings) database.Config {
switch settings.Database.Driver {
case config.DatabaseDriverMySQL:
return mysqlDatabaseConfig(settings.Database)
case config.DatabaseDriverPostgres:
return postgresDatabaseConfig(settings.Database)
case "", config.DatabaseDriverSQLite:
return database.Config{Driver: database.DriverSQLite, Source: settings.DBFile}
default:
return database.Config{Driver: settings.Database.Driver}
}
}
func mysqlDatabaseConfig(settings config.DatabaseSettings) database.Config {
return database.Config{
Driver: database.DriverMySQL,
Source: database.MySQLDSN(database.MySQLConfig{
Host: settings.Host,
Port: settings.Port,
User: settings.User,
Password: settings.Password,
Name: settings.Name,
TLS: settings.TLS,
}),
Pool: &database.PoolSettings{
MaxOpenConns: settings.MaxOpenConns,
MaxIdleConns: settings.MaxIdleConns,
ConnMaxLifetime: settings.ConnMaxLifetime,
},
}
}
func postgresDatabaseConfig(settings config.DatabaseSettings) database.Config {
return database.Config{
Driver: database.DriverPostgres,
Source: database.PostgresDSN(database.PostgresConfig{
Host: settings.Host,
Port: settings.Port,
User: settings.User,
Password: settings.Password,
Name: settings.Name,
SSLMode: settings.SSLMode,
}),
Pool: &database.PoolSettings{
MaxOpenConns: settings.MaxOpenConns,
MaxIdleConns: settings.MaxIdleConns,
ConnMaxLifetime: settings.ConnMaxLifetime,
},
}
}
func openDatabase(settings config.Settings) *database.Connection {
connection, err := database.Connect(databaseConfig(settings))
if err != nil {
panic("Unable to open the database: " + err.Error())
}
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}); err != nil {
if err := database.Migrate(connection.DB); err != nil {
panic("Failed to migrate database: " + err.Error())
}
if err := persistence.BackfillUserRoles(db); err != nil {
panic("Failed to backfill user roles: " + err.Error())
}
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
panic("Failed to backfill image metadata: " + err.Error())
}
return db
return connection
}
func releasePublicKey() ed25519.PublicKey {
@@ -147,12 +207,13 @@ func releasePublicKey() ed25519.PublicKey {
return key
}
func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
dataDir := filepath.Dir(settings.DBFile)
func getRepositories(connection *database.Connection, settings config.Settings) *Repositories {
db := connection.DB
dataDir := settings.DataDir()
return &Repositories{
ArticleRepo: persistence.NewArticleRepository(db),
UserRepo: persistence.NewUserRepository(db),
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
ImageRepo: persistence.NewImageRepository(db),
InviteRepo: persistence.NewInviteRepository(db),
APIKeyRepo: persistence.NewAPIKeyRepository(db),
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
@@ -164,18 +225,18 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL),
SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
StagedRunner: update.NewExecStagedBinaryRunner(),
DatabaseBackup: update.NewSQLiteDatabaseBackup(db, filepath.Join(dataDir, backupsDirName), time.Now),
DatabaseBackup: connection.NewBackup(filepath.Join(dataDir, backupsDirName), time.Now),
UpdateResults: update.NewJSONResultStore(filepath.Join(dataDir, domainUpdate.ResultFileName)),
RestartRequests: update.NewJSONRestartRequestStore(filepath.Join(dataDir, domainUpdate.RestartRequestFileName)),
RunningInfo: update.NewJSONRunningInfoStore(filepath.Join(dataDir, domainUpdate.RunningInfoFileName)),
}
}
func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher()
renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator()
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo)
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage)
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default())
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge)
@@ -204,10 +265,11 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage),
MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
@@ -238,7 +300,7 @@ func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings) *
Verifier: repos.SignatureVerifier,
Runner: repos.StagedRunner,
Backup: repos.DatabaseBackup,
Staging: update.NewOSStagingArea(filepath.Join(filepath.Dir(settings.DBFile), updatesDirName)),
Staging: update.NewOSStagingArea(filepath.Join(settings.DataDir(), updatesDirName)),
Logger: log.Default(),
CurrentVersion: settings.Version,
Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH},
@@ -247,20 +309,24 @@ func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings) *
})
}
func InitContainer(settings config.Settings) (*UseCases, func() error) {
db := getDb(settings.DBFile, settings.UploadDir)
repos := getRepositories(db, settings)
return getUseCases(repos, settings), closeDb(db)
func backfillImageMetadata(imageRepo domainImage.Repository, imageStorage domainImage.Storage, settings config.Settings) error {
run := func(readable domainImage.Storage) error {
return useCases.NewBackfillImageMetadataUseCase(imageRepo, readable).Run()
}
if settings.Storage.Driver == config.StorageDriverS3 {
return storage.RunBackfillReportingReads(imageStorage, log.Default(), run)
}
return run(imageStorage)
}
func closeDb(db *gorm.DB) func() error {
return func() error {
sqlDb, err := db.DB()
if err != nil {
return err
}
return sqlDb.Close()
func InitContainer(settings config.Settings) (*UseCases, func() error) {
imageStorage := openImageStorage(settings)
connection := openDatabase(settings)
repos := getRepositories(connection, settings)
if err := backfillImageMetadata(repos.ImageRepo, imageStorage, settings); err != nil {
panic("Failed to backfill image metadata: " + err.Error())
}
return getUseCases(repos, imageStorage, settings), connection.Close
}
type BrowserUpdateCommands struct {
@@ -1,11 +1,13 @@
package staticimages
import (
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"io"
"io/fs"
"net/http"
"os"
"path"
"strconv"
"strings"
"time"
@@ -24,13 +26,13 @@ var contentTypeExtensions = map[string]string{
}
type Handler struct {
uploadDir fs.FS
openMedia *useCases.OpenMediaUseCase
modTime time.Time
}
func NewHandler(uploadDir string) *Handler {
func NewHandler(openMedia *useCases.OpenMediaUseCase) *Handler {
return &Handler{
uploadDir: os.DirFS(uploadDir),
openMedia: openMedia,
modTime: time.Now(),
}
}
@@ -39,18 +41,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
cleaned := cleanPath(requestedPath(r))
file, stat, ok := h.openFile(cleaned)
if !ok {
blob, err := h.openMedia.Open(cleaned)
if errors.Is(err, image.ErrBlobNotFound) {
http.NotFound(w, r)
return
}
defer file.Close()
content, ok := file.(io.ReadSeeker)
if !ok {
http.Error(w, "File not seekable", http.StatusInternalServerError)
if err != nil {
http.Error(w, "Unable to read the file", http.StatusInternalServerError)
return
}
defer blob.Content.Close()
contentType := contentTypeFor(cleaned)
w.Header().Set("Content-Type", contentType)
@@ -59,7 +59,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", svgContentSecurityPolicy)
}
http.ServeContent(w, r, cleaned, stat.ModTime(), content)
if content, ok := blob.Content.(io.ReadSeeker); ok {
http.ServeContent(w, r, cleaned, blob.ModTime, content)
return
}
w.Header().Set("Content-Length", strconv.FormatInt(blob.Size, 10))
if !blob.ModTime.IsZero() {
w.Header().Set("Last-Modified", blob.ModTime.UTC().Format(http.TimeFormat))
}
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = io.Copy(w, blob.Content)
}
}
func requestedPath(r *http.Request) string {
@@ -73,22 +84,6 @@ func cleanPath(p string) string {
return strings.TrimPrefix(path.Clean("/"+p), "/")
}
func (h *Handler) openFile(p string) (fs.File, fs.FileInfo, bool) {
if p == "" || p == "." {
return nil, nil, false
}
file, err := h.uploadDir.Open(p)
if err != nil {
return nil, nil, false
}
stat, err := file.Stat()
if err != nil || stat.IsDir() {
file.Close()
return nil, nil, false
}
return file, stat, true
}
func contentTypeFor(p string) string {
ext := path.Ext(p)
if contentType, ok := contentTypeExtensions[ext]; ok {
+1 -1
View File
@@ -292,7 +292,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
apiRouter.Use(chimiddleware.GetHead)
apiRouter.Get("/healthz", GetHealth)
apiRouter.Mount("/v1", backend)
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(settings.UploadDir))
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase))
apiRouter.Mount("/", frontend)
return apiRouter
+18 -4
View File
@@ -12,6 +12,7 @@ import (
"net/http"
"os"
"os/signal"
"slices"
"syscall"
"time"
@@ -44,7 +45,17 @@ 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": "",
@@ -108,10 +119,13 @@ func applyEnvDefaults() {
}
func ensureRequiredEnvVars() {
for _, envVar := range requiredEnvVars {
if _, isSet := os.LookupEnv(envVar); !isSet {
panic(fmt.Sprintf("Environment variable %s is not set", envVar))
}
required := slices.Concat(
requiredEnvVars,
config.DatabaseRequiredEnvVars(os.Getenv(config.EnvDatabaseDriver)),
config.StorageRequiredEnvVars(os.Getenv(config.EnvStorageDriver)),
)
if err := config.MissingEnvVarError(required, os.LookupEnv); err != nil {
panic(err.Error())
}
}
+51
View File
@@ -0,0 +1,51 @@
services:
mysql:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?set MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: renewcms
MYSQL_USER: renewcms
MYSQL_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p\"$$MYSQL_ROOT_PASSWORD\" --silent"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
renewcms:
image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
env_file:
- docker.env
environment:
DB_DRIVER: mysql
DB_HOST: mysql
DB_PORT: "3306"
DB_NAME: renewcms
DB_USER: renewcms
DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
DATA_DIR: /data
UPLOAD_DIR: /data/uploads
RENEWCMS_CONTAINER: "true"
ports:
- "8080:8080"
volumes:
- renewcms-data:/data
stop_grace_period: 15s
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
renewcms-data:
mysql-data:
+51
View File
@@ -0,0 +1,51 @@
services:
postgres:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_DB: renewcms
POSTGRES_USER: renewcms
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U renewcms -d renewcms"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
renewcms:
image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
env_file:
- docker.env
environment:
DB_DRIVER: postgres
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: renewcms
DB_USER: renewcms
DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
DB_SSLMODE: disable
DATA_DIR: /data
UPLOAD_DIR: /data/uploads
RENEWCMS_CONTAINER: "true"
ports:
- "8080:8080"
volumes:
- renewcms-data:/data
stop_grace_period: 15s
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
renewcms-data:
postgres-data:
+56
View File
@@ -0,0 +1,56 @@
services:
seaweedfs:
image: ${SEAWEEDFS_IMAGE:-chrislusf/seaweedfs:4.48}
restart: unless-stopped
command: ["server", "-dir=/data", "-s3", "-s3.port=8333"]
environment:
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-renewcms}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:?set S3_SECRET_ACCESS_KEY}
volumes:
- seaweedfs-data:/data
seaweedfs-init:
image: ${SEAWEEDFS_IMAGE:-chrislusf/seaweedfs:4.48}
restart: "no"
depends_on:
- seaweedfs
entrypoint: ["sh", "-c"]
command:
- |
until echo "s3.bucket.create -name renewcms-media" | timeout 30 weed shell -master=seaweedfs:9333 && echo "s3.bucket.list" | timeout 30 weed shell -master=seaweedfs:9333 | grep -q renewcms-media; do
sleep 2
done
renewcms:
image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}
restart: unless-stopped
depends_on:
seaweedfs-init:
condition: service_completed_successfully
env_file:
- docker.env
environment:
STORAGE_DRIVER: s3
S3_ENDPOINT: http://seaweedfs:8333
S3_BUCKET: renewcms-media
S3_REGION: us-east-1
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-renewcms}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:?set S3_SECRET_ACCESS_KEY}
S3_PATH_STYLE: "true"
DB_FILE: /data/renewcms.db
RENEWCMS_CONTAINER: "true"
ports:
- "8080:8080"
volumes:
- renewcms-data:/data
stop_grace_period: 15s
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
renewcms-data:
seaweedfs-data:
+21
View File
@@ -11,3 +11,24 @@ CORS_ALLOWED_ORIGINS=
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES=
DB_DRIVER=sqlite
DATA_DIR=
DB_HOST=
DB_PORT=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_TLS=false
DB_SSLMODE=disable
DB_MAX_OPEN_CONNS=10
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME_SECONDS=300
STORAGE_DRIVER=local
S3_BUCKET=
S3_REGION=us-east-1
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_KEY_PREFIX=
S3_PATH_STYLE=false
S3_TLS=true
+37 -2
View File
@@ -15,8 +15,10 @@ api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain
| Domain | `internal/domain/<name>/` | Entities, `Err…` sentinel errors, `Repository` interfaces. Imports only the stdlib and other domain packages, with no GORM tags. |
| Use cases | `internal/infrastructure/useCases/` | Application logic. Depends only on domain interfaces, never on persistence, mailer, GORM or `net/http`. |
| Adapters | `internal/infrastructure/persistence/{models,mappers,repositories}`, `internal/infrastructure/mailer` | Implement a domain `Repository` (assert with `var _ domain.Repository = &X{}`). GORM structs stay in `models` and are converted by `mappers`. |
| Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the user-role backfill. Only `sqlite` exists today. |
| Blob storage | `domain/image.Storage`, `internal/infrastructure/storage` | `Storage` (`Put`, `Open`, `Delete`) is the port for uploaded image bytes. `storage.Open(Config{Driver, Location})` picks the adapter; `local` is the only one. Repositories never touch files: use cases coordinate `Storage` and `image.Repository`. |
| Delivery | `api/handlers/<name>/`, `api/router.go` | HTTP handlers receive use cases through `NewHandler(...)`. They never import persistence, mailer or GORM. |
| Composition root | `api/dependecyInjection.go` | The only place that opens the DB, builds repositories and use cases, and lists models for `AutoMigrate`. |
| Composition root | `api/dependecyInjection.go` | The only place that picks the database and storage drivers, builds repositories and use cases, and runs `database.Migrate` and the image metadata backfill at boot. |
| Entry point | `cmd/server/main.go` | Env/config, HTTP server, graceful shutdown. |
Nothing under `internal/` imports `api/`.
@@ -55,7 +57,7 @@ The use cases `GetPurgeSettings`, `UpdatePurgeSettings`, `PreviewPurge` and `Run
### Image metadata backfill
At boot, `BackfillImageMetadata` fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the files in `UPLOAD_DIR`. It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written.
At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMetadata` and `UpdateMetadata`) fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the bytes through `image.Storage` (the files in `UPLOAD_DIR` with the local adapter, the bucket objects with the S3 adapter). It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written.
## Frontend ↔ backend
@@ -73,3 +75,36 @@ bin/renewcms ── /v1/* API
- **Production:** a single origin. The SPA calls the API with relative URLs, so the JWT cookie works without CORS. Files under `assets/` are content-hashed by Vite and cached for a year. `index.html` is never cached.
- **Development:** built with `-tags nofrontend` (`assets_stub.go`, `assets.Dist == nil`). Vite serves the SPA on :5173 and proxies `/v1` to the API on :8080. See [development.md](development.md).
## Storage and database seams
Uploaded images are addressed by a stored name (`<uuid>.<ext>`). `CreateImageUseCase` calls `Storage.Put` (which generates the name), then `Repository.Create` with `Path = /static/uploadedImages/<name>`, and deletes the blob again if the row cannot be created. `DeleteMediaUseCase` and `MergeMediaUseCase` remove the blob first and the row second (`mediaRemover`), so a failed blob delete keeps the row and the delete can be retried. `GET /static/uploadedImages/<name>` is served by `OpenMediaUseCase` through `api/handlers/staticimages`, which uses `http.ServeContent` when the blob is seekable and streams it otherwise; `ErrBlobNotFound` maps to 404 and any other error to 500.
SQL in repositories must run on SQLite, MySQL/MariaDB and PostgreSQL: substring searches use `LIKE ... ESCAPE '!'` through `containsPattern` instead of `instr`. `Settings.DataDir()` is where update state, staging and backups live; it is `DATA_DIR` when set and the directory of `DB_FILE` otherwise, so it does not depend on where the database is.
Three drivers are registered in `database.drivers`: `sqlite` (default), `mysql` (MySQL 8 and MariaDB 11, through `gorm.io/driver/mysql`) and `postgres` (PostgreSQL 16 and 17, alias `postgresql`, through `gorm.io/driver/postgres` on pgx v5); all are pure Go so `CGO_ENABLED=0` and the six release targets still build. `DB_DRIVER` picks one in `databaseConfig` (composition root); an unknown name fails in `database.Connect` with `unsupported database driver "<name>"`. `database.MySQLDSN` and `database.PostgresDSN` build the DSN from separate variables (the PostgreSQL one as an escaped URL with `sslmode`, `timezone=UTC` and a connect timeout), and the driver redacts the password from every error and names only host and port. `Connect` applies the optional `PoolSettings` to any driver. What keeps the repositories identical on both engines, all without touching the domain, use cases or handlers:
- Indexed and unique strings carry `size:` tags in the models (a MySQL index needs a bounded `varchar`). SQLite maps them to the same `text` column, so an existing SQLite file migrates with no schema or row change (`test/sqlite_schema_unchanged_test.go` builds a file from the old model definitions and compares `sqlite_master` and every row).
- `Migrate` creates MySQL tables with `utf8mb4_bin` (`gorm:table_options`), so `Bob` and `bob` are distinct like in SQLite. The DSN also sets `collation=utf8mb4_bin`.
- The DSN sets `clientFoundRows=true`: an update that matches a row but changes nothing still reports one affected row, as in SQLite. `InviteRepository.Renew`, `deleteOrNotFound` and `PurgeSettingsRepository.SaveLastRun` depend on that.
- The Go driver sends a zero `time.Time` as `0000-00-00`, which MySQL's default `NO_ZERO_DATE` rejects, so each session sets `sql_mode` to the server default minus `NO_ZERO_DATE`. Reading `0000-00-00` gives the zero time back.
- `UserRepository.Create` returns the filled model instead of re-reading it, because MySQL has no `RETURNING`.
On PostgreSQL the MySQL-only table options are not applied, pgx returns `timestamptz` values in the local zone so a query callback in the driver converts them to UTC, and only the `public` schema is used.
Backups are a `domain/update.DatabaseBackup` per driver. SQLite uses `VACUUM INTO`. MySQL uses `DumpBackup`, a pure Go logical dump (`SHOW CREATE TABLE` plus every row, strings escaped and binary values as hex, NULL and zero dates kept) written inside one repeatable-read transaction to `DATA_DIR/backups/renewcms-<time>.sql.partial` and renamed once complete. Any failure removes the partial file and returns an error, so the update download fails instead of claiming a backup. PostgreSQL reuses `DumpBackup` with its own `dumpDialect` (double-quoted identifiers, doubled single quotes, `\x` bytea literals): `CREATE TABLE` is rebuilt from `pg_catalog` (serial columns, defaults, primary key, unique and check constraints), followed by the indexes, the rows and a `setval` per `id` sequence, all inside one repeatable-read read-only transaction. All drivers keep the newest 3 files (`pruneBackups`).
`task test` runs everything that needs no server. The repository contract tests (`test/repository_contract_test.go`) run on SQLite always, and on MySQL when `RENEWCMS_TEST_MYSQL_DSN` points at a server (admin DSN; each test creates and drops its own database). `task test:mysql` starts `mysql:8.4` and then `mariadb:11` in Docker and runs the `MySQL` tests against each. The same contract tests run on PostgreSQL when `RENEWCMS_TEST_POSTGRES_DSN` points at a server (admin DSN, for example `postgres://postgres:pw@127.0.0.1:5432/postgres?sslmode=disable`), and `task test:postgres` starts `postgres:16` then `postgres:17` in Docker and runs the `Postgres` tests against each.
Two storage adapters implement `image.Storage`: `local` (default, files in `UPLOAD_DIR`) and `s3` (`storage.S3Storage`, `aws-sdk-go-v2` over plain HTTPS, pure Go so `CGO_ENABLED=0` still holds). `STORAGE_DRIVER` picks one in `imageStorageConfig` (composition root, from `config.StorageSettings`); `storage.Open` fails with `unsupported storage driver "<name>"` for anything else, and `openImageStorage` panics with the error. The S3 adapter works as follows, with no change to the domain, use cases, repositories or the `staticimages` handler:
- `Open` calls `GetObject` and returns a `Blob` whose `Content` is an `io.ReadSeeker` (`s3Reader`): `Size` and `ModTime` come from `ContentLength` and `LastModified`, the first bytes are read from that response and every `Seek` to another offset makes the next `Read` issue a ranged `GetObject`. `http.ServeContent` therefore answers `Range` (206, 416) and `If-Modified-Since` (304) with no `ETag`. A 404 maps to `image.ErrBlobNotFound`; any other failure is a plain error (500 in the handler).
- `Put` does one `PutObject` of `<S3_KEY_PREFIX><uuid>.<ext>` with the upload's `Content-Type`. `Delete` treats 404 as success. Names that are not plain file names are never requested, as in the local adapter.
- Each call has a 30 second timeout to get a response (the body then streams without a deadline) and the SDK default retries. Request checksums are only sent when required, so older S3-compatible servers work.
- `storage.Open` checks the bucket with `HeadBucket`. The failure text names the bucket and the reason (404, 403, other status or transport error) and never the credentials.
- At boot, `RunBackfillReportingReads` wraps the storage in a counter for the `BackfillImageMetadataUseCase` run and logs how many images it read; the use case itself is unchanged.
- Update backups (`DATA_DIR`) never include uploads, whatever the driver.
`task test:s3` starts SeaweedFS in Docker and runs `TestS3StorageIntegration` against it (skipped without Docker, and not part of `task test`). Everything else uses an in-process fake S3 server listening on `127.0.0.1`.
Adding a database driver: implement `database.Driver` (`Open`, and `NewBackup` returning a `domain/update.DatabaseBackup`), register it in `drivers`, then select it in `databaseConfig` in the composition root (and add the env vars to `main.go`, `.env.example` and `configuration.md`). Adding a storage backend: implement `image.Storage`, add a case to `storage.Open`, and select it in `openImageStorage`. No use case, handler or repository changes.
+68 -4
View File
@@ -24,17 +24,81 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|------------------------|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ENVIRONMENT` | `production` | `production` or `development`. In `production` the auth cookie is `Secure`, so it is only sent over HTTPS. `development` turns on HTTP request logging. |
| `PORT` | `8080` | Port the HTTP server listens on (all interfaces). |
| `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. |
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. |
| `DB_DRIVER` | `sqlite` | `sqlite`, `mysql` (MySQL and MariaDB) or `postgres` (alias `postgresql`). Any other value stops the server with `unsupported database driver "<value>"`. See [Database](#database). |
| `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. Ignored when `DB_DRIVER` is `mysql` or `postgres`. |
| `DATA_DIR` | directory of `DB_FILE` | Directory for update state, staged updates and database backups (`backups/`). Set it with `DB_DRIVER=mysql` or `postgres`, where there is no database file to sit beside (the default still follows `DB_FILE`). With SQLite, leave it unset to keep everything beside the database. |
| `STORAGE_DRIVER` | `local` | `local` (files in `UPLOAD_DIR`) or `s3` (an S3-compatible bucket). Any other value stops the server with `unsupported storage driver "<value>"`. See [Image storage](#image-storage). |
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. Ignored, without error, when `STORAGE_DRIVER=s3`. |
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
| `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. |
| `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. |
| `UPDATE_FEED_URL` | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, next to `DB_FILE`. |
| `UPDATE_FEED_URL` | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, in the data directory. |
Automatic media purge has no environment variable. Its schedule (on or off, interval, `enabled_at`, `last_purge_at`) lives in the purge settings row of the database, is changed from the Settings page or `PATCH /v1/media/purge-settings`, and is read again at every hourly tick.
## Image storage
By default uploaded images are files in `UPLOAD_DIR`. To keep them in an S3-compatible bucket (AWS S3, MinIO, Ceph, Cloudflare R2, Backblaze B2 and similar), set `STORAGE_DRIVER=s3` and the variables below. They are only read when `STORAGE_DRIVER=s3`. Image URLs do not change: the app still serves `/static/uploadedImages/<name>`, reading the object from the bucket (the bucket stays private, and `Range` and `If-Modified-Since` requests keep working; there is no `ETag`).
| Name | Default | Description |
|------------------------|-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `S3_BUCKET` | required | Name of an existing bucket. A missing value stops the server with `Environment variable S3_BUCKET is not set`. |
| `S3_REGION` | `us-east-1` | Bucket region. Most S3-compatible servers accept the default. |
| `S3_ENDPOINT` | *(empty)* | Host (with optional port) or full URL of an S3-compatible server. Leave empty for AWS S3. |
| `S3_ACCESS_KEY_ID` | *(empty)* | Access key. Set it together with `S3_SECRET_ACCESS_KEY`, or set neither. Setting only one stops the server with an error naming the missing one. |
| `S3_SECRET_ACCESS_KEY` | *(empty)* | Secret key. Never logged and never in an error message. When both keys are empty, the default AWS credential chain is used (`AWS_*` variables, shared credentials file, instance or task role). |
| `S3_KEY_PREFIX` | *(empty)* | Prefix put before every object key, for example `uploads/`. Include the trailing slash. |
| `S3_PATH_STYLE` | `false` | `true` addresses the bucket as `endpoint/bucket` instead of `bucket.endpoint`. SeaweedFS, MinIO and most self-hosted servers need `true`. |
| `S3_TLS` | `true` | Only used when `S3_ENDPOINT` has no `http://` or `https://` prefix: `true` means `https`, `false` means `http`. |
At startup the server checks the bucket (`HeadBucket`). A missing bucket, refused credentials or an unreachable server stop startup with `Unable to open the image storage: S3 bucket "<name>" is not usable: <reason>`; the error never contains the secret. Each S3 call has a 30 second timeout and the SDK default retries. At boot, images whose metadata is missing are read back from the bucket to fill it; when there were some, one log line says how many.
### Moving existing uploads to a bucket
There is no automatic migration and the database does not change: image rows only hold the stored name, so the same names must exist in the bucket. With the site stopped, copy the files under the same names, under `S3_KEY_PREFIX` if you set one, then set `STORAGE_DRIVER=s3` and restart:
```sh
aws s3 sync ./data/uploads s3://my-bucket/uploads/
rclone sync ./data/uploads remote:my-bucket/uploads
```
Keep `UPLOAD_DIR` until you have checked the site. Moving back is the same copy in the other direction.
### Backups
The pre-update backup covers the database only (and `DATA_DIR` state). Neither the update backup nor the data directory includes uploads, so back the bucket up with your provider (versioning, replication) or with `aws s3 sync` / `mc mirror`.
## Database
SQLite is the default and needs nothing but `DB_FILE`. To run against MySQL 8 or MariaDB 11 instead, set `DB_DRIVER=mysql` and the connection variables below. They are only read (and the four required ones only checked) when `DB_DRIVER` is `mysql` or `postgres`. A required variable that is not set stops the server with `Environment variable DB_HOST is not set` (or `DB_USER`, `DB_PASSWORD`, `DB_NAME`).
| Name | Default | Description |
|--------------------------------|----------|----------------------------------------------------------------------------------------------------------------------------------|
| `DB_HOST` | required | Server host name or IP address. |
| `DB_PORT` | `3306` (`5432` for PostgreSQL) | Server port. |
| `DB_USER` | required | Database user. It needs full rights on `DB_NAME`, because the schema is created and migrated at startup. |
| `DB_PASSWORD` | required | Database password. It is never logged and never appears in an error message. |
| `DB_NAME` | required | Name of an existing database. The server does not create it. |
| `DB_TLS` | `false` | `false`, `true`, `skip-verify` or `preferred`. Any other value stops the server with an error naming `DB_TLS` and these values. `true` verifies the server certificate; `skip-verify` encrypts without verifying it. |
| `DB_SSLMODE` | `disable` | PostgreSQL only: `disable`, `require`, `verify-ca` or `verify-full`. Any other value stops the server with an error naming `DB_SSLMODE` and these values. Ignored for SQLite and MySQL (use `DB_TLS` there). |
| `DB_MAX_OPEN_CONNS` | `10` | Maximum open connections in the pool. `0` means unlimited. |
| `DB_MAX_IDLE_CONNS` | `5` | Maximum idle connections kept in the pool. |
| `DB_CONN_MAX_LIFETIME_SECONDS` | `300` | Seconds after which a connection is closed and replaced. `0` keeps connections forever. Keep it below the server's `wait_timeout`. |
A non numeric (or negative) pool value stops the server with an error naming the variable. There is no DSN variable: the connection string is built from the variables above, with `parseTime=true`, `loc=UTC`, `charset=utf8mb4`, `collation=utf8mb4_bin` and `clientFoundRows=true`. Connecting uses a 5 second timeout, so a wrong host, port, password or database name stops startup quickly with `Unable to open the database`, naming the host and port but never the password.
### PostgreSQL
Set `DB_DRIVER=postgres` (or `postgresql`) and the same connection variables, with `DB_PORT` defaulting to `5432` and `DB_SSLMODE` instead of `DB_TLS`. PostgreSQL 16 and 17 are tested. The database must already exist and `DB_USER` needs full rights on the `public` schema, which is the only schema used. The connection string is built with escaping from the variables (a password may contain spaces, quotes, `@` or `/`), sets the session time zone to UTC and a 5 second connect timeout, and the pool variables above apply. A failure to connect stops startup with `Unable to open the database`, naming the host and port but never the password. Usernames and emails compare case-sensitively, as on SQLite. Before an update download RenewCMS writes a logical dump to `DATA_DIR/backups/renewcms-<time>.sql` (the newest 3 are kept); replay it into an empty database with `psql -f`. For routine backups prefer `pg_dump`.
### MySQL settings
Why those settings: tables are created with the `utf8mb4_bin` collation so that usernames and emails compare case-sensitively exactly like in SQLite (`Bob` and `bob` are different users). The collation of `DB_NAME` itself does not matter, but tables that already exist keep the collation they have. `clientFoundRows` makes an update that does not change a row still count as matched, which the invite and purge-settings code relies on. Each session also drops the server's `NO_ZERO_DATE` SQL mode, because the Go driver sends a zero time as `0000-00-00`. The other strict modes stay on.
With MySQL the data directory (`DATA_DIR`) holds update state and backups only. The pre-update backup is a `renewcms-<time>.sql` logical dump (schema and rows, written by RenewCMS itself, no `mysqldump` needed) that keeps the newest 3. If the dump fails, the update download fails with it. The dump holds your data, such as password hashes, but not the connection password.
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
For example, `WorkingDirectory=/var/lib/renewcms` with `DB_FILE=./data/renewcms.db` puts the database in `/var/lib/renewcms/data`. Browser-update `--data-dir` must name that directory, because the app reads helper status and update records beside the database. It is not necessarily `WorkingDirectory` or `ReadWritePaths`. Do not change an existing `DB_FILE` merely to match the helper: that could open a different, empty database. See [browser-update setup](deployment.md#browser-updates).
@@ -42,7 +106,7 @@ For example, `WorkingDirectory=/var/lib/renewcms` with `DB_FILE=./data/renewcms.
## Adding a variable
1. Read it in the code where it is needed (infrastructure or `api/`, never `internal/domain`).
2. Register it in [`cmd/server/main.go`](../cmd/server/main.go): add it to `requiredEnvVars` if it is required, or to `envDefaults` if it has a default.
2. Register it in [`cmd/server/main.go`](../cmd/server/main.go): add it to `requiredEnvVars` if it is required (or to `DatabaseRequiredEnvVars` in `internal/infrastructure/config` if only one database driver needs it), or to `envDefaults` if it has a default.
3. Add it to [`.env.example`](../.env.example) and to the tables above.
Never hardcode a secret, and never commit `.env`.
+46 -1
View File
@@ -8,7 +8,7 @@ RenewCMS ships as **one self-contained executable**. It serves:
| `/healthz` | Liveness check, returns `200 ok` |
| `/*` | The Vue SPA (embedded), with fallback to `index.html` for client-side routes |
Runtime state includes the SQLite database (`DB_FILE`), uploads (`UPLOAD_DIR`), and update records, staged binaries and backups beside the database. Running a release binary needs no Go, Bun or Node installation. Browser updates additionally require Linux with systemd and the helper setup below.
Runtime state includes the SQLite database (`DB_FILE`, or a MySQL/MariaDB or PostgreSQL server, see [Using MySQL or MariaDB](#using-mysql-or-mariadb) and [Using PostgreSQL](#using-postgresql)), uploads (`UPLOAD_DIR`, or an S3-compatible bucket with `STORAGE_DRIVER=s3`, see [configuration.md](configuration.md#image-storage)), and update records, staged binaries and backups beside the database. Running a release binary needs no Go, Bun or Node installation. Browser updates additionally require Linux with systemd and the helper setup below.
To run it in a container instead, see [docker.md](docker.md). The published image `git.floriansylvain.fr/fsylvain/renewcms` is upgraded with `docker compose pull && docker compose up -d`. Tags are `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release, and `vX.Y.Z-<pre>`, `X.Y.Z-<pre>` and the moving `<pre-name>` (`beta`, `rc`) for a pre-release, which never moves `latest`. Maintainers publish with `task docker:publish` after `docker login git.floriansylvain.fr`; there is no CI. See [Maintainer: publishing](docker.md#maintainer-publishing).
@@ -276,8 +276,53 @@ RenewCMS sends `Content-Security-Policy` (including `frame-ancestors 'none'`), `
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.
## Using MySQL or MariaDB
SQLite is the default. To use an existing MySQL 8 or MariaDB 11 server instead, create an empty database and a user with full rights on it, then set in `/etc/renewcms/renewcms.env`:
```sh
DB_DRIVER=mysql
DB_HOST=db.example.com
DB_USER=renewcms
DB_PASSWORD=<generated password>
DB_NAME=renewcms
DB_TLS=true
DATA_DIR=/var/lib/renewcms
```
`DB_FILE` is then ignored. `DATA_DIR` is where update state, staged updates and backups live, so give it the same value as the helper's `--data-dir` (see [browser updates](#browser-updates)). Use `DB_TLS=true` (or `preferred`) whenever the server is not on the same host. Every variable is described in [configuration.md](configuration.md#database). The schema is created and migrated at startup, so the first start on an empty database needs nothing else.
## Using PostgreSQL
To use an existing PostgreSQL 16 or 17 server, create an empty database and a user with full rights on its `public` schema, then set in `/etc/renewcms/renewcms.env`:
```sh
DB_DRIVER=postgres
DB_HOST=db.example.com
DB_USER=renewcms
DB_PASSWORD=<generated password>
DB_NAME=renewcms
DB_SSLMODE=require
DATA_DIR=/var/lib/renewcms
```
`DB_FILE` is then ignored, and `DB_PORT` defaults to `5432`. `DATA_DIR` is where update state, staged updates and backups live, so give it the same value as the helper's `--data-dir`. Use `DB_SSLMODE=require` (or `verify-full`) whenever the server is not on the same host. Every variable is described in [configuration.md](configuration.md#database). The schema is created and migrated at startup. Moving an existing SQLite site is a manual procedure like the MySQL one below, and RenewCMS writes a logical `.sql` dump to `DATA_DIR/backups` before each update download.
### Moving an existing SQLite site to MySQL
There is no migration tool. The manual procedure, with the site stopped:
1. Take a backup of the data directory (database file and uploads).
2. Start RenewCMS once on the empty MySQL database so it creates the schema, then stop it.
3. Export the rows from the SQLite file with `sqlite3 renewcms.db .dump`, keep only the `INSERT INTO` statements for `articles`, `images`, `users`, `invites`, `api_keys` and `purge_settings`, and import them into MySQL. Quoting and date formats differ between the two engines, so check a sample first: SQLite writes times such as `2026-01-02 03:04:05.123456789+00:00`, and the `+00:00` suffix must be removed for MariaDB and older MySQL versions. An unset time (`0001-01-01 00:00:00`) is valid as it is in MySQL and is read back as unset.
4. Copy `uploads/` to the new `UPLOAD_DIR`, set `DB_DRIVER=mysql` and the connection variables, and start the service.
Keep the SQLite file until you have checked the site.
## Backups
With MySQL, MariaDB or PostgreSQL, back the database up with your server's own tools (for example `mysqldump --single-transaction` or `pg_dump`), plus the uploads directory. RenewCMS also writes a logical dump to `DATA_DIR/backups` before each update download. The rest of this section is about SQLite.
Everything worth backing up is under the data directory. Take a consistent SQLite copy while the service is running:
```sh
+2
View File
@@ -33,6 +33,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` |
| [Integration](screens/integration.md) | `/integration` | 8 | `source/RenewCMS Screens Back-office.dc.html` |
| [API keys](screens/integration-api-keys.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` |
| [Activity](screens/activity.md) | `/activity` | 9 (plus 6 details, 3 My activity) | `source/RenewCMS Screens Activity.dc.html` |
| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` |
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html`, `source/RenewCMS Screens Media cleanup.dc.html` |
| [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` |
@@ -41,6 +42,7 @@ The visual spec of record for the back-office SPA and the transactional emails,
## Other boards
- `source/ActivityFrame.dc.html`: the frame that draws every Activity state, the Home card and the Settings retention card (the Activity board imports it). Its copy, component and API tables are in `source/activity-spec.js`. It holds the exact values. Home and Settings are drawn around dashed placeholders.
- `source/MediaFrame.dc.html`: the frame that draws the Media and Media picker states (the Back-office board imports it). Like `AppFrame.dc.html`, it holds the exact values.
- `source/SettingsFrame.dc.html`: the Media cleanup card and its purge dialog (the Media cleanup board imports it, with `MediaFrame.dc.html` for the Duplicates toolbar button). They hold the exact values.
- `source/IntegrationPage.dc.html` and `source/ApiKeysPage.dc.html`: the page components for the Integration and API keys screens (imported by `AppFrame.dc.html`). They hold the exact values.
+20
View File
@@ -829,3 +829,23 @@ Settings card. `USwitch` (square, `rounded-none`), `UInputNumber` with "days" su
### Media toolbar button
Secondary `UButton` icon `merge`, label "Purge duplicates", after the sort menu on desktop; full width in the stacked toolbar on mobile (44 px).
## Added with Activity
Source: `source/activity-spec.js` (component inventory) and `source/ActivityFrame.dc.html`. All built on existing Nuxt UI v4 components and tokens; no new token.
| Component | Nuxt UI base | Props | Emits | Slots | Used on |
|---|---|---|---|---|---|
| ActivityPage | custom page | none (reads the session role) | none | none | Activity |
| ActivitySummaryStrip | UCard (as button) | summary, loading, activeFilter | apply-filter | none | Activity (owner, admin) |
| ActivityFilterBar | UInput, USelectMenu, UButton, UBadge | filters, people, canFilterPeople | update:filters, clear | none | Activity |
| ActivityTable | UTable (day separator rows), UAvatar, UBadge, UPagination, USelectMenu | items, total, page, size, loading | open, update:page, update:size | empty | Activity (md and up) |
| ActivityCardList | UCard | same as ActivityTable | open | empty | Activity (below md) |
| ActivityDetailsPanel | USlideover (right on desktop, bottom on mobile), UCard, UButton | item, index, total, open | previous, next, close | none | Activity |
| ActivityDiff | custom list | changes | none | none | ActivityDetailsPanel |
| ActivityFeedCard | UCard, UAvatar, UButton | items, loading, error, own | retry | none | Home |
| RetentionCard | UCard, USelectMenu, UAlert, UButton | settings, canEdit | saved | none | Settings (owner) |
- Sparkline: seven `div` bars inside the strip card, no chart library. The strip cards are real `button`s with the card as their label.
- Day separators are full-width rows of the table. The "When" cell carries the exact time as its `title`.
- The sentence is composed from the `action` code and params with the i18n messages (English and French); the target name is user content and is not translated.
- Failed and security rows use `--rc-error-soft` / `--ui-error` and `--rc-warning-soft` / `--ui-warning` for emphasis, always paired with an icon or a word, never colour alone.
+3 -2
View File
@@ -11,15 +11,16 @@ A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it
| `/register/validate` | AuthShell | public | VerifyPage | POST /v1/auth/verify (+ POST /login inline on 401) |
| `/register/reset/request` | AuthShell | public | ResetRequestPage | POST /v1/auth/password-reset |
| `/register/reset/validate` | AuthShell | public | ResetConfirmPage | POST /v1/auth/password-reset/confirm |
| `/home` | DashboardShell | verified | HomePage | GET /v1/article/ · GET /v1/system/update (owner, for the banner) |
| `/home` | DashboardShell | verified | HomePage | GET /v1/article/ · GET /v1/activity?size=5 · GET /v1/system/update (owner, for the banner) |
| `/article` | DashboardShell | verified | ArticlesPage | GET /v1/article/ · PATCH /{id}/publish\|unpublish · DELETE /{id} |
| `/article/new` | DashboardShell | verified | ArticlesPage + NewArticleModal | POST /v1/article/ |
| `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · GET /v1/media/ · POST /v1/media/ · DELETE /{id} |
| `/media` | DashboardShell | verified | MediaPage | GET /v1/media/purge-preview · POST /v1/media/purge · GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage |
| `/activity` | DashboardShell | verified | ActivityPage + ActivityDetailsPanel | GET /v1/activity · GET /v1/activity/summary (owner, admin) · GET /v1/activity/export (owner, admin) |
| `/integration` | DashboardShell | verified | IntegrationPage | none |
| `/integration/api-keys` | DashboardShell | verified | ApiKeysPage + CreateApiKeyModal | GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} |
| `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id · POST /v1/user/invite/:id/renew |
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart · GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge |
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · PATCH /v1/settings (owner, retention) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart · GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge |
| `/profile` | DashboardShell | verified | ProfilePage | GET /v1/auth/me · PATCH /v1/user/me · POST /v1/auth/password-reset |
| `/invite/accept` | AuthShell | public | AcceptInvitePage | GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept* |
| `/:pathMatch(.*)*` | either | any | NotFoundPage | none |
+183
View File
@@ -0,0 +1,183 @@
# Activity
- **Route:** `/activity`
- **API:** GET /v1/activity · GET /v1/activity/summary · GET /v1/activity/export (see "API contract" below)
- **Visual reference:** `source/RenewCMS Screens Activity.dc.html`, group "Activity, owner and admin" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). The frame is `source/ActivityFrame.dc.html`; its copy, component mapping and API tables are `source/activity-spec.js`.
The audit log of the back-office: who did what, and when, across articles, media, users and invites, API keys, authentication and system tasks. It is for non-technical owners, so every row reads as a sentence, not a log line. Events are append-only: nothing in the app edits or deletes them. Only the retention period removes them (Settings, "Retention").
Other groups of the same board:
| Group | What it draws | Written up in |
|---|---|---|
| Details slide-over | The slide-over and its bottom-sheet variant, six events | this file, "Details slide-over" |
| My activity | The same screen for a user who is not owner or admin | this file, "My activity" |
| Home card | The Recent activity card on Home | `screens/home.md`, "Recent activity card" |
| Settings retention | The Retention card on Settings | `screens/settings.md`, "Activity retention" |
## Roles
Owner and admin see every event, the summary strip, the Person filter and Export. Any other role sees only its own events: the title becomes "My activity", the strip, the Person filter and Export are hidden, and the server scopes the list to that user (asking for another actor answers 403).
Today the product has only `owner` and `admin` (see `screens/users.md`, "Roles"), so "My activity" and the scoped answer cannot be reached by a real account yet. They are specified and tested now so a future role needs no rework. The board's sample "Editor" role and the nav it draws for a regular user (Home, Articles, Media, Activity) are illustrative.
## Sidebar
"Activity" (icon `history`) sits between Users and Integration for owner and admin: Home, Articles, Media, Users, Activity, Integration, Settings. The board draws the order that way. It is visible to every verified user. The mobile drawer gets the same item.
## The page
Title "Activity" (regular users: "My activity"). Subtitle "Who did what, and when, across your site." (regular users: "Everything you've done on this site."). Page padding 24 px desktop, 16 px mobile; the sidebar and top bar are the existing DashboardShell.
1. **Header action** (owner and admin): outline `UButton` with icon `download`, "Export CSV". While the file is prepared: "Preparing file…" and disabled. Success toast "Export started. Your file will download shortly." Error toast "Couldn't export activity. Try again." Disabled in "Empty, no activity yet".
2. **Summary strip** (owner and admin): four `UCard`, each a `button`. Labels: "Total actions", "Active people", "Failed actions", "Security events". Each shows the number, a sub-line and a seven-bar sparkline (seven `div` bars, no chart library, oldest left).
- Total: "12% vs previous 7 days" with `trending-up` / `trending-down` (the comparison is against the equal-length period just before the selected range). Click clears the filters.
- Active people: "7 people, 2 API keys". Click does nothing (no equivalent filter).
- Failed: "0.5% of all actions". Emphasis with `--rc-error-soft` and `--ui-error`, icon `circle-x`. Click applies Outcome = Failed.
- Security: "5 failed logins". Emphasis with `--rc-warning-soft` and `--ui-warning`, icon `shield-alert`. Click applies the security filter (auth, role changes, API key create and revoke).
- Zero: Failed shows "0" with "Nothing failed" and `circle-check`; Security shows "0" with "Nothing to flag" and `circle-check`; both drop the emphasis fill. No data at all: every card reads "0" with the sub-line "No data yet" (Failed and Security keep their calm lines).
- The strip follows the date range filter. Mobile: a 2×2 grid.
3. **Filter bar**: `UInput` (icon `search`, clearable, "Search activity"), Person `USelectMenu` (grouped: users, API keys, "System"; "All people"), Category `USelectMenu` ("All categories": Articles, Media, Users and invites, API keys, Authentication, System), Outcome button group (All / Succeeded / Failed; `soft` is the selected one), date presets (Today / 7 days / 30 days / Custom; Custom opens a date-range popover). Mobile: the bar collapses to a search field and a "Filters" button with a count badge; the controls move to a bottom sheet.
4. **Active filter chips**: `UBadge` (subtle) with a trailing ghost `x` button: "Search: spring", "Person: Marta Alves", "Category: Authentication", "Outcome: Failed", "Dates: 1 – 30 Sep 2026", then a `link` button "Clear all". The default date range (7 days) is not a chip.
5. **Timeline table**: newest first, grouped by day with full-width separator rows ("Today", "Yesterday", "3 Oct 2026"). Columns: When (relative within the day, clock time before; the full time is the cell's `title`), Actor (`UAvatar` with initials, a key icon for API keys or a cog for System, plus the name; badges "API key" / "System"; a signed-out actor reads "Not signed in"), Action (category icon and the sentence with the target name in semibold; a `shield` marker on security rows), Target (a link when it still exists; struck through with a "Deleted" badge when it does not), Outcome (`UBadge` "Succeeded" / "Failed"). Row click and Enter open the details.
6. **Pagination**: "Showing 1–25 of 1,284", `UPagination`, "Rows per page" select (25, 50, 100). Mobile: "Page 1 of 52" with previous and next.
7. **Mobile list**: below `md` the table is a stacked list of `UCard` rows (actor, sentence, time, outcome, "Target deleted" when relevant), 44 px touch targets.
Sentences are composed in the SPA from a stable `action` code and the event params, in the user's language (English and French), never stored as text. The target name and the typed values in a diff stay as the user wrote them.
## States
### Default, large dataset
**Trigger:** Both requests resolved with 1,284 events over 52 pages. Newest first, day separators, a security row. Hover on the first row shows its exact time.
### Loading
**Trigger:** Both requests in flight. Skeleton strip and table rows after 150 ms.
### Zero-value summary strip
**Trigger:** A quiet period. Failed and Security show a calm 0.
### Filtered, card applied
**Trigger:** The Failed actions card was clicked. Chips plus "Clear all"; mobile shows the count on Filters. Six results.
### Mobile filters sheet
**Trigger:** Filters open as a bottom sheet on mobile. The desktop frame keeps the inline bar. Sheet: title "Filters", Person (owner and admin), Category, Outcome, Dates (two date fields when Custom), footer "Clear all" and "Show 23 results".
### Empty, no activity yet
**Trigger:** A new site. `UEmpty` with icon `history`, title "No activity yet", description "Actions on articles, media, users and API keys will appear here." Export disabled.
### Empty, filters match nothing
**Trigger:** Filters with zero results. Title "No activity matches these filters", description "Try a different search or a wider date range.", action "Clear filters".
### Error with retry
**Trigger:** The list request failed (500 or network). `UEmpty` in the table area: "Couldn't load activity", "Check your connection and try again.", action "Try again". The strip still shows if its own call succeeded.
### Forbidden (403)
**Trigger:** The API answered 403 for this role or filter. "You can't view this activity.", "Your role doesn't include this page. Ask an owner or admin if you need access.", action "Back to home". The 401 is global.
## Details slide-over
`USlideover`, right side on desktop (about 440 px wide), bottom sheet on mobile. Opens from a row (click or Enter), closes with Escape, the close button or the scrim; focus returns to the row. Title "Activity details".
Body, in `UCard` sections:
- The full sentence, with the target name in semibold.
- When: the user's local time, then a muted "UTC" line.
- Actor (with the same badges), Source ("Web app", "API key", "System task"), IP address, Browser. For a system event, IP address and Browser read "Not applicable".
- Target: name (struck through with a "Deleted" badge, plus "This item was deleted. Its name is kept for reference." when it no longer exists), and a link when it exists.
- Why it failed (failed events only): the plain-language reason from the API, never a stack trace.
- Changes (edits only): rows of field, Before, After. Values longer than two lines clamp with "Show more" / "Show less". A body edit is a single row "Body edited (+312 characters)" and never shows text.
- Footer: "Previous event" and "Next event" buttons and "Event 2 of 1,284". They move through the current filtered list, across page boundaries.
### Edit with a diff
**Trigger:** A title change and a body edit. Title Before and After, then "Body edited (+312 characters)".
### Settings diff, long text
**Trigger:** Long values clamp to two lines with "Show more".
### Failed action, very long target title
**Trigger:** A failed publish. The reason is shown and the long target title wraps.
### Deleted target, no diff
**Trigger:** A deleted image. Struck-through target, "Deleted" badge, no Changes section.
### System-initiated, no human actor
**Trigger:** A scheduled task. "System" badge, source "System task", IP and Browser "Not applicable".
### Security event, failed login
**Trigger:** The actor was not signed in. "Not signed in", a restrained `shield` marker, the reason, the IP and browser.
## My activity
The same screen for a role that is not owner or admin. No summary strip, no Person filter, no Export. The list is the user's own events (server scoped).
### Default
**Trigger:** The user has events. Title "My activity".
### Empty
**Trigger:** No events yet: "No activity yet", "Your actions will appear here once you start working on the site."
### Details
**Trigger:** The slide-over on one of the user's own events.
## API contract
401 and 403 on every route follow `states.md`, "HTTP status to copy". The board's table in `source/activity-spec.js` is the source; this section fixes the parts it leaves open.
**`GET /v1/activity`**
- Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`, `api_keys`, `auth`, `system`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range` (`today`, `7d`, `30d`, `custom`), `from`, `to` (ISO dates, with `custom`), `page` (from 1), `size` (25, 50 or 100; default 25). Sorted by `created_at` descending, then `id` descending. Invalid values answer 400 "The request cannot be processed due to a mismatch in the format of the body."
- `security=true` is what the Security card applies; it is a flag on the event, not a category.
- Response: `{ items, total, page, size }`. An item:
```json
{
"id": 4821,
"created_at": "2026-10-06T10:42:18Z",
"action": "article.publish",
"category": "articles",
"actor": { "type": "user", "id": 3, "name": "Marta Alves" },
"target": { "type": "article", "id": "8", "name": "Spring release", "exists": true },
"outcome": "success",
"failure_reason": null,
"security": false,
"source": "web",
"ip": "203.0.113.12",
"user_agent": "Chrome 129 on Windows",
"changes": [{ "field": "online", "kind": "text", "before": "false", "after": "true" }]
}
```
- `actor.type` is `user`, `api_key`, `system` or `anonymous` (a failed login). `actor.name` is a snapshot taken when the event was recorded, so a renamed or deleted user still reads correctly. `target.name` is a snapshot too; `target.exists` is resolved when the list is read.
- `action` is a stable dotted code. The SPA owns the wording, in English and French. `params` holds extra values a sentence needs (for example `{ "merged": 3, "articles_updated": 5 }` on a media purge); they are never secrets.
- `changes[].kind` is `text` or `body`. A `body` change has `delta` (a signed number of characters) and no `before` or `after`. Only allowlisted fields ever appear in `changes`, and never a password, hash, API key, token or article body.
- `source` is `web`, `api_key` or `system`. `ip` and `user_agent` are `null` for `system`.
- A caller who is not owner or admin is scoped to its own events; `actor` other than itself answers 403.
**`GET /v1/activity/summary`** (owner and admin; 403 otherwise)
Same `range`, `from`, `to`. Response: `total`, `previous_total`, `active_people: { users, api_keys }`, `failed`, `security`, and `series` with seven daily counts for `total`, `active`, `failed` and `security`. The previous period is the equal-length period immediately before the range.
**`GET /v1/activity/export`** (owner and admin; 403 otherwise)
The same filters without `page` or `size`. Streams `text/csv; charset=utf-8` with `Content-Disposition: attachment`. Columns: `when` (RFC3339 UTC), `actor`, `action` (the code), `target`, `outcome`, `source`, `ip`. No changes, no user agent, no secrets. Cells that start with `=`, `+`, `-` or `@` are prefixed with a quote so a spreadsheet does not evaluate them.
**`GET /v1/settings` and `PATCH /v1/settings`**: see `screens/settings.md`, "Activity retention".
These routes are session only: with an API key they answer 403 "This action needs a real session." (A key never reads the audit trail.)
## What is recorded
Every row of the board is a sample. The real catalogue is the set of actions that exist in the product, in these categories:
| Category | Events |
|---|---|
| `articles` | create, edit (title, publish state, body size delta), publish, unpublish, delete |
| `media` | upload, rename, delete, merge, purge (manual; the scheduled run is a `system` event) |
| `users` | register, verify account, update profile (display name), invite create, cancel, renew, accept |
| `api_keys` | create, revoke |
| `auth` | login succeeded, login failed (security), logout, password reset requested (security), password reset completed (security) |
| `system` | update checked, update downloaded, restart requested, scheduled media purge, activity retention changed, activity cleanup |
Not drawn as real events: the "Editor" role and role changes, a "Site description" or "Comments" setting, "Ran the nightly backup" (backups are a step of the update job, not a schedule), "Removed 3 expired invites" and an administrator resetting another user's password. They stay in the board as sample data and are only recorded if the matching feature exists.
Read requests are not recorded (no "article viewed"), and neither are public API reads.
+10
View File
@@ -54,3 +54,13 @@ The greeting reads "Good morning, {name}", where the name is the display name wh
**Trigger:** The download is checked. The same banner changes its copy, and a dismiss comes back on the next visit because the update is waiting.
- Banner: success tone, `circle-check` icon, title "Version 2.3.0 is ready to install", text "Restart from Settings to finish. The site is unavailable for about 10 seconds.", button "Go to Settings"
## Recent activity card
**API:** GET /v1/activity?size=5. Visual reference: `source/RenewCMS Screens Activity.dc.html`, group "Home card". The board draws the rest of Home as a dashed "Existing Home cards" placeholder, so this card is not pixel-checked against it.
A card with the last five events. Header "Recent activity" (regular users: "Your recent activity") and a "View all" link with `arrow-right` to `/activity`. Each row: a 24 px avatar, the sentence clamped to two lines with the target in semibold, then "Marta Alves · 2 min ago" (own events: only the time). A failed event gets a "Failed" badge; a successful security event a small `shield`.
- Placement as drawn: a 420 px column to the right of the existing cards on desktop; stacked on mobile, drawn above the existing cards. Confirm against the real Home layout before coding.
- States: Owner or admin, Regular user, Loading (five skeleton rows after 150 ms), Error ("Couldn't load recent activity" with "Try again"), Empty ("No activity yet", "Actions on your site will appear here." / own: "Your actions will appear here.").
- The card never blocks the page: an error or empty card leaves the stats and the article list as they are.
+15
View File
@@ -177,3 +177,18 @@ Duplicates tab: secondary "Purge duplicates" button (merge) next to the sort men
### Components (Nuxt UI v4)
`UCard`, `USwitch`, `UInputNumber` (min 1, max 365, suffix), `UFormField`, `UAlert`, `UButton`, `UModal`, `USkeleton`, `useToast`.
## Activity retention
**API:** GET /v1/settings · PATCH /v1/settings (owner only). Visual reference: `source/RenewCMS Screens Activity.dc.html`, group "Settings retention". The board draws the other cards as a dashed "Existing Settings cards" placeholder and gives the page a different subtitle ("Site-wide options. Only owners can change them."), which is not a decision to change the existing one.
A third card below Media cleanup, owner only (hidden for admins), same card styling, 640 px at most as drawn.
- Header: "Retention" / "How long activity is kept." "Saved" (check, success) at the right after a save, fading after 2 s.
- "Keep activity for" `USelectMenu`: 30 days, 90 days, 365 days, Forever. Default 90 days. Helper: "Older events are removed automatically." For Forever: "Nothing is removed automatically. The log grows over time."
- Info alert (`lock`): "Activity logs are append-only", "Events can't be edited or deleted from the app. Only the retention period removes them."
- Divider, then read-only lines: "Last cleanup: 6 Oct 2026, 03:00 · 142 events removed" and "Next cleanup: 7 Oct 2026, 03:00". Forever: "Never" and "Not scheduled".
- Saves on change. Choosing a shorter period than the current one first shows a warning alert, "Events older than 30 days will be removed", "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.", buttons "Apply 30 days" and "Cancel"; nothing is sent until Apply. Lengthening or choosing Forever saves at once.
- Save error alert: "Couldn't save the retention period", "Your previous setting is still in place.", "Try again". The control reverts.
- States: Default (90 days, saved), Shortening the period, Forever, Save error. Loading and Offline follow the Media cleanup card.
- `GET /v1/settings` adds `activity_retention_days` (30, 90, 365 or 0 for forever), `activity_last_cleanup_at` (null before the first cleanup) and `activity_last_cleanup_removed`. `PATCH /v1/settings` takes `{ "activity_retention_days": 30 }`; any other value answers 400 "The request cannot be processed due to a mismatch in the format of the body."; a non-owner gets 403 "Only the owner can do this.". The cleanup runs once a day at 03:00 server time and never touches the last 24 hours.
+252
View File
@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<div style="display:contents">{{ view }}</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1440,&quot;height&quot;:900},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;mobile&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;boolean&quot;}}">
class Component extends DCLogic {
renderVals() {
const s = this.props.s || {}, m = !!this.props.mobile, h = React.createElement
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', mask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', ...(t ? P(t) : {}) } })
const bh = m ? 44 : 32
const MONO = "font-family:'IBM Plex Mono',monospace"
const B = (label, o = {}) => {
const k = o.k || 'outline'
const bg = k === 'primary' ? 'var(--ui-primary)' : 'var(--ui-bg)'
const fg = k === 'primary' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)'
const bd = k === 'primary' ? bg : 'var(--ui-border-accented)'
return S('height:' + (o.h || bh) + 'px;' + (o.w ? 'width:' + o.w + 'px;' : '') + 'padding:0 ' + (o.px ?? 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:' + (o.fs || 13) + 'px;white-space:nowrap;flex-shrink:0;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : ''), o.icon && I(o.icon, o.z || 16), label)
}
const BG = { ok: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'check'], fail: ['background:var(--rc-error-soft);color:var(--ui-error)', 'circle-x'], sec: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'shield-alert'], del: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'trash-2'], neutral: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)'], info: ['background:var(--rc-info-soft);color:var(--ui-info)'] }
const Bd = (t, k, sz = 20) => S('height:' + sz + 'px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;' + BG[k][0], BG[k][1] && I(BG[k][1], 12), t)
const Out = e => e.ok ? Bd('Succeeded', 'ok') : Bd('Failed', 'fail')
const Alert = (tone, icon, title, text, action) => D('display:flex;gap:10px;padding:12px 14px;background:var(--rc-' + tone + '-soft);color:var(--ui-' + tone + ')', S('display:flex;padding-top:2px', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:8px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600', title), text && S('color:var(--ui-text-toned)', text)), action && D('display:flex;gap:8px', ...[].concat(action).map(a => B(a, { h: m ? 44 : 28, fs: 12 })))))
const Empty = (icon, title, text, action, primary, pad = 56) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + pad + 'px 24px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I(icon, 20)), S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted);max-width:340px;text-wrap:pretty', text), action && B(action, { k: primary ? 'primary' : 'outline', px: 16 }))
const Note = t => S('align-self:flex-start;height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px \'IBM Plex Mono\',monospace', t)
const sk = (w, hh = 12, extra = '') => S('display:block;width:' + w + ';height:' + hh + 'px;background:var(--ui-bg-accented);flex-shrink:0;' + extra)
// ---------- data
const U = n => ({ n, k: 'user' }), KEY = n => ({ n, k: 'key' }), SYS = { n: 'Scheduler', k: 'system' }, ANON = { n: 'Not signed in', k: 'anon' }
const p2 = n => String(n).padStart(2, '0')
const T = (d, hh, mm, ss) => ({ abs: d + ' 2026, ' + p2(hh) + ':' + p2(mm) + ':' + p2(ss), utc: d + ' 2026, ' + p2(hh - 1) + ':' + p2(mm) + ':' + p2(ss) + ' UTC' })
const WEB = { src: 'Web app', ip: '203.0.113.24', ua: 'Firefox 131 on macOS' }
const LONG = 'The complete guide to restoring a nineteenth-century timber-framed farmhouse: structural surveys, lime mortar, breathable insulation and what it all costs'
const E = [
{ id: 1, day: 'Today', rel: '2 min ago', ...T('6 Oct', 10, 42, 18), a: U('Marta Alves'), cat: 'articles', s: 'Published the article ', em: 'Spring release', tk: 'Article', ex: 1, ok: 1, tid: 'art_8f3k2', ...WEB, diff: [{ f: 'Publish state', b: 'Draft', a: 'Published' }] },
{ id: 2, day: 'Today', rel: '29 min ago', ...T('6 Oct', 10, 15, 4), a: U('Marta Alves'), cat: 'articles', s: 'Edited the article ', em: 'Why we reuse original floorboards', tk: 'Article', ex: 1, ok: 1, tid: 'art_2m9xq', ...WEB, diff: [{ f: 'Title', b: 'Why we reuse floorboards', a: 'Why we reuse original floorboards' }, { f: 'Body', note: 'Body edited (+312 characters)' }] },
{ id: 3, day: 'Today', rel: '1 h ago', ...T('6 Oct', 9, 31, 47), a: ANON, cat: 'auth', s: 'Failed login attempt for ', em: 'alex@example.com', ok: 0, sec: 1, why: 'The password did not match. 3 attempts in 10 minutes.', src: 'Web app', ip: '198.51.100.77', ua: 'Chrome 129 on Windows' },
{ id: 4, day: 'Today', rel: '2 h ago', ...T('6 Oct', 8, 50, 9), a: U('Jonas Weber'), cat: 'keys', s: 'Revoked API key ', em: 'Mobile app', tk: 'API key', ex: 1, ok: 1, sec: 1, tid: 'key_71ab0', src: 'Web app', ip: '203.0.113.90', ua: 'Safari 18 on iOS' },
{ id: 5, day: 'Today', rel: '7 h ago', ...T('6 Oct', 3, 0, 2), a: SYS, cat: 'system', s: 'Ran the nightly backup', em: '', ok: 1, src: 'System task', ip: null, ua: null },
{ id: 6, day: 'Yesterday', rel: '18:20', ...T('5 Oct', 18, 20, 36), a: U('Inès Duarte'), cat: 'users', s: 'Changed the role of ', em: 'Jonas Weber', tk: 'User', ex: 1, ok: 1, sec: 1, tid: 'usr_c40d1', tail: ' from Editor to Admin', ...WEB, ip: '203.0.113.12', diff: [{ f: 'Role', b: 'Editor', a: 'Admin' }] },
{ id: 7, day: 'Yesterday', rel: '16:05', ...T('5 Oct', 16, 5, 51), a: KEY('Website sync'), cat: 'media', s: 'Uploaded the image ', em: 'north-wall.jpg', tk: 'Image', ex: 1, ok: 1, tid: 'img_0pk4w', src: 'API key', ip: '192.0.2.18', ua: 'node-fetch 3.3' },
{ id: 8, day: 'Yesterday', rel: '14:40', ...T('5 Oct', 14, 40, 13), a: U('Marta Alves'), cat: 'media', s: 'Deleted the image ', em: 'old-banner.png', tk: 'Image', ex: 0, ok: 1, tid: 'img_9zq1c', ...WEB },
{ id: 9, day: 'Yesterday', rel: '11:12', ...T('5 Oct', 11, 12, 27), a: U('Inès Duarte'), cat: 'articles', s: 'Failed to publish the article ', em: LONG, tk: 'Article', ex: 1, ok: 0, tid: 'art_5t7yh', why: 'The article has no cover image. Add one, then publish again.', ...WEB, ip: '203.0.113.12' },
{ id: 10, day: 'Yesterday', rel: '03:00', ...T('5 Oct', 3, 0, 1), a: SYS, cat: 'users', s: 'Removed 3 expired invites', em: '', ok: 1, src: 'System task', ip: null, ua: null },
{ id: 11, day: '3 Oct 2026', rel: '16:48', ...T('3 Oct', 16, 48, 22), a: U('Inès Duarte'), cat: 'system', s: 'Changed the setting ', em: 'Site description', tk: 'Setting', ex: 1, ok: 1, tid: 'set_site_desc', ...WEB, ip: '203.0.113.12', diff: [{ f: 'Site description', b: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes.', a: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes, plus a seasonal maintenance checklist.', long: 1 }, { f: 'Comments', b: 'Off', a: 'On' }] },
{ id: 12, day: '3 Oct 2026', rel: '15:02', ...T('3 Oct', 15, 2, 40), a: U('Jonas Weber'), cat: 'keys', s: 'Created API key ', em: 'Mobile app', tk: 'API key', ex: 0, ok: 1, sec: 1, tid: 'key_71ab0', ...WEB, ip: '203.0.113.90' },
{ id: 13, day: '3 Oct 2026', rel: '14:20', ...T('3 Oct', 14, 20, 5), a: U('Inès Duarte'), cat: 'users', s: 'Updated the user ', em: 'Sam Rivera', tk: 'User', ex: 1, ok: 1, tid: 'usr_77ea2', ...WEB, ip: '203.0.113.12', diff: [{ f: 'Name', b: 'Sam Rivera', a: 'Samuel Rivera' }, { f: 'Email', b: 'sam@example.com', a: 'samuel@example.com' }] },
{ id: 14, day: '3 Oct 2026', rel: '11:36', ...T('3 Oct', 11, 36, 58), a: U('Marta Alves'), cat: 'media', s: 'Uploaded the image ', em: 'floorboards-copy.jpg', tk: 'Image', ex: 1, ok: 1, tid: 'img_4ww8e', ...WEB },
{ id: 15, day: '3 Oct 2026', rel: '09:05', ...T('3 Oct', 9, 5, 12), a: U('Inès Duarte'), cat: 'auth', s: 'Reset the password for ', em: 'Sam Rivera', tk: 'User', ex: 1, ok: 1, sec: 1, tid: 'usr_77ea2', ...WEB, ip: '203.0.113.12' }
]
const CI = { articles: 'file-text', media: 'image', users: 'users', keys: 'key-round', auth: 'shield-check', system: 'server-cog' }
const CL = { articles: 'Articles', media: 'Media', users: 'Users & invites', keys: 'API keys', auth: 'Authentication', system: 'System' }
const user = s.role === 'user'
const ME = user ? 'Marta Alves' : 'Inès Duarte'
let list = s.ids ? s.ids.map(i => E.find(e => e.id === i)) : (user ? E.filter(e => e.a.n === 'Marta Alves') : E)
const total = s.total || (user ? '41' : '1,284')
const sentence = e => e.s + e.em + (e.tail || '')
const Av = (a, z = 24) => {
const base = 'width:' + z + 'px;height:' + z + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;'
if (a.k === 'user') return S(base + 'background:var(--rc-sand);color:var(--rc-on-sand);font:600 ' + Math.round(z * 0.42) + "px 'IBM Plex Sans',sans-serif", a.n.split(' ').map(x => x[0]).join('').slice(0, 2).toUpperCase())
return S(base + 'border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I({ key: 'key-round', system: 'cog', anon: 'user-round-x' }[a.k], Math.round(z * 0.6)))
}
const ActorBd = a => a.k === 'key' ? Bd('API key', 'neutral') : a.k === 'system' ? Bd('System', 'neutral') : null
const ell = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0'
const link = 'text-decoration:underline;text-underline-offset:3px;color:var(--ui-text-highlighted)'
const SecIcon = z => S('display:flex;color:var(--ui-warning)', I('shield-alert', z))
// ---------- controls
const Search = () => D('height:' + bh + 'px;' + (m ? 'flex:1;' : 'width:240px;') + 'padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (F.q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + (m ? ';font-size:16px' : ''), I('search', 14), S('flex:1;' + ell, F.q || 'Search activity'), F.q && S('width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
const Sel = (val, ph, w, icon) => D('height:' + bh + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (val ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + ';' + (w ? 'width:' + w + 'px;flex-shrink:0' : 'width:100%'), icon && I(icon, 14, 'color:var(--ui-text-muted)'), S('flex:1;' + ell, val || ph), I('chevron-down', 14, 'color:var(--ui-text-muted)'))
const Seg = (opts, on, fw) => D('display:flex;border:1px solid var(--ui-border-accented);flex-shrink:0;' + (fw ? 'width:100%' : ''), ...opts.map(o => S('height:' + (bh - 2) + 'px;padding:0 ' + (m ? 12 : 10) + 'px;' + (fw ? 'flex:1;justify-content:center;' : '') + 'display:flex;align-items:center;gap:6px;white-space:nowrap;font-weight:' + (o === on ? 500 : 400) + ';background:' + (o === on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (o === on ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)'), o === 'Custom' && I('calendar', 14), o)))
const F = { q: '', actor: '', cat: '', out: 'All', range: '7 days', ...(s.f || {}) }
const RANGES = ['Today', '7 days', '30 days', 'Custom']
const chips = []
if (F.q) chips.push('Search: ' + F.q)
if (F.actor && !user) chips.push('Person: ' + F.actor)
if (F.cat) chips.push('Category: ' + F.cat)
if (F.out !== 'All') chips.push('Outcome: ' + F.out)
if (F.range !== '7 days') chips.push('Dates: ' + (F.range === 'Custom' ? (F.custom || '1 – 30 Sep 2026') : F.range))
const nF = chips.length
const Chips = () => nF > 0 && D('display:flex;flex-wrap:wrap;align-items:center;gap:8px', ...chips.map(c => S('height:' + (m ? 32 : 24) + 'px;padding:0 4px 0 8px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-elevated);border:1px solid var(--ui-border);color:var(--ui-text-highlighted);font-size:12px;font-weight:500', c, S('width:20px;height:20px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 12)))), S('height:' + (m ? 44 : 24) + 'px;display:flex;align-items:center;padding:0 6px;font-weight:500;font-size:12px;' + link, 'Clear all'))
const FilterBar = () => m
? D('display:flex;gap:8px', Search(), h('span', { style: P('height:44px;padding:0 12px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:13px;flex-shrink:0') }, I('sliders-horizontal', 16), 'Filters', nF > 0 && S('min-width:18px;height:18px;padding:0 5px;display:inline-flex;align-items:center;justify-content:center;background:var(--ui-primary);color:var(--ui-bg);font:500 11px \'IBM Plex Mono\',monospace', String(nF))))
: D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', Search(), !user && Sel(F.actor, 'All people', 168, 'user-round'), Sel(F.cat, 'All categories', 176, 'layout-list'), Seg(['All', 'Succeeded', 'Failed'], F.out), D('flex:1;min-width:0'), Seg(RANGES, F.range))
// ---------- summary
const mk = (l, v, sub, ic, tone, ser) => ({ l, v, sub, ic, tone, ser })
const STAT = s.sum === 'zero' ? [mk('Total actions', '214', '4% vs previous 7 days', 'trending-down', 'n', [3, 5, 2, 6, 4, 1, 2]), mk('Active people', '5', '4 people, 1 API key', '', 'n', [2, 3, 2, 3, 2, 1, 2]), mk('Failed actions', '0', 'Nothing failed', 'circle-check', 'ok0', [0, 0, 0, 0, 0, 0, 0]), mk('Security events', '0', 'Nothing to flag', 'circle-check', 'ok0', [0, 0, 0, 0, 0, 0, 0])]
: s.sum === 'none' ? [mk('Total actions', '0', 'No data yet', '', 'n', [0, 0, 0, 0, 0, 0, 0]), mk('Active people', '0', 'No data yet', '', 'n', [0, 0, 0, 0, 0, 0, 0]), mk('Failed actions', '0', 'Nothing failed', 'circle-check', 'ok0', [0, 0, 0, 0, 0, 0, 0]), mk('Security events', '0', 'Nothing to flag', 'circle-check', 'ok0', [0, 0, 0, 0, 0, 0, 0])]
: [mk('Total actions', '1,284', '12% vs previous 7 days', 'trending-up', 'n', [4, 6, 5, 8, 7, 9, 6]), mk('Active people', '9', '7 people, 2 API keys', '', 'n', [5, 6, 6, 7, 7, 8, 9]), mk('Failed actions', '6', '0.5% of all actions', 'circle-x', 'error', [0, 1, 0, 2, 1, 0, 2]), mk('Security events', '11', '5 failed logins', 'shield-alert', 'warning', [1, 0, 2, 1, 3, 2, 2])]
const Bars = (ser, tone, z) => {
const mx = Math.max(...ser, 1), col = tone === 'error' ? 'var(--ui-error)' : tone === 'warning' ? 'var(--ui-warning)' : tone === 'ok0' ? 'var(--ui-border-accented)' : 'var(--ui-text-muted)'
return D('display:flex;align-items:flex-end;gap:2px;height:' + z + 'px;flex-shrink:0', ...ser.map(v => S('display:block;width:4px;height:' + Math.max(2, Math.round(v / mx * z)) + 'px;background:' + col)))
}
const StatCard = (c, i) => {
const act = s.card === i, tone = c.tone
const bg = tone === 'error' ? 'var(--rc-error-soft)' : tone === 'warning' ? 'var(--rc-warning-soft)' : 'var(--ui-bg)'
const vc = tone === 'error' ? 'var(--ui-error)' : tone === 'warning' ? 'var(--ui-warning)' : 'var(--ui-text-highlighted)'
const sc = tone === 'ok0' ? 'var(--ui-success)' : 'var(--ui-text-muted)'
return D('display:flex;flex-direction:column;gap:2px;padding:' + (m ? '10px 12px' : '12px 14px') + ';background:' + bg + ';border:1px solid ' + (act ? 'var(--ui-primary)' : 'var(--ui-border)') + ';' + (act ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : '') + (m ? 'flex:0 0 156px;' : 'min-width:0;'),
D('display:flex;align-items:center;justify-content:space-between;gap:8px', S('color:var(--ui-text-toned);font-weight:500;' + ell, c.l), Bars(c.ser, tone, m ? 16 : 20)),
S('font:600 ' + (m ? 22 : 24) + "px/32px 'IBM Plex Sans',sans-serif;color:" + vc, c.v),
S('display:flex;align-items:center;gap:4px;font-size:12px;color:' + sc + ';' + ell, c.ic && I(c.ic, 12), c.sub))
}
const SkelCard = () => D('display:flex;flex-direction:column;gap:8px;padding:' + (m ? '10px 12px' : '14px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);' + (m ? 'flex:0 0 156px' : 'min-width:0'), sk('55%', 12), sk('40%', 22), sk('70%', 10))
const Strip = () => {
if (user) return null
const kids = s.state === 'loading' ? [0, 1, 2, 3].map(SkelCard) : STAT.map(StatCard)
return m ? D('display:flex;gap:8px;overflow:hidden;margin:0 -16px;padding:0 16px', ...kids) : D('display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px', ...kids)
}
// ---------- table
const cols = user ? '96px minmax(0,1fr) 230px 120px' : '96px 210px minmax(0,1fr) 230px 120px'
const TableHead = () => D('display:grid;grid-template-columns:' + cols + ';height:36px;align-items:center;padding-right:16px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500;flex-shrink:0', S('padding-left:16px;display:flex;align-items:center;gap:4px;color:var(--ui-text-highlighted)', 'When', I('arrow-down', 12)), !user && S('', 'Actor'), S('', 'Action'), S('', 'Target'), S('', 'Outcome'))
const Day = d => D('display:flex;align-items:center;height:32px;padding:0 ' + (m ? 16 : 16) + 'px;background:var(--ui-bg-muted);border-top:1px solid var(--ui-border);font-weight:600;font-size:12px;color:var(--ui-text-highlighted);flex-shrink:0', d)
const Tip = e => S('position:absolute;left:12px;top:38px;z-index:5;padding:6px 8px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;white-space:nowrap;box-shadow:var(--rc-shadow-pop)', e.abs)
const TargetCell = e => !e.tk ? S('color:var(--ui-text-muted)', '—') : D('display:flex;flex-direction:column;min-width:0;padding-right:12px', e.ex ? S('display:flex;align-items:center;gap:4px;' + link, S(ell, e.em), I('arrow-up-right', 12, 'color:var(--ui-text-muted)')) : D('display:flex;align-items:center;gap:6px;min-width:0', S('flex:1;text-decoration:line-through;color:var(--ui-text-muted);' + ell, e.em), Bd('Deleted', 'del')), S('font-size:12px;color:var(--ui-text-muted)', e.tk))
const Row = e => {
const hov = s.tip === e.id, sel = s.panel === e.id
return D('position:relative;z-index:' + (hov ? 3 : 1) + ';display:grid;grid-template-columns:' + cols + ';align-items:center;min-height:52px;padding-right:16px;border-top:1px solid var(--ui-border);background:' + (sel || hov ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';' + (e.sec ? 'box-shadow:inset 2px 0 0 var(--ui-warning);' : ''),
S('padding-left:16px;position:relative;color:var(--ui-text-toned);white-space:nowrap', e.rel, hov && Tip(e)),
!user && D('display:flex;align-items:center;gap:8px;min-width:0;padding-right:12px', Av(e.a), S('font-weight:500;color:var(--ui-text-highlighted);' + ell, e.a.n), ActorBd(e.a)),
D('display:flex;align-items:center;gap:10px;min-width:0;padding-right:16px', S('display:flex;color:var(--ui-text-muted)', I(CI[e.cat], 16)), S('color:var(--ui-text);' + ell, e.s, S('font-weight:600;color:var(--ui-text-highlighted)', e.em), e.tail || ''), e.sec && SecIcon(14)),
TargetCell(e), D('display:flex', Out(e)))
}
const Card = e => {
const sel = s.panel === e.id
return D('display:flex;flex-direction:column;gap:8px;padding:12px 16px;border-top:1px solid var(--ui-border);background:' + (sel ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';' + (e.sec ? 'box-shadow:inset 2px 0 0 var(--ui-warning)' : ''),
D('display:flex;align-items:center;gap:8px;min-width:0', !user && Av(e.a, 24), !user && S('font-weight:500;color:var(--ui-text-highlighted);' + ell, e.a.n), !user && ActorBd(e.a), D('flex:1'), S('color:var(--ui-text-muted);font-size:12px;white-space:nowrap', e.rel)),
D('display:flex;gap:8px;align-items:flex-start', S('display:flex;padding-top:2px;color:var(--ui-text-muted)', I(CI[e.cat], 16)), S('flex:1;min-width:0;color:var(--ui-text);font-size:15px;line-height:22px;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden', e.s, S('font-weight:600;color:var(--ui-text-highlighted)', e.em), e.tail || '')),
D('display:flex;align-items:center;gap:6px;flex-wrap:wrap', Out(e), e.sec && Bd('Security', 'sec'), e.tk && !e.ex && Bd('Target deleted', 'del')))
}
const rowsOf = fn => { const out = []; let day = null; list.forEach(e => { if (e.day !== day) { out.push(Day(e.day)); day = e.day } out.push(fn(e)) }); return out }
const SkelRows = () => Array.from({ length: m ? 5 : 9 }, (_, i) => m
? D('display:flex;flex-direction:column;gap:10px;padding:14px 16px;border-top:1px solid var(--ui-border)', D('display:flex;gap:8px;align-items:center', sk('24px', 24), sk('90px', 12), D('flex:1'), sk('40px', 10)), sk(['90%', '75%', '85%'][i % 3], 14), sk('70px', 20))
: D('display:grid;grid-template-columns:' + cols + ';align-items:center;height:52px;padding-right:16px;border-top:1px solid var(--ui-border)', S('padding-left:16px', sk('56px')), !user && D('display:flex;gap:8px;align-items:center', sk('24px', 24), sk('90px')), sk(['70%', '55%', '82%'][i % 3], 12), sk('60%'), sk('72px', 20)))
const Table = () => D('flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;background:var(--ui-bg);border:1px solid var(--ui-border)', !m && TableHead(), ...(s.state === 'loading' ? (m ? [] : []).concat(SkelRows()) : rowsOf(m ? Card : Row)))
const PgBtn = (c, on, dis) => S('width:' + bh + 'px;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)') + ';font-weight:500;flex-shrink:0;opacity:' + (dis ? 0.4 : 1), c)
const Pager = () => s.state === 'loading' ? null : m
? D('display:flex;align-items:center;gap:8px;flex-shrink:0', PgBtn(I('chevron-left'), 0, 1), D('flex:1;display:flex;flex-direction:column;align-items:center;font-size:12px;color:var(--ui-text-muted)', S('color:var(--ui-text-highlighted);font-weight:500', 'Page 1 of ' + (user ? '2' : '52')), S('', '1–25 of ' + total)), D('width:96px;flex-shrink:0', Sel('25', '', 0)), PgBtn(I('chevron-right')))
: D('display:flex;align-items:center;gap:16px;flex-shrink:0', S('color:var(--ui-text-muted)', 'Showing 1–' + (user ? '25' : '25') + ' of ' + total), D('flex:1'), D('display:flex;align-items:center;gap:8px;color:var(--ui-text-muted)', 'Rows per page', D('width:80px', Sel('25', '', 0))), D('display:flex;gap:4px', PgBtn(I('chevron-left'), 0, 1), PgBtn('1', 1), PgBtn('2'), PgBtn('3'), PgBtn('…'), PgBtn(user ? '2' : '52'), PgBtn(I('chevron-right'))))
// ---------- header + page
const title = user ? 'My activity' : 'Activity'
const sub = user ? 'Everything you\'ve done on this site.' : 'Who did what, and when, across your site.'
const noData = s.state === 'none'
const Header = () => D('display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap;flex-shrink:0', D('flex:1;min-width:180px;display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted)', sub)), !user && s.state !== 'forbidden' && B('Export CSV', { icon: 'download', dis: noData || s.state === 'error' }))
const Page = () => {
const k = [Header()]
if (s.state === 'forbidden') { k.push(Empty('lock', 'You can\'t view this activity', 'Your role doesn\'t include this page. Ask an owner or admin if you need access.', 'Back to home', false)); return k }
const st = Strip(); if (st) k.push(st)
k.push(FilterBar()); const c = Chips(); if (c) k.push(c)
if (s.state === 'error') k.push(Alert('error', 'circle-x', 'Couldn\'t load activity', 'Check your connection and try again.', 'Try again'))
else if (s.state === 'none') k.push(Empty('history', 'No activity yet', user ? 'Your actions will appear here once you start working on the site.' : 'Actions on articles, media, users and API keys will appear here.'))
else if (s.state === 'nomatch') k.push(Empty('search-x', 'No activity matches these filters', 'Try a different search or a wider date range.', 'Clear filters', false))
else { k.push(Table()); const pg = Pager(); if (pg) k.push(pg) }
return k
}
// ---------- slide-over
const Av2 = Av
const Panel = () => {
const e = E.find(x => x.id === s.panel), i = list.indexOf(e)
const lab = t => S('color:var(--ui-text-muted)', t)
const val = (...c) => D('display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;color:var(--ui-text-highlighted)', ...c)
const Diff = d => {
if (d.note) return D('display:flex;flex-direction:column;gap:6px;padding:12px;border-top:1px solid var(--ui-border-muted)', S('font-weight:500;color:var(--ui-text-highlighted)', d.f), S('display:flex;align-items:center;gap:6px;color:var(--ui-text-toned)', I('file-pen-line', 14), d.note))
const clamp = d.long && !s.expand
const box = (t, bg, bd, c) => S('display:block;padding:8px 10px;background:' + bg + ';border:1px solid ' + bd + ';color:' + c + ';overflow-wrap:anywhere;' + (clamp ? 'display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden' : ''), t)
return D('display:flex;flex-direction:column;gap:8px;padding:12px;border-top:1px solid var(--ui-border-muted)', S('font-weight:500;color:var(--ui-text-highlighted)', d.f),
D('display:grid;grid-template-columns:' + (m ? 'minmax(0,1fr)' : 'minmax(0,1fr) 16px minmax(0,1fr)') + ';gap:' + (m ? 6 : 8) + 'px;align-items:' + (m ? 'stretch' : 'start'), D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-size:11px;color:var(--ui-text-muted)', 'Before'), box(d.b, 'var(--ui-bg-elevated)', 'var(--ui-border)', 'var(--ui-text-toned)')), m ? S('display:flex;justify-content:center;color:var(--ui-text-muted)', I('arrow-down', 14)) : S('display:flex;padding-top:30px;color:var(--ui-text-muted)', I('arrow-right', 14)), D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-size:11px;color:var(--ui-text-muted)', 'After'), box(d.a, 'var(--ui-bg)', 'var(--ui-border-accented)', 'var(--ui-text-highlighted)'))),
d.long && S('align-self:flex-start;font-weight:500;font-size:12px;min-height:' + (m ? 32 : 16) + 'px;' + link, clamp ? 'Show more' : 'Show less'))
}
const body = D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:20px',
D('display:flex;flex-direction:column;gap:10px', S('font:600 18px/26px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted);overflow-wrap:anywhere', sentence(e)), D('display:flex;gap:6px;flex-wrap:wrap;align-items:center', Out(e), e.sec && Bd('Security', 'sec'), S('display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--ui-text-muted)', I(CI[e.cat], 14), CL[e.cat]))),
!e.ok && Alert('error', 'circle-x', 'Why it failed', e.why),
D('display:grid;grid-template-columns:96px minmax(0,1fr);gap:10px 12px;align-items:start', lab('When'), D('display:flex;flex-direction:column;gap:2px', S('color:var(--ui-text-highlighted)', e.abs + ' (your time)'), S('color:var(--ui-text-muted);font-size:12px', e.utc)), lab('Actor'), val(Av(e.a, 24), S('font-weight:500', e.a.n), ActorBd(e.a)), lab('Source'), val(e.src), lab('IP address'), e.ip ? S(MONO + ';font-size:12px;color:var(--ui-text-highlighted)', e.ip) : S('color:var(--ui-text-muted)', 'Not applicable'), lab('Browser'), e.ua ? S('color:var(--ui-text-highlighted)', e.ua) : S('color:var(--ui-text-muted)', 'Not applicable')),
e.tk && D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Target'), D('display:flex;flex-direction:column;gap:6px;border:1px solid var(--ui-border);padding:10px 12px', D('display:flex;align-items:center;gap:8px;min-width:0', e.ex ? S('flex:1;display:flex;align-items:center;gap:4px;overflow-wrap:anywhere;' + link, e.em, I('arrow-up-right', 14, 'color:var(--ui-text-muted)')) : S('flex:1;text-decoration:line-through;color:var(--ui-text-muted);overflow-wrap:anywhere', e.em), !e.ex && Bd('Deleted', 'del')), S('font-size:12px;color:var(--ui-text-muted)', e.tk + ' · ' + e.tid), !e.ex && S('font-size:12px;color:var(--ui-text-toned)', 'This item was deleted. Its name is kept for reference.'))),
e.diff && D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Changes'), D('border:1px solid var(--ui-border)', ...e.diff.map((d, j) => { const r = Diff(d); return r }))))
const foot = D('flex-shrink:0;display:flex;align-items:center;gap:8px;padding:12px ' + (m ? 16 : 20) + 'px;border-top:1px solid var(--ui-border)', B('Previous event', { icon: 'chevron-up', dis: i === 0, full: m }), B('Next event', { icon: 'chevron-down', dis: i === list.length - 1, full: m }), !m && D('flex:1'), !m && S('color:var(--ui-text-muted);font-size:12px', 'Event ' + (i + 1) + ' of ' + total))
const head = D('height:56px;flex-shrink:0;display:flex;align-items:center;gap:12px;padding:0 ' + (m ? 8 : 20) + 'px 0 ' + (m ? 16 : 20) + 'px;border-bottom:1px solid var(--ui-border)', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Activity details'), m && S('color:var(--ui-text-muted);font-size:12px', (i + 1) + ' of ' + total), S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x')))
return D('position:absolute;inset:0;z-index:40;display:flex;justify-content:' + (m ? 'stretch' : 'flex-end') + ';align-items:' + (m ? 'flex-end' : 'stretch') + ';background:var(--rc-overlay)',
D((m ? 'width:100%;height:88%;' : 'width:480px;height:100%;') + 'background:var(--ui-bg);display:flex;flex-direction:column;box-shadow:var(--rc-shadow-modal)', m && D('flex-shrink:0;display:flex;justify-content:center;padding-top:8px', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')), head, body, foot))
}
const Drawer = () => {
const sec = (l, c) => D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', l), c)
return D('position:absolute;inset:0;z-index:40;display:flex;align-items:flex-end;background:var(--rc-overlay)', D('width:100%;background:var(--ui-bg);display:flex;flex-direction:column;box-shadow:var(--rc-shadow-modal)',
D('display:flex;justify-content:center;padding-top:8px', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
D('height:56px;display:flex;align-items:center;padding:0 8px 0 16px;border-bottom:1px solid var(--ui-border)', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Filters'), S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
D('padding:16px;display:flex;flex-direction:column;gap:16px', !user && sec('Person', Sel(F.actor, 'All people', 0, 'user-round')), sec('Category', Sel(F.cat, 'All categories', 0, 'layout-list')), sec('Outcome', Seg(['All', 'Succeeded', 'Failed'], F.out, 1)), sec('Dates', D('display:flex;flex-direction:column;gap:8px', Seg(['Today', '7 days', '30 days', 'Custom'], F.range, 1), F.range === 'Custom' && D('display:flex;gap:8px;align-items:center', D('flex:1', Sel('1 Sep 2026', '', 0, 'calendar')), D('flex:1', Sel('30 Sep 2026', '', 0, 'calendar')))))),
D('display:flex;gap:8px;padding:12px 16px 16px;border-top:1px solid var(--ui-border)', B('Clear all', { full: true, dis: nF === 0 }), B('Show ' + (s.count || '23') + ' results', { k: 'primary', full: true }))))
}
// ---------- home + settings
const Rows5 = (ls, own) => ls.map((e, i) => D('display:flex;gap:10px;align-items:flex-start;padding:10px 0;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), Av(e.a, 24), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('color:var(--ui-text);overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden', e.s, S('font-weight:600;color:var(--ui-text-highlighted)', e.em), e.tail || ''), S('font-size:12px;color:var(--ui-text-muted)', own ? e.rel : e.a.n + ' · ' + e.rel)), !e.ok && Bd('Failed', 'fail'), e.sec && e.ok && SecIcon(14)))
const ActCard = () => {
const own = user, ls = own ? E.filter(e => e.a.n === 'Marta Alves') : E.slice(0, 5)
const st = s.card || 'ok'
const inner = st === 'loading' ? Array.from({ length: 5 }, (_, i) => D('display:flex;gap:10px;padding:10px 0;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), sk('24px', 24), D('flex:1;display:flex;flex-direction:column;gap:6px', sk(['85%', '70%', '90%', '60%', '78%'][i], 12), sk('35%', 10))))
: st === 'error' ? [Alert('error', 'circle-x', 'Couldn\'t load recent activity', null, 'Try again')]
: st === 'empty' ? [D('padding:24px 0;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center', S('color:var(--ui-text-muted)', I('history', 20)), S('font-weight:600;color:var(--ui-text-highlighted)', 'No activity yet'), S('color:var(--ui-text-muted);max-width:240px', own ? 'Your actions will appear here.' : 'Actions on your site will appear here.'))]
: Rows5(ls, own)
return D('background:var(--ui-bg);border:1px solid var(--ui-border);display:flex;flex-direction:column;min-width:0', D('display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid var(--ui-border)', S('flex:1;font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', own ? 'Your recent activity' : 'Recent activity'), S('display:flex;align-items:center;gap:4px;font-weight:500;min-height:' + (m ? 44 : 20) + 'px;' + link, 'View all', I('arrow-right', 14))), D('padding:4px 16px 6px;display:flex;flex-direction:column', ...inner))
}
const Ph = (t, hh) => D('height:' + hh + 'px;border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);text-align:center;padding:16px', t)
const HomeBody = () => [D('display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Home'), S('color:var(--ui-text-muted)', 'Welcome back, ' + ME.split(' ')[0] + '.')), m ? D('display:flex;flex-direction:column;gap:16px', ActCard(), Ph('Existing Home cards', 160)) : D('display:grid;grid-template-columns:minmax(0,1fr) 420px;gap:16px;align-items:start', Ph('Existing Home cards', 420), ActCard())]
const RET = { '30': '30 days', '90': '90 days', '365': '365 days', '0': 'Forever' }
const SettingsBody = () => {
const r = s.ret || '90', fv = r === '0'
const dl = (k, v) => D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:' + (m ? 0 : 12) + 'px', S('width:' + (m ? 'auto' : '120px') + ';color:var(--ui-text-muted);flex-shrink:0', k), S('color:var(--ui-text-highlighted)', v))
const card = D('background:var(--ui-bg);border:1px solid var(--ui-border);max-width:640px;display:flex;flex-direction:column',
D('display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid var(--ui-border)', D('flex:1;display:flex;flex-direction:column', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Retention'), S('color:var(--ui-text-muted);font-size:12px', 'How long activity is kept.')), s.saved && S('display:flex;align-items:center;gap:4px;color:var(--ui-success);font-size:12px', I('check', 14), 'Saved')),
D('padding:16px;display:flex;flex-direction:column;gap:16px',
s.err && Alert('error', 'circle-x', 'Couldn\'t save the retention period', 'Your previous setting is still in place.', 'Try again'),
D('display:flex;flex-direction:column;gap:6px;max-width:260px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Keep activity for'), Sel(RET[s.shorten ? '30' : r], '', 0), S('color:var(--ui-text-muted);font-size:12px', fv ? 'Nothing is removed automatically. The log grows over time.' : 'Older events are removed automatically.')),
s.shorten && Alert('warning', 'triangle-alert', 'Events older than 30 days will be removed', 'This happens at the next cleanup on 7 Oct 2026, 03:00. It can\'t be undone.', ['Apply 30 days', 'Cancel']),
Alert('info', 'lock', 'Activity logs are append-only', 'Events can\'t be edited or deleted from the app. Only the retention period removes them.'),
D('display:flex;flex-direction:column;gap:6px;padding-top:12px;border-top:1px solid var(--ui-border)', dl('Last cleanup', fv ? 'Never' : '6 Oct 2026, 03:00 · 142 events removed'), dl('Next cleanup', fv ? 'Not scheduled' : '7 Oct 2026, 03:00'))))
return [D('display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Settings'), S('color:var(--ui-text-muted)', 'Site-wide options. Only owners can change them.')), Ph('Existing Settings cards', m ? 100 : 120), card]
}
// ---------- shell
const logo = h('svg', { width: 26, height: 26, viewBox: '0 0 16 16', shapeRendering: 'crispEdges', style: { color: 'var(--ui-text-highlighted)', flexShrink: 0 } }, h('rect', { width: 16, height: 16, fill: 'currentColor' }), h('path', { fill: 'var(--ui-bg)', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
const kind = s.kind || 'page'
const navKey = kind === 'home' ? 'home' : kind === 'settings' ? 'settings' : 'activity'
const nav = (user ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']])
const side = D('width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px', D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px', logo, S('font-weight:600;font-size:14px;color:var(--ui-text-highlighted)', 'RenewCMS')),
...nav.map(([k, ic, t]) => D('display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:' + (k === navKey ? 'var(--rc-sand)' : 'transparent') + ';color:' + (k === navKey ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)') + ';font-weight:' + (k === navKey ? 500 : 400), I(ic), t)),
D('flex:1'), D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;color:var(--ui-text-highlighted)', Av(U(ME), 26), S('flex:1;font-weight:500', ME), I('chevrons-up-down', 14)))
const crumb = { page: title, home: 'Home', settings: 'Settings' }[kind]
const topbar = m
? D('height:56px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 6px;gap:4px;color:var(--ui-text-highlighted)', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I('menu', 20)), S('flex:1;font-weight:600;font-size:16px', crumb))
: D('height:48px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 24px;gap:8px;color:var(--ui-text-muted)', I('panel-left-close'), S('width:1px;height:16px;background:var(--ui-border);margin:0 6px'), S('color:var(--ui-text-highlighted);font-weight:500', crumb))
const kids = kind === 'home' ? HomeBody() : kind === 'settings' ? SettingsBody() : Page()
const content = D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 24) + 'px;display:flex;flex-direction:column;gap:16px', ...kids)
const root = D('width:' + (m ? 390 : 1440) + 'px;height:' + (m ? 844 : 900) + 'px;position:relative;display:flex;flex-direction:' + (m ? 'column' : 'row') + ';background:var(--ui-bg-muted);color:var(--ui-text);font-family:\'IBM Plex Sans\',sans-serif;font-size:13px;line-height:20px;overflow:hidden',
!m && side, D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', topbar, content), s.panel && Panel(), m && s.drawer && Drawer())
return { view: root }
}
}
</script>
</body>
</html>
@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}</style>
</helmet>
<div ref="{{ rootRef }}" data-screen-label="Activity screens" style="background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screen: Activity log</h1>
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">New /activity screen, a Recent activity card for Home, and a Retention card for Settings. 1440 px at 50%, 390 px at 75%. Owner and admin see everything; regular users get "My activity". The theme switch shows every state in dark mode. Copy, component mapping and API calls are at the bottom.</p>
</div>
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
</sc-for>
</div>
</div>
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="5">
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span></div>
</div>
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
<div style="display:flex;flex-direction:column;gap:12px">
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
<div style="display:flex;gap:24px;align-items:flex-start">
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1440px;height:900px;zoom:0.5"><dc-import name="ActivityFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></div></div>
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><dc-import name="ActivityFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></div></div>
</div>
</div>
</sc-for>
</div>
</section>
</sc-for>
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px">
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Copy per state</h2>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<sc-for list="{{ copy }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for>
</div>
</section>
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px">
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Component inventory (Nuxt UI v4)</h2>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<sc-for list="{{ comps }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for>
</div>
</section>
<section style="display:flex;flex-direction:column;gap:20px;max-width:1240px">
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API calls</h2>
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
<sc-for list="{{ api }}" as="r" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:220px minmax(0,1fr);padding:10px 12px;border-top:1px solid var(--ui-border);line-height:20px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.a }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.b }}</span></div></sc-for>
</div>
</section>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;system&quot;,&quot;light&quot;,&quot;dark&quot;],&quot;default&quot;:&quot;light&quot;,&quot;tsType&quot;:&quot;'system' | 'light' | 'dark'&quot;}}">
class Component extends DCLogic {
state = { mode: this.props.theme ?? 'light', spec: { copy: [], comps: [], api: [] } }
rootRef = React.createRef()
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-on-sand': '#111111', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-on-sand': '#111111', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
resolved() {
if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
return this.state.mode
}
apply() {
const el = this.rootRef.current
if (!el) return
const vars = this.resolved() === 'dark' ? this.dark : this.light
for (const k in vars) el.style.setProperty(k, vars[k])
document.body.style.background = vars['--rc-canvas']
}
componentDidMount() {
this.apply()
import('./activity-spec.js').then(x => this.setState({ spec: { copy: x.copy, comps: x.comps, api: x.api } })).catch(() => {})
}
componentDidUpdate(p) {
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
this.apply()
}
groups() {
const W = (name, trigger, o = {}, mo = {}) => ({ name, trigger, s: o, m: { ...o, ...mo } })
const U = { role: 'user' }
const f1 = { cat: 'Authentication', out: 'Failed', range: '30 days' }
return [
{ label: 'Activity, owner and admin', title: 'Activity, owner and admin', route: '/activity', api: 'GET /v1/activity · GET /v1/activity/summary', states: [
W('Default, large dataset', 'Newest first, day separators, a security row, 1,284 events across 52 pages. Hover on the first row shows the exact time.', { tip: 1 }),
W('Loading', 'Skeleton strip and table while both requests are in flight.', { state: 'loading' }),
W('Zero-value summary strip', 'Quiet period: Failed and Security show a calm 0.', { sum: 'zero' }),
W('Filtered, card applied', 'Failed actions card clicked, chips and Clear all. Mobile shows the count on Filters.', { f: f1, card: 2, count: 6, total: '6', ids: [3], sum: 'default' }, {}),
W('Mobile filters sheet', 'Filters open as a bottom sheet (desktop uses the inline bar above).', { f: { ...f1, range: 'Custom' }, drawer: 1, count: 6, total: '6', ids: [3] }),
W('Empty, no activity yet', 'New site. Export is disabled.', { state: 'none', sum: 'none' }),
W('Empty, filters match nothing', 'Offers Clear filters.', { state: 'nomatch', f: { q: 'sitemap', cat: 'Media', out: 'Failed' }, total: '0' }),
W('Error with retry', 'Request failed. The summary strip still shows if its call succeeded.', { state: 'error' }),
W('Forbidden (403)', 'API returned 403 for this role or filter.', { state: 'forbidden', role: 'user' })
] },
{ label: 'Details slide-over', title: 'Details slide-over and bottom sheet', route: 'Row click', api: 'Uses the item from GET /v1/activity', states: [
W('Edit with a diff', 'Title change and "Body edited (+312 characters)". No body text.', { panel: 2 }),
W('Settings diff, long text', 'Long values clamp to two lines with Show more.', { panel: 11 }),
W('Failed action, very long target title', 'Failure reason shown, target title wraps.', { panel: 9 }),
W('Deleted target, no diff', 'Struck-through target with a Deleted badge.', { panel: 8 }),
W('System-initiated, no human actor', 'Scheduler with a System badge. IP and browser not applicable.', { panel: 5 }),
W('Security event, failed login', 'Actor not signed in. Restrained shield marker.', { panel: 3 })
] },
{ label: 'My activity', title: 'My activity, regular user', route: '/activity', api: 'GET /v1/activity (server scoped to the user)', states: [
W('Default', 'No summary strip, no actor filter, no Export. Title becomes My activity.', U),
W('Empty', 'The user has not done anything yet.', { ...U, state: 'none' }),
W('Details', 'Same slide-over, own event.', { ...U, panel: 1 })
] },
{ label: 'Home card', title: 'Home, Recent activity card', route: '/', api: 'GET /v1/activity?size=5', states: [
W('Owner or admin', 'Last 5 events with the actor name.', { kind: 'home' }),
W('Regular user', 'Own events only.', { kind: 'home', ...U }),
W('Loading', '', { kind: 'home', card: 'loading' }),
W('Error', '', { kind: 'home', card: 'error' }),
W('Empty', '', { kind: 'home', card: 'empty', ...U })
] },
{ label: 'Settings retention', title: 'Settings, Retention (owner only)', route: '/settings', api: 'GET /v1/settings · PATCH /v1/settings', states: [
W('Default, 90 days', 'Saves on change.', { kind: 'settings', saved: true }),
W('Shortening the period', 'Confirms before older events are removed.', { kind: 'settings', ret: '90', shorten: true }),
W('Forever', 'No cleanup scheduled.', { kind: 'settings', ret: '0' }),
W('Save error', '', { kind: 'settings', err: true })
] }
]
}
renderVals() {
const cur = this.state.mode
const spec = this.state.spec
const rows = a => a.map(r => ({ a: r[0], b: r[1] }))
return {
rootRef: this.rootRef,
modes: ['light', 'dark', 'system'].map(k => ({ label: k[0].toUpperCase() + k.slice(1), on: cur === k, bg: cur === k ? 'var(--ui-primary)' : 'var(--ui-bg)', fg: cur === k ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', pick: () => this.setState({ mode: k }) })),
groups: this.groups(),
copy: rows(spec.copy), comps: rows(spec.comps), api: rows(spec.api)
}
}
}
</script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
export const copy = [
['Page title', 'Activity. Regular users: My activity.'],
['Subtitle', 'Who did what, and when, across your site. Regular users: Everything you\'ve done on this site.'],
['Header action', 'Export CSV (owner and admin only). Preparing: Preparing file… Success toast: Export started. Your file will download shortly. Error toast: Couldn\'t export activity. Try again.'],
['Summary labels', 'Total actions · Active people · Failed actions · Security events'],
['Summary sub-lines', '12% vs previous 7 days · 7 people, 2 API keys · 0.5% of all actions · 5 failed logins'],
['Summary zero', 'Failed: Nothing failed. Security: Nothing to flag. No data at all: No data yet.'],
['Filter bar', 'Search activity · All people · All categories · All / Succeeded / Failed · Today / 7 days / 30 days / Custom'],
['Chips', 'Search: spring · Person: Marta Alves · Category: Authentication · Outcome: Failed · Dates: 1 – 30 Sep 2026 · Clear all'],
['Mobile filters', 'Filters (count badge) · sheet title Filters · Clear all · Show 23 results'],
['Day separators', 'Today · Yesterday · 3 Oct 2026'],
['Outcome badges', 'Succeeded · Failed. Row markers: Security (shield icon). Target badge: Deleted. Mobile card: Target deleted.'],
['Actor badges', 'API key · System. Not signed in: Not signed in.'],
['Empty, no activity', 'No activity yet. Actions on articles, media, users and API keys will appear here. Regular users: Your actions will appear here once you start working on the site.'],
['Empty, no match', 'No activity matches these filters. Try a different search or a wider date range. Action: Clear filters'],
['Error', 'Couldn\'t load activity. Check your connection and try again. Action: Try again'],
['Forbidden (403)', 'You can\'t view this activity. Your role doesn\'t include this page. Ask an owner or admin if you need access. Action: Back to home'],
['Pagination', 'Showing 1–25 of 1,284 · Rows per page 25 · mobile: Page 1 of 52'],
['Slide-over', 'Activity details · When (your time) + UTC line · Actor · Source (Web app, API key, System task) · IP address · Browser · Target · Changes · Before / After · Show more / Show less · Previous event · Next event · Event 2 of 1,284'],
['Slide-over, failed', 'Why it failed. Reason text comes from the API (plain language, no stack traces).'],
['Slide-over, deleted target', 'This item was deleted. Its name is kept for reference.'],
['Slide-over, system', 'Source: System task. IP address and Browser: Not applicable.'],
['Body edits', 'Body edited (+312 characters). No body text is shown.'],
['Home card', 'Recent activity (regular users: Your recent activity) · View all · Empty: No activity yet · Error: Couldn\'t load recent activity. Try again'],
['Retention', 'Retention · How long activity is kept. · Keep activity for: 30 days / 90 days / 365 days / Forever · Older events are removed automatically. · Forever: Nothing is removed automatically. The log grows over time.'],
['Retention notice', 'Activity logs are append-only. Events can\'t be edited or deleted from the app. Only the retention period removes them.'],
['Retention shorten', 'Events older than 30 days will be removed. This happens at the next cleanup on 7 Oct 2026, 03:00. It can\'t be undone. Actions: Apply 30 days · Cancel'],
['Retention status', 'Last cleanup 6 Oct 2026, 03:00 · 142 events removed · Next cleanup 7 Oct 2026, 03:00 · Never / Not scheduled for Forever. Save: Saved · Error: Couldn\'t save the retention period. Your previous setting is still in place.']
]
export const comps = [
['Page shell, sidebar entry', 'Existing dashboard shell. "Activity" (icon history) between Users and Settings, all verified users.'],
['Page header', 'Plain heading + UButton (outline, icon download) for Export CSV.'],
['Summary strip', 'Four UCard, each with a UButton-style click target (as="button"). Sparkline: seven div bars in the card (no chart library). Semantic emphasis via error and warning soft tokens.'],
['Search', 'UInput, icon search, clearable.'],
['Person and Category', 'USelectMenu. Person lists users, API keys and "System" in groups.'],
['Outcome and date presets', 'Groups of UButton (soft = selected, outline = other). Custom opens a date-range popover.'],
['Active filter chips', 'UBadge (variant subtle) with a trailing UButton (ghost, icon x). "Clear all" is UButton variant link.'],
['Timeline table', 'UTable with custom row slots for day separators (full-width row). Cells: UAvatar (initials, key or system icon), UBadge (API key, System, Succeeded, Failed, Deleted, Security).'],
['Row hover time', 'Native title attribute on the time cell (keeps to the allowed component list).'],
['Pagination', 'UPagination + USelectMenu for page size.'],
['Details panel', 'USlideover side="right" on desktop, side="bottom" on mobile. Body uses UCard sections. Prev and next are UButton.'],
['Mobile filters', 'USlideover side="bottom" with the same USelectMenu and UButton controls.'],
['Mobile list', 'UTable replaced below the md breakpoint by a stacked list of UCard rows.'],
['Empty, error, forbidden', 'UEmpty with icon, title, description and one UButton action. Error uses UEmpty in the table area.'],
['Loading', 'Skeleton blocks (bg-accented divs with animate-pulse) inside the same UCard and UTable layout.'],
['Home card', 'UCard with header, a list of UAvatar rows, and a link UButton.'],
['Retention', 'UCard in Settings with USelectMenu and read-only text lines.']
]
export const api = [
['GET /v1/activity', 'Query: q, actor (user:ID, key:ID, system), category (articles, media, users, api_keys, auth, system), outcome (all, success, failed), range (today, 7d, 30d, custom), from, to (ISO dates), page, size (25, 50, 100). Sorted newest first. Returns items (id, created_at, actor {type, id, name}, category, sentence parts, target {type, id, name, exists}, outcome, failure_reason, security, source, ip, user_agent, changes [{field, before, after, kind}]), total, page, size. Users who are not owner or admin are scoped to their own events by the server; requesting another actor returns 403. Body edits return a single change with kind "body" and a character delta, never the text. The same call powers the Home card (size=5) and the slide-over (items carry the detail fields).'],
['GET /v1/activity/summary', 'Query: the same range, from, to. Owner and admin only (403 otherwise). Returns total, previous_total, active_people {users, api_keys}, failed, security, and a 7-day series for each. Clicking a card applies outcome=failed or category in (auth, users role changes, api_keys) to the table, or clears filters for Total.'],
['GET /v1/activity/export', 'Query: the same filters, no page or size. Owner and admin only. Streams text/csv with columns when, actor, action, target, outcome, source, ip. No changes or secrets.'],
['GET /v1/settings', 'Existing endpoint. Adds activity_retention_days (30, 90, 365, 0 for forever), activity_last_cleanup_at, activity_last_cleanup_removed.'],
['PATCH /v1/settings', 'Existing endpoint. Body { activity_retention_days }. Owner only (403 otherwise). Shortening needs the confirm step in the UI first.']
]
+20
View File
@@ -305,3 +305,23 @@ Added by the Media screens, none of which exist yet:
| Unattended run | Settings | No toast. Updated on next visit |
401 and 403 are global and have no local copy.
## Added with Activity
### Activity
| State | Surface | Behaviour |
|---|---|---|
| Default | `/activity` | Strip, filters, day-grouped table, pagination |
| Loading | `/activity` | Skeleton strip and rows after 150 ms |
| Zero-value strip | `/activity` | Failed and Security show a calm 0 |
| Filtered | `/activity` | Chips and "Clear all"; a strip card applies its filter |
| Empty, no activity | `/activity` | "No activity yet"; Export disabled |
| Empty, no match | `/activity` | "No activity matches these filters" with "Clear filters" |
| Error | `/activity` | "Couldn't load activity" with "Try again"; the strip may still show |
| Forbidden (403) | `/activity` | "You can't view this activity." with "Back to home" |
| Details | Slide-over / bottom sheet | Sentence, time, actor, source, target, reason, changes, previous and next |
| My activity | `/activity` | No strip, Person filter or Export; own events only |
| Export | Header button, toasts | "Preparing file…", then "Export started. Your file will download shortly." or "Couldn't export activity. Try again." |
| Home card | `/home` | Owner or admin, regular user, loading, error, empty |
| Retention | `/settings` | Default, shortening (confirm), Forever, save error |
401 is global. The 403 on `/activity` has local copy because the page itself is refused; the 403 on the summary or export of a non-admin is never shown, since the controls are hidden.
+2
View File
@@ -55,6 +55,8 @@ If your editor reports `pattern all:dist: no matching files found`, either run `
```sh
task --list # everything available
task test # Go tests; extra args pass through: task test -- -run TestCreateArticle -v
task test:mysql # MySQL tests against real servers in Docker (mysql:8.4, then mariadb:11)
task test:postgres # PostgreSQL tests against real servers in Docker (postgres:16, then postgres:17)
task cover # coverage of the use cases → tmp/coverage.out
task mutate # gremlins mutation testing → tmp/gremlins.json
task test:web # SPA component tests (Vitest)
+41 -1
View File
@@ -62,7 +62,7 @@ The container receives SIGTERM on stop, and RenewCMS shuts down gracefully withi
## Data volume
State lives on the named volume `renewcms-data` (Compose prefixes it with the project name, usually `renewcms_renewcms-data`; check with `docker volume ls`). It holds the SQLite database, uploads, and update records.
State lives on the named volume `renewcms-data` (Compose prefixes it with the project name, usually `renewcms_renewcms-data`; check with `docker volume ls`). It holds the SQLite database (unless you use MySQL), uploads, and update records.
- `docker compose down` removes the container and keeps the volume. The data survives and `docker compose up -d` brings it back.
- **`docker compose down -v` deletes the volume and all data.** The next start is an empty instance.
@@ -86,6 +86,46 @@ docker run --rm -v VOLUME:/data -v "$PWD":/backup alpine sh -c "tar xzf /backup/
docker compose up -d
```
## MySQL or MariaDB
SQLite on the `/data` volume is the default. [`docker-compose.mysql.yml`](../docker-compose.mysql.yml) runs RenewCMS next to a MySQL 8.4 container instead:
```sh
export MYSQL_ROOT_PASSWORD=<generated>
export DB_PASSWORD=<generated>
docker compose -f docker-compose.mysql.yml up -d
```
It starts the `mysql` service with a named volume `mysql-data` and a healthcheck, and starts `renewcms` only once the database is healthy (`depends_on` with `service_healthy`). RenewCMS gets `DB_DRIVER=mysql`, `DB_HOST=mysql`, `DB_NAME`, `DB_USER` and `DB_PASSWORD` from the compose file and the rest from `docker.env`. `DATA_DIR=/data` keeps update state and the `.sql` pre-update backups on the `renewcms-data` volume, which still holds the uploads. Replace the placeholders; never commit real passwords.
To use MariaDB, change the `mysql` service image to `mariadb:11`, its `MYSQL_*` variables to `MARIADB_*` and its healthcheck to `healthcheck.sh --connect --innodb_initialized`. To use a server outside Compose, drop the `mysql` service and point `DB_HOST` at it. For a server on another machine set `DB_TLS=true`.
Back the database up with `docker compose -f docker-compose.mysql.yml exec mysql mysqldump ...` (a `tar` copy of the volume is not consistent while it runs), and the `renewcms-data` volume as described above. Moving an existing SQLite volume is a manual procedure, see [deployment.md](deployment.md#moving-an-existing-sqlite-site-to-mysql).
## PostgreSQL
[`docker-compose.postgres.yml`](../docker-compose.postgres.yml) runs RenewCMS next to a PostgreSQL 17 container:
```sh
export DB_PASSWORD=<generated>
docker compose -f docker-compose.postgres.yml up -d
```
It starts the `postgres` service with a named volume `postgres-data` and a `pg_isready` healthcheck, and starts `renewcms` only once the database is healthy (`depends_on` with `service_healthy`). RenewCMS gets `DB_DRIVER=postgres`, `DB_HOST=postgres`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` and `DB_SSLMODE=disable` (the traffic stays on the Compose network) from the compose file and the rest from `docker.env`. `DATA_DIR=/data` keeps update state and the `.sql` pre-update backups on the `renewcms-data` volume. For a server on another machine drop the `postgres` service, point `DB_HOST` at it and set `DB_SSLMODE=require`. Back the database up with `docker compose -f docker-compose.postgres.yml exec postgres pg_dump -U renewcms renewcms`. Replace the placeholders; never commit real passwords.
## S3-compatible storage (SeaweedFS)
[`docker-compose.s3.yml`](../docker-compose.s3.yml) runs RenewCMS with `STORAGE_DRIVER=s3` next to a [SeaweedFS](https://github.com/seaweedfs/seaweedfs) server (Apache 2.0, image `chrislusf/seaweedfs`, override with `SEAWEEDFS_IMAGE`) with its S3 gateway enabled, and a one-shot `seaweedfs-init` step that creates the bucket with `weed shell`:
```sh
export S3_SECRET_ACCESS_KEY=<generated>
docker compose -f docker-compose.s3.yml up -d
```
RenewCMS gets `S3_ENDPOINT=http://seaweedfs:8333`, `S3_PATH_STYLE=true`, the bucket name and the access key (`S3_ACCESS_KEY_ID`, default `renewcms`, and `S3_SECRET_ACCESS_KEY`) from the compose file, and the rest from `docker.env`. SeaweedFS reads the same key pair as its admin identity. The `renewcms-data` volume then holds the SQLite database and update state but no uploads; the images live in the `seaweedfs-data` volume. The bucket is not published and the app serves the images. SeaweedFS is used because MinIO stopped publishing community images. To use AWS S3 or another provider, drop the `seaweedfs` services and set the `S3_*` variables in `docker.env` (see [configuration.md](configuration.md#image-storage)). The volume backup above covers the database only, so back the bucket up separately. To move existing uploads see [configuration.md](configuration.md#moving-existing-uploads-to-a-bucket).
`task test:s3` starts a throwaway SeaweedFS and runs the storage integration test against it; it skips with a message when Docker is not available.
## Reverse proxy and TLS
With `ENVIRONMENT=production` the site must be served over HTTPS, so put a reverse proxy in front and set `PUBLIC_URL` to the public `https://` address. Bind the published port to the host only when the proxy runs on the same host, by changing `ports` to `"127.0.0.1:8080:8080"`.
+160
View File
@@ -0,0 +1,160 @@
Feature: Optional MySQL/MariaDB database driver
An operator who cannot use a local SQLite file runs RenewCMS against MySQL or
MariaDB by setting environment variables. SQLite remains the default and
behaves exactly as before.
Background:
Given the database drivers are registered by name in database.drivers
And the database is chosen by the DB_DRIVER environment variable
And repositories, use cases and handlers are unchanged
Scenario Outline: Driver selection
Given DB_DRIVER is "<value>"
When the server starts
Then the outcome is "<outcome>"
Examples:
| value | outcome |
| unset | the sqlite driver opens DB_FILE |
| sqlite | the sqlite driver opens DB_FILE |
| mysql | the mysql driver opens the DB_HOST connection |
| oracle | startup fails with: unsupported database driver "oracle" |
Scenario Outline: Missing MySQL variable
Given DB_DRIVER is "mysql"
And "<missing>" is not set
When the environment is validated
Then startup fails with "Environment variable <missing> is not set"
Examples:
| missing |
| DB_HOST |
| DB_USER |
| DB_PASSWORD |
| DB_NAME |
Scenario: SQLite does not require MySQL variables
Given DB_DRIVER is "sqlite"
And DB_HOST, DB_USER, DB_PASSWORD and DB_NAME are not set
When the environment is validated
Then validation succeeds
Scenario Outline: DSN built from variables
Given DB_HOST "<host>", DB_PORT "<port>", DB_USER "app", DB_NAME "cms", DB_TLS "<tls>"
When the MySQL DSN is built
Then the DSN targets "<host>:<expectedPort>" and database "cms"
And it contains parseTime=true, loc=UTC, charset=utf8mb4, clientFoundRows=true
And it contains tls="<tls>"
Examples:
| host | port | expectedPort | tls |
| db.local | | 3306 | false |
| db.local | 3307 | 3307 | true |
| mysql | 3306 | 3306 | skip-verify |
Scenario: Invalid TLS option is rejected
Given DB_DRIVER is "mysql" and DB_TLS is "maybe"
When the settings are read
Then startup fails naming DB_TLS and the accepted values
Scenario Outline: Fail fast when the server cannot be used
Given DB_DRIVER is "mysql" and "<fault>"
When the server starts
Then startup stops with "Unable to open the database"
And the message names the host and port
And the message does not contain the DB_PASSWORD value
Examples:
| fault |
| nothing listens on DB_PORT |
| the password is wrong |
| the database does not exist |
Scenario: Data directory with MySQL
Given DB_DRIVER is "mysql" and DATA_DIR is "/data"
Then update state, staging and backups live in "/data"
And DB_FILE is ignored
Scenario Outline: DataDir resolution
Given DB_FILE "<dbfile>" and DATA_DIR "<datadir>"
Then Settings.DataDir() is "<result>"
Examples:
| dbfile | datadir | result |
| ./data/renewcms.db | | ./data |
| /var/lib/cms/cms.db | | /var/lib/cms |
| ./data/renewcms.db | /srv/x | /srv/x |
Scenario: Connection pool
Given DB_MAX_OPEN_CONNS 4, DB_MAX_IDLE_CONNS 2 and DB_CONN_MAX_LIFETIME_SECONDS 60
When the MySQL connection is opened
Then the pool limits are 4 open, 2 idle and 60 seconds
Scenario: Migration on MySQL
Given an empty MySQL database
When database.Migrate runs
Then the tables articles, images, users, invites, api_keys and purge_settings exist
And the unique indexes on users and api_keys exist
And the first user lacking a role becomes "owner" and the others "admin"
Scenario: SQLite schema is untouched by the model changes
Given a SQLite file created by the models before this change, holding rows
When database.Migrate runs
Then the schema and every row are unchanged
Scenario Outline: Repository behaviour is the same on both drivers
Given the "<driver>" driver
Then the repository contract "<case>" holds
Examples:
| driver | case |
| sqlite | create user and read it back with its id and timestamps |
| mysql | create user and read it back with its id and timestamps |
| sqlite | username "Bob" and "bob" are distinct users |
| mysql | username "Bob" and "bob" are distinct users |
| sqlite | duplicate email returns an error and stores nothing |
| mysql | duplicate email returns an error and stores nothing |
| sqlite | unknown id returns domain ErrUserNotFound |
| mysql | unknown id returns domain ErrUserNotFound |
| sqlite | accept invite twice: the second returns ErrInviteNotFound |
| mysql | accept invite twice: the second returns ErrInviteNotFound |
| sqlite | accept invite with a taken username leaves the invite unaccepted |
| mysql | accept invite with a taken username leaves the invite unaccepted |
| sqlite | renew invite with unchanged values still succeeds |
| mysql | renew invite with unchanged values still succeeds |
| sqlite | duplicate image hashes are grouped and counted |
| mysql | duplicate image hashes are grouped and counted |
| sqlite | media search treats % and _ literally |
| mysql | media search treats % and _ literally |
| sqlite | article body of 1 MB round-trips |
| mysql | article body of 1 MB round-trips |
| sqlite | zero reset expiration round-trips as a zero time |
| mysql | zero reset expiration round-trips as a zero time |
| sqlite | purge settings save, then SaveLastRun with identical values succeeds |
| mysql | purge settings save, then SaveLastRun with identical values succeeds |
Scenario Outline: Backup before an update
Given the "<driver>" driver with a populated database
When the pre-update backup runs
Then a file "<pattern>" exists in DATA_DIR/backups
And only the 3 newest backups are kept
And a failure returns an error, so the update download fails instead of claiming a backup
Examples:
| driver | pattern |
| sqlite | renewcms-<time>.db |
| mysql | renewcms-<time>.sql |
Scenario: MySQL backup content
Given a MySQL database with 2 users and 1 article
When the pre-update backup runs
Then the dump contains the schema and the rows of every table
And it does not contain the DB_PASSWORD value
And replaying it into an empty database restores the same rows
Scenario: Secrets are never exposed
Given DB_DRIVER is "mysql"
Then no log line, error message or file in the repository contains the DB_PASSWORD value
Scenario: Static builds
Then the module builds with CGO_ENABLED=0 for the 6 task release targets
+42
View File
@@ -0,0 +1,42 @@
# QA: MySQL/MariaDB database driver
Backend only. Go tests in test/, black-box, no live server by default.
Decisions: separate DB_* vars, new DATA_DIR (DB_FILE ignored under mysql), pure-Go logical dump backup, `task test:mysql` against mysql:8.4 and mariadb:11, SQLite-to-MySQL data migration documented as manual export/import only.
## Automated, no server (task test)
1. Registry: Connect with Driver "sqlite" works against a temp file; Driver "oracle" returns `unsupported database driver "oracle"`; the registry contains sqlite and mysql.
2. Env validation: table-driven over the Driver selection and Missing MySQL variable scenarios. SQLite needs no DB_* vars. DB_DRIVER defaults to sqlite. An invalid DB_TLS or a non-numeric pool var returns an error naming the variable.
3. DSN builder: table-driven over the DSN scenario. Assert host:port, database, parseTime, loc=UTC, charset=utf8mb4, clientFoundRows, tls. Assert that the redacted form used in errors never contains the password.
4. Settings.DataDir: table-driven (DB_FILE only, DATA_DIR overrides).
5. Fail fast without a server: point mysql at a closed local port with a 1s timeout. Assert the error mentions the host and port, and not the password.
6. Pool settings: assert sql.DB stats MaxOpenConnections via a stub opener, or via the real mysql test below.
7. SQLite regression: build a database file using the pre-change schema (a fixture created with the old model definitions), run Migrate, and assert that the schema (sqlite_master) and the rows are unchanged. The existing suite must pass unchanged.
8. Backup (SQLite): existing tests unchanged.
9. Dump writer: unit-test the SQL dump formatter with a fake row source (quoting, NULL, time, bool, binary-safe strings, 3-file retention, existing-file handling, failure leaves no partial file).
## Automated, real server (task test:mysql)
Setup: the task runs `docker run` for mysql:8.4 and, in a second pass, mariadb:11, waits for readiness, exports RENEWCMS_TEST_MYSQL_DSN, runs `go test -tags nofrontend ./test/... -run MySQL -count=1`, then removes the container. Tests call t.Skip when the var is unset, so `task test` skips them. The repository tests are parameterised over both drivers (SQLite temp file and the MySQL DSN, each with a fresh database), and run the "Repository behaviour is the same on both drivers" Examples.
10. Migrate on an empty database; table and index existence via information_schema; the backfill role rules.
11. User create/read, case-sensitive username and email, duplicate email, ErrUserNotFound.
12. Invite accept twice, accept with a taken username (invite stays unaccepted), renew with unchanged values.
13. Image duplicates grouping and counts, search with % and _, filename backfill listing.
14. Article body 1 MB, title lookup, online filter.
15. API key unique hashed_key, revoke, last_used.
16. Zero-time round trip, UTC times.
17. Purge settings upsert.
18. Backup: run on a populated MySQL database, assert the .sql file, the 3-file retention, a replay into a second empty database giving identical rows, and that the file contains no password.
19. Wrong password and missing database fail with the "Unable to open the database" prefix and no password in the message.
## Build and lint
20. `task lint:go` and `task test` pass. `task test:mysql` passes on MySQL 8.4 and MariaDB 11 (record both; NOT RUN if Docker is unavailable).
21. `CGO_ENABLED=0 go build -tags nofrontend ./...` passes. Cross-compile the 6 `task release` targets (e.g. `GOOS=linux GOARCH=arm64`) with CGO_ENABLED=0.
22. `task mutate` impact: none, because it only targets useCases. No change.
23. No comment lines in the added Go files (the hook enforces this).
## Manual (needs Compose and real SMTP)
24. docker-compose.mysql.yml example: renewcms plus a mysql service with a named volume and a healthcheck, `depends_on: condition: service_healthy`, DB_DRIVER=mysql, DB_HOST=mysql, DATA_DIR=/data. `docker compose up`: /healthz returns 200, register, log in, create an article, restart the stack, and the data persists.
25. Stop the mysql container and start renewcms: it exits with a clear error and no password in the logs. docker.env.example holds placeholders only.
26. In the Docker image (non-root uid 10001), trigger the backup path and confirm the .sql file lands under /data/backups.
27. Docs: configuration.md, docker.md, deployment.md, `.env.example`, `docker.env.example` and the architecture.md "Storage and database seams" section list the new vars and the manual SQLite to MySQL export/import note.
28. Verify the SQLite default end to end with no new vars set: same behaviour as before.
+153
View File
@@ -0,0 +1,153 @@
Feature: Optional PostgreSQL database driver
An operator who prefers PostgreSQL runs RenewCMS against it by setting
environment variables. SQLite stays the default, and MySQL/MariaDB behaves
exactly as before.
Background:
Given the database drivers are registered by name in database.drivers
And the database is chosen by the DB_DRIVER environment variable
And repositories, use cases and handlers are unchanged
Scenario Outline: Driver selection
Given DB_DRIVER is "<value>"
When the server starts
Then the outcome is "<outcome>"
Examples:
| value | outcome |
| unset | the sqlite driver opens DB_FILE |
| sqlite | the sqlite driver opens DB_FILE |
| mysql | the mysql driver opens the DB_HOST connection |
| postgres | the postgres driver opens the DB_HOST connection |
| postgresql | the postgres driver opens the DB_HOST connection |
| oracle | startup fails with: unsupported database driver "oracle" |
Scenario Outline: Missing PostgreSQL variable
Given DB_DRIVER is "postgres"
And "<missing>" is not set
When the environment is validated
Then startup fails with "Environment variable <missing> is not set"
Examples:
| missing |
| DB_HOST |
| DB_USER |
| DB_PASSWORD |
| DB_NAME |
Scenario: SQLite and MySQL do not require PostgreSQL-only variables
Given DB_DRIVER is "sqlite" or "mysql"
And DB_SSLMODE is "maybe"
When the settings are read
Then DB_SSLMODE is ignored and validation succeeds
Scenario Outline: Connection string built from variables
Given DB_HOST "<host>", DB_PORT "<port>", DB_USER "app", DB_NAME "cms", DB_SSLMODE "<sslmode>"
When the PostgreSQL DSN is built
Then the DSN targets "<host>:<expectedPort>" and database "cms"
And it sets sslmode "<expectedSslmode>"
And it sets the session time zone to UTC
And a password containing spaces, quotes, "@" or "/" is escaped so that parsing the DSN returns it unchanged
Examples:
| host | port | expectedPort | sslmode | expectedSslmode |
| db.local | | 5432 | | disable |
| db.local | 5433 | 5433 | require | require |
| pg | 5432 | 5432 | verify-full | verify-full |
Scenario: Invalid SSL mode is rejected
Given DB_DRIVER is "postgres" and DB_SSLMODE is "maybe"
When the settings are read
Then startup fails naming DB_SSLMODE and the accepted values
Scenario Outline: Fail fast when the server cannot be used
Given DB_DRIVER is "postgres" and "<fault>"
When the server starts
Then startup stops with "Unable to open the database"
And the message names the host and port
And the message does not contain the DB_PASSWORD value
Examples:
| fault |
| nothing listens on DB_PORT |
| the password is wrong |
| the database does not exist |
| the server requires TLS and DB_SSLMODE is disable |
Scenario: Data directory with PostgreSQL
Given DB_DRIVER is "postgres" and DATA_DIR is "/data"
Then update state, staging and backups live in "/data"
And DB_FILE is ignored
Scenario: Connection pool
Given DB_MAX_OPEN_CONNS 4, DB_MAX_IDLE_CONNS 2 and DB_CONN_MAX_LIFETIME_SECONDS 60
When the PostgreSQL connection is opened
Then the pool limits are 4 open, 2 idle and 60 seconds
Scenario: Migration on PostgreSQL
Given an empty PostgreSQL database
When database.Migrate runs
Then the tables articles, images, users, invites, api_keys and purge_settings exist in schema "public"
And the unique indexes on users and api_keys exist
And the first user lacking a role becomes "owner" and the others "admin"
And running database.Migrate a second time changes nothing
Scenario: Existing drivers are untouched by the PostgreSQL addition
Given a SQLite file holding rows, and a MySQL database holding rows
When database.Migrate runs on each
Then the schema and every row are unchanged on both
Scenario Outline: Repository behaviour is the same on every driver
Given the "<driver>" driver
Then the repository contract "<case>" holds
Examples:
| driver | case |
| postgres | create user and read it back with its id and timestamps |
| postgres | username "Bob" and "bob" are distinct users |
| postgres | email "A@x.io" and "a@x.io" follow the same rule as on sqlite |
| postgres | duplicate email returns an error and stores nothing |
| postgres | the failed duplicate insert leaves the connection usable |
| postgres | unknown id returns domain ErrUserNotFound |
| postgres | accept invite twice: the second returns ErrInviteNotFound |
| postgres | accept invite with a taken username leaves the invite unaccepted |
| postgres | renew invite with unchanged values still succeeds |
| postgres | duplicate image hashes are grouped and counted |
| postgres | media search treats % and _ literally |
| postgres | media search is case-sensitive exactly as on sqlite |
| postgres | article body of 1 MB round-trips |
| postgres | article title with emoji and accents round-trips |
| postgres | zero reset expiration round-trips as a zero time |
| postgres | stored times read back in UTC |
| postgres | purge settings save, then SaveLastRun with identical values succeeds |
| postgres | api key unique hashed_key, revoke and last_used |
| postgres | offline article reads as not found publicly, online article is listed |
| postgres | listing order and pagination match sqlite for the same data |
Scenario Outline: Backup before an update
Given the "<driver>" driver with a populated database
When the pre-update backup runs
Then a file "<pattern>" exists in DATA_DIR/backups
And only the 3 newest backups are kept
And a failure returns an error, so the update download fails instead of claiming a backup
Examples:
| driver | pattern |
| sqlite | renewcms-<time>.db |
| mysql | renewcms-<time>.sql |
| postgres | renewcms-<time>.sql |
Scenario: PostgreSQL backup content
Given a PostgreSQL database with 2 users and 1 article
When the pre-update backup runs
Then the dump contains the schema, indexes and the rows of every table
And it does not contain the DB_PASSWORD value
And replaying it into an empty database restores the same rows
And sequences are reset so the next insert gets an unused id
Scenario: Secrets are never exposed
Given DB_DRIVER is "postgres"
Then no log line, error message or file in the repository contains the DB_PASSWORD value
Scenario: Static builds
Then the module builds with CGO_ENABLED=0 for the 6 task release targets
+43
View File
@@ -0,0 +1,43 @@
# QA: PostgreSQL database driver
Backend only. Go tests in test/, black-box, no live server by default.
Decisions: DB_DRIVER=postgres (alias postgresql), gorm.io/driver/postgres (pgx, pure Go), shared DB_* vars plus DB_SSLMODE, pure-Go logical dump backup, `task test:postgres` against postgres:16 and postgres:17, SQLite or MySQL to PostgreSQL migration is a documented manual export/import only.
## Automated, no server (task test)
1. Registry: contains sqlite, mysql and postgres. Alias "postgresql" resolves to postgres. "oracle" returns `unsupported database driver "oracle"`.
2. Env validation: table-driven over Driver selection and Missing PostgreSQL variable. Invalid DB_SSLMODE or a non-numeric pool var returns an error naming the variable. DB_SSLMODE is ignored under sqlite and mysql. MySQL validation unchanged.
3. DSN builder: table-driven over the DSN scenario. Parse the output with the pgx config parser and assert host, port, database, sslmode and the UTC time zone. Round-trip a password such as `s3cr3t/Value@42:x 'q'`. The form used in errors never contains the password.
4. Settings.DataDir under postgres: DATA_DIR wins, DB_FILE ignored.
5. Fail fast without a server: closed 127.0.0.1 port, 1s connect timeout. Error has the "Unable to open the database" prefix, mentions host and port, not the password.
6. Pool settings via a stub opener or the real test below.
7. Regression: the existing SQLite and MySQL tests pass unchanged. A pre-change SQLite fixture is unchanged after Migrate.
8. Dump writer: reuse the existing formatter tests. Add PostgreSQL value formatting with a fake row source: bool as true/false, bytea hex escapes, timestamptz in UTC, NULL, identifier and string quoting.
9. Migrate dialect handling: no MySQL table options are applied under postgres (tableOptionsFor).
## Automated, real server (task test:postgres)
Setup: the task runs `docker run` for postgres:16 then postgres:17, waits with pg_isready, exports RENEWCMS_TEST_POSTGRES_DSN, runs `go test -tags nofrontend ./test/... -run Postgres -count=1`, then removes the container. Tests t.Skip when the variable is unset. Repository contract tests are parameterised over sqlite, mysql and postgres with a fresh database each.
10. Migrate on an empty database: tables and indexes via information_schema and pg_indexes, role backfill, idempotent second run.
11. User create/read, case-sensitive username and email, duplicate email, ErrUserNotFound. After the unique violation the next query on the same pool succeeds.
12. Invite accept twice, accept with a taken username (invite stays unaccepted), renew with unchanged values.
13. Image duplicates grouping and counts (GROUP BY strictness), search with % and _ and its case rule, filename backfill listing.
14. Article body 1 MB, unicode title, title lookup, online filter, listing order and pagination parity with sqlite.
15. API key unique hashed_key, revoke, last_used.
16. Zero-time round trip, UTC times.
17. Purge settings upsert.
18. Backup on a populated database: .sql file, 3-file retention, replay into a second empty database gives identical rows, no password in the file, next insert after replay gets a fresh id.
19. Wrong password, missing database and TLS-required-but-disabled fail with the "Unable to open the database" prefix and no password.
## Build and lint
20. `task lint:go` and `task test` pass. `task test:postgres` passes on 16 and 17 (record both; NOT RUN if Docker is unavailable).
21. `CGO_ENABLED=0 go build -tags nofrontend ./...` passes. Cross-compile the 6 `task release` targets.
22. `task mutate` impact: none (useCases only).
23. No comment lines in the added Go files.
24. Layering: `go list -deps` on useCases and domain shows no pgx or gorm. Only api/dependecyInjection.go builds the postgres config.
## Manual (needs Compose and real SMTP)
25. docker-compose.postgres.yml: renewcms plus a postgres service with a named volume and a pg_isready healthcheck, depends_on service_healthy, DB_DRIVER=postgres, DB_HOST=postgres, DATA_DIR=/data. `docker compose up`: /healthz 200, register, log in, create an article, restart the stack, data persists.
26. Stop the postgres container and start renewcms: clear error, no password in the logs.
27. In the Docker image (uid 10001), trigger the backup path and confirm the .sql file lands under /data/backups.
28. Docs: .env.example, docker.env.example, docs/configuration.md, docs/docker.md, docs/deployment.md, docs/development.md and the docs/architecture.md "Storage and database seams" section list DB_DRIVER=postgres, DB_SSLMODE and the manual migration note. DB_SSLMODE goes into cmd/server/main.go envDefaults.
29. SQLite default end to end with no new vars set behaves as before.
+143
View File
@@ -0,0 +1,143 @@
Feature: Uploaded images can be stored in an S3-compatible bucket
As an operator whose host disk is ephemeral or who runs several hosts
I want to set STORAGE_DRIVER=s3 and bucket settings
So that uploads live in the bucket while images are still served by the app
at /static/uploadedImages/<name>, with unchanged URLs
Background:
Given STORAGE_DRIVER defaults to "local" and local disk behaves exactly as today
And an S3 storage implements image.Storage (Put, Open, Delete) in internal/infrastructure/storage
And storage.Open has a case for the driver "s3" and openImageStorage selects it from the settings
And no domain, use case, repository or staticimages handler file changes
And the bucket is faked by an in-process httptest S3 server listening on 127.0.0.1
And Open returns a Blob whose Content is an io.ReadSeeker, so http.ServeContent handles Range and conditional requests
And the Blob ModTime and Size come from the S3 object metadata
And no ETag is emitted and conditional requests are validated by Last-Modified only
Scenario Outline: Startup configuration validation
Given STORAGE_DRIVER is <driver> and the S3 variables are <vars>
When the storage settings are validated
Then the result is <result>
Examples:
| driver | vars | result |
| (unset) | none | driver local, no S3 variable required |
| local | S3_BUCKET set | driver local, S3 variables ignored |
| s3 | S3_BUCKET and S3_REGION set | valid, default AWS credential chain |
| s3 | S3_BUCKET set, S3_REGION unset | region defaults to us-east-1 |
| s3 | S3_REGION set, S3_BUCKET unset | error "Environment variable S3_BUCKET is not set" |
| s3 | key id set, secret unset | error naming S3_SECRET_ACCESS_KEY |
| s3 | secret set, key id unset | error naming S3_ACCESS_KEY_ID |
| s3 | UPLOAD_DIR set | valid, UPLOAD_DIR ignored without error |
| gcs | none | error unsupported storage driver "gcs" |
Scenario: The registry opens the S3 storage after a bucket check
Given the fake S3 answers HEAD bucket with 200
When storage.Open is called with driver "s3"
Then it returns a storage and no error
Scenario Outline: Startup fails fast with a clear secret-free error
Given the fake S3 answers HEAD bucket with <status>
When storage.Open is called with driver "s3" and secret access key "TOPSECRET"
Then it returns an error mentioning the bucket name
And the error text does not contain "TOPSECRET"
And openImageStorage panics with that same text
Examples:
| status |
| 404 |
| 403 |
| 500 |
Scenario: Put stores the object under the prefix with its content type
Given S3_KEY_PREFIX is "uploads/"
When Put is called with stored name "a1b2.png", content type "image/png" and 3 bytes
Then the fake S3 holds key "uploads/a1b2.png" with those bytes and Content-Type "image/png"
Scenario: Put failure is returned as an error
Given the fake S3 answers PUT with 500
When Put is called
Then Put returns a non-nil error that is not image.ErrBlobNotFound
Scenario: Open returns content, size and modification time
Given the fake S3 holds "uploads/a1b2.png" with 10 bytes and a known Last-Modified
When Open("a1b2.png") is called
Then the Blob has Size 10 and that ModTime
And its Content implements io.ReadSeeker and reads the 10 bytes
Scenario: Open of a missing key is not found
Given the fake S3 answers GET with 404 NoSuchKey
When Open("missing.png") is called
Then the result is image.ErrBlobNotFound, asserted with errors.Is
Scenario: Open with a backend failure is not a not-found
Given the fake S3 answers GET with 500
When Open is called
Then the error is non-nil and is not image.ErrBlobNotFound
Scenario Outline: Delete is idempotent
Given the fake S3 <state>
When Delete("a1b2.png") is called
Then it returns no error
Examples:
| state |
| holds the key |
| does not hold the key (404) |
Scenario: A backend failure on Delete is returned
Given the fake S3 answers DELETE with 500
When Delete is called
Then it returns a non-nil error
Scenario Outline: Serving /static/uploadedImages/<name> from the bucket
Given the bucket holds "a1b2.png" with 10 bytes and a known Last-Modified
When <method> /static/uploadedImages/a1b2.png is requested <with>
Then the status is <status>
And the response has <result>
Examples:
| method | with | status | result |
| GET | no extra header | 200 | Content-Type image/png, Content-Length 10, Last-Modified, Accept-Ranges bytes, Cache-Control "public, no-cache", X-Content-Type-Options nosniff |
| HEAD | no extra header | 200 | the same headers and an empty body |
| GET | If-Modified-Since = Last-Modified | 304 | an empty body |
| GET | Range bytes=0-3 | 206 | Content-Range "bytes 0-3/10" and Content-Length 4 |
| GET | Range bytes=50-60 | 416 | Content-Range "bytes */10" |
Scenario: SVG from the bucket keeps its security headers
Given the bucket holds "logo.svg"
When GET /static/uploadedImages/logo.svg is requested
Then Content-Type is image/svg+xml
And Content-Security-Policy is "default-src 'none'; style-src 'unsafe-inline'; sandbox"
Scenario: Missing object is 404 and a backend failure is 500
Given the bucket does not hold "missing.png"
When GET /static/uploadedImages/missing.png is requested
Then the status is 404
Given the fake S3 answers GET with 500
When GET /static/uploadedImages/a1b2.png is requested
Then the status is 500 and the plain text body is "Unable to read the file"
Scenario: Path traversal never reaches another key
When GET /static/uploadedImages/../secret.png is requested
Then the key requested from the fake S3 is the prefix plus "secret.png"
Scenario: Handler as-is for a non-seekable blob
Given a storage fake returning a Blob whose Content is not an io.ReadSeeker, with Size 10 and a ModTime
When GET and HEAD /static/uploadedImages/a1b2.png are requested
Then GET returns 200 with Content-Length 10, Last-Modified and the body
And HEAD returns 200 with the same headers and an empty body
And no Range or ETag behaviour is specified for this path
Scenario: Boot backfill logs how many images it read from the bucket
Given the driver is s3 and 3 stored images lack metadata
When InitContainer runs the boot backfill
Then a log line reports 3 images read from storage and contains no credential
And backfill behaviour and errors are unchanged
And no cap is applied and BackfillImageMetadataUseCase is not modified
Scenario: The local driver is unchanged
Given STORAGE_DRIVER is unset and UPLOAD_DIR is a temp directory
When an image is uploaded and requested
Then it is stored in UPLOAD_DIR and served exactly as before
+26
View File
@@ -0,0 +1,26 @@
# QA: S3 image storage
All tests live in test/ (package test). No cloud account, no real SMTP, no real SQLite file. Test names carry "S3Storage".
Settled decisions: boot backfill gets a log line only, wired in the composition root, with no cap and no use case change. No ETag is emitted; the staticimages handler is unchanged and conditional requests are validated by Last-Modified only.
## Automated
1. Config validation: table test over STORAGE_DRIVER and S3_* lookup functions (the MissingEnvVarError pattern, a `StorageRequiredEnvVars(driver)` helper like `DatabaseRequiredEnvVars`). Covers the validation table, UPLOAD_DIR ignored, unknown driver error.
2. Registry: storage.Open with "local" returns local storage, "s3" returns the S3 storage after HEAD bucket, anything else returns the "unsupported storage driver" error.
3. Fake S3: an httptest.Server on 127.0.0.1 only (never bind :port) implementing HEAD bucket, PUT, GET with Range, HEAD and DELETE object, with injectable statuses and recorded keys, headers and bodies.
4. Adapter tests against the fake: the Put, Open (found, 404, 500) and Delete scenarios, HEAD bucket 404/403/500, prefix handling, ModTime and Size from metadata, Content as io.ReadSeeker (seek then read issues a ranged GET), secret absent from error text.
5. Handler tests: staticimages.NewHandler over OpenMediaUseCase over the S3 storage on the fake server, mounted on a fresh chi.NewRouter() at /static/uploadedImages/*. Cover GET, HEAD, 304 via If-Modified-Since, 206, 416, SVG CSP, 404, 500, traversal.
6. Handler regression: a storage fake returning a non-seekable Blob. GET and HEAD behave exactly as today.
7. Backfill log: the boot log line for the number of images read, captured with a log writer, containing no credential.
8. Local regression: all existing image tests pass unchanged.
9. Gates: `task test` and `task lint:go` pass. `task mutate` is not part of this slice, but the new storage package becomes a target if the mutation scope is widened. The 404 to ErrBlobNotFound and idempotent-Delete branches need killing tests.
## Optional integration (task test:s3)
Start a SeaweedFS container with Docker, create a bucket, set RENEWCMS_TEST_S3_ENDPOINT and run the adapter tests against it. Skip with a message when Docker is unavailable. Not part of `task test`.
## Manual
- Run docker-compose.s3.yml, upload an image in the SPA, confirm the object is in the bucket and /static/uploadedImages/<name> serves it, including after the container is recreated.
- Real AWS S3, R2, B2 or Scaleway smoke test, and an instance-role credential check.
- Start with a wrong bucket and confirm a clear, secret-free startup error.
- Sync an old UPLOAD_DIR with `aws s3 sync` or `mc mirror` and confirm old images load.
- Confirm docs describe: variables (configuration.md, .env.example, docker.env.example, Docker docs), UPLOAD_DIR ignored with s3, manual migration, update backups covering the DB only and not the bucket, and the updated architecture.md "Storage and database seams" section.
+29 -2
View File
@@ -4,22 +4,46 @@ go 1.27.1
require (
github.com/MadAppGang/httplog v1.3.0
github.com/aws/aws-sdk-go-v2 v1.47.1
github.com/aws/aws-sdk-go-v2/config v1.33.6
github.com/aws/aws-sdk-go-v2/credentials v1.20.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0
github.com/glebarez/sqlite v1.11.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/go-chi/jwtauth/v5 v5.3.3
github.com/go-playground/validator/v10 v10.28.0
github.com/go-sql-driver/mysql v1.8.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/jwx/v2 v2.1.6
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
golang.org/x/net v0.59.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/gorm v1.31.1
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.3
gorm.io/gorm v1.31.2
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
github.com/aws/smithy-go v1.28.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
@@ -28,6 +52,9 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -35,7 +62,6 @@ require (
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -43,6 +69,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.1 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
+62 -4
View File
@@ -1,7 +1,45 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/MadAppGang/httplog v1.3.0 h1:1XU54TO8kiqTeO+7oZLKAM3RP/cJ7SadzslRcKspVHo=
github.com/MadAppGang/httplog v1.3.0/go.mod h1:gpYEdkjh/Cda6YxtDy4AB7KY+fR7mb3SqBZw74A5hJ4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw=
github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug=
github.com/aws/aws-sdk-go-v2/config v1.33.6/go.mod h1:grRAFzdAZJrwcbasJRg2MPvIrVjtlfXllHssN6+E1JE=
github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw=
github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 h1:8gALAAmacnIXh+z6VkdDanv4/IkG5APdg4DZLDTmLog=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 h1:VMAdYqr4Jn/8ATs9BHC5riwrs0d6m1Z2ohFriSwZwm0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 h1:DzCCWLzcIRQ77F3DEUljud7bEjTgFOIKXP52NmVRyhU=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 h1:Umtl/0YZhng4xndfW3lKJrYYP7NLEjI6bGXVomwLcs0=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/SBzUUymbUQuZbSPImghg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -31,6 +69,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@@ -41,6 +81,14 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -65,6 +113,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -74,10 +124,12 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
@@ -105,8 +157,14 @@ gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkp
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0=
gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
+9
View File
@@ -191,6 +191,15 @@ type ImageInput struct {
UploadedByID uint32
}
type Metadata struct {
Filename string
Size int64
ContentType string
Width int
Height int
SHA256 string
}
type Image struct {
ID uint32 `json:"id"`
Path string `json:"path"`
@@ -6,7 +6,7 @@ type SearchQuery struct {
}
type Repository interface {
Create(input ImageInput) (Image, error)
Create(image Image) (Image, error)
Get(id uint32) (Image, error)
Delete(id uint32) error
List(query SearchQuery) ([]Image, error)
@@ -14,4 +14,6 @@ type Repository interface {
Rename(id uint32, filename string) (Image, error)
ListDuplicates() ([]Image, error)
Counts() (Counts, error)
ListMissingMetadata() ([]Image, error)
UpdateMetadata(id uint32, metadata Metadata) error
}
+27
View File
@@ -0,0 +1,27 @@
package image
import (
"errors"
"io"
"time"
)
var ErrBlobNotFound = errors.New("stored image content not found")
const PublicPathPrefix = "/static/uploadedImages/"
func PublicPath(storedName string) string {
return PublicPathPrefix + storedName
}
type Blob struct {
Content io.ReadCloser
Size int64
ModTime time.Time
}
type Storage interface {
Put(contentType string, content io.Reader) (string, error)
Open(storedName string) (Blob, error)
Delete(storedName string) error
}
@@ -0,0 +1,149 @@
package config
import (
"fmt"
"os"
"slices"
"strconv"
"strings"
"time"
)
const (
DatabaseDriverSQLite = "sqlite"
DatabaseDriverMySQL = "mysql"
DatabaseDriverPostgres = "postgres"
databaseDriverPostgresAlias = "postgresql"
DefaultDatabaseDriver = DatabaseDriverSQLite
DefaultDatabasePort = "3306"
DefaultPostgresPort = "5432"
DefaultDatabaseTLS = "false"
DefaultDatabaseSSLMode = "disable"
DefaultMaxOpenConns = "10"
DefaultMaxIdleConns = "5"
DefaultConnMaxLifetimeSeconds = "300"
EnvDatabaseDriver = "DB_DRIVER"
envDatabaseHost = "DB_HOST"
envDatabasePort = "DB_PORT"
envDatabaseUser = "DB_USER"
envDatabasePassword = "DB_PASSWORD"
envDatabaseName = "DB_NAME"
envDatabaseTLS = "DB_TLS"
envDatabaseSSLMode = "DB_SSLMODE"
envDatabaseMaxOpenConns = "DB_MAX_OPEN_CONNS"
envDatabaseMaxIdleConns = "DB_MAX_IDLE_CONNS"
envDatabaseConnMaxLifetimeSecs = "DB_CONN_MAX_LIFETIME_SECONDS"
)
var mysqlRequiredEnvVars = []string{envDatabaseHost, envDatabaseUser, envDatabasePassword, envDatabaseName}
var DatabaseTLSModes = []string{"false", "true", "skip-verify", "preferred"}
var DatabaseSSLModes = []string{"disable", "require", "verify-ca", "verify-full"}
type DatabaseSettings struct {
Driver string
Host string
Port string
User string
Password string
Name string
TLS string
SSLMode string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
}
func NormalizeDatabaseDriver(driver string) string {
if driver == databaseDriverPostgresAlias {
return DatabaseDriverPostgres
}
return driver
}
func DatabaseRequiredEnvVars(driver string) []string {
switch NormalizeDatabaseDriver(driver) {
case DatabaseDriverMySQL, DatabaseDriverPostgres:
return slices.Clone(mysqlRequiredEnvVars)
}
return nil
}
func MissingEnvVarError(names []string, lookup func(string) (string, bool)) error {
for _, name := range names {
if _, isSet := lookup(name); !isSet {
return fmt.Errorf("Environment variable %s is not set", name)
}
}
return nil
}
func databaseDriverFromEnv() string {
return NormalizeDatabaseDriver(envOrDefault(EnvDatabaseDriver, DefaultDatabaseDriver))
}
func databaseSettingsFromEnv() (DatabaseSettings, error) {
driver := databaseDriverFromEnv()
if driver != DatabaseDriverMySQL && driver != DatabaseDriverPostgres {
return DatabaseSettings{Driver: driver}, nil
}
defaultPort, tls, sslMode := DefaultDatabasePort, "", ""
if driver == DatabaseDriverPostgres {
defaultPort = DefaultPostgresPort
sslMode = envOrDefault(envDatabaseSSLMode, DefaultDatabaseSSLMode)
if !slices.Contains(DatabaseSSLModes, sslMode) {
return DatabaseSettings{}, fmt.Errorf("%s must be one of %s, got %q", envDatabaseSSLMode, strings.Join(DatabaseSSLModes, ", "), sslMode)
}
} else {
tls = envOrDefault(envDatabaseTLS, DefaultDatabaseTLS)
if !slices.Contains(DatabaseTLSModes, tls) {
return DatabaseSettings{}, fmt.Errorf("%s must be one of %s, got %q", envDatabaseTLS, strings.Join(DatabaseTLSModes, ", "), tls)
}
}
maxOpen, err := nonNegativeIntEnv(envDatabaseMaxOpenConns, DefaultMaxOpenConns)
if err != nil {
return DatabaseSettings{}, err
}
maxIdle, err := nonNegativeIntEnv(envDatabaseMaxIdleConns, DefaultMaxIdleConns)
if err != nil {
return DatabaseSettings{}, err
}
lifetimeSeconds, err := nonNegativeIntEnv(envDatabaseConnMaxLifetimeSecs, DefaultConnMaxLifetimeSeconds)
if err != nil {
return DatabaseSettings{}, err
}
return DatabaseSettings{
Driver: driver,
Host: os.Getenv(envDatabaseHost),
Port: envOrDefault(envDatabasePort, defaultPort),
User: os.Getenv(envDatabaseUser),
Password: os.Getenv(envDatabasePassword),
Name: os.Getenv(envDatabaseName),
TLS: tls,
SSLMode: sslMode,
MaxOpenConns: maxOpen,
MaxIdleConns: maxIdle,
ConnMaxLifetime: time.Duration(lifetimeSeconds) * time.Second,
}, nil
}
func envOrDefault(name string, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func nonNegativeIntEnv(name string, fallback string) (int, error) {
raw := envOrDefault(name, fallback)
value, err := strconv.Atoi(raw)
if err != nil || value < 0 {
return 0, fmt.Errorf("%s must be a non-negative whole number, got %q", name, raw)
}
return value, nil
}
@@ -2,6 +2,7 @@ package config
import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -27,6 +28,9 @@ type Settings struct {
UploadDir string
JWTSecret string
DBFile string
Database DatabaseSettings
Storage StorageSettings
DataDirPath string
Port string
Smtp SmtpSettings
AuthRateLimit AuthRateLimitSettings
@@ -36,6 +40,13 @@ type Settings struct {
Container bool
}
func (s Settings) DataDir() string {
if s.DataDirPath != "" {
return s.DataDirPath
}
return filepath.Dir(s.DBFile)
}
func (s Settings) IsProduction() bool {
return s.Environment == "production"
}
@@ -56,6 +67,16 @@ func NewSettingsFromEnv() (Settings, error) {
return Settings{}, err
}
database, err := databaseSettingsFromEnv()
if err != nil {
return Settings{}, err
}
storage, err := storageSettingsFromEnv()
if err != nil {
return Settings{}, err
}
return Settings{
Environment: os.Getenv("ENVIRONMENT"),
CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")),
@@ -63,6 +84,9 @@ func NewSettingsFromEnv() (Settings, error) {
UploadDir: os.Getenv("UPLOAD_DIR"),
JWTSecret: os.Getenv("JWT_SECRET"),
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"),
@@ -0,0 +1,114 @@
package config
import (
"fmt"
"os"
"slices"
"strconv"
)
const (
StorageDriverLocal = "local"
StorageDriverS3 = "s3"
DefaultStorageDriver = StorageDriverLocal
DefaultS3Region = "us-east-1"
DefaultS3PathStyle = "false"
DefaultS3TLS = "true"
EnvStorageDriver = "STORAGE_DRIVER"
envS3Bucket = "S3_BUCKET"
envS3Region = "S3_REGION"
envS3Endpoint = "S3_ENDPOINT"
envS3AccessKeyID = "S3_ACCESS_KEY_ID"
envS3SecretKey = "S3_SECRET_ACCESS_KEY"
envS3KeyPrefix = "S3_KEY_PREFIX"
envS3PathStyle = "S3_PATH_STYLE"
envS3TLS = "S3_TLS"
)
var s3RequiredEnvVars = []string{envS3Bucket}
type S3Settings struct {
Bucket string
Region string
Endpoint string
AccessKeyID string
SecretAccessKey string
KeyPrefix string
PathStyle bool
TLS bool
}
type StorageSettings struct {
Driver string
S3 S3Settings
}
func StorageRequiredEnvVars(driver string) []string {
if driver == StorageDriverS3 {
return slices.Clone(s3RequiredEnvVars)
}
return nil
}
func storageSettingsFromEnv() (StorageSettings, error) {
driver := envOrDefault(EnvStorageDriver, DefaultStorageDriver)
switch driver {
case StorageDriverLocal:
return StorageSettings{Driver: driver}, nil
case StorageDriverS3:
s3, err := s3SettingsFromEnv()
if err != nil {
return StorageSettings{}, err
}
return StorageSettings{Driver: driver, S3: s3}, nil
default:
return StorageSettings{}, fmt.Errorf("unsupported storage driver %q", driver)
}
}
func s3SettingsFromEnv() (S3Settings, error) {
if err := MissingEnvVarError(s3RequiredEnvVars, nonEmptyEnvLookup); err != nil {
return S3Settings{}, err
}
accessKeyID := os.Getenv(envS3AccessKeyID)
secretKey := os.Getenv(envS3SecretKey)
if accessKeyID != "" && secretKey == "" {
return S3Settings{}, fmt.Errorf("Environment variable %s is not set (it is required when %s is set)", envS3SecretKey, envS3AccessKeyID)
}
if secretKey != "" && accessKeyID == "" {
return S3Settings{}, fmt.Errorf("Environment variable %s is not set (it is required when %s is set)", envS3AccessKeyID, envS3SecretKey)
}
pathStyle, err := boolEnv(envS3PathStyle, DefaultS3PathStyle)
if err != nil {
return S3Settings{}, err
}
tls, err := boolEnv(envS3TLS, DefaultS3TLS)
if err != nil {
return S3Settings{}, err
}
return S3Settings{
Bucket: os.Getenv(envS3Bucket),
Region: envOrDefault(envS3Region, DefaultS3Region),
Endpoint: os.Getenv(envS3Endpoint),
AccessKeyID: accessKeyID,
SecretAccessKey: secretKey,
KeyPrefix: os.Getenv(envS3KeyPrefix),
PathStyle: pathStyle,
TLS: tls,
}, nil
}
func nonEmptyEnvLookup(name string) (string, bool) {
value := os.Getenv(name)
return value, value != ""
}
func boolEnv(name string, fallback string) (bool, error) {
raw := envOrDefault(name, fallback)
value, err := strconv.ParseBool(raw)
if err != nil {
return false, fmt.Errorf("%s must be true or false, got %q", name, raw)
}
return value, nil
}
@@ -0,0 +1,55 @@
package database
import (
"os"
"path/filepath"
"slices"
"strings"
"time"
)
type backupFile struct {
path string
modified time.Time
}
func newBackupTarget(directory string, now time.Time, extension string) (string, error) {
if err := os.MkdirAll(directory, os.ModePerm); err != nil {
return "", err
}
target := filepath.Join(directory, backupFilePrefix+now.UTC().Format(backupTimeLayout)+extension)
if _, err := os.Stat(target); err == nil {
return "", errBackupExists
}
return target, nil
}
func pruneBackups(directory string, extension string) error {
entries, err := os.ReadDir(directory)
if err != nil {
return err
}
files := make([]backupFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), extension) {
continue
}
info, err := entry.Info()
if err != nil {
return err
}
files = append(files, backupFile{path: filepath.Join(directory, entry.Name()), modified: info.ModTime()})
}
slices.SortFunc(files, func(a, b backupFile) int {
if byTime := b.modified.Compare(a.modified); byTime != 0 {
return byTime
}
return strings.Compare(b.path, a.path)
})
for _, stale := range files[min(backupsToKeep, len(files)):] {
if err := os.Remove(stale.path); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,77 @@
package database
import (
domainUpdate "RenewCMS/internal/domain/update"
"fmt"
"time"
"gorm.io/gorm"
)
type PoolSettings struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
}
type Config struct {
Driver string
Source string
Pool *PoolSettings
}
type Driver interface {
Open(source string) (*gorm.DB, error)
NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup
}
var drivers = map[string]Driver{
DriverSQLite: sqliteDriver{},
DriverMySQL: mysqlDriver{},
DriverPostgres: postgresDriver{},
}
type Connection struct {
DB *gorm.DB
driver Driver
}
func Connect(config Config) (*Connection, error) {
driver, ok := drivers[config.Driver]
if !ok {
return nil, fmt.Errorf("unsupported database driver %q", config.Driver)
}
db, err := driver.Open(config.Source)
if err != nil {
return nil, err
}
if config.Pool != nil {
if err := applyPool(db, *config.Pool); err != nil {
return nil, err
}
}
return &Connection{DB: db, driver: driver}, nil
}
func applyPool(db *gorm.DB, pool PoolSettings) error {
sqlDb, err := db.DB()
if err != nil {
return err
}
sqlDb.SetMaxOpenConns(pool.MaxOpenConns)
sqlDb.SetMaxIdleConns(pool.MaxIdleConns)
sqlDb.SetConnMaxLifetime(pool.ConnMaxLifetime)
return nil
}
func (c *Connection) NewBackup(directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return c.driver.NewBackup(c.DB, directory, now)
}
func (c *Connection) Close() error {
sqlDb, err := c.DB.DB()
if err != nil {
return err
}
return sqlDb.Close()
}
@@ -0,0 +1,80 @@
package database
import (
domain "RenewCMS/internal/domain/update"
"bufio"
"os"
"time"
)
const (
dumpFileExtension = ".sql"
dumpPartialFileExtension = ".partial"
)
type DumpBackup struct {
open func() (DumpSource, error)
directory string
now func() time.Time
dialect dumpDialect
}
var _ domain.DatabaseBackup = &DumpBackup{}
func NewDumpBackup(open func() (DumpSource, error), directory string, now func() time.Time) *DumpBackup {
return &DumpBackup{open: open, directory: directory, now: now, dialect: mysqlDumpDialect}
}
func (b *DumpBackup) Backup() (string, error) {
target, err := newBackupTarget(b.directory, b.now(), dumpFileExtension)
if err != nil {
return "", err
}
if err := b.writeAtomically(target); err != nil {
return "", err
}
if err := pruneBackups(b.directory, dumpFileExtension); err != nil {
return "", err
}
return target, nil
}
func (b *DumpBackup) writeAtomically(target string) error {
partial := target + dumpPartialFileExtension
if err := b.writePartial(partial); err != nil {
_ = os.Remove(partial)
return err
}
if err := os.Rename(partial, target); err != nil {
_ = os.Remove(partial)
return err
}
return nil
}
func (b *DumpBackup) writePartial(partial string) error {
source, err := b.open()
if err != nil {
return err
}
defer source.Close()
file, err := os.OpenFile(partial, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
buffered := bufio.NewWriter(file)
if err := writeDump(buffered, source, b.dialect); err != nil {
_ = file.Close()
return err
}
if err := buffered.Flush(); err != nil {
_ = file.Close()
return err
}
if err := file.Sync(); err != nil {
_ = file.Close()
return err
}
return file.Close()
}
@@ -0,0 +1,15 @@
package database
import (
"RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
func Migrate(db *gorm.DB) error {
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{})
if err != nil {
return err
}
return BackfillUserRoles(db)
}
@@ -0,0 +1,86 @@
package database
import (
domainUpdate "RenewCMS/internal/domain/update"
"fmt"
"net"
"strings"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
const (
DriverMySQL = "mysql"
mysqlCharset = "utf8mb4"
mysqlLocation = "UTC"
mysqlCollation = "utf8mb4_bin"
mysqlConnectTimeout = 5 * time.Second
mysqlSessionSQLMode = "'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'"
mysqlTableOptionsKey = "gorm:table_options"
redactedPassword = "***"
)
type mysqlDriver struct{}
type MySQLConfig struct {
Host string
Port string
User string
Password string
Name string
TLS string
}
func MySQLDSN(config MySQLConfig) string {
dsn := mysqldriver.NewConfig()
dsn.User = config.User
dsn.Passwd = config.Password
dsn.Net = "tcp"
dsn.Addr = net.JoinHostPort(config.Host, config.Port)
dsn.DBName = config.Name
dsn.TLSConfig = config.TLS
dsn.ParseTime = true
dsn.ClientFoundRows = true
dsn.Collation = mysqlCollation
dsn.Timeout = mysqlConnectTimeout
dsn.Params = map[string]string{
"charset": mysqlCharset,
"loc": mysqlLocation,
"sql_mode": mysqlSessionSQLMode,
}
return dsn.FormatDSN()
}
func (mysqlDriver) Open(source string) (*gorm.DB, error) {
parsed, err := mysqldriver.ParseDSN(source)
if err != nil {
return nil, fmt.Errorf("the mysql connection settings are invalid")
}
db, err := gorm.Open(mysql.Open(source), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("cannot use the mysql server at %s: %s", parsed.Addr, redact(err.Error(), parsed.Passwd))
}
return db, nil
}
func (mysqlDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return NewMySQLBackup(db, directory, now)
}
func redact(message string, secret string) string {
if secret == "" {
return message
}
return strings.ReplaceAll(message, secret, redactedPassword)
}
func tableOptionsFor(db *gorm.DB) *gorm.DB {
if db.Dialector.Name() == DriverMySQL {
return db.Set(mysqlTableOptionsKey, "DEFAULT CHARSET="+mysqlCharset+" COLLATE="+mysqlCollation)
}
return db
}
@@ -0,0 +1,146 @@
package database
import (
"database/sql"
"fmt"
"time"
"gorm.io/gorm"
)
const (
dumpTimeLayout = "2006-01-02 15:04:05.000000"
zeroDumpTime = "0000-00-00 00:00:00"
)
func NewMySQLBackup(db *gorm.DB, directory string, now func() time.Time) *DumpBackup {
return NewDumpBackup(func() (DumpSource, error) { return openMySQLDumpSource(db) }, directory, now)
}
type txDumpSource struct {
tx *gorm.DB
quote func(name string) string
convert func(value any) []byte
}
func openTxDumpSource(db *gorm.DB, quote func(name string) string, convert func(value any) []byte) (*txDumpSource, error) {
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
if tx.Error != nil {
return nil, tx.Error
}
return &txDumpSource{tx: tx, quote: quote, convert: convert}, nil
}
func (s *txDumpSource) Rows(table string) (DumpRows, error) {
rows, err := s.tx.Raw("SELECT * FROM " + s.quote(table)).Rows()
if err != nil {
return nil, err
}
return &sqlDumpRows{rows: rows, convert: s.convert}, nil
}
func (s *txDumpSource) Close() error {
return s.tx.Rollback().Error
}
type mysqlDumpSource struct {
*txDumpSource
}
var _ DumpSource = &mysqlDumpSource{}
func openMySQLDumpSource(db *gorm.DB) (*mysqlDumpSource, error) {
source, err := openTxDumpSource(db, quoteIdentifier, dumpBytes)
if err != nil {
return nil, err
}
return &mysqlDumpSource{source}, nil
}
func (s *mysqlDumpSource) Tables() ([]string, error) {
var tables []string
err := s.tx.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' ORDER BY table_name").Scan(&tables).Error
return tables, err
}
func (s *mysqlDumpSource) CreateStatement(table string) (string, error) {
var name, statement string
row := s.tx.Raw("SHOW CREATE TABLE " + quoteIdentifier(table)).Row()
if err := row.Scan(&name, &statement); err != nil {
return "", err
}
return statement, nil
}
type sqlDumpRows struct {
rows *sql.Rows
convert func(value any) []byte
}
var _ DumpRows = &sqlDumpRows{}
func (r *sqlDumpRows) Columns() ([]DumpColumn, error) {
types, err := r.rows.ColumnTypes()
if err != nil {
return nil, err
}
columns := make([]DumpColumn, len(types))
for index, columnType := range types {
columns[index] = DumpColumn{Name: columnType.Name(), Type: columnType.DatabaseTypeName()}
}
return columns, nil
}
func (r *sqlDumpRows) Next() bool {
return r.rows.Next()
}
func (r *sqlDumpRows) Values() ([][]byte, error) {
columnCount, err := r.rows.Columns()
if err != nil {
return nil, err
}
scanned := make([]any, len(columnCount))
targets := make([]any, len(columnCount))
for index := range scanned {
targets[index] = &scanned[index]
}
if err := r.rows.Scan(targets...); err != nil {
return nil, err
}
values := make([][]byte, len(scanned))
for index, value := range scanned {
values[index] = r.convert(value)
}
return values, nil
}
func (r *sqlDumpRows) Err() error {
return r.rows.Err()
}
func (r *sqlDumpRows) Close() error {
return r.rows.Close()
}
func dumpBytes(value any) []byte {
switch typed := value.(type) {
case nil:
return nil
case []byte:
return append([]byte{}, typed...)
case string:
return []byte(typed)
case time.Time:
return []byte(formatDumpTime(typed))
default:
return []byte(fmt.Sprint(typed))
}
}
func formatDumpTime(value time.Time) string {
if value.IsZero() {
return zeroDumpTime
}
return value.UTC().Format(dumpTimeLayout)
}
@@ -0,0 +1,108 @@
package database
import (
domainUpdate "RenewCMS/internal/domain/update"
"fmt"
"net"
"net/url"
"reflect"
"time"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
const (
DriverPostgres = "postgres"
postgresTimeZone = "UTC"
postgresConnectTimeoutSeconds = 5
postgresScheme = "postgres"
postgresUTCCallback = "renewcms:postgres_utc"
gormQueryCallback = "gorm:query"
)
var timeType = reflect.TypeOf(time.Time{})
type postgresDriver struct{}
type PostgresConfig struct {
Host string
Port string
User string
Password string
Name string
SSLMode string
}
func PostgresDSN(config PostgresConfig) string {
dsn := url.URL{
Scheme: postgresScheme,
User: url.UserPassword(config.User, config.Password),
Host: net.JoinHostPort(config.Host, config.Port),
Path: "/" + config.Name,
}
query := url.Values{}
query.Set("sslmode", config.SSLMode)
query.Set("timezone", postgresTimeZone)
query.Set("connect_timeout", fmt.Sprint(postgresConnectTimeoutSeconds))
dsn.RawQuery = query.Encode()
return dsn.String()
}
func (postgresDriver) Open(source string) (*gorm.DB, error) {
parsed, err := pgconn.ParseConfig(source)
if err != nil {
return nil, fmt.Errorf("the postgres connection settings are invalid")
}
db, err := gorm.Open(postgres.Open(source), &gorm.Config{})
if err != nil {
address := net.JoinHostPort(parsed.Host, fmt.Sprint(parsed.Port))
return nil, fmt.Errorf("cannot use the postgres server at %s: %s", address, redact(err.Error(), parsed.Password))
}
if err := db.Callback().Query().After(gormQueryCallback).Register(postgresUTCCallback, readTimesInUTC); err != nil {
return nil, err
}
return db, nil
}
func readTimesInUTC(db *gorm.DB) {
if db.Error != nil || db.Statement.Dest == nil {
return
}
convertTimesToUTC(reflect.ValueOf(db.Statement.Dest))
}
func convertTimesToUTC(value reflect.Value) {
switch value.Kind() {
case reflect.Pointer, reflect.Interface:
if !value.IsNil() {
convertTimesToUTC(value.Elem())
}
case reflect.Slice, reflect.Array:
for index := 0; index < value.Len(); index++ {
convertTimesToUTC(value.Index(index))
}
case reflect.Struct:
convertStructTimesToUTC(value)
}
}
func convertStructTimesToUTC(value reflect.Value) {
if value.Type() == timeType {
if value.CanSet() {
value.Set(reflect.ValueOf(value.Interface().(time.Time).UTC()))
}
return
}
for index := 0; index < value.NumField(); index++ {
if value.Type().Field(index).IsExported() {
convertTimesToUTC(value.Field(index))
}
}
}
func (postgresDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return NewPostgresBackup(db, directory, now)
}
@@ -0,0 +1,189 @@
package database
import (
"fmt"
"strings"
"time"
"gorm.io/gorm"
)
const (
postgresSchema = "public"
postgresTablesQuery = "SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE' ORDER BY table_name"
postgresColumnsQuery = `SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS data_type, a.attnotnull AS not_null, COALESCE(pg_get_expr(d.adbin, d.adrelid), '') AS default_value, a.attidentity::text AS identity
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE n.nspname = ? AND c.relname = ? AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum`
postgresConstraintsQuery = `SELECT con.conname AS name, pg_get_constraintdef(con.oid) AS definition
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ? AND c.relname = ? AND con.contype IN ('p', 'u', 'c')
ORDER BY CASE con.contype WHEN 'p' THEN 0 ELSE 1 END, con.conname`
postgresIndexesQuery = `SELECT i.indexdef
FROM pg_indexes i
WHERE i.schemaname = ? AND i.tablename = ?
AND i.indexname NOT IN (
SELECT con.conname FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ? AND c.relname = ?
)
ORDER BY i.indexname`
postgresIdentityColumn = "id"
postgresTimeLayout = "2006-01-02 15:04:05.999999999Z07:00"
postgresByteaType = "BYTEA"
)
var postgresNumericTypes = map[string]bool{
"INT2": true, "INT4": true, "INT8": true, "FLOAT4": true, "FLOAT8": true, "NUMERIC": true, "OID": true,
}
var postgresSerialTypes = map[string]string{
"smallint": "smallserial",
"integer": "serial",
"bigint": "bigserial",
}
var postgresDumpDialect = dumpDialect{
preamble: []string{
"SET client_encoding = 'UTF8';",
"SET standard_conforming_strings = on;",
"SET TimeZone = 'UTC';",
},
quote: quotePostgresIdentifier,
literal: postgresLiteral,
tableTrailer: postgresSequenceReset,
}
func NewPostgresBackup(db *gorm.DB, directory string, now func() time.Time) *DumpBackup {
backup := NewDumpBackup(func() (DumpSource, error) { return openPostgresDumpSource(db) }, directory, now)
backup.dialect = postgresDumpDialect
return backup
}
type postgresDumpSource struct {
*txDumpSource
}
var _ DumpSource = &postgresDumpSource{}
func openPostgresDumpSource(db *gorm.DB) (*postgresDumpSource, error) {
source, err := openTxDumpSource(db, quotePostgresIdentifier, postgresDumpBytes)
if err != nil {
return nil, err
}
return &postgresDumpSource{source}, nil
}
func (s *postgresDumpSource) Tables() ([]string, error) {
var tables []string
err := s.tx.Raw(postgresTablesQuery, postgresSchema).Scan(&tables).Error
return tables, err
}
type postgresColumn struct {
Name string
DataType string
NotNull bool
DefaultValue string
Identity string
}
type postgresConstraint struct {
Name string
Definition string
}
func (s *postgresDumpSource) CreateStatement(table string) (string, error) {
var columns []postgresColumn
if err := s.tx.Raw(postgresColumnsQuery, postgresSchema, table).Scan(&columns).Error; err != nil {
return "", err
}
var constraints []postgresConstraint
if err := s.tx.Raw(postgresConstraintsQuery, postgresSchema, table).Scan(&constraints).Error; err != nil {
return "", err
}
var indexes []string
if err := s.tx.Raw(postgresIndexesQuery, postgresSchema, table, postgresSchema, table).Scan(&indexes).Error; err != nil {
return "", err
}
var definitions []string
for _, column := range columns {
definitions = append(definitions, " "+postgresColumnDefinition(column))
}
for _, constraint := range constraints {
definitions = append(definitions, fmt.Sprintf(" CONSTRAINT %s %s", quotePostgresIdentifier(constraint.Name), constraint.Definition))
}
statements := []string{fmt.Sprintf("CREATE TABLE %s (\n%s\n)", quotePostgresIdentifier(table), strings.Join(definitions, ",\n"))}
statements = append(statements, indexes...)
return strings.Join(statements, ";\n"), nil
}
func postgresColumnDefinition(column postgresColumn) string {
quoted := quotePostgresIdentifier(column.Name)
serial, isSerial := postgresSerialTypes[column.DataType]
if isSerial && strings.HasPrefix(column.DefaultValue, "nextval(") {
return quoted + " " + serial + " NOT NULL"
}
if column.Identity != "" {
return quoted + " " + column.DataType + " GENERATED BY DEFAULT AS IDENTITY NOT NULL"
}
definition := quoted + " " + column.DataType
if column.DefaultValue != "" {
definition += " DEFAULT " + column.DefaultValue
}
if column.NotNull {
definition += " NOT NULL"
}
return definition
}
func postgresDumpBytes(value any) []byte {
if moment, isTime := value.(time.Time); isTime {
return []byte(moment.UTC().Format(postgresTimeLayout))
}
return dumpBytes(value)
}
func quotePostgresIdentifier(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
func postgresLiteral(databaseType string, value []byte) string {
if value == nil {
return "NULL"
}
upper := strings.ToUpper(databaseType)
if postgresNumericTypes[upper] && plainNumber.Match(value) {
return string(value)
}
if upper == postgresByteaType {
return fmt.Sprintf(`'\x%x'`, value)
}
return "'" + strings.ReplaceAll(string(value), "'", "''") + "'"
}
func postgresSequenceReset(table string, columns []DumpColumn) []string {
for _, column := range columns {
if column.Name != postgresIdentityColumn {
continue
}
quotedTable, quotedColumn := quotePostgresIdentifier(table), quotePostgresIdentifier(column.Name)
sequenceTable := strings.ReplaceAll(quotedTable, "'", "''")
return []string{fmt.Sprintf(
"SELECT setval(pg_get_serial_sequence('%s', '%s'), COALESCE((SELECT MAX(%s) FROM %s), 1), (SELECT COUNT(*) > 0 FROM %s));",
sequenceTable, column.Name, quotedColumn, quotedTable, quotedTable,
)}
}
return nil
}
@@ -0,0 +1,189 @@
package database
import (
"encoding/hex"
"fmt"
"io"
"regexp"
"strings"
)
type DumpColumn struct {
Name string
Type string
}
type DumpRows interface {
Columns() ([]DumpColumn, error)
Next() bool
Values() ([][]byte, error)
Err() error
Close() error
}
type DumpSource interface {
Tables() ([]string, error)
CreateStatement(table string) (string, error)
Rows(table string) (DumpRows, error)
Close() error
}
type dumpDialect struct {
preamble []string
postamble []string
quote func(name string) string
literal func(databaseType string, value []byte) string
tableTrailer func(table string, columns []DumpColumn) []string
}
var mysqlDumpDialect = dumpDialect{
preamble: []string{
"SET NAMES utf8mb4;",
"SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';",
"SET foreign_key_checks = 0;",
},
postamble: []string{
"SET foreign_key_checks = 1;",
},
quote: quoteIdentifier,
literal: sqlLiteral,
tableTrailer: func(string, []DumpColumn) []string { return nil },
}
var plainNumber = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$`)
var textEscapes = strings.NewReplacer(
`\`, `\\`,
`'`, `\'`,
"\x00", `\0`,
"\n", `\n`,
"\r", `\r`,
"\x1a", `\Z`,
)
func WriteDump(output io.Writer, source DumpSource) error {
return writeDump(output, source, mysqlDumpDialect)
}
func writeDump(output io.Writer, source DumpSource, dialect dumpDialect) error {
if err := writeStatements(output, dialect.preamble); err != nil {
return err
}
tables, err := source.Tables()
if err != nil {
return err
}
for _, table := range tables {
if err := writeTable(output, source, table, dialect); err != nil {
return err
}
}
return writeStatements(output, dialect.postamble)
}
func writeStatements(output io.Writer, statements []string) error {
for _, statement := range statements {
if _, err := fmt.Fprintln(output, statement); err != nil {
return err
}
}
return nil
}
func writeTable(output io.Writer, source DumpSource, table string, dialect dumpDialect) error {
create, err := source.CreateStatement(table)
if err != nil {
return err
}
if _, err := fmt.Fprintf(output, "%s;\n", create); err != nil {
return err
}
rows, err := source.Rows(table)
if err != nil {
return err
}
defer rows.Close()
return writeRows(output, table, rows, dialect)
}
func writeRows(output io.Writer, table string, rows DumpRows, dialect dumpDialect) error {
columns, err := rows.Columns()
if err != nil {
return err
}
insertPrefix := insertStatementPrefix(table, columns, dialect)
for rows.Next() {
values, err := rows.Values()
if err != nil {
return err
}
if _, err := fmt.Fprintf(output, "%s%s);\n", insertPrefix, joinLiterals(columns, values, dialect)); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return err
}
return writeStatements(output, dialect.tableTrailer(table, columns))
}
func insertStatementPrefix(table string, columns []DumpColumn, dialect dumpDialect) string {
names := make([]string, len(columns))
for index, column := range columns {
names[index] = dialect.quote(column.Name)
}
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (", dialect.quote(table), strings.Join(names, ","))
}
func joinLiterals(columns []DumpColumn, values [][]byte, dialect dumpDialect) string {
literals := make([]string, len(columns))
for index, column := range columns {
literals[index] = dialect.literal(column.Type, values[index])
}
return strings.Join(literals, ",")
}
func quoteIdentifier(name string) string {
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
}
func sqlLiteral(databaseType string, value []byte) string {
if value == nil {
return "NULL"
}
switch {
case isNumericType(databaseType) && plainNumber.Match(value):
return string(value)
case isBinaryType(databaseType):
return hexLiteral(value)
default:
return "'" + textEscapes.Replace(string(value)) + "'"
}
}
func hexLiteral(value []byte) string {
if len(value) == 0 {
return "''"
}
return "0x" + hex.EncodeToString(value)
}
func isNumericType(databaseType string) bool {
upper := strings.ToUpper(databaseType)
for _, suffix := range []string{"INT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "YEAR"} {
if strings.HasSuffix(upper, suffix) {
return true
}
}
return false
}
func isBinaryType(databaseType string) bool {
upper := strings.ToUpper(databaseType)
for _, suffix := range []string{"BLOB", "BINARY", "BIT", "GEOMETRY"} {
if strings.HasSuffix(upper, suffix) {
return true
}
}
return false
}
@@ -0,0 +1,33 @@
package database
import (
domainUpdate "RenewCMS/internal/domain/update"
"fmt"
"os"
"path/filepath"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
const DriverSQLite = "sqlite"
const sqliteBusyTimeoutMs = 5000
type sqliteDriver struct{}
func SQLiteDSN(dbName string) string {
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs)
}
func (sqliteDriver) Open(file string) (*gorm.DB, error) {
if err := os.MkdirAll(filepath.Dir(file), os.ModePerm); err != nil {
return nil, fmt.Errorf("unable to create necessary subdirectories: %w", err)
}
return gorm.Open(sqlite.Open(SQLiteDSN(file)), &gorm.Config{})
}
func (sqliteDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return NewSQLiteBackup(db, directory, now)
}
@@ -0,0 +1,46 @@
package database
import (
domain "RenewCMS/internal/domain/update"
"errors"
"os"
"time"
"gorm.io/gorm"
)
var errBackupExists = errors.New("a backup with this name already exists")
const (
backupsToKeep = 3
backupFileExtension = ".db"
backupFilePrefix = "renewcms-"
backupTimeLayout = "20060102T150405.000000000Z"
)
type SQLiteBackup struct {
db *gorm.DB
directory string
now func() time.Time
}
var _ domain.DatabaseBackup = &SQLiteBackup{}
func NewSQLiteBackup(db *gorm.DB, directory string, now func() time.Time) *SQLiteBackup {
return &SQLiteBackup{db: db, directory: directory, now: now}
}
func (b *SQLiteBackup) Backup() (string, error) {
target, err := newBackupTarget(b.directory, b.now(), backupFileExtension)
if err != nil {
return "", err
}
if err := b.db.Exec("VACUUM INTO ?", target).Error; err != nil {
_ = os.Remove(target)
return "", err
}
if err := pruneBackups(b.directory, backupFileExtension); err != nil {
return "", err
}
return target, nil
}
@@ -1,4 +1,4 @@
package persistence
package database
import (
domainUser "RenewCMS/internal/domain/user"
@@ -7,7 +7,7 @@ type APIKey struct {
UserID uint32 `gorm:"index;not null"`
Name string `gorm:"not null"`
Prefix string `gorm:"not null"`
HashedKey string `gorm:"uniqueIndex;not null"`
HashedKey string `gorm:"uniqueIndex;not null;size:64"`
ExpiresAt *time.Time
RevokedAt *time.Time
LastUsedAt *time.Time
@@ -15,7 +15,7 @@ type Image struct {
ContentType string
Width int
Height int
SHA256 string `gorm:"column:sha256;index"`
SHA256 string `gorm:"column:sha256;index;size:64"`
UploadedByID uint32
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
@@ -4,7 +4,7 @@ import "time"
type Invite struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Email string `gorm:"index;not null"`
Email string `gorm:"index;not null;size:255"`
HashedCode string `gorm:"not null"`
ExpiresAt time.Time
InvitedBy uint32 `gorm:"not null"`
@@ -6,14 +6,14 @@ import (
type User struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Username string `gorm:"unique;not null"`
Username string `gorm:"unique;not null;size:255"`
Password string `gorm:"not null"`
PasswordResetCode *string `gorm:"unique"`
Email string `gorm:"unique;not null"`
DisplayName string `gorm:"not null;default:''"`
PasswordResetCode *string `gorm:"unique;size:255"`
Email string `gorm:"unique;not null;size:255"`
DisplayName string `gorm:"not null;default:'';size:255"`
IsVerified bool `gorm:"default=false;not null"`
Role string `gorm:"not null;default:''"`
VerificationCode *string `gorm:"unique"`
Role string `gorm:"not null;default:'';size:32"`
VerificationCode *string `gorm:"unique;size:255"`
VerificationExpiration time.Time
PasswordResetExpiration time.Time
TokenVersion uint32 `gorm:"default:0;not null"`
@@ -126,7 +126,7 @@ func (a *ArticleRepository) Delete(id uint32) error {
func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Article, error) {
var entityArticles []entity.Article
err := a.db.Model(&entity.Article{}).Where("instr(body, ?) > 0", filename).Order("id asc").Find(&entityArticles).Error
err := a.db.Model(&entity.Article{}).Where("body LIKE ? ESCAPE '!'", containsPattern(filename)).Order("id asc").Find(&entityArticles).Error
if err != nil {
return nil, err
}
@@ -1,77 +0,0 @@
package persistence
import (
entity "RenewCMS/internal/infrastructure/persistence/models"
"bytes"
"crypto/sha256"
"encoding/hex"
stdimage "image"
"os"
"path/filepath"
_ "golang.org/x/image/webp"
_ "image/jpeg"
_ "image/png"
"gorm.io/gorm"
)
var extensionContentTypes = map[string]string{
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
}
func BackfillImageMetadata(db *gorm.DB, uploadDir string) error {
var pending []entity.Image
err := db.Where("filename = '' OR filename IS NULL").Order("id asc").Find(&pending).Error
if err != nil {
return err
}
for _, row := range pending {
metadata, ok := readStoredImage(uploadDir, row.Path)
if !ok {
continue
}
err := db.Model(&entity.Image{}).Where("id = ?", row.ID).UpdateColumns(metadata).Error
if err != nil {
return err
}
}
return nil
}
func readStoredImage(uploadDir string, storedPath string) (map[string]any, bool) {
fileName := filepath.Base(storedPath)
contentType, known := extensionContentTypes[filepath.Ext(fileName)]
if !known {
return nil, false
}
data, err := os.ReadFile(filepath.Join(uploadDir, fileName))
if err != nil {
return nil, false
}
width, height := 0, 0
if contentType != "image/svg+xml" {
config, _, err := stdimage.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, false
}
width, height = config.Width, config.Height
}
checksum := sha256.Sum256(data)
return map[string]any{
"filename": fileName,
"size": int64(len(data)),
"content_type": contentType,
"width": width,
"height": height,
"sha256": hex.EncodeToString(checksum[:]),
}, true
}
@@ -4,67 +4,24 @@ import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"io"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"gorm.io/gorm"
)
const uploadedImagesURLPrefix = "/static/uploadedImages/"
type ImageRepository struct {
db *gorm.DB
uploadDir string
db *gorm.DB
}
func NewImageRepository(db *gorm.DB, uploadDir string) *ImageRepository {
return &ImageRepository{db, uploadDir}
func NewImageRepository(db *gorm.DB) *ImageRepository {
return &ImageRepository{db}
}
var contentTypeExtensions = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpeg",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
func (i ImageRepository) Create(input domain.ImageInput) (domain.Image, error) {
fileBytes, err := io.ReadAll(input.Content)
if err != nil {
return domain.Image{}, err
}
err = os.MkdirAll(i.uploadDir, 0755)
if err != nil {
return domain.Image{}, err
}
newName := uuid.NewString() + contentTypeExtensions[input.ContentType]
finalPath := filepath.Join(i.uploadDir, newName)
err = os.WriteFile(finalPath, fileBytes, 0666)
if err != nil {
return domain.Image{}, err
}
model := mappers.ImageToModel(domain.Image{
Path: uploadedImagesURLPrefix + newName,
Filename: input.Filename,
Size: input.Size,
ContentType: input.ContentType,
Width: input.Width,
Height: input.Height,
SHA256: input.SHA256,
UploadedByID: input.UploadedByID,
})
func (i ImageRepository) Create(image domain.Image) (domain.Image, error) {
model := mappers.ImageToModel(image)
if err := i.db.Create(&model).Error; err != nil {
_ = os.Remove(finalPath)
return domain.Image{}, err
}
return mappers.ImageToDomain(model), nil
}
@@ -78,24 +35,13 @@ func (i ImageRepository) Get(id uint32) (domain.Image, error) {
}
func (i ImageRepository) Delete(id uint32) error {
var model entity.Image
err := i.db.First(&model, id).Error
if err != nil {
return mapNotFoundErr(err, domain.ErrImageNotFound)
}
err = os.Remove(filepath.Join(i.uploadDir, filepath.Base(model.Path)))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return i.db.Delete(&entity.Image{}, id).Error
return deleteOrNotFound(i.db.Delete(&entity.Image{}, id), domain.ErrImageNotFound)
}
func (i ImageRepository) List(query domain.SearchQuery) ([]domain.Image, error) {
statement := i.db.Model(&entity.Image{})
if query.Search != "" {
statement = statement.Where("instr(lower(filename), ?) > 0", strings.ToLower(query.Search))
statement = statement.Where("LOWER(filename) LIKE ? ESCAPE '!'", containsPattern(strings.ToLower(query.Search)))
}
if query.Hash != "" {
statement = statement.Where("sha256 = ?", query.Hash)
@@ -171,4 +117,24 @@ func imagesToDomain(models []entity.Image) []domain.Image {
return images
}
func (i ImageRepository) ListMissingMetadata() ([]domain.Image, error) {
var models []entity.Image
err := i.db.Where("filename = '' OR filename IS NULL").Order("id asc").Find(&models).Error
if err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
func (i ImageRepository) UpdateMetadata(id uint32, metadata domain.Metadata) error {
return i.db.Model(&entity.Image{}).Where("id = ?", id).UpdateColumns(map[string]any{
"filename": metadata.Filename,
"size": metadata.Size,
"content_type": metadata.ContentType,
"width": metadata.Width,
"height": metadata.Height,
"sha256": metadata.SHA256,
}).Error
}
var _ domain.Repository = &ImageRepository{}
@@ -2,6 +2,7 @@ package persistence
import (
"errors"
"strings"
"gorm.io/gorm"
)
@@ -22,3 +23,8 @@ func deleteOrNotFound(result *gorm.DB, sentinel error) error {
}
return nil
}
func containsPattern(fragment string) string {
escaped := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(fragment)
return "%" + escaped + "%"
}
@@ -28,7 +28,7 @@ func (u *UserRepository) Get(id uint32) (domainUser.User, error) {
}
func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
creationResult := u.db.Create(&entity.User{
createdUser := entity.User{
Username: user.Username,
Password: user.Password,
PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode),
@@ -37,16 +37,12 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
Role: string(user.Role),
VerificationCode: mappers.DomainToNullableString(user.VerificationCode),
VerificationExpiration: user.VerificationExpiration,
})
if creationResult.Error != nil {
return domainUser.User{}, creationResult.Error
}
if err := u.db.Create(&createdUser).Error; err != nil {
return domainUser.User{}, err
}
var createdUser entity.User
creationResult.Scan(&createdUser)
return mappers.UserToDomain(createdUser),
nil
return mappers.UserToDomain(createdUser), nil
}
func (u *UserRepository) Delete(id uint32) error {
@@ -0,0 +1,74 @@
package storage
import (
domain "RenewCMS/internal/domain/image"
"errors"
"io"
"os"
"path/filepath"
"github.com/google/uuid"
)
var contentTypeExtensions = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpeg",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
type LocalStorage struct {
directory string
}
var _ domain.Storage = &LocalStorage{}
func NewLocalStorage(directory string) *LocalStorage {
return &LocalStorage{directory: directory}
}
func (s *LocalStorage) Put(contentType string, content io.Reader) (string, error) {
data, err := io.ReadAll(content)
if err != nil {
return "", err
}
if err := os.MkdirAll(s.directory, 0755); err != nil {
return "", err
}
storedName := uuid.NewString() + contentTypeExtensions[contentType]
if err := os.WriteFile(filepath.Join(s.directory, storedName), data, 0666); err != nil {
return "", err
}
return storedName, nil
}
func (s *LocalStorage) Open(storedName string) (domain.Blob, error) {
if !isPlainName(storedName) {
return domain.Blob{}, domain.ErrBlobNotFound
}
file, err := os.Open(filepath.Join(s.directory, storedName))
if err != nil {
return domain.Blob{}, domain.ErrBlobNotFound
}
info, err := file.Stat()
if err != nil || info.IsDir() {
_ = file.Close()
return domain.Blob{}, domain.ErrBlobNotFound
}
return domain.Blob{Content: file, Size: info.Size(), ModTime: info.ModTime()}, nil
}
func (s *LocalStorage) Delete(storedName string) error {
if !isPlainName(storedName) {
return nil
}
err := os.Remove(filepath.Join(s.directory, storedName))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
func isPlainName(name string) bool {
return name != "" && name != "." && name != ".." && filepath.Base(name) == name && filepath.ToSlash(name) == name
}
@@ -0,0 +1,42 @@
package storage
import (
domain "RenewCMS/internal/domain/image"
"sync/atomic"
)
type ReadCountingStorage struct {
domain.Storage
opened atomic.Int64
}
var _ domain.Storage = &ReadCountingStorage{}
func NewReadCountingStorage(inner domain.Storage) *ReadCountingStorage {
return &ReadCountingStorage{Storage: inner}
}
func (s *ReadCountingStorage) Open(storedName string) (domain.Blob, error) {
blob, err := s.Storage.Open(storedName)
if err == nil {
s.opened.Add(1)
}
return blob, err
}
func (s *ReadCountingStorage) Opened() int {
return int(s.opened.Load())
}
type Logger interface {
Printf(format string, args ...any)
}
func RunBackfillReportingReads(inner domain.Storage, logger Logger, run func(domain.Storage) error) error {
counting := NewReadCountingStorage(inner)
err := run(counting)
if opened := counting.Opened(); opened > 0 {
logger.Printf("image metadata backfill read %d images from the bucket", opened)
}
return err
}
@@ -0,0 +1,83 @@
package storage
import (
"context"
"errors"
"fmt"
"io"
)
type rangeOpener func(offset int64) (io.ReadCloser, context.CancelFunc, error)
type s3Reader struct {
size int64
position int64
bodyOffset int64
body io.ReadCloser
cancel context.CancelFunc
reopen rangeOpener
}
func newS3Reader(size int64, body io.ReadCloser, cancel context.CancelFunc, reopen rangeOpener) *s3Reader {
return &s3Reader{size: size, body: body, cancel: cancel, reopen: reopen}
}
func (r *s3Reader) Read(p []byte) (int, error) {
if r.position >= r.size {
return 0, io.EOF
}
if r.body == nil || r.bodyOffset != r.position {
if err := r.openAtPosition(); err != nil {
return 0, err
}
}
n, err := r.body.Read(p)
r.position += int64(n)
r.bodyOffset = r.position
return n, err
}
func (r *s3Reader) openAtPosition() error {
r.release()
body, cancel, err := r.reopen(r.position)
if err != nil {
return err
}
r.body, r.cancel, r.bodyOffset = body, cancel, r.position
return nil
}
func (r *s3Reader) Seek(offset int64, whence int) (int64, error) {
var target int64
switch whence {
case io.SeekStart:
target = offset
case io.SeekCurrent:
target = r.position + offset
case io.SeekEnd:
target = r.size + offset
default:
return 0, errors.New("invalid seek whence")
}
if target < 0 {
return 0, fmt.Errorf("negative seek position %d", target)
}
r.position = target
return target, nil
}
func (r *s3Reader) Close() error {
r.release()
return nil
}
func (r *s3Reader) release() {
if r.body != nil {
_ = r.body.Close()
r.body = nil
}
if r.cancel != nil {
r.cancel()
r.cancel = nil
}
}
@@ -0,0 +1,211 @@
package storage
import (
domain "RenewCMS/internal/domain/image"
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/google/uuid"
)
const s3CallTimeout = 30 * time.Second
type S3Config struct {
Bucket string
Region string
Endpoint string
AccessKeyID string
SecretAccessKey string
KeyPrefix string
PathStyle bool
TLS bool
MaxAttempts int
}
type S3Storage struct {
client *s3.Client
bucket string
keyPrefix string
}
var _ domain.Storage = &S3Storage{}
func OpenS3Storage(config S3Config) (*S3Storage, error) {
client, err := newS3Client(config)
if err != nil {
return nil, fmt.Errorf("unable to configure the S3 storage for bucket %q: %s", config.Bucket, redact(err, config))
}
store := &S3Storage{client: client, bucket: config.Bucket, keyPrefix: config.KeyPrefix}
if err := store.checkBucket(config); err != nil {
return nil, err
}
return store, nil
}
func newS3Client(config S3Config) (*s3.Client, error) {
options := []func(*awsconfig.LoadOptions) error{awsconfig.WithRegion(config.Region)}
if config.AccessKeyID != "" {
options = append(options, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.SecretAccessKey, "")))
}
loaded, err := awsconfig.LoadDefaultConfig(context.Background(), options...)
if err != nil {
return nil, err
}
loaded.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
loaded.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
if config.MaxAttempts > 0 {
loaded.RetryMaxAttempts = config.MaxAttempts
}
endpoint := s3Endpoint(config)
return s3.NewFromConfig(loaded, func(o *s3.Options) {
o.UsePathStyle = config.PathStyle
if endpoint != "" {
o.BaseEndpoint = aws.String(endpoint)
}
}), nil
}
func s3Endpoint(config S3Config) string {
if config.Endpoint == "" || strings.Contains(config.Endpoint, "://") {
return config.Endpoint
}
if config.TLS {
return "https://" + config.Endpoint
}
return "http://" + config.Endpoint
}
func (s *S3Storage) checkBucket(config S3Config) error {
ctx, cancel := context.WithTimeout(context.Background(), s3CallTimeout)
defer cancel()
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)})
if err == nil {
return nil
}
return fmt.Errorf("S3 bucket %q is not usable: %s", s.bucket, bucketFailureReason(err, config))
}
func bucketFailureReason(err error, config S3Config) string {
switch statusOf(err) {
case http.StatusNotFound:
return "the bucket does not exist at the configured endpoint and region"
case http.StatusForbidden:
return "access denied, check the credentials and the bucket policy"
case 0:
return redact(err, config)
default:
return fmt.Sprintf("unexpected response from the storage service (HTTP %d)", statusOf(err))
}
}
func redact(err error, config S3Config) string {
text := err.Error()
for _, secret := range []string{config.SecretAccessKey, config.AccessKeyID} {
if secret != "" {
text = strings.ReplaceAll(text, secret, "***")
}
}
return text
}
func statusOf(err error) int {
var responseErr *awshttp.ResponseError
if errors.As(err, &responseErr) {
return responseErr.HTTPStatusCode()
}
return 0
}
func isNotFound(err error) bool {
return statusOf(err) == http.StatusNotFound
}
func (s *S3Storage) key(storedName string) string {
return s.keyPrefix + storedName
}
func (s *S3Storage) Put(contentType string, content io.Reader) (string, error) {
data, err := io.ReadAll(content)
if err != nil {
return "", err
}
storedName := uuid.NewString() + contentTypeExtensions[contentType]
ctx, cancel := context.WithTimeout(context.Background(), s3CallTimeout)
defer cancel()
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s.key(storedName)),
Body: bytes.NewReader(data),
ContentType: aws.String(contentType),
})
if err != nil {
return "", fmt.Errorf("unable to store the image in the S3 bucket: %w", err)
}
return storedName, nil
}
func (s *S3Storage) Open(storedName string) (domain.Blob, error) {
if !isPlainName(storedName) {
return domain.Blob{}, domain.ErrBlobNotFound
}
key := s.key(storedName)
output, cancel, err := s.getObject(key, "")
if err != nil {
if isNotFound(err) {
return domain.Blob{}, domain.ErrBlobNotFound
}
return domain.Blob{}, fmt.Errorf("unable to read the image from the S3 bucket: %w", err)
}
size := aws.ToInt64(output.ContentLength)
reader := newS3Reader(size, output.Body, cancel, func(offset int64) (io.ReadCloser, context.CancelFunc, error) {
ranged, rangedCancel, err := s.getObject(key, fmt.Sprintf("bytes=%d-", offset))
if err != nil {
return nil, nil, fmt.Errorf("unable to read the image from the S3 bucket: %w", err)
}
return ranged.Body, rangedCancel, nil
})
return domain.Blob{Content: reader, Size: size, ModTime: aws.ToTime(output.LastModified)}, nil
}
func (s *S3Storage) getObject(key string, byteRange string) (*s3.GetObjectOutput, context.CancelFunc, error) {
ctx, cancel := context.WithCancel(context.Background())
deadline := time.AfterFunc(s3CallTimeout, cancel)
input := &s3.GetObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)}
if byteRange != "" {
input.Range = aws.String(byteRange)
}
output, err := s.client.GetObject(ctx, input)
deadline.Stop()
if err != nil {
cancel()
return nil, nil, err
}
return output, cancel, nil
}
func (s *S3Storage) Delete(storedName string) error {
if !isPlainName(storedName) {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), s3CallTimeout)
defer cancel()
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s.key(storedName)),
})
if err != nil && !isNotFound(err) {
return fmt.Errorf("unable to delete the image from the S3 bucket: %w", err)
}
return nil
}
@@ -0,0 +1,28 @@
package storage
import (
domain "RenewCMS/internal/domain/image"
"fmt"
)
const (
DriverLocal = "local"
DriverS3 = "s3"
)
type Config struct {
Driver string
Location string
S3 S3Config
}
func Open(config Config) (domain.Storage, error) {
switch config.Driver {
case DriverLocal:
return NewLocalStorage(config.Location), nil
case DriverS3:
return OpenS3Storage(config.S3)
default:
return nil, fmt.Errorf("unsupported storage driver %q", config.Driver)
}
}
@@ -1,87 +0,0 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gorm.io/gorm"
)
var errBackupExists = errors.New("a backup with this name already exists")
const (
backupsToKeep = 3
backupFileExtension = ".db"
backupFilePrefix = "renewcms-"
backupTimeLayout = "20060102T150405.000000000Z"
)
type SQLiteDatabaseBackup struct {
db *gorm.DB
directory string
now func() time.Time
}
var _ domain.DatabaseBackup = &SQLiteDatabaseBackup{}
func NewSQLiteDatabaseBackup(db *gorm.DB, directory string, now func() time.Time) *SQLiteDatabaseBackup {
return &SQLiteDatabaseBackup{db: db, directory: directory, now: now}
}
func (b *SQLiteDatabaseBackup) Backup() (string, error) {
if err := os.MkdirAll(b.directory, os.ModePerm); err != nil {
return "", err
}
target := filepath.Join(b.directory, backupFilePrefix+b.now().UTC().Format(backupTimeLayout)+backupFileExtension)
if _, err := os.Stat(target); err == nil {
return "", errBackupExists
}
if err := b.db.Exec("VACUUM INTO ?", target).Error; err != nil {
_ = os.Remove(target)
return "", err
}
if err := b.prune(); err != nil {
return "", err
}
return target, nil
}
type backupFile struct {
path string
modified time.Time
}
func (b *SQLiteDatabaseBackup) prune() error {
entries, err := os.ReadDir(b.directory)
if err != nil {
return err
}
files := make([]backupFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), backupFileExtension) {
continue
}
info, err := entry.Info()
if err != nil {
return err
}
files = append(files, backupFile{path: filepath.Join(b.directory, entry.Name()), modified: info.ModTime()})
}
sort.Slice(files, func(i, j int) bool {
if files[i].modified.Equal(files[j].modified) {
return files[i].path > files[j].path
}
return files[i].modified.After(files[j].modified)
})
for _, stale := range files[min(backupsToKeep, len(files)):] {
if err := os.Remove(stale.path); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,90 @@
package useCases
import (
"RenewCMS/internal/domain/image"
"bytes"
"crypto/sha256"
"encoding/hex"
stdimage "image"
"io"
"path"
_ "golang.org/x/image/webp"
_ "image/jpeg"
_ "image/png"
)
var extensionContentTypes = map[string]string{
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
}
type BackfillImageMetadataUseCase struct {
imageRepository image.Repository
storage image.Storage
}
func NewBackfillImageMetadataUseCase(imageRepository image.Repository, storage image.Storage) *BackfillImageMetadataUseCase {
return &BackfillImageMetadataUseCase{imageRepository, storage}
}
func (u *BackfillImageMetadataUseCase) Run() error {
pending, err := u.imageRepository.ListMissingMetadata()
if err != nil {
return err
}
for _, row := range pending {
metadata, ok := u.readStoredImage(row.Path)
if !ok {
continue
}
if err := u.imageRepository.UpdateMetadata(row.ID, metadata); err != nil {
return err
}
}
return nil
}
func (u *BackfillImageMetadataUseCase) readStoredImage(storedPath string) (image.Metadata, bool) {
fileName := image.StoredName(storedPath)
contentType, known := extensionContentTypes[path.Ext(fileName)]
if !known {
return image.Metadata{}, false
}
data, err := u.read(fileName)
if err != nil {
return image.Metadata{}, false
}
width, height := 0, 0
if contentType != "image/svg+xml" {
config, _, err := stdimage.DecodeConfig(bytes.NewReader(data))
if err != nil {
return image.Metadata{}, false
}
width, height = config.Width, config.Height
}
checksum := sha256.Sum256(data)
return image.Metadata{
Filename: fileName,
Size: int64(len(data)),
ContentType: contentType,
Width: width,
Height: height,
SHA256: hex.EncodeToString(checksum[:]),
}, true
}
func (u *BackfillImageMetadataUseCase) read(storedName string) ([]byte, error) {
blob, err := u.storage.Open(storedName)
if err != nil {
return nil, err
}
defer blob.Content.Close()
return io.ReadAll(blob.Content)
}
@@ -24,10 +24,11 @@ var rasterFormats = map[string]string{
type CreateImageUseCase struct {
imageRepository image.Repository
storage image.Storage
}
func NewCreateImageUseCase(imageRepository image.Repository) *CreateImageUseCase {
return &CreateImageUseCase{imageRepository}
func NewCreateImageUseCase(imageRepository image.Repository, storage image.Storage) *CreateImageUseCase {
return &CreateImageUseCase{imageRepository, storage}
}
func (g *CreateImageUseCase) CreateImage(input image.ImageInput) (image.Image, error) {
@@ -46,8 +47,13 @@ func (g *CreateImageUseCase) CreateImage(input image.ImageInput) (image.Image, e
}
checksum := sha256.Sum256(data)
return g.imageRepository.Create(image.ImageInput{
Content: bytes.NewReader(data),
storedName, err := g.storage.Put(input.ContentType, bytes.NewReader(data))
if err != nil {
return image.Image{}, err
}
created, err := g.imageRepository.Create(image.Image{
Path: image.PublicPath(storedName),
Filename: image.NormalizeUploadFilename(input.Filename, input.ContentType),
Size: int64(len(data)),
ContentType: input.ContentType,
@@ -56,6 +62,11 @@ func (g *CreateImageUseCase) CreateImage(input image.ImageInput) (image.Image, e
SHA256: hex.EncodeToString(checksum[:]),
UploadedByID: input.UploadedByID,
})
if err != nil {
_ = g.storage.Delete(storedName)
return image.Image{}, err
}
return created, nil
}
func readWithinLimit(content io.Reader) ([]byte, error) {
@@ -4,12 +4,17 @@ import "RenewCMS/internal/domain/image"
type DeleteMediaUseCase struct {
imageRepository image.Repository
remover mediaRemover
}
func NewDeleteMediaUseCase(imageRepository image.Repository) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository, mediaRemover{imageRepository, storage}}
}
func (u *DeleteMediaUseCase) Delete(id uint32) error {
return u.imageRepository.Delete(id)
img, err := u.imageRepository.Get(id)
if err != nil {
return err
}
return u.remover.remove(img)
}
@@ -18,10 +18,11 @@ type MergeMediaResult struct {
type MergeMediaUseCase struct {
imageRepository image.Repository
articleRepository article.Repository
remover mediaRemover
}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository, articleRepository}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository, articleRepository, mediaRemover{imageRepository, storage}}
}
type bodyRewrite struct {
@@ -53,7 +54,7 @@ func (u *MergeMediaUseCase) Merge(keepId uint32, mergeIds []uint32) (MergeMediaR
}
for _, duplicate := range copies {
if err := u.imageRepository.Delete(duplicate.ID); err != nil {
if err := u.remover.remove(duplicate); err != nil {
return MergeMediaResult{}, err
}
}
@@ -0,0 +1,15 @@
package useCases
import "RenewCMS/internal/domain/image"
type OpenMediaUseCase struct {
storage image.Storage
}
func NewOpenMediaUseCase(storage image.Storage) *OpenMediaUseCase {
return &OpenMediaUseCase{storage}
}
func (u *OpenMediaUseCase) Open(storedName string) (image.Blob, error) {
return u.storage.Open(storedName)
}
@@ -0,0 +1,15 @@
package useCases
import "RenewCMS/internal/domain/image"
type mediaRemover struct {
imageRepository image.Repository
storage image.Storage
}
func (r mediaRemover) remove(img image.Image) error {
if err := r.storage.Delete(image.StoredName(img.Path)); err != nil {
return err
}
return r.imageRepository.Delete(img.ID)
}
+3 -3
View File
@@ -111,11 +111,11 @@ func newAPIKeyEnv(t *testing.T) *apiKeyEnv {
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites),
GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage()),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(env.images, env.articles),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage()),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage()),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
+7 -17
View File
@@ -2,20 +2,15 @@ package test
import (
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/storage"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestImageRepositoryHasAnExtensionForEveryAllowedContentType(t *testing.T) {
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.Image{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
repo := persistence.NewImageRepository(db, t.TempDir())
func TestLocalStorageHasAnExtensionForEveryAllowedContentType(t *testing.T) {
store := storage.NewLocalStorage(t.TempDir())
allowedContentTypes := imageDomain.AllowedContentTypes()
expectedContentTypes := []string{"image/jpeg", "image/png", "image/svg+xml", "image/webp"}
@@ -25,17 +20,12 @@ func TestImageRepositoryHasAnExtensionForEveryAllowedContentType(t *testing.T) {
for _, contentType := range allowedContentTypes {
t.Run(contentType, func(t *testing.T) {
result, err := repo.Create(imageDomain.ImageInput{
Content: strings.NewReader("fake-image-data"),
Filename: "upload",
ContentType: contentType,
})
storedName, err := store.Put(contentType, strings.NewReader("fake-image-data"))
if err != nil {
t.Fatalf("Create failed: %v", err)
t.Fatalf("Put failed: %v", err)
}
extension := filepath.Ext(result.Path)
if extension == "" {
t.Errorf("expected a non-empty extension for %q, got path %q", contentType, result.Path)
if filepath.Ext(storedName) == "" {
t.Errorf("expected a non-empty extension for %q, got name %q", contentType, storedName)
}
})
}
+82 -19
View File
@@ -21,6 +21,55 @@ var mockImageExtensions = map[string]string{
"image/svg+xml": ".svg",
}
type mockImageStorage struct {
blobs map[string][]byte
putCalls int
nextName int
lastContent []byte
deleted []string
failPut error
failDelete error
}
func newMockImageStorage() *mockImageStorage {
return &mockImageStorage{blobs: map[string][]byte{}}
}
func (m *mockImageStorage) Put(contentType string, content io.Reader) (string, error) {
m.putCalls++
if m.failPut != nil {
return "", m.failPut
}
data, err := io.ReadAll(content)
if err != nil {
return "", err
}
m.nextName++
name := "stored-" + strconv.Itoa(m.nextName) + mockImageExtensions[contentType]
m.blobs[name] = data
m.lastContent = data
return name, nil
}
func (m *mockImageStorage) Open(name string) (imageDomain.Blob, error) {
data, ok := m.blobs[name]
if !ok {
return imageDomain.Blob{}, imageDomain.ErrBlobNotFound
}
return imageDomain.Blob{Content: io.NopCloser(strings.NewReader(string(data))), Size: int64(len(data))}, nil
}
func (m *mockImageStorage) Delete(name string) error {
m.deleted = append(m.deleted, name)
if m.failDelete != nil {
return m.failDelete
}
delete(m.blobs, name)
return nil
}
var _ imageDomain.Storage = &mockImageStorage{}
type mockImageRepo struct {
images map[uint32]imageDomain.Image
nextID uint32
@@ -28,7 +77,6 @@ type mockImageRepo struct {
failDeleteFor map[uint32]error
deleteCalls []uint32
createCalls int
lastContent []byte
onDelete func(id uint32)
}
@@ -45,34 +93,49 @@ func (m *mockImageRepo) sortedImages() []imageDomain.Image {
return result
}
func (m *mockImageRepo) Create(input imageDomain.ImageInput) (imageDomain.Image, error) {
func (m *mockImageRepo) Create(input imageDomain.Image) (imageDomain.Image, error) {
m.createCalls++
if err := m.fail.trigger("Create"); err != nil {
return imageDomain.Image{}, err
}
if input.Content != nil {
m.lastContent, _ = io.ReadAll(input.Content)
}
for m.images[m.nextID+1].ID != 0 {
m.nextID++
}
m.nextID++
img := imageDomain.Image{
ID: m.nextID,
Path: "/static/uploadedImages/" + strconv.Itoa(int(m.nextID)) + mockImageExtensions[input.ContentType],
Filename: input.Filename,
Size: input.Size,
ContentType: input.ContentType,
Width: input.Width,
Height: input.Height,
SHA256: input.SHA256,
UploadedByID: input.UploadedByID,
CreatedAt: mockImageEpoch.Add(time.Duration(m.nextID) * time.Minute),
}
img := input
img.ID = m.nextID
img.CreatedAt = mockImageEpoch.Add(time.Duration(m.nextID) * time.Minute)
m.images[img.ID] = img
return img, nil
}
func (m *mockImageRepo) ListMissingMetadata() ([]imageDomain.Image, error) {
if err := m.fail.trigger("ListMissingMetadata"); err != nil {
return nil, err
}
pending := []imageDomain.Image{}
for _, img := range m.sortedImages() {
if img.Filename == "" {
pending = append(pending, img)
}
}
return pending, nil
}
func (m *mockImageRepo) UpdateMetadata(id uint32, metadata imageDomain.Metadata) error {
if err := m.fail.trigger("UpdateMetadata"); err != nil {
return err
}
img, ok := m.images[id]
if !ok {
return imageDomain.ErrImageNotFound
}
img.Filename, img.Size, img.ContentType = metadata.Filename, metadata.Size, metadata.ContentType
img.Width, img.Height, img.SHA256 = metadata.Width, metadata.Height, metadata.SHA256
m.images[id] = img
return nil
}
func (m *mockImageRepo) Get(id uint32) (imageDomain.Image, error) {
if err := m.fail.trigger("Get"); err != nil {
return imageDomain.Image{}, err
@@ -186,8 +249,8 @@ var _ imageDomain.Repository = &mockImageRepo{}
func TestImageUseCases(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo)
deleteUc := useCases.NewDeleteMediaUseCase(repo)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage())
t.Run("Create Image", func(t *testing.T) {
input := imageDomain.ImageInput{
+5 -5
View File
@@ -39,7 +39,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)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
result, err := createUc.CreateImage(inputOf(paddedTo(validBytesFor(t, contentType), int(imageDomain.MaxImageSize)), contentType))
@@ -58,7 +58,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)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
_, err := createUc.CreateImage(inputOf([]byte("%PDF"), "application/pdf"))
@@ -72,7 +72,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)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
_, err := createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)+1), "image/png"))
@@ -86,7 +86,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)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
_, err := createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)), "image/png"))
if err != nil {
@@ -108,7 +108,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)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
_, err := createUc.CreateImage(inputOf(pngBytes(t, 3, 2), "image/png"))
+1 -1
View File
@@ -60,7 +60,7 @@ func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
env.seedArticle(id, "Article", true, env.urlOf(3))
}
recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles}
uc := useCases.NewMergeMediaUseCase(env.images, recorder)
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage)
if _, err := uc.Merge(2, []uint32{3}); err != nil {
t.Fatalf("unexpected error: %v", err)
+5 -3
View File
@@ -32,6 +32,7 @@ const (
type mediaEnv struct {
articles *mockArticleRepo
images *mockImageRepo
storage *mockImageStorage
users *mockUserRepo
purgeSettings *mockPurgeSettingsRepo
clock time.Time
@@ -47,6 +48,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
env := &mediaEnv{
articles: newMockArticleRepo(),
images: newMockImageRepo(),
storage: newMockImageStorage(),
users: newMockUserRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil),
purgeSettings: newMockPurgeSettingsRepo(),
@@ -58,7 +60,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
env.users.users[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false}
env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1}
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
now := func() time.Time { return env.clock }
container := &api.UseCases{
Token: env.token,
@@ -68,10 +70,10 @@ func newMediaEnv(t *testing.T) *mediaEnv {
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage),
MergeMediaUseCase: merge,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
+6 -6
View File
@@ -411,7 +411,7 @@ func TestMediaUploading(t *testing.T) {
if len(env.images.images) != 1 || env.images.images[1].UploadedByID != mediaVerifiedUserId {
t.Errorf("expected one new image with UploadedByID 1, got %+v", env.images.images)
}
if !reflect.DeepEqual(env.images.lastContent, content) {
if !reflect.DeepEqual(env.storage.lastContent, content) {
t.Errorf("expected the repository to receive the uploaded bytes")
}
})
@@ -947,7 +947,7 @@ func TestMediaMerging(t *testing.T) {
t.Run("A duplicated id reaching the use case is refused before anything is read", func(t *testing.T) {
env := newEnv(t)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
_, err := uc.Merge(2, []uint32{3, 3})
@@ -996,7 +996,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())
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage())
for _, contentType := range domainImage.AllowedContentTypes() {
_, err := uc.CreateImage(inputOf([]byte("not an image"), contentType))
@@ -1007,7 +1007,7 @@ func TestMediaSentinels(t *testing.T) {
})
t.Run("An svg without an svg root is unreadable", func(t *testing.T) {
uc := useCases.NewCreateImageUseCase(newMockImageRepo())
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage())
_, err := uc.CreateImage(inputOf([]byte("<html/>"), "image/svg+xml"))
@@ -1017,7 +1017,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())
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage())
_, err := uc.CreateImage(inputOf(jpegBytes(t, 3, 2), "image/png"))
@@ -1042,7 +1042,7 @@ func TestMediaSentinels(t *testing.T) {
env.seedImage(2, "b.png", 1, "image/png", 1, 1, hashB, 1)
env.seedImage(3, "c.png", 1, "image/png", 1, 1, "", 1)
env.seedImage(4, "d.png", 1, "image/png", 1, 1, "", 1)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
if _, err := uc.Merge(1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) {
t.Errorf("expected ErrMergeCopyMissing, got %v", err)
+17 -35
View File
@@ -5,7 +5,8 @@ import (
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"bytes"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"os"
"path/filepath"
@@ -21,13 +22,13 @@ func newMediaPersistence(t *testing.T) (*gorm.DB, *persistence.ImageRepository,
t.Fatalf("failed to migrate: %v", err)
}
uploadDir := t.TempDir()
return db, persistence.NewImageRepository(db, uploadDir), uploadDir
return db, persistence.NewImageRepository(db), uploadDir
}
func createStoredImage(t *testing.T, repo *persistence.ImageRepository, filename string, content []byte, contentType string) domainImage.Image {
t.Helper()
created, err := repo.Create(domainImage.ImageInput{
Content: bytes.NewReader(content),
created, err := repo.Create(domainImage.Image{
Path: domainImage.PublicPath(filename),
Filename: filename,
Size: int64(len(content)),
ContentType: contentType,
@@ -42,8 +43,8 @@ func createStoredImage(t *testing.T, repo *persistence.ImageRepository, filename
}
func TestMediaImageRepository(t *testing.T) {
t.Run("Create stores the file and the metadata and returns the assigned id", func(t *testing.T) {
_, repo, uploadDir := newMediaPersistence(t)
t.Run("Create stores the metadata and returns the assigned id", func(t *testing.T) {
_, repo, _ := newMediaPersistence(t)
content := pngBytes(t, 3, 2)
created := createStoredImage(t, repo, "a.png", content, "image/png")
@@ -51,10 +52,6 @@ func TestMediaImageRepository(t *testing.T) {
if created.ID == 0 || created.Filename != "a.png" || created.SHA256 != sha256Hex(content) || created.Width != 3 || created.Size != int64(len(content)) {
t.Errorf("unexpected image %+v", created)
}
onDisk, err := os.ReadFile(filepath.Join(uploadDir, filepath.Base(created.Path)))
if err != nil || !bytes.Equal(onDisk, content) {
t.Errorf("expected the file on disk, got %v", err)
}
got, err := repo.Get(created.ID)
if err != nil || got.SHA256 != created.SHA256 {
t.Errorf("Get returned %+v, %v", got, err)
@@ -69,34 +66,19 @@ func TestMediaImageRepository(t *testing.T) {
}
})
t.Run("Delete removes the file and the row", func(t *testing.T) {
_, repo, uploadDir := newMediaPersistence(t)
t.Run("Delete removes the row", func(t *testing.T) {
_, repo, _ := newMediaPersistence(t)
created := createStoredImage(t, repo, "a.png", pngBytes(t, 3, 2), "image/png")
if err := repo.Delete(created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := os.Stat(filepath.Join(uploadDir, filepath.Base(created.Path))); !os.IsNotExist(err) {
t.Errorf("expected the file to be gone")
}
if _, err := repo.Get(created.ID); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected the row to be gone, got %v", err)
}
})
t.Run("A file already missing from disk still deletes the row", func(t *testing.T) {
_, repo, uploadDir := newMediaPersistence(t)
created := createStoredImage(t, repo, "a.png", pngBytes(t, 3, 2), "image/png")
if err := os.Remove(filepath.Join(uploadDir, filepath.Base(created.Path))); err != nil {
t.Fatalf("failed to remove the file: %v", err)
}
if err := repo.Delete(created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := repo.Get(created.ID); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected the row to be gone, got %v", err)
}
if err := repo.Delete(created.ID); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected ErrImageNotFound on a second delete, got %v", err)
}
})
t.Run("List, FindByHash, Rename, ListDuplicates and Counts", func(t *testing.T) {
@@ -183,7 +165,7 @@ func TestMediaImageBackfill(t *testing.T) {
writeFile(t, uploadDir, "two.svg", svg)
pngRow, svgRow := seedLegacyRow(t, db, "one.png"), seedLegacyRow(t, db, "two.svg")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil {
t.Fatalf("backfill failed: %v", err)
}
@@ -201,7 +183,7 @@ func TestMediaImageBackfill(t *testing.T) {
db, repo, uploadDir := newMediaPersistence(t)
row := seedLegacyRow(t, db, "gone.png")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil {
t.Fatalf("backfill failed: %v", err)
}
@@ -217,7 +199,7 @@ func TestMediaImageBackfill(t *testing.T) {
writeFile(t, uploadDir, "fine.png", pngBytes(t, 3, 2))
broken, fine := seedLegacyRow(t, db, "broken.png"), seedLegacyRow(t, db, "fine.png")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil {
t.Fatalf("backfill failed: %v", err)
}
@@ -233,7 +215,7 @@ func TestMediaImageBackfill(t *testing.T) {
db, repo, uploadDir := newMediaPersistence(t)
writeFile(t, uploadDir, "one.png", pngBytes(t, 3, 2))
row := seedLegacyRow(t, db, "one.png")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil {
t.Fatalf("backfill failed: %v", err)
}
afterFirst, _ := repo.Get(row.ID)
@@ -241,7 +223,7 @@ func TestMediaImageBackfill(t *testing.T) {
t.Fatal(err)
}
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil {
t.Fatalf("second backfill failed: %v", err)
}
+1 -1
View File
@@ -31,7 +31,7 @@ type schedulerEnv struct {
func newSchedulerEnv(t *testing.T) *schedulerEnv {
t.Helper()
env := newPurgeEnv(t)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
now := func() time.Time { return env.clock }
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0))
return &schedulerEnv{
+3 -3
View File
@@ -32,7 +32,7 @@ func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase {
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0))
}
@@ -148,7 +148,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
env.useImages(1, 2)
env.useImages(2, 1, 2)
env.useImages(3, 1)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
result, err := merge.Merge(1, []uint32{2})
@@ -163,7 +163,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
env.useImages(1, 2)
env.useImages(2, 2)
env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure}
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
result, err := merge.Merge(1, []uint32{2})
+210
View File
@@ -0,0 +1,210 @@
package test
import (
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"strings"
"testing"
"time"
)
func pngUpload(t *testing.T) domainImage.ImageInput {
t.Helper()
return domainImage.ImageInput{
Content: bytes.NewReader(pngBytes(t, 3, 2)),
Filename: "a.png",
ContentType: "image/png",
}
}
func uploadPng(t *testing.T, uc *useCases.CreateImageUseCase) domainImage.Image {
t.Helper()
created, err := uc.CreateImage(pngUpload(t))
if err != nil {
t.Fatalf("CreateImage failed: %v", err)
}
return created
}
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))
if !strings.HasPrefix(created.Path, domainImage.PublicPathPrefix) {
t.Errorf("unexpected path %q", created.Path)
}
if !bytes.Equal(store.blobs[domainImage.StoredName(created.Path)], pngBytes(t, 3, 2)) {
t.Error("expected the blob to hold the uploaded bytes")
}
})
t.Run("A storage failure creates no row", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
store.failPut = errRepositoryFailure
_, err := useCases.NewCreateImageUseCase(repo, store).CreateImage(pngUpload(t))
if !errors.Is(err, errRepositoryFailure) || repo.createCalls != 0 {
t.Errorf("got %v with %d Create calls", err, repo.createCalls)
}
})
t.Run("A row failure removes the blob that was just stored", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
repo.fail.failNextWith("Create", errRepositoryFailure)
_, err := useCases.NewCreateImageUseCase(repo, store).CreateImage(pngUpload(t))
if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected the repository failure, got %v", err)
}
if len(store.blobs) != 0 || len(store.deleted) != 1 {
t.Errorf("expected the orphan blob to be deleted, blobs=%d deleted=%v", len(store.blobs), store.deleted)
}
})
}
func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
t.Run("Both disappear", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store))
if err := useCases.NewDeleteMediaUseCase(repo, store).Delete(created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if len(store.blobs) != 0 || len(repo.images) != 0 {
t.Errorf("expected nothing left, blobs=%d rows=%d", len(store.blobs), len(repo.images))
}
})
t.Run("An unknown id is ErrImageNotFound and touches no blob", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
err := useCases.NewDeleteMediaUseCase(repo, store).Delete(7)
if !errors.Is(err, domainImage.ErrImageNotFound) || len(store.deleted) != 0 {
t.Errorf("got %v, deleted %v", err, store.deleted)
}
})
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))
store.failDelete = errRepositoryFailure
err := useCases.NewDeleteMediaUseCase(repo, store).Delete(created.ID)
if !errors.Is(err, errRepositoryFailure) || len(repo.images) != 1 || len(repo.deleteCalls) != 0 {
t.Errorf("got %v, rows=%d deleteCalls=%v", err, len(repo.images), repo.deleteCalls)
}
})
}
func TestMergeMediaRemovesTheMergedBlobs(t *testing.T) {
repo, articles, store := newMockImageRepo(), newMockArticleRepo(), newMockImageStorage()
create := useCases.NewCreateImageUseCase(repo, store)
kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create)
result, err := useCases.NewMergeMediaUseCase(repo, articles, store).Merge(kept.ID, []uint32{copyOne.ID, copyTwo.ID})
if err != nil || result.Merged != 2 {
t.Fatalf("merge returned %+v, %v", result, err)
}
if len(store.blobs) != 1 || store.blobs[domainImage.StoredName(kept.Path)] == nil {
t.Errorf("expected only the kept blob to remain, got %d blobs", len(store.blobs))
}
}
func TestBackfillImageMetadataUseCase(t *testing.T) {
t.Run("Rows without metadata are filled from the stored blob and unreadable ones are skipped", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
store.blobs["one.png"] = pngBytes(t, 3, 2)
store.blobs["broken.png"] = []byte("nope")
repo.images[1] = domainImage.Image{ID: 1, Path: domainImage.PublicPath("one.png")}
repo.images[2] = domainImage.Image{ID: 2, Path: domainImage.PublicPath("broken.png")}
repo.images[3] = domainImage.Image{ID: 3, Path: domainImage.PublicPath("missing.png")}
repo.images[4] = domainImage.Image{ID: 4, Path: domainImage.PublicPath("notes.txt")}
repo.images[5] = domainImage.Image{ID: 5, Path: domainImage.PublicPath("done.png"), Filename: "done.png"}
if err := useCases.NewBackfillImageMetadataUseCase(repo, store).Run(); err != nil {
t.Fatalf("Run failed: %v", err)
}
got := repo.images[1]
if got.Filename != "one.png" || got.ContentType != "image/png" || got.Width != 3 || got.Height != 2 || got.SHA256 != sha256Hex(store.blobs["one.png"]) {
t.Errorf("unexpected backfilled row %+v", got)
}
for _, id := range []uint32{2, 3, 4} {
if repo.images[id].Filename != "" {
t.Errorf("row %d must be left as is, got %+v", id, repo.images[id])
}
}
})
t.Run("Repository failures stop the run", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
repo.fail.failNextWith("ListMissingMetadata", errRepositoryFailure)
if err := useCases.NewBackfillImageMetadataUseCase(repo, store).Run(); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the list failure, got %v", err)
}
repo, store = newMockImageRepo(), newMockImageStorage()
store.blobs["one.png"] = pngBytes(t, 1, 1)
repo.images[1] = domainImage.Image{ID: 1, Path: domainImage.PublicPath("one.png")}
repo.fail.failNextWith("UpdateMetadata", errRepositoryFailure)
if err := useCases.NewBackfillImageMetadataUseCase(repo, store).Run(); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the update failure, got %v", err)
}
})
}
func TestDriverRegistries(t *testing.T) {
t.Run("An unknown database driver is rejected", func(t *testing.T) {
if _, err := database.Connect(database.Config{Driver: "oracle", Source: "x"}); err == nil || !strings.Contains(err.Error(), "oracle") {
t.Errorf("expected an unsupported driver error, got %v", err)
}
})
t.Run("An unknown storage driver is rejected", func(t *testing.T) {
if _, err := storage.Open(storage.Config{Driver: "floppy"}); err == nil || !strings.Contains(err.Error(), "floppy") {
t.Errorf("expected an unsupported driver error, got %v", err)
}
})
t.Run("The sqlite driver connects, migrates, backs up and closes", func(t *testing.T) {
dir := t.TempDir()
connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: dir + "/nested/app.db"})
if err != nil {
t.Fatalf("Connect failed: %v", err)
}
if err := database.Migrate(connection.DB); err != nil {
t.Fatalf("Migrate failed: %v", err)
}
if err := connection.DB.Create(&models.Article{Title: "t"}).Error; err != nil {
t.Fatalf("expected the schema to exist: %v", err)
}
if _, err := connection.NewBackup(dir+"/backups", time.Now).Backup(); err != nil {
t.Errorf("Backup failed: %v", err)
}
if err := connection.Close(); err != nil {
t.Errorf("Close failed: %v", err)
}
})
t.Run("The storage registry returns a working local storage", func(t *testing.T) {
store, err := storage.Open(storage.Config{Driver: storage.DriverLocal, Location: t.TempDir()})
if err != nil {
t.Fatalf("Open failed: %v", err)
}
if _, err := store.Put("image/png", strings.NewReader("x")); err != nil {
t.Errorf("Put failed: %v", err)
}
})
}
+559
View File
@@ -0,0 +1,559 @@
package test
import (
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/persistence/database"
"bytes"
"context"
"database/sql"
"fmt"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
)
const mysqlTestPassword = "s3cr3t/Value@42:x"
func setMySQLEnv(t *testing.T) {
t.Helper()
setBaseSettingsEnv(t)
t.Setenv("DB_DRIVER", "mysql")
t.Setenv("DB_HOST", "db.local")
t.Setenv("DB_USER", "app")
t.Setenv("DB_PASSWORD", mysqlTestPassword)
t.Setenv("DB_NAME", "cms")
for _, optional := range []string{"DB_PORT", "DB_TLS", "DB_MAX_OPEN_CONNS", "DB_MAX_IDLE_CONNS", "DB_CONN_MAX_LIFETIME_SECONDS", "DATA_DIR"} {
t.Setenv(optional, "")
}
}
func lookupFrom(values map[string]string) func(string) (string, bool) {
return func(name string) (string, bool) {
value, found := values[name]
return value, found
}
}
func TestMySQLDriverSelection(t *testing.T) {
t.Run("Driver selection", func(t *testing.T) {
cases := []struct {
value string
set bool
driver string
}{
{"", false, "sqlite"},
{"sqlite", true, "sqlite"},
{"mysql", true, "mysql"},
{"oracle", true, "oracle"},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("DB_DRIVER %q", tc.value), func(t *testing.T) {
setMySQLEnv(t)
if tc.set {
t.Setenv("DB_DRIVER", tc.value)
} else {
t.Setenv("DB_DRIVER", "")
}
settings, err := config.NewSettingsFromEnv()
if err != nil {
t.Fatalf("settings: %v", err)
}
if settings.Database.Driver != tc.driver {
t.Errorf("expected driver %q, got %q", tc.driver, settings.Database.Driver)
}
})
}
})
t.Run("an unregistered driver fails with its name", func(t *testing.T) {
_, err := database.Connect(database.Config{Driver: "oracle", Source: "x"})
if err == nil || err.Error() != `unsupported database driver "oracle"` {
t.Errorf("unexpected error %v", err)
}
})
}
func TestMySQLEnvironmentValidation(t *testing.T) {
complete := map[string]string{"DB_HOST": "h", "DB_USER": "u", "DB_PASSWORD": "p", "DB_NAME": "n"}
t.Run("Missing MySQL variable", func(t *testing.T) {
for _, missing := range []string{"DB_HOST", "DB_USER", "DB_PASSWORD", "DB_NAME"} {
t.Run(missing, func(t *testing.T) {
values := map[string]string{}
for name, value := range complete {
if name != missing {
values[name] = value
}
}
err := config.MissingEnvVarError(config.DatabaseRequiredEnvVars("mysql"), lookupFrom(values))
expected := "Environment variable " + missing + " is not set"
if err == nil || err.Error() != expected {
t.Errorf("expected %q, got %v", expected, err)
}
})
}
})
t.Run("SQLite does not require MySQL variables", func(t *testing.T) {
for _, driver := range []string{"sqlite", "", "oracle"} {
if err := config.MissingEnvVarError(config.DatabaseRequiredEnvVars(driver), lookupFrom(nil)); err != nil {
t.Errorf("driver %q: expected success, got %v", driver, err)
}
}
})
t.Run("all MySQL variables set passes validation", func(t *testing.T) {
if err := config.MissingEnvVarError(config.DatabaseRequiredEnvVars("mysql"), lookupFrom(complete)); err != nil {
t.Errorf("expected success, got %v", err)
}
})
}
func TestMySQLSettingsFromEnv(t *testing.T) {
t.Run("defaults apply when the optional variables are empty", func(t *testing.T) {
setMySQLEnv(t)
settings, err := config.NewSettingsFromEnv()
if err != nil {
t.Fatalf("settings: %v", err)
}
database := settings.Database
if database.Port != "3306" || database.TLS != "false" || database.MaxOpenConns != 10 || database.MaxIdleConns != 5 || database.ConnMaxLifetime != 300*time.Second {
t.Errorf("unexpected defaults %+v", database)
}
if database.Host != "db.local" || database.User != "app" || database.Password != mysqlTestPassword || database.Name != "cms" {
t.Errorf("unexpected connection fields %+v", database)
}
})
t.Run("Invalid TLS option is rejected", func(t *testing.T) {
setMySQLEnv(t)
t.Setenv("DB_TLS", "maybe")
_, err := config.NewSettingsFromEnv()
if err == nil {
t.Fatal("expected an error")
}
for _, expected := range []string{"DB_TLS", "false", "true", "skip-verify", "preferred"} {
if !strings.Contains(err.Error(), expected) {
t.Errorf("error %q must mention %q", err, expected)
}
}
})
t.Run("every accepted TLS option is read", func(t *testing.T) {
for _, mode := range []string{"false", "true", "skip-verify", "preferred"} {
setMySQLEnv(t)
t.Setenv("DB_TLS", mode)
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.TLS != mode {
t.Errorf("mode %q: settings %+v, err %v", mode, settings.Database, err)
}
}
})
t.Run("a non numeric pool variable names the variable", func(t *testing.T) {
for _, name := range []string{"DB_MAX_OPEN_CONNS", "DB_MAX_IDLE_CONNS", "DB_CONN_MAX_LIFETIME_SECONDS"} {
setMySQLEnv(t)
t.Setenv(name, "many")
_, err := config.NewSettingsFromEnv()
if err == nil || !strings.Contains(err.Error(), name) {
t.Errorf("%s: expected an error naming it, got %v", name, err)
}
}
})
t.Run("a negative pool variable is rejected", func(t *testing.T) {
setMySQLEnv(t)
t.Setenv("DB_MAX_OPEN_CONNS", "-1")
if _, err := config.NewSettingsFromEnv(); err == nil {
t.Error("expected an error")
}
})
t.Run("zero is an accepted pool value", func(t *testing.T) {
setMySQLEnv(t)
t.Setenv("DB_MAX_OPEN_CONNS", "0")
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.MaxOpenConns != 0 {
t.Errorf("unexpected settings %+v, err %v", settings.Database, err)
}
})
t.Run("pool variables are read", func(t *testing.T) {
setMySQLEnv(t)
t.Setenv("DB_MAX_OPEN_CONNS", "4")
t.Setenv("DB_MAX_IDLE_CONNS", "2")
t.Setenv("DB_CONN_MAX_LIFETIME_SECONDS", "60")
t.Setenv("DB_PORT", "3307")
settings, err := config.NewSettingsFromEnv()
database := settings.Database
if err != nil || database.MaxOpenConns != 4 || database.MaxIdleConns != 2 || database.ConnMaxLifetime != 60*time.Second || database.Port != "3307" {
t.Errorf("unexpected settings %+v, err %v", database, err)
}
})
t.Run("SQLite ignores the MySQL variables", func(t *testing.T) {
setMySQLEnv(t)
t.Setenv("DB_DRIVER", "sqlite")
t.Setenv("DB_TLS", "maybe")
t.Setenv("DB_MAX_OPEN_CONNS", "many")
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.Driver != "sqlite" || settings.Database.Host != "" {
t.Errorf("unexpected settings %+v, err %v", settings.Database, err)
}
})
}
func TestMySQLDSN(t *testing.T) {
t.Run("DSN built from variables", func(t *testing.T) {
cases := []struct {
host, port, expectedPort, tls string
}{
{"db.local", "", "3306", "false"},
{"db.local", "3307", "3307", "true"},
{"mysql", "3306", "3306", "skip-verify"},
}
for _, tc := range cases {
t.Run(tc.host+":"+tc.port+" tls "+tc.tls, func(t *testing.T) {
port := tc.port
if port == "" {
port = config.DefaultDatabasePort
}
dsn := database.MySQLDSN(database.MySQLConfig{Host: tc.host, Port: port, User: "app", Password: "pw", Name: "cms", TLS: tc.tls})
parsed, err := mysqldriver.ParseDSN(dsn)
if err != nil {
t.Fatalf("parse: %v", err)
}
if parsed.Addr != tc.host+":"+tc.expectedPort || parsed.DBName != "cms" || parsed.User != "app" {
t.Errorf("unexpected target %q db %q user %q", parsed.Addr, parsed.DBName, parsed.User)
}
for _, fragment := range []string{"parseTime=true", "loc=UTC", "charset=utf8mb4", "clientFoundRows=true", "tls=" + tc.tls} {
if !strings.Contains(dsn, fragment) {
t.Errorf("dsn must contain %q", fragment)
}
}
if parsed.Timeout <= 0 || parsed.Timeout > 10*time.Second {
t.Errorf("connect timeout must be short, got %v", parsed.Timeout)
}
})
}
})
t.Run("a password with reserved characters survives the DSN", func(t *testing.T) {
dsn := database.MySQLDSN(database.MySQLConfig{Host: "db.local", Port: "3306", User: "app", Password: mysqlTestPassword, Name: "cms", TLS: "false"})
parsed, err := mysqldriver.ParseDSN(dsn)
if err != nil || parsed.Passwd != mysqlTestPassword || parsed.DBName != "cms" || parsed.Addr != "db.local:3306" {
t.Errorf("unexpected parse %+v err %v", parsed, err)
}
})
t.Run("the connection uses a binary collation so Bob and bob stay distinct", func(t *testing.T) {
parsed, _ := mysqldriver.ParseDSN(database.MySQLDSN(database.MySQLConfig{Host: "h", Port: "1", User: "u", Name: "n", TLS: "false"}))
if parsed.Collation != "utf8mb4_bin" {
t.Errorf("unexpected collation %q", parsed.Collation)
}
})
}
func TestMySQLDataDir(t *testing.T) {
t.Run("DataDir resolution", func(t *testing.T) {
cases := []struct{ dbFile, dataDir, result string }{
{"./data/renewcms.db", "", "./data"},
{"/var/lib/cms/cms.db", "", "/var/lib/cms"},
{"./data/renewcms.db", "/srv/x", "/srv/x"},
}
for _, tc := range cases {
t.Run(tc.dbFile+" "+tc.dataDir, func(t *testing.T) {
settings := config.Settings{DBFile: tc.dbFile, DataDirPath: tc.dataDir}
if got := settings.DataDir(); filepath.Clean(got) != filepath.Clean(tc.result) {
t.Errorf("expected %q, got %q", tc.result, got)
}
})
}
})
t.Run("Data directory with MySQL", func(t *testing.T) {
setMySQLEnv(t)
t.Setenv("DATA_DIR", "/data")
t.Setenv("DB_FILE", "./elsewhere/ignored.db")
settings, err := config.NewSettingsFromEnv()
if err != nil || filepath.Clean(settings.DataDir()) != filepath.Clean("/data") {
t.Errorf("unexpected data dir %q err %v", settings.DataDir(), err)
}
})
}
func TestMySQLConnectionPool(t *testing.T) {
t.Run("Connection pool", func(t *testing.T) {
pool := database.PoolSettings{MaxOpenConns: 4, MaxIdleConns: 2, ConnMaxLifetime: 60 * time.Second}
connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: filepath.ToSlash(filepath.Join(t.TempDir(), "pool.db")), Pool: &pool})
if err != nil {
t.Fatalf("connect: %v", err)
}
defer connection.Close()
sqlDb, _ := connection.DB.DB()
held := make([]*sql.Conn, 0, 4)
for range 4 {
conn, err := sqlDb.Conn(context.Background())
if err != nil {
t.Fatalf("conn: %v", err)
}
held = append(held, conn)
}
blocked, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, fifthErr := sqlDb.Conn(blocked)
for _, conn := range held {
_ = conn.Close()
}
stats := sqlDb.Stats()
if stats.MaxOpenConnections != 4 {
t.Errorf("expected 4 open connections at most, got %d", stats.MaxOpenConnections)
}
if fifthErr == nil {
t.Error("a fifth concurrent connection must wait for the pool")
}
if stats.Idle != 2 {
t.Errorf("expected 2 idle connections, got %d", stats.Idle)
}
})
t.Run("the connection lifetime closes old connections", func(t *testing.T) {
pool := database.PoolSettings{MaxOpenConns: 1, MaxIdleConns: 1, ConnMaxLifetime: 20 * time.Millisecond}
connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: filepath.ToSlash(filepath.Join(t.TempDir(), "life.db")), Pool: &pool})
if err != nil {
t.Fatalf("connect: %v", err)
}
defer connection.Close()
sqlDb, _ := connection.DB.DB()
_ = sqlDb.Ping()
time.Sleep(100 * time.Millisecond)
_ = sqlDb.Ping()
if sqlDb.Stats().MaxLifetimeClosed == 0 {
t.Error("an expired connection must be closed")
}
})
t.Run("settings without a pool leave the defaults untouched", func(t *testing.T) {
connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: filepath.ToSlash(filepath.Join(t.TempDir(), "plain.db"))})
if err != nil {
t.Fatalf("connect: %v", err)
}
defer connection.Close()
sqlDb, _ := connection.DB.DB()
if sqlDb.Stats().MaxOpenConnections != 0 {
t.Errorf("expected an unlimited pool, got %d", sqlDb.Stats().MaxOpenConnections)
}
})
}
func closedLocalPort(t *testing.T) string {
t.Helper()
return freeTCPPort(t)
}
func mysqlDSNWithTimeout(config database.MySQLConfig, timeout time.Duration) string {
parsed, _ := mysqldriver.ParseDSN(database.MySQLDSN(config))
parsed.Timeout = timeout
return parsed.FormatDSN()
}
func TestMySQLFailFast(t *testing.T) {
t.Run("nothing listens on DB_PORT", func(t *testing.T) {
port := closedLocalPort(t)
dsn := mysqlDSNWithTimeout(database.MySQLConfig{Host: "127.0.0.1", Port: port, User: "app", Password: mysqlTestPassword, Name: "cms", TLS: "false"}, time.Second)
started := time.Now()
_, err := database.Connect(database.Config{Driver: database.DriverMySQL, Source: dsn})
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), "127.0.0.1:"+port) {
t.Errorf("the error must name the host and port: %v", err)
}
if strings.Contains(err.Error(), mysqlTestPassword) {
t.Errorf("the error must not contain the password: %v", err)
}
if elapsed := time.Since(started); elapsed > 4*time.Second {
t.Errorf("startup must fail fast, took %v", elapsed)
}
})
t.Run("an empty password leaves the error message untouched", func(t *testing.T) {
port := closedLocalPort(t)
dsn := mysqlDSNWithTimeout(database.MySQLConfig{Host: "127.0.0.1", Port: port, User: "app", Name: "cms", TLS: "false"}, time.Second)
_, err := database.Connect(database.Config{Driver: database.DriverMySQL, Source: dsn})
if err == nil {
t.Fatal("expected an error")
}
if strings.Contains(err.Error(), "***") {
t.Errorf("an empty password must not be redacted into the message: %v", err)
}
if !strings.Contains(err.Error(), "127.0.0.1:"+port) {
t.Errorf("the error must name the host and port: %v", err)
}
})
t.Run("an unusable DSN is rejected without echoing it", func(t *testing.T) {
_, err := database.Connect(database.Config{Driver: database.DriverMySQL, Source: "user:" + mysqlTestPassword + "@broken"})
if err == nil || strings.Contains(err.Error(), mysqlTestPassword) {
t.Errorf("unexpected error %v", err)
}
})
}
func startupEnvironment(t *testing.T, extra ...string) []string {
t.Helper()
dataDir := t.TempDir()
return append(emptyEnvironment(),
append([]string{
"ENVIRONMENT=development", "PORT=" + freeTCPPort(t), "PUBLIC_URL=http://localhost", "JWT_SECRET=test-secret-for-startup",
"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",
}, extra...)...)
}
func runServerWithEnvironment(t *testing.T, binary string, environment []string) (string, int) {
t.Helper()
command := exec.Command(binary)
command.Dir = t.TempDir()
command.Env = environment
var output bytes.Buffer
command.Stdout, command.Stderr = &output, &output
err := command.Run()
if exitErr, ok := err.(*exec.ExitError); ok {
return output.String(), exitErr.ExitCode()
}
if err != nil {
t.Fatal(err)
}
return output.String(), 0
}
func TestMySQLStartupFailures(t *testing.T) {
binary := buildServerBinary(t, "")
t.Run("an unsupported driver stops the server", func(t *testing.T) {
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t, "DB_DRIVER=oracle"))
if code == 0 || !strings.Contains(output, `unsupported database driver "oracle"`) {
t.Errorf("code %d output %q", code, output)
}
})
t.Run("Missing MySQL variable stops the server", func(t *testing.T) {
complete := map[string]string{"DB_HOST": "127.0.0.1", "DB_USER": "app", "DB_PASSWORD": mysqlTestPassword, "DB_NAME": "cms"}
for missing := range complete {
t.Run(missing, func(t *testing.T) {
extra := []string{"DB_DRIVER=mysql"}
for name, value := range complete {
if name != missing {
extra = append(extra, name+"="+value)
}
}
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t, extra...))
if code == 0 || !strings.Contains(output, "Environment variable "+missing+" is not set") {
t.Errorf("code %d output %q", code, output)
}
if strings.Contains(output, mysqlTestPassword) {
t.Errorf("the output must not contain the password: %q", output)
}
})
}
})
t.Run("an invalid DB_TLS stops the server naming the variable", func(t *testing.T) {
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
"DB_DRIVER=mysql", "DB_HOST=127.0.0.1", "DB_USER=app", "DB_PASSWORD="+mysqlTestPassword, "DB_NAME=cms", "DB_TLS=maybe"))
if code == 0 || !strings.Contains(output, "DB_TLS") || strings.Contains(output, mysqlTestPassword) {
t.Errorf("code %d output %q", code, output)
}
})
t.Run("Fail fast when the server cannot be used", func(t *testing.T) {
port := closedLocalPort(t)
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
"DB_DRIVER=mysql", "DB_HOST=127.0.0.1", "DB_PORT="+port, "DB_USER=app", "DB_PASSWORD="+mysqlTestPassword, "DB_NAME=cms"))
if code == 0 || !strings.Contains(output, "Unable to open the database") {
t.Errorf("code %d output %q", code, output)
}
if !strings.Contains(output, "127.0.0.1:"+port) {
t.Errorf("the message must name the host and port: %q", output)
}
if strings.Contains(output, mysqlTestPassword) {
t.Errorf("the message must not contain the password: %q", output)
}
})
t.Run("the SQLite default starts without any MySQL variable", func(t *testing.T) {
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t, "PORT=99999"))
bindFailed := strings.Contains(output, "invalid port")
if code == 0 || !bindFailed || strings.Contains(output, "Environment variable DB_") {
t.Errorf("startup must get as far as binding the port, code %d output %q", code, output)
}
})
}
func TestMySQLSettingsAreNeverPrinted(t *testing.T) {
t.Run("Secrets are never exposed", func(t *testing.T) {
setMySQLEnv(t)
settings, err := config.NewSettingsFromEnv()
if err != nil {
t.Fatal(err)
}
dsn := mysqlDSNWithTimeout(database.MySQLConfig{Host: "127.0.0.1", Port: closedLocalPort(t), User: settings.Database.User, Password: settings.Database.Password, Name: settings.Database.Name, TLS: settings.Database.TLS}, time.Second)
_, err = database.Connect(database.Config{Driver: database.DriverMySQL, Source: dsn})
if err == nil || strings.Contains(err.Error(), settings.Database.Password) {
t.Errorf("unexpected error %v", err)
}
})
}
+311
View File
@@ -0,0 +1,311 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
domainInvite "RenewCMS/internal/domain/invite"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
)
func migratedMySQL(t *testing.T) (*mysqlTestServer, database.MySQLConfig, *gorm.DB) {
t.Helper()
server := connectMySQLTestServer(t)
config := server.createDatabase(t)
connection := server.connect(t, config)
if err := database.Migrate(connection.DB); err != nil {
t.Fatalf("migrate: %v", err)
}
return server, config, connection.DB
}
func columnCount(t *testing.T, db *gorm.DB, query string, arguments ...any) int {
t.Helper()
var count int
if err := db.Raw(query, arguments...).Scan(&count).Error; err != nil {
t.Fatalf("query %q: %v", query, err)
}
return count
}
func TestMySQLMigration(t *testing.T) {
t.Run("Migration on MySQL", func(t *testing.T) {
_, _, db := migratedMySQL(t)
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
if !db.Migrator().HasTable(table) {
t.Errorf("table %s must exist", table)
}
}
uniqueColumns := map[string][]string{
"users": {"username", "email", "password_reset_code", "verification_code"},
"api_keys": {"hashed_key"},
}
for table, columns := range uniqueColumns {
for _, column := range columns {
count := columnCount(t, db, "SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? AND non_unique = 0 AND seq_in_index = 1", table, column)
if count == 0 {
t.Errorf("a unique index on %s.%s must exist", table, column)
}
}
}
binaryTables := columnCount(t, db, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_collation = 'utf8mb4_bin'")
if binaryTables != 6 {
t.Errorf("every table must use the utf8mb4_bin collation, got %d of 6", binaryTables)
}
})
t.Run("migrating twice changes nothing", func(t *testing.T) {
_, _, db := migratedMySQL(t)
if err := database.Migrate(db); err != nil {
t.Errorf("the second migration must succeed: %v", err)
}
})
t.Run("the first user lacking a role becomes owner and the others admin", func(t *testing.T) {
_, _, db := migratedMySQL(t)
for _, name := range []string{"first", "second", "third"} {
insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', 1, 0)", name, name+"@x.example")
if insert.Error != nil {
t.Fatalf("insert %s: %v", name, insert.Error)
}
}
if err := database.Migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
roles := map[string]string{}
var users []models.User
db.Find(&users)
for _, user := range users {
roles[user.Username] = user.Role
}
if roles["first"] != "owner" || roles["second"] != "admin" || roles["third"] != "admin" {
t.Errorf("unexpected roles %v", roles)
}
})
}
func TestMySQLTimes(t *testing.T) {
t.Run("times are stored and read back in UTC", func(t *testing.T) {
_, _, db := migratedMySQL(t)
repos := newContractRepositories(db)
zone := time.FixedZone("plus2", 2*3600)
expiration := time.Date(2030, 5, 6, 7, 8, 9, 0, zone)
user := contractUser("zoned", "zoned@x.example")
user.VerificationExpiration = expiration
created := mustCreateUser(t, repos, user)
read, err := repos.users.Get(created.ID)
if err != nil || !read.VerificationExpiration.Equal(expiration) || read.VerificationExpiration.Location() != time.UTC {
t.Errorf("expected %v in UTC, got %v (%v) err %v", expiration, read.VerificationExpiration, read.VerificationExpiration.Location(), err)
}
})
t.Run("sessions allow the zero date the driver sends for a zero time", func(t *testing.T) {
_, _, db := migratedMySQL(t)
var mode string
if err := db.Raw("SELECT @@SESSION.sql_mode").Scan(&mode).Error; err != nil {
t.Fatalf("read sql_mode: %v", err)
}
if strings.Contains(mode, "NO_ZERO_DATE") || !strings.Contains(mode, "STRICT_TRANS_TABLES") {
t.Errorf("unexpected session sql_mode %q", mode)
}
})
}
func populateForBackup(t *testing.T, repos contractRepositories) {
t.Helper()
trickyName := "o'brien \"quoted\" back\\slash\nline é😀"
mustCreateUser(t, repos, contractUser(trickyName, "tricky@x.example"))
second := contractUser("plain", "plain@x.example")
second.PasswordResetCode = "reset-code"
mustCreateUser(t, repos, second)
if _, err := repos.articles.Create(domainArticle.Article{Title: "Title", Body: "line1\nline2 'q' \"d\" \\ \x00 end", IsOnline: true}); err != nil {
t.Fatalf("create article: %v", err)
}
mustCreateImage(t, repos, "pic.png", strings.Repeat("c", 64))
if _, err := repos.invites.Create(domainInvite.Invite{Email: "i@x.example", HashedCode: "h", ExpiresAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC), InvitedBy: 1}); err != nil {
t.Fatalf("create invite: %v", err)
}
}
func steppingClock(start time.Time) func() time.Time {
current := start
return func() time.Time {
current = current.Add(time.Second)
return current
}
}
func TestMySQLBackup(t *testing.T) {
t.Run("Backup before an update", func(t *testing.T) {
_, _, db := migratedMySQL(t)
populateForBackup(t, newContractRepositories(db))
directory := filepath.Join(t.TempDir(), "backups")
backup := database.NewMySQLBackup(db, directory, steppingClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
var paths []string
for range 5 {
path, err := backup.Backup()
if err != nil {
t.Fatalf("backup: %v", err)
}
paths = append(paths, path)
}
entries, _ := os.ReadDir(directory)
if len(entries) != 3 {
t.Fatalf("only the 3 newest backups are kept, got %d", len(entries))
}
for _, path := range paths[2:] {
if filepath.Ext(path) != ".sql" || !strings.HasPrefix(filepath.Base(path), "renewcms-") {
t.Errorf("unexpected backup name %q", path)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("the newest backup %q must remain: %v", path, err)
}
}
})
t.Run("a failure returns an error and leaves no file", func(t *testing.T) {
server := connectMySQLTestServer(t)
connection := server.connect(t, server.createDatabase(t))
directory := filepath.Join(t.TempDir(), "backups")
backup := database.NewMySQLBackup(connection.DB, directory, time.Now)
if err := connection.Close(); err != nil {
t.Fatalf("close: %v", err)
}
path, err := backup.Backup()
if err == nil || path != "" {
t.Errorf("expected a failure, got %q %v", path, err)
}
if entries, _ := os.ReadDir(directory); len(entries) != 0 {
t.Errorf("no file may remain, got %d entries", len(entries))
}
})
t.Run("MySQL backup content", func(t *testing.T) {
server, _, db := migratedMySQL(t)
repos := newContractRepositories(db)
populateForBackup(t, repos)
directory := filepath.Join(t.TempDir(), "backups")
backup := database.NewMySQLBackup(db, directory, time.Now)
path, err := backup.Backup()
if err != nil {
t.Fatalf("backup: %v", err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
dump := string(content)
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
if !strings.Contains(dump, "CREATE TABLE `"+table+"`") {
t.Errorf("the dump must contain the schema of %s", table)
}
}
for _, table := range []string{"articles", "images", "users", "invites"} {
if !strings.Contains(dump, "INSERT INTO `"+table+"`") {
t.Errorf("the dump must contain rows of %s", table)
}
}
if password := server.config.Password; password != "" && strings.Contains(dump, password) {
t.Errorf("the dump must not contain the DB_PASSWORD value")
}
restoredConfig := server.createDatabase(t)
replayDump(t, restoredConfig, dump)
restored := server.connect(t, restoredConfig)
restoredPath, err := database.NewMySQLBackup(restored.DB, filepath.Join(t.TempDir(), "again"), time.Now).Backup()
if err != nil {
t.Fatalf("backup of the restored database: %v", err)
}
restoredContent, _ := os.ReadFile(restoredPath)
if line := firstDifferingLine(withoutRedundantCharset(dump), withoutRedundantCharset(string(restoredContent))); line != "" {
t.Errorf("replaying the dump must restore the same schema and rows, first difference: %s", line)
}
restoredUsers := newContractRepositories(restored.DB).users.GetAll()
if len(restoredUsers) != 2 || restoredUsers[0].Username != "o'brien \"quoted\" back\\slash\nline é😀" || restoredUsers[1].PasswordResetCode != "reset-code" {
t.Errorf("unexpected restored users %+v", restoredUsers)
}
restoredArticles, _ := newContractRepositories(restored.DB).articles.Get(1)
if restoredArticles.Body != "line1\nline2 'q' \"d\" \\ \x00 end" {
t.Errorf("unexpected restored article body %q", restoredArticles.Body)
}
})
}
func replayDump(t *testing.T, config database.MySQLConfig, dump string) {
t.Helper()
parsed, err := mysqldriver.ParseDSN(database.MySQLDSN(config))
if err != nil {
t.Fatal(err)
}
parsed.MultiStatements = true
replay, err := sql.Open("mysql", parsed.FormatDSN())
if err != nil {
t.Fatal(err)
}
defer replay.Close()
if _, err := replay.Exec(dump); err != nil {
t.Fatalf("replay: %v", err)
}
}
func TestMySQLUsersAreCaseSensitiveThroughTheDomain(t *testing.T) {
t.Run("Bob and bob can both register", func(t *testing.T) {
_, _, db := migratedMySQL(t)
repos := newContractRepositories(db)
mustCreateUser(t, repos, contractUser("Bob", "b1@x.example"))
other, err := repos.users.Create(contractUser("bob", "b2@x.example"))
_, missing := repos.users.GetByUsername("BOB")
if err != nil || other.Username != "bob" || missing == nil {
t.Errorf("created %+v err %v, uppercase lookup err %v", other, err, missing)
}
if _, err := repos.users.GetByEmail("B1@X.EXAMPLE"); err == nil {
t.Error("emails are compared exactly like SQLite does")
}
})
}
func firstDifferingLine(expected string, actual string) string {
expectedLines, actualLines := strings.Split(expected, "\n"), strings.Split(actual, "\n")
for index := 0; index < max(len(expectedLines), len(actualLines)); index++ {
var want, got string
if index < len(expectedLines) {
want = expectedLines[index]
}
if index < len(actualLines) {
got = actualLines[index]
}
if want != got {
return fmt.Sprintf("line %d: want %q got %q", index+1, want, got)
}
}
return ""
}
func withoutRedundantCharset(dump string) string {
return strings.ReplaceAll(dump, " CHARACTER SET utf8mb4", "")
}
+337
View File
@@ -0,0 +1,337 @@
package test
import (
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/persistence/database"
"fmt"
"net/url"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
const postgresTestPassword = "p@ss w'rd/\"x:42%"
func setPostgresEnv(t *testing.T) {
t.Helper()
setBaseSettingsEnv(t)
t.Setenv("DB_DRIVER", "postgres")
t.Setenv("DB_HOST", "db.local")
t.Setenv("DB_USER", "app")
t.Setenv("DB_PASSWORD", postgresTestPassword)
t.Setenv("DB_NAME", "cms")
for _, optional := range []string{"DB_PORT", "DB_TLS", "DB_SSLMODE", "DB_MAX_OPEN_CONNS", "DB_MAX_IDLE_CONNS", "DB_CONN_MAX_LIFETIME_SECONDS", "DATA_DIR"} {
t.Setenv(optional, "")
}
}
func TestPostgresDriverSelection(t *testing.T) {
t.Run("Driver selection", func(t *testing.T) {
cases := []struct {
value string
driver string
}{
{"postgres", "postgres"},
{"postgresql", "postgres"},
{"mysql", "mysql"},
{"oracle", "oracle"},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("DB_DRIVER %q", tc.value), func(t *testing.T) {
setPostgresEnv(t)
t.Setenv("DB_DRIVER", tc.value)
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.Driver != tc.driver {
t.Errorf("expected driver %q, got %+v err %v", tc.driver, settings.Database, err)
}
})
}
})
}
func TestPostgresEnvironmentValidation(t *testing.T) {
complete := map[string]string{"DB_HOST": "h", "DB_USER": "u", "DB_PASSWORD": "p", "DB_NAME": "n"}
t.Run("Missing PostgreSQL variable", func(t *testing.T) {
for _, driver := range []string{"postgres", "postgresql"} {
for _, missing := range []string{"DB_HOST", "DB_USER", "DB_PASSWORD", "DB_NAME"} {
t.Run(driver+" "+missing, func(t *testing.T) {
values := map[string]string{}
for name, value := range complete {
if name != missing {
values[name] = value
}
}
err := config.MissingEnvVarError(config.DatabaseRequiredEnvVars(driver), lookupFrom(values))
expected := "Environment variable " + missing + " is not set"
if err == nil || err.Error() != expected {
t.Errorf("expected %q, got %v", expected, err)
}
})
}
}
})
t.Run("all PostgreSQL variables set passes validation", func(t *testing.T) {
if err := config.MissingEnvVarError(config.DatabaseRequiredEnvVars("postgres"), lookupFrom(complete)); err != nil {
t.Errorf("expected success, got %v", err)
}
})
t.Run("SQLite and MySQL do not require PostgreSQL-only variables", func(t *testing.T) {
for _, driver := range []string{"sqlite", "mysql"} {
setMySQLEnv(t)
t.Setenv("DB_DRIVER", driver)
t.Setenv("DB_SSLMODE", "maybe")
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.SSLMode != "" {
t.Errorf("driver %q: DB_SSLMODE must be ignored, got %+v err %v", driver, settings.Database, err)
}
}
})
}
func TestPostgresSettingsFromEnv(t *testing.T) {
t.Run("defaults apply when the optional variables are empty", func(t *testing.T) {
setPostgresEnv(t)
settings, err := config.NewSettingsFromEnv()
if err != nil {
t.Fatalf("settings: %v", err)
}
database := settings.Database
if database.Port != "5432" || database.SSLMode != "disable" || database.MaxOpenConns != 10 || database.MaxIdleConns != 5 || database.ConnMaxLifetime != 300*time.Second {
t.Errorf("unexpected defaults %+v", database)
}
if database.Host != "db.local" || database.User != "app" || database.Password != postgresTestPassword || database.Name != "cms" {
t.Errorf("unexpected connection fields %+v", database)
}
})
t.Run("MySQL keeps its default port", func(t *testing.T) {
setMySQLEnv(t)
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.Port != "3306" {
t.Errorf("unexpected settings %+v err %v", settings.Database, err)
}
})
t.Run("Invalid SSL mode is rejected", func(t *testing.T) {
setPostgresEnv(t)
t.Setenv("DB_SSLMODE", "maybe")
_, err := config.NewSettingsFromEnv()
if err == nil {
t.Fatal("expected an error")
}
for _, expected := range []string{"DB_SSLMODE", "disable", "require", "verify-ca", "verify-full"} {
if !strings.Contains(err.Error(), expected) {
t.Errorf("error %q must mention %q", err, expected)
}
}
})
t.Run("every accepted SSL mode is read", func(t *testing.T) {
for _, mode := range []string{"disable", "require", "verify-ca", "verify-full"} {
setPostgresEnv(t)
t.Setenv("DB_SSLMODE", mode)
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Database.SSLMode != mode {
t.Errorf("mode %q: settings %+v, err %v", mode, settings.Database, err)
}
}
})
t.Run("Connection pool variables are read", func(t *testing.T) {
setPostgresEnv(t)
t.Setenv("DB_MAX_OPEN_CONNS", "4")
t.Setenv("DB_MAX_IDLE_CONNS", "2")
t.Setenv("DB_CONN_MAX_LIFETIME_SECONDS", "60")
settings, err := config.NewSettingsFromEnv()
database := settings.Database
if err != nil || database.MaxOpenConns != 4 || database.MaxIdleConns != 2 || database.ConnMaxLifetime != 60*time.Second {
t.Errorf("unexpected settings %+v, err %v", database, err)
}
})
t.Run("Data directory with PostgreSQL", func(t *testing.T) {
setPostgresEnv(t)
t.Setenv("DATA_DIR", "/data")
t.Setenv("DB_FILE", "./elsewhere/ignored.db")
settings, err := config.NewSettingsFromEnv()
if err != nil || strings.ReplaceAll(settings.DataDir(), "\\", "/") != "/data" {
t.Errorf("unexpected data dir %q err %v", settings.DataDir(), err)
}
})
}
func TestPostgresDSN(t *testing.T) {
t.Run("Connection string built from variables", func(t *testing.T) {
cases := []struct {
host, port, expectedPort, sslmode, expectedSslmode string
}{
{"db.local", "", "5432", "", "disable"},
{"db.local", "5433", "5433", "require", "require"},
{"pg", "5432", "5432", "verify-full", "verify-full"},
}
for _, tc := range cases {
t.Run(tc.host+":"+tc.port+" sslmode "+tc.sslmode, func(t *testing.T) {
port, sslmode := tc.port, tc.sslmode
if port == "" {
port = config.DefaultPostgresPort
}
if sslmode == "" {
sslmode = config.DefaultDatabaseSSLMode
}
dsn := database.PostgresDSN(database.PostgresConfig{Host: tc.host, Port: port, User: "app", Password: postgresTestPassword, Name: "cms", SSLMode: sslmode})
parsed, err := pgconn.ParseConfig(dsn)
if err != nil {
t.Fatalf("parse: %v", err)
}
if fmt.Sprintf("%s:%d", parsed.Host, parsed.Port) != tc.host+":"+tc.expectedPort || parsed.Database != "cms" || parsed.User != "app" {
t.Errorf("unexpected target %s:%d db %q user %q", parsed.Host, parsed.Port, parsed.Database, parsed.User)
}
if parsed.Password != postgresTestPassword {
t.Errorf("the password must survive the DSN, got %q", parsed.Password)
}
if parsed.RuntimeParams["timezone"] != "UTC" {
t.Errorf("the session time zone must be UTC, got %v", parsed.RuntimeParams)
}
if tc.expectedSslmode == "disable" && parsed.TLSConfig != nil {
t.Errorf("sslmode disable must not configure TLS")
}
if tc.expectedSslmode != "disable" && parsed.TLSConfig == nil {
t.Errorf("sslmode %s must configure TLS", tc.expectedSslmode)
}
if query := mustParseQuery(t, dsn); query.Get("sslmode") != tc.expectedSslmode {
t.Errorf("expected sslmode %q, got %q", tc.expectedSslmode, query.Get("sslmode"))
}
if parsed.ConnectTimeout <= 0 || parsed.ConnectTimeout > 10*time.Second {
t.Errorf("connect timeout must be short, got %v", parsed.ConnectTimeout)
}
})
}
})
}
func mustParseQuery(t *testing.T, dsn string) url.Values {
t.Helper()
parsed, err := url.Parse(dsn)
if err != nil {
t.Fatal(err)
}
return parsed.Query()
}
func TestPostgresFailFast(t *testing.T) {
t.Run("nothing listens on DB_PORT", func(t *testing.T) {
port := closedLocalPort(t)
dsn := database.PostgresDSN(database.PostgresConfig{Host: "127.0.0.1", Port: port, User: "app", Password: postgresTestPassword, Name: "cms", SSLMode: "disable"})
started := time.Now()
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: dsn})
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), "127.0.0.1:"+port) {
t.Errorf("the error must name the host and port: %v", err)
}
if strings.Contains(err.Error(), postgresTestPassword) {
t.Errorf("the error must not contain the password: %v", err)
}
if elapsed := time.Since(started); elapsed > 8*time.Second {
t.Errorf("startup must fail fast, took %v", elapsed)
}
})
t.Run("an unusable DSN is rejected without echoing it", func(t *testing.T) {
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: "postgres://user:" + postgresTestPassword + "@[broken"})
if err == nil || strings.Contains(err.Error(), postgresTestPassword) {
t.Errorf("unexpected error %v", err)
}
})
}
func TestPostgresStartupFailures(t *testing.T) {
binary := buildServerBinary(t, "")
t.Run("Missing PostgreSQL variable stops the server", func(t *testing.T) {
complete := map[string]string{"DB_HOST": "127.0.0.1", "DB_USER": "app", "DB_PASSWORD": postgresTestPassword, "DB_NAME": "cms"}
for missing := range complete {
t.Run(missing, func(t *testing.T) {
extra := []string{"DB_DRIVER=postgresql"}
for name, value := range complete {
if name != missing {
extra = append(extra, name+"="+value)
}
}
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t, extra...))
if code == 0 || !strings.Contains(output, "Environment variable "+missing+" is not set") || strings.Contains(output, postgresTestPassword) {
t.Errorf("code %d output %q", code, output)
}
})
}
})
t.Run("an invalid DB_SSLMODE stops the server naming the variable", func(t *testing.T) {
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
"DB_DRIVER=postgres", "DB_HOST=127.0.0.1", "DB_USER=app", "DB_PASSWORD="+postgresTestPassword, "DB_NAME=cms", "DB_SSLMODE=maybe"))
if code == 0 || !strings.Contains(output, "DB_SSLMODE") || strings.Contains(output, postgresTestPassword) {
t.Errorf("code %d output %q", code, output)
}
})
t.Run("Fail fast when the server cannot be used", func(t *testing.T) {
port := closedLocalPort(t)
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
"DB_DRIVER=postgres", "DB_HOST=127.0.0.1", "DB_PORT="+port, "DB_USER=app", "DB_PASSWORD="+postgresTestPassword, "DB_NAME=cms"))
if code == 0 || !strings.Contains(output, "Unable to open the database") {
t.Errorf("code %d output %q", code, output)
}
if !strings.Contains(output, "127.0.0.1:"+port) {
t.Errorf("the message must name the host and port: %q", output)
}
if strings.Contains(output, postgresTestPassword) {
t.Errorf("the message must not contain the password: %q", output)
}
})
t.Run("an omitted DB_PORT targets 5432", func(t *testing.T) {
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
"DB_DRIVER=postgres", "DB_HOST=127.0.0.1", "DB_USER=app", "DB_PASSWORD="+postgresTestPassword, "DB_NAME=cms"))
if code == 0 || strings.Contains(output, postgresTestPassword) {
t.Errorf("code %d output %q", code, output)
}
if strings.Contains(output, "Unable to open the database") && !strings.Contains(output, "127.0.0.1:5432") {
t.Errorf("the default port must be 5432: %q", output)
}
})
}
+444
View File
@@ -0,0 +1,444 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
domainInvite "RenewCMS/internal/domain/invite"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
"database/sql"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const postgresTestDSNVariable = "RENEWCMS_TEST_POSTGRES_DSN"
var postgresTestDatabaseCounter atomic.Int64
type postgresTestServer struct {
admin *sql.DB
config database.PostgresConfig
}
func connectPostgresTestServer(t *testing.T) *postgresTestServer {
t.Helper()
dsn := os.Getenv(postgresTestDSNVariable)
if dsn == "" {
t.Skip(postgresTestDSNVariable + " is not set; run task test:postgres to test against a real server")
}
parsed, err := pgconn.ParseConfig(dsn)
if err != nil {
t.Fatalf("parse %s: %v", postgresTestDSNVariable, err)
}
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open admin: %v", err)
}
t.Cleanup(func() { _ = admin.Close() })
return &postgresTestServer{admin: admin, config: database.PostgresConfig{
Host: parsed.Host, Port: fmt.Sprint(parsed.Port), User: parsed.User, Password: parsed.Password, SSLMode: "disable",
}}
}
func (s *postgresTestServer) createDatabase(t *testing.T) database.PostgresConfig {
t.Helper()
name := fmt.Sprintf("renewcms_test_%d_%d", time.Now().UnixNano()%1_000_000_000, postgresTestDatabaseCounter.Add(1))
if _, err := s.admin.Exec("CREATE DATABASE " + name); err != nil {
t.Fatalf("create database: %v", err)
}
t.Cleanup(func() { _, _ = s.admin.Exec("DROP DATABASE IF EXISTS " + name + " WITH (FORCE)") })
config := s.config
config.Name = name
return config
}
func (s *postgresTestServer) connect(t *testing.T, config database.PostgresConfig) *database.Connection {
t.Helper()
connection, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(config)})
if err != nil {
t.Fatalf("connect postgres: %v", err)
}
connection.DB.Logger = logger.Discard
t.Cleanup(func() { _ = connection.Close() })
return connection
}
func openContractPostgres(t *testing.T) *gorm.DB {
t.Helper()
server := connectPostgresTestServer(t)
connection := server.connect(t, server.createDatabase(t))
if err := database.Migrate(connection.DB); err != nil {
t.Fatalf("migrate postgres: %v", err)
}
return connection.DB
}
func migratedPostgres(t *testing.T) (*postgresTestServer, database.PostgresConfig, *gorm.DB) {
t.Helper()
server := connectPostgresTestServer(t)
config := server.createDatabase(t)
connection := server.connect(t, config)
if err := database.Migrate(connection.DB); err != nil {
t.Fatalf("migrate: %v", err)
}
return server, config, connection.DB
}
func TestPostgresRepositoryContract(t *testing.T) {
runRepositoryContract(t, contractBackend{name: "postgres", open: openContractPostgres})
}
func TestPostgresConnectionPool(t *testing.T) {
t.Run("Connection pool", func(t *testing.T) {
server := connectPostgresTestServer(t)
pool := database.PoolSettings{MaxOpenConns: 4, MaxIdleConns: 2, ConnMaxLifetime: 60 * time.Second}
connection, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(server.createDatabase(t)), Pool: &pool})
if err != nil {
t.Fatalf("connect: %v", err)
}
defer connection.Close()
sqlDb, _ := connection.DB.DB()
stats := sqlDb.Stats()
if stats.MaxOpenConnections != 4 {
t.Errorf("expected 4 open connections at most, got %d", stats.MaxOpenConnections)
}
})
}
func TestPostgresServerFaults(t *testing.T) {
t.Run("the password is wrong", func(t *testing.T) {
server := connectPostgresTestServer(t)
config := server.createDatabase(t)
config.Password = "definitely/wrong p@ss"
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(config)})
expectedAddress := net.JoinHostPort(config.Host, config.Port)
if err == nil || !strings.Contains(err.Error(), expectedAddress) || strings.Contains(err.Error(), config.Password) {
t.Errorf("unexpected error %v", err)
}
})
t.Run("the database does not exist", func(t *testing.T) {
server := connectPostgresTestServer(t)
config := server.config
config.Name = "renewcms_missing_database"
_, err := database.Connect(database.Config{Driver: database.DriverPostgres, Source: database.PostgresDSN(config)})
if err == nil || !strings.Contains(err.Error(), net.JoinHostPort(config.Host, config.Port)) || strings.Contains(err.Error(), config.Password) {
t.Errorf("unexpected error %v", err)
}
})
}
func TestPostgresMigration(t *testing.T) {
t.Run("Migration on PostgreSQL", func(t *testing.T) {
_, _, db := migratedPostgres(t)
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
count := columnCount(t, db, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?", table)
if count != 1 {
t.Errorf("table %s must exist in schema public", table)
}
}
uniqueColumns := map[string][]string{
"users": {"username", "email", "password_reset_code", "verification_code"},
"api_keys": {"hashed_key"},
}
for table, columns := range uniqueColumns {
for _, column := range columns {
count := columnCount(t, db, `SELECT COUNT(*) FROM pg_index i
JOIN pg_class c ON c.oid = i.indrelid
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = i.indkey[0]
WHERE c.relname = ? AND a.attname = ? AND i.indisunique AND array_length(i.indkey, 1) = 1`, table, column)
if count == 0 {
t.Errorf("a unique index on %s.%s must exist", table, column)
}
}
}
})
t.Run("migrating twice changes nothing", func(t *testing.T) {
_, _, db := migratedPostgres(t)
before := columnCount(t, db, "SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public'")
if err := database.Migrate(db); err != nil {
t.Errorf("the second migration must succeed: %v", err)
}
if after := columnCount(t, db, "SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public'"); after != before {
t.Errorf("indexes changed from %d to %d", before, after)
}
})
t.Run("the first user lacking a role becomes owner and the others admin", func(t *testing.T) {
_, _, db := migratedPostgres(t)
for _, name := range []string{"first", "second", "third"} {
insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', true, 0)", name, name+"@x.example")
if insert.Error != nil {
t.Fatalf("insert %s: %v", name, insert.Error)
}
}
if err := database.Migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
roles := map[string]string{}
var users []models.User
db.Find(&users)
for _, user := range users {
roles[user.Username] = user.Role
}
if roles["first"] != "owner" || roles["second"] != "admin" || roles["third"] != "admin" {
t.Errorf("unexpected roles %v", roles)
}
})
}
func TestPostgresTimes(t *testing.T) {
t.Run("times are stored and read back in UTC", func(t *testing.T) {
_, _, db := migratedPostgres(t)
repos := newContractRepositories(db)
zone := time.FixedZone("plus2", 2*3600)
expiration := time.Date(2030, 5, 6, 7, 8, 9, 0, zone)
user := contractUser("zoned", "zoned@x.example")
user.VerificationExpiration = expiration
created := mustCreateUser(t, repos, user)
read, err := repos.users.Get(created.ID)
if err != nil || !read.VerificationExpiration.Equal(expiration) || read.VerificationExpiration.Location() != time.UTC {
t.Errorf("expected %v in UTC, got %v (%v) err %v", expiration, read.VerificationExpiration, read.VerificationExpiration.Location(), err)
}
})
}
func TestPostgresTimesInLists(t *testing.T) {
_, _, db := migratedPostgres(t)
repos := newContractRepositories(db)
zone := time.FixedZone("plus2", 2*3600)
for _, name := range []string{"first", "second"} {
user := contractUser(name, name+"@x.example")
user.VerificationExpiration = time.Date(2030, 5, 6, 7, 8, 9, 0, zone)
mustCreateUser(t, repos, user)
}
users := repos.users.GetAll()
if len(users) != 2 {
t.Fatalf("expected 2 users, got %d", len(users))
}
for _, user := range users {
if user.VerificationExpiration.Location() != time.UTC {
t.Errorf("%s: expected UTC, got %v", user.Username, user.VerificationExpiration.Location())
}
}
}
func TestPostgresBackupOfIdentityAndBinaryColumns(t *testing.T) {
server, _, db := migratedPostgres(t)
if err := db.Exec(`CREATE TABLE extras (id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, payload bytea, label text DEFAULT 'x' NOT NULL, amount numeric(10,2))`).Error; err != nil {
t.Fatalf("create extras: %v", err)
}
if err := db.Exec(`INSERT INTO extras (payload, amount) VALUES (decode('00ff27', 'hex'), 12.50), (NULL, NULL)`).Error; err != nil {
t.Fatalf("insert extras: %v", err)
}
path, err := database.NewPostgresBackup(db, filepath.Join(t.TempDir(), "backups"), time.Now).Backup()
if err != nil {
t.Fatalf("backup: %v", err)
}
content, _ := os.ReadFile(path)
dump := string(content)
for _, fragment := range []string{
`"id" integer GENERATED BY DEFAULT AS IDENTITY NOT NULL`,
`"label" text DEFAULT 'x'::text NOT NULL`,
`'\x00ff27'`,
`12.50`,
`NULL`,
} {
if !strings.Contains(dump, fragment) {
t.Errorf("the dump must contain %s", fragment)
}
}
restoredConfig := server.createDatabase(t)
replayPostgresDump(t, restoredConfig, dump)
restored := server.connect(t, restoredConfig)
var payload []byte
if err := restored.DB.Raw(`SELECT payload FROM extras WHERE id = 1`).Row().Scan(&payload); err != nil || len(payload) != 3 || payload[1] != 0xff {
t.Errorf("unexpected restored payload %v err %v", payload, err)
}
var next int
if err := restored.DB.Raw(`INSERT INTO extras (label) VALUES ('y') RETURNING id`).Row().Scan(&next); err != nil || next != 3 {
t.Errorf("the identity sequence must continue after the restored rows, got %d err %v", next, err)
}
}
func populateForPostgresBackup(t *testing.T, repos contractRepositories) {
t.Helper()
trickyName := "o'brien \"quoted\" back\\slash\nline é😀"
mustCreateUser(t, repos, contractUser(trickyName, "tricky@x.example"))
second := contractUser("plain", "plain@x.example")
second.PasswordResetCode = "reset-code"
mustCreateUser(t, repos, second)
if _, err := repos.articles.Create(domainArticle.Article{Title: "Title", Body: "line1\nline2 'q' \"d\" \\ end", IsOnline: true}); err != nil {
t.Fatalf("create article: %v", err)
}
mustCreateImage(t, repos, "pic.png", strings.Repeat("c", 64))
if _, err := repos.invites.Create(domainInvite.Invite{Email: "i@x.example", HashedCode: "h", ExpiresAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC), InvitedBy: 1}); err != nil {
t.Fatalf("create invite: %v", err)
}
}
func TestPostgresBackup(t *testing.T) {
t.Run("Backup before an update", func(t *testing.T) {
_, _, db := migratedPostgres(t)
populateForPostgresBackup(t, newContractRepositories(db))
directory := filepath.Join(t.TempDir(), "backups")
backup := database.NewPostgresBackup(db, directory, steppingClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
var paths []string
for range 5 {
path, err := backup.Backup()
if err != nil {
t.Fatalf("backup: %v", err)
}
paths = append(paths, path)
}
entries, _ := os.ReadDir(directory)
if len(entries) != 3 {
t.Fatalf("only the 3 newest backups are kept, got %d", len(entries))
}
for _, path := range paths[2:] {
if filepath.Ext(path) != ".sql" || !strings.HasPrefix(filepath.Base(path), "renewcms-") {
t.Errorf("unexpected backup name %q", path)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("the newest backup %q must remain: %v", path, err)
}
}
})
t.Run("a failure returns an error and leaves no file", func(t *testing.T) {
server := connectPostgresTestServer(t)
connection := server.connect(t, server.createDatabase(t))
directory := filepath.Join(t.TempDir(), "backups")
backup := database.NewPostgresBackup(connection.DB, directory, time.Now)
if err := connection.Close(); err != nil {
t.Fatalf("close: %v", err)
}
path, err := backup.Backup()
if err == nil || path != "" {
t.Errorf("expected a failure, got %q %v", path, err)
}
if entries, _ := os.ReadDir(directory); len(entries) != 0 {
t.Errorf("no file may remain, got %d entries", len(entries))
}
})
t.Run("PostgreSQL backup content", func(t *testing.T) {
server, _, db := migratedPostgres(t)
populateForPostgresBackup(t, newContractRepositories(db))
directory := filepath.Join(t.TempDir(), "backups")
path, err := database.NewPostgresBackup(db, directory, time.Now).Backup()
if err != nil {
t.Fatalf("backup: %v", err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
dump := string(content)
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
if !strings.Contains(dump, `CREATE TABLE "`+table+`"`) {
t.Errorf("the dump must contain the schema of %s", table)
}
}
if !strings.Contains(dump, "CREATE UNIQUE INDEX") {
t.Errorf("the dump must contain the indexes")
}
for _, table := range []string{"articles", "images", "users", "invites"} {
if !strings.Contains(dump, `INSERT INTO "`+table+`"`) {
t.Errorf("the dump must contain rows of %s", table)
}
}
if password := server.config.Password; password != "" && strings.Contains(dump, password) {
t.Errorf("the dump must not contain the DB_PASSWORD value")
}
restoredConfig := server.createDatabase(t)
replayPostgresDump(t, restoredConfig, dump)
restored := server.connect(t, restoredConfig)
restoredPath, err := database.NewPostgresBackup(restored.DB, filepath.Join(t.TempDir(), "again"), time.Now).Backup()
if err != nil {
t.Fatalf("backup of the restored database: %v", err)
}
restoredContent, _ := os.ReadFile(restoredPath)
if line := firstDifferingLine(dump, string(restoredContent)); line != "" {
t.Errorf("replaying the dump must restore the same schema and rows, first difference: %s", line)
}
restoredRepos := newContractRepositories(restored.DB)
restoredUsers := restoredRepos.users.GetAll()
if len(restoredUsers) != 2 || restoredUsers[0].Username != "o'brien \"quoted\" back\\slash\nline é😀" || restoredUsers[1].PasswordResetCode != "reset-code" {
t.Errorf("unexpected restored users %+v", restoredUsers)
}
restoredArticle, _ := restoredRepos.articles.Get(1)
if restoredArticle.Body != "line1\nline2 'q' \"d\" \\ end" {
t.Errorf("unexpected restored article body %q", restoredArticle.Body)
}
next := mustCreateUser(t, restoredRepos, contractUser("third", "third@x.example"))
if next.ID != 3 {
t.Errorf("sequences must be reset so the next insert gets an unused id, got %d", next.ID)
}
})
}
func replayPostgresDump(t *testing.T, config database.PostgresConfig, dump string) {
t.Helper()
replay, err := sql.Open("pgx", database.PostgresDSN(config))
if err != nil {
t.Fatal(err)
}
defer replay.Close()
if _, err := replay.Exec(dump); err != nil {
t.Fatalf("replay: %v", err)
}
}
func TestPostgresUsersAreCaseSensitiveThroughTheDomain(t *testing.T) {
t.Run("Bob and bob can both register", func(t *testing.T) {
_, _, db := migratedPostgres(t)
repos := newContractRepositories(db)
mustCreateUser(t, repos, contractUser("Bob", "b1@x.example"))
other, err := repos.users.Create(contractUser("bob", "b2@x.example"))
_, missing := repos.users.GetByUsername("BOB")
if err != nil || other.Username != "bob" || missing == nil {
t.Errorf("created %+v err %v, uppercase lookup err %v", other, err, missing)
}
if _, err := repos.users.GetByEmail("B1@X.EXAMPLE"); err == nil {
t.Error("emails are compared exactly like SQLite does")
}
})
}
+368
View File
@@ -0,0 +1,368 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite"
domainPurge "RenewCMS/internal/domain/purge"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/persistence/database"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"database/sql"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const mysqlTestDSNVariable = "RENEWCMS_TEST_MYSQL_DSN"
var mysqlTestDatabaseCounter atomic.Int64
type contractRepositories struct {
db *gorm.DB
users *persistence.UserRepository
invites *persistence.InviteRepository
images *persistence.ImageRepository
articles *persistence.ArticleRepository
purge *persistence.PurgeSettingsRepository
}
type contractBackend struct {
name string
open func(t *testing.T) *gorm.DB
}
func openContractSQLite(t *testing.T) *gorm.DB {
t.Helper()
path := filepath.ToSlash(filepath.Join(t.TempDir(), "contract.db"))
connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: path})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
connection.DB.Logger = logger.Discard
if err := database.Migrate(connection.DB); err != nil {
t.Fatalf("migrate sqlite: %v", err)
}
t.Cleanup(func() { _ = connection.Close() })
return connection.DB
}
type mysqlTestServer struct {
admin *sql.DB
config database.MySQLConfig
}
func connectMySQLTestServer(t *testing.T) *mysqlTestServer {
t.Helper()
dsn := os.Getenv(mysqlTestDSNVariable)
if dsn == "" {
t.Skip(mysqlTestDSNVariable + " is not set; run task test:mysql to test against a real server")
}
parsed, err := mysqldriver.ParseDSN(dsn)
if err != nil {
t.Fatalf("parse %s: %v", mysqlTestDSNVariable, err)
}
host, port, err := net.SplitHostPort(parsed.Addr)
if err != nil {
t.Fatalf("split %q: %v", parsed.Addr, err)
}
admin, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatalf("open admin: %v", err)
}
t.Cleanup(func() { _ = admin.Close() })
return &mysqlTestServer{admin: admin, config: database.MySQLConfig{Host: host, Port: port, User: parsed.User, Password: parsed.Passwd, TLS: "false"}}
}
func (s *mysqlTestServer) createDatabase(t *testing.T) database.MySQLConfig {
t.Helper()
name := fmt.Sprintf("renewcms_test_%d_%d", time.Now().UnixNano()%1_000_000_000, mysqlTestDatabaseCounter.Add(1))
if _, err := s.admin.Exec("CREATE DATABASE " + name); err != nil {
t.Fatalf("create database: %v", err)
}
t.Cleanup(func() { _, _ = s.admin.Exec("DROP DATABASE IF EXISTS " + name) })
config := s.config
config.Name = name
return config
}
func (s *mysqlTestServer) connect(t *testing.T, config database.MySQLConfig) *database.Connection {
t.Helper()
connection, err := database.Connect(database.Config{Driver: database.DriverMySQL, Source: database.MySQLDSN(config)})
if err != nil {
t.Fatalf("connect mysql: %v", err)
}
connection.DB.Logger = logger.Discard
t.Cleanup(func() { _ = connection.Close() })
return connection
}
func openContractMySQL(t *testing.T) *gorm.DB {
t.Helper()
server := connectMySQLTestServer(t)
connection := server.connect(t, server.createDatabase(t))
if err := database.Migrate(connection.DB); err != nil {
t.Fatalf("migrate mysql: %v", err)
}
return connection.DB
}
func newContractRepositories(db *gorm.DB) contractRepositories {
return contractRepositories{
db: db,
users: persistence.NewUserRepository(db),
invites: persistence.NewInviteRepository(db),
images: persistence.NewImageRepository(db),
articles: persistence.NewArticleRepository(db),
purge: persistence.NewPurgeSettingsRepository(db),
}
}
func contractUser(username string, email string) domainUser.User {
return domainUser.User{Username: username, Password: "hashed", Email: email, Role: domainUser.RoleAdmin, IsVerified: true}
}
func contractInvite(t *testing.T, repos contractRepositories, email string) domainInvite.Invite {
t.Helper()
created, err := repos.invites.Create(domainInvite.Invite{
Email: email,
HashedCode: "hashed-code",
ExpiresAt: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC),
InvitedBy: 1,
CreatedAt: time.Date(2029, 12, 30, 3, 4, 5, 0, time.UTC),
})
if err != nil {
t.Fatalf("create invite: %v", err)
}
return created
}
func mustCreateUser(t *testing.T, repos contractRepositories, user domainUser.User) domainUser.User {
t.Helper()
created, err := repos.users.Create(user)
if err != nil {
t.Fatalf("create user %q: %v", user.Username, err)
}
return created
}
func mustCreateImage(t *testing.T, repos contractRepositories, filename string, hash string) domainImage.Image {
t.Helper()
created, err := repos.images.Create(domainImage.Image{Path: "/static/uploadedImages/" + filename, Filename: filename, SHA256: hash, ContentType: "image/png", Size: 3})
if err != nil {
t.Fatalf("create image %q: %v", filename, err)
}
return created
}
func runRepositoryContract(t *testing.T, backend contractBackend) {
cases := []struct {
name string
run func(t *testing.T, repos contractRepositories)
}{
{`create user and read it back with its id and timestamps`, func(t *testing.T, repos contractRepositories) {
created := mustCreateUser(t, repos, contractUser("Alice", "alice@x.example"))
read, err := repos.users.Get(created.ID)
if err != nil || created.ID == 0 || read.ID != created.ID || read.Username != "Alice" || read.Email != "alice@x.example" {
t.Fatalf("created %+v read %+v err %v", created, read, err)
}
if created.CreatedAt.IsZero() || read.CreatedAt.IsZero() || read.UpdatedAt.IsZero() {
t.Errorf("timestamps must be filled: created %v read %v / %v", created.CreatedAt, read.CreatedAt, read.UpdatedAt)
}
if difference := read.CreatedAt.Sub(created.CreatedAt).Abs(); difference > time.Second {
t.Errorf("the stored timestamp %v must match the returned one %v", read.CreatedAt, created.CreatedAt)
}
if time.Since(read.CreatedAt).Abs() > time.Minute {
t.Errorf("the timestamp %v must be the current instant", read.CreatedAt)
}
}},
{`username "Bob" and "bob" are distinct users`, func(t *testing.T, repos contractRepositories) {
upper := mustCreateUser(t, repos, contractUser("Bob", "upper@x.example"))
lower := mustCreateUser(t, repos, contractUser("bob", "lower@x.example"))
foundUpper, upperErr := repos.users.GetByUsername("Bob")
foundLower, lowerErr := repos.users.GetByUsername("bob")
if upperErr != nil || lowerErr != nil || foundUpper.ID != upper.ID || foundLower.ID != lower.ID || upper.ID == lower.ID {
t.Errorf("Bob %+v (%v) and bob %+v (%v) must be distinct", foundUpper, upperErr, foundLower, lowerErr)
}
}},
{`duplicate email returns an error and stores nothing`, func(t *testing.T, repos contractRepositories) {
mustCreateUser(t, repos, contractUser("first", "same@x.example"))
_, err := repos.users.Create(contractUser("second", "same@x.example"))
if err == nil {
t.Error("expected an error for the duplicate email")
}
if all := repos.users.GetAll(); len(all) != 1 || all[0].Username != "first" {
t.Errorf("nothing may be stored for the failed create, got %+v", all)
}
}},
{`unknown id returns domain ErrUserNotFound`, func(t *testing.T, repos contractRepositories) {
_, err := repos.users.Get(4242)
if !errors.Is(err, domainUser.ErrUserNotFound) {
t.Errorf("expected ErrUserNotFound, got %v", err)
}
}},
{`accept invite twice: the second returns ErrInviteNotFound`, func(t *testing.T, repos contractRepositories) {
pending := contractInvite(t, repos, "new@x.example")
accepted := time.Now()
first, firstErr := repos.invites.AcceptWithUser(pending.ID, accepted, contractUser("lea", "new@x.example"))
_, secondErr := repos.invites.AcceptWithUser(pending.ID, accepted, contractUser("lea2", "other@x.example"))
if firstErr != nil || first.ID == 0 {
t.Fatalf("first accept: %+v %v", first, firstErr)
}
if !errors.Is(secondErr, domainInvite.ErrInviteNotFound) {
t.Errorf("expected ErrInviteNotFound, got %v", secondErr)
}
if len(repos.users.GetAll()) != 1 {
t.Errorf("only the first accept may create a user")
}
}},
{`accept invite with a taken username leaves the invite unaccepted`, func(t *testing.T, repos contractRepositories) {
mustCreateUser(t, repos, contractUser("marta", "marta@x.example"))
pending := contractInvite(t, repos, "new@x.example")
_, err := repos.invites.AcceptWithUser(pending.ID, time.Now(), contractUser("marta", "new@x.example"))
if err == nil {
t.Fatal("expected the taken username to be refused")
}
invites := repos.invites.GetAll()
if len(invites) != 1 || invites[0].AcceptedAt != nil {
t.Errorf("the invite must stay pending, got %+v", invites)
}
}},
{`renew invite with unchanged values still succeeds`, func(t *testing.T, repos contractRepositories) {
pending := contractInvite(t, repos, "new@x.example")
renewed, err := repos.invites.Renew(pending.ID, pending.HashedCode, pending.ExpiresAt, pending.CreatedAt)
if err != nil || renewed.ID != pending.ID || !renewed.ExpiresAt.Equal(pending.ExpiresAt) {
t.Errorf("renew with identical values: %+v %v", renewed, err)
}
}},
{`duplicate image hashes are grouped and counted`, func(t *testing.T, repos contractRepositories) {
first := strings.Repeat("a", 64)
second := strings.Repeat("b", 64)
mustCreateImage(t, repos, "a1.png", first)
mustCreateImage(t, repos, "a2.png", first)
mustCreateImage(t, repos, "b1.png", second)
mustCreateImage(t, repos, "none1.png", "")
mustCreateImage(t, repos, "none2.png", "")
counts, countsErr := repos.images.Counts()
duplicates, listErr := repos.images.ListDuplicates()
if countsErr != nil || counts.All != 5 || counts.DuplicateGroups != 1 {
t.Errorf("counts %+v err %v", counts, countsErr)
}
if listErr != nil || len(duplicates) != 2 || duplicates[0].Filename != "a1.png" || duplicates[1].Filename != "a2.png" {
t.Errorf("duplicates %+v err %v", duplicates, listErr)
}
}},
{`media search treats % and _ literally`, func(t *testing.T, repos contractRepositories) {
for _, filename := range []string{"100%.png", "a_b.png", "axb.png", "50x.png", "Plain.png"} {
mustCreateImage(t, repos, filename, "")
}
percent, percentErr := repos.images.List(domainImage.SearchQuery{Search: "%"})
underscore, underscoreErr := repos.images.List(domainImage.SearchQuery{Search: "_"})
caseless, caselessErr := repos.images.List(domainImage.SearchQuery{Search: "PLAIN"})
if percentErr != nil || len(percent) != 1 || percent[0].Filename != "100%.png" {
t.Errorf("percent search %+v err %v", percent, percentErr)
}
if underscoreErr != nil || len(underscore) != 1 || underscore[0].Filename != "a_b.png" {
t.Errorf("underscore search %+v err %v", underscore, underscoreErr)
}
if caselessErr != nil || len(caseless) != 1 || caseless[0].Filename != "Plain.png" {
t.Errorf("case insensitive search %+v err %v", caseless, caselessErr)
}
}},
{`article body of 1 MB round-trips`, func(t *testing.T, repos contractRepositories) {
body := "é€😀" + strings.Repeat("0123456789abcdef", 1<<16)
created, err := repos.articles.Create(domainArticle.Article{Title: "Big", Body: body, IsOnline: true})
if err != nil {
t.Fatalf("create: %v", err)
}
read, err := repos.articles.Get(created.ID)
if err != nil || read.Body != body || !read.IsOnline {
t.Errorf("the body must round-trip, got %d bytes err %v", len(read.Body), err)
}
found, err := repos.articles.FindByBodyContaining("€😀0123")
if err != nil || len(found) != 1 {
t.Errorf("body search found %d err %v", len(found), err)
}
}},
{`zero reset expiration round-trips as a zero time`, func(t *testing.T, repos contractRepositories) {
created := mustCreateUser(t, repos, contractUser("zero", "zero@x.example"))
fresh, freshErr := repos.users.Get(created.ID)
withCode, codeErr := repos.users.UpdatePasswordResetCode(created.ID, "code", time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC))
reset, resetErr := repos.users.ResetPassword(created.ID, "new-hash")
reread, rereadErr := repos.users.Get(created.ID)
if freshErr != nil || !fresh.PasswordResetExpiration.IsZero() || !fresh.VerificationExpiration.IsZero() {
t.Errorf("fresh user expirations %v / %v err %v", fresh.PasswordResetExpiration, fresh.VerificationExpiration, freshErr)
}
if codeErr != nil || withCode.PasswordResetExpiration.IsZero() {
t.Errorf("the expiration must be stored: %+v %v", withCode, codeErr)
}
if resetErr != nil || rereadErr != nil || !reset.PasswordResetExpiration.IsZero() || !reread.PasswordResetExpiration.IsZero() || reread.TokenVersion != 1 {
t.Errorf("the zero expiration must round-trip: %+v %v %v", reread, resetErr, rereadErr)
}
}},
{`purge settings save, then SaveLastRun with identical values succeeds`, func(t *testing.T, repos contractRepositories) {
enabledAt := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
lastRun := time.Date(2030, 2, 3, 4, 5, 6, 0, time.UTC)
saved := domainPurge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 7, EnabledAt: &enabledAt}
if err := repos.purge.Save(saved); err != nil {
t.Fatalf("save: %v", err)
}
firstErr := repos.purge.SaveLastRun(lastRun, 3, 4)
secondErr := repos.purge.SaveLastRun(lastRun, 3, 4)
got, err := repos.purge.Get()
if firstErr != nil || secondErr != nil || err != nil {
t.Fatalf("SaveLastRun errors %v / %v, get %v", firstErr, secondErr, err)
}
if !got.AutoPurgeEnabled || got.AutoPurgeIntervalDays != 7 || got.LastPurgeMerged != 3 || got.LastPurgeArticlesUpdated != 4 || got.LastPurgeAt == nil || !got.LastPurgeAt.Equal(lastRun) {
t.Errorf("the settings must survive an identical SaveLastRun, got %+v", got)
}
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
tc.run(t, newContractRepositories(backend.open(t)))
})
}
}
func TestSQLiteRepositoryContract(t *testing.T) {
runRepositoryContract(t, contractBackend{name: "sqlite", open: openContractSQLite})
}
func TestMySQLRepositoryContract(t *testing.T) {
runRepositoryContract(t, contractBackend{name: "mysql", open: openContractMySQL})
}
+9 -25
View File
@@ -8,8 +8,7 @@ import (
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
@@ -19,8 +18,6 @@ import (
"path/filepath"
"testing"
imageDomain "RenewCMS/internal/domain/image"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
@@ -143,11 +140,11 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage()),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage()),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage()),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
}
}
@@ -200,34 +197,21 @@ func TestRuntimeSettingsInjectionCors(t *testing.T) {
}
func TestRuntimeSettingsInjectionUploadDirectory(t *testing.T) {
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.Image{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
injectedUploadDir := t.TempDir()
repo := persistence.NewImageRepository(db, injectedUploadDir)
store := storage.NewLocalStorage(injectedUploadDir)
processUploadDir := t.TempDir()
t.Setenv("UPLOAD_DIR", processUploadDir)
result, err := repo.Create(imageDomain.ImageInput{
Content: bytes.NewReader([]byte("fake-image-data")),
Filename: "upload.png",
ContentType: "image/png",
})
storedName, err := store.Put("image/png", bytes.NewReader([]byte("fake-image-data")))
if err != nil {
t.Fatalf("Create failed: %v", err)
t.Fatalf("Put failed: %v", err)
}
if got := result.Path; len(got) < len("/static/uploadedImages/") || got[:len("/static/uploadedImages/")] != "/static/uploadedImages/" {
t.Errorf("expected Path to start with /static/uploadedImages/, got %q", got)
}
fileName := filepath.Base(result.Path)
if _, err := os.Stat(filepath.Join(injectedUploadDir, fileName)); err != nil {
if _, err := os.Stat(filepath.Join(injectedUploadDir, storedName)); err != nil {
t.Errorf("expected the file to exist under the injected upload dir: %v", err)
}
if _, err := os.Stat(filepath.Join(processUploadDir, fileName)); !os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(processUploadDir, storedName)); !os.IsNotExist(err) {
t.Errorf("expected the file to not exist under the process UPLOAD_DIR")
}
}
+441
View File
@@ -0,0 +1,441 @@
package test
import (
"RenewCMS/api/handlers/staticimages"
validator "RenewCMS/api/pkg/validator"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
const s3ObjectBody = "0123456789"
func newS3StorageRouter(store domainImage.Storage) *chi.Mux {
router := chi.NewRouter()
router.Mount("/static/uploadedImages", staticimages.NewHandler(useCases.NewOpenMediaUseCase(store)))
return router
}
func serveS3Storage(router http.Handler, method string, target string, headers map[string]string) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, target, nil)
for name, value := range headers {
request.Header.Set(name, value)
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
return recorder
}
func clearStorageEnv(t *testing.T) {
t.Helper()
setBaseSettingsEnv(t)
for _, name := range []string{"STORAGE_DRIVER", "S3_BUCKET", "S3_REGION", "S3_ENDPOINT", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "S3_KEY_PREFIX", "S3_PATH_STYLE", "S3_TLS"} {
t.Setenv(name, "")
}
}
func TestS3StorageAcceptance(t *testing.T) {
validator.InitValidator()
t.Run("Startup configuration validation", func(t *testing.T) {
examples := []struct {
name string
env map[string]string
wantError string
check func(t *testing.T, settings config.Settings)
}{
{
name: "(unset) with no S3 variable",
env: map[string]string{},
check: func(t *testing.T, settings config.Settings) {
if settings.Storage.Driver != "local" {
t.Errorf("driver = %q, want local", settings.Storage.Driver)
}
},
},
{
name: "local with S3_BUCKET set",
env: map[string]string{"STORAGE_DRIVER": "local", "S3_BUCKET": "media"},
check: func(t *testing.T, settings config.Settings) {
if settings.Storage.Driver != "local" || settings.Storage.S3.Bucket != "" {
t.Errorf("S3 variables must be ignored, got %+v", settings.Storage)
}
},
},
{
name: "s3 with S3_BUCKET and S3_REGION set",
env: map[string]string{"STORAGE_DRIVER": "s3", "S3_BUCKET": "media", "S3_REGION": "eu-west-3"},
check: func(t *testing.T, settings config.Settings) {
s3 := settings.Storage.S3
if s3.Bucket != "media" || s3.Region != "eu-west-3" || s3.AccessKeyID != "" || s3.SecretAccessKey != "" {
t.Errorf("unexpected settings %+v", s3)
}
},
},
{
name: "s3 with S3_BUCKET set and S3_REGION unset",
env: map[string]string{"STORAGE_DRIVER": "s3", "S3_BUCKET": "media"},
check: func(t *testing.T, settings config.Settings) {
if settings.Storage.S3.Region != "us-east-1" {
t.Errorf("region = %q, want us-east-1", settings.Storage.S3.Region)
}
},
},
{
name: "s3 with S3_REGION set and S3_BUCKET unset",
env: map[string]string{"STORAGE_DRIVER": "s3", "S3_REGION": "eu-west-3"},
wantError: "Environment variable S3_BUCKET is not set",
},
{
name: "s3 with key id set and secret unset",
env: map[string]string{"STORAGE_DRIVER": "s3", "S3_BUCKET": "media", "S3_ACCESS_KEY_ID": "id"},
wantError: "S3_SECRET_ACCESS_KEY",
},
{
name: "s3 with secret set and key id unset",
env: map[string]string{"STORAGE_DRIVER": "s3", "S3_BUCKET": "media", "S3_SECRET_ACCESS_KEY": "secret"},
wantError: "S3_ACCESS_KEY_ID",
},
{
name: "s3 with UPLOAD_DIR set",
env: map[string]string{"STORAGE_DRIVER": "s3", "S3_BUCKET": "media", "UPLOAD_DIR": "./data/uploads"},
check: func(t *testing.T, settings config.Settings) {
if settings.Storage.Driver != "s3" {
t.Errorf("driver = %q, want s3", settings.Storage.Driver)
}
},
},
{
name: "gcs with no variable",
env: map[string]string{"STORAGE_DRIVER": "gcs"},
wantError: `unsupported storage driver "gcs"`,
},
}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
clearStorageEnv(t)
for name, value := range example.env {
t.Setenv(name, value)
}
settings, err := config.NewSettingsFromEnv()
if example.wantError != "" {
if err == nil || !strings.Contains(err.Error(), example.wantError) {
t.Fatalf("expected error containing %q, got %v", example.wantError, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error %v", err)
}
example.check(t, settings)
})
}
})
t.Run("The registry opens the S3 storage after a bucket check", func(t *testing.T) {
fake := newFakeS3Server(t)
store, err := storage.Open(storage.Config{Driver: "s3", S3: fake.config("")})
if err != nil || store == nil {
t.Fatalf("expected a storage and no error, got %v, %v", store, err)
}
})
t.Run("Startup fails fast with a clear secret-free error", func(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusForbidden, http.StatusInternalServerError} {
fake := newFakeS3Server(t)
fake.setBucketStatus(status)
_, err := storage.Open(storage.Config{Driver: "s3", S3: fake.config("")})
if err == nil {
t.Fatalf("status %d: expected an error", status)
}
if !strings.Contains(err.Error(), s3TestBucket) {
t.Errorf("status %d: error must mention the bucket, got %q", status, err.Error())
}
if strings.Contains(err.Error(), s3TestSecret) {
t.Errorf("status %d: error leaks the secret: %q", status, err.Error())
}
}
})
t.Run("Put stores the object under the prefix with its content type", func(t *testing.T) {
fake := newFakeS3Server(t)
store := openFakeS3Storage(t, fake, "uploads/")
name, err := store.Put("image/png", bytes.NewReader([]byte{1, 2, 3}))
if err != nil || !strings.HasSuffix(name, ".png") {
t.Fatalf("Put returned %q, %v", name, err)
}
object, ok := fake.object("uploads/" + name)
if !ok || !bytes.Equal(object.data, []byte{1, 2, 3}) || object.contentType != "image/png" {
t.Errorf("unexpected stored object %+v (found %v)", object, ok)
}
})
t.Run("Put failure is returned as an error", func(t *testing.T) {
fake := newFakeS3Server(t)
store := openFakeS3Storage(t, fake, "")
fake.failMethod(http.MethodPut, http.StatusInternalServerError)
_, err := store.Put("image/png", bytes.NewReader([]byte{1}))
if err == nil || errors.Is(err, domainImage.ErrBlobNotFound) {
t.Errorf("expected a plain error, got %v", err)
}
})
t.Run("Open returns content, size and modification time", func(t *testing.T) {
fake := newFakeS3Server(t)
fake.put("uploads/a1b2.png", []byte(s3ObjectBody), "image/png")
store := openFakeS3Storage(t, fake, "uploads/")
blob, err := store.Open("a1b2.png")
if err != nil {
t.Fatalf("Open failed: %v", err)
}
if blob.Size != 10 || !blob.ModTime.Equal(s3TestModTime) {
t.Errorf("unexpected size %d or modtime %v", blob.Size, blob.ModTime)
}
if got := readAllBlob(t, blob); string(got) != s3ObjectBody {
t.Errorf("unexpected content %q", got)
}
})
t.Run("Open returns a Content that implements io.ReadSeeker", func(t *testing.T) {
fake := newFakeS3Server(t)
fake.put("a1b2.png", []byte(s3ObjectBody), "image/png")
blob, err := openFakeS3Storage(t, fake, "").Open("a1b2.png")
if err != nil {
t.Fatalf("Open failed: %v", err)
}
defer blob.Content.Close()
if _, ok := blob.Content.(interface {
Seek(int64, int) (int64, error)
}); !ok {
t.Error("Content must be seekable")
}
})
t.Run("Open of a missing key is not found", func(t *testing.T) {
fake := newFakeS3Server(t)
_, err := openFakeS3Storage(t, fake, "").Open("missing.png")
if !errors.Is(err, domainImage.ErrBlobNotFound) {
t.Errorf("expected ErrBlobNotFound, got %v", err)
}
})
t.Run("Open with a backend failure is not a not-found", func(t *testing.T) {
fake := newFakeS3Server(t)
store := openFakeS3Storage(t, fake, "")
fake.failMethod(http.MethodGet, http.StatusInternalServerError)
_, err := store.Open("a1b2.png")
if err == nil || errors.Is(err, domainImage.ErrBlobNotFound) {
t.Errorf("expected a plain error, got %v", err)
}
})
t.Run("Delete is idempotent", func(t *testing.T) {
fake := newFakeS3Server(t)
fake.put("a1b2.png", []byte(s3ObjectBody), "image/png")
store := openFakeS3Storage(t, fake, "")
if err := store.Delete("a1b2.png"); err != nil {
t.Fatalf("deleting an existing key failed: %v", err)
}
if _, ok := fake.object("a1b2.png"); ok {
t.Error("the key must be gone")
}
fake.failMethod(http.MethodDelete, http.StatusNotFound)
if err := store.Delete("a1b2.png"); err != nil {
t.Errorf("deleting a missing key must not fail, got %v", err)
}
})
t.Run("A backend failure on Delete is returned", func(t *testing.T) {
fake := newFakeS3Server(t)
store := openFakeS3Storage(t, fake, "")
fake.failMethod(http.MethodDelete, http.StatusInternalServerError)
if err := store.Delete("a1b2.png"); err == nil {
t.Error("expected an error")
}
})
t.Run("Serving /static/uploadedImages/<name> from the bucket", func(t *testing.T) {
fake := newFakeS3Server(t)
fake.put("a1b2.png", []byte(s3ObjectBody), "image/png")
router := newS3StorageRouter(openFakeS3Storage(t, fake, ""))
lastModified := s3TestModTime.UTC().Format(http.TimeFormat)
assertCommonHeaders := func(t *testing.T, recorder *httptest.ResponseRecorder) {
t.Helper()
header := recorder.Header()
if header.Get("Content-Type") != "image/png" || header.Get("Last-Modified") != lastModified ||
header.Get("Accept-Ranges") != "bytes" || header.Get("Cache-Control") != "public, no-cache" ||
header.Get("X-Content-Type-Options") != "nosniff" || header.Get("Content-Length") != "10" {
t.Errorf("unexpected headers %v", header)
}
if header.Get("ETag") != "" {
t.Errorf("no ETag expected, got %q", header.Get("ETag"))
}
}
t.Run("GET with no extra header", func(t *testing.T) {
recorder := serveS3Storage(router, http.MethodGet, "/static/uploadedImages/a1b2.png", nil)
if recorder.Code != http.StatusOK || recorder.Body.String() != s3ObjectBody {
t.Fatalf("got %d %q", recorder.Code, recorder.Body.String())
}
assertCommonHeaders(t, recorder)
})
t.Run("HEAD with no extra header", func(t *testing.T) {
recorder := serveS3Storage(router, http.MethodHead, "/static/uploadedImages/a1b2.png", nil)
if recorder.Code != http.StatusOK || recorder.Body.Len() != 0 {
t.Fatalf("got %d with %d body bytes", recorder.Code, recorder.Body.Len())
}
assertCommonHeaders(t, recorder)
})
t.Run("GET with If-Modified-Since equal to Last-Modified", func(t *testing.T) {
recorder := serveS3Storage(router, http.MethodGet, "/static/uploadedImages/a1b2.png", map[string]string{"If-Modified-Since": lastModified})
if recorder.Code != http.StatusNotModified || recorder.Body.Len() != 0 {
t.Errorf("got %d with %d body bytes", recorder.Code, recorder.Body.Len())
}
})
t.Run("GET with Range bytes=0-3", func(t *testing.T) {
recorder := serveS3Storage(router, http.MethodGet, "/static/uploadedImages/a1b2.png", map[string]string{"Range": "bytes=0-3"})
if recorder.Code != http.StatusPartialContent || recorder.Body.String() != "0123" {
t.Fatalf("got %d %q", recorder.Code, recorder.Body.String())
}
if recorder.Header().Get("Content-Range") != "bytes 0-3/10" || recorder.Header().Get("Content-Length") != "4" {
t.Errorf("unexpected headers %v", recorder.Header())
}
})
t.Run("GET with Range bytes=4-6 reads the middle through a ranged request", func(t *testing.T) {
recorder := serveS3Storage(router, http.MethodGet, "/static/uploadedImages/a1b2.png", map[string]string{"Range": "bytes=4-6"})
if recorder.Code != http.StatusPartialContent || recorder.Body.String() != "456" {
t.Fatalf("got %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("GET with Range bytes=50-60", func(t *testing.T) {
recorder := serveS3Storage(router, http.MethodGet, "/static/uploadedImages/a1b2.png", map[string]string{"Range": "bytes=50-60"})
if recorder.Code != http.StatusRequestedRangeNotSatisfiable || recorder.Header().Get("Content-Range") != "bytes */10" {
t.Errorf("got %d %v", recorder.Code, recorder.Header())
}
})
})
t.Run("SVG from the bucket keeps its security headers", func(t *testing.T) {
fake := newFakeS3Server(t)
fake.put("logo.svg", []byte("<svg/>"), "image/svg+xml")
router := newS3StorageRouter(openFakeS3Storage(t, fake, ""))
recorder := serveS3Storage(router, http.MethodGet, "/static/uploadedImages/logo.svg", nil)
if recorder.Header().Get("Content-Type") != "image/svg+xml" {
t.Errorf("unexpected Content-Type %q", recorder.Header().Get("Content-Type"))
}
if recorder.Header().Get("Content-Security-Policy") != "default-src 'none'; style-src 'unsafe-inline'; sandbox" {
t.Errorf("unexpected CSP %q", recorder.Header().Get("Content-Security-Policy"))
}
})
t.Run("Missing object is 404 and a backend failure is 500", func(t *testing.T) {
fake := newFakeS3Server(t)
fake.put("a1b2.png", []byte(s3ObjectBody), "image/png")
router := newS3StorageRouter(openFakeS3Storage(t, fake, ""))
if recorder := serveS3Storage(router, http.MethodGet, "/static/uploadedImages/missing.png", nil); recorder.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", recorder.Code)
}
fake.failMethod(http.MethodGet, http.StatusInternalServerError)
recorder := serveS3Storage(router, http.MethodGet, "/static/uploadedImages/a1b2.png", nil)
if recorder.Code != http.StatusInternalServerError || strings.TrimSpace(recorder.Body.String()) != "Unable to read the file" {
t.Errorf("got %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("Path traversal never reaches another key", func(t *testing.T) {
fake := newFakeS3Server(t)
router := newS3StorageRouter(openFakeS3Storage(t, fake, "uploads/"))
serveS3Storage(router, http.MethodGet, "/static/uploadedImages/../secret.png", nil)
keys := fake.requestedKeys(http.MethodGet)
if len(keys) != 1 || keys[0] != "uploads/secret.png" {
t.Errorf("unexpected requested keys %v", keys)
}
})
t.Run("Handler as-is for a non-seekable blob", func(t *testing.T) {
store := streamOnlyStorage{data: []byte(s3ObjectBody)}
get := serveFrom(t, store, http.MethodGet, "/remote.png")
if get.Code != http.StatusOK || get.Body.String() != s3ObjectBody || get.Header().Get("Content-Length") != "10" || get.Header().Get("Last-Modified") == "" {
t.Errorf("unexpected GET %d %q %v", get.Code, get.Body.String(), get.Header())
}
head := serveFrom(t, store, http.MethodHead, "/remote.png")
if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("Content-Length") != "10" || head.Header().Get("Last-Modified") == "" {
t.Errorf("unexpected HEAD %d %q %v", head.Code, head.Body.String(), head.Header())
}
})
t.Run("Boot backfill logs how many images it read from the bucket", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
for id, name := range []string{"one.png", "two.png", "three.png"} {
store.blobs[name] = pngBytes(t, id+1, 1)
repo.images[uint32(id+1)] = domainImage.Image{ID: uint32(id + 1), Path: domainImage.PublicPath(name)}
}
repo.images[4] = domainImage.Image{ID: 4, Path: domainImage.PublicPath("missing.png")}
var logged bytes.Buffer
err := storage.RunBackfillReportingReads(store, log.New(&logged, "", 0), func(readable domainImage.Storage) error {
return useCases.NewBackfillImageMetadataUseCase(repo, readable).Run()
})
if err != nil {
t.Fatalf("backfill failed: %v", err)
}
if !strings.Contains(logged.String(), "read 3 images") || strings.Contains(logged.String(), s3TestSecret) {
t.Errorf("unexpected log %q", logged.String())
}
if repo.images[1].Filename != "one.png" || repo.images[4].Filename != "" {
t.Errorf("backfill behaviour changed: %+v", repo.images)
}
failure := errors.New("repository down")
repo.fail.failNextWith("ListMissingMetadata", failure)
var silent bytes.Buffer
err = storage.RunBackfillReportingReads(store, log.New(&silent, "", 0), func(readable domainImage.Storage) error {
return useCases.NewBackfillImageMetadataUseCase(repo, readable).Run()
})
if !errors.Is(err, failure) || silent.Len() != 0 {
t.Errorf("errors must be returned unchanged and nothing logged for zero reads, got %v %q", err, silent.String())
}
})
t.Run("The local driver is unchanged", func(t *testing.T) {
clearStorageEnv(t)
settings, err := config.NewSettingsFromEnv()
if err != nil || settings.Storage.Driver != "local" {
t.Fatalf("expected the local driver, got %+v, %v", settings.Storage, err)
}
uploadDir := t.TempDir()
store, err := storage.Open(storage.Config{Driver: storage.DriverLocal, Location: uploadDir})
if err != nil {
t.Fatalf("Open failed: %v", err)
}
name, err := store.Put("image/png", bytes.NewReader([]byte(s3ObjectBody)))
if err != nil {
t.Fatalf("Put failed: %v", err)
}
if data, err := os.ReadFile(filepath.Join(uploadDir, name)); err != nil || string(data) != s3ObjectBody {
t.Fatalf("expected the file in UPLOAD_DIR, got %q, %v", data, err)
}
recorder := serveS3Storage(newS3StorageRouter(store), http.MethodGet, "/static/uploadedImages/"+name, nil)
if recorder.Code != http.StatusOK || recorder.Body.String() != s3ObjectBody || recorder.Header().Get("Content-Type") != "image/png" {
t.Errorf("got %d %q %v", recorder.Code, recorder.Body.String(), recorder.Header())
}
})
}
+71
View File
@@ -0,0 +1,71 @@
package test
import (
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/storage"
"bytes"
"errors"
"io"
"os"
"testing"
)
const (
envTestS3Endpoint = "RENEWCMS_TEST_S3_ENDPOINT"
envTestS3Bucket = "RENEWCMS_TEST_S3_BUCKET"
envTestS3AccessKey = "RENEWCMS_TEST_S3_ACCESS_KEY"
envTestS3SecretKey = "RENEWCMS_TEST_S3_SECRET_KEY"
)
func TestS3StorageIntegration(t *testing.T) {
endpoint := os.Getenv(envTestS3Endpoint)
if endpoint == "" {
t.Skipf("%s is not set, run task test:s3 to start a throwaway S3 server", envTestS3Endpoint)
}
store, err := storage.Open(storage.Config{Driver: storage.DriverS3, S3: storage.S3Config{
Bucket: os.Getenv(envTestS3Bucket),
Region: "us-east-1",
Endpoint: endpoint,
AccessKeyID: os.Getenv(envTestS3AccessKey),
SecretAccessKey: os.Getenv(envTestS3SecretKey),
KeyPrefix: "integration/",
PathStyle: true,
}})
if err != nil {
t.Fatalf("storage.Open failed: %v", err)
}
name, err := store.Put("image/png", bytes.NewReader([]byte("0123456789")))
if err != nil {
t.Fatalf("Put failed: %v", err)
}
blob, err := store.Open(name)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
if blob.Size != 10 || blob.ModTime.IsZero() {
t.Errorf("unexpected blob metadata size=%d modtime=%v", blob.Size, blob.ModTime)
}
seeker, ok := blob.Content.(io.ReadSeeker)
if !ok {
t.Fatal("Content is not an io.ReadSeeker")
}
if _, err := seeker.Seek(4, io.SeekStart); err != nil {
t.Fatalf("Seek failed: %v", err)
}
chunk := make([]byte, 3)
if _, err := io.ReadFull(seeker, chunk); err != nil || string(chunk) != "456" {
t.Errorf("ranged read = %q, %v", chunk, err)
}
_ = blob.Content.Close()
if err := store.Delete(name); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if err := store.Delete(name); err != nil {
t.Errorf("deleting a missing key must not fail, got %v", err)
}
if _, err := store.Open(name); !errors.Is(err, domainImage.ErrBlobNotFound) {
t.Errorf("expected ErrBlobNotFound after delete, got %v", err)
}
}
+547
View File
@@ -0,0 +1,547 @@
package test
import (
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/storage"
"bytes"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
)
const (
s3TestBucket = "media"
s3TestSecret = "TOPSECRET"
s3TestAccessKey = "AKIATESTKEY"
)
var s3TestModTime = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
type fakeS3Object struct {
data []byte
contentType string
modTime time.Time
}
type fakeS3Request struct {
method string
key string
rng string
}
type fakeS3Server struct {
server *httptest.Server
mu sync.Mutex
objects map[string]fakeS3Object
bucketStatus int
failures map[string]int
requests []fakeS3Request
}
func newFakeS3Server(t *testing.T) *fakeS3Server {
t.Helper()
fake := &fakeS3Server{
objects: map[string]fakeS3Object{},
bucketStatus: http.StatusOK,
failures: map[string]int{},
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
fake.server = httptest.NewUnstartedServer(http.HandlerFunc(fake.handle))
fake.server.Listener = listener
fake.server.Start()
t.Cleanup(fake.server.Close)
return fake
}
func (f *fakeS3Server) put(key string, data []byte, contentType string) {
f.mu.Lock()
defer f.mu.Unlock()
f.objects[key] = fakeS3Object{data: data, contentType: contentType, modTime: s3TestModTime}
}
func (f *fakeS3Server) object(key string) (fakeS3Object, bool) {
f.mu.Lock()
defer f.mu.Unlock()
object, ok := f.objects[key]
return object, ok
}
func (f *fakeS3Server) failMethod(method string, status int) {
f.mu.Lock()
defer f.mu.Unlock()
f.failures[method] = status
}
func (f *fakeS3Server) setBucketStatus(status int) {
f.mu.Lock()
defer f.mu.Unlock()
f.bucketStatus = status
}
func (f *fakeS3Server) recorded() []fakeS3Request {
f.mu.Lock()
defer f.mu.Unlock()
return append([]fakeS3Request(nil), f.requests...)
}
func (f *fakeS3Server) requestedKeys(method string) []string {
var keys []string
for _, request := range f.recorded() {
if request.method == method {
keys = append(keys, request.key)
}
}
return keys
}
func (f *fakeS3Server) handle(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
trimmed := strings.TrimPrefix(r.URL.Path, "/"+s3TestBucket)
if r.URL.Path != "/"+s3TestBucket && trimmed == r.URL.Path {
http.Error(w, "unknown bucket", http.StatusNotFound)
return
}
key := strings.TrimPrefix(trimmed, "/")
if key == "" {
w.WriteHeader(f.bucketStatus)
return
}
f.requests = append(f.requests, fakeS3Request{method: r.Method, key: key, rng: r.Header.Get("Range")})
if status, failing := f.failures[r.Method]; failing {
writeFakeS3Error(w, r, status, "InternalError")
return
}
switch r.Method {
case http.MethodPut:
data, _ := io.ReadAll(r.Body)
f.objects[key] = fakeS3Object{data: data, contentType: r.Header.Get("Content-Type"), modTime: s3TestModTime}
w.WriteHeader(http.StatusOK)
case http.MethodDelete:
delete(f.objects, key)
w.WriteHeader(http.StatusNoContent)
case http.MethodGet, http.MethodHead:
object, ok := f.objects[key]
if !ok {
writeFakeS3Error(w, r, http.StatusNotFound, "NoSuchKey")
return
}
writeFakeS3Object(w, r, object)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func writeFakeS3Error(w http.ResponseWriter, r *http.Request, status int, code string) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(status)
if r.Method != http.MethodHead {
_, _ = fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?><Error><Code>%s</Code><Message>%s</Message></Error>`, code, code)
}
}
func writeFakeS3Object(w http.ResponseWriter, r *http.Request, object fakeS3Object) {
data := object.data
status := http.StatusOK
w.Header().Set("Content-Type", object.contentType)
w.Header().Set("Last-Modified", object.modTime.UTC().Format(http.TimeFormat))
if rng := r.Header.Get("Range"); rng != "" {
var start int
spec := strings.TrimPrefix(rng, "bytes=")
bounds := strings.SplitN(spec, "-", 2)
start, _ = strconv.Atoi(bounds[0])
end := len(data) - 1
if bounds[1] != "" {
end, _ = strconv.Atoi(bounds[1])
}
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(data)))
data = data[start : end+1]
status = http.StatusPartialContent
}
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.WriteHeader(status)
if r.Method != http.MethodHead {
_, _ = w.Write(data)
}
}
func (f *fakeS3Server) config(prefix string) storage.S3Config {
return storage.S3Config{
Bucket: s3TestBucket,
Region: "us-east-1",
Endpoint: f.server.URL,
AccessKeyID: s3TestAccessKey,
SecretAccessKey: s3TestSecret,
KeyPrefix: prefix,
PathStyle: true,
MaxAttempts: 1,
}
}
func openFakeS3Storage(t *testing.T, fake *fakeS3Server, prefix string) domainImage.Storage {
t.Helper()
store, err := storage.Open(storage.Config{Driver: storage.DriverS3, S3: fake.config(prefix)})
if err != nil {
t.Fatalf("storage.Open failed: %v", err)
}
return store
}
func readAllBlob(t *testing.T, blob domainImage.Blob) []byte {
t.Helper()
defer blob.Content.Close()
data, err := io.ReadAll(blob.Content)
if err != nil {
t.Fatalf("read failed: %v", err)
}
return data
}
func TestS3StorageSeekThenReadUsesRangedRequests(t *testing.T) {
fake := newFakeS3Server(t)
fake.put("a1b2.png", []byte("0123456789"), "image/png")
store := openFakeS3Storage(t, fake, "")
blob, err := store.Open("a1b2.png")
if err != nil {
t.Fatalf("Open failed: %v", err)
}
defer blob.Content.Close()
seeker, ok := blob.Content.(io.ReadSeeker)
if !ok {
t.Fatal("Content is not an io.ReadSeeker")
}
end, err := seeker.Seek(0, io.SeekEnd)
if err != nil || end != 10 {
t.Fatalf("Seek to end = %d, %v", end, err)
}
if _, err := seeker.Seek(4, io.SeekStart); err != nil {
t.Fatalf("Seek failed: %v", err)
}
chunk := make([]byte, 3)
if _, err := io.ReadFull(seeker, chunk); err != nil || string(chunk) != "456" {
t.Fatalf("read after seek = %q, %v", chunk, err)
}
rest, _ := io.ReadAll(seeker)
if string(rest) != "789" {
t.Errorf("remaining bytes = %q", rest)
}
var ranges []string
for _, request := range fake.recorded() {
if request.method == http.MethodGet {
ranges = append(ranges, request.rng)
}
}
if len(ranges) != 2 || ranges[0] != "" || ranges[1] != "bytes=4-" {
t.Errorf("unexpected GET ranges %q", ranges)
}
if _, err := seeker.Seek(-1, io.SeekStart); err == nil {
t.Error("a negative seek must fail")
}
}
func TestS3StorageNamesThatAreNotPlainAreNeverRequested(t *testing.T) {
fake := newFakeS3Server(t)
store := openFakeS3Storage(t, fake, "uploads/")
for _, name := range []string{"", ".", "..", "../x.png", "a/b.png"} {
if _, err := store.Open(name); !errors.Is(err, domainImage.ErrBlobNotFound) {
t.Errorf("Open(%q) = %v, want ErrBlobNotFound", name, err)
}
if err := store.Delete(name); err != nil {
t.Errorf("Delete(%q) = %v, want nil", name, err)
}
}
if requests := fake.recorded(); len(requests) != 0 {
t.Errorf("no object request expected, got %v", requests)
}
}
func TestS3StorageErrorsNeverContainTheSecret(t *testing.T) {
fake := newFakeS3Server(t)
store := openFakeS3Storage(t, fake, "")
fake.failMethod(http.MethodPut, http.StatusInternalServerError)
fake.failMethod(http.MethodGet, http.StatusInternalServerError)
fake.failMethod(http.MethodDelete, http.StatusInternalServerError)
_, putErr := store.Put("image/png", bytes.NewReader([]byte("abc")))
_, openErr := store.Open("a1b2.png")
deleteErr := store.Delete("a1b2.png")
for name, err := range map[string]error{"Put": putErr, "Open": openErr, "Delete": deleteErr} {
if err == nil {
t.Fatalf("%s must fail", name)
}
if strings.Contains(err.Error(), s3TestSecret) {
t.Errorf("%s error leaks the secret: %v", name, err)
}
}
}
func TestS3StorageOpenWithUnreachableEndpointIsSecretFree(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
endpoint := "http://" + listener.Addr().String()
_ = listener.Close()
_, err = storage.Open(storage.Config{Driver: storage.DriverS3, S3: storage.S3Config{
Bucket: s3TestBucket, Region: "us-east-1", Endpoint: endpoint, PathStyle: true, MaxAttempts: 1,
AccessKeyID: s3TestAccessKey, SecretAccessKey: s3TestSecret,
}})
if err == nil || !strings.Contains(err.Error(), s3TestBucket) || strings.Contains(err.Error(), s3TestSecret) {
t.Errorf("expected a bucket-named, secret-free error, got %v", err)
}
}
func TestS3StorageEndpointWithoutSchemeFollowsTheTLSSetting(t *testing.T) {
fake := newFakeS3Server(t)
settings := fake.config("")
settings.Endpoint = strings.TrimPrefix(fake.server.URL, "http://")
settings.TLS = false
store, err := storage.Open(storage.Config{Driver: storage.DriverS3, S3: settings})
if err != nil {
t.Fatalf("plain HTTP endpoint without scheme must open, got %v", err)
}
if _, err := store.Open("missing.png"); !errors.Is(err, domainImage.ErrBlobNotFound) {
t.Errorf("expected not found, got %v", err)
}
}
func TestS3StorageSettingsFromEnv(t *testing.T) {
setBaseSettingsEnv(t)
for _, name := range []string{"STORAGE_DRIVER", "S3_BUCKET", "S3_REGION", "S3_ENDPOINT", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "S3_KEY_PREFIX", "S3_PATH_STYLE", "S3_TLS"} {
t.Setenv(name, "")
}
t.Run("Every S3 variable is read", func(t *testing.T) {
t.Setenv("STORAGE_DRIVER", "s3")
t.Setenv("S3_BUCKET", "media")
t.Setenv("S3_REGION", "eu-west-3")
t.Setenv("S3_ENDPOINT", "minio:9000")
t.Setenv("S3_ACCESS_KEY_ID", "id")
t.Setenv("S3_SECRET_ACCESS_KEY", "secret")
t.Setenv("S3_KEY_PREFIX", "cms/")
t.Setenv("S3_PATH_STYLE", "true")
t.Setenv("S3_TLS", "false")
settings, err := config.NewSettingsFromEnv()
if err != nil {
t.Fatalf("unexpected error %v", err)
}
want := config.S3Settings{Bucket: "media", Region: "eu-west-3", Endpoint: "minio:9000", AccessKeyID: "id", SecretAccessKey: "secret", KeyPrefix: "cms/", PathStyle: true, TLS: false}
if settings.Storage.Driver != "s3" || settings.Storage.S3 != want {
t.Errorf("unexpected storage settings %+v", settings.Storage)
}
})
t.Run("Defaults apply", func(t *testing.T) {
t.Setenv("STORAGE_DRIVER", "s3")
t.Setenv("S3_BUCKET", "media")
settings, err := config.NewSettingsFromEnv()
if err != nil {
t.Fatalf("unexpected error %v", err)
}
s3 := settings.Storage.S3
if s3.Region != "us-east-1" || s3.PathStyle || !s3.TLS || s3.KeyPrefix != "" || s3.Endpoint != "" {
t.Errorf("unexpected defaults %+v", s3)
}
})
t.Run("Invalid booleans are rejected", func(t *testing.T) {
t.Setenv("STORAGE_DRIVER", "s3")
t.Setenv("S3_BUCKET", "media")
t.Setenv("S3_PATH_STYLE", "maybe")
if _, err := config.NewSettingsFromEnv(); err == nil || !strings.Contains(err.Error(), "S3_PATH_STYLE") {
t.Errorf("expected an S3_PATH_STYLE error, got %v", err)
}
t.Setenv("S3_PATH_STYLE", "")
t.Setenv("S3_TLS", "maybe")
if _, err := config.NewSettingsFromEnv(); err == nil || !strings.Contains(err.Error(), "S3_TLS") {
t.Errorf("expected an S3_TLS error, got %v", err)
}
})
t.Run("Required variables by driver", func(t *testing.T) {
if got := config.StorageRequiredEnvVars("s3"); len(got) != 1 || got[0] != "S3_BUCKET" {
t.Errorf("unexpected required variables %v", got)
}
if got := config.StorageRequiredEnvVars("local"); len(got) != 0 {
t.Errorf("local requires nothing, got %v", got)
}
})
}
func countS3Gets(fake *fakeS3Server) int {
return len(fake.requestedKeys(http.MethodGet))
}
func openFakeS3Seeker(t *testing.T, fake *fakeS3Server, name string) io.ReadSeekCloser {
t.Helper()
store := openFakeS3Storage(t, fake, "")
blob, err := store.Open(name)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
seeker, ok := blob.Content.(io.ReadSeekCloser)
if !ok {
t.Fatal("Content is not an io.ReadSeekCloser")
}
return seeker
}
func TestS3StorageSequentialReadUsesASingleRequest(t *testing.T) {
fake := newFakeS3Server(t)
fake.put("a1b2.png", []byte("0123456789"), "image/png")
seeker := openFakeS3Seeker(t, fake, "a1b2.png")
defer seeker.Close()
first := make([]byte, 4)
if _, err := io.ReadFull(seeker, first); err != nil || string(first) != "0123" {
t.Fatalf("first read = %q, %v", first, err)
}
rest, err := io.ReadAll(seeker)
if err != nil || string(rest) != "456789" {
t.Fatalf("rest = %q, %v", rest, err)
}
if gets := countS3Gets(fake); gets != 1 {
t.Errorf("sequential read must use 1 GET, got %d", gets)
}
}
func TestS3StorageReadAtEndReturnsEOFWithoutRequest(t *testing.T) {
fake := newFakeS3Server(t)
fake.put("a1b2.png", []byte("0123456789"), "image/png")
seeker := openFakeS3Seeker(t, fake, "a1b2.png")
defer seeker.Close()
if _, err := seeker.Seek(0, io.SeekEnd); err != nil {
t.Fatalf("Seek failed: %v", err)
}
n, err := seeker.Read(make([]byte, 4))
if n != 0 || err != io.EOF {
t.Errorf("Read at end = %d, %v, want 0, EOF", n, err)
}
if gets := countS3Gets(fake); gets != 1 {
t.Errorf("reading at the end must not issue a request, got %d GETs", gets)
}
}
func TestS3StorageSeekWhenceVariants(t *testing.T) {
fake := newFakeS3Server(t)
fake.put("a1b2.png", []byte("0123456789"), "image/png")
seeker := openFakeS3Seeker(t, fake, "a1b2.png")
defer seeker.Close()
if pos, err := seeker.Seek(-3, io.SeekEnd); err != nil || pos != 7 {
t.Fatalf("SeekEnd(-3) = %d, %v", pos, err)
}
if pos, err := seeker.Seek(-2, io.SeekCurrent); err != nil || pos != 5 {
t.Fatalf("SeekCurrent(-2) = %d, %v", pos, err)
}
chunk := make([]byte, 2)
if _, err := io.ReadFull(seeker, chunk); err != nil || string(chunk) != "56" {
t.Errorf("read after seeks = %q, %v", chunk, err)
}
if _, err := seeker.Seek(0, 99); err == nil {
t.Error("an invalid whence must fail")
}
}
func TestS3StorageReadAfterCloseReopensTheObject(t *testing.T) {
fake := newFakeS3Server(t)
fake.put("a1b2.png", []byte("0123456789"), "image/png")
seeker := openFakeS3Seeker(t, fake, "a1b2.png")
head := make([]byte, 3)
if _, err := io.ReadFull(seeker, head); err != nil {
t.Fatalf("read failed: %v", err)
}
if err := seeker.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
rest, err := io.ReadAll(seeker)
if err != nil || string(rest) != "3456789" {
t.Errorf("read after Close = %q, %v", rest, err)
}
_ = seeker.Close()
if gets := countS3Gets(fake); gets != 2 {
t.Errorf("expected 2 GETs, got %d", gets)
}
}
func TestS3StorageMaxAttemptsLimitsRetries(t *testing.T) {
fake := newFakeS3Server(t)
store := openFakeS3Storage(t, fake, "")
fake.failMethod(http.MethodGet, http.StatusInternalServerError)
if _, err := store.Open("a1b2.png"); err == nil {
t.Fatal("Open must fail")
}
if gets := countS3Gets(fake); gets != 1 {
t.Errorf("MaxAttempts=1 must issue exactly 1 GET, got %d", gets)
}
}
func TestS3StorageTLSEndpointWithoutSchemeUsesHTTPS(t *testing.T) {
fake := newFakeS3Server(t)
settings := fake.config("")
settings.Endpoint = strings.TrimPrefix(fake.server.URL, "http://")
settings.TLS = true
_, err := storage.Open(storage.Config{Driver: storage.DriverS3, S3: settings})
if err == nil || !strings.Contains(err.Error(), s3TestBucket) {
t.Errorf("https against a plain HTTP server must fail naming the bucket, got %v", err)
}
}
func TestS3StorageRedactsSecretsFoundInTransportErrors(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
endpoint := "http://" + listener.Addr().String() + "/" + s3TestAccessKey + "/" + s3TestSecret
_ = listener.Close()
_, err = storage.Open(storage.Config{Driver: storage.DriverS3, S3: storage.S3Config{
Bucket: s3TestBucket, Region: "us-east-1", Endpoint: endpoint, PathStyle: true, MaxAttempts: 1,
AccessKeyID: s3TestAccessKey, SecretAccessKey: s3TestSecret,
}})
if err == nil {
t.Fatal("expected an error")
}
if strings.Contains(err.Error(), s3TestSecret) || strings.Contains(err.Error(), s3TestAccessKey) {
t.Errorf("credentials leaked: %v", err)
}
if !strings.Contains(err.Error(), "***") {
t.Errorf("expected redaction markers, got %v", err)
}
}
func TestS3StorageReadFailsWhenTheRangedRequestFails(t *testing.T) {
fake := newFakeS3Server(t)
fake.put("a1b2.png", []byte("0123456789"), "image/png")
seeker := openFakeS3Seeker(t, fake, "a1b2.png")
defer seeker.Close()
if _, err := seeker.Seek(4, io.SeekStart); err != nil {
t.Fatalf("Seek failed: %v", err)
}
fake.failMethod(http.MethodGet, http.StatusInternalServerError)
n, err := seeker.Read(make([]byte, 3))
if err == nil || n != 0 {
t.Errorf("Read = %d, %v, want a failure with no bytes", n, err)
}
}
+3 -1
View File
@@ -3,6 +3,8 @@ package test
import (
"RenewCMS/api/handlers/staticimages"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
"net/http/httptest"
"os"
@@ -18,7 +20,7 @@ const spaIndexMarker = "SPA-INDEX-MARKER"
func newServeUploadedImagesRouter(uploadDir string) *chi.Mux {
r := chi.NewRouter()
r.Use(chimiddleware.GetHead)
r.Mount("/static/uploadedImages", staticimages.NewHandler(uploadDir))
r.Mount("/static/uploadedImages", staticimages.NewHandler(useCases.NewOpenMediaUseCase(storage.NewLocalStorage(uploadDir))))
r.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(spaIndexMarker))
+328
View File
@@ -0,0 +1,328 @@
package test
import (
"RenewCMS/internal/infrastructure/persistence/database"
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
type fakeDumpTable struct {
create string
columns []database.DumpColumn
rows [][][]byte
}
type fakeDumpSource struct {
order []string
tables map[string]fakeDumpTable
failOn string
closed bool
rowsError error
}
var _ database.DumpSource = &fakeDumpSource{}
type fakeDumpRows struct {
table fakeDumpTable
index int
failed error
}
var _ database.DumpRows = &fakeDumpRows{}
func (s *fakeDumpSource) Tables() ([]string, error) { return s.order, nil }
func (s *fakeDumpSource) CreateStatement(table string) (string, error) {
if s.failOn == table {
return "", errors.New("show create failed")
}
return s.tables[table].create, nil
}
func (s *fakeDumpSource) Rows(table string) (database.DumpRows, error) {
return &fakeDumpRows{table: s.tables[table], index: -1, failed: s.rowsError}, nil
}
func (s *fakeDumpSource) Close() error {
s.closed = true
return nil
}
func (r *fakeDumpRows) Columns() ([]database.DumpColumn, error) { return r.table.columns, nil }
func (r *fakeDumpRows) Next() bool {
r.index++
return r.index < len(r.table.rows)
}
func (r *fakeDumpRows) Values() ([][]byte, error) { return r.table.rows[r.index], nil }
func (r *fakeDumpRows) Err() error { return r.failed }
func (r *fakeDumpRows) Close() error { return nil }
func newFakeDumpSource() *fakeDumpSource {
return &fakeDumpSource{
order: []string{"notes", "plain"},
tables: map[string]fakeDumpTable{
"notes": {
create: "CREATE TABLE `notes` (\n `id` int unsigned NOT NULL,\n `body` longtext,\n `blob` longblob,\n `at` datetime(3),\n `ok` tinyint(1)\n)",
columns: []database.DumpColumn{
{Name: "id", Type: "UNSIGNED INT"},
{Name: "body", Type: "LONGTEXT"},
{Name: "blob", Type: "LONGBLOB"},
{Name: "at", Type: "DATETIME"},
{Name: "ok", Type: "TINYINT"},
},
rows: [][][]byte{
{[]byte("1"), []byte("it's a \"test\"\nline\\two\r\x00\x1a"), []byte{0x00, 0xff, 0x10}, []byte("2026-01-02 03:04:05.123000"), []byte("1")},
{[]byte("2"), nil, []byte{}, []byte("0000-00-00 00:00:00"), []byte("0")},
},
},
"plain": {
create: "CREATE TABLE `plain` (\n `a` int\n)",
columns: []database.DumpColumn{{Name: "a", Type: "INT"}},
},
},
}
}
func TestDumpWriter(t *testing.T) {
t.Run("the dump holds the schema and the rows of every table", func(t *testing.T) {
var output bytes.Buffer
err := database.WriteDump(&output, newFakeDumpSource())
if err != nil {
t.Fatalf("write: %v", err)
}
dump := output.String()
for _, expected := range []string{
"CREATE TABLE `notes` (",
"CREATE TABLE `plain` (",
"INSERT INTO `notes` (`id`,`body`,`blob`,`at`,`ok`) VALUES (1,'it\\'s a \"test\"\\nline\\\\two\\r\\0\\Z',0x00ff10,'2026-01-02 03:04:05.123000',1);",
"INSERT INTO `notes` (`id`,`body`,`blob`,`at`,`ok`) VALUES (2,NULL,'','0000-00-00 00:00:00',0);",
"SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';",
} {
if !strings.Contains(dump, expected) {
t.Errorf("the dump must contain %q, got:\n%s", expected, dump)
}
}
if strings.Contains(dump, "INSERT INTO `plain`") {
t.Error("an empty table has no rows to insert")
}
})
t.Run("every statement after the schema stays on one line", func(t *testing.T) {
var output bytes.Buffer
_ = database.WriteDump(&output, newFakeDumpSource())
for _, line := range strings.Split(output.String(), "\n") {
if strings.HasPrefix(line, "INSERT") && !strings.HasSuffix(line, ");") {
t.Errorf("insert split over several lines: %q", line)
}
}
})
t.Run("a failing table stops the dump with the error", func(t *testing.T) {
source := newFakeDumpSource()
source.failOn = "plain"
err := database.WriteDump(&bytes.Buffer{}, source)
if err == nil || err.Error() != "show create failed" {
t.Errorf("unexpected error %v", err)
}
})
t.Run("a row error stops the dump with the error", func(t *testing.T) {
source := newFakeDumpSource()
source.rowsError = errors.New("connection lost")
err := database.WriteDump(&bytes.Buffer{}, source)
if err == nil || err.Error() != "connection lost" {
t.Errorf("unexpected error %v", err)
}
})
}
type dumpBackupRig struct {
directory string
source *fakeDumpSource
clock time.Time
backup *database.DumpBackup
}
func newDumpBackupRig(t *testing.T) *dumpBackupRig {
t.Helper()
rig := &dumpBackupRig{
directory: filepath.Join(t.TempDir(), "nested", "backups"),
source: newFakeDumpSource(),
clock: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC),
}
rig.backup = database.NewDumpBackup(func() (database.DumpSource, error) { return rig.source, nil }, rig.directory, func() time.Time {
rig.clock = rig.clock.Add(time.Second)
return rig.clock
})
return rig
}
func (r *dumpBackupRig) names(t *testing.T) []string {
t.Helper()
entries, err := os.ReadDir(r.directory)
if err != nil {
t.Fatalf("read backups: %v", err)
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
return names
}
func TestDumpBackup(t *testing.T) {
t.Run("a file renewcms-<time>.sql exists in the backups directory", func(t *testing.T) {
rig := newDumpBackupRig(t)
path, err := rig.backup.Backup()
if err != nil {
t.Fatalf("backup: %v", err)
}
name := filepath.Base(path)
if filepath.Dir(path) != rig.directory || !strings.HasPrefix(name, "renewcms-20260304T050608") || !strings.HasSuffix(name, ".sql") {
t.Errorf("unexpected backup path %q", path)
}
content, err := os.ReadFile(path)
if err != nil || !strings.Contains(string(content), "INSERT INTO `notes`") {
t.Errorf("unexpected content %q err %v", content, err)
}
if !rig.source.closed {
t.Error("the source must be released")
}
})
t.Run("only the 3 newest backups are kept", func(t *testing.T) {
rig := newDumpBackupRig(t)
var paths []string
for range 5 {
path, err := rig.backup.Backup()
if err != nil {
t.Fatalf("backup: %v", err)
}
paths = append(paths, path)
}
names := rig.names(t)
if len(names) != 3 {
t.Fatalf("expected 3 backups, got %v", names)
}
for _, newest := range paths[2:] {
if _, err := os.Stat(newest); err != nil {
t.Errorf("the newest backup %q must remain: %v", newest, err)
}
}
})
t.Run("a failure returns an error and leaves no file behind", func(t *testing.T) {
rig := newDumpBackupRig(t)
rig.source.failOn = "plain"
path, err := rig.backup.Backup()
if err == nil || path != "" {
t.Fatalf("expected a failure, got path %q err %v", path, err)
}
if names := rig.names(t); len(names) != 0 {
t.Errorf("no partial file may remain, got %v", names)
}
})
t.Run("a source that cannot be opened is a failure", func(t *testing.T) {
directory := filepath.Join(t.TempDir(), "backups")
backup := database.NewDumpBackup(func() (database.DumpSource, error) { return nil, errors.New("no connection") }, directory, time.Now)
path, err := backup.Backup()
if err == nil || path != "" {
t.Errorf("expected a failure, got path %q err %v", path, err)
}
if entries, _ := os.ReadDir(directory); len(entries) != 0 {
t.Errorf("no file may remain, got %d entries", len(entries))
}
})
t.Run("a backup taken at the same instant is refused instead of overwritten", func(t *testing.T) {
directory := filepath.Join(t.TempDir(), "backups")
fixed := func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }
backup := database.NewDumpBackup(func() (database.DumpSource, error) { return newFakeDumpSource(), nil }, directory, fixed)
if _, err := backup.Backup(); err != nil {
t.Fatalf("first backup: %v", err)
}
_, err := backup.Backup()
if err == nil {
t.Error("expected the second backup to fail")
}
})
}
func TestDumpBackupFiles(t *testing.T) {
t.Run("backups sharing a modification time are pruned by name, oldest name first", func(t *testing.T) {
rig := newDumpBackupRig(t)
if err := os.MkdirAll(rig.directory, os.ModePerm); err != nil {
t.Fatalf("mkdir: %v", err)
}
shared := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
for _, name := range []string{"renewcms-1.sql", "renewcms-2.sql", "renewcms-3.sql", "renewcms-4.sql"} {
path := filepath.Join(rig.directory, name)
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.Chtimes(path, shared, shared); err != nil {
t.Fatalf("chtimes: %v", err)
}
}
if _, err := rig.backup.Backup(); err != nil {
t.Fatalf("backup: %v", err)
}
names := rig.names(t)
if len(names) != 3 || !strings.HasPrefix(names[0], "renewcms-2026") || names[1] != "renewcms-3.sql" || names[2] != "renewcms-4.sql" {
t.Errorf("the new backup and the two highest names must survive, got %v", names)
}
})
t.Run("a target that cannot be renamed into is a failure and leaves no partial file", func(t *testing.T) {
rig := newDumpBackupRig(t)
blocker := ""
rig.backup = database.NewDumpBackup(func() (database.DumpSource, error) {
blocker = filepath.Join(rig.directory, "renewcms-"+rig.clock.UTC().Format("20060102T150405.000000000Z")+".sql")
if err := os.MkdirAll(filepath.Join(blocker, "inner"), os.ModePerm); err != nil {
t.Fatalf("block target: %v", err)
}
return rig.source, nil
}, rig.directory, func() time.Time {
rig.clock = rig.clock.Add(time.Second)
return rig.clock
})
path, err := rig.backup.Backup()
if err == nil || path != "" {
t.Fatalf("expected a failure, got path %q err %v", path, err)
}
for _, name := range rig.names(t) {
if strings.HasSuffix(name, ".partial") {
t.Errorf("the partial file %q must be removed", name)
}
}
})
}
+2 -2
View File
@@ -1,7 +1,7 @@
package test
import (
"RenewCMS/api"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
"path/filepath"
"testing"
@@ -16,7 +16,7 @@ func TestProductionSQLiteDSNWaitsForTheWriteLock(t *testing.T) {
t.Run("A second writer waits for the first instead of failing with a locked database", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "busy.db")
open := func() *gorm.DB {
db, err := gorm.Open(sqlite.Open(api.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard})
db, err := gorm.Open(sqlite.Open(database.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open: %v", err)
}
+177
View File
@@ -0,0 +1,177 @@
package test
import (
"RenewCMS/internal/infrastructure/persistence/database"
"fmt"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type legacyArticle struct {
gorm.Model
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Title string
Body string
IsOnline bool `gorm:"not_null;default:false"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
func (legacyArticle) TableName() string { return "articles" }
type legacyImage struct {
gorm.Model
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
Path string
Filename string
Size int64
ContentType string
Width int
Height int
SHA256 string `gorm:"column:sha256;index"`
UploadedByID uint32
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
func (legacyImage) TableName() string { return "images" }
type legacyUser struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Username string `gorm:"unique;not null"`
Password string `gorm:"not null"`
PasswordResetCode *string `gorm:"unique"`
Email string `gorm:"unique;not null"`
DisplayName string `gorm:"not null;default:''"`
IsVerified bool `gorm:"default=false;not null"`
Role string `gorm:"not null;default:''"`
VerificationCode *string `gorm:"unique"`
VerificationExpiration time.Time
PasswordResetExpiration time.Time
TokenVersion uint32 `gorm:"default:0;not null"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
func (legacyUser) TableName() string { return "users" }
type legacyInvite struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Email string `gorm:"index;not null"`
HashedCode string `gorm:"not null"`
ExpiresAt time.Time
InvitedBy uint32 `gorm:"not null"`
AcceptedAt *time.Time
CreatedAt time.Time `gorm:"autoCreateTime"`
}
func (legacyInvite) TableName() string { return "invites" }
type legacyAPIKey struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
UserID uint32 `gorm:"index;not null"`
Name string `gorm:"not null"`
Prefix string `gorm:"not null"`
HashedKey string `gorm:"uniqueIndex;not null"`
ExpiresAt *time.Time
RevokedAt *time.Time
LastUsedAt *time.Time
CreatedAt time.Time `gorm:"autoCreateTime"`
}
func (legacyAPIKey) TableName() string { return "api_keys" }
type legacyPurgeSettings struct {
ID uint32 `gorm:"primaryKey"`
AutoPurgeEnabled bool `gorm:"not null"`
AutoPurgeIntervalDays int `gorm:"not null"`
LastPurgeAt *time.Time
LastPurgeMerged int `gorm:"not null"`
LastPurgeArticlesUpdated int `gorm:"not null"`
EnabledAt *time.Time
}
func (legacyPurgeSettings) TableName() string { return "purge_settings" }
type sqliteSnapshot struct {
schema map[string]string
rows map[string][]map[string]any
}
func takeSqliteSnapshot(t *testing.T, db *gorm.DB) sqliteSnapshot {
t.Helper()
snapshot := sqliteSnapshot{schema: map[string]string{}, rows: map[string][]map[string]any{}}
var objects []struct{ Name, Sql string }
if err := db.Raw("SELECT name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY name").Scan(&objects).Error; err != nil {
t.Fatalf("read schema: %v", err)
}
for _, object := range objects {
snapshot.schema[object.Name] = object.Sql
}
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
var rows []map[string]any
if err := db.Raw(fmt.Sprintf("SELECT * FROM %s ORDER BY 1", table)).Scan(&rows).Error; err != nil {
t.Fatalf("read %s: %v", table, err)
}
snapshot.rows[table] = rows
}
return snapshot
}
func seedLegacyRows(t *testing.T, db *gorm.DB) {
t.Helper()
at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
reset := "reset-1"
seeds := []any{
&legacyArticle{Title: "First", Body: "Body", IsOnline: true},
&legacyImage{Path: "/static/uploadedImages/a.png", Filename: "a.png", Size: 12, ContentType: "image/png", Width: 1, Height: 2, SHA256: "abc", UploadedByID: 1},
&legacyUser{Username: "Bob", Password: "x", Email: "bob@x.example", DisplayName: "Bob", IsVerified: true, Role: "owner", PasswordResetCode: &reset, VerificationExpiration: at},
&legacyUser{Username: "bob", Password: "y", Email: "bob2@x.example", Role: "admin"},
&legacyInvite{Email: "new@x.example", HashedCode: "h", ExpiresAt: at, InvitedBy: 1},
&legacyAPIKey{UserID: 1, Name: "ci", Prefix: "rcms_abc", HashedKey: "0123456789abcdef", ExpiresAt: &at},
&legacyPurgeSettings{ID: 1, AutoPurgeEnabled: true, AutoPurgeIntervalDays: 7, LastPurgeAt: &at},
}
for _, seed := range seeds {
if err := db.Create(seed).Error; err != nil {
t.Fatalf("seed %T: %v", seed, err)
}
}
}
func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
t.Run("SQLite schema is untouched by the model changes", func(t *testing.T) {
path := filepath.ToSlash(filepath.Join(t.TempDir(), "legacy.db"))
db, err := gorm.Open(sqlite.Open(database.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(&legacyArticle{}, &legacyImage{}, &legacyUser{}, &legacyInvite{}, &legacyAPIKey{}, &legacyPurgeSettings{}); err != nil {
t.Fatalf("legacy migrate: %v", err)
}
seedLegacyRows(t, db)
before := takeSqliteSnapshot(t, db)
if err := database.Migrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
after := takeSqliteSnapshot(t, db)
if !reflect.DeepEqual(before.schema, after.schema) {
t.Errorf("schema changed:\nbefore %v\nafter %v", before.schema, after.schema)
}
if !reflect.DeepEqual(before.rows, after.rows) {
t.Errorf("rows changed:\nbefore %v\nafter %v", before.rows, after.rows)
}
})
}
+160
View File
@@ -0,0 +1,160 @@
package test
import (
"RenewCMS/api/handlers/staticimages"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type streamOnlyStorage struct {
data []byte
}
func (s streamOnlyStorage) Put(string, io.Reader) (string, error) { return "", errors.New("read only") }
func (s streamOnlyStorage) Delete(string) error { return nil }
func (s streamOnlyStorage) Open(name string) (domainImage.Blob, error) {
if name != "remote.png" {
return domainImage.Blob{}, domainImage.ErrBlobNotFound
}
return domainImage.Blob{
Content: io.NopCloser(bytes.NewReader(s.data)),
Size: int64(len(s.data)),
ModTime: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}, nil
}
type failingStorage struct{ streamOnlyStorage }
func (failingStorage) Open(string) (domainImage.Blob, error) {
return domainImage.Blob{}, errors.New("boom")
}
func serveFrom(t *testing.T, store domainImage.Storage, method, target string) *httptest.ResponseRecorder {
t.Helper()
handler := staticimages.NewHandler(useCases.NewOpenMediaUseCase(store))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
return rec
}
func TestLocalStorage(t *testing.T) {
t.Run("Put then Open returns the stored bytes and Delete removes them", func(t *testing.T) {
store := storage.NewLocalStorage(t.TempDir() + "/nested")
name, err := store.Put("image/png", strings.NewReader("hello"))
if err != nil || !strings.HasSuffix(name, ".png") {
t.Fatalf("Put returned %q, %v", name, err)
}
blob, err := store.Open(name)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
got, _ := io.ReadAll(blob.Content)
_ = blob.Content.Close()
if string(got) != "hello" || blob.Size != 5 {
t.Errorf("unexpected blob %q size %d", got, blob.Size)
}
if err := store.Delete(name); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := store.Open(name); !errors.Is(err, domainImage.ErrBlobNotFound) {
t.Errorf("expected ErrBlobNotFound after delete, got %v", err)
}
if err := store.Delete(name); err != nil {
t.Errorf("deleting a missing file must not fail, got %v", err)
}
})
t.Run("Names that are not plain file names are never opened or deleted", func(t *testing.T) {
dir := t.TempDir()
store := storage.NewLocalStorage(dir)
for _, name := range []string{"", ".", "..", "../x.png", "a/b.png", `a\b.png`} {
if _, err := store.Open(name); !errors.Is(err, domainImage.ErrBlobNotFound) {
t.Errorf("Open(%q) = %v, want ErrBlobNotFound", name, err)
}
if err := store.Delete(name); err != nil {
t.Errorf("Delete(%q) = %v, want nil", name, err)
}
}
})
}
func TestStaticImagesWithNonSeekableStorage(t *testing.T) {
store := streamOnlyStorage{data: []byte("remote-bytes")}
t.Run("GET streams the body with length and modification headers", func(t *testing.T) {
rec := serveFrom(t, store, http.MethodGet, "/remote.png")
if rec.Code != http.StatusOK || rec.Body.String() != "remote-bytes" {
t.Fatalf("got %d %q", rec.Code, rec.Body.String())
}
if rec.Header().Get("Content-Length") != "12" || rec.Header().Get("Content-Type") != "image/png" {
t.Errorf("unexpected headers %v", rec.Header())
}
if rec.Header().Get("Last-Modified") == "" {
t.Error("expected Last-Modified")
}
})
t.Run("HEAD sends the headers without a body", func(t *testing.T) {
rec := serveFrom(t, store, http.MethodHead, "/remote.png")
if rec.Code != http.StatusOK || rec.Body.Len() != 0 {
t.Errorf("got %d with %d body bytes", rec.Code, rec.Body.Len())
}
})
t.Run("A missing blob answers 404 and any other storage error answers 500", func(t *testing.T) {
if rec := serveFrom(t, store, http.MethodGet, "/other.png"); rec.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", rec.Code)
}
if rec := serveFrom(t, failingStorage{}, http.MethodGet, "/remote.png"); rec.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", rec.Code)
}
})
}
func TestMediaSearchTreatsWildcardsLiterally(t *testing.T) {
db, repo, _ := newMediaPersistence(t)
if err := db.AutoMigrate(&models.Article{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
percent := createStoredImage(t, repo, "100%_done.png", pngBytes(t, 1, 1), "image/png")
createStoredImage(t, repo, "plain.png", pngBytes(t, 2, 2), "image/png")
for _, search := range []string{"%", "_", "!", "100%_"} {
found, err := repo.List(domainImage.SearchQuery{Search: search})
if err != nil {
t.Fatalf("List(%q) failed: %v", search, err)
}
if search == "!" {
if len(found) != 0 {
t.Errorf("List(%q) matched %d images, want 0", search, len(found))
}
continue
}
if len(found) != 1 || found[0].ID != percent.ID {
t.Errorf("List(%q) = %+v, want only the 100%%_done image", search, found)
}
}
articles := persistence.NewArticleRepository(db)
if _, err := articles.Create(domainArticle.Article{Title: "t", Body: "uses 50%_off.png here"}); err != nil {
t.Fatalf("article create failed: %v", err)
}
if found, _ := articles.FindByBodyContaining("0%_o"); len(found) != 1 {
t.Errorf("expected the literal match, got %d", len(found))
}
if found, _ := articles.FindByBodyContaining("0x_o"); len(found) != 0 {
t.Errorf("underscore must not act as a wildcard, got %d", len(found))
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/persistence/database"
infraUpdate "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"context"
@@ -551,7 +552,7 @@ func TestUpdateBackupBeforeReadyHTTP(t *testing.T) {
}
backdate(t, path, time.Duration(10-index)*time.Hour)
}
rig.realBackup = infraUpdate.NewSQLiteDatabaseBackup(newBackupTestDatabase(t), backups, time.Now)
rig.realBackup = database.NewSQLiteBackup(newBackupTestDatabase(t), backups, time.Now)
rig.rebuild()
h := newDownloadHTTP(t, rig)
+6 -6
View File
@@ -1,8 +1,8 @@
package test
import (
"RenewCMS/api"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
infraUpdate "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
@@ -768,7 +768,7 @@ func TestSQLiteDatabaseBackup(t *testing.T) {
db := newBackupTestDatabase(t)
backups := filepath.Join(t.TempDir(), "backups")
clock := time.Date(2026, 9, 30, 10, 0, 0, 0, time.UTC)
backup := infraUpdate.NewSQLiteDatabaseBackup(db, backups, func() time.Time {
backup := database.NewSQLiteBackup(db, backups, func() time.Time {
clock = clock.Add(time.Second)
return clock
})
@@ -813,7 +813,7 @@ func TestSQLiteDatabaseBackup(t *testing.T) {
t.Run("a failed copy prunes nothing", func(t *testing.T) {
directory := t.TempDir()
fixed := func() time.Time { return clock }
failing := infraUpdate.NewSQLiteDatabaseBackup(db, directory, fixed)
failing := database.NewSQLiteBackup(db, directory, fixed)
if _, err := failing.Backup(); err != nil {
t.Fatal(err)
}
@@ -837,7 +837,7 @@ func TestSQLiteDatabaseBackup(t *testing.T) {
func newBackupTestDatabase(t *testing.T) *gorm.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "live.db")
db, err := gorm.Open(sqlite.Open(api.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard})
db, err := gorm.Open(sqlite.Open(database.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatal(err)
}
@@ -904,7 +904,7 @@ func TestSQLiteDatabaseBackupStaleCopyHeldOpen(t *testing.T) {
t.Fatal(err)
}
defer held.Close()
backup := infraUpdate.NewSQLiteDatabaseBackup(db, directory, time.Now)
backup := database.NewSQLiteBackup(db, directory, time.Now)
if _, err := backup.Backup(); err == nil {
t.Error("a stale copy that cannot be removed must surface as an error")
@@ -1001,7 +1001,7 @@ func TestSQLiteDatabaseBackupKeepsTheNewestNameOnEqualTimes(t *testing.T) {
t.Fatal(err)
}
}
backup := infraUpdate.NewSQLiteDatabaseBackup(newBackupTestDatabase(t), directory, time.Now)
backup := database.NewSQLiteBackup(newBackupTestDatabase(t), directory, time.Now)
if _, err := backup.Backup(); err != nil {
t.Fatal(err)
+9 -2
View File
@@ -6,6 +6,7 @@ import (
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/storage"
infraUpdate "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
@@ -551,7 +552,10 @@ func TestRestartResultLivesUntilTheNextJob(t *testing.T) {
func TestHealthzIsUnchanged(t *testing.T) {
t.Run("Healthz is unchanged", func(t *testing.T) {
container := &api.UseCases{Token: jwtauth.New("HS256", []byte("test-secret"), nil)}
container := &api.UseCases{
Token: jwtauth.New("HS256", []byte("test-secret"), nil),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())),
}
router := api.InitRoutes(container, config.Settings{Environment: "production"})
rec := httptest.NewRecorder()
@@ -609,7 +613,10 @@ func TestRunningInfoFileAtStartup(t *testing.T) {
})
t.Run("the file is not served by any route", func(t *testing.T) {
container := &api.UseCases{Token: jwtauth.New("HS256", []byte("test-secret"), nil)}
container := &api.UseCases{
Token: jwtauth.New("HS256", []byte("test-secret"), nil),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())),
}
router := api.InitRoutes(container, config.Settings{Environment: "production"})
for _, target := range []string{"/update-running.json", "/v1/update-running.json", "/static/uploadedImages/update-running.json"} {
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more