Compare commits

..
7 Commits
220 changed files with 17425 additions and 1082 deletions

No files matched your search

+21
View File
@@ -16,3 +16,24 @@ AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES= TRUSTED_PROXIES=
UPDATE_FEED_URL=https://git.floriansylvain.fr UPDATE_FEED_URL=https://git.floriansylvain.fr
RENEWCMS_CONTAINER=false 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
+123 -2
View File
@@ -10,6 +10,7 @@ vars:
env: env:
CGO_ENABLED: '0' CGO_ENABLED: '0'
__COMPAT_LAYER: RunAsInvoker
tasks: tasks:
default: default:
@@ -46,6 +47,127 @@ tasks:
cmds: cmds:
- go test -tags nofrontend ./test/... -count=1 {{.CLI_ARGS}} - 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: cover:
desc: Go test coverage of the use cases → tmp/coverage.out desc: Go test coverage of the use cases → tmp/coverage.out
cmds: cmds:
@@ -56,8 +178,7 @@ tasks:
mutate: mutate:
desc: Gremlins mutation testing of the use cases → tmp/gremlins.json desc: Gremlins mutation testing of the use cases → tmp/gremlins.json
cmds: cmds:
- mkdir -p tmp - bash scripts/mutate.sh
- gremlins unleash --integration --tags nofrontend --coverpkg=./internal/infrastructure/useCases/...,./api/middleware/...,./api/pkg/claims/... --timeout-coefficient 10 --output tmp/gremlins.json ./internal/infrastructure/useCases ./api/middleware ./api/pkg/claims
test:web: test:web:
desc: Run the SPA test suite (Vitest) in run mode desc: Run the SPA test suite (Vitest) in run mode
+235 -132
View File
@@ -3,23 +3,26 @@ package api
import ( import (
domainAPIKey "RenewCMS/internal/domain/apikey" domainAPIKey "RenewCMS/internal/domain/apikey"
domainArticle "RenewCMS/internal/domain/article" domainArticle "RenewCMS/internal/domain/article"
domainAudit "RenewCMS/internal/domain/audit"
domainImage "RenewCMS/internal/domain/image" domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite" domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail" domainMail "RenewCMS/internal/domain/mail"
domainPurge "RenewCMS/internal/domain/purge" domainPurge "RenewCMS/internal/domain/purge"
domainUpdate "RenewCMS/internal/domain/update" domainUpdate "RenewCMS/internal/domain/update"
domainUser "RenewCMS/internal/domain/user" domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/auditing"
"RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer" "RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/markdown" "RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/persistence/models" "RenewCMS/internal/infrastructure/persistence/database"
persistence "RenewCMS/internal/infrastructure/persistence/repositories" persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/scheduler" "RenewCMS/internal/infrastructure/scheduler"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/update" "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"context"
"crypto/ed25519" "crypto/ed25519"
"fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
@@ -28,57 +31,77 @@ import (
"sync" "sync"
"time" "time"
"github.com/glebarez/sqlite"
"github.com/go-chi/jwtauth/v5" "github.com/go-chi/jwtauth/v5"
"gorm.io/gorm"
) )
type UseCases struct { type UseCases struct {
apiKeyAuthOnce sync.Once apiKeyAuthOnce sync.Once
apiKeyAuthMiddleware func(http.Handler) http.Handler apiKeyAuthMiddleware func(http.Handler) http.Handler
Token *jwtauth.JWTAuth Token *jwtauth.JWTAuth
PurgeScheduler *scheduler.Runner PurgeScheduler *scheduler.Runner
CreateArticleUseCase *useCases.CreateArticleUseCase ActivityCleanupScheduler *scheduler.Runner
GetArticleUseCase *useCases.GetArticleUseCase CreateArticleUseCase *useCases.CreateArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase GetArticleUseCase *useCases.GetArticleUseCase
UpdateArticleUseCase *useCases.UpdateArticleUseCase ListArticlesUseCase *useCases.ListArticlesUseCase
DeleteArticleUseCase *useCases.DeleteArticleUseCase UpdateArticleUseCase *useCases.UpdateArticleUseCase
GetUserUseCase *useCases.GetUserUseCase DeleteArticleUseCase *useCases.DeleteArticleUseCase
CreateUserUseCase *useCases.CreateUserUseCase GetUserUseCase *useCases.GetUserUseCase
AuthenticateUserUseCase *useCases.AuthenticateUserUseCase CreateUserUseCase *useCases.CreateUserUseCase
VerifyUserUseCase *useCases.VerifyUserUseCase AuthenticateUserUseCase *useCases.AuthenticateUserUseCase
CancelRegistrationUseCase *useCases.CancelRegistrationUseCase VerifyUserUseCase *useCases.VerifyUserUseCase
RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase CancelRegistrationUseCase *useCases.CancelRegistrationUseCase
ResetPasswordUseCase *useCases.ResetPasswordUseCase RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase
UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase ResetPasswordUseCase *useCases.ResetPasswordUseCase
ListUsersUseCase *useCases.ListUsersUseCase UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase
CreateInviteUseCase *useCases.CreateInviteUseCase ListUsersUseCase *useCases.ListUsersUseCase
CancelInviteUseCase *useCases.CancelInviteUseCase CreateInviteUseCase *useCases.CreateInviteUseCase
RenewInviteUseCase *useCases.RenewInviteUseCase CancelInviteUseCase *useCases.CancelInviteUseCase
ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase RenewInviteUseCase *useCases.RenewInviteUseCase
GetInviteUseCase *useCases.GetInviteUseCase ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase
AcceptInviteUseCase *useCases.AcceptInviteUseCase GetInviteUseCase *useCases.GetInviteUseCase
SendMailUseCase *useCases.SendMailUseCase AcceptInviteUseCase *useCases.AcceptInviteUseCase
CreateImageUseCase *useCases.CreateImageUseCase SendMailUseCase *useCases.SendMailUseCase
ListMediaUseCase *useCases.ListMediaUseCase CreateImageUseCase *useCases.CreateImageUseCase
RenameMediaUseCase *useCases.RenameMediaUseCase OpenMediaUseCase *useCases.OpenMediaUseCase
DeleteMediaUseCase *useCases.DeleteMediaUseCase ListMediaUseCase *useCases.ListMediaUseCase
MergeMediaUseCase *useCases.MergeMediaUseCase RenameMediaUseCase *useCases.RenameMediaUseCase
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase DeleteMediaUseCase *useCases.DeleteMediaUseCase
GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase MergeMediaUseCase *useCases.MergeMediaUseCase
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase RunPurgeUseCase *useCases.RunPurgeUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase GetSettingsUseCase *useCases.GetSettingsUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
RequestRestartUseCase *useCases.RequestRestartUseCase CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase RequestRestartUseCase *useCases.RequestRestartUseCase
ListAPIKeysUseCase *useCases.ListAPIKeysUseCase RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase
AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase ListAPIKeysUseCase *useCases.ListAPIKeysUseCase
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase
AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase
ListActivityUseCase *useCases.ListActivityUseCase
GetActivitySummaryUseCase *useCases.GetActivitySummaryUseCase
ExportActivityUseCase *useCases.ExportActivityUseCase
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase
}
func (c *UseCases) RunSchedulers(ctx context.Context) {
var running sync.WaitGroup
for _, runner := range []*scheduler.Runner{c.PurgeScheduler, c.ActivityCleanupScheduler} {
if runner == nil {
continue
}
running.Add(1)
go func() {
defer running.Done()
runner.Run(ctx)
}()
}
running.Wait()
} }
type Repositories struct { type Repositories struct {
@@ -88,6 +111,8 @@ type Repositories struct {
InviteRepo domainInvite.Repository InviteRepo domainInvite.Repository
APIKeyRepo domainAPIKey.Repository APIKeyRepo domainAPIKey.Repository
PurgeSettingsRepo domainPurge.Repository PurgeSettingsRepo domainPurge.Repository
AuditRepo domainAudit.Repository
AuditSettingsRepo domainAudit.SettingsRepository
MailRepo domainMail.Repository MailRepo domainMail.Repository
ReleaseFeed domainUpdate.ReleaseFeed ReleaseFeed domainUpdate.ReleaseFeed
UpdateStore domainUpdate.StatusStore UpdateStore domainUpdate.StatusStore
@@ -103,7 +128,6 @@ type Repositories struct {
} }
const ( const (
sqliteBusyTimeoutMs = 5000
updateStatusFileName = "update-status.json" updateStatusFileName = "update-status.json"
updateJobFileName = "update-job.json" updateJobFileName = "update-job.json"
updateHelperFileName = "update-helper.json" updateHelperFileName = "update-helper.json"
@@ -111,32 +135,94 @@ const (
backupsDirName = "backups" backupsDirName = "backups"
) )
func SQLiteDSN(dbName string) string { func imageStorageConfig(settings config.Settings) storage.Config {
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs) 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 { func openImageStorage(settings config.Settings) domainImage.Storage {
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil { imageStorage, err := storage.Open(imageStorageConfig(settings))
panic("Unable to create necessary subdirectories: " + err.Error()) 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 { if err != nil {
panic("Unable to open the database: " + err.Error()) panic("Unable to open the database: " + err.Error())
} }
if err := database.Migrate(connection.DB); err != nil {
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}); err != nil {
panic("Failed to migrate database: " + err.Error()) panic("Failed to migrate database: " + err.Error())
} }
return connection
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
} }
func releasePublicKey() ed25519.PublicKey { func releasePublicKey() ed25519.PublicKey {
@@ -147,15 +233,18 @@ func releasePublicKey() ed25519.PublicKey {
return key return key
} }
func getRepositories(db *gorm.DB, settings config.Settings) *Repositories { func getRepositories(connection *database.Connection, settings config.Settings) *Repositories {
dataDir := filepath.Dir(settings.DBFile) db := connection.DB
dataDir := settings.DataDir()
return &Repositories{ return &Repositories{
ArticleRepo: persistence.NewArticleRepository(db), ArticleRepo: persistence.NewArticleRepository(db),
UserRepo: persistence.NewUserRepository(db), UserRepo: persistence.NewUserRepository(db),
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir), ImageRepo: persistence.NewImageRepository(db),
InviteRepo: persistence.NewInviteRepository(db), InviteRepo: persistence.NewInviteRepository(db),
APIKeyRepo: persistence.NewAPIKeyRepository(db), APIKeyRepo: persistence.NewAPIKeyRepository(db),
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db), PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
AuditRepo: persistence.NewAuditEventRepository(db),
AuditSettingsRepo: persistence.NewAuditSettingsRepository(db),
MailRepo: mailer.NewMailRepository(settings.Smtp), MailRepo: mailer.NewMailRepository(settings.Smtp),
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL), ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)), UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
@@ -164,70 +253,79 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL), AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL),
SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()), SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
StagedRunner: update.NewExecStagedBinaryRunner(), 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)), UpdateResults: update.NewJSONResultStore(filepath.Join(dataDir, domainUpdate.ResultFileName)),
RestartRequests: update.NewJSONRestartRequestStore(filepath.Join(dataDir, domainUpdate.RestartRequestFileName)), RestartRequests: update.NewJSONRestartRequestStore(filepath.Join(dataDir, domainUpdate.RestartRequestFileName)),
RunningInfo: update.NewJSONRunningInfoStore(filepath.Join(dataDir, domainUpdate.RunningInfoFileName)), 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() hasher := hashing.NewBcryptHasher()
renderer := markdown.NewConverter() renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator() keyGenerator := hashing.NewSHA256KeyGenerator()
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo) recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default())
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, recorder)
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default()) runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder)
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge) purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge, recorder)
activityCleanup := useCases.NewCleanupActivityUseCase(repos.AuditRepo, repos.AuditSettingsRepo, recorder)
return &UseCases{ return &UseCases{
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()), PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil), ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer), Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer), CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder),
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo), UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL), GetUserUseCase: useCases.NewGetUserUseCase(repos.UserRepo),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher), CreateUserUseCase: useCases.NewCreateUserUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher), AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(repos.UserRepo, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo), VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher, recorder),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL), CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now), RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder),
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo), UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL), ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo), CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL), CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo), RenewInviteUseCase: useCases.NewRenewInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL, recorder),
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher), ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher), GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo), SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo), CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage, recorder),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo), OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo), ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo),
MergeMediaUseCase: mergeMedia, RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo), DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, recorder),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo), MergeMediaUseCase: mergeMedia,
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo), GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
RunPurgeUseCase: runPurge, UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version), PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now), RunPurgeUseCase: runPurge,
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now), GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings), GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now), CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now, recorder),
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now), StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings, recorder),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now), RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now), RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now), CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now), ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
ListActivityUseCase: useCases.NewListActivityUseCase(repos.AuditRepo, repos.UserRepo, repos.ArticleRepo, repos.ImageRepo, repos.APIKeyRepo, time.Now),
GetActivitySummaryUseCase: useCases.NewGetActivitySummaryUseCase(repos.AuditRepo, time.Now),
ExportActivityUseCase: useCases.NewExportActivityUseCase(repos.AuditRepo, time.Now),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(repos.AuditSettingsRepo),
UpdateActivityRetentionUseCase: useCases.NewUpdateActivityRetentionUseCase(repos.AuditSettingsRepo, recorder),
} }
} }
func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings) *useCases.StartUpdateDownloadUseCase { func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings, recorder domainAudit.Recorder) *useCases.StartUpdateDownloadUseCase {
return useCases.NewStartUpdateDownloadUseCase(useCases.StartUpdateDownloadDependencies{ return useCases.NewStartUpdateDownloadUseCase(useCases.StartUpdateDownloadDependencies{
Store: repos.UpdateStore, Store: repos.UpdateStore,
Jobs: repos.UpdateJobs, Jobs: repos.UpdateJobs,
@@ -238,29 +336,34 @@ func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings) *
Verifier: repos.SignatureVerifier, Verifier: repos.SignatureVerifier,
Runner: repos.StagedRunner, Runner: repos.StagedRunner,
Backup: repos.DatabaseBackup, Backup: repos.DatabaseBackup,
Staging: update.NewOSStagingArea(filepath.Join(filepath.Dir(settings.DBFile), updatesDirName)), Staging: update.NewOSStagingArea(filepath.Join(settings.DataDir(), updatesDirName)),
Logger: log.Default(), Logger: log.Default(),
CurrentVersion: settings.Version, CurrentVersion: settings.Version,
Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH}, Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH},
FeedURL: settings.UpdateFeedURL, FeedURL: settings.UpdateFeedURL,
Now: time.Now, Now: time.Now,
Recorder: recorder,
}) })
} }
func InitContainer(settings config.Settings) (*UseCases, func() error) { func backfillImageMetadata(imageRepo domainImage.Repository, imageStorage domainImage.Storage, settings config.Settings) error {
db := getDb(settings.DBFile, settings.UploadDir) run := func(readable domainImage.Storage) error {
repos := getRepositories(db, settings) return useCases.NewBackfillImageMetadataUseCase(imageRepo, readable).Run()
return getUseCases(repos, settings), closeDb(db) }
if settings.Storage.Driver == config.StorageDriverS3 {
return storage.RunBackfillReportingReads(imageStorage, log.Default(), run)
}
return run(imageStorage)
} }
func closeDb(db *gorm.DB) func() error { func InitContainer(settings config.Settings) (*UseCases, func() error) {
return func() error { imageStorage := openImageStorage(settings)
sqlDb, err := db.DB() connection := openDatabase(settings)
if err != nil { repos := getRepositories(connection, settings)
return err if err := backfillImageMetadata(repos.ImageRepo, imageStorage, settings); err != nil {
} panic("Failed to backfill image metadata: " + err.Error())
return sqlDb.Close()
} }
return getUseCases(repos, imageStorage, settings), connection.Close
} }
type BrowserUpdateCommands struct { type BrowserUpdateCommands struct {
+243
View File
@@ -0,0 +1,243 @@
package activity
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"time"
)
const (
csvContentType = "text/csv; charset=utf-8"
csvContentDisposition = `attachment; filename="activity.csv"`
)
type Handler struct {
ListUseCase *useCases.ListActivityUseCase
SummaryUseCase *useCases.GetActivitySummaryUseCase
ExportUseCase *useCases.ExportActivityUseCase
}
type filterQuery struct {
Q string `validate:"max=200"`
Actor string `validate:"max=40"`
Category string `validate:"omitempty,oneof=articles media users api_keys auth system"`
Outcome string `validate:"omitempty,oneof=all success failed"`
Security string `validate:"omitempty,oneof=true"`
Range string `validate:"omitempty,oneof=today 7d 30d custom"`
From string `validate:"max=10"`
To string `validate:"max=10"`
}
type listQuery struct {
filterQuery
Page int `validate:"gte=1"`
Size int `validate:"oneof=25 50 100"`
}
type actorResponse struct {
Type audit.ActorType `json:"type"`
ID *uint32 `json:"id"`
Name string `json:"name"`
}
type targetResponse struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Exists bool `json:"exists"`
}
type itemResponse struct {
ID uint64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Action string `json:"action"`
Category audit.Category `json:"category"`
Actor actorResponse `json:"actor"`
Target *targetResponse `json:"target"`
Outcome audit.Outcome `json:"outcome"`
FailureReason *string `json:"failure_reason"`
Security bool `json:"security"`
Source audit.Source `json:"source"`
IP *string `json:"ip"`
UserAgent *string `json:"user_agent"`
Params map[string]any `json:"params"`
Changes []audit.Change `json:"changes"`
}
type listResponse struct {
Items []itemResponse `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
func NewHandler(list *useCases.ListActivityUseCase, summary *useCases.GetActivitySummaryUseCase, export *useCases.ExportActivityUseCase) *Handler {
return &Handler{ListUseCase: list, SummaryUseCase: summary, ExportUseCase: export}
}
func readFilterQuery(values url.Values) filterQuery {
return filterQuery{
Q: values.Get("q"),
Actor: values.Get("actor"),
Category: values.Get("category"),
Outcome: values.Get("outcome"),
Security: values.Get("security"),
Range: values.Get("range"),
From: values.Get("from"),
To: values.Get("to"),
}
}
func readListQuery(values url.Values) (listQuery, error) {
page, err := intParam(values, "page", 1)
if err != nil {
return listQuery{}, err
}
size, err := intParam(values, "size", 25)
if err != nil {
return listQuery{}, err
}
return listQuery{filterQuery: readFilterQuery(values), Page: page, Size: size}, nil
}
func intParam(values url.Values, name string, fallback int) (int, error) {
if !values.Has(name) {
return fallback, nil
}
return strconv.Atoi(values.Get(name))
}
func toActivityQuery(query filterQuery) useCases.ActivityQuery {
return useCases.ActivityQuery{
Q: query.Q,
Actor: query.Actor,
Category: query.Category,
Outcome: query.Outcome,
Security: query.Security,
Range: query.Range,
From: query.From,
To: query.To,
}
}
func writeActivityError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, audit.ErrInvalidFilter):
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
case errors.Is(err, audit.ErrActorNotAllowed):
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
case errors.Is(err, user.ErrUserNotFound):
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
query, err := readListQuery(r.URL.Query())
if err != nil || validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
viewerId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
activityQuery := toActivityQuery(query.filterQuery)
activityQuery.Page, activityQuery.Size = query.Page, query.Size
page, err := h.ListUseCase.ListActivity(useCases.ListActivityCommand{ViewerID: viewerId, Query: activityQuery})
if err != nil {
writeActivityError(w, err)
return
}
items := make([]itemResponse, 0, len(page.Items))
for _, entry := range page.Items {
items = append(items, toItemResponse(entry))
}
writeJSON(w, listResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
}
func toItemResponse(entry useCases.ActivityEntry) itemResponse {
event := entry.Event
item := itemResponse{
ID: event.ID,
CreatedAt: event.CreatedAt,
Action: event.Action,
Category: event.Category,
Actor: actorResponse{Type: event.Actor.Type, ID: event.Actor.ID, Name: event.Actor.Name},
Outcome: event.Outcome,
FailureReason: event.FailureReason,
Security: event.Security,
Source: event.Source,
IP: event.IP,
UserAgent: event.UserAgent,
Params: event.Params,
Changes: event.Changes,
}
if item.Params == nil {
item.Params = map[string]any{}
}
if item.Changes == nil {
item.Changes = []audit.Change{}
}
if event.Target.Type != "" {
item.Target = &targetResponse{Type: event.Target.Type, ID: event.Target.ID, Name: event.Target.Name, Exists: entry.TargetExists}
}
return item
}
func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) {
query := readFilterQuery(r.URL.Query())
if validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
summary, err := h.SummaryUseCase.GetActivitySummary(toActivityQuery(query))
if err != nil {
writeActivityError(w, err)
return
}
writeJSON(w, summary)
}
func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
query := readFilterQuery(r.URL.Query())
if validator.Validate.Struct(query) != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
prepared, err := h.ExportUseCase.Prepare(toActivityQuery(query))
if err != nil {
writeActivityError(w, err)
return
}
w.Header().Set("Content-Type", csvContentType)
w.Header().Set("Content-Disposition", csvContentDisposition)
w.Header().Set("Cache-Control", "no-store")
_ = prepared.WriteCSV(w)
}
func writeJSON(w http.ResponseWriter, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
}
+2 -2
View File
@@ -71,7 +71,7 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
created, err := h.CreateUseCase.CreateAPIKey(useCases.CreateAPIKeyCommand{ created, err := h.CreateUseCase.CreateAPIKey(r.Context(), useCases.CreateAPIKeyCommand{
UserID: userId, UserID: userId,
Name: request.Name, Name: request.Name,
ExpiresAt: request.ExpiresAt, ExpiresAt: request.ExpiresAt,
@@ -161,7 +161,7 @@ func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.RevokeUseCase.RevokeAPIKey(userId, uint32(id)); err != nil { if err := h.RevokeUseCase.RevokeAPIKey(r.Context(), userId, uint32(id)); err != nil {
switch { switch {
case errors.Is(err, domain.ErrAPIKeyNotFound): case errors.Is(err, domain.ErrAPIKeyNotFound):
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound) http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
+4 -4
View File
@@ -149,7 +149,7 @@ func (h *Handler) PostArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
createdArticle, err := h.CreateUseCase.CreateArticle(useCases.CreateArticleCommand{ createdArticle, err := h.CreateUseCase.CreateArticle(r.Context(), useCases.CreateArticleCommand{
Title: localArticle.Title, Title: localArticle.Title,
Body: localArticle.Body, Body: localArticle.Body,
Format: format, Format: format,
@@ -201,7 +201,7 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
return return
} }
updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(id, isOnline, format) updatedArticle, err := h.UpdateUseCase.UpdateIsOnline(r.Context(), id, isOnline, format)
if err != nil { if err != nil {
writeArticleErrorResponse(w, err) writeArticleErrorResponse(w, err)
return return
@@ -237,7 +237,7 @@ func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
return return
} }
updatedArticle, err := h.UpdateUseCase.UpdateBody(id, localArticle.Body, format) updatedArticle, err := h.UpdateUseCase.UpdateBody(r.Context(), id, localArticle.Body, format)
if err != nil { if err != nil {
writeArticleErrorResponse(w, err) writeArticleErrorResponse(w, err)
return return
@@ -314,7 +314,7 @@ func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.DeleteUseCase.DeleteArticle(id); err != nil { if err := h.DeleteUseCase.DeleteArticle(r.Context(), id); err != nil {
if errors.Is(err, domain.ErrArticleNotFound) { if errors.Is(err, domain.ErrArticleNotFound) {
http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound) http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound)
return return
+1 -1
View File
@@ -67,7 +67,7 @@ func (h *AcceptInviteHandler) AcceptInvite(w http.ResponseWriter, r *http.Reques
return return
} }
createdUser, err := h.AcceptInviteUseCase.AcceptInvite(useCases.AcceptInviteCommand{ createdUser, err := h.AcceptInviteUseCase.AcceptInvite(r.Context(), useCases.AcceptInviteCommand{
Code: request.Code, Code: request.Code,
Username: request.Username, Username: request.Username,
Password: request.Password, Password: request.Password,
+4 -3
View File
@@ -87,7 +87,7 @@ func (h *LoginHandler) Login(w http.ResponseWriter, r *http.Request) {
return return
} }
dbUser, err := h.AuthenticateUseCase.Authenticate(credentials.Username, credentials.Password) dbUser, err := h.AuthenticateUseCase.Authenticate(r.Context(), credentials.Username, credentials.Password)
if err != nil { if err != nil {
http.Error(w, apperr.LogsErrorMessage, http.StatusForbidden) http.Error(w, apperr.LogsErrorMessage, http.StatusForbidden)
return return
@@ -113,7 +113,7 @@ func (h *LoginHandler) Register(w http.ResponseWriter, r *http.Request) {
return return
} }
createdUser, err := h.CreateUseCase.CreateUser(useCases.CreateUserCommand{ createdUser, err := h.CreateUseCase.CreateUser(r.Context(), useCases.CreateUserCommand{
Username: credentials.Username, Username: credentials.Username,
Password: credentials.Password, Password: credentials.Password,
Email: credentials.Email, Email: credentials.Email,
@@ -139,7 +139,8 @@ func (h *LoginHandler) RegistrationStatus(w http.ResponseWriter, _ *http.Request
_, _ = w.Write(body) _, _ = w.Write(body)
} }
func (h *LoginHandler) Logout(w http.ResponseWriter, _ *http.Request) { func (h *LoginHandler) Logout(w http.ResponseWriter, r *http.Request) {
h.AuthenticateUseCase.Logout(r.Context())
removeJwtCookie(w, h.IsProduction) removeJwtCookie(w, h.IsProduction)
message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"}) message, _ := json.Marshal(map[string]any{"message": "User logged out! HTTPonly jwt cookie deleted"})
_, _ = w.Write(message) _, _ = w.Write(message)
+2 -2
View File
@@ -46,7 +46,7 @@ func (h *PasswordResetHandler) RequestPasswordReset(w http.ResponseWriter, r *ht
return return
} }
_ = h.RequestPasswordResetUseCase.RequestPasswordReset(credentials.Email) _ = h.RequestPasswordResetUseCase.RequestPasswordReset(r.Context(), credentials.Email)
message, _ := json.Marshal(map[string]any{"message": apperr.PasswordResetRequestedMessage}) message, _ := json.Marshal(map[string]any{"message": apperr.PasswordResetRequestedMessage})
_, _ = w.Write(message) _, _ = w.Write(message)
@@ -66,7 +66,7 @@ func (h *PasswordResetHandler) ResetPassword(w http.ResponseWriter, r *http.Requ
return return
} }
err = h.ResetPasswordUseCase.ResetPassword(credentials.Email, credentials.Code, credentials.Password) err = h.ResetPasswordUseCase.ResetPassword(r.Context(), credentials.Email, credentials.Code, credentials.Password)
if err != nil { if err != nil {
if errors.Is(err, user.ErrInvalidPasswordResetCode) || errors.Is(err, user.ErrPasswordResetCodeExpired) { if errors.Is(err, user.ErrInvalidPasswordResetCode) || errors.Is(err, user.ErrPasswordResetCodeExpired) {
http.Error(w, apperr.PasswordResetFailedErrorMessage, http.StatusForbidden) http.Error(w, apperr.PasswordResetFailedErrorMessage, http.StatusForbidden)
+1 -1
View File
@@ -50,7 +50,7 @@ func (h *VerificationHandler) Verify(w http.ResponseWriter, r *http.Request) {
return return
} }
verifiedUser, err := h.VerifyUseCase.Verify(userId, credentials.Code) verifiedUser, err := h.VerifyUseCase.Verify(r.Context(), userId, credentials.Code)
if err != nil { if err != nil {
if errors.Is(err, user.ErrUserNotFound) { if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
+4 -4
View File
@@ -213,7 +213,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
} }
defer closeFile() defer closeFile()
created, err := h.CreateUseCase.CreateImage(input) created, err := h.CreateUseCase.CreateImage(r.Context(), input)
if err != nil { if err != nil {
upload.WriteCreateError(w, err) upload.WriteCreateError(w, err)
return return
@@ -247,7 +247,7 @@ func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) {
return return
} }
renamed, err := h.RenameUseCase.Rename(id, request.Filename) renamed, err := h.RenameUseCase.Rename(r.Context(), id, request.Filename)
if err != nil { if err != nil {
if errors.Is(err, domainImage.ErrInvalidFilename) { if errors.Is(err, domainImage.ErrInvalidFilename) {
http.Error(w, apperr.MediaFilenameErrorMessage, http.StatusBadRequest) http.Error(w, apperr.MediaFilenameErrorMessage, http.StatusBadRequest)
@@ -266,7 +266,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.DeleteUseCase.Delete(id); err != nil { if err := h.DeleteUseCase.Delete(r.Context(), id); err != nil {
writeImageError(w, err) writeImageError(w, err)
return return
} }
@@ -304,7 +304,7 @@ func (h *Handler) Merge(w http.ResponseWriter, r *http.Request) {
return return
} }
result, err := h.MergeUseCase.Merge(request.KeepID, request.MergeIDs) result, err := h.MergeUseCase.Merge(r.Context(), request.KeepID, request.MergeIDs)
if err != nil { if err != nil {
writeMergeError(w, err) writeMergeError(w, err)
return return
+1 -1
View File
@@ -154,7 +154,7 @@ func (h *Handler) RunPurge(w http.ResponseWriter, r *http.Request) {
} }
} }
result, err := h.RunPurgeUseCase.Run(request.Fingerprint) result, err := h.RunPurgeUseCase.Run(r.Context(), request.Fingerprint)
if err != nil { if err != nil {
writePurgeError(w, err) writePurgeError(w, err)
return return
+59 -4
View File
@@ -1,21 +1,76 @@
package settings package settings
import ( import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/useCases" "RenewCMS/internal/infrastructure/useCases"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"time"
) )
type Handler struct { type Handler struct {
GetSettingsUseCase *useCases.GetSettingsUseCase GetSettingsUseCase *useCases.GetSettingsUseCase
GetActivityRetentionUseCase *useCases.GetActivityRetentionUseCase
UpdateActivityRetentionUseCase *useCases.UpdateActivityRetentionUseCase
} }
func NewHandler(getSettings *useCases.GetSettingsUseCase) *Handler { type UpdateSettingsRequest struct {
return &Handler{GetSettingsUseCase: getSettings} ActivityRetentionDays *int `json:"activity_retention_days" validate:"required,oneof=0 30 90 365"`
}
type settingsResponse struct {
Version string `json:"version"`
ActivityRetentionDays int `json:"activity_retention_days"`
ActivityLastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
ActivityLastCleanupRemoved int `json:"activity_last_cleanup_removed"`
}
func NewHandler(getSettings *useCases.GetSettingsUseCase, getRetention *useCases.GetActivityRetentionUseCase, updateRetention *useCases.UpdateActivityRetentionUseCase) *Handler {
return &Handler{GetSettingsUseCase: getSettings, GetActivityRetentionUseCase: getRetention, UpdateActivityRetentionUseCase: updateRetention}
} }
func (h *Handler) GetSettings(w http.ResponseWriter, _ *http.Request) { func (h *Handler) GetSettings(w http.ResponseWriter, _ *http.Request) {
body, err := json.Marshal(h.GetSettingsUseCase.GetSettings()) retention, err := h.GetActivityRetentionUseCase.GetActivityRetention()
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.write(w, retention)
}
func (h *Handler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
var request UpdateSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
retention, err := h.UpdateActivityRetentionUseCase.UpdateActivityRetention(r.Context(), *request.ActivityRetentionDays)
if err != nil {
if errors.Is(err, audit.ErrInvalidRetention) {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.write(w, retention)
}
func (h *Handler) write(w http.ResponseWriter, retention audit.Settings) {
body, err := json.Marshal(settingsResponse{
Version: h.GetSettingsUseCase.GetSettings().Version,
ActivityRetentionDays: retention.RetentionDays,
ActivityLastCleanupAt: retention.LastCleanupAt,
ActivityLastCleanupRemoved: retention.LastCleanupRemoved,
})
if err != nil { if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
@@ -1,11 +1,13 @@
package staticimages package staticimages
import ( import (
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"io" "io"
"io/fs"
"net/http" "net/http"
"os"
"path" "path"
"strconv"
"strings" "strings"
"time" "time"
@@ -24,13 +26,13 @@ var contentTypeExtensions = map[string]string{
} }
type Handler struct { type Handler struct {
uploadDir fs.FS openMedia *useCases.OpenMediaUseCase
modTime time.Time modTime time.Time
} }
func NewHandler(uploadDir string) *Handler { func NewHandler(openMedia *useCases.OpenMediaUseCase) *Handler {
return &Handler{ return &Handler{
uploadDir: os.DirFS(uploadDir), openMedia: openMedia,
modTime: time.Now(), 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") w.Header().Set("X-Content-Type-Options", "nosniff")
cleaned := cleanPath(requestedPath(r)) cleaned := cleanPath(requestedPath(r))
file, stat, ok := h.openFile(cleaned) blob, err := h.openMedia.Open(cleaned)
if !ok { if errors.Is(err, image.ErrBlobNotFound) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
defer file.Close() if err != nil {
http.Error(w, "Unable to read the file", http.StatusInternalServerError)
content, ok := file.(io.ReadSeeker)
if !ok {
http.Error(w, "File not seekable", http.StatusInternalServerError)
return return
} }
defer blob.Content.Close()
contentType := contentTypeFor(cleaned) contentType := contentTypeFor(cleaned)
w.Header().Set("Content-Type", contentType) 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) 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 { func requestedPath(r *http.Request) string {
@@ -73,22 +84,6 @@ func cleanPath(p string) string {
return strings.TrimPrefix(path.Clean("/"+p), "/") 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 { func contentTypeFor(p string) string {
ext := path.Ext(p) ext := path.Ext(p)
if contentType, ok := contentTypeExtensions[ext]; ok { if contentType, ok := contentTypeExtensions[ext]; ok {
+4 -4
View File
@@ -47,7 +47,7 @@ func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) { func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
if _, err := h.CheckForUpdateUseCase.Check(); err != nil { if _, err := h.CheckForUpdateUseCase.Check(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
@@ -58,8 +58,8 @@ func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner)) writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
} }
func (h *Handler) Download(w http.ResponseWriter, _ *http.Request) { func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
status, err := h.StartUpdateDownloadUseCase.Start() status, err := h.StartUpdateDownloadUseCase.Start(r.Context())
if err != nil { if err != nil {
writeDownloadError(w, err) writeDownloadError(w, err)
return return
@@ -77,7 +77,7 @@ func (h *Handler) Restart(w http.ResponseWriter, r *http.Request) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return return
} }
status, err := h.RequestRestartUseCase.Request(userId, kind) status, err := h.RequestRestartUseCase.Request(r.Context(), userId, kind)
if err != nil { if err != nil {
writeRestartError(w, err) writeRestartError(w, err)
return return
+3 -3
View File
@@ -82,7 +82,7 @@ func (h *InviteHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
return return
} }
created, err := h.CreateInviteUseCase.CreateInvite(useCases.CreateInviteCommand{ created, err := h.CreateInviteUseCase.CreateInvite(r.Context(), useCases.CreateInviteCommand{
Email: request.Email, Email: request.Email,
InvitedBy: userId, InvitedBy: userId,
}) })
@@ -124,7 +124,7 @@ func (h *InviteHandler) CancelInvite(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.CancelInviteUseCase.CancelInvite(id); err != nil { if err := h.CancelInviteUseCase.CancelInvite(r.Context(), id); err != nil {
writeManageInviteError(w, err) writeManageInviteError(w, err)
return return
} }
@@ -144,7 +144,7 @@ func (h *InviteHandler) RenewInvite(w http.ResponseWriter, r *http.Request) {
return return
} }
renewed, err := h.RenewInviteUseCase.RenewInvite(useCases.RenewInviteCommand{ID: id, RenewedBy: userId}) renewed, err := h.RenewInviteUseCase.RenewInvite(r.Context(), useCases.RenewInviteCommand{ID: id, RenewedBy: userId})
if err != nil { if err != nil {
writeManageInviteError(w, err) writeManageInviteError(w, err)
return return
+1 -1
View File
@@ -46,7 +46,7 @@ func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
return return
} }
updatedUser, err := h.UpdateProfileUseCase.UpdateDisplayName(userId, *request.DisplayName) updatedUser, err := h.UpdateProfileUseCase.UpdateDisplayName(r.Context(), userId, *request.DisplayName)
if err != nil { if err != nil {
if errors.Is(err, user.ErrInvalidDisplayName) { if errors.Is(err, user.ErrInvalidDisplayName) {
http.Error(w, apperr.DisplayNameErrorMessage, http.StatusBadRequest) http.Error(w, apperr.DisplayNameErrorMessage, http.StatusBadRequest)
+8
View File
@@ -19,6 +19,8 @@ const bearerScheme = "bearer "
type apiKeyIdContextKey struct{} type apiKeyIdContextKey struct{}
type apiKeyNameContextKey struct{}
func bearerAPIKey(r *http.Request) (string, bool) { func bearerAPIKey(r *http.Request) (string, bool) {
header := r.Header.Get("Authorization") header := r.Header.Get("Authorization")
if len(header) < len(bearerScheme) || !strings.EqualFold(header[:len(bearerScheme)], bearerScheme) { if len(header) < len(bearerScheme) || !strings.EqualFold(header[:len(bearerScheme)], bearerScheme) {
@@ -34,6 +36,11 @@ func apiKeyIdFrom(r *http.Request) (uint32, bool) {
return id, ok return id, ok
} }
func apiKeyNameFrom(r *http.Request) string {
name, _ := r.Context().Value(apiKeyNameContextKey{}).(string)
return name
}
func RequireSession(next http.Handler) http.Handler { func RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, isKey := bearerAPIKey(r); isKey { if _, isKey := bearerAPIKey(r); isKey {
@@ -70,6 +77,7 @@ func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests in
ctx := jwtauth.NewContext(r.Context(), token, nil) ctx := jwtauth.NewContext(r.Context(), token, nil)
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID) ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
ctx = context.WithValue(ctx, apiKeyNameContextKey{}, authenticated.Key.Name)
limited.ServeHTTP(w, r.WithContext(ctx)) limited.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
+64
View File
@@ -0,0 +1,64 @@
package middleware
import (
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
)
func AuditContext(getUser *useCases.GetUserUseCase, trustedProxies []string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := audit.Origin{
Actor: auditActor(getUser, r),
Source: auditSource(r),
IP: clientIP(r, trustedProxies),
UserAgent: truncatedUserAgent(r),
}
next.ServeHTTP(w, r.WithContext(audit.WithOrigin(r.Context(), origin)))
})
}
}
func auditActor(getUser *useCases.GetUserUseCase, r *http.Request) audit.Actor {
if id, ok := apiKeyIdFrom(r); ok {
return audit.APIKeyActor(id, apiKeyNameFrom(r))
}
userId, ok := claims.UserId(r)
if !ok {
return audit.AnonymousActor("")
}
current, err := getUser.GetUser(userId)
if err != nil {
return audit.AnonymousActor("")
}
tokenVersion, ok := claims.TokenVersion(r)
if !ok || tokenVersion != current.TokenVersion {
return audit.AnonymousActor("")
}
return audit.UserActor(current)
}
func auditSource(r *http.Request) audit.Source {
if _, ok := apiKeyIdFrom(r); ok {
return audit.SourceAPIKey
}
return audit.SourceWeb
}
func clientIP(r *http.Request, trustedProxies []string) string {
realIP := r.Header.Get("X-Real-IP")
if realIP != "" && isTrustedProxy(r.RemoteAddr, trustedProxies) {
return realIP
}
return hostOf(r.RemoteAddr)
}
func truncatedUserAgent(r *http.Request) string {
runes := []rune(r.Header.Get("User-Agent"))
if len(runes) > audit.MaxUserAgentLength {
runes = runes[:audit.MaxUserAgentLength]
}
return string(runes)
}
+20
View File
@@ -0,0 +1,20 @@
package middleware
import (
"RenewCMS/api/pkg/apperr"
"net/http"
"github.com/go-chi/jwtauth/v5"
"github.com/lestrrat-go/jwx/v2/jwt"
)
func RequireSignedIn(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, _, err := jwtauth.FromContext(r.Context())
if err != nil || token == nil || jwt.Validate(token) != nil {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
+57 -14
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"RenewCMS/api/handlers/activity"
"RenewCMS/api/handlers/apikey" "RenewCMS/api/handlers/apikey"
"RenewCMS/api/handlers/article" "RenewCMS/api/handlers/article"
"RenewCMS/api/handlers/auth" "RenewCMS/api/handlers/auth"
@@ -46,9 +47,10 @@ func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
return c.apiKeyAuthMiddleware return c.apiKeyAuthMiddleware
} }
func NewArticleRouter(container *UseCases) http.Handler { func NewArticleRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase) articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
@@ -71,6 +73,8 @@ func NewArticleRouter(container *UseCases) http.Handler {
func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler { func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction) loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction) verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
@@ -124,6 +128,7 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
r.Use(jwtauth.Verifier(container.Token)) r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token)) r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
@@ -140,17 +145,51 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
return r return r
} }
func NewSettingsRouter(container *UseCases) http.Handler { func NewSettingsRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
settingsHandler := settings.NewHandler(container.GetSettingsUseCase) settingsHandler := settings.NewHandler(container.GetSettingsUseCase, container.GetActivityRetentionUseCase, container.UpdateActivityRetentionUseCase)
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", settingsHandler.GetSettings)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Patch("/", settingsHandler.UpdateSettings)
})
return r
}
func NewActivityRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
activityHandler := activity.NewHandler(container.ListActivityUseCase, container.GetActivitySummaryUseCase, container.ExportActivityUseCase)
r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token)) r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(jwtauth.Authenticator(container.Token)) r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/", settingsHandler.GetSettings) r.Get("/", activityHandler.List)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/summary", activityHandler.Summary)
r.Get("/export", activityHandler.Export)
})
return r return r
} }
@@ -161,13 +200,14 @@ const (
updateDownloadWindow = time.Minute updateDownloadWindow = time.Minute
) )
func NewSystemRouter(container *UseCases) http.Handler { func NewSystemRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase) updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase)
r.Use(middleware.RequireSession) r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token)) r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token)) r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
@@ -189,13 +229,14 @@ func NewSystemRouter(container *UseCases) http.Handler {
return r return r
} }
func NewAPIKeyRouter(container *UseCases) http.Handler { func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase) apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
r.Use(middleware.RequireSession) r.Use(middleware.RequireSession)
r.Use(jwtauth.Verifier(container.Token)) r.Use(jwtauth.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token)) r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
@@ -205,13 +246,14 @@ func NewAPIKeyRouter(container *UseCases) http.Handler {
return r return r
} }
func NewMediaRouter(container *UseCases) http.Handler { func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase) mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase)
r.Use(jwtauth.Verifier(container.Token)) r.Use(jwtauth.Verifier(container.Token))
r.Use(container.apiKeyAuth()) r.Use(container.apiKeyAuth())
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(jwtauth.Authenticator(container.Token)) r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
@@ -241,13 +283,14 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Get("/", GetHelloWorld) r.Get("/", GetHelloWorld)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(container.Token)) r.Use(jwtauth.Verifier(container.Token))
r.Mount("/article", NewArticleRouter(container)) r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies))
}) })
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container)) r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
r.Mount("/media", NewMediaRouter(container)) r.Mount("/activity", NewActivityRouter(container, settings.TrustedProxies))
r.Mount("/api-key", NewAPIKeyRouter(container)) r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
r.Mount("/system", NewSystemRouter(container)) r.Mount("/api-key", NewAPIKeyRouter(container, settings.TrustedProxies))
r.Mount("/system", NewSystemRouter(container, settings.TrustedProxies))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
return r return r
@@ -292,7 +335,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
apiRouter.Use(chimiddleware.GetHead) apiRouter.Use(chimiddleware.GetHead)
apiRouter.Get("/healthz", GetHealth) apiRouter.Get("/healthz", GetHealth)
apiRouter.Mount("/v1", backend) apiRouter.Mount("/v1", backend)
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(settings.UploadDir)) apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase))
apiRouter.Mount("/", frontend) apiRouter.Mount("/", frontend)
return apiRouter return apiRouter
+19 -5
View File
@@ -12,6 +12,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"slices"
"syscall" "syscall"
"time" "time"
@@ -44,7 +45,17 @@ var envDefaults = map[string]string{
"ENVIRONMENT": "production", "ENVIRONMENT": "production",
"PORT": "8080", "PORT": "8080",
"DB_FILE": "./data/renewcms.db", "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", "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_MAX_ATTEMPTS": "5",
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60", "AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
"TRUSTED_PROXIES": "", "TRUSTED_PROXIES": "",
@@ -81,7 +92,7 @@ func run() error {
validator.InitValidator() validator.InitValidator()
server := newServer(api.InitRoutes(container, settings), settings) server := newServer(api.InitRoutes(container, settings), settings)
return serveUntilStopped(server, settings, container.RecordRunningInfoUseCase.Record, container.PurgeScheduler.Run) return serveUntilStopped(server, settings, container.RecordRunningInfoUseCase.Record, container.RunSchedulers)
} }
func initEnvVariables() { func initEnvVariables() {
@@ -108,10 +119,13 @@ func applyEnvDefaults() {
} }
func ensureRequiredEnvVars() { func ensureRequiredEnvVars() {
for _, envVar := range requiredEnvVars { required := slices.Concat(
if _, isSet := os.LookupEnv(envVar); !isSet { requiredEnvVars,
panic(fmt.Sprintf("Environment variable %s is not set", envVar)) 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_MAX_ATTEMPTS=5
AUTH_RATE_LIMIT_WINDOW_SECONDS=60 AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES= 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
+56
View File
@@ -27,6 +27,62 @@ for example `https://cms.example/v1/article/`.
| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me | | Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me |
| Settings | GET /v1/settings | | Settings | GET /v1/settings |
## Activity log
The activity routes are session only: a key answers 403 "This action needs a real session." and a
key never reads the audit trail. Events are append-only; there is no `POST`, `PUT`, `PATCH` or
`DELETE` under `/v1/activity`.
| Route | Who | Purpose |
| --- | --- | --- |
| `GET /v1/activity` | Any verified user | Filtered, paged events. Owner and admin see every event; any other role is scoped to its own events, and `actor` set to someone else answers 403 "Only owners and admins can do this." |
| `GET /v1/activity/summary` | Owner, admin | Counts for the range, the previous period, active people and seven-bucket series. |
| `GET /v1/activity/export` | Owner, admin | The same filters as the list without `page` or `size`, as a `text/csv; charset=utf-8` attachment (`when,actor,action,target,outcome,source,ip`). Cells starting with `=`, `+`, `-` or `@` get a leading quote. |
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`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from
1) and `size` (25, 50 or 100). Any invalid value answers 400 with the body-mismatch message. Dates
are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
`GET /v1/settings` also returns `activity_retention_days` (30, 90, 365 or 0 for forever, default
90), `activity_last_cleanup_at` and `activity_last_cleanup_removed`. `PATCH /v1/settings` with
`{"activity_retention_days": 30}` is session only and owner only (403 "Only the owner can do this.");
any other value answers 400.
### What is recorded
Every real action is recorded by its use case, once, after the change was made (outcome `success`)
or after the use case refused it (outcome `failed`, with a plain `failure_reason`). Nothing is
recorded for a read (no `GET`, no list, no public article read, no status poll), for a request that
fails validation, or for a target that does not exist (404). A failed audit write is logged and
never changes the response or the change itself.
| Category | Actions |
| --- | --- |
| `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` |
| `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge` |
| `users` | `user.register`, `user.verify`, `user.update_profile`, `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` |
| `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` |
Target types: `article`, `image`, `user`, `invite`, `api_key` and `release` (id and name are the tag).
Only the fields `title`, `online`, `body`, `filename` and `display_name` appear in `changes`, with the
kinds `text` and `body`; a body edit carries a signed character count delta and never the text. A
change that leaves a value as it was (same body, same publish state, same file name, same display
name) records nothing. Params: `media.merge` and `media.purge` and `system.media_purge` carry
`merged` and `articles_updated`, plus `failed_groups` when a group failed (still outcome `success`);
`system.update_checked` carries `update_available`; `system.restart_requested` carries `kind` and,
for an update, `tag` (it has no target). `system.update_downloaded` is recorded when the job reaches
`ready` or `failed`, with the origin captured at request time; a refusal at the start is recorded
immediately. A failed registration or invite acceptance has an `anonymous` actor named by the
attempted username.
`failure_reason` is one fixed sentence per domain error, matched with `errors.Is`, at most 255
characters, for example "The title must be between 3 and 200 characters.", "That file name is not
valid.", "The merge request is not valid.", "A purge is already running.", "Registration is closed
on this site.", "That invitation has expired.", "The release feed could not be reached." or "The
update could not be downloaded.". Any other error becomes "Something went wrong on our side. Please
try again."; the text of the error never reaches the event, the API or the CSV. Passwords, hashes,
codes, article bodies, file bytes, storage paths and asset urls are never recorded.
Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts
`q`, `hash`, `cursor`, `filter`, `sort` and `limit`. `q`, `hash`, `cursor`, `filter`, `sort` and `limit`.
+51 -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. | | 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`. | | 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`. | | 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. | | 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. | | Entry point | `cmd/server/main.go` | Env/config, HTTP server, graceful shutdown. |
Nothing under `internal/` imports `api/`. Nothing under `internal/` imports `api/`.
@@ -53,9 +55,23 @@ The use cases `GetPurgeSettings`, `UpdatePurgeSettings`, `PreviewPurge` and `Run
| `GET /v1/media/purge-preview` | Groups, copies, distinct articles, the first 5 groups, `more` and the fingerprint. | | `GET /v1/media/purge-preview` | Groups, copies, distinct articles, the first 5 groups, `more` and the fingerprint. |
| `POST /v1/media/purge` | Runs the purge. Optional `fingerprint`; 409 when stale or when a run is in progress. | | `POST /v1/media/purge` | Runs the purge. Optional `fingerprint`; 409 when stale or when a run is in progress. |
### Activity log
`internal/domain/audit` holds the append-only `Event` (actor, target and failure-reason snapshots, outcome, security flag, source, ip, user agent, `params` and `changes`), the closed vocabularies (actor type, category, outcome, source, change kind), the `Filter`, `Summary` and `Settings` types, the `Repository` interface (`Append`, `List`, `Summary`, `DeleteOlderThan`: no way to edit or delete one event), the `SettingsRepository` (`Get`, `SaveRetention`, `SaveLastCleanup`; no row means 90 days) and the sentinels `ErrInvalidFilter`, `ErrInvalidRetention`, `ErrInvalidEvent` and `ErrActorNotAllowed`. The `Recorder` port is `Record(ctx, event)`; it returns nothing, so a caller can never fail on it.
`internal/infrastructure/auditing.Recorder` is the adapter. It fills a missing actor, source, ip and user agent from the `audit.Origin` carried by the request context, stamps `created_at` from the injected clock in UTC, bounds long names, drops an event outside the vocabulary, and recovers from any error or panic of the repository by writing one line to the process log (the action and the error only). `middleware.AuditContext` builds the origin after the credential middleware: a session gives a `user` actor and source `web`, an API key an `api_key` actor and source `api_key`, no valid credential an `anonymous` actor; the ip is the peer address, or `X-Real-IP` when the peer is a trusted proxy; the user agent is cut to 255 characters. Use cases that produce events (authenticate and logout, password reset request and completion, API key create and revoke, retention change, cleanup) take the context and an `audit.Recorder`; secrets (passwords, raw keys, reset codes, tokens and their hashes) are never put in an event or in its log line.
Persistence is `models.AuditEvent` (`audit_events`, indexes on `created_at`, `(actor_type, actor_id, created_at)` and `(category, created_at)`; `params` and `changes` are JSON text) and `models.AuditSettings` (one row), both in `AutoMigrate`. The queries use only portable SQL (`LOWER(...) LIKE ... ESCAPE '!'`, `COUNT(DISTINCT ...)`, `CASE` sums), so they run on SQLite, MySQL/MariaDB and PostgreSQL. The summary splits the range into seven equal buckets, oldest first.
The use cases `ListActivity`, `GetActivitySummary` and `ExportActivity` turn the raw query into a domain filter (`range` defaults to 7 days; `custom` needs two dates, `to` included), scope a caller who is not owner or admin to its own events, and resolve `target.exists` at read time. The export reads the log through `List` in pages of 500 and writes the CSV with `encoding/csv`. `/v1/activity` and the settings `PATCH` sit behind `RequireSession`, the verifier, the current-token and verified-user checks; summary and export also behind `RequireOwnerOrAdmin`, the settings `PATCH` behind `RequireOwner`.
`CleanupActivityUseCase.Tick(now)` follows the `PurgeScheduler` model. With a retention of 0 it never runs. Otherwise it is due once a day, at or after 03:00 in the location of `now` and when the last cleanup is before today's 03:00. It deletes events older than `now` minus the retention days (never newer than 24 hours), records the run (`activity_last_cleanup_at`, `activity_last_cleanup_removed`) even when nothing was removed, and only then records a `system.activity_cleanup` event when something was removed. A failing delete returns the error and leaves the last-cleanup fields as they were, so the next tick retries. `scheduler.Runner` now wraps a task with a label: `NewRunner` keeps the purge behaviour and log line, and `NewCleanupRunner` ticks the cleanup. `UseCases.RunSchedulers` runs both runners and `cmd/server/main.go` starts it with the server's signal context.
Producers: `ArticleChanges`, `TextChange` and `BodyChange` in `internal/domain/audit` build the change lists (the body change holds only a signed rune count delta), and `NewTarget` and `ReleaseTarget` build the target snapshots (types `article`, `image`, `user`, `invite`, `api_key`, `release`). The use cases create, edit, publish, unpublish and delete article, upload, rename, delete, merge and purge media, register, verify, update profile, create, cancel, renew and accept invite, check, download and restart take the request context first and an `audit.Recorder` last, and record exactly one event after the change or the refusal (the action catalogue is in `docs/api.md`). Events are never written by handlers, middleware or GORM hooks, and nothing is recorded for reads or for a target that does not exist. `useCases/auditFailures.go` holds the fixed table of failure sentences keyed by domain `Err...` sentinels (matched with `errors.Is`, each at most 255 characters; the restart action overrides `ErrJobInProgress`) with the generic fallback "Something went wrong on our side. Please try again."; raw error text is never stored. The manual purge runs the groups through the non-recording `MergeMediaUseCase.merge`, so only one `media.purge` event is written; `RunPurgeUseCase.Execute` is the non-recording run that `PurgeSchedulerUseCase.Tick(now)` uses to record one `system.media_purge` event with the system actor and `context.Background()`. `StartUpdateDownloadUseCase` hands `context.WithoutCancel(ctx)` to its job goroutine so the `system.update_downloaded` event, written when the job becomes `ready` or `failed`, keeps the origin of the request. `middleware.AuditContext` is mounted on the article, user, media and system routers (after the API key middleware where keys are accepted), so API key calls are attributed to the key.
### Image metadata backfill ### 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 ## Frontend ↔ backend
@@ -73,3 +89,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. - **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). - **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.
+72 -4
View File
@@ -24,25 +24,93 @@ 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. | | `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). | | `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. | | `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). |
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. | | `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). | | `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_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. | | `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). | | `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
| `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. | | `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. 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)). 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). 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).
## Activity log retention
The activity log needs no environment variable. Its retention period (30, 90, 365 days or forever, 90 by default) is a setting the owner changes with `PATCH /v1/settings`, and the daily cleanup runs at 03:00 in the server's time zone (`TZ`, or the host's zone).
## Adding a variable ## Adding a variable
1. Read it in the code where it is needed (infrastructure or `api/`, never `internal/domain`). 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. 3. Add it to [`.env.example`](../.env.example) and to the tables above.
Never hardcode a secret, and never commit `.env`. 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` | | `/healthz` | Liveness check, returns `200 ok` |
| `/*` | The Vue SPA (embedded), with fallback to `index.html` for client-side routes | | `/*` | 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). 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. 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 ## 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: Everything worth backing up is under the data directory. Take a consistent SQLite copy while the service is running:
```sh ```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` | | [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` | | [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` | | [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` | | [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` | | [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` | | [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 ## 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/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/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. - `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 ### 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). 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/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/request` | AuthShell | public | ResetRequestPage | POST /v1/auth/password-reset |
| `/register/reset/validate` | AuthShell | public | ResetConfirmPage | POST /v1/auth/password-reset/confirm | | `/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` | DashboardShell | verified | ArticlesPage | GET /v1/article/ · PATCH /{id}/publish\|unpublish · DELETE /{id} |
| `/article/new` | DashboardShell | verified | ArticlesPage + NewArticleModal | POST /v1/article/ | | `/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} | | `/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 | | `/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` | DashboardShell | verified | IntegrationPage | none |
| `/integration/api-keys` | DashboardShell | verified | ApiKeysPage + CreateApiKeyModal | GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} | | `/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 | | `/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 | | `/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* | | `/invite/accept` | AuthShell | public | AcceptInvitePage | GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept* |
| `/:pathMatch(.*)*` | either | any | NotFoundPage | none | | `/: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. **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" - 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) ### Components (Nuxt UI v4)
`UCard`, `USwitch`, `UInputNumber` (min 1, max 365, suffix), `UFormField`, `UAlert`, `UButton`, `UModal`, `USkeleton`, `useToast`. `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 | | Unattended run | Settings | No toast. Updated on next visit |
401 and 403 are global and have no local copy. 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 ```sh
task --list # everything available task --list # everything available
task test # Go tests; extra args pass through: task test -- -run TestCreateArticle -v 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 cover # coverage of the use cases → tmp/coverage.out
task mutate # gremlins mutation testing → tmp/gremlins.json task mutate # gremlins mutation testing → tmp/gremlins.json
task test:web # SPA component tests (Vitest) 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 ## 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` 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. - **`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 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 ## 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"`. 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"`.
+560
View File
@@ -0,0 +1,560 @@
Feature: The back-office keeps an append-only activity log that owners can read and retention can prune
As the owner of a RenewCMS site
I want every important action recorded with who, what, when and from where
So that I can review activity, spot failed logins and keep the log from growing forever
Background:
Given the spec of record is docs/design/screens/activity.md ("Roles", "API contract", "What is recorded") and docs/design/screens/settings.md ("Activity retention")
And this slice is backend only: no SPA, no new env var, no change to the design
And error responses are plain text from http.Error and success responses are JSON with snake_case fields
And the layering rule holds: internal/domain/audit imports only the stdlib and other domain packages, use cases see only domain interfaces, handlers see only use cases, persistence owns GORM, and no comment is added anywhere
And validation of query and body goes through validate tags and validator.Validate.Struct where a struct exists
And the error text for a malformed query or body is "The request cannot be processed due to a mismatch in the format of the body."
Rule: The domain event is a complete snapshot and the repository is append-only
Scenario: An event carries everything the log needs
Given an event is built for the action "article.publish" in category "articles"
Then it holds an actor snapshot (type, id, name), the action code and its params, the category, a target snapshot (type, id, name), an outcome, a failure_reason, a security flag, a source, an ip, a user_agent, changes and a created_at
Scenario Outline: The vocabulary is closed
Then the allowed <field> values are exactly <values>
Examples:
| field | values |
| actor.type | user, api_key, system, anonymous |
| category | articles, media, users, api_keys, auth, system |
| outcome | success, failed |
| source | web, api_key, system |
| change.kind | text, body |
Scenario: The repository exposes no way to edit or delete one event
Then the audit Repository interface has exactly the methods Append, List, Summary and DeleteOlderThan
And the persistence implementation exposes no other exported method than those and its constructor
And the HTTP surface has no POST, PUT, PATCH or DELETE under /v1/activity
Scenario: The sentinel errors are matchable
Then the audit package declares Err sentinels for an invalid filter and an invalid retention period
And each is asserted with errors.Is
Rule: The recorder never fails or blocks the caller
Scenario: A failing repository does not change the caller's result
Given the audit repository fails on Append with the error "disk full"
When a user logs in with valid credentials through POST /v1/auth/login
Then the response is 200 with a jwt cookie and the usual JSON message
And the process log contains a line naming the failed audit write and "disk full"
Scenario: A panicking repository does not crash the request
Given the audit repository panics on Append
When a user logs out through POST /v1/auth/logout
Then the response is 200 and the process continues serving
Scenario: The recorder fills the event from the audit context
Given a context holding actor user 3 "marta", source "web", ip "203.0.113.12" and user agent "Mozilla/5.0 test"
When the recorder records an event that has no actor, source, ip or user agent
Then the stored event has those values
And created_at is the injected clock's time in UTC
Scenario: Secrets never reach the log or the stored event
Given actions that handle a password, a raw API key, a reset code and a jwt
When they are recorded
Then no stored field, no params and no process log line contains any of those values or their hashes
Rule: The audit context is set by middleware from the credentials and the trusted-proxy IP
Scenario: A session sets a user actor with source web
Given a request with a valid jwt cookie for user 3 "marta"
When it passes the audit context middleware
Then the context holds actor type "user", id 3, name "marta" and source "web"
Scenario: An API key sets an api_key actor with source api_key
Given a request with "Authorization: Bearer <valid key>" for the key 7 named "Deploy bot"
When it passes the audit context middleware
Then the context holds actor type "api_key", id 7, name "Deploy bot" and source "api_key"
Scenario: No credentials sets an anonymous actor
Given a request without a cookie or key
Then the context holds actor type "anonymous" and source "web"
Scenario Outline: The ip comes from the trusted-proxy real IP only
Given the trusted proxies are "10.0.0.5"
And the request peer is "<peer>" and X-Real-IP is "<header>"
Then the context ip is "<ip>"
Examples:
| peer | header | ip |
| 10.0.0.5:443 | 203.0.113.12 | 203.0.113.12 |
| 198.51.100.9:443 | 203.0.113.12 | 198.51.100.9 |
| 198.51.100.9:443 | | 198.51.100.9 |
Scenario: The user agent is stored as sent and bounded
Given the User-Agent header is 400 characters long
Then the context user agent is its first 255 characters
Rule: Persistence stores events portably and indexed
Background:
Given the persistence tests run against a real in-memory SQLite database migrated with the model list of migrate.go
And the SQL stays portable across SQLite, MySQL/MariaDB and PostgreSQL
Scenario: The model is migrated with its indexes
Then the audit events table exists
And it has an index on created_at, an index on (actor_type, actor_id, created_at) and an index on (category, created_at)
Scenario: An appended event round-trips
When an event with params {"merged": 3}, changes and a null failure_reason is appended
Then listing returns it with every field equal, params and changes included
Scenario: List sorts by created_at descending then id descending
Given three events with the same created_at and one older
Then List returns the three by id descending, then the older one
Scenario Outline: Filters narrow the list and the total
Given events across actors, categories, outcomes and days
When List runs with <filter>
Then items and total contain exactly <expected>
Examples:
| filter | expected |
| actor user:3 | events of user 3 only |
| actor key:7 | events of api key 7 only |
| actor system | events with actor type system only |
| category auth | auth events only |
| outcome failed | failed events only |
| outcome success | succeeded events only |
| security true | events whose security flag is true |
| q "SPRING" | events whose actor or target name contains "spring", case-insensitive |
| q "50%_" | only names containing the literal text 50%_ |
| from 2026-10-01 to 2026-10-03 | events from 2026-10-01T00:00:00Z to the end of 2026-10-03 |
Scenario: Paging slices the sorted list and total is the unpaged count
Given 60 events
When List runs with page 2 and size 25
Then 25 items are returned, the 26th to 50th of the sorted list, and total is 60
Scenario: A page past the end is empty
When List runs with page 9 and size 25 over 60 events
Then items is empty and total is 60
Scenario: The summary counts the range and the previous period
Given events in the last 7 days and in the 7 days before
When Summary runs for range 7d
Then total, previous_total, failed and security are the counts of those periods
And active_people.users counts distinct user actors and active_people.api_keys counts distinct key actors, with anonymous and system excluded
And series holds seven daily counts for total, active, failed and security, oldest first
Scenario: DeleteOlderThan removes only events before the cutoff
Given events at 100, 40, 31, 29 and 1 days ago
When DeleteOlderThan runs with the cutoff 30 days ago
Then it returns 3 and only the 29-day and 1-day events remain
Rule: Reading the log is session only and scoped by role
Background:
Given the handler is mounted at /v1/activity behind jwtauth.Verifier, jwtauth.Authenticator, the current-token check and the verified-user check
And the routes sit behind RequireSession, so no API key reaches them
Scenario Outline: The credentials gate answers before any data
When GET <path> is sent <credentials>
Then the response is <status> with body "<body>"
And the audit repository was not read
Examples:
| path | credentials | status | body |
| /v1/activity | without a jwt cookie | 401 | You must be logged in to access this resource. |
| /v1/activity/summary | without a jwt cookie | 401 | You must be logged in to access this resource. |
| /v1/activity/export | without a jwt cookie | 401 | You must be logged in to access this resource. |
| /v1/activity | with a valid API key | 403 | This action needs a real session. |
| /v1/activity/summary | with a valid API key | 403 | This action needs a real session. |
| /v1/activity/export | with a valid API key | 403 | This action needs a real session. |
| /v1/activity | with an unverified user's cookie | 403 | You must verify your account to access this resource. |
Scenario Outline: Owner and admin see every event
Given events by users 1, 2 and 3, an api key and the system
When GET /v1/activity is sent with the cookie of the <role>
Then the response is 200 and total counts the events of every actor
Examples:
| role |
| owner |
| admin |
Scenario: Another role is scoped to its own events
Given a verified user 3 whose role is neither owner nor admin
And events by users 1, 2 and 3
When GET /v1/activity is sent with user 3's cookie
Then every item has actor type "user" and id 3
And total counts only user 3's events
Scenario Outline: A scoped caller cannot ask for another actor
Given a verified user 3 whose role is neither owner nor admin
When GET /v1/activity?actor=<actor> is sent with user 3's cookie
Then the response is <status>
Examples:
| actor | status |
| user:3 | 200 |
| user:2 | 403 |
| key:7 | 403 |
| system | 403 |
Scenario Outline: A scoped caller cannot read the summary or the export
Given a verified user 3 whose role is neither owner nor admin
When GET <path> is sent with user 3's cookie
Then the response is 403 with body "Only owners and admins can do this."
Examples:
| path |
| /v1/activity/summary |
| /v1/activity/export |
Rule: GET /v1/activity lists filtered, paged events
Scenario: The default request
Given 30 events over the last 3 days
When the owner sends GET /v1/activity
Then the response is 200 with {"items": [...], "total": 30, "page": 1, "size": 25}
And items has 25 entries, newest first
Scenario: An item has the documented shape
Given user 3 "Marta Alves" published the article 8 "Spring release" at 2026-10-06T10:42:18Z from 203.0.113.12 with "Chrome 129 on Windows"
When the owner sends GET /v1/activity
Then the first item is
"""
{"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", "params": {}, "changes": [{"field": "online", "kind": "text", "before": "false", "after": "true"}]}
"""
Scenario: A system event has no ip or user agent
Given a system event "system.activity_cleanup"
When the owner sends GET /v1/activity
Then its item has actor {"type": "system", "id": null, "name": "System"}, source "system", ip null and user_agent null
Scenario: A renamed or deleted user still reads correctly
Given an event recorded when user 3 was named "Marta Alves"
And user 3 has since been renamed "M. Alves" and then deleted
When the owner sends GET /v1/activity
Then the item's actor.name is still "Marta Alves"
Scenario Outline: target.exists is resolved when the list is read
Given an event whose target is <type> "<id>" named "<name>"
And <state>
When the owner sends GET /v1/activity
Then the item's target.name is "<name>" and target.exists is <exists>
Examples:
| type | id | name | state | exists |
| article | 8 | Spring release | the article still exists | true |
| article | 8 | Spring release | the article was deleted | false |
| user | 3 | Marta Alves | the user was deleted | false |
Scenario: A body change carries a delta and never text
Given an event with a body change of +312 characters
Then its change is {"field": "body", "kind": "body", "delta": 312} with no before and no after
Scenario Outline: The filters are applied through the query
When the owner sends GET /v1/activity?<query>
Then the response is 200 and items match <expected>
Examples:
| query | expected |
| q=spring | the actor or target name contains spring |
| actor=user:3 | events of user 3 |
| actor=key:7 | events of api key 7 |
| actor=system | system events |
| category=auth | auth events |
| outcome=failed | failed events |
| outcome=success | succeeded events |
| outcome=all | both outcomes |
| security=true | events with the security flag |
| range=today | events since 00:00 UTC today |
| range=7d | events of the last 7 days |
| range=30d | events of the last 30 days |
| range=custom&from=2026-09-01&to=2026-09-30 | events from 1 to 30 September 2026 UTC |
| page=2&size=50 | the second page of 50 |
Scenario: Omitting range means 7 days
Given events 2 days old and 10 days old
When the owner sends GET /v1/activity
Then only the 2-day-old event is listed
Scenario Outline: Invalid values answer 400
When the owner sends GET /v1/activity?<query>
Then the response is 400 with body "The request cannot be processed due to a mismatch in the format of the body."
And the audit repository was not read
Examples:
| query |
| actor=user:abc |
| actor=user:-1 |
| actor=group:3 |
| category=billing |
| outcome=maybe |
| security=false |
| range=year |
| range=custom |
| range=custom&from=2026-09-30&to=2026-09-01 |
| range=custom&from=yesterday&to=today |
| page=0 |
| page=abc |
| size=10 |
| size=200 |
Rule: GET /v1/activity/summary answers the strip
Scenario: The summary of the last seven days
Given 12 events in the last 7 days, 3 failed, 2 with the security flag, by 2 users and 1 api key
And 10 events in the 7 days before
When the owner sends GET /v1/activity/summary?range=7d
Then the response is 200 and the body is
"""
{"total": 12, "previous_total": 10, "active_people": {"users": 2, "api_keys": 1}, "failed": 3, "security": 2, "series": {"total": [...7 numbers], "active": [...7], "failed": [...7], "security": [...7]}}
"""
And each series array has 7 entries, oldest first, and the total series sums to 12
Scenario: An empty log answers zeros
Given no events
When the owner sends GET /v1/activity/summary
Then every number is 0 and every series array is seven zeros
Scenario: The summary uses the same filters as the range
When the owner sends GET /v1/activity/summary?range=custom&from=2026-09-01&to=2026-09-07
Then the counts cover exactly that range and previous_total covers the 7 days immediately before 2026-09-01
Scenario: An invalid range answers 400
When the owner sends GET /v1/activity/summary?range=year
Then the response is 400 with the body-mismatch message
Rule: GET /v1/activity/export streams a safe CSV
Scenario: The response is a CSV attachment
Given 3 events
When the owner sends GET /v1/activity/export
Then the response is 200 with Content-Type "text/csv; charset=utf-8" and Content-Disposition starting with "attachment"
And the first row is "when,actor,action,target,outcome,source,ip"
And there are 3 data rows, newest first
Scenario: A row holds the documented columns only
Given user 3 "Marta Alves" published "Spring release" at 2026-10-06T10:42:18Z from 203.0.113.12 with changes and a user agent
Then the row is "2026-10-06T10:42:18Z,Marta Alves,article.publish,Spring release,success,web,203.0.113.12"
And the file contains no changes and no user agent
Scenario Outline: Cells that a spreadsheet would evaluate are neutralised
Given an event whose <column> is "<value>"
Then the cell is "<cell>"
Examples:
| column | value | cell |
| actor | =cmd | '=cmd |
| target | +1+1 | '+1+1 |
| target | -2 | '-2 |
| actor | @SUM(A1) | '@SUM(A1) |
| target | Spring release | Spring release |
| target | a=b | a=b |
Scenario: Commas, quotes and newlines are quoted
Given a target whose name contains a comma, double quotes and a newline
Then the cell is quoted with doubled inner quotes and the file still has one row for the event
Scenario: The export uses the list filters without paging
Given 60 events of which 8 failed
When the owner sends GET /v1/activity/export?outcome=failed&page=2&size=25
Then the file has the 8 failed rows and page and size are ignored
Scenario: A system event has an empty ip cell
Given a system event
Then its ip cell is empty
Scenario: An invalid filter answers 400 before any byte is streamed
When the owner sends GET /v1/activity/export?outcome=maybe
Then the response is 400 with the body-mismatch message and no CSV header
Scenario: A large export is read in batches
Given 1200 events
When the owner exports them
Then the file has 1200 rows and the repository was read through List in pages, not all at once
Rule: The retention period is a setting only the owner can change
Scenario: The default is 90 days
Given no retention row exists
When a verified user sends GET /v1/settings
Then the body has "activity_retention_days": 90, "activity_last_cleanup_at": null and "activity_last_cleanup_removed": 0
And the existing fields of the response are unchanged
Scenario: The last cleanup is reported
Given a cleanup ran at 2026-10-06T03:00:00Z and removed 142 events
When a verified user sends GET /v1/settings
Then "activity_last_cleanup_at" is "2026-10-06T03:00:00Z" and "activity_last_cleanup_removed" is 142
Scenario Outline: The owner sets a valid period
When the owner sends PATCH /v1/settings with body {"activity_retention_days": <days>}
Then the response is 200 and the body has "activity_retention_days": <days>
And a following GET /v1/settings returns <days>
Examples:
| days |
| 30 |
| 90 |
| 365 |
| 0 |
Scenario Outline: Any other value answers 400 and changes nothing
Given the stored retention is 90
When the owner sends PATCH /v1/settings with body <body>
Then the response is 400 with body "The request cannot be processed due to a mismatch in the format of the body."
And the stored retention is still 90
Examples:
| body |
| {"activity_retention_days": 7} |
| {"activity_retention_days": -1} |
| {"activity_retention_days": 366} |
| {"activity_retention_days": 30.5} |
| {"activity_retention_days": "30"} |
| {"activity_retention_days": null} |
| {} |
| not json |
Scenario: An admin cannot change it
When an admin sends PATCH /v1/settings with body {"activity_retention_days": 30}
Then the response is 403 with body "Only the owner can do this."
And the stored retention is unchanged
Scenario Outline: The gate answers before the body is read
When PATCH /v1/settings is sent <credentials> with body {"activity_retention_days": 30}
Then the response is <status> with body "<body>"
Examples:
| credentials | status | body |
| without a jwt cookie | 401 | You must be logged in to access this resource. |
| with an owner's API key | 403 | This action needs a real session. |
| with an unverified owner | 403 | You must verify your account to access this resource. |
Scenario: A change is recorded as a system event by the owner
Given the stored retention is 90
When the owner sends PATCH /v1/settings with body {"activity_retention_days": 30}
Then one event exists with action "system.activity_retention_changed", category "system", actor the owner, source "web", outcome "success", security false and params {"from": 90, "to": 30}
Scenario: Setting the same value records nothing
Given the stored retention is 90
When the owner sends PATCH /v1/settings with body {"activity_retention_days": 90}
Then the response is 200 and no event is recorded
Rule: A daily cleanup removes events past the retention period
Background:
Given the cleanup is a use case with a Tick(now) like PurgeScheduler, driven by the scheduler runner with hand-fed channels and an injected clock
And the server time zone is the injected clock's location
Scenario Outline: The cleanup is due once a day at or after 03:00
Given the retention is 90 and the last cleanup was at <last>
When Tick runs at <now>
Then ran is <ran>
Examples:
| last | now | ran |
| never | 2026-10-06T02:59:00 | false |
| never | 2026-10-06T03:00:00 | true |
| never | 2026-10-06T14:00:00 | true |
| 2026-10-05T03:00:00 | 2026-10-06T02:59:00 | false |
| 2026-10-05T03:00:00 | 2026-10-06T03:00:00 | true |
| 2026-10-06T03:00:00 | 2026-10-06T04:00:00 | false |
| 2026-10-06T03:00:00 | 2026-10-07T03:00:00 | true |
Scenario: Forever never runs
Given the retention is 0
When Tick runs at 2026-10-06T03:00:00
Then ran is false and DeleteOlderThan is not called
Scenario: The cutoff is now minus the retention days
Given the retention is 30 and events at 40, 31, 29 and 1 days ago
When the cleanup runs at 2026-10-06T03:00:00
Then the events 31 and 40 days old are removed and the others remain
And the last cleanup time is the clock's time and the removed count is 2
Scenario: The last 24 hours are never removed
Given the cleanup is asked for a cutoff newer than 24 hours ago
Then the effective cutoff is 24 hours before now
Scenario: A cleanup that removes nothing still records the run
Given the retention is 90 and no event is older than 90 days
When the cleanup runs
Then the last cleanup time is the clock's time, the removed count is 0 and no system.activity_cleanup event is recorded
Scenario: A cleanup that removes events records a system event after deleting
Given 142 events older than the retention
When the cleanup runs
Then one event exists with action "system.activity_cleanup", category "system", actor type "system", source "system", ip null, user_agent null, outcome "success" and params {"removed": 142}
And that event is not among the removed ones
Scenario: A failing delete is retried at the next tick
Given DeleteOlderThan fails
When Tick runs
Then the error is returned and logged, the last cleanup fields are unchanged and the next tick runs the cleanup again
Scenario: A shortened period applies at the next cleanup
Given the retention was 90 and the owner sets 30
Then no event is removed by the change itself
And the next cleanup removes events older than 30 days
Scenario: The scheduler stops with the process
When the context is cancelled
Then the runner returns and no goroutine outlives it
Rule: The first producers record the right events
Scenario Outline: Authentication events
When <trigger>
Then one event exists with action "<action>", category "auth", <actor>, source "web", outcome "<outcome>", security <security>, failure_reason <reason> and the request ip and user agent
Examples:
| trigger | action | actor | outcome | security | reason |
| POST /v1/auth/login with the right username and password | auth.login | actor user with the account's name | success | false | null |
| POST /v1/auth/login with the right username and a wrong password | auth.login_failed | actor anonymous named by the attempted username | failed | true | "Wrong username or password." |
| POST /v1/auth/login with an unknown username | auth.login_failed | actor anonymous named by the attempted username | failed | true | "Wrong username or password." |
| POST /v1/auth/logout with a valid jwt cookie | auth.logout | actor user | success | false | null |
| POST /v1/auth/password-reset for an existing account | auth.password_reset_requested | actor user | success | true | null |
| POST /v1/auth/password-reset/confirm with a valid code | auth.password_reset_completed | actor user | success | true | null |
Scenario: A failed login reveals no more than the response does
Given a wrong password for an existing user and an unknown username
Then both events have the same failure_reason, actor type anonymous and no target
And neither event holds the password
Scenario Outline: Nothing is recorded when nothing happened
When <trigger>
Then no event is recorded and the response is unchanged
Examples:
| trigger |
| POST /v1/auth/logout without a jwt cookie |
| POST /v1/auth/password-reset for an unknown email |
| POST /v1/auth/password-reset/confirm with an invalid code |
| POST /v1/auth/login with a body that fails validation |
| POST /v1/auth/login answered 429 by the rate limiter |
Scenario: A reset event never holds the code
When a password reset is requested and completed
Then neither event nor the process log contains the raw code, its hash or the new password
Scenario: Creating an API key is recorded
When the owner sends POST /v1/api-key with a name "Deploy bot"
Then one event exists with action "api_key.create", category "api_keys", actor the owner, source "web", target {"type": "api_key", "id": "<new id>", "name": "Deploy bot"}, outcome "success" and security false
And no stored field and no response of the activity API contains the raw key or its hash
Scenario: Revoking an API key is recorded
Given the API key 7 named "Deploy bot"
When the owner sends DELETE /v1/api-key/7
Then one event exists with action "api_key.revoke", category "api_keys", actor the owner, target {"type": "api_key", "id": "7", "name": "Deploy bot"} and outcome "success"
Scenario: A revoke of an unknown key records nothing
When the owner sends DELETE /v1/api-key/99
Then the response is 404 and no event is recorded
Scenario: The recorded events are readable through the API
Given a failed login, an API key creation and a retention change were recorded
When the owner sends GET /v1/activity
Then the three events are listed newest first with their documented items
And GET /v1/activity?security=true lists only the failed login
+72
View File
@@ -0,0 +1,72 @@
# QA procedure: activity-log-backend
Go only, no SPA. Package `test` (black-box), new files `test/activity_log_domain_test.go`, `test/activity_log_persistence_test.go`, `test/activity_log_http_test.go` and `test/activity_cleanup_test.go`. Fake and helper type names must be unique across the package. No live server, no real SMTP, no real SQLite file, no real sleeping, no `time.Ticker`. No env var is added. Never put a secret value in a test name or log assertion helper beyond the fixed test strings.
## Setup shared by every case
1. `validator.InitValidator()` once. `jwtauth.New("HS256", []byte("test-secret"), nil)` standing in for `JWT_SECRET`.
2. Fakes: `fakeAuditClock` (mutable, `Now()` and `Set(t)`, UTC), `mockAuditRepo` (in-memory slice, `Append`, `List`, `Summary`, `DeleteOlderThan`, failure and panic switches, call counters per method), existing `mockUserRepo` extended with users: owner 1, admin 2, a verified user 3 with role `"editor"` (domain has only owner and admin, so the role is a plain string in the fake), an unverified user 4. Fake API key repo with key 7 "Deploy bot" and a raw key built with `apikey.RawKeyPrefix`.
3. Router: a fresh `chi.NewRouter()` mirroring `api/router.go`. Mount `/activity` (RequireSession, `jwtauth.Verifier`, `jwtauth.Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, then the audit context middleware), `/settings` with the new PATCH, `/api-key` and `/auth` over the real use cases and the fakes. `httptest.NewRecorder` per request. Cookies are real `jwt` cookies signed with the test token carrying `user_id` and `token_version`.
4. Capture the standard log with `log.SetOutput(&buf)` and restore with `t.Cleanup`. No parallel tests except where `-race` is stated.
5. Seed helper `seedEvents(repo, n, spec)` building events with explicit `created_at` from the clock.
## A. Domain (no HTTP)
6. Vocabulary table: for each of actor type, category, outcome, source and change kind, the exported constants equal the allowed set from the feature; an invalid value is rejected by the domain constructor or validator with the matching `Err` sentinel (`errors.Is`).
7. Append-only guard: `reflect.TypeOf((*audit.Repository)(nil)).Elem()` has exactly `Append`, `List`, `Summary`, `DeleteOlderThan`. The persistence implementation's exported method set (via `reflect`) equals the same four. Router walk (`chi.Walk`) finds only GET under `/v1/activity`.
8. Recorder: with `mockAuditRepo` failing (`errors.New("disk full")`), `Record` returns without error, and `buf` names the failed write and `disk full`. A panicking repo is recovered. A context holding actor, source, ip and user agent fills an event that lacks them; an event that already has an actor keeps it. `created_at` equals `fakeAuditClock.Now()` in UTC.
## B. Persistence (real in-memory SQLite, same model list as `api/dependecyInjection.go` and `migrate.go`)
9. Migrate, then query `sqlite_master` and assert the table and three indexes: `created_at`; `(actor_type, actor_id, created_at)`; `(category, created_at)`. Assert the audit model is in the `migrate.go` list (migrate the full list, not just the audit model).
10. Round-trip: append an event with params, changes (text and body kinds) and a null failure_reason; `List` returns equal fields. A system event round-trips with null ip and user agent.
11. Order: three events with identical `created_at` come back by id descending, older last.
12. Filter table from the feature (actor user, key, system, category, outcome, security, `q` case-insensitive, `q` containing `%` and `_` matched literally, date range inclusive of the whole `to` day UTC). Assert items and total for each.
13. Paging: 60 events, page 2 size 25 gives items 26-50 and total 60; page 9 gives empty items and total 60.
14. Summary: events in the last 7 days and the 7 days before; assert total, previous_total, failed, security, distinct user and key counts (anonymous and system excluded) and the four seven-element series, oldest first.
15. `DeleteOlderThan`: events at 100, 40, 31, 29, 1 days; cutoff 30 days; returns 3, remaining ids as expected.
16. Portability: assert no raw dialect-specific SQL (`ILIKE`, `strftime`, `date_trunc`, backticks) appears in the repository source. Cross-database run is manual (see below).
## C. Audit context middleware (probe handler echoing the context as JSON)
17. Table: valid cookie for user 3 gives `{type user, id 3, name, source web}`; valid `Authorization: Bearer <key>` gives `{api_key, 7, "Deploy bot", source api_key}`; none gives anonymous and web.
18. IP table with `TrustedProxyRealIP([]string{"10.0.0.5"})` in front: trusted peer plus `X-Real-IP` gives the header; untrusted peer ignores it; no header gives the peer. User agent of 400 characters is cut to 255.
## D. Read endpoints (HTTP)
19. Gate table: no cookie 401 `You must be logged in to access this resource.`; API key 403 `This action needs a real session.`; unverified cookie 403 `You must verify your account to access this resource.`, each for list, summary and export, with `mockAuditRepo` List/Summary counters at 0.
20. Roles: owner and admin see all actors; user 3 sees only its own with `total` of its own count; `actor=user:3` is 200, `actor=user:2`, `actor=key:7`, `actor=system` are 403 (body `Only owners and admins can do this.`); summary and export for user 3 are 403 with the same body.
21. Shape: decode the first item into a map and compare with the JSON in the feature, including `"failure_reason": null`, `"params": {}`, integer `actor.id`, string `target.id`, `exists`. System event has `actor.id` null, `ip` null, `user_agent` null. Renamed and deleted user keeps the snapshot name. `exists` table with fake article repo (present, deleted) and user repo (deleted). A `body` change has `delta` and no `before` or `after`.
22. Query table: every row of the filter outline, asserted against the seeded set; default range 7d (2-day event in, 10-day event out); default `page` 1, `size` 25.
23. 400 table: every invalid query row gives exactly `The request cannot be processed due to a mismatch in the format of the body.` with the repo read counter at 0.
24. Summary: the 7d example (12 / 10 / 2 users + 1 key / 3 / 2 and seven-length series that sum to the total); empty log all zeros; custom range previous period is the 7 days immediately before; invalid range 400.
25. Export: status 200, `Content-Type: text/csv; charset=utf-8`, `Content-Disposition` begins `attachment`. Parse with `encoding/csv`: header `when,actor,action,target,outcome,source,ip`, row values as in the feature, no changes and no user agent column, system ip cell empty. Formula table (`=cmd`, `+1+1`, `-2`, `@SUM(A1)` get a leading `'`; `Spring release` and `a=b` untouched). A name with comma, quotes and a newline is one parseable record. `outcome=failed&page=2&size=25` returns only failed rows. 1200 events yield 1200 rows and `mockAuditRepo` saw more than one `List` call with a bounded size. Invalid filter gives 400 with no `Content-Type: text/csv`.
## E. Retention settings (HTTP)
26. `GET /v1/settings` with no stored row: `activity_retention_days` 90, `activity_last_cleanup_at` null, `activity_last_cleanup_removed` 0, existing fields (`version`) unchanged. With a stored cleanup: values echoed (RFC3339 UTC).
27. `PATCH` accepts 30, 90, 365, 0 (200, body echoes, a following GET agrees). 400 table: 7, -1, 366, 30.5, `"30"`, null, `{}`, non-JSON, each with the body-mismatch message and the stored value unchanged.
28. Gate: admin 403 `Only the owner can do this.`; no cookie 401; owner API key 403 `This action needs a real session.`; unverified owner 403 verification text.
29. Event: PATCH 90 to 30 records one `system.activity_retention_changed` event with actor owner, source web and params `{"from":90,"to":30}`; PATCH to the same value records none.
## F. Cleanup scheduler (no HTTP, hand-fed channels, `fakeAuditClock`)
30. Tick table from the feature (never/last cleanup versus now, boundary at exactly 03:00, same-day second tick not due, next-day tick due). Assert `ran` and the `DeleteOlderThan` call count. Retention 0 never calls it.
31. Cutoff: retention 30, events at 40, 31, 29, 1 days; run at 03:00; 31 and 40 removed; `last_cleanup_at` equals the clock; removed count 2. A request for a cutoff inside 24 hours is clamped to now minus 24 hours.
32. Zero removed: fields updated, no `system.activity_cleanup` event. 142 removed: one event with action `system.activity_cleanup`, actor type system, source system, ip and user agent null, params `{"removed":142}`, and it is not deleted by the run that created it.
33. `DeleteOlderThan` failure: Tick returns the error, last-cleanup fields unchanged, the log buffer holds the error, the next Tick runs again after healing.
34. Runner: one tick runs one Tick; cancelling the context returns `Run` within a timeout; a done channel proves no goroutine outlives it (`-race`).
35. Shortening retention from 90 to 30 deletes nothing at PATCH time (repo `DeleteOlderThan` counter 0); the next due Tick deletes older than 30.
## G. Producers (HTTP)
36. Login: right credentials gives `auth.login` (actor user, source web, security false, failure_reason null, ip and user agent from the request); wrong password and unknown username give `auth.login_failed` with actor anonymous named by the attempted username, outcome failed, security true, failure_reason `Wrong username or password.`, identical for both; validation failure and a rate-limited 429 record nothing. No stored field or log line contains the password.
37. Logout with a valid cookie gives `auth.logout`; without a cookie records nothing.
38. Password reset: request for an existing account gives `auth.password_reset_requested` (security true); unknown email records nothing and the response is byte-identical to the existing account's response except `Date`; confirm with a valid code gives `auth.password_reset_completed`; invalid code records nothing. Neither the raw code, its hash nor the new password appears in any stored field or in `buf`.
39. API key: `POST /v1/api-key` records `api_key.create` with the target `{api_key, <id>, name}`; `DELETE /v1/api-key/7` records `api_key.revoke`; `DELETE /v1/api-key/99` is 404 and records nothing. Marshal every stored event and every `/v1/activity` response and assert the raw key and its hash are absent.
40. Recorder failure on each producer: the HTTP status and body equal those of the same request with a healthy repository.
41. End to end: record a failed login, a key creation and a retention change, then `GET /v1/activity` lists them newest first, and `?security=true` returns only the failed login.
## H. Layering and hygiene guards
42. Grep-style test: `internal/domain/audit` imports only the stdlib and other `internal/domain/*`; the audit use cases import no `gorm`, `persistence`, `mailer`, `net/http`, chi or `api/...`; handlers import no `persistence`, `models` or `gorm`; the persistence repository asserts `var _ audit.Repository = &X{}`; the GORM model lives in `persistence/models` with a mapper.
43. Grep the new and edited files, Go and docs, for comments: none; only tool directives.
44. Docs read: `docs/api.md` lists the three activity routes, the query parameters, response shapes, the 400/401/403 answers and the PATCH `/v1/settings` contract with the three new GET fields; `docs/architecture.md` describes `domain/audit`, the recorder and context, the persistence model and indexes, and the retention scheduler; `docs/configuration.md` states that retention is stored in the database (default 90) and that no env var was added.
## Manual (not automated)
45. Real boot with `task dev:api`: log in with a wrong password, log in correctly, create and revoke an API key, PATCH retention as owner (with the session cookie), then read `/v1/activity` and `/v1/activity/export` and open the CSV in a spreadsheet to confirm injected cells show as text.
46. Behind a reverse proxy with `TRUSTED_PROXIES` containing the proxy: the stored ip is the client's, not the proxy's. Without it: the proxy's.
47. Backdate events in the SQLite file, set retention to 30 and either wait for 03:00 or move the system clock; confirm removed rows and the new Last cleanup values in `GET /v1/settings`.
48. Run the persistence suite against a MySQL/MariaDB and a PostgreSQL instance through the documented database settings of `docs/configuration.md`: index creation, case-insensitive `q`, literal `%` and `_`, date-range boundaries, and `DeleteOlderThan` counts.
49. SIGINT during a cleanup: the process exits after the run ends, with no database error on close.
+506
View File
@@ -0,0 +1,506 @@
Feature: Every real action of the back-office is recorded in the activity log from inside its use case
As the owner of a RenewCMS site
I want articles, media, users and invites, and system tasks to leave an activity event
So that the Activity screen shows who did what, and a failed attempt says why in plain words
Background:
Given the spec of record is docs/design/screens/activity.md ("What is recorded", "API contract") and the foundation slice features/activity-log-backend.feature
And this slice is backend only: no SPA, no new env var, no new table, no change to docs/design
And events are recorded by the use cases through audit.Recorder, never by handlers, middleware or GORM hooks
And every mutating use case method takes the request context as its first parameter and hands it to Recorder.Record, which fills actor, source, ip and user agent from the audit context
And system events set the system actor and source "system" themselves, with ip and user_agent null
And an event is recorded once, after the change was made (success) or after the use case decided to refuse or abort it (failed), never before
And no read is recorded: no GET, no list, no public article read, no status poll
And error responses are plain text from http.Error and success responses are JSON with snake_case fields, unchanged by this slice
And the layering rule holds: use cases see only domain interfaces, no comment is added anywhere
Rule: The audit context reaches every producing route
Scenario Outline: A producing route carries the origin of the request
When <request> is sent with <credentials>
Then the stored event has actor <actor>, source "<source>" and the request ip and user agent
Examples:
| request | credentials | actor | source |
| POST /v1/article | the owner's jwt cookie | the owner, named by display name | web |
| POST /v1/article | an API key "Deploy bot"| api_key id 7 named "Deploy bot" | api_key |
| POST /v1/media | an API key "Deploy bot"| api_key id 7 named "Deploy bot" | api_key |
| POST /v1/system/update/check | the owner's jwt cookie | the owner | web |
Rule: A failed audit write never changes the user's action
Scenario Outline: A failing or panicking repository leaves the response untouched
Given the audit repository <fault> on Append
When <request> succeeds
Then the response status and body equal those of the same request with a healthy repository
And the change itself was applied
And the process log names the failed audit write
Examples:
| fault | request |
| fails | POST /v1/article |
| fails | DELETE /v1/article/8 |
| panics | PATCH /v1/article/8/publish |
| fails | POST /v1/media |
| fails | POST /v1/media/merge |
| fails | POST /v1/user/invite |
| fails | POST /v1/system/update/restart |
Rule: Articles
Scenario: Creating an article is recorded
When the owner sends POST /v1/article with {"title": "Spring release", "body": "Hello"}
Then one event exists with action "article.create", category "articles", actor the owner, source "web", target {"type": "article", "id": "<new id>", "name": "Spring release"}, outcome "success", failure_reason null and no changes
And the stored event holds no article body
Scenario: Editing the body records a size delta and never the text
Given the article 8 "Spring release" has a body of 100 characters
When the owner sends PATCH /v1/article/8 with a body of 412 characters
Then one event exists with action "article.edit", target {"type": "article", "id": "8", "name": "Spring release"} and changes [{"field": "body", "kind": "body", "delta": 312}]
And no stored field and no /v1/activity response contains any text of either body
Scenario Outline: The delta is signed and counts characters
Given the article 8 has a body of <before> characters
When its body is replaced by one of <after> characters
Then the change delta is <delta>
Examples:
| before | after | delta |
| 100 | 412 | 312 |
| 412 | 100 | -312 |
| 5 | 5 | 0 |
Scenario: A body with multi-byte characters is counted in characters
Given the article 8 has the body "héllo"
When its body is replaced by "héllo wörld"
Then the change delta is 6
Scenario: Saving an identical body records nothing
Given the article 8 has the body "Same text"
When the owner sends PATCH /v1/article/8 with the body "Same text"
Then the response is 200 and no event is recorded
Scenario: A title change is recorded as a text diff when a title edit exists
Given a use case that changes an article's title from "Spring release" to "Spring release 2"
Then its event has action "article.edit" and changes [{"field": "title", "kind": "text", "before": "Spring release", "after": "Spring release 2"}]
And only the fields title, online and body can ever appear in an article event's changes
Scenario: Publishing is recorded with the publish state
Given the article 8 "Spring release" is offline
When the owner sends PATCH /v1/article/8/publish
Then one event exists with action "article.publish", category "articles" and changes [{"field": "online", "kind": "text", "before": "false", "after": "true"}]
Scenario: Unpublishing is recorded with the publish state
Given the article 8 is online
When the owner sends PATCH /v1/article/8/unpublish
Then one event exists with action "article.unpublish" and changes [{"field": "online", "kind": "text", "before": "true", "after": "false"}]
Scenario Outline: A publish state that does not change records nothing
Given the article 8 is <state>
When the owner sends PATCH /v1/article/8/<path>
Then the response is 200 and no event is recorded
Examples:
| state | path |
| online | publish |
| offline | unpublish |
Scenario: Deleting an article is recorded with its name snapshot
Given the article 8 "Spring release" exists
When the owner sends DELETE /v1/article/8
Then one event exists with action "article.delete", target {"type": "article", "id": "8", "name": "Spring release"} and outcome "success"
And GET /v1/activity shows that target with exists false
Scenario Outline: A refused article action is recorded as failed with a plain reason
Given <state>
When <request>
Then one event exists with action "<action>", outcome "failed" and failure_reason "<reason>"
And the response is unchanged
Examples:
| state | request | action | reason |
| no article has this title | POST /v1/article with a 2 character title | article.create | The title must be between 3 and 200 characters. |
| the repository fails with "db is locked" | POST /v1/article with a valid title | article.create | Something went wrong on our side. Please try again. |
| the repository fails with "db is locked" | PATCH /v1/article/8/publish | article.publish | Something went wrong on our side. Please try again. |
Scenario: An article that does not exist records nothing
When the owner sends DELETE /v1/article/99, PATCH /v1/article/99, PATCH /v1/article/99/publish and PATCH /v1/article/99/unpublish
Then every response is 404 and no event is recorded
Scenario: A request that fails validation records nothing
When the owner sends POST /v1/article with an empty body or a malformed id such as /v1/article/abc
Then the response is 400 and no event is recorded
Scenario Outline: Reads are never recorded
When <request> is sent
Then no event is recorded
Examples:
| request |
| GET /v1/article |
| GET /v1/article/8 |
| GET /v1/article/8 as the public |
Rule: Media
Scenario: Uploading an image is recorded
When the owner sends POST /v1/media with the PNG "spring-banner.png"
Then one event exists with action "media.upload", category "media", actor the owner, target {"type": "image", "id": "<new id>", "name": "spring-banner.png"} and outcome "success"
And the stored event holds no file content and no storage path
Scenario Outline: A refused upload is recorded as failed
When the owner uploads <file>
Then one event exists with action "media.upload", outcome "failed" and failure_reason "<reason>"
And the target name is the file name as sent, normalised the way a stored upload would be named, or empty when it is unusable
And the response is unchanged
Examples:
| file | reason |
| a GIF | The file must be a PNG, JPEG, WEBP or SVG image. |
| a PNG larger than the limit | The file is too large to upload. |
| a corrupt PNG | The image could not be read. |
Scenario: Renaming an image records the filename diff
Given the image 5 "banner.png"
When the owner sends PATCH /v1/media/5 with {"filename": "spring-banner"}
Then one event exists with action "media.rename", target {"type": "image", "id": "5", "name": "spring-banner.png"} and changes [{"field": "filename", "kind": "text", "before": "banner.png", "after": "spring-banner.png"}]
Scenario: Renaming to the same name records nothing
Given the image 5 "banner.png"
When the owner renames it to "banner"
Then no event is recorded
Scenario: A refused rename is recorded as failed
Given the image 5 "banner.png"
When the owner sends PATCH /v1/media/5 with an invalid filename
Then one event exists with action "media.rename", outcome "failed", failure_reason "That file name is not valid." and target {"type": "image", "id": "5", "name": "banner.png"}
Scenario: Deleting an image is recorded with its name snapshot
Given the image 5 "banner.png"
When the owner sends DELETE /v1/media/5
Then one event exists with action "media.delete", target {"type": "image", "id": "5", "name": "banner.png"} and outcome "success"
And GET /v1/activity shows that target with exists false
Scenario: Merging images is recorded once with its counts
Given the images 5 "banner.png", 6 "banner-copy.png" and 7 "banner-copy-2.png" share one content hash
And two articles reference the copies
When the owner sends POST /v1/media/merge keeping 5 and merging [6, 7]
Then one event exists with action "media.merge", target {"type": "image", "id": "5", "name": "banner.png"} and params {"merged": 2, "articles_updated": 2}
And no event names a removed copy as a separate action
Scenario Outline: A refused merge is recorded as failed
When the owner sends POST /v1/media/merge that fails with <error>
Then one event exists with action "media.merge", outcome "failed" and failure_reason "<reason>"
Examples:
| error | reason |
| ErrInvalidMergeRequest | The merge request is not valid. |
| ErrMergeCopyMissing | One of the copies no longer exists. |
| ErrMergeHashMismatch | The images are not identical copies.|
Scenario: A manual purge is recorded once and not as separate merges
Given a purge plan with 2 groups, 3 copies and 5 articles to update
When the owner sends POST /v1/media/purge
Then one event exists with action "media.purge", category "media", actor the owner, source "web", outcome "success" and params {"merged": 3, "articles_updated": 5}
And no "media.merge" event is recorded for its groups
Scenario: A partly failed manual purge is a success with the failed group count
Given a purge plan with 3 groups where 1 group fails
When the owner sends POST /v1/media/purge
Then one event exists with action "media.purge", outcome "success" and params that include "failed_groups": 1
Scenario Outline: A refused manual purge is recorded as failed
When the owner sends POST /v1/media/purge and the use case returns <error>
Then one event exists with action "media.purge", outcome "failed" and failure_reason "<reason>"
Examples:
| error | reason |
| ErrPurgeConflict | The library changed since the preview. Review it again. |
| ErrPurgeBusy | A purge is already running. |
Scenario: The scheduled purge is a system event
Given the purge is due and merges 3 copies and updates 5 articles
When PurgeSchedulerUseCase.Tick runs
Then one event exists with action "system.media_purge", category "system", actor type system named "System", source "system", ip null, user_agent null, outcome "success" and params {"merged": 3, "articles_updated": 5}
Scenario: A scheduled purge with a failed group records the failed group count
Given the purge is due and 1 group fails while others merge
When Tick runs
Then one event exists with action "system.media_purge", outcome "success" and params that include "failed_groups": 1
Scenario: A scheduled purge that is not due or merges nothing records nothing
Given the purge is not due, or is due and finds no duplicate
When Tick runs
Then no event is recorded
Scenario: A scheduled purge that cannot record its run is a failed system event
Given the purge is due and the run result cannot be saved
When Tick runs
Then one event exists with action "system.media_purge", outcome "failed" and failure_reason "Something went wrong on our side. Please try again."
Scenario: Browsing and the media usage lookup are never recorded
When the owner sends GET /v1/media, GET /v1/media/5/usage, GET /v1/media/purge-preview and GET /v1/media/purge-settings
Then no event is recorded
Rule: Users and invites
Scenario: Registering is recorded with the new account as actor
Given registration is open
When POST /v1/auth/register is sent with the username "marta"
Then one event exists with action "user.register", category "users", actor user "marta" with the new id, source "web", target {"type": "user", "id": "<new id>", "name": "marta"} and outcome "success"
And the stored event holds no password, no hash and no verification code
Scenario Outline: A refused registration is recorded as failed
When POST /v1/auth/register is sent and <cause>
Then one event exists with action "user.register", outcome "failed", failure_reason "<reason>", actor anonymous named by the attempted username and no target
And the response is unchanged
Examples:
| cause | reason |
| registration is closed | Registration is closed on this site. |
| the verification email cannot be sent | The verification email could not be sent. |
Scenario: Verifying an account is recorded
Given the unverified user 4 "lea" with a valid code
When user 4 sends POST /v1/auth/verify with that code
Then one event exists with action "user.verify", category "users", actor user 4, target {"type": "user", "id": "4", "name": "lea"} and outcome "success"
And the stored event does not hold the code or its hash
Scenario Outline: A refused verification is recorded as failed
Given the unverified user 4 "lea"
When user 4 sends POST /v1/auth/verify and the use case returns <error>
Then one event exists with action "user.verify", outcome "failed" and failure_reason "<reason>"
Examples:
| error | reason |
| ErrInvalidVerificationCode | The verification code is not valid. |
| ErrVerificationCodeExpired | The verification code has expired. |
Scenario: Changing the display name records a before and after
Given user 3 has the display name "Marta"
When user 3 sends PATCH /v1/user/me with {"display_name": "Marta Alves"}
Then one event exists with action "user.update_profile", category "users", target {"type": "user", "id": "3", "name": "Marta Alves"} and changes [{"field": "display_name", "kind": "text", "before": "Marta", "after": "Marta Alves"}]
And the actor snapshot name is the name held when the request began
Scenario Outline: Setting a display name that does not change records nothing
Given user 3 has the display name "<current>"
When user 3 sends PATCH /v1/user/me with {"display_name": "<sent>"}
Then no event is recorded
Examples:
| current | sent |
| Marta | Marta |
| Marta | Marta |
| | |
Scenario: A display name that is too long is recorded as failed
When user 3 sends PATCH /v1/user/me with a 61 character name
Then one event exists with action "user.update_profile", outcome "failed" and failure_reason "That display name is too long."
And the stored event holds no changes
Scenario: Creating an invite is recorded
When the owner sends POST /v1/user/invite with {"email": "new@example.com"}
Then one event exists with action "invite.create", category "users", actor the owner, target {"type": "invite", "id": "<invite id>", "name": "new@example.com"} and outcome "success"
And the stored event holds no invite code and no hash
Scenario Outline: A refused invite action is recorded as failed
When the owner <request> and the use case returns <error>
Then one event exists with action "<action>", outcome "failed" and failure_reason "<reason>"
Examples:
| request | error | action | reason |
| sends POST /v1/user/invite | ErrEmailHasAccount | invite.create | That email already has an account. |
| sends POST /v1/user/invite | ErrPendingInvite | invite.create | An invitation is already pending for that email. |
| sends POST /v1/user/invite | ErrInviteEmailNotSent | invite.create | The invitation email could not be sent. |
| sends POST /v1/user/invite/4/renew | ErrInviteEmailNotSent | invite.renew | The invitation email could not be sent. |
| sends POST /v1/user/invite/4/renew | ErrInviteAlreadyAccepted | invite.renew | That invitation was already accepted. |
| sends DELETE /v1/user/invite/4 | ErrInviteAlreadyAccepted | invite.cancel | That invitation was already accepted. |
Scenario: Cancelling an invite is recorded
Given the pending invite 4 for "new@example.com"
When the owner sends DELETE /v1/user/invite/4
Then one event exists with action "invite.cancel", target {"type": "invite", "id": "4", "name": "new@example.com"} and outcome "success"
Scenario: Renewing an invite is recorded
Given the pending invite 4 for "new@example.com"
When the owner sends POST /v1/user/invite/4/renew
Then one event exists with action "invite.renew", target {"type": "invite", "id": "4", "name": "new@example.com"} and outcome "success"
And a renewal whose email fails is rolled back and recorded only as failed
Scenario: Accepting an invite is recorded with the new account as actor
Given the pending invite 4 for "new@example.com"
When POST /v1/auth/invite/accept is sent with a valid code and the username "sam"
Then one event exists with action "invite.accept", category "users", actor user "sam" with the new id, source "web", target {"type": "invite", "id": "4", "name": "new@example.com"} and outcome "success"
And the stored event holds no code, no hash and no password
Scenario Outline: A refused acceptance is recorded as failed
When POST /v1/auth/invite/accept is sent and the use case returns <error>
Then one event exists with action "invite.accept", outcome "failed", failure_reason "<reason>" and actor anonymous named by the attempted username
And the stored event has no target when the invite could not be identified
Examples:
| error | reason |
| ErrInviteInvalid | That invitation is not valid. |
| ErrInviteExpired | That invitation has expired. |
| ErrInviteAlreadyAccepted | That invitation was already accepted. |
| ErrUsernameTaken | That username is already taken. |
| ErrEmailHasAccount | That email already has an account. |
Scenario: An invite or user that does not exist records nothing
When the owner sends DELETE /v1/user/invite/99 or POST /v1/user/invite/99/renew
Then every response is 404 and no event is recorded
Scenario Outline: Reads and checks are never recorded
When <request> is sent
Then no event is recorded
Examples:
| request |
| GET /v1/user |
| GET /v1/auth/me |
| GET /v1/auth/invite?code=<code> |
| GET /v1/auth/registration |
Rule: System tasks
Scenario: Checking for an update is recorded
Given the release feed answers v1.4.0 while the running version is v1.3.2
When the owner sends POST /v1/system/update/check
Then one event exists with action "system.update_checked", category "system", actor the owner, source "web", target {"type": "release", "id": "v1.4.0", "name": "v1.4.0"}, outcome "success" and params {"update_available": true}
Scenario Outline: A failed update check is recorded with a plain reason
When the owner sends POST /v1/system/update/check and the use case returns <error>
Then one event exists with action "system.update_checked", outcome "failed", failure_reason "<reason>" and no target
Examples:
| error | reason |
| ErrFeedUnavailable | The release feed could not be reached. |
| ErrNoRelease | No release was found. |
Scenario: The status poll is never recorded
When the owner sends GET /v1/system/update
Then no event is recorded
Scenario: A download that completes is recorded when the job is ready
Given a newer release v1.4.0 is available and the update helper is installed
When the owner sends POST /v1/system/update/download
Then the response is returned before the job ends and no event exists yet
And when the job reaches the ready state one event exists with action "system.update_downloaded", actor the owner, source "web", target {"type": "release", "id": "v1.4.0", "name": "v1.4.0"} and outcome "success"
And the origin (actor, ip, user agent) is the one captured at request time
Scenario Outline: A download that fails is recorded with a reason that hides the cause
Given a newer release v1.4.0 is available and the job fails with the error kind "<kind>"
Then one event exists with action "system.update_downloaded", outcome "failed" and failure_reason "<reason>"
And the stored event holds no url, no path and no raw error text
Examples:
| kind | reason |
| download | The update could not be downloaded. |
| verification | The downloaded update failed its integrity check. |
Scenario Outline: A download refused at the start is recorded as failed
When the owner sends POST /v1/system/update/download and the use case returns <error>
Then one event exists with action "system.update_downloaded", outcome "failed" and failure_reason "<reason>"
And the response is unchanged
Examples:
| error | reason |
| ErrNotPrepared | This server cannot apply updates. |
| ErrJobInProgress | An update is already being downloaded. |
| ErrRestartPending | A restart is already pending. |
| ErrNothingToUpdate | There is nothing to update. |
| ErrPreflightRefused | The update cannot be downloaded. |
Scenario: A restart request is recorded
Given the update helper is installed and no job is running
When the owner sends POST /v1/system/update/restart
Then one event exists with action "system.restart_requested", category "system", actor the owner, source "web", outcome "success" and params {"kind": "restart"}
Scenario: A restart that applies a downloaded update records the kind and version
Given a downloaded update v1.4.0 is ready
When the owner sends POST /v1/system/update/restart
Then the event has params {"kind": "update", "tag": "v1.4.0"}
Scenario Outline: A refused restart is recorded as failed
When the owner sends POST /v1/system/update/restart and the use case returns <error>
Then one event exists with action "system.restart_requested", outcome "failed" and failure_reason "<reason>"
Examples:
| error | reason |
| ErrNotPrepared | This server cannot apply updates. |
| ErrJobInProgress | An update is still being downloaded. |
| ErrRestartPending | A restart is already pending. |
| ErrRestartKindMismatch | The requested restart does not match the server state. |
Rule: A failed outcome never exposes more than the plain-language reason
Scenario: Failure reasons are mapped from domain sentinels
Given a table that maps each domain Err sentinel above to a fixed sentence
When a use case fails with an error that wraps one of them
Then failure_reason is that sentence, matched with errors.Is
Scenario Outline: Unmapped errors become the generic sentence
When a use case fails with <error>
Then failure_reason is "Something went wrong on our side. Please try again."
And neither the event, the activity API nor the CSV export holds the text of the error
Examples:
| error |
| errors.New("db is locked") |
| an error carrying an SMTP host and password |
| an error carrying a file system path |
| an error wrapping a sentinel and a raw cause |
Scenario: Every failure reason fits the stored limit
Then every mapped sentence is at most 255 characters
Scenario: A failed event keeps its target when the target is known
Given the image 5 "banner.png" exists
When a rename of image 5 is refused
Then the failed event keeps the target snapshot of image 5
Rule: Secrets and bodies never reach the log
Scenario Outline: Sensitive values are absent everywhere
Given the action <action> handled <secret>
When it is recorded, listed through GET /v1/activity and exported through GET /v1/activity/export
Then no stored field, no params, no change, no response, no CSV cell and no process log line contains <secret>
Examples:
| action | secret |
| user.register | the password, its hash and the verification code |
| user.verify | the verification code and its hash |
| invite.create | the invite code and its hash |
| invite.accept | the invite code, its hash and the password |
| article.edit | the old and the new article body |
| media.upload | the file bytes and the storage path |
| system.update_downloaded | the asset url and the staging path |
Scenario: Only allowlisted fields appear in changes
Then the only change fields any producer emits are title, online, body, filename and display_name
And the only change kinds are text and body
Rule: The recorded events read back through the existing API
Scenario: Producer events are listed with their documented shape
Given an article was created, edited, published and deleted by the owner
When the owner sends GET /v1/activity?category=articles
Then the four events are listed newest first, with category "articles", the documented item shape and the article target exists false after the delete
Scenario Outline: Categories filter the producers
Given one event of each producer was recorded
When the owner sends GET /v1/activity?category=<category>
Then the items are exactly the events listed in <actions>
Examples:
| category | actions |
| articles | article.create, article.edit, article.publish, article.unpublish, article.delete |
| media | media.upload, media.rename, media.delete, media.merge, media.purge |
| users | user.register, user.verify, user.update_profile, invite.create, invite.cancel, invite.renew, invite.accept |
| system | system.media_purge, system.update_checked, system.update_downloaded, system.restart_requested |
Scenario: A failed producer event is filterable
Given a refused merge and a successful rename were recorded
When the owner sends GET /v1/activity?outcome=failed
Then only the refused merge is listed with its failure_reason
+67
View File
@@ -0,0 +1,67 @@
# QA procedure: activity-log-producers
Go only, no SPA. Package `test` (black-box). New files: `test/activity_producers_articles_test.go`, `test/activity_producers_media_test.go`, `test/activity_producers_users_test.go`, `test/activity_producers_system_test.go`, `test/activity_producers_reasons_test.go`. Reuse the foundation helpers in `test/activity_support_test.go` (`mockAuditRepo`, `fakeAuditClock`, `seedEvents`). Extend the existing hand-written fakes (`mockArticleRepo`, the image, user, invite, purge, update and status fakes) with failure switches and call counters. New fake type names must be unique across the package. No live server, no real SMTP, no real SQLite file, no real sleeping, no `time.Ticker`. Secrets in tests are fixed test strings only.
## Setup shared by every case
1. `validator.InitValidator()` once. `jwtauth.New("HS256", []byte("test-secret"), nil)` stands in for `JWT_SECRET`.
2. One `auditing.NewRecorder(mockAuditRepo, fakeAuditClock.Now, logger)` shared by every use case. `logger` writes to a `bytes.Buffer` (restore with `t.Cleanup`).
3. Users: owner 1, admin 2, verified user 3 "Marta", unverified user 4 "lea" (with a hashed code). API key 7 "Deploy bot" in the fake key repo.
4. A fresh `chi.NewRouter()` per test that mirrors `api/router.go` for the article, user, media, system and auth routers, with `middleware.AuditContext(getUser, trustedProxies)` placed exactly where the router wires it (after `apiKeyAuth()` on the article, user and media routers). Cookies are real signed `jwt` cookies with `user_id` and `token_version`. `httptest.NewRecorder` per request.
5. Helpers: `eventsOf(repo)`, `onlyEvent(t, repo)` (fails unless exactly one event), `assertNoEvent(t, repo)`.
6. Each mutating use case method takes `ctx context.Context` first. A method called with a context holding no origin still works.
## A. Origin and routing
7. Table (feature rule 1): each request with the cookie or the API key yields the expected actor type, id, name and source, plus the request ip and user agent. Proves `AuditContext` is mounted on the article, user, media and system routers.
8. A route without credentials, or answered 401/403/429, records nothing.
## B. Articles
9. Create: one `article.create` event, target id string, name as typed, no changes, no body anywhere (marshal the event and assert absence).
10. Edit: table `(before, after, delta)`: 100 to 412 (312), 412 to 100 (-312), 5 to 5 with different content (0, still recorded), `héllo` to `héllo wörld` (6, characters not bytes). Identical body records nothing. Change kind `body`, `Delta` set, `Before` and `After` nil.
11. Publish and unpublish: `online` text change (`"false"`/`"true"`). Repeating the same state records nothing and returns 200.
12. Delete: event keeps the name snapshot. After it, `GET /v1/activity` shows `target.exists` false.
13. Title diff: unit-test the change builder with title, online and body only; any other field name is rejected.
14. Failure table: 2 character title, repository failing with `errors.New("db is locked")` (generic reason). 404 on delete, edit, publish, unpublish of id 99 and 400 on malformed id or body record nothing.
15. Reads (list, get, public get) record nothing.
## C. Media
16. Upload: in-memory PNG, target name equals the normalised filename, no bytes and no path. Failed upload table: GIF, oversize, corrupt PNG, each with its mapped reason.
17. Rename: `filename` text change `banner.png` to `spring-banner.png`. Same name records nothing. Invalid stem records a failed event keeping the target. Missing image records nothing (404).
18. Delete: event with name snapshot. Missing image records nothing.
19. Merge: three images with one hash, two articles referencing the copies. One `media.merge` event with `{"merged":2,"articles_updated":2}`, target the kept image. Failure table: `ErrInvalidMergeRequest`, `ErrMergeCopyMissing`, `ErrMergeHashMismatch`.
20. Manual purge: plan with 2 groups, 3 copies, 5 articles. Exactly one `media.purge` event with `{"merged":3,"articles_updated":5}` and zero `media.merge` events. A plan with one failing group: outcome success with `failed_groups` 1. `ErrPurgeConflict` and `ErrPurgeBusy` give failed events. Preview, usage, list and settings GETs record nothing.
21. Scheduled purge: drive `PurgeSchedulerUseCase.Tick(now)` with the fake purge repo and clock. Due and merging 3: one `system.media_purge` event with system actor, source system, ip nil, user agent nil. Due with a failed group: success with `failed_groups`. Not due, or no duplicates: none. A fake `SaveLastRun` failure: failed event with the generic reason.
22. Manual purge after the scheduler in the same test yields two events with sources `web` and `system`.
## D. Users and invites
23. Register: open registration records `user.register` with the new account as actor and target. Closed registration and a failing mailer (error carrying a fake SMTP password) record failed events with the mapped reason, anonymous actor named by the attempted username, no raw text. Marshal the event and assert password, hash and code are absent.
24. Verify: success, invalid code, expired code.
25. Display name: before/after diff, trimmed no-op records nothing, empty to empty records nothing, 61 characters records a failed event with no changes. Actor snapshot name is the pre-change name.
26. Invites: create, cancel, renew, accept each record the documented event with the invited email as target name. Failure table covers every error in the feature. A renew whose mail fails is rolled back and records one failed event only. Unknown ids (404) record nothing.
27. Accept: actor is the new account, source `web`; failed accept actor is anonymous with the attempted username.
28. Reads (`GET /v1/user`, `/v1/auth/me`, `/v1/auth/invite`, `/v1/auth/registration`) record nothing.
## E. System tasks
29. Check: success event with `{"update_available":true}` and a release target. `ErrFeedUnavailable` and `ErrNoRelease` give failed events without target. Status `GET` records nothing.
30. Download: fakes complete the job through a hand-fed channel (no `time.Sleep`). No event before the job ends, one after `ready`; actor, ip and user agent are those captured at request time even though the request context was cancelled. Failure kinds `download` and `verification` give the two reasons, no URL or path. Start-time refusals give failed events. Run with `-race`.
31. Restart: `{"kind":"restart"}` and `{"kind":"update","tag":"v1.4.0"}`. Refusals for `ErrNotPrepared`, `ErrJobInProgress`, `ErrRestartPending`, `ErrRestartKindMismatch`.
## F. Reasons, secrets and recorder faults
32. Reason table: wrap each sentinel with `fmt.Errorf("%w: %w", sentinel, errors.New("secret detail"))`, assert the sentence equals the table and the extra text is absent. Unmapped errors give the generic sentence. Every sentence is at most 255 characters.
33. Recorder fault: for each producer row of the fault table, run with failing, panicking and healthy `mockAuditRepo`. Identical status and body, change applied, log line naming the failed write.
34. Secret sweep: after exercising every producer, marshal every stored event, the `/v1/activity` response and the `/v1/activity/export` file; assert none contains the password, hash, verification code, invite code, raw API key, article body, file bytes, asset URL or staging path. The log buffer is free of them too.
35. Allowlist: every `Change.Field` is in `{title, online, body, filename, display_name}` and every kind is `text` or `body`.
36. Read-back: list by `category=articles|media|users|system` and `outcome=failed`; compare action sets with the feature tables.
## G. Mutation guard (gremlins stays clean)
37. `task mutate` scope is `./internal/infrastructure/useCases`. Each new branch needs a killing assertion: every no-op guard (body, online, filename, display name) with equal and differing values; delta sign and rune-versus-byte count; success versus failed outcome and recorded once after the change (event count and order against the repository call); every `errors.Is` row of the reason map plus the generic fallback; every `ctx` forwarding; the system-actor defaults. Bare-bool conditions produce no mutant, so cover both branches with tests regardless.
## H. Layering and hygiene
38. Touched use cases import no `gorm`, `persistence`, `mailer`, `net/http`, chi or `api/...`; handlers only pass `r.Context()`; `internal/domain/audit` imports only stdlib and domain packages.
39. No comments in new or edited Go files, test files and docs, except tool directives.
40. Docs (`docs/api.md`, `docs/architecture.md`) list the action catalogue, target types, reason sentences and the rule that no read is recorded.
## Manual (not automated)
41. Real boot with `task dev:api` and SMTP: register, verify, invite, renew, accept and cancel an invite, change display name, create/edit/publish/unpublish/delete an article, upload/rename/merge/delete images, manual purge; read `/v1/activity` and the CSV export and read every sentence.
42. Real update helper on Linux systemd (or the Docker QA flow): check, download until `ready`, restart; one event each, real asset names absent. Skip in a container.
43. Leave the scheduled purge due and wait for the hourly tick: a single `system.media_purge` event, source `system`, no ip or user agent.
44. Remove the database file's write access during one article edit: edit result unchanged, process log names the audit write failure.
+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 ( require (
github.com/MadAppGang/httplog v1.3.0 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/glebarez/sqlite v1.11.0
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/go-chi/jwtauth/v5 v5.3.3 github.com/go-chi/jwtauth/v5 v5.3.3
github.com/go-playground/validator/v10 v10.28.0 github.com/go-playground/validator/v10 v10.28.0
github.com/go-sql-driver/mysql v1.8.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/joho/godotenv v1.5.1 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/crypto v0.57.0
golang.org/x/image v0.46.0 golang.org/x/image v0.46.0
golang.org/x/net v0.59.0 golang.org/x/net v0.59.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df 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 ( require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // 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/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // 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/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // 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/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
@@ -35,7 +62,6 @@ require (
github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // 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/lestrrat-go/option v1.0.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
@@ -43,6 +69,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/text v0.42.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // 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 h1:1XU54TO8kiqTeO+7oZLKAM3RP/cJ7SadzslRcKspVHo=
github.com/MadAppGang/httplog v1.3.0/go.mod h1:gpYEdkjh/Cda6YxtDy4AB7KY+fR7mb3SqBZw74A5hJ4= 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 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 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= 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/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 h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI= 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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -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-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 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 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
+30
View File
@@ -0,0 +1,30 @@
package audit
import (
"RenewCMS/internal/domain/article"
"strconv"
"unicode/utf8"
)
func TextChange(field string, before string, after string) Change {
return Change{Field: field, Kind: ChangeText, Before: &before, After: &after}
}
func BodyChange(before string, after string) Change {
delta := utf8.RuneCountInString(after) - utf8.RuneCountInString(before)
return Change{Field: FieldBody, Kind: ChangeBody, Delta: &delta}
}
func ArticleChanges(before article.Article, after article.Article) []Change {
changes := []Change{}
if before.Title != after.Title {
changes = append(changes, TextChange(FieldTitle, before.Title, after.Title))
}
if before.IsOnline != after.IsOnline {
changes = append(changes, TextChange(FieldOnline, strconv.FormatBool(before.IsOnline), strconv.FormatBool(after.IsOnline)))
}
if before.Body != after.Body {
changes = append(changes, BodyChange(before.Body, after.Body))
}
return changes
}
+243
View File
@@ -0,0 +1,243 @@
package audit
import (
"RenewCMS/internal/domain/user"
"errors"
"slices"
"strconv"
"strings"
"time"
)
var ErrInvalidFilter = errors.New("invalid activity filter")
var ErrInvalidRetention = errors.New("invalid activity retention period")
var ErrInvalidEvent = errors.New("invalid activity event")
var ErrActorNotAllowed = errors.New("activity of another actor is not allowed")
type ActorType string
const (
ActorUser ActorType = "user"
ActorAPIKey ActorType = "api_key"
ActorSystem ActorType = "system"
ActorAnonymous ActorType = "anonymous"
)
type Category string
const (
CategoryArticles Category = "articles"
CategoryMedia Category = "media"
CategoryUsers Category = "users"
CategoryAPIKeys Category = "api_keys"
CategoryAuth Category = "auth"
CategorySystem Category = "system"
)
type Outcome string
const (
OutcomeSuccess Outcome = "success"
OutcomeFailed Outcome = "failed"
)
type Source string
const (
SourceWeb Source = "web"
SourceAPIKey Source = "api_key"
SourceSystem Source = "system"
)
type ChangeKind string
const (
ChangeText ChangeKind = "text"
ChangeBody ChangeKind = "body"
)
const (
ActionLogin = "auth.login"
ActionLoginFailed = "auth.login_failed"
ActionLogout = "auth.logout"
ActionPasswordResetRequest = "auth.password_reset_requested"
ActionPasswordResetDone = "auth.password_reset_completed"
ActionAPIKeyCreate = "api_key.create"
ActionAPIKeyRevoke = "api_key.revoke"
ActionRetentionChanged = "system.activity_retention_changed"
ActionCleanup = "system.activity_cleanup"
ActionArticleCreate = "article.create"
ActionArticleEdit = "article.edit"
ActionArticlePublish = "article.publish"
ActionArticleUnpublish = "article.unpublish"
ActionArticleDelete = "article.delete"
ActionMediaUpload = "media.upload"
ActionMediaRename = "media.rename"
ActionMediaDelete = "media.delete"
ActionMediaMerge = "media.merge"
ActionMediaPurge = "media.purge"
ActionUserRegister = "user.register"
ActionUserVerify = "user.verify"
ActionUserUpdateProfile = "user.update_profile"
ActionInviteCreate = "invite.create"
ActionInviteCancel = "invite.cancel"
ActionInviteRenew = "invite.renew"
ActionInviteAccept = "invite.accept"
ActionMediaPurgeScheduled = "system.media_purge"
ActionUpdateChecked = "system.update_checked"
ActionUpdateDownloaded = "system.update_downloaded"
ActionRestartRequested = "system.restart_requested"
)
const (
TargetAPIKey = "api_key"
TargetUser = "user"
TargetArticle = "article"
TargetImage = "image"
TargetInvite = "invite"
TargetRelease = "release"
)
const (
SystemActorName = "System"
ReasonWrongLogin = "Wrong username or password."
MaxUserAgentLength = 255
ParamRemoved = "removed"
ParamFrom = "from"
ParamTo = "to"
ParamMerged = "merged"
ParamArticlesUpdated = "articles_updated"
ParamFailedGroups = "failed_groups"
ParamUpdateAvailable = "update_available"
ParamKind = "kind"
ParamTag = "tag"
FieldTitle = "title"
FieldOnline = "online"
FieldBody = "body"
FieldFilename = "filename"
FieldDisplayName = "display_name"
maxNameLength = 255
maxActionLength = 64
maxFailureReasonSize = 255
)
func ActorTypes() []ActorType {
return []ActorType{ActorUser, ActorAPIKey, ActorSystem, ActorAnonymous}
}
func Categories() []Category {
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem}
}
func Outcomes() []Outcome {
return []Outcome{OutcomeSuccess, OutcomeFailed}
}
func Sources() []Source {
return []Source{SourceWeb, SourceAPIKey, SourceSystem}
}
func ChangeKinds() []ChangeKind {
return []ChangeKind{ChangeText, ChangeBody}
}
type Actor struct {
Type ActorType `json:"type"`
ID *uint32 `json:"id"`
Name string `json:"name"`
}
type Target struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
}
type Change struct {
Field string `json:"field"`
Kind ChangeKind `json:"kind"`
Before *string `json:"before,omitempty"`
After *string `json:"after,omitempty"`
Delta *int `json:"delta,omitempty"`
}
type Event struct {
ID uint64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Action string `json:"action"`
Category Category `json:"category"`
Actor Actor `json:"actor"`
Target Target `json:"target"`
Outcome Outcome `json:"outcome"`
FailureReason *string `json:"failure_reason"`
Security bool `json:"security"`
Source Source `json:"source"`
IP *string `json:"ip"`
UserAgent *string `json:"user_agent"`
Params map[string]any `json:"params"`
Changes []Change `json:"changes"`
}
func NewEvent(action string, category Category) Event {
return Event{
Action: action,
Category: category,
Outcome: OutcomeSuccess,
Params: map[string]any{},
Changes: []Change{},
}
}
func SystemActor() Actor {
return Actor{Type: ActorSystem, Name: SystemActorName}
}
func UserActor(u user.User) Actor {
id := u.ID
name := strings.TrimSpace(u.DisplayName)
if name == "" {
name = u.Username
}
return Actor{Type: ActorUser, ID: &id, Name: name}
}
func AnonymousActor(name string) Actor {
return Actor{Type: ActorAnonymous, Name: name}
}
func APIKeyActor(id uint32, name string) Actor {
return Actor{Type: ActorAPIKey, ID: &id, Name: name}
}
func APIKeyTarget(id uint32, name string) Target {
return Target{Type: TargetAPIKey, ID: strconv.FormatUint(uint64(id), 10), Name: name}
}
func (e Event) Validate() error {
if e.Action == "" || len(e.Action) > maxActionLength {
return ErrInvalidEvent
}
if !slices.Contains(Categories(), e.Category) || !slices.Contains(Outcomes(), e.Outcome) || !slices.Contains(Sources(), e.Source) || !slices.Contains(ActorTypes(), e.Actor.Type) {
return ErrInvalidEvent
}
if e.FailureReason != nil && len(*e.FailureReason) > maxFailureReasonSize {
return ErrInvalidEvent
}
for _, change := range e.Changes {
if change.Kind != ChangeText && change.Kind != ChangeBody {
return ErrInvalidEvent
}
}
return nil
}
func (e Event) Truncated() Event {
e.Actor.Name = truncate(e.Actor.Name, maxNameLength)
e.Target.Name = truncate(e.Target.Name, maxNameLength)
return e
}
func truncate(value string, limit int) string {
runes := []rune(value)
return string(runes[:min(len(runes), limit)])
}
+98
View File
@@ -0,0 +1,98 @@
package audit
import (
"slices"
"strconv"
"strings"
"time"
)
const SeriesBuckets = 7
type ActorRef struct {
Type ActorType
ID *uint32
}
const (
userActorPrefix = "user:"
apiKeyActorPrefix = "key:"
systemActorRef = "system"
)
func ParseActor(raw string) (ActorRef, error) {
if raw == systemActorRef {
return ActorRef{Type: ActorSystem}, nil
}
if rest, ok := strings.CutPrefix(raw, userActorPrefix); ok {
return parseActorID(ActorUser, rest)
}
if rest, ok := strings.CutPrefix(raw, apiKeyActorPrefix); ok {
return parseActorID(ActorAPIKey, rest)
}
return ActorRef{}, ErrInvalidFilter
}
func parseActorID(actorType ActorType, raw string) (ActorRef, error) {
id, err := strconv.ParseUint(raw, 10, 32)
if err != nil {
return ActorRef{}, ErrInvalidFilter
}
value := uint32(id)
return ActorRef{Type: actorType, ID: &value}, nil
}
func ParseCategory(raw string) (Category, error) {
category := Category(raw)
if !slices.Contains(Categories(), category) {
return "", ErrInvalidFilter
}
return category, nil
}
type Filter struct {
Query string
Actor *ActorRef
Category Category
Outcome Outcome
Security bool
From *time.Time
To *time.Time
Page int
Size int
}
type Page struct {
Items []Event
Total int
}
type SummaryQuery struct {
From time.Time
To time.Time
}
func (q SummaryQuery) Previous() SummaryQuery {
return SummaryQuery{From: q.From.Add(-q.To.Sub(q.From)), To: q.From}
}
type ActivePeople struct {
Users int `json:"users"`
APIKeys int `json:"api_keys"`
}
type Series struct {
Total []int `json:"total"`
Active []int `json:"active"`
Failed []int `json:"failed"`
Security []int `json:"security"`
}
type Summary struct {
Total int `json:"total"`
PreviousTotal int `json:"previous_total"`
ActivePeople ActivePeople `json:"active_people"`
Failed int `json:"failed"`
Security int `json:"security"`
Series Series `json:"series"`
}
+31
View File
@@ -0,0 +1,31 @@
package audit
import "context"
type Recorder interface {
Record(ctx context.Context, event Event)
}
type discardRecorder struct{}
func (discardRecorder) Record(context.Context, Event) {}
var Discard Recorder = discardRecorder{}
type Origin struct {
Actor Actor
Source Source
IP string
UserAgent string
}
type originContextKey struct{}
func WithOrigin(ctx context.Context, origin Origin) context.Context {
return context.WithValue(ctx, originContextKey{}, origin)
}
func OriginFrom(ctx context.Context) (Origin, bool) {
origin, ok := ctx.Value(originContextKey{}).(Origin)
return origin, ok
}
@@ -0,0 +1,16 @@
package audit
import "time"
type Repository interface {
Append(event Event) (Event, error)
List(filter Filter) (Page, error)
Summary(query SummaryQuery) (Summary, error)
DeleteOlderThan(cutoff time.Time) (int, error)
}
type SettingsRepository interface {
Get() (Settings, error)
SaveRetention(days int) error
SaveLastCleanup(at time.Time, removed int) error
}
+57
View File
@@ -0,0 +1,57 @@
package audit
import "time"
const (
DefaultRetentionDays = 90
ForeverRetentionDays = 0
CleanupHour = 3
KeepRecentHours = 24
)
func RetentionOptions() []int {
return []int{30, 90, 365, ForeverRetentionDays}
}
func ValidateRetentionDays(days int) error {
for _, option := range RetentionOptions() {
if option == days {
return nil
}
}
return ErrInvalidRetention
}
type Settings struct {
RetentionDays int `json:"activity_retention_days"`
LastCleanupAt *time.Time `json:"activity_last_cleanup_at"`
LastCleanupRemoved int `json:"activity_last_cleanup_removed"`
}
func DefaultSettings() Settings {
return Settings{RetentionDays: DefaultRetentionDays}
}
func (s Settings) CleanupDue(now time.Time) bool {
todayRun := time.Date(now.Year(), now.Month(), now.Day(), CleanupHour, 0, 0, 0, now.Location())
if now.Before(todayRun) {
return false
}
return s.LastCleanupAt == nil || s.LastCleanupAt.Before(todayRun)
}
func ClampCutoff(now time.Time, cutoff time.Time) time.Time {
newest := now.Add(-KeepRecentHours * time.Hour)
if cutoff.After(newest) {
return newest
}
return cutoff
}
func RetentionCutoff(now time.Time, retentionDays int) time.Time {
return ClampCutoff(now, now.Add(-time.Duration(retentionDays)*24*time.Hour))
}
type CleanupResult struct {
Removed int
}
+11
View File
@@ -0,0 +1,11 @@
package audit
import "strconv"
func NewTarget(targetType string, id uint32, name string) Target {
return Target{Type: targetType, ID: strconv.FormatUint(uint64(id), 10), Name: name}
}
func ReleaseTarget(tag string) Target {
return Target{Type: TargetRelease, ID: tag, Name: tag}
}
+9
View File
@@ -191,6 +191,15 @@ type ImageInput struct {
UploadedByID uint32 UploadedByID uint32
} }
type Metadata struct {
Filename string
Size int64
ContentType string
Width int
Height int
SHA256 string
}
type Image struct { type Image struct {
ID uint32 `json:"id"` ID uint32 `json:"id"`
Path string `json:"path"` Path string `json:"path"`
@@ -6,7 +6,7 @@ type SearchQuery struct {
} }
type Repository interface { type Repository interface {
Create(input ImageInput) (Image, error) Create(image Image) (Image, error)
Get(id uint32) (Image, error) Get(id uint32) (Image, error)
Delete(id uint32) error Delete(id uint32) error
List(query SearchQuery) ([]Image, error) List(query SearchQuery) ([]Image, error)
@@ -14,4 +14,6 @@ type Repository interface {
Rename(id uint32, filename string) (Image, error) Rename(id uint32, filename string) (Image, error)
ListDuplicates() ([]Image, error) ListDuplicates() ([]Image, error)
Counts() (Counts, 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
}
+1
View File
@@ -15,6 +15,7 @@ var ErrInvalidPasswordResetCode = errors.New("invalid password reset code")
var ErrPasswordResetCodeExpired = errors.New("password reset code expired") var ErrPasswordResetCodeExpired = errors.New("password reset code expired")
var ErrInvalidDisplayName = errors.New("invalid display name") var ErrInvalidDisplayName = errors.New("invalid display name")
var ErrUsernameTaken = errors.New("username taken") var ErrUsernameTaken = errors.New("username taken")
var ErrVerificationEmailNotSent = errors.New("verification email not sent")
type Role string type Role string
@@ -0,0 +1,57 @@
package auditing
import (
"RenewCMS/internal/domain/audit"
"context"
"log"
"time"
)
type Recorder struct {
repository audit.Repository
now func() time.Time
logger *log.Logger
}
var _ audit.Recorder = &Recorder{}
func NewRecorder(repository audit.Repository, now func() time.Time, logger *log.Logger) *Recorder {
return &Recorder{repository, now, logger}
}
func (r *Recorder) Record(ctx context.Context, event audit.Event) {
defer func() {
if recovered := recover(); recovered != nil {
r.logger.Printf("audit: writing the %s event panicked: %v", event.Action, recovered)
}
}()
event = r.complete(ctx, event).Truncated()
if err := event.Validate(); err != nil {
r.logger.Printf("audit: dropped the %s event: %v", event.Action, err)
return
}
if _, err := r.repository.Append(event); err != nil {
r.logger.Printf("audit: failed to write the %s event: %v", event.Action, err)
}
}
func (r *Recorder) complete(ctx context.Context, event audit.Event) audit.Event {
origin, _ := audit.OriginFrom(ctx)
if event.Actor.Type == "" {
event.Actor = origin.Actor
}
if event.Source == "" {
event.Source = origin.Source
}
if event.IP == nil && origin.IP != "" {
ip := origin.IP
event.IP = &ip
}
if event.UserAgent == nil && origin.UserAgent != "" {
userAgent := origin.UserAgent
event.UserAgent = &userAgent
}
event.CreatedAt = r.now().UTC()
return event
}
@@ -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 ( import (
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -27,6 +28,9 @@ type Settings struct {
UploadDir string UploadDir string
JWTSecret string JWTSecret string
DBFile string DBFile string
Database DatabaseSettings
Storage StorageSettings
DataDirPath string
Port string Port string
Smtp SmtpSettings Smtp SmtpSettings
AuthRateLimit AuthRateLimitSettings AuthRateLimit AuthRateLimitSettings
@@ -36,6 +40,13 @@ type Settings struct {
Container bool Container bool
} }
func (s Settings) DataDir() string {
if s.DataDirPath != "" {
return s.DataDirPath
}
return filepath.Dir(s.DBFile)
}
func (s Settings) IsProduction() bool { func (s Settings) IsProduction() bool {
return s.Environment == "production" return s.Environment == "production"
} }
@@ -56,6 +67,16 @@ func NewSettingsFromEnv() (Settings, error) {
return Settings{}, err return Settings{}, err
} }
database, err := databaseSettingsFromEnv()
if err != nil {
return Settings{}, err
}
storage, err := storageSettingsFromEnv()
if err != nil {
return Settings{}, err
}
return Settings{ return Settings{
Environment: os.Getenv("ENVIRONMENT"), Environment: os.Getenv("ENVIRONMENT"),
CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")), CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")),
@@ -63,6 +84,9 @@ func NewSettingsFromEnv() (Settings, error) {
UploadDir: os.Getenv("UPLOAD_DIR"), UploadDir: os.Getenv("UPLOAD_DIR"),
JWTSecret: os.Getenv("JWT_SECRET"), JWTSecret: os.Getenv("JWT_SECRET"),
DBFile: os.Getenv("DB_FILE"), DBFile: os.Getenv("DB_FILE"),
Database: database,
Storage: storage,
DataDirPath: os.Getenv("DATA_DIR"),
Port: os.Getenv("PORT"), Port: os.Getenv("PORT"),
Smtp: SmtpSettings{ Smtp: SmtpSettings{
Login: os.Getenv("SMTP_LOGIN"), 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{}, &models.AuditEvent{}, &models.AuditSettings{})
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 ( import (
domainUser "RenewCMS/internal/domain/user" domainUser "RenewCMS/internal/domain/user"
@@ -0,0 +1,97 @@
package mappers
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/persistence/models"
"encoding/json"
)
const (
emptyParams = "{}"
emptyChanges = "[]"
)
func AuditEventToModel(e audit.Event) (models.AuditEvent, error) {
params, err := encodeJSON(e.Params, emptyParams)
if err != nil {
return models.AuditEvent{}, err
}
changes, err := encodeJSON(e.Changes, emptyChanges)
if err != nil {
return models.AuditEvent{}, err
}
return models.AuditEvent{
ID: e.ID,
CreatedAt: e.CreatedAt.UTC(),
Action: e.Action,
Category: string(e.Category),
ActorType: string(e.Actor.Type),
ActorID: e.Actor.ID,
ActorName: e.Actor.Name,
TargetType: e.Target.Type,
TargetID: e.Target.ID,
TargetName: e.Target.Name,
Outcome: string(e.Outcome),
FailureReason: e.FailureReason,
Security: e.Security,
Source: string(e.Source),
IP: e.IP,
UserAgent: e.UserAgent,
Params: params,
Changes: changes,
}, nil
}
func AuditEventToDomain(m models.AuditEvent) (audit.Event, error) {
params := map[string]any{}
if err := json.Unmarshal([]byte(m.Params), &params); err != nil {
return audit.Event{}, err
}
changes := []audit.Change{}
if err := json.Unmarshal([]byte(m.Changes), &changes); err != nil {
return audit.Event{}, err
}
return audit.Event{
ID: m.ID,
CreatedAt: m.CreatedAt.UTC(),
Action: m.Action,
Category: audit.Category(m.Category),
Actor: audit.Actor{Type: audit.ActorType(m.ActorType), ID: m.ActorID, Name: m.ActorName},
Target: audit.Target{Type: m.TargetType, ID: m.TargetID, Name: m.TargetName},
Outcome: audit.Outcome(m.Outcome),
FailureReason: m.FailureReason,
Security: m.Security,
Source: audit.Source(m.Source),
IP: m.IP,
UserAgent: m.UserAgent,
Params: params,
Changes: changes,
}, nil
}
func encodeJSON(value any, empty string) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
if string(encoded) == "null" {
return empty, nil
}
return string(encoded), nil
}
func AuditSettingsToDomain(m models.AuditSettings) audit.Settings {
return audit.Settings{
RetentionDays: m.RetentionDays,
LastCleanupAt: m.LastCleanupAt,
LastCleanupRemoved: m.LastCleanupRemoved,
}
}
func AuditSettingsToModel(s audit.Settings) models.AuditSettings {
return models.AuditSettings{
RetentionDays: s.RetentionDays,
LastCleanupAt: s.LastCleanupAt,
LastCleanupRemoved: s.LastCleanupRemoved,
}
}
@@ -7,7 +7,7 @@ type APIKey struct {
UserID uint32 `gorm:"index;not null"` UserID uint32 `gorm:"index;not null"`
Name string `gorm:"not null"` Name string `gorm:"not null"`
Prefix 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 ExpiresAt *time.Time
RevokedAt *time.Time RevokedAt *time.Time
LastUsedAt *time.Time LastUsedAt *time.Time
@@ -0,0 +1,24 @@
package models
import "time"
type AuditEvent struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
CreatedAt time.Time `gorm:"not null;index:idx_audit_events_created_at;index:idx_audit_events_actor,priority:3;index:idx_audit_events_category,priority:2"`
Action string `gorm:"not null;size:64"`
Category string `gorm:"not null;size:16;index:idx_audit_events_category,priority:1"`
ActorType string `gorm:"not null;size:16;index:idx_audit_events_actor,priority:1"`
ActorID *uint32 `gorm:"index:idx_audit_events_actor,priority:2"`
ActorName string `gorm:"not null;size:255"`
TargetType string `gorm:"not null;size:32"`
TargetID string `gorm:"not null;size:64"`
TargetName string `gorm:"not null;size:255"`
Outcome string `gorm:"not null;size:16"`
FailureReason *string `gorm:"size:255"`
Security bool `gorm:"not null"`
Source string `gorm:"not null;size:16"`
IP *string `gorm:"size:64"`
UserAgent *string `gorm:"size:255"`
Params string `gorm:"not null;type:text"`
Changes string `gorm:"not null;type:text"`
}
@@ -0,0 +1,10 @@
package models
import "time"
type AuditSettings struct {
ID uint32 `gorm:"primaryKey"`
RetentionDays int `gorm:"not null"`
LastCleanupAt *time.Time
LastCleanupRemoved int `gorm:"not null"`
}
@@ -15,7 +15,7 @@ type Image struct {
ContentType string ContentType string
Width int Width int
Height int Height int
SHA256 string `gorm:"column:sha256;index"` SHA256 string `gorm:"column:sha256;index;size:64"`
UploadedByID uint32 UploadedByID uint32
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
@@ -4,7 +4,7 @@ import "time"
type Invite struct { type Invite struct {
ID uint32 `gorm:"primaryKey;autoIncrement"` ID uint32 `gorm:"primaryKey;autoIncrement"`
Email string `gorm:"index;not null"` Email string `gorm:"index;not null;size:255"`
HashedCode string `gorm:"not null"` HashedCode string `gorm:"not null"`
ExpiresAt time.Time ExpiresAt time.Time
InvitedBy uint32 `gorm:"not null"` InvitedBy uint32 `gorm:"not null"`
@@ -6,14 +6,14 @@ import (
type User struct { type User struct {
ID uint32 `gorm:"primaryKey;autoIncrement"` ID uint32 `gorm:"primaryKey;autoIncrement"`
Username string `gorm:"unique;not null"` Username string `gorm:"unique;not null;size:255"`
Password string `gorm:"not null"` Password string `gorm:"not null"`
PasswordResetCode *string `gorm:"unique"` PasswordResetCode *string `gorm:"unique;size:255"`
Email string `gorm:"unique;not null"` Email string `gorm:"unique;not null;size:255"`
DisplayName string `gorm:"not null;default:''"` DisplayName string `gorm:"not null;default:'';size:255"`
IsVerified bool `gorm:"default=false;not null"` IsVerified bool `gorm:"default=false;not null"`
Role string `gorm:"not null;default:''"` Role string `gorm:"not null;default:'';size:32"`
VerificationCode *string `gorm:"unique"` VerificationCode *string `gorm:"unique;size:255"`
VerificationExpiration time.Time VerificationExpiration time.Time
PasswordResetExpiration time.Time PasswordResetExpiration time.Time
TokenVersion uint32 `gorm:"default:0;not null"` 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) { func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Article, error) {
var entityArticles []entity.Article 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 { if err != nil {
return nil, err return nil, err
} }
@@ -0,0 +1,188 @@
package persistence
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"strings"
"time"
"gorm.io/gorm"
)
const likeEscape = " ESCAPE '!'"
type AuditEventRepository struct {
db *gorm.DB
}
var _ audit.Repository = &AuditEventRepository{}
func NewAuditEventRepository(db *gorm.DB) *AuditEventRepository {
return &AuditEventRepository{db}
}
func (a *AuditEventRepository) Append(event audit.Event) (audit.Event, error) {
model, err := mappers.AuditEventToModel(event)
if err != nil {
return audit.Event{}, err
}
model.ID = 0
if err := a.db.Create(&model).Error; err != nil {
return audit.Event{}, err
}
return mappers.AuditEventToDomain(model)
}
func (a *AuditEventRepository) List(filter audit.Filter) (audit.Page, error) {
var total int64
if err := a.filtered(filter).Count(&total).Error; err != nil {
return audit.Page{}, err
}
query := a.filtered(filter).Order("created_at DESC, id DESC")
if filter.Size > 0 {
query = query.Limit(filter.Size).Offset(offsetOf(filter))
}
var found []entity.AuditEvent
if err := query.Find(&found).Error; err != nil {
return audit.Page{}, err
}
items := make([]audit.Event, 0, len(found))
for _, model := range found {
event, err := mappers.AuditEventToDomain(model)
if err != nil {
return audit.Page{}, err
}
items = append(items, event)
}
return audit.Page{Items: items, Total: int(total)}, nil
}
func offsetOf(filter audit.Filter) int {
if filter.Page < 1 {
return 0
}
return (filter.Page - 1) * filter.Size
}
func (a *AuditEventRepository) filtered(filter audit.Filter) *gorm.DB {
query := a.db.Model(&entity.AuditEvent{})
if filter.Actor != nil {
query = query.Where("actor_type = ?", string(filter.Actor.Type))
if filter.Actor.ID != nil {
query = query.Where("actor_id = ?", *filter.Actor.ID)
}
}
if filter.Category != "" {
query = query.Where("category = ?", string(filter.Category))
}
if filter.Outcome != "" {
query = query.Where("outcome = ?", string(filter.Outcome))
}
if filter.Security {
query = query.Where("security = ?", true)
}
if filter.From != nil {
query = query.Where("created_at >= ?", filter.From.UTC())
}
if filter.To != nil {
query = query.Where("created_at < ?", filter.To.UTC())
}
if filter.Query != "" {
pattern := containsPattern(strings.ToLower(filter.Query))
query = query.Where("(LOWER(actor_name) LIKE ?"+likeEscape+" OR LOWER(target_name) LIKE ?"+likeEscape+")", pattern, pattern)
}
return query
}
func (a *AuditEventRepository) Summary(query audit.SummaryQuery) (audit.Summary, error) {
var summary audit.Summary
totals, err := a.countsBetween(query.From, query.To)
if err != nil {
return audit.Summary{}, err
}
summary.Total, summary.Failed, summary.Security = totals.Total, totals.Failed, totals.Security
previous := query.Previous()
previousTotals, err := a.countsBetween(previous.From, previous.To)
if err != nil {
return audit.Summary{}, err
}
summary.PreviousTotal = previousTotals.Total
if summary.ActivePeople.Users, err = a.distinctActors(audit.ActorUser, query.From, query.To); err != nil {
return audit.Summary{}, err
}
if summary.ActivePeople.APIKeys, err = a.distinctActors(audit.ActorAPIKey, query.From, query.To); err != nil {
return audit.Summary{}, err
}
summary.Series, err = a.series(query)
return summary, err
}
type countsRow struct {
Total int
Failed int
Security int
}
func (a *AuditEventRepository) countsBetween(from time.Time, to time.Time) (countsRow, error) {
var row countsRow
err := a.db.Model(&entity.AuditEvent{}).
Select("COUNT(*) AS total, "+
"COALESCE(SUM(CASE WHEN outcome = ? THEN 1 ELSE 0 END), 0) AS failed, "+
"COALESCE(SUM(CASE WHEN security = ? THEN 1 ELSE 0 END), 0) AS security", string(audit.OutcomeFailed), true).
Where("created_at >= ? AND created_at < ?", from.UTC(), to.UTC()).
Scan(&row).Error
return row, err
}
func (a *AuditEventRepository) distinctActors(actorType audit.ActorType, from time.Time, to time.Time) (int, error) {
var count int64
err := a.db.Model(&entity.AuditEvent{}).
Where("actor_type = ? AND created_at >= ? AND created_at < ?", string(actorType), from.UTC(), to.UTC()).
Distinct("actor_id").
Count(&count).Error
return int(count), err
}
func (a *AuditEventRepository) series(query audit.SummaryQuery) (audit.Series, error) {
series := audit.Series{
Total: make([]int, audit.SeriesBuckets),
Active: make([]int, audit.SeriesBuckets),
Failed: make([]int, audit.SeriesBuckets),
Security: make([]int, audit.SeriesBuckets),
}
width := query.To.Sub(query.From) / audit.SeriesBuckets
if width <= 0 {
return series, nil
}
for i := 0; i < audit.SeriesBuckets; i++ {
from := query.From.Add(time.Duration(i) * width)
to := from.Add(width)
if i == audit.SeriesBuckets-1 {
to = query.To
}
counts, err := a.countsBetween(from, to)
if err != nil {
return audit.Series{}, err
}
users, err := a.distinctActors(audit.ActorUser, from, to)
if err != nil {
return audit.Series{}, err
}
keys, err := a.distinctActors(audit.ActorAPIKey, from, to)
if err != nil {
return audit.Series{}, err
}
series.Total[i], series.Failed[i], series.Security[i], series.Active[i] = counts.Total, counts.Failed, counts.Security, users+keys
}
return series, nil
}
func (a *AuditEventRepository) DeleteOlderThan(cutoff time.Time) (int, error) {
result := a.db.Where("created_at < ?", cutoff.UTC()).Delete(&entity.AuditEvent{})
return int(result.RowsAffected), result.Error
}
@@ -0,0 +1,61 @@
package persistence
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"time"
"gorm.io/gorm"
)
const auditSettingsRowID uint32 = 1
type AuditSettingsRepository struct {
db *gorm.DB
}
var _ audit.SettingsRepository = &AuditSettingsRepository{}
func NewAuditSettingsRepository(db *gorm.DB) *AuditSettingsRepository {
return &AuditSettingsRepository{db}
}
func (a *AuditSettingsRepository) Get() (audit.Settings, error) {
var model entity.AuditSettings
err := a.db.First(&model, auditSettingsRowID).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return audit.DefaultSettings(), nil
}
if err != nil {
return audit.Settings{}, err
}
return mappers.AuditSettingsToDomain(model), nil
}
func (a *AuditSettingsRepository) SaveRetention(days int) error {
return a.update(map[string]any{"retention_days": days}, func(settings *audit.Settings) {
settings.RetentionDays = days
})
}
func (a *AuditSettingsRepository) SaveLastCleanup(at time.Time, removed int) error {
utc := at.UTC()
return a.update(map[string]any{"last_cleanup_at": utc, "last_cleanup_removed": removed}, func(settings *audit.Settings) {
settings.LastCleanupAt = &utc
settings.LastCleanupRemoved = removed
})
}
func (a *AuditSettingsRepository) update(columns map[string]any, apply func(*audit.Settings)) error {
result := a.db.Model(&entity.AuditSettings{}).Where("id = ?", auditSettingsRowID).Updates(columns)
if result.Error != nil || result.RowsAffected > 0 {
return result.Error
}
settings := audit.DefaultSettings()
apply(&settings)
model := mappers.AuditSettingsToModel(settings)
model.ID = auditSettingsRowID
return a.db.Create(&model).Error
}
@@ -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" domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers" "RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"io"
"os"
"path/filepath"
"strings" "strings"
"github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
) )
const uploadedImagesURLPrefix = "/static/uploadedImages/"
type ImageRepository struct { type ImageRepository struct {
db *gorm.DB db *gorm.DB
uploadDir string
} }
func NewImageRepository(db *gorm.DB, uploadDir string) *ImageRepository { func NewImageRepository(db *gorm.DB) *ImageRepository {
return &ImageRepository{db, uploadDir} return &ImageRepository{db}
} }
var contentTypeExtensions = map[string]string{ func (i ImageRepository) Create(image domain.Image) (domain.Image, error) {
"image/png": ".png", model := mappers.ImageToModel(image)
"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,
})
if err := i.db.Create(&model).Error; err != nil { if err := i.db.Create(&model).Error; err != nil {
_ = os.Remove(finalPath)
return domain.Image{}, err return domain.Image{}, err
} }
return mappers.ImageToDomain(model), nil 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 { func (i ImageRepository) Delete(id uint32) error {
var model entity.Image return deleteOrNotFound(i.db.Delete(&entity.Image{}, id), domain.ErrImageNotFound)
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
} }
func (i ImageRepository) List(query domain.SearchQuery) ([]domain.Image, error) { func (i ImageRepository) List(query domain.SearchQuery) ([]domain.Image, error) {
statement := i.db.Model(&entity.Image{}) statement := i.db.Model(&entity.Image{})
if query.Search != "" { 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 != "" { if query.Hash != "" {
statement = statement.Where("sha256 = ?", query.Hash) statement = statement.Where("sha256 = ?", query.Hash)
@@ -171,4 +117,24 @@ func imagesToDomain(models []entity.Image) []domain.Image {
return images 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{} var _ domain.Repository = &ImageRepository{}
@@ -2,6 +2,7 @@ package persistence
import ( import (
"errors" "errors"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -22,3 +23,8 @@ func deleteOrNotFound(result *gorm.DB, sentinel error) error {
} }
return nil 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) { func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
creationResult := u.db.Create(&entity.User{ createdUser := entity.User{
Username: user.Username, Username: user.Username,
Password: user.Password, Password: user.Password,
PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode), PasswordResetCode: mappers.DomainToNullableString(user.PasswordResetCode),
@@ -37,16 +37,12 @@ func (u *UserRepository) Create(user domainUser.User) (domainUser.User, error) {
Role: string(user.Role), Role: string(user.Role),
VerificationCode: mappers.DomainToNullableString(user.VerificationCode), VerificationCode: mappers.DomainToNullableString(user.VerificationCode),
VerificationExpiration: user.VerificationExpiration, VerificationExpiration: user.VerificationExpiration,
}) }
if creationResult.Error != nil { if err := u.db.Create(&createdUser).Error; err != nil {
return domainUser.User{}, creationResult.Error return domainUser.User{}, err
} }
var createdUser entity.User return mappers.UserToDomain(createdUser), nil
creationResult.Scan(&createdUser)
return mappers.UserToDomain(createdUser),
nil
} }
func (u *UserRepository) Delete(id uint32) error { func (u *UserRepository) Delete(id uint32) error {
+64 -9
View File
@@ -1,9 +1,11 @@
package scheduler package scheduler
import ( import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/purge" "RenewCMS/internal/domain/purge"
"context" "context"
"errors" "errors"
"fmt"
"log" "log"
"time" "time"
) )
@@ -17,8 +19,39 @@ type Job interface {
Tick(now time.Time) (purge.RunResult, bool, error) Tick(now time.Time) (purge.RunResult, bool, error)
} }
type CleanupJob interface {
Tick(now time.Time) (audit.CleanupResult, bool, error)
}
type task interface {
tick(now time.Time) (summary string, ran bool, err error)
}
type purgeTask struct {
job Job
}
func (p purgeTask) tick(now time.Time) (string, bool, error) {
result, ran, err := p.job.Tick(now)
if errors.Is(err, purge.ErrPurgeBusy) {
return "", false, nil
}
summary := fmt.Sprintf("groups_purged=%d merged=%d articles_updated=%d failed_groups=%d", result.GroupsPurged, result.Merged, result.ArticlesUpdated, result.FailedGroups)
return summary, ran, err
}
type cleanupTask struct {
job CleanupJob
}
func (c cleanupTask) tick(now time.Time) (string, bool, error) {
result, ran, err := c.job.Tick(now)
return fmt.Sprintf("removed=%d", result.Removed), ran, err
}
type Runner struct { type Runner struct {
job Job label string
task task
now func() time.Time now func() time.Time
ticks <-chan time.Time ticks <-chan time.Time
startup <-chan time.Time startup <-chan time.Time
@@ -26,14 +59,39 @@ type Runner struct {
release func() release func()
} }
const (
purgeLabel = "automatic purge"
cleanupLabel = "activity cleanup"
)
func newRunner(label string, task task, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner {
return &Runner{label: label, task: task, now: now, ticks: ticks, startup: startup, logger: logger, release: func() {}}
}
func NewRunner(job Job, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner { func NewRunner(job Job, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner {
return &Runner{job: job, now: now, ticks: ticks, startup: startup, logger: logger, release: func() {}} return newRunner(purgeLabel, purgeTask{job}, now, ticks, startup, logger)
}
func NewCleanupRunner(job CleanupJob, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner {
return newRunner(cleanupLabel, cleanupTask{job}, now, ticks, startup, logger)
} }
func NewSystemRunner(job Job, logger *log.Logger) *Runner { func NewSystemRunner(job Job, logger *log.Logger) *Runner {
return withSystemTimers(func(now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time) *Runner {
return NewRunner(job, now, ticks, startup, logger)
})
}
func NewSystemCleanupRunner(job CleanupJob, logger *log.Logger) *Runner {
return withSystemTimers(func(now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time) *Runner {
return NewCleanupRunner(job, now, ticks, startup, logger)
})
}
func withSystemTimers(build func(now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time) *Runner) *Runner {
ticker := time.NewTicker(TickInterval) ticker := time.NewTicker(TickInterval)
startup := time.NewTimer(StartupDelay) startup := time.NewTimer(StartupDelay)
runner := NewRunner(job, time.Now, ticker.C, startup.C, logger) runner := build(time.Now, ticker.C, startup.C)
runner.release = func() { runner.release = func() {
ticker.Stop() ticker.Stop()
startup.Stop() startup.Stop()
@@ -61,15 +119,12 @@ func (r *Runner) tick(ctx context.Context) {
if ctx.Err() != nil { if ctx.Err() != nil {
return return
} }
result, ran, err := r.job.Tick(r.now()) summary, ran, err := r.task.tick(r.now())
if errors.Is(err, purge.ErrPurgeBusy) {
return
}
if err != nil { if err != nil {
r.logger.Printf("scheduler: automatic purge failed: %v", err) r.logger.Printf("scheduler: %s failed: %v", r.label, err)
return return
} }
if ran { if ran {
r.logger.Printf("scheduler: automatic purge completed: groups_purged=%d merged=%d articles_updated=%d failed_groups=%d", result.GroupsPurged, result.Merged, result.ArticlesUpdated, result.FailedGroups) r.logger.Printf("scheduler: %s completed: %s", r.label, summary)
} }
} }
@@ -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
}
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff. Show more