Compare commits

..
17 Commits
Author SHA1 Message Date
fsylvain 8907bb49fe chore: groundwork for activity page 2026-10-06 14:23:51 +02:00
fsylvain dc5d12348c feat: PostgreSQL driver support 2026-10-06 13:35:10 +02:00
fsylvain eb9bfabed5 feat: optional S3-compatible storage for uploaded images, instead of the local upload directory 2026-10-06 12:13:24 +02:00
fsylvain d752a61e52 feat: optional MySQL/MariaDB database driver for deployments that cannot use local SQLite 2026-10-06 04:08:33 +02:00
fsylvain 81e299f09a refac: decouple persistence and image storage behind driver registries 2026-10-06 02:13:35 +02:00
fsylvain 421cfafec3 release: v2.9.0-beta Media duplicate purge 2026-10-05 23:58:54 +02:00
fsylvain 2303b12377 feat: media duplicate purge, part 3 of 3: the SPA (Settings "Media cleanup" card, PurgeDuplicatesDialog, Media Duplicates toolbar button) 2026-10-05 23:57:50 +02:00
fsylvain 1a55a39425 feat: media duplicate purge, part 2 of 3: the automatic purge scheduler (Go only) 2026-10-05 19:44:15 +02:00
fsylvain e82d0ce254 feat: media duplicate purge, part 1 of 3: persisted purge settings and the purge API 2026-10-05 18:24:03 +02:00
fsylvain 161ddc0f7d chore: groundwork for media purge system 2026-10-05 17:16:06 +02:00
fsylvain c1e73bda81 release: v2.8.3-beta Security policy fixes for the web app 2026-10-05 16:33:31 +02:00
fsylvain 508a8ab77e fix: CSP policy missing front components like icons, nextui color mode, wasm 2026-10-05 16:32:19 +02:00
fsylvain 1e5f324579 release: v2.8.2-beta Security headers and clearer log in errors 2026-10-05 16:24:12 +02:00
fsylvain 1d4f4bd2cc fix: missing security headers on every response, including the SPA and uploaded images 2026-10-05 16:19:37 +02:00
fsylvain d8f171b8cc fix: more complete log in username error messages 2026-10-05 16:04:24 +02:00
fsylvain 8ad3941f6d release: v2.8.1-beta Docker deployment 2026-10-05 04:56:12 +02:00
fsylvain d275ba3c14 feat: complete dockerization (optional) 2026-10-05 04:52:49 +02:00
196 changed files with 17706 additions and 543 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
.git
.gitignore
.dockerignore
.env
docker.env
tmp/
bin/
data/
node_modules
web/node_modules
internal/infrastructure/assets/dist
.task/
.claude/
.idea
.vscode
*.db
*.exe
+22
View File
@@ -15,3 +15,25 @@ AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES=
UPDATE_FEED_URL=https://git.floriansylvain.fr
RENEWCMS_CONTAINER=false
DB_DRIVER=sqlite
DATA_DIR=
DB_HOST=
DB_PORT=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_TLS=false
DB_SSLMODE=disable
DB_MAX_OPEN_CONNS=10
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME_SECONDS=300
STORAGE_DRIVER=local
S3_BUCKET=
S3_REGION=us-east-1
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_KEY_PREFIX=
S3_PATH_STYLE=false
S3_TLS=true
+1
View File
@@ -12,6 +12,7 @@ dist/
# Environment
.env
docker.env
# Generated files
tmp/
+30
View File
@@ -0,0 +1,30 @@
FROM oven/bun:1-alpine AS web
WORKDIR /src/web
COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile
COPY web/ ./
RUN bun run build-only
FROM golang:1.27.1-alpine AS server
ARG VERSION=dev
ENV CGO_ENABLED=0
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=web /src/internal/infrastructure/assets/dist ./internal/infrastructure/assets/dist
RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/renewcms ./cmd/server
FROM alpine:3.22
RUN addgroup -S -g 10001 renewcms \
&& adduser -S -u 10001 -G renewcms -H -s /sbin/nologin renewcms \
&& mkdir /data \
&& chown renewcms:renewcms /data
COPY --from=server /out/renewcms /usr/local/bin/renewcms
ENV RENEWCMS_CONTAINER=true
USER renewcms
WORKDIR /data
VOLUME /data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["wget", "-qO-", "http://127.0.0.1:8080/healthz"]
ENTRYPOINT ["/usr/local/bin/renewcms"]
+11
View File
@@ -15,6 +15,16 @@ cp .env.example .env # fill in PUBLIC_URL, JWT_SECRET and SMTP_*
Data is stored in `./data/` by default. For a real server (systemd, HTTPS, backups), see **[docs/deployment.md](docs/deployment.md)**.
### Run with Docker
```sh
cp docker.env.example docker.env # fill in PUBLIC_URL, JWT_SECRET and SMTP_*
docker compose pull
docker compose up -d # http://localhost:8080
```
Upgrade with `docker compose pull && docker compose up -d`. The image `git.floriansylvain.fr/fsylvain/renewcms` is published by the maintainer with `task docker:publish` (no CI). Tags: `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release; `vX.Y.Z-<pre>`, `X.Y.Z-<pre>` and the moving `beta` or `rc` for a pre-release (`latest` never moves for those, so the default is `beta` for now). To build from source use the override described in **[docs/docker.md](docs/docker.md)**.
### Updates from the browser
On Linux with systemd, install v2.6.0-beta or newer manually, then follow [the browser-update setup guide](docs/deployment.md#browser-updates). It covers finding the actual database directory, installing the helper, testing a restart and troubleshooting. Other platforms use manual upgrades.
@@ -35,6 +45,7 @@ task dev # API with hot reload + Vite HMR → http://localhost:51
|---|---|
| [docs/development.md](docs/development.md) | Dev setup, how Vite and the API fit together, tests, lint, `task` commands |
| [docs/deployment.md](docs/deployment.md) | Building release binaries, running as a service, HTTPS, backups, upgrades |
| [docs/docker.md](docs/docker.md) | Running RenewCMS in Docker: image, Compose, data volume, backups, reverse proxy, tags, upgrades, publishing |
| [docs/configuration.md](docs/configuration.md) | Every environment variable |
| [docs/architecture.md](docs/architecture.md) | Backend layering and how the SPA is embedded |
+248
View File
@@ -10,6 +10,7 @@ vars:
env:
CGO_ENABLED: '0'
__COMPAT_LAYER: RunAsInvoker
tasks:
default:
@@ -46,6 +47,127 @@ tasks:
cmds:
- go test -tags nofrontend ./test/... -count=1 {{.CLI_ARGS}}
test:mysql:
desc: Run the MySQL tests against real servers (mysql:8.4 then mariadb:11 in Docker, removed afterwards)
cmds:
- cmd: |
PASSWORD=renewcms-test-root
STATUS=0
run_engine() {
NAME="renewcms-test-$1"
IMAGE="$2"
docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -e MYSQL_ROOT_PASSWORD="$PASSWORD" -p 127.0.0.1::3306 "$IMAGE" >/dev/null || return 1
MAPPING="$(docker port "$NAME" 3306/tcp)"
PORT="${MAPPING##*:}"
READY=no
TRIES=0
while [ "$TRIES" -lt 90 ]; do
if docker exec "$NAME" sh -c "\$(command -v mariadb || command -v mysql) -h127.0.0.1 -uroot -p$PASSWORD -e 'SELECT 1'" >/dev/null 2>&1; then
READY=yes
break
fi
TRIES=$((TRIES + 1))
sleep 2
done
if [ "$READY" != yes ]; then
echo "$IMAGE did not become ready" >&2
docker logs "$NAME" 2>&1 | tail -n 20 >&2
docker rm -f "$NAME" >/dev/null 2>&1
return 1
fi
echo "== $IMAGE on 127.0.0.1:$PORT"
RENEWCMS_TEST_MYSQL_DSN="root:$PASSWORD@tcp(127.0.0.1:$PORT)/mysql" go test -tags nofrontend ./test/... -run MySQL -count=1 {{.CLI_ARGS}}
RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1
return $RESULT
}
run_engine mysql mysql:8.4 || STATUS=1
run_engine mariadb mariadb:11 || STATUS=1
exit $STATUS
silent: true
test:postgres:
desc: Run the PostgreSQL tests against real servers (postgres:16 then postgres:17 in Docker, removed afterwards)
cmds:
- cmd: |
PASSWORD=renewcms-test-postgres
STATUS=0
run_engine() {
NAME="renewcms-test-postgres-$1"
IMAGE="postgres:$1"
docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -e POSTGRES_PASSWORD="$PASSWORD" -p 127.0.0.1::5432 "$IMAGE" >/dev/null || return 1
MAPPING="$(docker port "$NAME" 5432/tcp | head -n 1)"
PORT="${MAPPING##*:}"
READY=no
TRIES=0
while [ "$TRIES" -lt 90 ]; do
if docker exec "$NAME" pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && docker exec "$NAME" psql -h 127.0.0.1 -U postgres -c 'SELECT 1' >/dev/null 2>&1; then
READY=yes
break
fi
TRIES=$((TRIES + 1))
sleep 2
done
if [ "$READY" != yes ]; then
echo "$IMAGE did not become ready" >&2
docker logs "$NAME" 2>&1 | tail -n 20 >&2
docker rm -f "$NAME" >/dev/null 2>&1
return 1
fi
echo "== $IMAGE on 127.0.0.1:$PORT"
RENEWCMS_TEST_POSTGRES_DSN="postgres://postgres:$PASSWORD@127.0.0.1:$PORT/postgres?sslmode=disable" go test -tags nofrontend ./test/... -run Postgres -count=1 {{.CLI_ARGS}}
RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1
return $RESULT
}
run_engine 16 || STATUS=1
run_engine 17 || STATUS=1
exit $STATUS
silent: true
test:s3:
desc: Run the S3 storage integration test against a throwaway SeaweedFS in Docker (skipped when Docker is unavailable)
cmds:
- cmd: |
if ! docker info >/dev/null 2>&1; then
echo "Docker is not available, skipping the S3 integration test"
exit 0
fi
export MSYS_NO_PATHCONV=1
NAME=renewcms-test-seaweedfs
IMAGE=chrislusf/seaweedfs:4.48
BUCKET=renewcms-test
ACCESS_KEY=renewcms-test
SECRET_KEY=renewcms-test-secret
docker rm -f "$NAME" >/dev/null 2>&1
docker run -d --name "$NAME" -e AWS_ACCESS_KEY_ID="$ACCESS_KEY" -e AWS_SECRET_ACCESS_KEY="$SECRET_KEY" -p 127.0.0.1::8333 "$IMAGE" server -dir=/data -s3 -s3.port=8333 >/dev/null || exit 1
MAPPING="$(docker port "$NAME" 8333/tcp | head -n 1)"
PORT="${MAPPING##*:}"
READY=no
TRIES=0
while [ "$TRIES" -lt 60 ]; do
if docker run --rm --network "container:$NAME" --entrypoint sh "$IMAGE" -c "echo 's3.bucket.create -name $BUCKET' | timeout 30 weed shell -master=127.0.0.1:9333 && echo s3.bucket.list | timeout 30 weed shell -master=127.0.0.1:9333 | grep -q $BUCKET" >/dev/null 2>&1; then
READY=yes
break
fi
TRIES=$((TRIES + 1))
sleep 2
done
if [ "$READY" != yes ]; then
echo "SeaweedFS did not become ready" >&2
docker logs "$NAME" 2>&1 | tail -n 20 >&2
docker rm -f "$NAME" >/dev/null 2>&1
exit 1
fi
echo "== SeaweedFS on 127.0.0.1:$PORT"
RENEWCMS_TEST_S3_ENDPOINT="http://127.0.0.1:$PORT" RENEWCMS_TEST_S3_BUCKET="$BUCKET" RENEWCMS_TEST_S3_ACCESS_KEY="$ACCESS_KEY" RENEWCMS_TEST_S3_SECRET_KEY="$SECRET_KEY" go test -tags nofrontend ./test/... -run S3StorageIntegration -count=1 -v {{.CLI_ARGS}}
RESULT=$?
docker rm -f "$NAME" >/dev/null 2>&1
exit $RESULT
silent: true
cover:
desc: Go test coverage of the use cases → tmp/coverage.out
cmds:
@@ -170,6 +292,132 @@ tasks:
cmds:
- ./bin/{{.BINARY}}{{exeExt}}
docker:build:
desc: Build the Docker image tagged with the git describe version and latest
cmds:
- docker build --build-arg VERSION={{.VERSION}} -t renewcms:{{.VERSION}} -t renewcms:latest .
docker:run:
desc: Run the image once on a named volume, reading docker.env, on http://localhost:8080
cmds:
- docker run --rm --name renewcms -p 8080:8080 --env-file docker.env -e DB_FILE=/data/renewcms.db -e UPLOAD_DIR=/data/uploads -v renewcms-data:/data --stop-timeout 15 renewcms:latest
docker:up:
desc: Build from the local sources and start the Compose service in the background
env:
VERSION: '{{.VERSION}}'
cmds:
- docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
docker:pull:
desc: Pull the published image (RENEWCMS_IMAGE, default the beta tag)
cmds:
- docker compose pull
docker:upgrade:
desc: Pull the published image and recreate the Compose service on it
cmds:
- docker compose pull
- docker compose up -d
docker:down:
desc: Stop and remove the Compose service (the data volume is kept)
cmds:
- docker compose down
docker:logs:
desc: Follow the Compose service logs
cmds:
- docker compose logs -f renewcms
docker:publish:
desc: 'Build and push the multi-arch image to the registry. Usage, task docker:publish [-- --dry-run]'
vars:
REGISTRY_IMAGE:
sh: echo "${REGISTRY_IMAGE-git.floriansylvain.fr/fsylvain/renewcms}"
cmds:
- cmd: |
IMAGE='{{.REGISTRY_IMAGE}}'
V='{{.VERSION}}'
ARGS='{{.CLI_ARGS}}'
fail() { echo "docker:publish refused: $1" >&2; exit 1; }
DRY=no
case "$ARGS" in
"") ;;
"--dry-run") DRY=yes ;;
*) fail "unknown argument '$ARGS' (only --dry-run is accepted)" ;;
esac
[ -n "$IMAGE" ] || fail "REGISTRY_IMAGE is empty"
case "$V" in
v*) ;;
*) fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-<pre>" ;;
esac
R="${V#v}"
CORE="${R%%-*}"
PRE=""
[ "$CORE" = "$R" ] || PRE="${R#*-}"
MAJOR="${CORE%%.*}"
REST="${CORE#*.}"
MINOR="${REST%%.*}"
PATCH="${REST#*.}"
for N in "$MAJOR" "$MINOR" "$PATCH"; do
case "$N" in
""|*[!0-9]*) fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-<pre>" ;;
esac
done
[ "$CORE" = "$MAJOR.$MINOR.$PATCH" ] || fail "VERSION '$V' must be an exact tag vX.Y.Z or vX.Y.Z-<pre>"
if [ "$R" != "$CORE" ]; then
case "$PRE" in
""|*[!0-9A-Za-z.-]*) fail "VERSION '$V' has an invalid pre-release part" ;;
esac
PRENAME="${PRE%%[!A-Za-z]*}"
[ -n "$PRENAME" ] || fail "VERSION '$V' pre-release must start with letters, for example -beta or -rc.1"
fi
if [ "$DRY" = no ]; then
[ -z "$(git status --porcelain)" ] || fail "the work tree has uncommitted changes"
[ "$(git describe --tags --exact-match 2>/dev/null)" = "$V" ] || fail "VERSION '$V' is not an exact git tag on HEAD (an override is accepted only with --dry-run)"
fi
TAGS="$V $R"
if [ -n "$PRE" ]; then
TAGS="$TAGS $PRENAME"
else
TAGS="$TAGS $MAJOR.$MINOR latest"
fi
ARGS_TAGS=""
LIST=""
for T in $TAGS; do
ARGS_TAGS="$ARGS_TAGS -t $IMAGE:$T"
LIST="$LIST, $T"
done
echo "image: $IMAGE"
echo "tags: ${LIST#, }"
if [ "$DRY" = yes ]; then
echo "dry run: building for the host platform only, loading locally, pushing nothing"
docker buildx build --load --build-arg VERSION="$V" $ARGS_TAGS .
exit 0
fi
BUILDER="$(docker buildx inspect 2>&1)" || fail "docker buildx is not available or no builder is selected (see docs/docker.md, Maintainer: publishing)"
STORE="$(docker info 2>&1)"
case "$BUILDER" in
*linux/amd64*) ;;
*) fail "the selected buildx builder does not support linux/amd64 (see docs/docker.md, Maintainer: publishing)" ;;
esac
case "$BUILDER" in
*linux/arm64*) ;;
*) fail "the selected buildx builder does not support linux/arm64: enable QEMU/binfmt (see docs/docker.md, Maintainer: publishing)" ;;
esac
case "$BUILDER" in
*"Driver:"*"docker-container"*|*"Driver:"*"kubernetes"*|*"Driver:"*"remote"*) ;;
*)
case "$STORE" in
*io.containerd.snapshotter*) ;;
*) fail "the selected builder uses the plain docker driver without the containerd image store, which cannot build multi-platform images: run 'docker buildx create --use' (see docs/docker.md, Maintainer: publishing)" ;;
esac
;;
esac
docker buildx build --platform linux/amd64,linux/arm64 --build-arg VERSION="$V" $ARGS_TAGS --push .
silent: true
clean:
desc: Remove build output, the built SPA and tmp/
cmds:
+129 -45
View File
@@ -6,18 +6,20 @@ import (
domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail"
domainPurge "RenewCMS/internal/domain/purge"
domainUpdate "RenewCMS/internal/domain/update"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/persistence/models"
"RenewCMS/internal/infrastructure/persistence/database"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/scheduler"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"crypto/ed25519"
"fmt"
"log"
"net/http"
"os"
@@ -26,15 +28,14 @@ import (
"sync"
"time"
"github.com/glebarez/sqlite"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm"
)
type UseCases struct {
apiKeyAuthOnce sync.Once
apiKeyAuthMiddleware func(http.Handler) http.Handler
Token *jwtauth.JWTAuth
PurgeScheduler *scheduler.Runner
CreateArticleUseCase *useCases.CreateArticleUseCase
GetArticleUseCase *useCases.GetArticleUseCase
ListArticlesUseCase *useCases.ListArticlesUseCase
@@ -57,11 +58,16 @@ type UseCases struct {
AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
OpenMediaUseCase *useCases.OpenMediaUseCase
ListMediaUseCase *useCases.ListMediaUseCase
RenameMediaUseCase *useCases.RenameMediaUseCase
DeleteMediaUseCase *useCases.DeleteMediaUseCase
MergeMediaUseCase *useCases.MergeMediaUseCase
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
GetPurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
@@ -80,6 +86,7 @@ type Repositories struct {
ImageRepo domainImage.Repository
InviteRepo domainInvite.Repository
APIKeyRepo domainAPIKey.Repository
PurgeSettingsRepo domainPurge.Repository
MailRepo domainMail.Repository
ReleaseFeed domainUpdate.ReleaseFeed
UpdateStore domainUpdate.StatusStore
@@ -95,7 +102,6 @@ type Repositories struct {
}
const (
sqliteBusyTimeoutMs = 5000
updateStatusFileName = "update-status.json"
updateJobFileName = "update-job.json"
updateHelperFileName = "update-helper.json"
@@ -103,32 +109,94 @@ const (
backupsDirName = "backups"
)
func SQLiteDSN(dbName string) string {
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs)
func imageStorageConfig(settings config.Settings) storage.Config {
if settings.Storage.Driver == config.StorageDriverS3 {
s3 := settings.Storage.S3
return storage.Config{
Driver: storage.DriverS3,
S3: storage.S3Config{
Bucket: s3.Bucket,
Region: s3.Region,
Endpoint: s3.Endpoint,
AccessKeyID: s3.AccessKeyID,
SecretAccessKey: s3.SecretAccessKey,
KeyPrefix: s3.KeyPrefix,
PathStyle: s3.PathStyle,
TLS: s3.TLS,
},
}
}
return storage.Config{Driver: storage.DriverLocal, Location: settings.UploadDir}
}
func getDb(dbName string, uploadDir string) *gorm.DB {
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil {
panic("Unable to create necessary subdirectories: " + err.Error())
func openImageStorage(settings config.Settings) domainImage.Storage {
imageStorage, err := storage.Open(imageStorageConfig(settings))
if err != nil {
panic("Unable to open the image storage: " + err.Error())
}
db, err := gorm.Open(sqlite.Open(SQLiteDSN(dbName)), &gorm.Config{})
return imageStorage
}
func databaseConfig(settings config.Settings) database.Config {
switch settings.Database.Driver {
case config.DatabaseDriverMySQL:
return mysqlDatabaseConfig(settings.Database)
case config.DatabaseDriverPostgres:
return postgresDatabaseConfig(settings.Database)
case "", config.DatabaseDriverSQLite:
return database.Config{Driver: database.DriverSQLite, Source: settings.DBFile}
default:
return database.Config{Driver: settings.Database.Driver}
}
}
func mysqlDatabaseConfig(settings config.DatabaseSettings) database.Config {
return database.Config{
Driver: database.DriverMySQL,
Source: database.MySQLDSN(database.MySQLConfig{
Host: settings.Host,
Port: settings.Port,
User: settings.User,
Password: settings.Password,
Name: settings.Name,
TLS: settings.TLS,
}),
Pool: &database.PoolSettings{
MaxOpenConns: settings.MaxOpenConns,
MaxIdleConns: settings.MaxIdleConns,
ConnMaxLifetime: settings.ConnMaxLifetime,
},
}
}
func postgresDatabaseConfig(settings config.DatabaseSettings) database.Config {
return database.Config{
Driver: database.DriverPostgres,
Source: database.PostgresDSN(database.PostgresConfig{
Host: settings.Host,
Port: settings.Port,
User: settings.User,
Password: settings.Password,
Name: settings.Name,
SSLMode: settings.SSLMode,
}),
Pool: &database.PoolSettings{
MaxOpenConns: settings.MaxOpenConns,
MaxIdleConns: settings.MaxIdleConns,
ConnMaxLifetime: settings.ConnMaxLifetime,
},
}
}
func openDatabase(settings config.Settings) *database.Connection {
connection, err := database.Connect(databaseConfig(settings))
if err != nil {
panic("Unable to open the database: " + err.Error())
}
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}); err != nil {
if err := database.Migrate(connection.DB); err != nil {
panic("Failed to migrate database: " + err.Error())
}
if err := persistence.BackfillUserRoles(db); err != nil {
panic("Failed to backfill user roles: " + err.Error())
}
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
panic("Failed to backfill image metadata: " + err.Error())
}
return db
return connection
}
func releasePublicKey() ed25519.PublicKey {
@@ -139,35 +207,42 @@ func releasePublicKey() ed25519.PublicKey {
return key
}
func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
dataDir := filepath.Dir(settings.DBFile)
func getRepositories(connection *database.Connection, settings config.Settings) *Repositories {
db := connection.DB
dataDir := settings.DataDir()
return &Repositories{
ArticleRepo: persistence.NewArticleRepository(db),
UserRepo: persistence.NewUserRepository(db),
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
ImageRepo: persistence.NewImageRepository(db),
InviteRepo: persistence.NewInviteRepository(db),
APIKeyRepo: persistence.NewAPIKeyRepository(db),
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
MailRepo: mailer.NewMailRepository(settings.Smtp),
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
UpdateJobs: update.NewJSONJobStore(filepath.Join(dataDir, updateJobFileName)),
UpdateHelper: update.NewFileHelperDetector(filepath.Join(dataDir, updateHelperFileName)),
UpdateHelper: update.NewContainerHelperDetector(update.NewFileHelperDetector(filepath.Join(dataDir, updateHelperFileName)), settings.Container),
AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL),
SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
StagedRunner: update.NewExecStagedBinaryRunner(),
DatabaseBackup: update.NewSQLiteDatabaseBackup(db, filepath.Join(dataDir, backupsDirName), time.Now),
DatabaseBackup: connection.NewBackup(filepath.Join(dataDir, backupsDirName), time.Now),
UpdateResults: update.NewJSONResultStore(filepath.Join(dataDir, domainUpdate.ResultFileName)),
RestartRequests: update.NewJSONRestartRequestStore(filepath.Join(dataDir, domainUpdate.RestartRequestFileName)),
RunningInfo: update.NewJSONRunningInfoStore(filepath.Join(dataDir, domainUpdate.RunningInfoFileName)),
}
}
func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher()
renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator()
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage)
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default())
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge)
return &UseCases{
PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()),
Token: jwtauth.New("HS256", []byte(settings.JWTSecret), nil),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer),
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
@@ -190,14 +265,19 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage),
MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo),
RunPurgeUseCase: runPurge,
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, settings.Container, time.Now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now),
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, repos.UpdateResults, repos.RestartRequests, settings.Version, time.Now),
@@ -220,7 +300,7 @@ func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings) *
Verifier: repos.SignatureVerifier,
Runner: repos.StagedRunner,
Backup: repos.DatabaseBackup,
Staging: update.NewOSStagingArea(filepath.Join(filepath.Dir(settings.DBFile), updatesDirName)),
Staging: update.NewOSStagingArea(filepath.Join(settings.DataDir(), updatesDirName)),
Logger: log.Default(),
CurrentVersion: settings.Version,
Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH},
@@ -229,20 +309,24 @@ func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings) *
})
}
func InitContainer(settings config.Settings) (*UseCases, func() error) {
db := getDb(settings.DBFile, settings.UploadDir)
repos := getRepositories(db, settings)
return getUseCases(repos, settings), closeDb(db)
func backfillImageMetadata(imageRepo domainImage.Repository, imageStorage domainImage.Storage, settings config.Settings) error {
run := func(readable domainImage.Storage) error {
return useCases.NewBackfillImageMetadataUseCase(imageRepo, readable).Run()
}
if settings.Storage.Driver == config.StorageDriverS3 {
return storage.RunBackfillReportingReads(imageStorage, log.Default(), run)
}
return run(imageStorage)
}
func closeDb(db *gorm.DB) func() error {
return func() error {
sqlDb, err := db.DB()
if err != nil {
return err
}
return sqlDb.Close()
func InitContainer(settings config.Settings) (*UseCases, func() error) {
imageStorage := openImageStorage(settings)
connection := openDatabase(settings)
repos := getRepositories(connection, settings)
if err := backfillImageMetadata(repos.ImageRepo, imageStorage, settings); err != nil {
panic("Failed to backfill image metadata: " + err.Error())
}
return getUseCases(repos, imageStorage, settings), connection.Close
}
type BrowserUpdateCommands struct {
+8 -4
View File
@@ -2,6 +2,7 @@ package frontend
import (
"RenewCMS/internal/infrastructure/assets"
"bytes"
"io"
"io/fs"
"net/http"
@@ -93,12 +94,15 @@ func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
}
defer f.Close()
content, ok := f.(io.ReadSeeker)
if !ok {
http.Error(w, "File not seekable", http.StatusInternalServerError)
html, err := io.ReadAll(f)
if err != nil {
http.Error(w, "Index not readable", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", indexCacheControl)
http.ServeContent(w, r, "index.html", h.modTime, content)
if policy := w.Header().Get("Content-Security-Policy"); policy != "" {
w.Header().Set("Content-Security-Policy", AllowInlineScripts(policy, html))
}
http.ServeContent(w, r, "index.html", h.modTime, bytes.NewReader(html))
}
+33
View File
@@ -0,0 +1,33 @@
package frontend
import (
"crypto/sha256"
"encoding/base64"
"regexp"
"strings"
)
const scriptSrcSelf = "script-src 'self'"
var inlineScriptPattern = regexp.MustCompile(`(?is)<script(\s[^>]*)?>(.*?)</script>`)
var srcAttributePattern = regexp.MustCompile(`(?i)\ssrc\s*=`)
func inlineScriptSources(html []byte) []string {
var sources []string
for _, match := range inlineScriptPattern.FindAllSubmatch(html, -1) {
if srcAttributePattern.Match(match[1]) || strings.TrimSpace(string(match[2])) == "" {
continue
}
digest := sha256.Sum256(match[2])
sources = append(sources, "'sha256-"+base64.StdEncoding.EncodeToString(digest[:])+"'")
}
return sources
}
func AllowInlineScripts(policy string, html []byte) string {
sources := inlineScriptSources(html)
if len(sources) == 0 {
return policy
}
return strings.Replace(policy, scriptSrcSelf, scriptSrcSelf+" "+strings.Join(sources, " "), 1)
}
+9 -1
View File
@@ -25,6 +25,10 @@ type Handler struct {
DeleteUseCase *useCases.DeleteMediaUseCase
MergeUseCase *useCases.MergeMediaUseCase
UsageUseCase *useCases.GetMediaUsageUseCase
PurgeSettingsUseCase *useCases.GetPurgeSettingsUseCase
UpdatePurgeSettingsUseCase *useCases.UpdatePurgeSettingsUseCase
PreviewPurgeUseCase *useCases.PreviewPurgeUseCase
RunPurgeUseCase *useCases.RunPurgeUseCase
}
type RenameRequest struct {
@@ -74,7 +78,7 @@ type mergeResponse struct {
Merged int `json:"merged"`
}
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase) *Handler {
func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUseCase, rename *useCases.RenameMediaUseCase, deleteUseCase *useCases.DeleteMediaUseCase, merge *useCases.MergeMediaUseCase, usage *useCases.GetMediaUsageUseCase, purgeSettings *useCases.GetPurgeSettingsUseCase, updatePurgeSettings *useCases.UpdatePurgeSettingsUseCase, previewPurge *useCases.PreviewPurgeUseCase, runPurge *useCases.RunPurgeUseCase) *Handler {
return &Handler{
CreateUseCase: create,
ListUseCase: list,
@@ -82,6 +86,10 @@ func NewHandler(create *useCases.CreateImageUseCase, list *useCases.ListMediaUse
DeleteUseCase: deleteUseCase,
MergeUseCase: merge,
UsageUseCase: usage,
PurgeSettingsUseCase: purgeSettings,
UpdatePurgeSettingsUseCase: updatePurgeSettings,
PreviewPurgeUseCase: previewPurge,
RunPurgeUseCase: runPurge,
}
}
+169
View File
@@ -0,0 +1,169 @@
package media
import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/purge"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"io"
"net/http"
"time"
)
type PurgeSettingsRequest struct {
AutoPurgeEnabled *bool `json:"auto_purge_enabled"`
AutoPurgeIntervalDays *int `json:"auto_purge_interval_days" validate:"omitempty,gte=1,lte=365"`
}
type PurgeRunRequest struct {
Fingerprint *string `json:"fingerprint"`
}
type purgeSettingsResponse struct {
AutoPurgeEnabled bool `json:"auto_purge_enabled"`
AutoPurgeIntervalDays int `json:"auto_purge_interval_days"`
LastPurgeAt *time.Time `json:"last_purge_at"`
LastPurgeMerged int `json:"last_purge_merged"`
LastPurgeArticlesUpdated int `json:"last_purge_articles_updated"`
EnabledAt *time.Time `json:"enabled_at"`
NextPurgeAt *time.Time `json:"next_purge_at"`
}
type purgePreviewGroupResponse struct {
KeepID uint32 `json:"keep_id"`
Filename string `json:"filename"`
URL string `json:"url"`
UsedIn int `json:"used_in"`
Merged int `json:"merged"`
}
type purgePreviewResponse struct {
Groups int `json:"groups"`
Copies int `json:"copies"`
Articles int `json:"articles"`
List []purgePreviewGroupResponse `json:"list"`
More int `json:"more"`
Fingerprint string `json:"fingerprint"`
}
type purgeRunResponse struct {
GroupsTotal int `json:"groups_total"`
GroupsPurged int `json:"groups_purged"`
Merged int `json:"merged"`
ArticlesUpdated int `json:"articles_updated"`
FailedGroups int `json:"failed_groups"`
}
func toPurgeSettingsResponse(settings purge.Settings) purgeSettingsResponse {
return purgeSettingsResponse{
AutoPurgeEnabled: settings.AutoPurgeEnabled,
AutoPurgeIntervalDays: settings.AutoPurgeIntervalDays,
LastPurgeAt: settings.LastPurgeAt,
LastPurgeMerged: settings.LastPurgeMerged,
LastPurgeArticlesUpdated: settings.LastPurgeArticlesUpdated,
EnabledAt: settings.EnabledAt,
NextPurgeAt: settings.NextPurgeAt(),
}
}
func writePurgeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, purge.ErrInvalidPurgeInterval):
http.Error(w, apperr.PurgeIntervalErrorMessage, http.StatusBadRequest)
case errors.Is(err, purge.ErrPurgeConflict):
http.Error(w, apperr.PurgeConflictErrorMessage, http.StatusConflict)
case errors.Is(err, purge.ErrPurgeBusy):
http.Error(w, apperr.PurgeBusyErrorMessage, http.StatusConflict)
default:
writeInternalServerError(w)
}
}
func (h *Handler) GetPurgeSettings(w http.ResponseWriter, _ *http.Request) {
settings, err := h.PurgeSettingsUseCase.Get()
if err != nil {
writePurgeError(w, err)
return
}
writeJSON(w, http.StatusOK, toPurgeSettingsResponse(settings))
}
func (h *Handler) UpdatePurgeSettings(w http.ResponseWriter, r *http.Request) {
var request PurgeSettingsRequest
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.PurgeIntervalErrorMessage, http.StatusBadRequest)
return
}
settings, err := h.UpdatePurgeSettingsUseCase.Update(useCases.UpdatePurgeSettingsCommand{
AutoPurgeEnabled: request.AutoPurgeEnabled,
AutoPurgeIntervalDays: request.AutoPurgeIntervalDays,
})
if err != nil {
writePurgeError(w, err)
return
}
writeJSON(w, http.StatusOK, toPurgeSettingsResponse(settings))
}
func (h *Handler) PreviewPurge(w http.ResponseWriter, _ *http.Request) {
preview, err := h.PreviewPurgeUseCase.Preview()
if err != nil {
writePurgeError(w, err)
return
}
list := make([]purgePreviewGroupResponse, 0, len(preview.List))
for _, group := range preview.List {
list = append(list, purgePreviewGroupResponse{
KeepID: group.KeepID,
Filename: group.Filename,
URL: group.URL,
UsedIn: group.UsedIn,
Merged: group.Merged,
})
}
writeJSON(w, http.StatusOK, purgePreviewResponse{
Groups: preview.Groups,
Copies: preview.Copies,
Articles: preview.Articles,
List: list,
More: preview.More,
Fingerprint: preview.Fingerprint,
})
}
func (h *Handler) RunPurge(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
var request PurgeRunRequest
if len(raw) > 0 {
if err := json.Unmarshal(raw, &request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return
}
}
result, err := h.RunPurgeUseCase.Run(request.Fingerprint)
if err != nil {
writePurgeError(w, err)
return
}
writeJSON(w, http.StatusOK, purgeRunResponse{
GroupsTotal: result.GroupsTotal,
GroupsPurged: result.GroupsPurged,
Merged: result.Merged,
ArticlesUpdated: result.ArticlesUpdated,
FailedGroups: result.FailedGroups,
})
}
@@ -1,11 +1,13 @@
package staticimages
import (
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"io"
"io/fs"
"net/http"
"os"
"path"
"strconv"
"strings"
"time"
@@ -24,13 +26,13 @@ var contentTypeExtensions = map[string]string{
}
type Handler struct {
uploadDir fs.FS
openMedia *useCases.OpenMediaUseCase
modTime time.Time
}
func NewHandler(uploadDir string) *Handler {
func NewHandler(openMedia *useCases.OpenMediaUseCase) *Handler {
return &Handler{
uploadDir: os.DirFS(uploadDir),
openMedia: openMedia,
modTime: time.Now(),
}
}
@@ -39,18 +41,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
cleaned := cleanPath(requestedPath(r))
file, stat, ok := h.openFile(cleaned)
if !ok {
blob, err := h.openMedia.Open(cleaned)
if errors.Is(err, image.ErrBlobNotFound) {
http.NotFound(w, r)
return
}
defer file.Close()
content, ok := file.(io.ReadSeeker)
if !ok {
http.Error(w, "File not seekable", http.StatusInternalServerError)
if err != nil {
http.Error(w, "Unable to read the file", http.StatusInternalServerError)
return
}
defer blob.Content.Close()
contentType := contentTypeFor(cleaned)
w.Header().Set("Content-Type", contentType)
@@ -59,7 +59,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", svgContentSecurityPolicy)
}
http.ServeContent(w, r, cleaned, stat.ModTime(), content)
if content, ok := blob.Content.(io.ReadSeeker); ok {
http.ServeContent(w, r, cleaned, blob.ModTime, content)
return
}
w.Header().Set("Content-Length", strconv.FormatInt(blob.Size, 10))
if !blob.ModTime.IsZero() {
w.Header().Set("Last-Modified", blob.ModTime.UTC().Format(http.TimeFormat))
}
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = io.Copy(w, blob.Content)
}
}
func requestedPath(r *http.Request) string {
@@ -73,22 +84,6 @@ func cleanPath(p string) string {
return strings.TrimPrefix(path.Clean("/"+p), "/")
}
func (h *Handler) openFile(p string) (fs.File, fs.FileInfo, bool) {
if p == "" || p == "." {
return nil, nil, false
}
file, err := h.uploadDir.Open(p)
if err != nil {
return nil, nil, false
}
stat, err := file.Stat()
if err != nil || stat.IsDir() {
file.Close()
return nil, nil, false
}
return file, stat, true
}
func contentTypeFor(p string) string {
ext := path.Ext(p)
if contentType, ok := contentTypeExtensions[ext]; ok {
+18
View File
@@ -95,3 +95,21 @@ func RequireOwner(getUser *useCases.GetUserUseCase) func(http.Handler) http.Hand
})
}
}
func RequireOwnerOrAdmin(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
currentUser, ok := resolveCurrentUser(getUser, w, r)
if !ok {
return
}
if currentUser.Role != user.RoleOwner && currentUser.Role != user.RoleAdmin {
http.Error(w, apperr.OwnerOrAdminRequiredErrorMessage, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
+33
View File
@@ -0,0 +1,33 @@
package middleware
import "net/http"
const contentSecurityPolicy = "default-src 'self'; " +
"script-src 'self' 'wasm-unsafe-eval'; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: blob: https:; " +
"connect-src 'self'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
"frame-ancestors 'none'"
const strictTransportSecurity = "max-age=31536000"
func SecurityHeaders(hsts bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", contentSecurityPolicy)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
if hsts {
h.Set("Strict-Transport-Security", strictTransportSecurity)
}
next.ServeHTTP(w, r)
})
}
}
+4
View File
@@ -38,3 +38,7 @@ const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html
const APIKeyNotFoundErrorMessage = "API key not found"
const APIKeyExpiryErrorMessage = "The expiry must be a date in the future."
const SessionRequiredErrorMessage = "This action needs a real session."
const OwnerOrAdminRequiredErrorMessage = "Only owners and admins can do this."
const PurgeConflictErrorMessage = "The library changed, so nothing was purged. Check the new summary and try again."
const PurgeBusyErrorMessage = "A purge is already running."
const PurgeIntervalErrorMessage = "Enter a whole number of days between 1 and 365."
+10 -2
View File
@@ -208,13 +208,20 @@ func NewAPIKeyRouter(container *UseCases) http.Handler {
func NewMediaRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase)
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(container.apiKeyAuth())
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwnerOrAdmin(container.GetUserUseCase))
r.Get("/purge-settings", mediaHandler.GetPurgeSettings)
r.Patch("/purge-settings", mediaHandler.UpdatePurgeSettings)
r.Get("/purge-preview", mediaHandler.PreviewPurge)
r.Post("/purge", mediaHandler.RunPurge)
})
r.Get("/", mediaHandler.List)
r.Post("/", mediaHandler.Upload)
r.Post("/merge", mediaHandler.Merge)
@@ -280,11 +287,12 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
frontend := InitFrontendRoutes(settings.Environment)
apiRouter := chi.NewRouter()
apiRouter.Use(middleware.SecurityHeaders(settings.IsProduction()))
enableCorsIfConfigured(apiRouter, settings.CorsAllowedOrigins)
apiRouter.Use(chimiddleware.GetHead)
apiRouter.Get("/healthz", GetHealth)
apiRouter.Mount("/v1", backend)
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(settings.UploadDir))
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase))
apiRouter.Mount("/", frontend)
return apiRouter
+31 -7
View File
@@ -12,6 +12,7 @@ import (
"net/http"
"os"
"os/signal"
"slices"
"syscall"
"time"
@@ -44,11 +45,22 @@ var envDefaults = map[string]string{
"ENVIRONMENT": "production",
"PORT": "8080",
"DB_FILE": "./data/renewcms.db",
config.EnvDatabaseDriver: config.DefaultDatabaseDriver,
"DB_TLS": config.DefaultDatabaseTLS,
"DB_SSLMODE": config.DefaultDatabaseSSLMode,
"DB_MAX_OPEN_CONNS": config.DefaultMaxOpenConns,
"DB_MAX_IDLE_CONNS": config.DefaultMaxIdleConns,
"DB_CONN_MAX_LIFETIME_SECONDS": config.DefaultConnMaxLifetimeSeconds,
"UPLOAD_DIR": "./data/uploads",
config.EnvStorageDriver: config.DefaultStorageDriver,
"S3_REGION": config.DefaultS3Region,
"S3_PATH_STYLE": config.DefaultS3PathStyle,
"S3_TLS": config.DefaultS3TLS,
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
"TRUSTED_PROXIES": "",
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
"RENEWCMS_CONTAINER": "false",
}
const versionFlag = "--version"
@@ -80,7 +92,7 @@ func run() error {
validator.InitValidator()
server := newServer(api.InitRoutes(container, settings), settings)
return serveUntilStopped(server, settings, container.RecordRunningInfoUseCase.Record)
return serveUntilStopped(server, settings, container.RecordRunningInfoUseCase.Record, container.PurgeScheduler.Run)
}
func initEnvVariables() {
@@ -107,10 +119,13 @@ func applyEnvDefaults() {
}
func ensureRequiredEnvVars() {
for _, envVar := range requiredEnvVars {
if _, isSet := os.LookupEnv(envVar); !isSet {
panic(fmt.Sprintf("Environment variable %s is not set", envVar))
}
required := slices.Concat(
requiredEnvVars,
config.DatabaseRequiredEnvVars(os.Getenv(config.EnvDatabaseDriver)),
config.StorageRequiredEnvVars(os.Getenv(config.EnvStorageDriver)),
)
if err := config.MissingEnvVarError(required, os.LookupEnv); err != nil {
panic(err.Error())
}
}
@@ -131,9 +146,18 @@ func newServer(handler http.Handler, settings config.Settings) *http.Server {
}
}
func serveUntilStopped(server *http.Server, settings config.Settings, recordRunning func() error) error {
func serveUntilStopped(server *http.Server, settings config.Settings, recordRunning func() error, runScheduler func(context.Context)) error {
stopSignal, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
schedulerDone := make(chan struct{})
go func() {
defer close(schedulerDone)
runScheduler(stopSignal)
}()
defer func() {
stop()
<-schedulerDone
}()
serverErr := make(chan error, 1)
go listen(server, serverErr, settings, recordRunning)
+7
View File
@@ -0,0 +1,7 @@
services:
renewcms:
build:
context: .
args:
VERSION: ${VERSION:-dev}
image: renewcms:${VERSION:-dev}
+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:
+24
View File
@@ -0,0 +1,24 @@
services:
renewcms:
image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}
restart: unless-stopped
env_file:
- docker.env
environment:
DB_FILE: /data/renewcms.db
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:
+34
View File
@@ -0,0 +1,34 @@
PUBLIC_URL=https://cms.example.com
JWT_SECRET=replace-with-output-of-openssl-rand-hex-32
SMTP_HOST=smtp.example.com
SMTP_PORT=465
SMTP_LOGIN=noreply@example.com
SMTP_PASSWORD=replace-me
SMTP_SENDER=noreply@example.com
ENVIRONMENT=production
CORS_ALLOWED_ORIGINS=
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES=
DB_DRIVER=sqlite
DATA_DIR=
DB_HOST=
DB_PORT=
DB_USER=
DB_PASSWORD=
DB_NAME=
DB_TLS=false
DB_SSLMODE=disable
DB_MAX_OPEN_CONNS=10
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME_SECONDS=300
STORAGE_DRIVER=local
S3_BUCKET=
S3_REGION=us-east-1
S3_ENDPOINT=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_KEY_PREFIX=
S3_PATH_STYLE=false
S3_TLS=true
+60 -2
View File
@@ -15,8 +15,10 @@ api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain
| Domain | `internal/domain/<name>/` | Entities, `Err…` sentinel errors, `Repository` interfaces. Imports only the stdlib and other domain packages, with no GORM tags. |
| Use cases | `internal/infrastructure/useCases/` | Application logic. Depends only on domain interfaces, never on persistence, mailer, GORM or `net/http`. |
| Adapters | `internal/infrastructure/persistence/{models,mappers,repositories}`, `internal/infrastructure/mailer` | Implement a domain `Repository` (assert with `var _ domain.Repository = &X{}`). GORM structs stay in `models` and are converted by `mappers`. |
| Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the user-role backfill. Only `sqlite` exists today. |
| Blob storage | `domain/image.Storage`, `internal/infrastructure/storage` | `Storage` (`Put`, `Open`, `Delete`) is the port for uploaded image bytes. `storage.Open(Config{Driver, Location})` picks the adapter; `local` is the only one. Repositories never touch files: use cases coordinate `Storage` and `image.Repository`. |
| Delivery | `api/handlers/<name>/`, `api/router.go` | HTTP handlers receive use cases through `NewHandler(...)`. They never import persistence, mailer or GORM. |
| Composition root | `api/dependecyInjection.go` | The only place that opens the DB, builds repositories and use cases, and lists models for `AutoMigrate`. |
| Composition root | `api/dependecyInjection.go` | The only place that picks the database and storage drivers, builds repositories and use cases, and runs `database.Migrate` and the image metadata backfill at boot. |
| Entry point | `cmd/server/main.go` | Env/config, HTTP server, graceful shutdown. |
Nothing under `internal/` imports `api/`.
@@ -30,9 +32,32 @@ Nothing under `internal/` imports `api/`.
| `api/pkg/upload` | Multipart `file` reading and the mapping of upload errors (415, 413, 400) shared by both upload routes. |
| `api/pkg/publicuser` | `PublicUser`, the JSON shape of a user with no password, codes or token version. `publicuser.New` converts a domain `user.User`, so handlers never serialise the domain struct directly. |
### Media purge
`internal/domain/purge` holds `Settings` (automatic purge on or off, interval of 1 to 365 days, `last_purge_*` history, `enabled_at`), the `Repository` interface (`Get` returns the defaults when nothing was saved, `Save`, and `SaveLastRun`, which writes only the `last_purge_*` columns so a settings change made during a run is never overwritten) and the sentinels `ErrInvalidPurgeInterval`, `ErrPurgeConflict` (stale fingerprint) and `ErrPurgeBusy` (a run is already in progress). `Settings.NextPurgeAt` is `max(last_purge_at, enabled_at)` plus the interval when automatic purge is on, and null otherwise. The settings live in a single-row `purge_settings` table (`models.PurgeSettings`, `mappers.PurgeSettings*`, `persistence.PurgeSettingsRepository`), created by `AutoMigrate`.
The use cases `GetPurgeSettings`, `UpdatePurgeSettings`, `PreviewPurge` and `RunPurge` depend only on domain interfaces, an injected clock and an injected `*log.Logger`, so a scheduler can call them. `PreviewPurge` and `RunPurge` share one plan (`purgePlan.go`): a duplicate group (same non-empty sha256) is planned only when a copy is used in an article, the most used copy is kept (ties: oldest `created_at`, then lowest id) and every other copy is merged into it. The fingerprint is a SHA-256 over the planned groups. `RunPurge` takes a non-blocking in-process lock (`ErrPurgeBusy` for a concurrent call), refuses a non-matching fingerprint with `ErrPurgeConflict`, merges group by group with `MergeMedia` (a failing group is counted and skipped), unions the `UpdatedArticleIDs` of the fully merged groups, and records `last_purge_*` after every run.
#### Automatic purge scheduler
`PurgeSchedulerUseCase.Tick(now)` returns the `purge.RunResult` of the run, whether it ran and an error. It reads the purge settings on every call and runs `RunPurge.Run(nil)` (no fingerprint) when `Settings.NextPurgeAt()` is set and `now` is not before it. It never writes settings: only `RunPurge` records `last_purge_*`, through `SaveLastRun`, which moves the anchor forward even when nothing was purged. The runner in `internal/infrastructure/scheduler` calls `Tick` and logs one line with `groups_purged`, `merged`, `articles_updated` and `failed_groups` when a run happened. It calls `Tick` once after a startup delay of 1 minute, which catches up a run missed during downtime in a single purge, then once every hour. `api/dependecyInjection.go` builds it and `cmd/server/main.go` starts it with the signal context of the server. Changes to the settings apply on the next tick, with no restart.
- Overlap guard: a manual run holds the `RunPurge` lock, so `Tick` returns `ErrPurgeBusy` at once. The runner treats it as a skip, logs nothing and re-evaluates at the next tick.
- Failures: any other error is logged and the loop keeps going. There is no retry inside a tick; the next hourly tick evaluates the schedule again.
- Shutdown: on SIGINT or SIGTERM the runner stops taking ticks and waits, with no time limit, for a `Tick` in progress. `main` waits for the runner to return before the database is closed.
`middleware.RequireOwnerOrAdmin` lets only the roles owner and admin through (403 otherwise, including an empty role). The media router mounts these routes after the verified chain and before the `/{id}` routes:
| Route | Purpose |
|---|---|
| `GET /v1/media/purge-settings` | Settings with `next_purge_at`. |
| `PATCH /v1/media/purge-settings` | Partial update of `auto_purge_enabled` and `auto_purge_interval_days`. |
| `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. |
### 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
@@ -50,3 +75,36 @@ bin/renewcms ── /v1/* API
- **Production:** a single origin. The SPA calls the API with relative URLs, so the JWT cookie works without CORS. Files under `assets/` are content-hashed by Vite and cached for a year. `index.html` is never cached.
- **Development:** built with `-tags nofrontend` (`assets_stub.go`, `assets.Dist == nil`). Vite serves the SPA on :5173 and proxies `/v1` to the API on :8080. See [development.md](development.md).
## Storage and database seams
Uploaded images are addressed by a stored name (`<uuid>.<ext>`). `CreateImageUseCase` calls `Storage.Put` (which generates the name), then `Repository.Create` with `Path = /static/uploadedImages/<name>`, and deletes the blob again if the row cannot be created. `DeleteMediaUseCase` and `MergeMediaUseCase` remove the blob first and the row second (`mediaRemover`), so a failed blob delete keeps the row and the delete can be retried. `GET /static/uploadedImages/<name>` is served by `OpenMediaUseCase` through `api/handlers/staticimages`, which uses `http.ServeContent` when the blob is seekable and streams it otherwise; `ErrBlobNotFound` maps to 404 and any other error to 500.
SQL in repositories must run on SQLite, MySQL/MariaDB and PostgreSQL: substring searches use `LIKE ... ESCAPE '!'` through `containsPattern` instead of `instr`. `Settings.DataDir()` is where update state, staging and backups live; it is `DATA_DIR` when set and the directory of `DB_FILE` otherwise, so it does not depend on where the database is.
Three drivers are registered in `database.drivers`: `sqlite` (default), `mysql` (MySQL 8 and MariaDB 11, through `gorm.io/driver/mysql`) and `postgres` (PostgreSQL 16 and 17, alias `postgresql`, through `gorm.io/driver/postgres` on pgx v5); all are pure Go so `CGO_ENABLED=0` and the six release targets still build. `DB_DRIVER` picks one in `databaseConfig` (composition root); an unknown name fails in `database.Connect` with `unsupported database driver "<name>"`. `database.MySQLDSN` and `database.PostgresDSN` build the DSN from separate variables (the PostgreSQL one as an escaped URL with `sslmode`, `timezone=UTC` and a connect timeout), and the driver redacts the password from every error and names only host and port. `Connect` applies the optional `PoolSettings` to any driver. What keeps the repositories identical on both engines, all without touching the domain, use cases or handlers:
- Indexed and unique strings carry `size:` tags in the models (a MySQL index needs a bounded `varchar`). SQLite maps them to the same `text` column, so an existing SQLite file migrates with no schema or row change (`test/sqlite_schema_unchanged_test.go` builds a file from the old model definitions and compares `sqlite_master` and every row).
- `Migrate` creates MySQL tables with `utf8mb4_bin` (`gorm:table_options`), so `Bob` and `bob` are distinct like in SQLite. The DSN also sets `collation=utf8mb4_bin`.
- The DSN sets `clientFoundRows=true`: an update that matches a row but changes nothing still reports one affected row, as in SQLite. `InviteRepository.Renew`, `deleteOrNotFound` and `PurgeSettingsRepository.SaveLastRun` depend on that.
- The Go driver sends a zero `time.Time` as `0000-00-00`, which MySQL's default `NO_ZERO_DATE` rejects, so each session sets `sql_mode` to the server default minus `NO_ZERO_DATE`. Reading `0000-00-00` gives the zero time back.
- `UserRepository.Create` returns the filled model instead of re-reading it, because MySQL has no `RETURNING`.
On PostgreSQL the MySQL-only table options are not applied, pgx returns `timestamptz` values in the local zone so a query callback in the driver converts them to UTC, and only the `public` schema is used.
Backups are a `domain/update.DatabaseBackup` per driver. SQLite uses `VACUUM INTO`. MySQL uses `DumpBackup`, a pure Go logical dump (`SHOW CREATE TABLE` plus every row, strings escaped and binary values as hex, NULL and zero dates kept) written inside one repeatable-read transaction to `DATA_DIR/backups/renewcms-<time>.sql.partial` and renamed once complete. Any failure removes the partial file and returns an error, so the update download fails instead of claiming a backup. PostgreSQL reuses `DumpBackup` with its own `dumpDialect` (double-quoted identifiers, doubled single quotes, `\x` bytea literals): `CREATE TABLE` is rebuilt from `pg_catalog` (serial columns, defaults, primary key, unique and check constraints), followed by the indexes, the rows and a `setval` per `id` sequence, all inside one repeatable-read read-only transaction. All drivers keep the newest 3 files (`pruneBackups`).
`task test` runs everything that needs no server. The repository contract tests (`test/repository_contract_test.go`) run on SQLite always, and on MySQL when `RENEWCMS_TEST_MYSQL_DSN` points at a server (admin DSN; each test creates and drops its own database). `task test:mysql` starts `mysql:8.4` and then `mariadb:11` in Docker and runs the `MySQL` tests against each. The same contract tests run on PostgreSQL when `RENEWCMS_TEST_POSTGRES_DSN` points at a server (admin DSN, for example `postgres://postgres:pw@127.0.0.1:5432/postgres?sslmode=disable`), and `task test:postgres` starts `postgres:16` then `postgres:17` in Docker and runs the `Postgres` tests against each.
Two storage adapters implement `image.Storage`: `local` (default, files in `UPLOAD_DIR`) and `s3` (`storage.S3Storage`, `aws-sdk-go-v2` over plain HTTPS, pure Go so `CGO_ENABLED=0` still holds). `STORAGE_DRIVER` picks one in `imageStorageConfig` (composition root, from `config.StorageSettings`); `storage.Open` fails with `unsupported storage driver "<name>"` for anything else, and `openImageStorage` panics with the error. The S3 adapter works as follows, with no change to the domain, use cases, repositories or the `staticimages` handler:
- `Open` calls `GetObject` and returns a `Blob` whose `Content` is an `io.ReadSeeker` (`s3Reader`): `Size` and `ModTime` come from `ContentLength` and `LastModified`, the first bytes are read from that response and every `Seek` to another offset makes the next `Read` issue a ranged `GetObject`. `http.ServeContent` therefore answers `Range` (206, 416) and `If-Modified-Since` (304) with no `ETag`. A 404 maps to `image.ErrBlobNotFound`; any other failure is a plain error (500 in the handler).
- `Put` does one `PutObject` of `<S3_KEY_PREFIX><uuid>.<ext>` with the upload's `Content-Type`. `Delete` treats 404 as success. Names that are not plain file names are never requested, as in the local adapter.
- Each call has a 30 second timeout to get a response (the body then streams without a deadline) and the SDK default retries. Request checksums are only sent when required, so older S3-compatible servers work.
- `storage.Open` checks the bucket with `HeadBucket`. The failure text names the bucket and the reason (404, 403, other status or transport error) and never the credentials.
- At boot, `RunBackfillReportingReads` wraps the storage in a counter for the `BackfillImageMetadataUseCase` run and logs how many images it read; the use case itself is unchanged.
- Update backups (`DATA_DIR`) never include uploads, whatever the driver.
`task test:s3` starts SeaweedFS in Docker and runs `TestS3StorageIntegration` against it (skipped without Docker, and not part of `task test`). Everything else uses an in-process fake S3 server listening on `127.0.0.1`.
Adding a database driver: implement `database.Driver` (`Open`, and `NewBackup` returning a `domain/update.DatabaseBackup`), register it in `drivers`, then select it in `databaseConfig` in the composition root (and add the env vars to `main.go`, `.env.example` and `configuration.md`). Adding a storage backend: implement `image.Storage`, add a case to `storage.Open`, and select it in `openImageStorage`. No use case, handler or repository changes.
+71 -4
View File
@@ -24,13 +24,80 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|------------------------|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `ENVIRONMENT` | `production` | `production` or `development`. In `production` the auth cookie is `Secure`, so it is only sent over HTTPS. `development` turns on HTTP request logging. |
| `PORT` | `8080` | Port the HTTP server listens on (all interfaces). |
| `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. |
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. |
| `DB_DRIVER` | `sqlite` | `sqlite`, `mysql` (MySQL and MariaDB) or `postgres` (alias `postgresql`). Any other value stops the server with `unsupported database driver "<value>"`. See [Database](#database). |
| `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. Ignored when `DB_DRIVER` is `mysql` or `postgres`. |
| `DATA_DIR` | directory of `DB_FILE` | Directory for update state, staged updates and database backups (`backups/`). Set it with `DB_DRIVER=mysql` or `postgres`, where there is no database file to sit beside (the default still follows `DB_FILE`). With SQLite, leave it unset to keep everything beside the database. |
| `STORAGE_DRIVER` | `local` | `local` (files in `UPLOAD_DIR`) or `s3` (an S3-compatible bucket). Any other value stops the server with `unsupported storage driver "<value>"`. See [Image storage](#image-storage). |
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. Ignored, without error, when `STORAGE_DRIVER=s3`. |
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
| `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`. |
| `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`, in the data directory. |
Automatic media purge has no environment variable. Its schedule (on or off, interval, `enabled_at`, `last_purge_at`) lives in the purge settings row of the database, is changed from the Settings page or `PATCH /v1/media/purge-settings`, and is read again at every hourly tick.
## Image storage
By default uploaded images are files in `UPLOAD_DIR`. To keep them in an S3-compatible bucket (AWS S3, MinIO, Ceph, Cloudflare R2, Backblaze B2 and similar), set `STORAGE_DRIVER=s3` and the variables below. They are only read when `STORAGE_DRIVER=s3`. Image URLs do not change: the app still serves `/static/uploadedImages/<name>`, reading the object from the bucket (the bucket stays private, and `Range` and `If-Modified-Since` requests keep working; there is no `ETag`).
| Name | Default | Description |
|------------------------|-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `S3_BUCKET` | required | Name of an existing bucket. A missing value stops the server with `Environment variable S3_BUCKET is not set`. |
| `S3_REGION` | `us-east-1` | Bucket region. Most S3-compatible servers accept the default. |
| `S3_ENDPOINT` | *(empty)* | Host (with optional port) or full URL of an S3-compatible server. Leave empty for AWS S3. |
| `S3_ACCESS_KEY_ID` | *(empty)* | Access key. Set it together with `S3_SECRET_ACCESS_KEY`, or set neither. Setting only one stops the server with an error naming the missing one. |
| `S3_SECRET_ACCESS_KEY` | *(empty)* | Secret key. Never logged and never in an error message. When both keys are empty, the default AWS credential chain is used (`AWS_*` variables, shared credentials file, instance or task role). |
| `S3_KEY_PREFIX` | *(empty)* | Prefix put before every object key, for example `uploads/`. Include the trailing slash. |
| `S3_PATH_STYLE` | `false` | `true` addresses the bucket as `endpoint/bucket` instead of `bucket.endpoint`. SeaweedFS, MinIO and most self-hosted servers need `true`. |
| `S3_TLS` | `true` | Only used when `S3_ENDPOINT` has no `http://` or `https://` prefix: `true` means `https`, `false` means `http`. |
At startup the server checks the bucket (`HeadBucket`). A missing bucket, refused credentials or an unreachable server stop startup with `Unable to open the image storage: S3 bucket "<name>" is not usable: <reason>`; the error never contains the secret. Each S3 call has a 30 second timeout and the SDK default retries. At boot, images whose metadata is missing are read back from the bucket to fill it; when there were some, one log line says how many.
### Moving existing uploads to a bucket
There is no automatic migration and the database does not change: image rows only hold the stored name, so the same names must exist in the bucket. With the site stopped, copy the files under the same names, under `S3_KEY_PREFIX` if you set one, then set `STORAGE_DRIVER=s3` and restart:
```sh
aws s3 sync ./data/uploads s3://my-bucket/uploads/
rclone sync ./data/uploads remote:my-bucket/uploads
```
Keep `UPLOAD_DIR` until you have checked the site. Moving back is the same copy in the other direction.
### Backups
The pre-update backup covers the database only (and `DATA_DIR` state). Neither the update backup nor the data directory includes uploads, so back the bucket up with your provider (versioning, replication) or with `aws s3 sync` / `mc mirror`.
## Database
SQLite is the default and needs nothing but `DB_FILE`. To run against MySQL 8 or MariaDB 11 instead, set `DB_DRIVER=mysql` and the connection variables below. They are only read (and the four required ones only checked) when `DB_DRIVER` is `mysql` or `postgres`. A required variable that is not set stops the server with `Environment variable DB_HOST is not set` (or `DB_USER`, `DB_PASSWORD`, `DB_NAME`).
| Name | Default | Description |
|--------------------------------|----------|----------------------------------------------------------------------------------------------------------------------------------|
| `DB_HOST` | required | Server host name or IP address. |
| `DB_PORT` | `3306` (`5432` for PostgreSQL) | Server port. |
| `DB_USER` | required | Database user. It needs full rights on `DB_NAME`, because the schema is created and migrated at startup. |
| `DB_PASSWORD` | required | Database password. It is never logged and never appears in an error message. |
| `DB_NAME` | required | Name of an existing database. The server does not create it. |
| `DB_TLS` | `false` | `false`, `true`, `skip-verify` or `preferred`. Any other value stops the server with an error naming `DB_TLS` and these values. `true` verifies the server certificate; `skip-verify` encrypts without verifying it. |
| `DB_SSLMODE` | `disable` | PostgreSQL only: `disable`, `require`, `verify-ca` or `verify-full`. Any other value stops the server with an error naming `DB_SSLMODE` and these values. Ignored for SQLite and MySQL (use `DB_TLS` there). |
| `DB_MAX_OPEN_CONNS` | `10` | Maximum open connections in the pool. `0` means unlimited. |
| `DB_MAX_IDLE_CONNS` | `5` | Maximum idle connections kept in the pool. |
| `DB_CONN_MAX_LIFETIME_SECONDS` | `300` | Seconds after which a connection is closed and replaced. `0` keeps connections forever. Keep it below the server's `wait_timeout`. |
A non numeric (or negative) pool value stops the server with an error naming the variable. There is no DSN variable: the connection string is built from the variables above, with `parseTime=true`, `loc=UTC`, `charset=utf8mb4`, `collation=utf8mb4_bin` and `clientFoundRows=true`. Connecting uses a 5 second timeout, so a wrong host, port, password or database name stops startup quickly with `Unable to open the database`, naming the host and port but never the password.
### PostgreSQL
Set `DB_DRIVER=postgres` (or `postgresql`) and the same connection variables, with `DB_PORT` defaulting to `5432` and `DB_SSLMODE` instead of `DB_TLS`. PostgreSQL 16 and 17 are tested. The database must already exist and `DB_USER` needs full rights on the `public` schema, which is the only schema used. The connection string is built with escaping from the variables (a password may contain spaces, quotes, `@` or `/`), sets the session time zone to UTC and a 5 second connect timeout, and the pool variables above apply. A failure to connect stops startup with `Unable to open the database`, naming the host and port but never the password. Usernames and emails compare case-sensitively, as on SQLite. Before an update download RenewCMS writes a logical dump to `DATA_DIR/backups/renewcms-<time>.sql` (the newest 3 are kept); replay it into an empty database with `psql -f`. For routine backups prefer `pg_dump`.
### MySQL settings
Why those settings: tables are created with the `utf8mb4_bin` collation so that usernames and emails compare case-sensitively exactly like in SQLite (`Bob` and `bob` are different users). The collation of `DB_NAME` itself does not matter, but tables that already exist keep the collation they have. `clientFoundRows` makes an update that does not change a row still count as matched, which the invite and purge-settings code relies on. Each session also drops the server's `NO_ZERO_DATE` SQL mode, because the Go driver sends a zero time as `0000-00-00`. The other strict modes stay on.
With MySQL the data directory (`DATA_DIR`) holds update state and backups only. The pre-update backup is a `renewcms-<time>.sql` logical dump (schema and rows, written by RenewCMS itself, no `mysqldump` needed) that keeps the newest 3. If the dump fails, the update download fails with it. The dump holds your data, such as password hashes, but not the connection password.
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
@@ -39,7 +106,7 @@ For example, `WorkingDirectory=/var/lib/renewcms` with `DB_FILE=./data/renewcms.
## Adding a variable
1. Read it in the code where it is needed (infrastructure or `api/`, never `internal/domain`).
2. Register it in [`cmd/server/main.go`](../cmd/server/main.go): add it to `requiredEnvVars` if it is required, or to `envDefaults` if it has a default.
2. Register it in [`cmd/server/main.go`](../cmd/server/main.go): add it to `requiredEnvVars` if it is required (or to `DatabaseRequiredEnvVars` in `internal/infrastructure/config` if only one database driver needs it), or to `envDefaults` if it has a default.
3. Add it to [`.env.example`](../.env.example) and to the tables above.
Never hardcode a secret, and never commit `.env`.
+55 -2
View File
@@ -8,7 +8,9 @@ RenewCMS ships as **one self-contained executable**. It serves:
| `/healthz` | Liveness check, returns `200 ok` |
| `/*` | The Vue SPA (embedded), with fallback to `index.html` for client-side routes |
Runtime state includes the SQLite database (`DB_FILE`), uploads (`UPLOAD_DIR`), and update records, staged binaries and backups beside the database. Running a release binary needs no Go, Bun or Node installation. Browser updates additionally require Linux with systemd and the helper setup below.
Runtime state includes the SQLite database (`DB_FILE`, or a MySQL/MariaDB or PostgreSQL server, see [Using MySQL or MariaDB](#using-mysql-or-mariadb) and [Using PostgreSQL](#using-postgresql)), uploads (`UPLOAD_DIR`, or an S3-compatible bucket with `STORAGE_DRIVER=s3`, see [configuration.md](configuration.md#image-storage)), and update records, staged binaries and backups beside the database. Running a release binary needs no Go, Bun or Node installation. Browser updates additionally require Linux with systemd and the helper setup below.
To run it in a container instead, see [docker.md](docker.md). The published image `git.floriansylvain.fr/fsylvain/renewcms` is upgraded with `docker compose pull && docker compose up -d`. Tags are `vX.Y.Z`, `X.Y.Z`, `X.Y` and `latest` for a stable release, and `vX.Y.Z-<pre>`, `X.Y.Z-<pre>` and the moving `<pre-name>` (`beta`, `rc`) for a pre-release, which never moves `latest`. Maintainers publish with `task docker:publish` after `docker login git.floriansylvain.fr`; there is no CI. See [Maintainer: publishing](docker.md#maintainer-publishing).
## 1. Build
@@ -33,7 +35,7 @@ To check a download by hand: `go run ./cmd/releasesign verify checksums.txt`, wi
### Update checks
The owner's Settings page asks the release feed for the newest release (see `UPDATE_FEED_URL` in [configuration.md](configuration.md)) and keeps the last result in `update-status.json`, next to `DB_FILE`. Back it up with the rest of the data directory if you like; it is rebuilt by the next check when missing. The Settings page reports a newer version. To install it from the browser, set up [Browser updates](#browser-updates) once on the server.
The owner's Settings page asks the release feed for the newest release (see `UPDATE_FEED_URL` in [configuration.md](configuration.md)) and keeps the last result in `update-status.json`, next to `DB_FILE`. Back it up with the rest of the data directory if you like; it is rebuilt by the next check when missing. The Settings page reports a newer version. To install it from the browser, set up [Browser updates](#browser-updates) once on the server. In Docker (`RENEWCMS_CONTAINER=true`) browser updates are not offered; Settings tells the owner to pull a new image instead, see [docker.md](docker.md).
### Update downloads
@@ -268,8 +270,59 @@ If RenewCMS runs on the same host as the proxy, consider firewalling port 8080.
Either way, set `TRUSTED_PROXIES` (see [configuration.md](configuration.md)) to the proxy's own address as seen by RenewCMS, typically `127.0.0.1` when the proxy and RenewCMS share a host, so the login/password-reset rate limiter honors the forwarded client IP instead of the proxy's own address.
### Security headers
RenewCMS sends `Content-Security-Policy` (including `frame-ancestors 'none'`), `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` and `Cross-Origin-Opener-Policy` itself, so the proxy doesn't need to add them. With `ENVIRONMENT=production` it also sends `Strict-Transport-Security: max-age=31536000` (browsers ignore it over plain HTTP, so serve the site over HTTPS only). Don't add the same headers in the proxy, or they will be duplicated.
The CSP allows Google Fonts (`fonts.googleapis.com`, `fonts.gstatic.com`) and `https:` images, because the SPA loads its fonts from Google and articles may embed remote images. `style-src` keeps `'unsafe-inline'` for the article editor; `script-src` is `'self'` only.
## Using MySQL or MariaDB
SQLite is the default. To use an existing MySQL 8 or MariaDB 11 server instead, create an empty database and a user with full rights on it, then set in `/etc/renewcms/renewcms.env`:
```sh
DB_DRIVER=mysql
DB_HOST=db.example.com
DB_USER=renewcms
DB_PASSWORD=<generated password>
DB_NAME=renewcms
DB_TLS=true
DATA_DIR=/var/lib/renewcms
```
`DB_FILE` is then ignored. `DATA_DIR` is where update state, staged updates and backups live, so give it the same value as the helper's `--data-dir` (see [browser updates](#browser-updates)). Use `DB_TLS=true` (or `preferred`) whenever the server is not on the same host. Every variable is described in [configuration.md](configuration.md#database). The schema is created and migrated at startup, so the first start on an empty database needs nothing else.
## Using PostgreSQL
To use an existing PostgreSQL 16 or 17 server, create an empty database and a user with full rights on its `public` schema, then set in `/etc/renewcms/renewcms.env`:
```sh
DB_DRIVER=postgres
DB_HOST=db.example.com
DB_USER=renewcms
DB_PASSWORD=<generated password>
DB_NAME=renewcms
DB_SSLMODE=require
DATA_DIR=/var/lib/renewcms
```
`DB_FILE` is then ignored, and `DB_PORT` defaults to `5432`. `DATA_DIR` is where update state, staged updates and backups live, so give it the same value as the helper's `--data-dir`. Use `DB_SSLMODE=require` (or `verify-full`) whenever the server is not on the same host. Every variable is described in [configuration.md](configuration.md#database). The schema is created and migrated at startup. Moving an existing SQLite site is a manual procedure like the MySQL one below, and RenewCMS writes a logical `.sql` dump to `DATA_DIR/backups` before each update download.
### Moving an existing SQLite site to MySQL
There is no migration tool. The manual procedure, with the site stopped:
1. Take a backup of the data directory (database file and uploads).
2. Start RenewCMS once on the empty MySQL database so it creates the schema, then stop it.
3. Export the rows from the SQLite file with `sqlite3 renewcms.db .dump`, keep only the `INSERT INTO` statements for `articles`, `images`, `users`, `invites`, `api_keys` and `purge_settings`, and import them into MySQL. Quoting and date formats differ between the two engines, so check a sample first: SQLite writes times such as `2026-01-02 03:04:05.123456789+00:00`, and the `+00:00` suffix must be removed for MariaDB and older MySQL versions. An unset time (`0001-01-01 00:00:00`) is valid as it is in MySQL and is read back as unset.
4. Copy `uploads/` to the new `UPLOAD_DIR`, set `DB_DRIVER=mysql` and the connection variables, and start the service.
Keep the SQLite file until you have checked the site.
## Backups
With MySQL, MariaDB or PostgreSQL, back the database up with your server's own tools (for example `mysqldump --single-transaction` or `pg_dump`), plus the uploads directory. RenewCMS also writes a logical dump to `DATA_DIR/backups` before each update download. The rest of this section is about SQLite.
Everything worth backing up is under the data directory. Take a consistent SQLite copy while the service is running:
```sh
+4 -1
View File
@@ -33,15 +33,18 @@ The visual spec of record for the back-office SPA and the transactional emails,
| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` |
| [Integration](screens/integration.md) | `/integration` | 8 | `source/RenewCMS Screens Back-office.dc.html` |
| [API keys](screens/integration-api-keys.md) | `/integration/api-keys` | 12 | `source/RenewCMS Screens API keys.dc.html` |
| [Activity](screens/activity.md) | `/activity` | 9 (plus 6 details, 3 My activity) | `source/RenewCMS Screens Activity.dc.html` |
| [Users](screens/users.md) | `/users` | 17 | `source/RenewCMS Screens Back-office.dc.html` |
| [Settings](screens/settings.md) | `/settings` | 25 | `source/RenewCMS Screens Back-office.dc.html` |
| [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` |
| [Accept invite](screens/accept-invite.md) | `/invite/accept?c=CODE` | 9 | `source/RenewCMS Screens Auth.dc.html` |
| [Global](screens/global.md) | `any route` | 4 | `source/RenewCMS Screens Back-office.dc.html` |
## Other boards
- `source/ActivityFrame.dc.html`: the frame that draws every Activity state, the Home card and the Settings retention card (the Activity board imports it). Its copy, component and API tables are in `source/activity-spec.js`. It holds the exact values. Home and Settings are drawn around dashed placeholders.
- `source/MediaFrame.dc.html`: the frame that draws the Media and Media picker states (the Back-office board imports it). Like `AppFrame.dc.html`, it holds the exact values.
- `source/SettingsFrame.dc.html`: the Media cleanup card and its purge dialog (the Media cleanup board imports it, with `MediaFrame.dc.html` for the Duplicates toolbar button). They hold the exact values.
- `source/IntegrationPage.dc.html` and `source/ApiKeysPage.dc.html`: the page components for the Integration and API keys screens (imported by `AppFrame.dc.html`). They hold the exact values.
- `source/RenewCMS Prototype.dc.html`: the clickable flows (first run, edit and publish, password reset, delete, session expiry mid-edit).
- `source/RenewCMS Handoff.dc.html`: the handoff page the first markdown here was generated from. It predates the Users and Profile screens and the invitation email, so it does not list them. Those are written by hand from the Back-office board, `AppFrame.dc.html`, the Prototype and `emails/invitation.html`.
+35
View File
@@ -814,3 +814,38 @@ Exact values live in `source/AppFrame.dc.html` (the Settings and Home states). C
| RestartOverlay | custom full-page `role="status"` | title, text, startedAt, slow | reload | App shell |
| UpdateBanner | UAlert | state: 'available'\|'ready' | go-to-settings, dismiss | Home (owner) |
| UpdatePill | UBadge | none | none | DashboardShell Settings item (dot on mobile) |
## Added with Media cleanup
### PurgeDuplicatesDialog
Used by Settings ("Purge now") and Media, Duplicates tab ("Purge duplicates"). Built on `UModal`, `sm:max-w-md`, bottom sheet on mobile, cancel first, confirm is `color="error"`.
- Props: `open`, `preview` (`groups`, `copies`, `articles`, `list[]`, `more`), `status` (`loading | ready | purging | nothing | error | partial | conflict`).
- While `purging`: confirm shows `loader-circle` and reads "Purging…"; `dismissible` false, Cancel disabled.
- Group row: 40×30 thumbnail, filename, "Kept · used in N articles", right-aligned "N copies merged". Max 5 rows, then "and N more groups".
- Notices sit above the summary in a `UAlert` (soft): error for failure, warning for partial and conflict.
- Emits `purged` (with counts). The parent shows the toast.
### MediaCleanupCard
Settings card. `USwitch` (square, `rounded-none`), `UInputNumber` with "days" suffix, read-only lines, divider, outline button. Saves on change and on blur or Enter; shows "Saving…" then "Saved" in the header. Read-only mode disables controls, hides the button and shows an info `UAlert`.
### Media toolbar button
Secondary `UButton` icon `merge`, label "Purge duplicates", after the sort menu on desktop; full width in the stacked toolbar on mobile (44 px).
## Added with Activity
Source: `source/activity-spec.js` (component inventory) and `source/ActivityFrame.dc.html`. All built on existing Nuxt UI v4 components and tokens; no new token.
| Component | Nuxt UI base | Props | Emits | Slots | Used on |
|---|---|---|---|---|---|
| ActivityPage | custom page | none (reads the session role) | none | none | Activity |
| ActivitySummaryStrip | UCard (as button) | summary, loading, activeFilter | apply-filter | none | Activity (owner, admin) |
| ActivityFilterBar | UInput, USelectMenu, UButton, UBadge | filters, people, canFilterPeople | update:filters, clear | none | Activity |
| ActivityTable | UTable (day separator rows), UAvatar, UBadge, UPagination, USelectMenu | items, total, page, size, loading | open, update:page, update:size | empty | Activity (md and up) |
| ActivityCardList | UCard | same as ActivityTable | open | empty | Activity (below md) |
| ActivityDetailsPanel | USlideover (right on desktop, bottom on mobile), UCard, UButton | item, index, total, open | previous, next, close | none | Activity |
| ActivityDiff | custom list | changes | none | none | ActivityDetailsPanel |
| ActivityFeedCard | UCard, UAvatar, UButton | items, loading, error, own | retry | none | Home |
| RetentionCard | UCard, USelectMenu, UAlert, UButton | settings, canEdit | saved | none | Settings (owner) |
- Sparkline: seven `div` bars inside the strip card, no chart library. The strip cards are real `button`s with the card as their label.
- Day separators are full-width rows of the table. The "When" cell carries the exact time as its `title`.
- The sentence is composed from the `action` code and params with the i18n messages (English and French); the target name is user content and is not translated.
- Failed and security rows use `--rc-error-soft` / `--ui-error` and `--rc-warning-soft` / `--ui-warning` for emphasis, always paired with an icon or a word, never colour alone.
+4 -3
View File
@@ -11,15 +11,16 @@ A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it
| `/register/validate` | AuthShell | public | VerifyPage | POST /v1/auth/verify (+ POST /login inline on 401) |
| `/register/reset/request` | AuthShell | public | ResetRequestPage | POST /v1/auth/password-reset |
| `/register/reset/validate` | AuthShell | public | ResetConfirmPage | POST /v1/auth/password-reset/confirm |
| `/home` | DashboardShell | verified | HomePage | GET /v1/article/ · GET /v1/system/update (owner, for the banner) |
| `/home` | DashboardShell | verified | HomePage | GET /v1/article/ · GET /v1/activity?size=5 · GET /v1/system/update (owner, for the banner) |
| `/article` | DashboardShell | verified | ArticlesPage | GET /v1/article/ · PATCH /{id}/publish\|unpublish · DELETE /{id} |
| `/article/new` | DashboardShell | verified | ArticlesPage + NewArticleModal | POST /v1/article/ |
| `/article/:id/edit` | DashboardShell | verified | EditorPage | GET /{id} · PATCH /{id} · PATCH /{id}/publish\|unpublish · GET /v1/media/ · POST /v1/media/ · DELETE /{id} |
| `/media` | DashboardShell | verified | MediaPage | GET /v1/media/ · 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/api-keys` | DashboardShell | verified | ApiKeysPage + CreateApiKeyModal | GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} |
| `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id · POST /v1/user/invite/:id/renew |
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart |
| `/settings` | DashboardShell | verified | SettingsPage | GET /v1/settings · PATCH /v1/settings (owner, retention) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart · GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge |
| `/profile` | DashboardShell | verified | ProfilePage | GET /v1/auth/me · PATCH /v1/user/me · POST /v1/auth/password-reset |
| `/invite/accept` | AuthShell | public | AcceptInvitePage | GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept* |
| `/:pathMatch(.*)*` | either | any | NotFoundPage | none |
+183
View File
@@ -0,0 +1,183 @@
# Activity
- **Route:** `/activity`
- **API:** GET /v1/activity · GET /v1/activity/summary · GET /v1/activity/export (see "API contract" below)
- **Visual reference:** `source/RenewCMS Screens Activity.dc.html`, group "Activity, owner and admin" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). The frame is `source/ActivityFrame.dc.html`; its copy, component mapping and API tables are `source/activity-spec.js`.
The audit log of the back-office: who did what, and when, across articles, media, users and invites, API keys, authentication and system tasks. It is for non-technical owners, so every row reads as a sentence, not a log line. Events are append-only: nothing in the app edits or deletes them. Only the retention period removes them (Settings, "Retention").
Other groups of the same board:
| Group | What it draws | Written up in |
|---|---|---|
| Details slide-over | The slide-over and its bottom-sheet variant, six events | this file, "Details slide-over" |
| My activity | The same screen for a user who is not owner or admin | this file, "My activity" |
| Home card | The Recent activity card on Home | `screens/home.md`, "Recent activity card" |
| Settings retention | The Retention card on Settings | `screens/settings.md`, "Activity retention" |
## Roles
Owner and admin see every event, the summary strip, the Person filter and Export. Any other role sees only its own events: the title becomes "My activity", the strip, the Person filter and Export are hidden, and the server scopes the list to that user (asking for another actor answers 403).
Today the product has only `owner` and `admin` (see `screens/users.md`, "Roles"), so "My activity" and the scoped answer cannot be reached by a real account yet. They are specified and tested now so a future role needs no rework. The board's sample "Editor" role and the nav it draws for a regular user (Home, Articles, Media, Activity) are illustrative.
## Sidebar
"Activity" (icon `history`) sits between Users and Integration for owner and admin: Home, Articles, Media, Users, Activity, Integration, Settings. The board draws the order that way. It is visible to every verified user. The mobile drawer gets the same item.
## The page
Title "Activity" (regular users: "My activity"). Subtitle "Who did what, and when, across your site." (regular users: "Everything you've done on this site."). Page padding 24 px desktop, 16 px mobile; the sidebar and top bar are the existing DashboardShell.
1. **Header action** (owner and admin): outline `UButton` with icon `download`, "Export CSV". While the file is prepared: "Preparing file…" and disabled. Success toast "Export started. Your file will download shortly." Error toast "Couldn't export activity. Try again." Disabled in "Empty, no activity yet".
2. **Summary strip** (owner and admin): four `UCard`, each a `button`. Labels: "Total actions", "Active people", "Failed actions", "Security events". Each shows the number, a sub-line and a seven-bar sparkline (seven `div` bars, no chart library, oldest left).
- Total: "12% vs previous 7 days" with `trending-up` / `trending-down` (the comparison is against the equal-length period just before the selected range). Click clears the filters.
- Active people: "7 people, 2 API keys". Click does nothing (no equivalent filter).
- Failed: "0.5% of all actions". Emphasis with `--rc-error-soft` and `--ui-error`, icon `circle-x`. Click applies Outcome = Failed.
- Security: "5 failed logins". Emphasis with `--rc-warning-soft` and `--ui-warning`, icon `shield-alert`. Click applies the security filter (auth, role changes, API key create and revoke).
- Zero: Failed shows "0" with "Nothing failed" and `circle-check`; Security shows "0" with "Nothing to flag" and `circle-check`; both drop the emphasis fill. No data at all: every card reads "0" with the sub-line "No data yet" (Failed and Security keep their calm lines).
- The strip follows the date range filter. Mobile: a 2×2 grid.
3. **Filter bar**: `UInput` (icon `search`, clearable, "Search activity"), Person `USelectMenu` (grouped: users, API keys, "System"; "All people"), Category `USelectMenu` ("All categories": Articles, Media, Users and invites, API keys, Authentication, System), Outcome button group (All / Succeeded / Failed; `soft` is the selected one), date presets (Today / 7 days / 30 days / Custom; Custom opens a date-range popover). Mobile: the bar collapses to a search field and a "Filters" button with a count badge; the controls move to a bottom sheet.
4. **Active filter chips**: `UBadge` (subtle) with a trailing ghost `x` button: "Search: spring", "Person: Marta Alves", "Category: Authentication", "Outcome: Failed", "Dates: 1 – 30 Sep 2026", then a `link` button "Clear all". The default date range (7 days) is not a chip.
5. **Timeline table**: newest first, grouped by day with full-width separator rows ("Today", "Yesterday", "3 Oct 2026"). Columns: When (relative within the day, clock time before; the full time is the cell's `title`), Actor (`UAvatar` with initials, a key icon for API keys or a cog for System, plus the name; badges "API key" / "System"; a signed-out actor reads "Not signed in"), Action (category icon and the sentence with the target name in semibold; a `shield` marker on security rows), Target (a link when it still exists; struck through with a "Deleted" badge when it does not), Outcome (`UBadge` "Succeeded" / "Failed"). Row click and Enter open the details.
6. **Pagination**: "Showing 1–25 of 1,284", `UPagination`, "Rows per page" select (25, 50, 100). Mobile: "Page 1 of 52" with previous and next.
7. **Mobile list**: below `md` the table is a stacked list of `UCard` rows (actor, sentence, time, outcome, "Target deleted" when relevant), 44 px touch targets.
Sentences are composed in the SPA from a stable `action` code and the event params, in the user's language (English and French), never stored as text. The target name and the typed values in a diff stay as the user wrote them.
## States
### Default, large dataset
**Trigger:** Both requests resolved with 1,284 events over 52 pages. Newest first, day separators, a security row. Hover on the first row shows its exact time.
### Loading
**Trigger:** Both requests in flight. Skeleton strip and table rows after 150 ms.
### Zero-value summary strip
**Trigger:** A quiet period. Failed and Security show a calm 0.
### Filtered, card applied
**Trigger:** The Failed actions card was clicked. Chips plus "Clear all"; mobile shows the count on Filters. Six results.
### Mobile filters sheet
**Trigger:** Filters open as a bottom sheet on mobile. The desktop frame keeps the inline bar. Sheet: title "Filters", Person (owner and admin), Category, Outcome, Dates (two date fields when Custom), footer "Clear all" and "Show 23 results".
### Empty, no activity yet
**Trigger:** A new site. `UEmpty` with icon `history`, title "No activity yet", description "Actions on articles, media, users and API keys will appear here." Export disabled.
### Empty, filters match nothing
**Trigger:** Filters with zero results. Title "No activity matches these filters", description "Try a different search or a wider date range.", action "Clear filters".
### Error with retry
**Trigger:** The list request failed (500 or network). `UEmpty` in the table area: "Couldn't load activity", "Check your connection and try again.", action "Try again". The strip still shows if its own call succeeded.
### Forbidden (403)
**Trigger:** The API answered 403 for this role or filter. "You can't view this activity.", "Your role doesn't include this page. Ask an owner or admin if you need access.", action "Back to home". The 401 is global.
## Details slide-over
`USlideover`, right side on desktop (about 440 px wide), bottom sheet on mobile. Opens from a row (click or Enter), closes with Escape, the close button or the scrim; focus returns to the row. Title "Activity details".
Body, in `UCard` sections:
- The full sentence, with the target name in semibold.
- When: the user's local time, then a muted "UTC" line.
- Actor (with the same badges), Source ("Web app", "API key", "System task"), IP address, Browser. For a system event, IP address and Browser read "Not applicable".
- Target: name (struck through with a "Deleted" badge, plus "This item was deleted. Its name is kept for reference." when it no longer exists), and a link when it exists.
- Why it failed (failed events only): the plain-language reason from the API, never a stack trace.
- Changes (edits only): rows of field, Before, After. Values longer than two lines clamp with "Show more" / "Show less". A body edit is a single row "Body edited (+312 characters)" and never shows text.
- Footer: "Previous event" and "Next event" buttons and "Event 2 of 1,284". They move through the current filtered list, across page boundaries.
### Edit with a diff
**Trigger:** A title change and a body edit. Title Before and After, then "Body edited (+312 characters)".
### Settings diff, long text
**Trigger:** Long values clamp to two lines with "Show more".
### Failed action, very long target title
**Trigger:** A failed publish. The reason is shown and the long target title wraps.
### Deleted target, no diff
**Trigger:** A deleted image. Struck-through target, "Deleted" badge, no Changes section.
### System-initiated, no human actor
**Trigger:** A scheduled task. "System" badge, source "System task", IP and Browser "Not applicable".
### Security event, failed login
**Trigger:** The actor was not signed in. "Not signed in", a restrained `shield` marker, the reason, the IP and browser.
## My activity
The same screen for a role that is not owner or admin. No summary strip, no Person filter, no Export. The list is the user's own events (server scoped).
### Default
**Trigger:** The user has events. Title "My activity".
### Empty
**Trigger:** No events yet: "No activity yet", "Your actions will appear here once you start working on the site."
### Details
**Trigger:** The slide-over on one of the user's own events.
## API contract
401 and 403 on every route follow `states.md`, "HTTP status to copy". The board's table in `source/activity-spec.js` is the source; this section fixes the parts it leaves open.
**`GET /v1/activity`**
- Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`, `api_keys`, `auth`, `system`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range` (`today`, `7d`, `30d`, `custom`), `from`, `to` (ISO dates, with `custom`), `page` (from 1), `size` (25, 50 or 100; default 25). Sorted by `created_at` descending, then `id` descending. Invalid values answer 400 "The request cannot be processed due to a mismatch in the format of the body."
- `security=true` is what the Security card applies; it is a flag on the event, not a category.
- Response: `{ items, total, page, size }`. An item:
```json
{
"id": 4821,
"created_at": "2026-10-06T10:42:18Z",
"action": "article.publish",
"category": "articles",
"actor": { "type": "user", "id": 3, "name": "Marta Alves" },
"target": { "type": "article", "id": "8", "name": "Spring release", "exists": true },
"outcome": "success",
"failure_reason": null,
"security": false,
"source": "web",
"ip": "203.0.113.12",
"user_agent": "Chrome 129 on Windows",
"changes": [{ "field": "online", "kind": "text", "before": "false", "after": "true" }]
}
```
- `actor.type` is `user`, `api_key`, `system` or `anonymous` (a failed login). `actor.name` is a snapshot taken when the event was recorded, so a renamed or deleted user still reads correctly. `target.name` is a snapshot too; `target.exists` is resolved when the list is read.
- `action` is a stable dotted code. The SPA owns the wording, in English and French. `params` holds extra values a sentence needs (for example `{ "merged": 3, "articles_updated": 5 }` on a media purge); they are never secrets.
- `changes[].kind` is `text` or `body`. A `body` change has `delta` (a signed number of characters) and no `before` or `after`. Only allowlisted fields ever appear in `changes`, and never a password, hash, API key, token or article body.
- `source` is `web`, `api_key` or `system`. `ip` and `user_agent` are `null` for `system`.
- A caller who is not owner or admin is scoped to its own events; `actor` other than itself answers 403.
**`GET /v1/activity/summary`** (owner and admin; 403 otherwise)
Same `range`, `from`, `to`. Response: `total`, `previous_total`, `active_people: { users, api_keys }`, `failed`, `security`, and `series` with seven daily counts for `total`, `active`, `failed` and `security`. The previous period is the equal-length period immediately before the range.
**`GET /v1/activity/export`** (owner and admin; 403 otherwise)
The same filters without `page` or `size`. Streams `text/csv; charset=utf-8` with `Content-Disposition: attachment`. Columns: `when` (RFC3339 UTC), `actor`, `action` (the code), `target`, `outcome`, `source`, `ip`. No changes, no user agent, no secrets. Cells that start with `=`, `+`, `-` or `@` are prefixed with a quote so a spreadsheet does not evaluate them.
**`GET /v1/settings` and `PATCH /v1/settings`**: see `screens/settings.md`, "Activity retention".
These routes are session only: with an API key they answer 403 "This action needs a real session." (A key never reads the audit trail.)
## What is recorded
Every row of the board is a sample. The real catalogue is the set of actions that exist in the product, in these categories:
| Category | Events |
|---|---|
| `articles` | create, edit (title, publish state, body size delta), publish, unpublish, delete |
| `media` | upload, rename, delete, merge, purge (manual; the scheduled run is a `system` event) |
| `users` | register, verify account, update profile (display name), invite create, cancel, renew, accept |
| `api_keys` | create, revoke |
| `auth` | login succeeded, login failed (security), logout, password reset requested (security), password reset completed (security) |
| `system` | update checked, update downloaded, restart requested, scheduled media purge, activity retention changed, activity cleanup |
Not drawn as real events: the "Editor" role and role changes, a "Site description" or "Comments" setting, "Ran the nightly backup" (backups are a step of the update job, not a schedule), "Removed 3 expired invites" and an administrator resetting another user's password. They stay in the board as sample data and are only recorded if the matching feature exists.
Read requests are not recorded (no "article viewed"), and neither are public API reads.
+10
View File
@@ -54,3 +54,13 @@ The greeting reads "Good morning, {name}", where the name is the display name wh
**Trigger:** The download is checked. The same banner changes its copy, and a dismiss comes back on the next visit because the update is waiting.
- Banner: success tone, `circle-check` icon, title "Version 2.3.0 is ready to install", text "Restart from Settings to finish. The site is unavailable for about 10 seconds.", button "Go to Settings"
## Recent activity card
**API:** GET /v1/activity?size=5. Visual reference: `source/RenewCMS Screens Activity.dc.html`, group "Home card". The board draws the rest of Home as a dashed "Existing Home cards" placeholder, so this card is not pixel-checked against it.
A card with the last five events. Header "Recent activity" (regular users: "Your recent activity") and a "View all" link with `arrow-right` to `/activity`. Each row: a 24 px avatar, the sentence clamped to two lines with the target in semibold, then "Marta Alves · 2 min ago" (own events: only the time). A failed event gets a "Failed" badge; a successful security event a small `shield`.
- Placement as drawn: a 420 px column to the right of the existing cards on desktop; stacked on mobile, drawn above the existing cards. Confirm against the real Home layout before coding.
- States: Owner or admin, Regular user, Loading (five skeleton rows after 150 ms), Error ("Couldn't load recent activity" with "Try again"), Empty ("No activity yet", "Actions on your site will appear here." / own: "Your actions will appear here.").
- The card never blocks the page: an error or empty card leaves the stats and the article list as they are.
+3 -2
View File
@@ -1,7 +1,7 @@
# Media
- **Route:** `/media` (verified users)
- **API:** GET /v1/media/ · POST /v1/media/ · PATCH /v1/media/{id} · DELETE /v1/media/{id} · POST /v1/media/merge · GET /v1/media/{id}/usage
- **API:** 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
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Media", drawn by `source/MediaFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
The library of every uploaded image. It replaces "images belong to one article": an image belongs to the library and can be used in any number of articles. Deleting an article never deletes images. The sidebar gains **Media** (`images` icon) between Articles and Users, breadcrumb "Media".
@@ -12,7 +12,7 @@ New route for verified users, in the sidebar between Articles and Users with the
Header: title "Media", subtitle "Every image you've uploaded, ready to use in any article.", primary "Upload" button (`upload` icon) on the right. On mobile the button is a full-width 48 px button pinned to the bottom (it turns into "Try again in 0:42" while rate limited, and is replaced by the bulk bar while items are selected).
Toolbar: tabs **All**, **Unused** and **Duplicates** (count badge of duplicate groups, only while groups exist), then search ("Search by filename"), sort and the grid/list toggle. Sort options: Newest (default), Oldest, Name, Largest. Below md the toolbar stacks: search, tabs, then sort and toggle.
Toolbar: tabs **All**, **Unused** and **Duplicates** (count badge of duplicate groups, only while groups exist), then search ("Search by filename"), sort and the grid/list toggle. Sort options: Newest (default), Oldest, Name, Largest. On the Duplicates tab, owners and admins also get a secondary "Purge duplicates" button (`merge` icon) after the sort menu (full width, 44 px, in the stacked toolbar on mobile), hidden while there are no groups; it opens the PurgeDuplicatesDialog described in [settings](settings.md#media-cleanup-second-card). Below md the toolbar stacks: search, tabs, then sort and toggle.
Grid (default): 5 columns on desktop, 2 on mobile, 16 px gap (10 px on mobile). Each card is one button: 4:3 thumbnail (checkerboard behind transparent PNG and SVG), filename (medium, ellipsis), `size · dimensions` (SVG dimensions read "Vector"), then badges. The accessible name reads "north-wall.jpg, 3.9 MB, used in 2 articles" or "…, unused". A "Select all" row with the image count sits above the grid.
@@ -149,6 +149,7 @@ Grid items are buttons, one tab stop each. Enter opens the detail panel, Space t
| Upload: rejected files (413, 400) | Type and size are checked in the browser first, so nothing is sent. A server 413 or 400 for a file that got through shows the same row with its own message. |
| Upload: too many requests (429) | Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept. |
| Duplicates: groups | The Duplicates tab. Groups of identical images side by side, each with its uploader, upload date and "Used in N articles". The suggested copy to keep is the most used, then the oldest. |
| Duplicates: purge all | Owner or admin, groups exist. "Purge duplicates" opens the purge dialog (preview, confirm, progress, results), the same one as Settings "Purge now". "Merge into this one" is unchanged. |
| Duplicates: empty | No groups. The Duplicates tab loses its count badge. |
| Merge: confirm | "Merge into this one" on floorboards.jpg. The dialog says articles using the other copies will switch to this image and the other copies will be deleted, with the article count. |
| Merge: in progress | Request in flight. The dialog can't be dismissed. |
+68 -1
View File
@@ -1,7 +1,7 @@
# Settings
- **Route:** `/settings`
- **API:** GET /v1/settings (version) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart
- **API:** GET /v1/media/purge-settings · PATCH /v1/media/purge-settings · GET /v1/media/purge-preview · POST /v1/media/purge (see "Media cleanup (second card)" at the end) · GET /v1/settings (version) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Settings", drawn by `source/AppFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Opened from the sidebar (Settings, settings icon, after Integration) and from the mobile drawer. Every verified user sees it. It holds two sections: Version (everyone) and Updates (the owner has the buttons, admins read the status).
@@ -125,3 +125,70 @@ For the owner only: an **Update** pill on the Settings sidebar item, and a dot o
| Admin view | Admins see the version and the note, with no buttons. Up to date: "You're up to date" and "Only the owner can update or restart the site." No Home banner or Settings pill. |
| Role changed (403) | An owner was changed to admin while the page was open and pressed a button. The card reloads in the admin view. |
| Update already running (409) | Another owner started it. The card switches to that run's progress. |
## Media cleanup (second card)
Route: `/settings`. Visual reference: `RenewCMS Screens Media cleanup.dc.html` (frames: `SettingsFrame.dc.html`, `MediaFrame.dc.html`).
API: `GET/PATCH /v1/media/purge-settings` (owner, admin), `GET /v1/media/purge-preview`, `POST /v1/media/purge`. 401 and 403 are global: no local copy.
Rule: a group is purged only if at least one copy is used in an article. All copies merge into the one used in the most articles (ties: oldest, then lowest id). Groups with no used copy are left alone. Automatic purge is off by default.
### Card
Second card below Version, 20 px gap, same card styling. Page max width 720 px.
- Title: "Media cleanup". Description: "Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one."
- "Automatic purge" / helper "Runs in the background. Off by default." / USwitch.
- When on: "Every" / helper "Between 1 and 365 days." / number input, suffix "days", default 30.
- Read-only lines: "Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated" or "Never run". When on, also "Next purge: 14 Jul 2026".
- Divider, then "Purge now" / "Merge every group of duplicates that has an image in use, right away." / outline button "Purge duplicates" (merge).
- No Save button. Saves on toggle and on blur or Enter of the days field. "Saved" (check, success) sits in the card header and fades after 2 s; "Saving…" (loader-circle) while pending.
- Non-owner/admin: switch and input disabled, button and divider hidden, info alert "Only owners and admins can change this."
### States (names matched exactly)
- Default: off, never run
- On: 30 days, last run
- Saving
- Saved
- Save failed: "Couldn't save. Try again in a moment." under the field; the control reverts.
- Validation error: "Enter a whole number of days between 1 and 365." Nothing is sent.
- Read-only (non-admin)
- Loading skeleton (after 150 ms)
- Offline: banner "You're offline. Changing automatic purge and purging duplicates need a connection." Switch, input and button disabled.
### Dialog
UModal, `sm:max-w-md`, bottom sheet on mobile, cancel first.
- Title "Purge duplicates?". Description "Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
- Summary: "4 groups · 9 copies will be deleted · 12 articles will be updated". Singular: "1 group · 1 copy will be deleted · 2 articles will be updated".
- Bordered list of up to 5 groups: thumbnail, filename, "Kept · used in 4 articles", and "2 copies merged". More: "and 3 more groups".
- Link "Review in Duplicates" to `/media?filter=duplicates`.
- Buttons: "Cancel", danger "Purge 4 groups". Pending: "Purging…" with loader-circle, dialog can't be dismissed.
- Loading preview: skeleton rows, confirm disabled.
- Nothing to purge: icon copy, "Nothing to purge", "No duplicate is used in an article, so there is nothing to merge.", button "Close". No confirm.
- Error (stays open): "Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
- Partial: "Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish." Buttons "Close", "Retry". Summary refreshes to the remaining groups.
- Conflict (409): "The library changed, so nothing was purged. Check the new summary and try again." Summary refreshes.
### Results
- Toast (5 s): circle-check, "Duplicates purged" / "9 copies merged. 12 articles updated."
- "Last purge" updates in place; "Next purge" moves forward by the interval.
- An unattended run shows no toast. The next visit shows the updated line.
### Media entry point
Duplicates tab: secondary "Purge duplicates" button (merge) next to the sort menu; stacked toolbar on mobile. Opens the same dialog. Hidden for non-owner/admin and when there are no groups. "Merge into this one" is unchanged.
### Components (Nuxt UI v4)
`UCard`, `USwitch`, `UInputNumber` (min 1, max 365, suffix), `UFormField`, `UAlert`, `UButton`, `UModal`, `USkeleton`, `useToast`.
## Activity retention
**API:** GET /v1/settings · PATCH /v1/settings (owner only). Visual reference: `source/RenewCMS Screens Activity.dc.html`, group "Settings retention". The board draws the other cards as a dashed "Existing Settings cards" placeholder and gives the page a different subtitle ("Site-wide options. Only owners can change them."), which is not a decision to change the existing one.
A third card below Media cleanup, owner only (hidden for admins), same card styling, 640 px at most as drawn.
- Header: "Retention" / "How long activity is kept." "Saved" (check, success) at the right after a save, fading after 2 s.
- "Keep activity for" `USelectMenu`: 30 days, 90 days, 365 days, Forever. Default 90 days. Helper: "Older events are removed automatically." For Forever: "Nothing is removed automatically. The log grows over time."
- Info alert (`lock`): "Activity logs are append-only", "Events can't be edited or deleted from the app. Only the retention period removes them."
- Divider, then read-only lines: "Last cleanup: 6 Oct 2026, 03:00 · 142 events removed" and "Next cleanup: 7 Oct 2026, 03:00". Forever: "Never" and "Not scheduled".
- Saves on change. Choosing a shorter period than the current one first shows a warning alert, "Events older than 30 days will be removed", "This happens at the next cleanup on 7 Oct 2026, 03:00. It can't be undone.", buttons "Apply 30 days" and "Cancel"; nothing is sent until Apply. Lengthening or choosing Forever saves at once.
- Save error alert: "Couldn't save the retention period", "Your previous setting is still in place.", "Try again". The control reverts.
- States: Default (90 days, saved), Shortening the period, Forever, Save error. Loading and Offline follow the Media cleanup card.
- `GET /v1/settings` adds `activity_retention_days` (30, 90, 365 or 0 for forever), `activity_last_cleanup_at` (null before the first cleanup) and `activity_last_cleanup_removed`. `PATCH /v1/settings` takes `{ "activity_retention_days": 30 }`; any other value answers 400 "The request cannot be processed due to a mismatch in the format of the body."; a non-owner gets 403 "Only the owner can do this.". The cleanup runs once a day at 03:00 server time and never touches the last 24 hours.
+252
View File
@@ -0,0 +1,252 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<div style="display:contents">{{ view }}</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1440,&quot;height&quot;:900},&quot;s&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;object&quot;},&quot;mobile&quot;:{&quot;editor&quot;:null,&quot;tsType&quot;:&quot;boolean&quot;}}">
class Component extends DCLogic {
renderVals() {
const s = this.props.s || {}, m = !!this.props.mobile, h = React.createElement
const P = t => Object.fromEntries(t.split(';').filter(x => x.trim()).map(x => { const i = x.indexOf(':'); const k = x.slice(0, i).trim(); return [k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), x.slice(i + 1).trim()] }))
const D = (t, ...c) => h('div', { style: P(t) }, ...c)
const S = (t, ...c) => h('span', { style: P(t) }, ...c)
const I = (n, z = 16, t = '') => h('span', { 'aria-hidden': 'true', style: { display: 'inline-block', flexShrink: 0, width: z, height: z, backgroundColor: 'currentColor', WebkitMask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', mask: 'url(https://api.iconify.design/lucide/' + n + '.svg) center / contain no-repeat', ...(t ? P(t) : {}) } })
const bh = m ? 44 : 32
const MONO = "font-family:'IBM Plex Mono',monospace"
const B = (label, o = {}) => {
const k = o.k || 'outline'
const bg = k === 'primary' ? 'var(--ui-primary)' : 'var(--ui-bg)'
const fg = k === 'primary' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)'
const bd = k === 'primary' ? bg : 'var(--ui-border-accented)'
return S('height:' + (o.h || bh) + 'px;' + (o.w ? 'width:' + o.w + 'px;' : '') + 'padding:0 ' + (o.px ?? 12) + 'px;display:flex;align-items:center;justify-content:center;gap:6px;background:' + bg + ';color:' + fg + ';border:1px solid ' + bd + ';font-weight:500;font-size:' + (o.fs || 13) + 'px;white-space:nowrap;flex-shrink:0;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : ''), o.icon && I(o.icon, o.z || 16), label)
}
const BG = { ok: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'check'], fail: ['background:var(--rc-error-soft);color:var(--ui-error)', 'circle-x'], sec: ['background:var(--rc-warning-soft);color:var(--ui-warning)', 'shield-alert'], del: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)', 'trash-2'], neutral: ['border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)'], info: ['background:var(--rc-info-soft);color:var(--ui-info)'] }
const Bd = (t, k, sz = 20) => S('height:' + sz + 'px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;' + BG[k][0], BG[k][1] && I(BG[k][1], 12), t)
const Out = e => e.ok ? Bd('Succeeded', 'ok') : Bd('Failed', 'fail')
const Alert = (tone, icon, title, text, action) => D('display:flex;gap:10px;padding:12px 14px;background:var(--rc-' + tone + '-soft);color:var(--ui-' + tone + ')', S('display:flex;padding-top:2px', I(icon)), D('flex:1;display:flex;flex-direction:column;gap:8px', D('display:flex;flex-direction:column;gap:2px', S('font-weight:600', title), text && S('color:var(--ui-text-toned)', text)), action && D('display:flex;gap:8px', ...[].concat(action).map(a => B(a, { h: m ? 44 : 28, fs: 12 })))))
const Empty = (icon, title, text, action, primary, pad = 56) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + pad + 'px 24px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I(icon, 20)), S('font-weight:600;font-size:15px;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted);max-width:340px;text-wrap:pretty', text), action && B(action, { k: primary ? 'primary' : 'outline', px: 16 }))
const Note = t => S('align-self:flex-start;height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px \'IBM Plex Mono\',monospace', t)
const sk = (w, hh = 12, extra = '') => S('display:block;width:' + w + ';height:' + hh + 'px;background:var(--ui-bg-accented);flex-shrink:0;' + extra)
// ---------- data
const U = n => ({ n, k: 'user' }), KEY = n => ({ n, k: 'key' }), SYS = { n: 'Scheduler', k: 'system' }, ANON = { n: 'Not signed in', k: 'anon' }
const p2 = n => String(n).padStart(2, '0')
const T = (d, hh, mm, ss) => ({ abs: d + ' 2026, ' + p2(hh) + ':' + p2(mm) + ':' + p2(ss), utc: d + ' 2026, ' + p2(hh - 1) + ':' + p2(mm) + ':' + p2(ss) + ' UTC' })
const WEB = { src: 'Web app', ip: '203.0.113.24', ua: 'Firefox 131 on macOS' }
const LONG = 'The complete guide to restoring a nineteenth-century timber-framed farmhouse: structural surveys, lime mortar, breathable insulation and what it all costs'
const E = [
{ id: 1, day: 'Today', rel: '2 min ago', ...T('6 Oct', 10, 42, 18), a: U('Marta Alves'), cat: 'articles', s: 'Published the article ', em: 'Spring release', tk: 'Article', ex: 1, ok: 1, tid: 'art_8f3k2', ...WEB, diff: [{ f: 'Publish state', b: 'Draft', a: 'Published' }] },
{ id: 2, day: 'Today', rel: '29 min ago', ...T('6 Oct', 10, 15, 4), a: U('Marta Alves'), cat: 'articles', s: 'Edited the article ', em: 'Why we reuse original floorboards', tk: 'Article', ex: 1, ok: 1, tid: 'art_2m9xq', ...WEB, diff: [{ f: 'Title', b: 'Why we reuse floorboards', a: 'Why we reuse original floorboards' }, { f: 'Body', note: 'Body edited (+312 characters)' }] },
{ id: 3, day: 'Today', rel: '1 h ago', ...T('6 Oct', 9, 31, 47), a: ANON, cat: 'auth', s: 'Failed login attempt for ', em: 'alex@example.com', ok: 0, sec: 1, why: 'The password did not match. 3 attempts in 10 minutes.', src: 'Web app', ip: '198.51.100.77', ua: 'Chrome 129 on Windows' },
{ id: 4, day: 'Today', rel: '2 h ago', ...T('6 Oct', 8, 50, 9), a: U('Jonas Weber'), cat: 'keys', s: 'Revoked API key ', em: 'Mobile app', tk: 'API key', ex: 1, ok: 1, sec: 1, tid: 'key_71ab0', src: 'Web app', ip: '203.0.113.90', ua: 'Safari 18 on iOS' },
{ id: 5, day: 'Today', rel: '7 h ago', ...T('6 Oct', 3, 0, 2), a: SYS, cat: 'system', s: 'Ran the nightly backup', em: '', ok: 1, src: 'System task', ip: null, ua: null },
{ id: 6, day: 'Yesterday', rel: '18:20', ...T('5 Oct', 18, 20, 36), a: U('Inès Duarte'), cat: 'users', s: 'Changed the role of ', em: 'Jonas Weber', tk: 'User', ex: 1, ok: 1, sec: 1, tid: 'usr_c40d1', tail: ' from Editor to Admin', ...WEB, ip: '203.0.113.12', diff: [{ f: 'Role', b: 'Editor', a: 'Admin' }] },
{ id: 7, day: 'Yesterday', rel: '16:05', ...T('5 Oct', 16, 5, 51), a: KEY('Website sync'), cat: 'media', s: 'Uploaded the image ', em: 'north-wall.jpg', tk: 'Image', ex: 1, ok: 1, tid: 'img_0pk4w', src: 'API key', ip: '192.0.2.18', ua: 'node-fetch 3.3' },
{ id: 8, day: 'Yesterday', rel: '14:40', ...T('5 Oct', 14, 40, 13), a: U('Marta Alves'), cat: 'media', s: 'Deleted the image ', em: 'old-banner.png', tk: 'Image', ex: 0, ok: 1, tid: 'img_9zq1c', ...WEB },
{ id: 9, day: 'Yesterday', rel: '11:12', ...T('5 Oct', 11, 12, 27), a: U('Inès Duarte'), cat: 'articles', s: 'Failed to publish the article ', em: LONG, tk: 'Article', ex: 1, ok: 0, tid: 'art_5t7yh', why: 'The article has no cover image. Add one, then publish again.', ...WEB, ip: '203.0.113.12' },
{ id: 10, day: 'Yesterday', rel: '03:00', ...T('5 Oct', 3, 0, 1), a: SYS, cat: 'users', s: 'Removed 3 expired invites', em: '', ok: 1, src: 'System task', ip: null, ua: null },
{ id: 11, day: '3 Oct 2026', rel: '16:48', ...T('3 Oct', 16, 48, 22), a: U('Inès Duarte'), cat: 'system', s: 'Changed the setting ', em: 'Site description', tk: 'Setting', ex: 1, ok: 1, tid: 'set_site_desc', ...WEB, ip: '203.0.113.12', diff: [{ f: 'Site description', b: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes.', a: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes, plus a seasonal maintenance checklist.', long: 1 }, { f: 'Comments', b: 'Off', a: 'On' }] },
{ id: 12, day: '3 Oct 2026', rel: '15:02', ...T('3 Oct', 15, 2, 40), a: U('Jonas Weber'), cat: 'keys', s: 'Created API key ', em: 'Mobile app', tk: 'API key', ex: 0, ok: 1, sec: 1, tid: 'key_71ab0', ...WEB, ip: '203.0.113.90' },
{ id: 13, day: '3 Oct 2026', rel: '14:20', ...T('3 Oct', 14, 20, 5), a: U('Inès Duarte'), cat: 'users', s: 'Updated the user ', em: 'Sam Rivera', tk: 'User', ex: 1, ok: 1, tid: 'usr_77ea2', ...WEB, ip: '203.0.113.12', diff: [{ f: 'Name', b: 'Sam Rivera', a: 'Samuel Rivera' }, { f: 'Email', b: 'sam@example.com', a: 'samuel@example.com' }] },
{ id: 14, day: '3 Oct 2026', rel: '11:36', ...T('3 Oct', 11, 36, 58), a: U('Marta Alves'), cat: 'media', s: 'Uploaded the image ', em: 'floorboards-copy.jpg', tk: 'Image', ex: 1, ok: 1, tid: 'img_4ww8e', ...WEB },
{ id: 15, day: '3 Oct 2026', rel: '09:05', ...T('3 Oct', 9, 5, 12), a: U('Inès Duarte'), cat: 'auth', s: 'Reset the password for ', em: 'Sam Rivera', tk: 'User', ex: 1, ok: 1, sec: 1, tid: 'usr_77ea2', ...WEB, ip: '203.0.113.12' }
]
const CI = { articles: 'file-text', media: 'image', users: 'users', keys: 'key-round', auth: 'shield-check', system: 'server-cog' }
const CL = { articles: 'Articles', media: 'Media', users: 'Users & invites', keys: 'API keys', auth: 'Authentication', system: 'System' }
const user = s.role === 'user'
const ME = user ? 'Marta Alves' : 'Inès Duarte'
let list = s.ids ? s.ids.map(i => E.find(e => e.id === i)) : (user ? E.filter(e => e.a.n === 'Marta Alves') : E)
const total = s.total || (user ? '41' : '1,284')
const sentence = e => e.s + e.em + (e.tail || '')
const Av = (a, z = 24) => {
const base = 'width:' + z + 'px;height:' + z + 'px;flex-shrink:0;display:flex;align-items:center;justify-content:center;'
if (a.k === 'user') return S(base + 'background:var(--rc-sand);color:var(--rc-on-sand);font:600 ' + Math.round(z * 0.42) + "px 'IBM Plex Sans',sans-serif", a.n.split(' ').map(x => x[0]).join('').slice(0, 2).toUpperCase())
return S(base + 'border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated);color:var(--ui-text-toned)', I({ key: 'key-round', system: 'cog', anon: 'user-round-x' }[a.k], Math.round(z * 0.6)))
}
const ActorBd = a => a.k === 'key' ? Bd('API key', 'neutral') : a.k === 'system' ? Bd('System', 'neutral') : null
const ell = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0'
const link = 'text-decoration:underline;text-underline-offset:3px;color:var(--ui-text-highlighted)'
const SecIcon = z => S('display:flex;color:var(--ui-warning)', I('shield-alert', z))
// ---------- controls
const Search = () => D('height:' + bh + 'px;' + (m ? 'flex:1;' : 'width:240px;') + 'padding:0 4px 0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (F.q ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + (m ? ';font-size:16px' : ''), I('search', 14), S('flex:1;' + ell, F.q || 'Search activity'), F.q && S('width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 14)))
const Sel = (val, ph, w, icon) => D('height:' + bh + 'px;padding:0 10px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:' + (val ? 'var(--ui-text-highlighted)' : 'var(--ui-text-muted)') + ';' + (w ? 'width:' + w + 'px;flex-shrink:0' : 'width:100%'), icon && I(icon, 14, 'color:var(--ui-text-muted)'), S('flex:1;' + ell, val || ph), I('chevron-down', 14, 'color:var(--ui-text-muted)'))
const Seg = (opts, on, fw) => D('display:flex;border:1px solid var(--ui-border-accented);flex-shrink:0;' + (fw ? 'width:100%' : ''), ...opts.map(o => S('height:' + (bh - 2) + 'px;padding:0 ' + (m ? 12 : 10) + 'px;' + (fw ? 'flex:1;justify-content:center;' : '') + 'display:flex;align-items:center;gap:6px;white-space:nowrap;font-weight:' + (o === on ? 500 : 400) + ';background:' + (o === on ? 'var(--rc-sand)' : 'var(--ui-bg)') + ';color:' + (o === on ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)'), o === 'Custom' && I('calendar', 14), o)))
const F = { q: '', actor: '', cat: '', out: 'All', range: '7 days', ...(s.f || {}) }
const RANGES = ['Today', '7 days', '30 days', 'Custom']
const chips = []
if (F.q) chips.push('Search: ' + F.q)
if (F.actor && !user) chips.push('Person: ' + F.actor)
if (F.cat) chips.push('Category: ' + F.cat)
if (F.out !== 'All') chips.push('Outcome: ' + F.out)
if (F.range !== '7 days') chips.push('Dates: ' + (F.range === 'Custom' ? (F.custom || '1 – 30 Sep 2026') : F.range))
const nF = chips.length
const Chips = () => nF > 0 && D('display:flex;flex-wrap:wrap;align-items:center;gap:8px', ...chips.map(c => S('height:' + (m ? 32 : 24) + 'px;padding:0 4px 0 8px;display:inline-flex;align-items:center;gap:4px;background:var(--ui-bg-elevated);border:1px solid var(--ui-border);color:var(--ui-text-highlighted);font-size:12px;font-weight:500', c, S('width:20px;height:20px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x', 12)))), S('height:' + (m ? 44 : 24) + 'px;display:flex;align-items:center;padding:0 6px;font-weight:500;font-size:12px;' + link, 'Clear all'))
const FilterBar = () => m
? D('display:flex;gap:8px', Search(), h('span', { style: P('height:44px;padding:0 12px;display:flex;align-items:center;gap:6px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;font-size:13px;flex-shrink:0') }, I('sliders-horizontal', 16), 'Filters', nF > 0 && S('min-width:18px;height:18px;padding:0 5px;display:inline-flex;align-items:center;justify-content:center;background:var(--ui-primary);color:var(--ui-bg);font:500 11px \'IBM Plex Mono\',monospace', String(nF))))
: D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', Search(), !user && Sel(F.actor, 'All people', 168, 'user-round'), Sel(F.cat, 'All categories', 176, 'layout-list'), Seg(['All', 'Succeeded', 'Failed'], F.out), D('flex:1;min-width:0'), Seg(RANGES, F.range))
// ---------- summary
const mk = (l, v, sub, ic, tone, ser) => ({ l, v, sub, ic, tone, ser })
const STAT = s.sum === 'zero' ? [mk('Total actions', '214', '4% vs previous 7 days', 'trending-down', 'n', [3, 5, 2, 6, 4, 1, 2]), mk('Active people', '5', '4 people, 1 API key', '', 'n', [2, 3, 2, 3, 2, 1, 2]), mk('Failed actions', '0', 'Nothing failed', 'circle-check', 'ok0', [0, 0, 0, 0, 0, 0, 0]), mk('Security events', '0', 'Nothing to flag', 'circle-check', 'ok0', [0, 0, 0, 0, 0, 0, 0])]
: s.sum === 'none' ? [mk('Total actions', '0', 'No data yet', '', 'n', [0, 0, 0, 0, 0, 0, 0]), mk('Active people', '0', 'No data yet', '', 'n', [0, 0, 0, 0, 0, 0, 0]), mk('Failed actions', '0', 'Nothing failed', 'circle-check', 'ok0', [0, 0, 0, 0, 0, 0, 0]), mk('Security events', '0', 'Nothing to flag', 'circle-check', 'ok0', [0, 0, 0, 0, 0, 0, 0])]
: [mk('Total actions', '1,284', '12% vs previous 7 days', 'trending-up', 'n', [4, 6, 5, 8, 7, 9, 6]), mk('Active people', '9', '7 people, 2 API keys', '', 'n', [5, 6, 6, 7, 7, 8, 9]), mk('Failed actions', '6', '0.5% of all actions', 'circle-x', 'error', [0, 1, 0, 2, 1, 0, 2]), mk('Security events', '11', '5 failed logins', 'shield-alert', 'warning', [1, 0, 2, 1, 3, 2, 2])]
const Bars = (ser, tone, z) => {
const mx = Math.max(...ser, 1), col = tone === 'error' ? 'var(--ui-error)' : tone === 'warning' ? 'var(--ui-warning)' : tone === 'ok0' ? 'var(--ui-border-accented)' : 'var(--ui-text-muted)'
return D('display:flex;align-items:flex-end;gap:2px;height:' + z + 'px;flex-shrink:0', ...ser.map(v => S('display:block;width:4px;height:' + Math.max(2, Math.round(v / mx * z)) + 'px;background:' + col)))
}
const StatCard = (c, i) => {
const act = s.card === i, tone = c.tone
const bg = tone === 'error' ? 'var(--rc-error-soft)' : tone === 'warning' ? 'var(--rc-warning-soft)' : 'var(--ui-bg)'
const vc = tone === 'error' ? 'var(--ui-error)' : tone === 'warning' ? 'var(--ui-warning)' : 'var(--ui-text-highlighted)'
const sc = tone === 'ok0' ? 'var(--ui-success)' : 'var(--ui-text-muted)'
return D('display:flex;flex-direction:column;gap:2px;padding:' + (m ? '10px 12px' : '12px 14px') + ';background:' + bg + ';border:1px solid ' + (act ? 'var(--ui-primary)' : 'var(--ui-border)') + ';' + (act ? 'box-shadow:inset 0 0 0 1px var(--ui-primary);' : '') + (m ? 'flex:0 0 156px;' : 'min-width:0;'),
D('display:flex;align-items:center;justify-content:space-between;gap:8px', S('color:var(--ui-text-toned);font-weight:500;' + ell, c.l), Bars(c.ser, tone, m ? 16 : 20)),
S('font:600 ' + (m ? 22 : 24) + "px/32px 'IBM Plex Sans',sans-serif;color:" + vc, c.v),
S('display:flex;align-items:center;gap:4px;font-size:12px;color:' + sc + ';' + ell, c.ic && I(c.ic, 12), c.sub))
}
const SkelCard = () => D('display:flex;flex-direction:column;gap:8px;padding:' + (m ? '10px 12px' : '14px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);' + (m ? 'flex:0 0 156px' : 'min-width:0'), sk('55%', 12), sk('40%', 22), sk('70%', 10))
const Strip = () => {
if (user) return null
const kids = s.state === 'loading' ? [0, 1, 2, 3].map(SkelCard) : STAT.map(StatCard)
return m ? D('display:flex;gap:8px;overflow:hidden;margin:0 -16px;padding:0 16px', ...kids) : D('display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px', ...kids)
}
// ---------- table
const cols = user ? '96px minmax(0,1fr) 230px 120px' : '96px 210px minmax(0,1fr) 230px 120px'
const TableHead = () => D('display:grid;grid-template-columns:' + cols + ';height:36px;align-items:center;padding-right:16px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500;flex-shrink:0', S('padding-left:16px;display:flex;align-items:center;gap:4px;color:var(--ui-text-highlighted)', 'When', I('arrow-down', 12)), !user && S('', 'Actor'), S('', 'Action'), S('', 'Target'), S('', 'Outcome'))
const Day = d => D('display:flex;align-items:center;height:32px;padding:0 ' + (m ? 16 : 16) + 'px;background:var(--ui-bg-muted);border-top:1px solid var(--ui-border);font-weight:600;font-size:12px;color:var(--ui-text-highlighted);flex-shrink:0', d)
const Tip = e => S('position:absolute;left:12px;top:38px;z-index:5;padding:6px 8px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font-size:12px;white-space:nowrap;box-shadow:var(--rc-shadow-pop)', e.abs)
const TargetCell = e => !e.tk ? S('color:var(--ui-text-muted)', '—') : D('display:flex;flex-direction:column;min-width:0;padding-right:12px', e.ex ? S('display:flex;align-items:center;gap:4px;' + link, S(ell, e.em), I('arrow-up-right', 12, 'color:var(--ui-text-muted)')) : D('display:flex;align-items:center;gap:6px;min-width:0', S('flex:1;text-decoration:line-through;color:var(--ui-text-muted);' + ell, e.em), Bd('Deleted', 'del')), S('font-size:12px;color:var(--ui-text-muted)', e.tk))
const Row = e => {
const hov = s.tip === e.id, sel = s.panel === e.id
return D('position:relative;z-index:' + (hov ? 3 : 1) + ';display:grid;grid-template-columns:' + cols + ';align-items:center;min-height:52px;padding-right:16px;border-top:1px solid var(--ui-border);background:' + (sel || hov ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';' + (e.sec ? 'box-shadow:inset 2px 0 0 var(--ui-warning);' : ''),
S('padding-left:16px;position:relative;color:var(--ui-text-toned);white-space:nowrap', e.rel, hov && Tip(e)),
!user && D('display:flex;align-items:center;gap:8px;min-width:0;padding-right:12px', Av(e.a), S('font-weight:500;color:var(--ui-text-highlighted);' + ell, e.a.n), ActorBd(e.a)),
D('display:flex;align-items:center;gap:10px;min-width:0;padding-right:16px', S('display:flex;color:var(--ui-text-muted)', I(CI[e.cat], 16)), S('color:var(--ui-text);' + ell, e.s, S('font-weight:600;color:var(--ui-text-highlighted)', e.em), e.tail || ''), e.sec && SecIcon(14)),
TargetCell(e), D('display:flex', Out(e)))
}
const Card = e => {
const sel = s.panel === e.id
return D('display:flex;flex-direction:column;gap:8px;padding:12px 16px;border-top:1px solid var(--ui-border);background:' + (sel ? 'var(--ui-bg-elevated)' : 'var(--ui-bg)') + ';' + (e.sec ? 'box-shadow:inset 2px 0 0 var(--ui-warning)' : ''),
D('display:flex;align-items:center;gap:8px;min-width:0', !user && Av(e.a, 24), !user && S('font-weight:500;color:var(--ui-text-highlighted);' + ell, e.a.n), !user && ActorBd(e.a), D('flex:1'), S('color:var(--ui-text-muted);font-size:12px;white-space:nowrap', e.rel)),
D('display:flex;gap:8px;align-items:flex-start', S('display:flex;padding-top:2px;color:var(--ui-text-muted)', I(CI[e.cat], 16)), S('flex:1;min-width:0;color:var(--ui-text);font-size:15px;line-height:22px;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden', e.s, S('font-weight:600;color:var(--ui-text-highlighted)', e.em), e.tail || '')),
D('display:flex;align-items:center;gap:6px;flex-wrap:wrap', Out(e), e.sec && Bd('Security', 'sec'), e.tk && !e.ex && Bd('Target deleted', 'del')))
}
const rowsOf = fn => { const out = []; let day = null; list.forEach(e => { if (e.day !== day) { out.push(Day(e.day)); day = e.day } out.push(fn(e)) }); return out }
const SkelRows = () => Array.from({ length: m ? 5 : 9 }, (_, i) => m
? D('display:flex;flex-direction:column;gap:10px;padding:14px 16px;border-top:1px solid var(--ui-border)', D('display:flex;gap:8px;align-items:center', sk('24px', 24), sk('90px', 12), D('flex:1'), sk('40px', 10)), sk(['90%', '75%', '85%'][i % 3], 14), sk('70px', 20))
: D('display:grid;grid-template-columns:' + cols + ';align-items:center;height:52px;padding-right:16px;border-top:1px solid var(--ui-border)', S('padding-left:16px', sk('56px')), !user && D('display:flex;gap:8px;align-items:center', sk('24px', 24), sk('90px')), sk(['70%', '55%', '82%'][i % 3], 12), sk('60%'), sk('72px', 20)))
const Table = () => D('flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;background:var(--ui-bg);border:1px solid var(--ui-border)', !m && TableHead(), ...(s.state === 'loading' ? (m ? [] : []).concat(SkelRows()) : rowsOf(m ? Card : Row)))
const PgBtn = (c, on, dis) => S('width:' + bh + 'px;height:' + bh + 'px;display:flex;align-items:center;justify-content:center;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:' + (on ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)') + ';font-weight:500;flex-shrink:0;opacity:' + (dis ? 0.4 : 1), c)
const Pager = () => s.state === 'loading' ? null : m
? D('display:flex;align-items:center;gap:8px;flex-shrink:0', PgBtn(I('chevron-left'), 0, 1), D('flex:1;display:flex;flex-direction:column;align-items:center;font-size:12px;color:var(--ui-text-muted)', S('color:var(--ui-text-highlighted);font-weight:500', 'Page 1 of ' + (user ? '2' : '52')), S('', '1–25 of ' + total)), D('width:96px;flex-shrink:0', Sel('25', '', 0)), PgBtn(I('chevron-right')))
: D('display:flex;align-items:center;gap:16px;flex-shrink:0', S('color:var(--ui-text-muted)', 'Showing 1–' + (user ? '25' : '25') + ' of ' + total), D('flex:1'), D('display:flex;align-items:center;gap:8px;color:var(--ui-text-muted)', 'Rows per page', D('width:80px', Sel('25', '', 0))), D('display:flex;gap:4px', PgBtn(I('chevron-left'), 0, 1), PgBtn('1', 1), PgBtn('2'), PgBtn('3'), PgBtn('…'), PgBtn(user ? '2' : '52'), PgBtn(I('chevron-right'))))
// ---------- header + page
const title = user ? 'My activity' : 'Activity'
const sub = user ? 'Everything you\'ve done on this site.' : 'Who did what, and when, across your site.'
const noData = s.state === 'none'
const Header = () => D('display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap;flex-shrink:0', D('flex:1;min-width:180px;display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', title), S('color:var(--ui-text-muted)', sub)), !user && s.state !== 'forbidden' && B('Export CSV', { icon: 'download', dis: noData || s.state === 'error' }))
const Page = () => {
const k = [Header()]
if (s.state === 'forbidden') { k.push(Empty('lock', 'You can\'t view this activity', 'Your role doesn\'t include this page. Ask an owner or admin if you need access.', 'Back to home', false)); return k }
const st = Strip(); if (st) k.push(st)
k.push(FilterBar()); const c = Chips(); if (c) k.push(c)
if (s.state === 'error') k.push(Alert('error', 'circle-x', 'Couldn\'t load activity', 'Check your connection and try again.', 'Try again'))
else if (s.state === 'none') k.push(Empty('history', 'No activity yet', user ? 'Your actions will appear here once you start working on the site.' : 'Actions on articles, media, users and API keys will appear here.'))
else if (s.state === 'nomatch') k.push(Empty('search-x', 'No activity matches these filters', 'Try a different search or a wider date range.', 'Clear filters', false))
else { k.push(Table()); const pg = Pager(); if (pg) k.push(pg) }
return k
}
// ---------- slide-over
const Av2 = Av
const Panel = () => {
const e = E.find(x => x.id === s.panel), i = list.indexOf(e)
const lab = t => S('color:var(--ui-text-muted)', t)
const val = (...c) => D('display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0;color:var(--ui-text-highlighted)', ...c)
const Diff = d => {
if (d.note) return D('display:flex;flex-direction:column;gap:6px;padding:12px;border-top:1px solid var(--ui-border-muted)', S('font-weight:500;color:var(--ui-text-highlighted)', d.f), S('display:flex;align-items:center;gap:6px;color:var(--ui-text-toned)', I('file-pen-line', 14), d.note))
const clamp = d.long && !s.expand
const box = (t, bg, bd, c) => S('display:block;padding:8px 10px;background:' + bg + ';border:1px solid ' + bd + ';color:' + c + ';overflow-wrap:anywhere;' + (clamp ? 'display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden' : ''), t)
return D('display:flex;flex-direction:column;gap:8px;padding:12px;border-top:1px solid var(--ui-border-muted)', S('font-weight:500;color:var(--ui-text-highlighted)', d.f),
D('display:grid;grid-template-columns:' + (m ? 'minmax(0,1fr)' : 'minmax(0,1fr) 16px minmax(0,1fr)') + ';gap:' + (m ? 6 : 8) + 'px;align-items:' + (m ? 'stretch' : 'start'), D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-size:11px;color:var(--ui-text-muted)', 'Before'), box(d.b, 'var(--ui-bg-elevated)', 'var(--ui-border)', 'var(--ui-text-toned)')), m ? S('display:flex;justify-content:center;color:var(--ui-text-muted)', I('arrow-down', 14)) : S('display:flex;padding-top:30px;color:var(--ui-text-muted)', I('arrow-right', 14)), D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-size:11px;color:var(--ui-text-muted)', 'After'), box(d.a, 'var(--ui-bg)', 'var(--ui-border-accented)', 'var(--ui-text-highlighted)'))),
d.long && S('align-self:flex-start;font-weight:500;font-size:12px;min-height:' + (m ? 32 : 16) + 'px;' + link, clamp ? 'Show more' : 'Show less'))
}
const body = D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:20px',
D('display:flex;flex-direction:column;gap:10px', S('font:600 18px/26px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted);overflow-wrap:anywhere', sentence(e)), D('display:flex;gap:6px;flex-wrap:wrap;align-items:center', Out(e), e.sec && Bd('Security', 'sec'), S('display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--ui-text-muted)', I(CI[e.cat], 14), CL[e.cat]))),
!e.ok && Alert('error', 'circle-x', 'Why it failed', e.why),
D('display:grid;grid-template-columns:96px minmax(0,1fr);gap:10px 12px;align-items:start', lab('When'), D('display:flex;flex-direction:column;gap:2px', S('color:var(--ui-text-highlighted)', e.abs + ' (your time)'), S('color:var(--ui-text-muted);font-size:12px', e.utc)), lab('Actor'), val(Av(e.a, 24), S('font-weight:500', e.a.n), ActorBd(e.a)), lab('Source'), val(e.src), lab('IP address'), e.ip ? S(MONO + ';font-size:12px;color:var(--ui-text-highlighted)', e.ip) : S('color:var(--ui-text-muted)', 'Not applicable'), lab('Browser'), e.ua ? S('color:var(--ui-text-highlighted)', e.ua) : S('color:var(--ui-text-muted)', 'Not applicable')),
e.tk && D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Target'), D('display:flex;flex-direction:column;gap:6px;border:1px solid var(--ui-border);padding:10px 12px', D('display:flex;align-items:center;gap:8px;min-width:0', e.ex ? S('flex:1;display:flex;align-items:center;gap:4px;overflow-wrap:anywhere;' + link, e.em, I('arrow-up-right', 14, 'color:var(--ui-text-muted)')) : S('flex:1;text-decoration:line-through;color:var(--ui-text-muted);overflow-wrap:anywhere', e.em), !e.ex && Bd('Deleted', 'del')), S('font-size:12px;color:var(--ui-text-muted)', e.tk + ' · ' + e.tid), !e.ex && S('font-size:12px;color:var(--ui-text-toned)', 'This item was deleted. Its name is kept for reference.'))),
e.diff && D('display:flex;flex-direction:column;gap:8px', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Changes'), D('border:1px solid var(--ui-border)', ...e.diff.map((d, j) => { const r = Diff(d); return r }))))
const foot = D('flex-shrink:0;display:flex;align-items:center;gap:8px;padding:12px ' + (m ? 16 : 20) + 'px;border-top:1px solid var(--ui-border)', B('Previous event', { icon: 'chevron-up', dis: i === 0, full: m }), B('Next event', { icon: 'chevron-down', dis: i === list.length - 1, full: m }), !m && D('flex:1'), !m && S('color:var(--ui-text-muted);font-size:12px', 'Event ' + (i + 1) + ' of ' + total))
const head = D('height:56px;flex-shrink:0;display:flex;align-items:center;gap:12px;padding:0 ' + (m ? 8 : 20) + 'px 0 ' + (m ? 16 : 20) + 'px;border-bottom:1px solid var(--ui-border)', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Activity details'), m && S('color:var(--ui-text-muted);font-size:12px', (i + 1) + ' of ' + total), S('width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x')))
return D('position:absolute;inset:0;z-index:40;display:flex;justify-content:' + (m ? 'stretch' : 'flex-end') + ';align-items:' + (m ? 'flex-end' : 'stretch') + ';background:var(--rc-overlay)',
D((m ? 'width:100%;height:88%;' : 'width:480px;height:100%;') + 'background:var(--ui-bg);display:flex;flex-direction:column;box-shadow:var(--rc-shadow-modal)', m && D('flex-shrink:0;display:flex;justify-content:center;padding-top:8px', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')), head, body, foot))
}
const Drawer = () => {
const sec = (l, c) => D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;color:var(--ui-text-highlighted)', l), c)
return D('position:absolute;inset:0;z-index:40;display:flex;align-items:flex-end;background:var(--rc-overlay)', D('width:100%;background:var(--ui-bg);display:flex;flex-direction:column;box-shadow:var(--rc-shadow-modal)',
D('display:flex;justify-content:center;padding-top:8px', S('display:block;width:36px;height:4px;background:var(--ui-border-accented)')),
D('height:56px;display:flex;align-items:center;padding:0 8px 0 16px;border-bottom:1px solid var(--ui-border)', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Filters'), S('width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted)', I('x'))),
D('padding:16px;display:flex;flex-direction:column;gap:16px', !user && sec('Person', Sel(F.actor, 'All people', 0, 'user-round')), sec('Category', Sel(F.cat, 'All categories', 0, 'layout-list')), sec('Outcome', Seg(['All', 'Succeeded', 'Failed'], F.out, 1)), sec('Dates', D('display:flex;flex-direction:column;gap:8px', Seg(['Today', '7 days', '30 days', 'Custom'], F.range, 1), F.range === 'Custom' && D('display:flex;gap:8px;align-items:center', D('flex:1', Sel('1 Sep 2026', '', 0, 'calendar')), D('flex:1', Sel('30 Sep 2026', '', 0, 'calendar')))))),
D('display:flex;gap:8px;padding:12px 16px 16px;border-top:1px solid var(--ui-border)', B('Clear all', { full: true, dis: nF === 0 }), B('Show ' + (s.count || '23') + ' results', { k: 'primary', full: true }))))
}
// ---------- home + settings
const Rows5 = (ls, own) => ls.map((e, i) => D('display:flex;gap:10px;align-items:flex-start;padding:10px 0;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), Av(e.a, 24), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('color:var(--ui-text);overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden', e.s, S('font-weight:600;color:var(--ui-text-highlighted)', e.em), e.tail || ''), S('font-size:12px;color:var(--ui-text-muted)', own ? e.rel : e.a.n + ' · ' + e.rel)), !e.ok && Bd('Failed', 'fail'), e.sec && e.ok && SecIcon(14)))
const ActCard = () => {
const own = user, ls = own ? E.filter(e => e.a.n === 'Marta Alves') : E.slice(0, 5)
const st = s.card || 'ok'
const inner = st === 'loading' ? Array.from({ length: 5 }, (_, i) => D('display:flex;gap:10px;padding:10px 0;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), sk('24px', 24), D('flex:1;display:flex;flex-direction:column;gap:6px', sk(['85%', '70%', '90%', '60%', '78%'][i], 12), sk('35%', 10))))
: st === 'error' ? [Alert('error', 'circle-x', 'Couldn\'t load recent activity', null, 'Try again')]
: st === 'empty' ? [D('padding:24px 0;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center', S('color:var(--ui-text-muted)', I('history', 20)), S('font-weight:600;color:var(--ui-text-highlighted)', 'No activity yet'), S('color:var(--ui-text-muted);max-width:240px', own ? 'Your actions will appear here.' : 'Actions on your site will appear here.'))]
: Rows5(ls, own)
return D('background:var(--ui-bg);border:1px solid var(--ui-border);display:flex;flex-direction:column;min-width:0', D('display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid var(--ui-border)', S('flex:1;font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', own ? 'Your recent activity' : 'Recent activity'), S('display:flex;align-items:center;gap:4px;font-weight:500;min-height:' + (m ? 44 : 20) + 'px;' + link, 'View all', I('arrow-right', 14))), D('padding:4px 16px 6px;display:flex;flex-direction:column', ...inner))
}
const Ph = (t, hh) => D('height:' + hh + 'px;border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);text-align:center;padding:16px', t)
const HomeBody = () => [D('display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Home'), S('color:var(--ui-text-muted)', 'Welcome back, ' + ME.split(' ')[0] + '.')), m ? D('display:flex;flex-direction:column;gap:16px', ActCard(), Ph('Existing Home cards', 160)) : D('display:grid;grid-template-columns:minmax(0,1fr) 420px;gap:16px;align-items:start', Ph('Existing Home cards', 420), ActCard())]
const RET = { '30': '30 days', '90': '90 days', '365': '365 days', '0': 'Forever' }
const SettingsBody = () => {
const r = s.ret || '90', fv = r === '0'
const dl = (k, v) => D('display:flex;flex-direction:' + (m ? 'column' : 'row') + ';gap:' + (m ? 0 : 12) + 'px', S('width:' + (m ? 'auto' : '120px') + ';color:var(--ui-text-muted);flex-shrink:0', k), S('color:var(--ui-text-highlighted)', v))
const card = D('background:var(--ui-bg);border:1px solid var(--ui-border);max-width:640px;display:flex;flex-direction:column',
D('display:flex;align-items:center;gap:8px;padding:12px 16px;border-bottom:1px solid var(--ui-border)', D('flex:1;display:flex;flex-direction:column', S('font:600 14px/22px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Retention'), S('color:var(--ui-text-muted);font-size:12px', 'How long activity is kept.')), s.saved && S('display:flex;align-items:center;gap:4px;color:var(--ui-success);font-size:12px', I('check', 14), 'Saved')),
D('padding:16px;display:flex;flex-direction:column;gap:16px',
s.err && Alert('error', 'circle-x', 'Couldn\'t save the retention period', 'Your previous setting is still in place.', 'Try again'),
D('display:flex;flex-direction:column;gap:6px;max-width:260px', S('font-weight:500;color:var(--ui-text-highlighted)', 'Keep activity for'), Sel(RET[s.shorten ? '30' : r], '', 0), S('color:var(--ui-text-muted);font-size:12px', fv ? 'Nothing is removed automatically. The log grows over time.' : 'Older events are removed automatically.')),
s.shorten && Alert('warning', 'triangle-alert', 'Events older than 30 days will be removed', 'This happens at the next cleanup on 7 Oct 2026, 03:00. It can\'t be undone.', ['Apply 30 days', 'Cancel']),
Alert('info', 'lock', 'Activity logs are append-only', 'Events can\'t be edited or deleted from the app. Only the retention period removes them.'),
D('display:flex;flex-direction:column;gap:6px;padding-top:12px;border-top:1px solid var(--ui-border)', dl('Last cleanup', fv ? 'Never' : '6 Oct 2026, 03:00 · 142 events removed'), dl('Next cleanup', fv ? 'Not scheduled' : '7 Oct 2026, 03:00'))))
return [D('display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;color:var(--ui-text-highlighted)', 'Settings'), S('color:var(--ui-text-muted)', 'Site-wide options. Only owners can change them.')), Ph('Existing Settings cards', m ? 100 : 120), card]
}
// ---------- shell
const logo = h('svg', { width: 26, height: 26, viewBox: '0 0 16 16', shapeRendering: 'crispEdges', style: { color: 'var(--ui-text-highlighted)', flexShrink: 0 } }, h('rect', { width: 16, height: 16, fill: 'currentColor' }), h('path', { fill: 'var(--ui-bg)', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
const kind = s.kind || 'page'
const navKey = kind === 'home' ? 'home' : kind === 'settings' ? 'settings' : 'activity'
const nav = (user ? [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['activity', 'history', 'Activity']] : [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['activity', 'history', 'Activity'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']])
const side = D('width:224px;flex-shrink:0;background:var(--ui-bg);border-right:1px solid var(--ui-border);display:flex;flex-direction:column;padding:12px;gap:2px', D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;margin-bottom:10px', logo, S('font-weight:600;font-size:14px;color:var(--ui-text-highlighted)', 'RenewCMS')),
...nav.map(([k, ic, t]) => D('display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:' + (k === navKey ? 'var(--rc-sand)' : 'transparent') + ';color:' + (k === navKey ? 'var(--rc-on-sand)' : 'var(--ui-text-toned)') + ';font-weight:' + (k === navKey ? 500 : 400), I(ic), t)),
D('flex:1'), D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;color:var(--ui-text-highlighted)', Av(U(ME), 26), S('flex:1;font-weight:500', ME), I('chevrons-up-down', 14)))
const crumb = { page: title, home: 'Home', settings: 'Settings' }[kind]
const topbar = m
? D('height:56px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 6px;gap:4px;color:var(--ui-text-highlighted)', S('width:44px;height:44px;display:flex;align-items:center;justify-content:center', I('menu', 20)), S('flex:1;font-weight:600;font-size:16px', crumb))
: D('height:48px;flex-shrink:0;border-bottom:1px solid var(--ui-border);background:var(--ui-bg);display:flex;align-items:center;padding:0 24px;gap:8px;color:var(--ui-text-muted)', I('panel-left-close'), S('width:1px;height:16px;background:var(--ui-border);margin:0 6px'), S('color:var(--ui-text-highlighted);font-weight:500', crumb))
const kids = kind === 'home' ? HomeBody() : kind === 'settings' ? SettingsBody() : Page()
const content = D('flex:1;min-height:0;overflow:hidden;padding:' + (m ? 16 : 24) + 'px;display:flex;flex-direction:column;gap:16px', ...kids)
const root = D('width:' + (m ? 390 : 1440) + 'px;height:' + (m ? 844 : 900) + 'px;position:relative;display:flex;flex-direction:' + (m ? 'column' : 'row') + ';background:var(--ui-bg-muted);color:var(--ui-text);font-family:\'IBM Plex Sans\',sans-serif;font-size:13px;line-height:20px;overflow:hidden',
!m && side, D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column', topbar, content), s.panel && Panel(), m && s.drawer && Drawer())
return { view: root }
}
}
</script>
</body>
</html>
+4 -2
View File
@@ -144,9 +144,10 @@ class Component extends DCLogic {
if (s.empty === 'first') { parts.push(Empty('images', 'No images yet', 'Upload your first image. You can use it in any article.', 'Upload an image', true), S('align-self:center;color:var(--ui-text-muted);font-size:12px', 'PNG, JPEG, WEBP, SVG · max 10 MB each')); return parts }
if (s.limited) parts.push(Alert('info', 'timer', 'Too many uploads', 'For your security, wait a moment before uploading again.'))
if (s.error) parts.push(Alert('error', 'circle-x', 'Couldn\'t load your images', 'Check your connection and try again.', 'Try again'))
const pb = tab === 'dups' && !s.member && !s.dupEmpty && B('Purge duplicates', { icon: 'merge', dis: s.offline, full: m })
const tb = m
? D('display:flex;flex-direction:column;gap:12px', Search(), TabRow(), D('display:flex;gap:8px', Sort(), Toggle()))
: D('display:flex;align-items:flex-end;gap:12px;border-bottom:1px solid var(--ui-border)', TabRow(), S('display:flex;padding-bottom:6px;gap:8px', Search(), Sort(), Toggle()))
? D('display:flex;flex-direction:column;gap:12px', Search(), TabRow(), D('display:flex;gap:8px', Sort(), Toggle()), pb && D('display:flex', pb))
: D('display:flex;align-items:flex-end;gap:12px;border-bottom:1px solid var(--ui-border)', TabRow(), S('display:flex;padding-bottom:6px;gap:8px', Search(), Sort(), pb, Toggle()))
parts.push(tb)
if (UP) parts.push(UpPanel())
if (s.loading) parts.push(Skel())
@@ -189,6 +190,7 @@ class Component extends DCLogic {
if (k === 'delUsed') return { title: 'Delete an image that\'s in use?', desc: '"north-wall.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can\'t be undone.', body: [ArtBox(usedList(2))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete image', tone: 'danger' }
if (k === 'bulk') return { title: 'Delete 3 images?', desc: '2 of them are used in 5 articles, which will show a broken image. The other one isn\'t used anywhere. This can\'t be undone.', body: [D('display:flex;flex-direction:column;border:1px solid var(--ui-border)', ...[['north-wall.jpg', 'Used in 2'], ['kitchen-after.webp', 'Used in 3'], ['window-frame.jpg', 'Unused']].map(([n, u], i) => D('display:flex;align-items:center;gap:8px;min-height:36px;padding:0 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), S('flex:1;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n), Bd(u === 'Unused' ? 'Unused' : u, u === 'Unused' ? 'unused' : 'used'))))], cancel: 'Cancel', confirm: md.loading ? 'Deleting…' : 'Delete 3 images', tone: 'danger' }
if (k === 'merge') return { title: 'Merge into floorboards.jpg?', desc: 'Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can\'t be undone.', body: [D('display:flex;gap:12px;align-items:center;border:1px solid var(--ui-border);padding:10px 12px', Th(byId(12), 'width:56px;height:42px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'floorboards.jpg'), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in 4 articles now')))], cancel: 'Cancel', confirm: md.loading ? 'Merging…' : 'Merge 2 copies', tone: 'danger' }
if (k === 'purge') return { title: 'Purge duplicates?', desc: 'Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can\'t be undone.', body: [S('font-weight:500;color:var(--ui-text-highlighted)', '4 groups · 9 copies will be deleted · 12 articles will be updated'), D('border:1px solid var(--ui-border)', ...[[12, 'floorboards.jpg', 4, '2 copies'], [7, 'IMG_4471.jpg', 2, '1 copy'], [2, 'kitchen-after.webp', 3, '3 copies'], [4, 'logo-renew.svg', 5, '3 copies']].map(([id, n, u, c], i) => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), Th(byId(id), 'width:40px;height:30px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500;color:var(--ui-text-highlighted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap', n), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in ' + pl(u))), S('font-size:12px;color:var(--ui-text-toned);white-space:nowrap', c + ' merged')))), S('display:inline-flex;align-items:center;gap:4px;align-self:flex-start;font-weight:500;color:var(--ui-text-highlighted);text-decoration:underline;text-underline-offset:3px;min-height:' + (m ? 44 : 20) + 'px', 'Review in Duplicates', I('arrow-right', 14))], cancel: 'Cancel', confirm: 'Purge 4 groups', tone: 'danger' }
if (k === 'remove') return { title: 'Remove this image from the article?', desc: 'It\'s removed from this article only. The file stays in your library and in any other article that uses it.', body: [D('display:flex;gap:12px;align-items:center;border:1px solid var(--ui-border);padding:10px 12px', Th(byId(1), 'width:56px;height:42px;flex-shrink:0;border:1px solid var(--ui-border)', 16), D('display:flex;flex-direction:column;min-width:0', S('font-weight:500;color:var(--ui-text-highlighted)', 'north-wall.jpg'), S('font-size:12px;color:var(--ui-text-muted)', 'Stays in the library · 3.9 MB')))], cancel: 'Cancel', confirm: 'Remove image' }
}
const Shell = (o, body) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? 16 : o.top || 140) + 'px 16px 16px',
@@ -51,7 +51,7 @@
</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;}}">
<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;system&quot;,&quot;tsType&quot;:&quot;'system' | 'light' | 'dark'&quot;}}">
class Component extends DCLogic {
state = { mode: this.props.theme ?? 'light' }
rootRef = React.createRef()
@@ -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>
@@ -223,6 +223,8 @@ class Component extends DCLogic {
Mp({ upload: upT([rowRej, { n: 'notes.pdf', sz: '210 KB', st: 'rej', title: 'notes.pdf isn\'t an image', msg: 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.' }, { n: 'broken.png', sz: '3.0 MB', st: 'rej', title: 'broken.png couldn\'t be read', msg: 'The server couldn\'t read this image. Save it again as a PNG or JPEG, then try again.' }], { title: 'Nothing was uploaded', sub: '3 files were rejected', done: true }), name: 'Upload: rejected files (413, 400)', trigger: 'Type and size are checked in the browser first, so nothing is sent. A server 413 or 400 for a file that got through shows the same row with its own message.' }),
Mp({ limited: true, name: 'Upload: too many requests (429)', trigger: 'Rate limited. Retry-After is counted down in the button. It re-enables at zero. Already-added files are kept.' }),
Mp({ tab: 'dups', name: 'Duplicates: groups', trigger: 'The Duplicates tab. Groups of identical images side by side, each with its uploader, upload date and "Used in N articles". The suggested copy to keep is the most used, then the oldest.' }),
Mp({ tab: 'dups', modal: { k: 'purge' }, name: 'Purge duplicates: confirm', trigger: '"Purge duplicates" in the toolbar, next to the sort menu (in the stacked toolbar on mobile). Opens the same dialog as Settings; every other state is on the Media cleanup board.' }),
Mp({ tab: 'dups', member: true, name: 'Duplicates: no purge button (member)', trigger: 'Verified users who are not owner or admin. The "Purge duplicates" button is hidden. "Merge into this one" is unchanged.' }),
Mp({ tab: 'dups', dupEmpty: true, name: 'Duplicates: empty', trigger: 'No groups. The Duplicates tab loses its count badge.' }),
Mp({ tab: 'dups', modal: { k: 'merge' }, name: 'Merge: confirm', trigger: '"Merge into this one" on floorboards.jpg. The dialog says articles using the other copies will switch to this image and the other copies will be deleted, with the article count.' }),
Mp({ tab: 'dups', modal: { k: 'merge', loading: true }, name: 'Merge: in progress', trigger: 'Request in flight. The dialog can\'t be dismissed.' }),
@@ -0,0 +1,139 @@
<!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 rel="icon" href="brand/favicon.svg">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&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}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
</helmet>
<div ref="{{ rootRef }}" data-screen-label="Media cleanup screens" style="--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-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;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: Media cleanup</h1>
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">A Media cleanup card on Settings, a purge dialog, and an entry point on the Media Duplicates tab (also in <a href="RenewCMS Screens Back-office.dc.html">the Back-office board</a>). 1440 px at 50%, 390 px at 75%. The theme switch shows every state in dark mode.</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="7">
<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>
<sc-if value="{{ g.note }}"><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p></sc-if>
</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"><sc-if value="{{ st.notMedia }}"><dc-import name="SettingsFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></sc-if></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"><sc-if value="{{ st.notMedia }}"><dc-import name="SettingsFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if><sc-if value="{{ st.isMedia }}"><dc-import name="MediaFrame" s="{{ st.m }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></sc-if></div></div>
</div>
</div>
</sc-for>
</div>
<sc-if value="{{ g.table }}"><div style="display:flex;flex-direction:column;gap:8px;max-width:1240px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.tableTitle }}</span><div style="background:var(--ui-bg);border:1px solid var(--ui-border)"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Where</span><span>Copy</span></div><sc-for list="{{ g.table }}" as="r" hint-placeholder-count="3"><div style="display:grid;grid-template-columns:90px 240px minmax(0,1fr);gap:0;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-toned);padding-right:16px">{{ r.b }}</span><span style="color:var(--ui-text-highlighted);text-wrap:pretty">{{ r.c }}</span></div></sc-for></div></div></sc-if>
</section>
</sc-for>
</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;system&quot;,&quot;tsType&quot;:&quot;'system' | 'light' | 'dark'&quot;}}">
class Component extends DCLogic {
state = { mode: this.props.theme ?? 'light' }
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-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-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() }
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, ...o, m: { ...o, ...mo } })
const L = { d: '14 Jun 2026', c: 3, a: 5 }
const on = { on: true, days: '30', last: L, next: '14 Jul 2026' }
const D = (k, name, trigger, o = {}) => W(name, trigger, { ...on, last: null, on: false, dialog: { k, ...(o.dg || {}) }, ...o.s })
const pvMore = { groups: 8, copies: 15, arts: 24, list: [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }, { im: 11, n: 'lime-plaster.jpg', u: 2, c: 1 }], more: 3 }
const pvOne = { groups: 1, copies: 1, arts: 2, list: [{ im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }], more: 0 }
const pv3 = { groups: 3, copies: 7, arts: 10, list: [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }], more: 0 }
const base = { last: null }
const ST = (o) => ({ ...base, ...o })
const Mp = (o) => ({ isMedia: true, kind: 'page', ...o, m: { isMedia: true, kind: 'page', ...o } })
return this.finish([
{ label: 'Settings', title: 'Settings: Media cleanup', route: '/settings', api: 'GET /v1/media/purge-settings · PATCH /v1/media/purge-settings (owner, admin)', note: 'A second card below Version, 20 px gap, same card styling. Automatic purge is off by default. Settings save immediately on toggle and on blur or Enter of the days field; there is no Save button. 401 and 403 are global (login redirect, /register/pending) and have no local copy.', states: [
W('Default: off, never run', 'Owner or admin. Automatic purge is off, so the "Every" row and "Next purge" are hidden.', ST({})),
W('On: 30 days, last run', 'Switch on. "Every" appears with the default of 30 days. The last-run line and the next run are shown.', on),
W('Saving', 'Brief state after toggling or leaving the field. "Saving…" with loader-circle sits in the card header; controls stay usable.', { ...on, saving: true, focusDays: true }),
W('Saved', 'The save succeeded. A small "Saved" confirmation shows in the card header and fades after 2 s. It is announced politely.', { ...on, saved: true }),
W('Save failed', 'A 500 or network failure. The error sits under the field and the control reverts to its previous value (30).', { ...on, saveErr: true, days: '30', focusDays: true }),
W('Validation error', 'Empty, 0, above 365 or not a whole number, checked on blur or Enter. Nothing is sent. The field keeps what was typed until corrected.', { ...on, days: '400', invalid: true, focusDays: true }),
W('Read-only (non-admin)', 'Other verified users. Switch and input are disabled, "Purge now" is hidden, and an info alert explains why.', { ...on, ro: true }),
W('Loading skeleton', 'The settings request is in flight. Skeleton shows only after 150 ms.', { loading: true }),
W('Offline', 'navigator.onLine is false. The banner matches Media. Switch, input and button are disabled.', { ...on, offline: true })
] },
{ label: 'Dialog', title: 'Purge duplicates: dialog', route: 'opens from "Purge now" (Settings) and "Purge duplicates" (Media, Duplicates tab)', api: 'GET /v1/media/purge-preview · POST /v1/media/purge', note: 'UModal, sm:max-w-md, bottom sheet on mobile. Cancel first, then the danger confirm. While the request runs, the confirm shows loader-circle and the dialog can\'t be dismissed. The preview is computed before confirming. A group is purged only if one of its copies is used in an article; the kept copy is the one used in the most articles (ties: oldest, then lowest id).', states: [
D('loading', 'Loading preview', 'The preview request is in flight. Skeleton rows show after 150 ms and the confirm is disabled.'),
D('confirm', 'Confirm', '"Purge now" pressed. Summary, up to 5 groups, a link to the Duplicates tab.'),
D('confirm', 'Confirm: many groups', 'More than 5 groups. The list stops at 5 and says how many more.', { dg: { pv: pvMore } }),
D('progress', 'In progress', 'POST in flight. Confirm reads "Purging…" with loader-circle; Cancel is disabled and Esc, the overlay and swipe do nothing.'),
D('nothing', 'Nothing to purge', 'The preview has no group with an image in use. No confirm; one Close button.'),
D('error', 'Error', '500 or network failure. The dialog stays open, nothing changed. Confirm retries.'),
D('partial', 'Partial failure', 'Some groups failed. The summary refreshes to what is left. Buttons: Close, Retry.', { dg: { pv: pvOne } }),
D('conflict', 'Conflict (409)', 'The library changed since the preview. Nothing was purged, the summary refreshes and the dialog stays open.', { dg: { pv: pv3 } })
] },
{ label: 'Results', title: 'Results and feedback', route: '/settings', api: 'after POST /v1/media/purge', note: 'An unattended run does not toast. The next visit to Settings shows the updated "Last purge" line.', states: [
W('Success', 'The dialog closes, a toast shows for 5 s, and the "Last purge" line updates in place.', { on: false, last: { d: '5 Oct 2026', c: 9, a: 12 }, toast: true }),
W('Success, automatic purge on', 'Same, with the next run moved forward by the interval.', { on: true, days: '30', last: { d: '5 Oct 2026', c: 9, a: 12 }, next: '4 Nov 2026', toast: true })
] },
{ label: 'Media', title: 'Media: Duplicates tab entry point', route: '/media?filter=duplicates', api: 'owner and admin only', note: 'A secondary "Purge duplicates" button (icon merge) in the toolbar next to the sort menu, and in the stacked toolbar on mobile. Per-group "Merge into this one" is unchanged.', states: [
Mp({ tab: 'dups', name: 'Duplicates tab with the button', trigger: 'Owner or admin.' }),
Mp({ tab: 'dups', modal: { k: 'purge' }, name: 'Dialog from Media', trigger: 'The same dialog as on Settings.' }),
Mp({ tab: 'dups', member: true, name: 'Member: button hidden', trigger: 'Other verified users.' }),
Mp({ tab: 'dups', offline: true, name: 'Offline', trigger: 'The button is disabled with the existing banner.' })
], tableTitle: 'Status to copy', table: [
{ a: '200', b: 'Purge done', c: 'Toast "Duplicates purged" / "9 copies merged. 12 articles updated."' },
{ a: '409', b: 'Library changed', c: '"The library changed, so nothing was purged. Check the new summary and try again."' },
{ a: '207', b: 'Partial', c: '"Some groups couldn\'t be merged. 3 of 4 groups were purged. Try again to finish."' },
{ a: '500', b: 'Purge failed', c: '"Couldn\'t purge the duplicates. Nothing was changed. Try again in a moment."' },
{ a: '500', b: 'Setting not saved', c: '"Couldn\'t save. Try again in a moment."' },
{ a: '400', b: 'Days invalid', c: '"Enter a whole number of days between 1 and 365."' }
] }
])
}
finish(gs) { return gs.map(g => ({ ...g, states: g.states.map(st => ({ notMedia: !st.isMedia, ...st, m: { ...st.m } })) })) }
renderVals() {
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
const on = this.state.mode === k
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
})
return { rootRef: this.rootRef, modes, groups: this.groups() }
}
}
</script>
</body>
</html>
+121
View File
@@ -0,0 +1,121 @@
<!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 B = (label, o = {}) => {
const k = o.k || 'outline'
const bg = k === 'primary' ? 'var(--ui-primary)' : k === 'danger' ? 'var(--ui-error)' : 'var(--ui-bg)'
const fg = k === 'primary' || k === 'danger' ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)'
const bd = k === 'primary' || k === 'danger' ? bg : 'var(--ui-border-accented)'
return S('height:' + (o.h || bh) + '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:13px;white-space:nowrap;opacity:' + (o.dis ? 0.4 : 1) + ';' + (o.full ? 'flex:1;' : ''), o.icon && I(o.icon, 16), label)
}
const CHECK = 'conic-gradient(var(--ui-bg-accented) 25%, var(--ui-bg) 0 50%, var(--ui-bg-accented) 0 75%, var(--ui-bg) 0) 0 0/16px 16px'
const TONES = ['var(--rc-sand)', 'var(--ui-bg-accented)', 'color-mix(in srgb, var(--ui-border-accented) 35%, var(--ui-bg))', 'var(--ui-bg-elevated)']
const mark = h('svg', { width: 20, height: 20, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: '#111' }), h('path', { fill: '#fff', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
const Th = (im, t = '') => D('position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);background:' + (im.alpha ? CHECK : 'repeating-linear-gradient(135deg, rgb(128 128 128 / 0.12) 0 1px, transparent 1px 12px), ' + TONES[im.tone % 4]) + ';' + t, im.svg ? mark : I('image', 16, 'opacity:0.55'))
const IMX = { 12: { tone: 0 }, 7: { tone: 2 }, 2: { tone: 1 }, 4: { tone: 3, alpha: true, svg: true }, 11: { tone: 1 } }
const Alert = (tone, icon, text, title) => 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:2px', title && S('font-weight:600', title), S('color:var(--ui-text-toned)', text)))
const hl = 'color:var(--ui-text-highlighted)'
const mono = "font-family:'IBM Plex Mono',monospace"
// ---------- card
const dis = !!(s.ro || s.offline || s.loading)
const on = !!s.on
const Sw = () => D('display:flex;align-items:center;justify-content:center;width:' + (m ? 44 : 36) + 'px;height:' + (m ? 44 : 24) + 'px;flex-shrink:0;opacity:' + (dis ? 0.4 : 1), D('position:relative;width:36px;height:20px;border:1px solid ' + (on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:' + (on ? 'var(--ui-primary)' : 'var(--ui-bg-accented)') + ';' + (s.focusSw ? 'outline:2px solid var(--ui-primary);outline-offset:2px;' : ''), D('position:absolute;top:1px;width:16px;height:16px;left:' + (on ? 17 : 1) + 'px;background:' + (on ? 'var(--ui-bg)' : 'var(--ui-bg)') + ';border:1px solid ' + (on ? 'transparent' : 'var(--ui-border-accented)') + ';box-sizing:border-box')))
const Row = (label, help, ctl, stack) => D('display:flex;' + (m || stack ? 'flex-direction:column;align-items:stretch;' : 'align-items:flex-start;justify-content:space-between;') + 'gap:' + (m ? 12 : 16) + 'px', D('display:flex;flex-direction:column;gap:2px;min-width:0', S('font-weight:500;' + hl, label), help && S('font-size:12px;color:var(--ui-text-muted)', help)), ctl)
const hasErr = s.invalid || s.saveErr
const errTxt = s.invalid ? 'Enter a whole number of days between 1 and 365.' : 'Couldn\'t save. Try again in a moment.'
const Days = () => D('display:flex;flex-direction:column;gap:6px;' + (m ? '' : 'align-items:flex-end;'),
D('display:flex;align-items:center;width:' + (m ? '100%' : '140px') + ';min-height:' + (m ? 44 : 40) + 'px;border:1px solid ' + (hasErr ? 'var(--ui-error)' : s.focusDays ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';' + (s.focusDays && !hasErr ? 'box-shadow:0 0 0 1px var(--ui-primary);' : '') + 'background:var(--ui-bg);font-size:' + (m ? 16 : 13) + 'px;opacity:' + (dis ? 0.4 : 1), S('flex:1;min-width:0;padding:0 12px;' + mono + ';' + hl, s.days ?? '30'), S('padding:0 12px;color:var(--ui-text-muted);' + mono + ';font-size:13px', 'days')),
hasErr && S('display:flex;gap:6px;align-items:flex-start;color:var(--ui-error);font-size:12px;' + (m ? '' : 'max-width:300px;text-align:right;'), I('circle-alert', 14, 'margin-top:2px'), errTxt))
const stat = s.saving ? S('display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text-muted)', I('loader-circle', 14), 'Saving…') : s.saved ? S('display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-success)', I('check', 14), 'Saved') : null
const last = s.last === undefined ? null : s.last
const lastLine = last ? 'Last purge: ' + last.d + ' · ' + last.c + ' copies merged · ' + last.a + ' articles updated' : 'Never run'
const Cleanup = () => {
const kids = [
D('display:flex;align-items:flex-start;gap:12px', D('flex:1;display:flex;flex-direction:column;gap:2px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;' + hl, 'Media cleanup'), S('color:var(--ui-text-muted);text-wrap:pretty', 'Merge identical images so your library stays tidy. Articles that use a copy are switched to the most used one.')), D('height:24px;display:flex;align-items:center;flex-shrink:0', stat))
]
if (s.ro) kids.push(Alert('info', 'info', 'Only owners and admins can change this.'))
kids.push(Row('Automatic purge', 'Runs in the background. Off by default.', Sw()))
if (on) kids.push(Row('Every', 'Between 1 and 365 days.', Days()))
kids.push(D('display:flex;flex-direction:column;gap:4px;color:var(--ui-text-toned)', S('', lastLine), on && S('', 'Next purge: ' + (s.next || '14 Jul 2026'))))
if (!s.ro) {
kids.push(D('height:1px;background:var(--ui-border)'))
kids.push(Row('Purge now', 'Merge every group of duplicates that has an image in use, right away.', D('display:flex;' + (m ? '' : 'flex-shrink:0'), B('Purge duplicates', { icon: 'merge', dis: !!(s.offline || s.loading), full: m, ring: s.focusBtn }))))
}
return D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:16px', ...kids)
}
const SkelBar = (w, hh = 13) => S('display:block;width:' + w + ';height:' + hh + 'px;background:var(--ui-bg-accented)')
const CleanupSkel = () => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 20) + 'px;display:flex;flex-direction:column;gap:16px', SkelBar('140px', 16), SkelBar('90%'), SkelBar('55%'), D('display:flex;justify-content:space-between;align-items:center', D('display:flex;flex-direction:column;gap:6px', SkelBar('120px'), SkelBar('180px', 11)), SkelBar('36px', 20)), SkelBar('60%'), D('height:1px;background:var(--ui-border)'), D('display:flex;justify-content:space-between;align-items:center', D('display:flex;flex-direction:column;gap:6px', SkelBar('100px'), SkelBar('220px', 11)), SkelBar('140px', bh)))
const pad = m ? 16 : 24
const page = D('flex:1;min-height:0;overflow:hidden;padding:' + pad + 'px;position:relative', D('max-width:720px;display:flex;flex-direction:column;gap:20px',
D('display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;' + hl, 'Settings'), S('color:var(--ui-text-muted)', 'Workspace information.')),
D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:' + (m ? 16 : 20) + 'px;display:flex;align-items:center;justify-content:space-between;gap:16px', S('font:600 15px/24px \'IBM Plex Sans\',sans-serif;' + hl, 'Version'), S('font:400 14px/22px \'IBM Plex Mono\',monospace;color:var(--ui-text-toned)', 'v2.2.0-beta')),
s.loading ? CleanupSkel() : Cleanup()))
// ---------- dialog
const G = [{ im: 12, n: 'floorboards.jpg', u: 4, c: 2 }, { im: 7, n: 'IMG_4471.jpg', u: 2, c: 1 }, { im: 2, n: 'kitchen-after.webp', u: 3, c: 3 }, { im: 4, n: 'logo-renew.svg', u: 5, c: 3 }, { im: 11, n: 'lime-plaster.jpg', u: 2, c: 1 }]
const pl = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
const PV = { groups: 4, copies: 9, arts: 12, list: G.slice(0, 4), more: 0, ...(s.dialog && s.dialog.pv) }
const summary = pv => pl(pv.groups, 'group') + ' · ' + pv.copies + (pv.copies === 1 ? ' copy' : ' copies') + ' will be deleted · ' + pl(pv.arts, 'article') + ' will be updated'
const GList = pv => D('border:1px solid var(--ui-border)', ...pv.list.map((g, i) => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), Th(IMX[g.im], 'width:40px;height:30px;flex-shrink:0;border:1px solid var(--ui-border)'), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500;' + hl + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', g.n), S('font-size:12px;color:var(--ui-text-muted)', 'Kept · used in ' + pl(g.u, 'article'))), S('font-size:12px;color:var(--ui-text-toned);white-space:nowrap;text-align:right', pl(g.c, 'copy').replace('copys', 'copies') + ' merged'))), pv.more > 0 && D('padding:8px 12px;border-top:1px solid var(--ui-border-muted);color:var(--ui-text-muted)', 'and ' + pv.more + ' more groups'))
const Link = () => S('display:inline-flex;align-items:center;gap:4px;align-self:flex-start;font-weight:500;' + hl + ';text-decoration:underline;text-underline-offset:3px;min-height:' + (m ? 44 : 20) + 'px', 'Review in Duplicates', I('arrow-right', 14))
const dg = s.dialog
const Shell = (o, body) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'flex-start') + ';justify-content:center;padding:' + (m ? 16 : 140) + 'px 16px 16px',
D('width:' + (m ? '100%' : '448px') + ';max-height:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal);display:flex;flex-direction:column',
o.center ? D('padding:28px 24px 0;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(--rc-info-soft);color:var(--ui-info)', I(o.icon, 20)), S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), S('color:var(--ui-text-muted);text-wrap:pretty;max-width:340px', o.desc))
: D('padding:20px 24px 0;display:flex;flex-direction:column;gap:4px', S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + hl, o.title), o.desc && S('color:var(--ui-text-muted);text-wrap:pretty', o.desc)),
body && D('padding:20px 24px 0;display:flex;flex-direction:column;gap:16px;min-height:0;overflow:hidden', ...body),
D('padding:20px 24px;display:flex;flex-direction:' + (m ? 'column-reverse' : 'row') + ';justify-content:flex-end;gap:8px;flex-shrink:0', o.cancel && B(o.cancel, { dis: o.loading, full: false }), o.confirm && S('height:' + bh + 'px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-error);color:var(--ui-bg);font-weight:500;opacity:' + (o.cdis ? 0.4 : 1), o.loading ? I('loader-circle') : o.cicon && I(o.cicon), o.confirm))))
const DESC = 'Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can\'t be undone.'
const DSkel = () => [SkelBar('80%', 16), D('border:1px solid var(--ui-border)', ...[0, 1, 2].map(i => D('display:flex;align-items:center;gap:12px;padding:8px 12px;border-top:' + (i ? '1px solid var(--ui-border-muted)' : '0'), SkelBar('40px', 30), D('flex:1;display:flex;flex-direction:column;gap:6px', SkelBar('45%'), SkelBar('60%', 11)))))]
const Dialog = () => {
const k = dg.k, pv = PV
const gp = 'Purge ' + pl(pv.groups, 'group')
const note = (tone, icon, t) => Alert(tone, icon, t)
if (k === 'loading') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purge duplicates', cdis: true }, DSkel())
if (k === 'nothing') return Shell({ center: true, icon: 'copy', title: 'Nothing to purge', desc: 'No duplicate is used in an article, so there is nothing to merge.', cancel: 'Close' }, null)
const body = [S('font-weight:500;' + hl, summary(pv)), GList(pv), Link()]
if (k === 'confirm') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, body)
if (k === 'progress') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: 'Purging…', loading: true }, body)
if (k === 'error') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('error', 'circle-x', 'Couldn\'t purge the duplicates. Nothing was changed. Try again in a moment.'), ...body])
if (k === 'partial') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Close', confirm: 'Retry', cicon: 'rotate-cw' }, [note('warning', 'triangle-alert', 'Some groups couldn\'t be merged. 3 of 4 groups were purged. Try again to finish.'), ...body])
if (k === 'conflict') return Shell({ title: 'Purge duplicates?', desc: DESC, cancel: 'Cancel', confirm: gp }, [note('warning', 'triangle-alert', 'The library changed, so nothing was purged. Check the new summary and try again.'), ...body])
}
const Toast = () => D('position:absolute;z-index:60;' + (m ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:356px') + ';display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', S('margin-top:2px;display:flex;color:var(--ui-success)', I('circle-check')), D('flex:1;display:flex;flex-direction:column;gap:2px', S('font-weight:600;' + hl, 'Duplicates purged'), S('color:var(--ui-text-muted)', '9 copies merged. 12 articles updated.')), I('x'))
// ---------- shell
const nav = [['home', 'house', 'Home'], ['articles', 'file-text', 'Articles'], ['media', 'images', 'Media'], ['users', 'users', 'Users'], ['integration', 'plug', 'Integration'], ['settings', 'settings', 'Settings']]
const mk = (z) => h('svg', { width: z, height: z, viewBox: '0 0 16 16', shapeRendering: 'crispEdges' }, h('rect', { width: 16, height: 16, fill: '#111' }), h('path', { fill: '#fff', fillRule: 'evenodd', d: 'M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z' }))
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', mk(26), S('font-weight:600;font-size:14px;' + hl, 'RenewCMS')),
...nav.map(([k, ic, t]) => D('display:flex;align-items:center;gap:10px;height:32px;padding:0 10px;background:' + (k === 'settings' ? 'var(--rc-sand)' : 'transparent') + ';color:' + (k === 'settings' ? '#111111' : 'var(--ui-text-toned)') + ';font-weight:' + (k === 'settings' ? 500 : 400), I(ic), t)),
D('flex:1'), D('display:flex;align-items:center;gap:10px;height:40px;padding:0 6px;' + hl, S('width:26px;height:26px;flex-shrink:0;background:#d4c5b0;color:#111;display:flex;align-items:center;justify-content:center;font:600 11px \'IBM Plex Sans\',sans-serif', 'MA'), S('flex:1;font-weight:500', 'Marta Alves'), I('chevrons-up-down', 14)))
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;' + hl, 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', 'Settings'))
: 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('font-weight:500;' + hl, 'Settings'))
const offline = s.offline && D('flex-shrink:0;display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted)', I('wifi-off'), S('flex:1', 'You\'re offline. Changing automatic purge and purging duplicates need a connection.'))
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, offline, page), dg && Dialog(), s.toast && Toast())
return { view: root }
}
}
</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.']
]
+44
View File
@@ -281,3 +281,47 @@ Added by the Media screens, none of which exist yet:
- `POST /v1/media/merge`: `{ keep_id, merge_ids }`. Rewrites article bodies from the merged copies' URLs to the kept image's URL, then deletes the copies. 200 with `{ articles_updated, merged }`, 404, 409 when a listed copy is gone or the group changed.
- `GET /v1/media/{id}/usage`: `[{ id, title, published }]`, read from article bodies.
- Images no longer belong to an article, and deleting an article no longer deletes images. `POST /v1/article/{id}/image` and `DELETE /v1/article/{id}/image/{imageId}` are replaced by the endpoints above.
## Added with Media cleanup
### Media cleanup
| State | Surface | Behaviour |
|---|---|---|
| Default | Settings card | Off, "Never run", no "Every" row |
| On | Settings card | "Every" 30 days, last and next run |
| Saving / Saved | Settings card header | "Saving…" then "Saved" for 2 s, polite live region |
| Save failed | Under the field | "Couldn't save. Try again in a moment." Control reverts |
| Validation | Under the field | "Enter a whole number of days between 1 and 365." Nothing sent |
| Read-only | Settings card | Controls disabled, button hidden, info alert |
| Loading | Settings card | Skeleton after 150 ms |
| Offline | Settings and Media | Banner as on Media. Controls and buttons disabled |
| Preview loading | Dialog | Skeleton rows, confirm disabled |
| Confirm | Dialog | Summary, up to 5 groups, link to Duplicates |
| In progress | Dialog | "Purging…", loader-circle, not dismissible |
| Nothing to purge | Dialog | Info state, "Close" only |
| Error | Dialog | Notice, stays open, confirm retries |
| Partial | Dialog | Warning, summary refreshes, "Close" and "Retry" |
| Conflict (409) | Dialog | Warning, summary refreshes |
| Success | Toast, 5 s | "Duplicates purged" / "9 copies merged. 12 articles updated." "Last purge" updates in place |
| Unattended run | Settings | No toast. Updated on next visit |
401 and 403 are global and have no local copy.
## Added with Activity
### Activity
| State | Surface | Behaviour |
|---|---|---|
| Default | `/activity` | Strip, filters, day-grouped table, pagination |
| Loading | `/activity` | Skeleton strip and rows after 150 ms |
| Zero-value strip | `/activity` | Failed and Security show a calm 0 |
| Filtered | `/activity` | Chips and "Clear all"; a strip card applies its filter |
| Empty, no activity | `/activity` | "No activity yet"; Export disabled |
| Empty, no match | `/activity` | "No activity matches these filters" with "Clear filters" |
| Error | `/activity` | "Couldn't load activity" with "Try again"; the strip may still show |
| Forbidden (403) | `/activity` | "You can't view this activity." with "Back to home" |
| Details | Slide-over / bottom sheet | Sentence, time, actor, source, target, reason, changes, previous and next |
| My activity | `/activity` | No strip, Person filter or Export; own events only |
| Export | Header button, toasts | "Preparing file…", then "Export started. Your file will download shortly." or "Couldn't export activity. Try again." |
| Home card | `/home` | Owner or admin, regular user, loading, error, empty |
| Retention | `/settings` | Default, shortening (confirm), Forever, save error |
401 is global. The 403 on `/activity` has local copy because the page itself is refused; the 403 on the summary or export of a non-admin is never shown, since the controls are hidden.
+2
View File
@@ -55,6 +55,8 @@ If your editor reports `pattern all:dist: no matching files found`, either run `
```sh
task --list # everything available
task test # Go tests; extra args pass through: task test -- -run TestCreateArticle -v
task test:mysql # MySQL tests against real servers in Docker (mysql:8.4, then mariadb:11)
task test:postgres # PostgreSQL tests against real servers in Docker (postgres:16, then postgres:17)
task cover # coverage of the use cases → tmp/coverage.out
task mutate # gremlins mutation testing → tmp/gremlins.json
task test:web # SPA component tests (Vitest)
+242
View File
@@ -0,0 +1,242 @@
# Docker
RenewCMS runs as a container. The image is published to the registry at `git.floriansylvain.fr/fsylvain/renewcms`, so site operators pull it and never build from source; it can also be built from this repository. The image holds only the single binary (SPA embedded) on Alpine, runs as a non-root user (uid 10001) and keeps all state on one volume mounted at `/data`. It reuses the configuration contract described in [configuration.md](configuration.md); there are no Docker-specific variables.
Requirements: Docker with Compose v2. [task](https://taskfile.dev) is optional for operators and needed to build from source or to publish. Building needs no local Go or Bun. The image build bundles the SPA with `vite build` only; the `vue-tsc` type-check is not run inside the image (it does not work under the Bun-only build image), so keep running `bun run type-check` locally.
## Quick start
1. Copy the example env file and edit it:
```sh
cp docker.env.example docker.env
```
Set `PUBLIC_URL`, the `SMTP_*` values and a generated secret (`openssl rand -hex 32`) for `JWT_SECRET`. `docker.env` is git-ignored and excluded from the build context, so it never ends up in an image. Never commit it.
2. Pull and start the published image:
```sh
docker compose pull
docker compose up -d
```
`docker-compose.yml` uses `image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}`. The default tag is `beta` because `latest` does not exist until the first stable release (the default changes to `latest` then). Set `RENEWCMS_IMAGE` in your shell or in a `.env` file next to the compose file to pin another tag, for example `RENEWCMS_IMAGE=git.floriansylvain.fr/fsylvain/renewcms:v2.9.0-beta`.
To build from the local sources instead, see [Building from source](#building-from-source).
3. Check it: `curl http://localhost:8080/healthz` returns `ok`, and `http://localhost:8080/` serves the SPA.
Other tasks: `task docker:pull`, `task docker:upgrade` (pull, then `docker compose up -d`), `task docker:up` (local build, see below), `task docker:build` (image only, tagged with the `git describe` version and `latest`), `task docker:run` (one-off foreground run on the volume `renewcms-data`, without Compose), `task docker:logs` and `task docker:down`.
If a required variable is missing the container exits non-zero and the log names it, for example `Environment variable JWT_SECRET is not set`.
### Local trial over plain HTTP
`ENVIRONMENT` defaults to `production`, which makes the auth cookie `Secure`: browsers do not store it over `http://`, so login appears to fail. For a local trial on `http://localhost:8080`, set `ENVIRONMENT=development` and `PUBLIC_URL=http://localhost:8080` in `docker.env`. Use `production` only behind HTTPS.
## Building from source
`docker-compose.build.yml` adds the `build:` section and tags the result `renewcms:${VERSION:-dev}`. Use it as an override:
```sh
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
`task docker:up` does the same and passes the `git describe` version as `VERSION`. To check what Compose resolves, run `docker compose config`: without the override it shows the registry image and no `build` section, with it the `build` section is present.
## Image tags
| Release | Tags pushed |
|---|---|
| `vX.Y.Z` (stable) | `vX.Y.Z`, `X.Y.Z`, `X.Y`, `latest` |
| `vX.Y.Z-<pre>` (for example `v2.9.0-beta`, `v2.9.0-rc.1`) | `vX.Y.Z-<pre>`, `X.Y.Z-<pre>`, and the moving tag `<pre-name>` (`beta`, `rc`) |
A pre-release never moves `latest`. Images are published for `linux/amd64` and `linux/arm64`. `renewcms --version` in the image prints the exact release tag. Images are not signed; the Ed25519 key signs the release binaries only.
## Environment
Compose reads `docker.env` through `env_file` and sets `DB_FILE=/data/renewcms.db` and `UPLOAD_DIR=/data/uploads` itself. Everything in [configuration.md](configuration.md) can go in `docker.env`. To publish another host port, change the left side of `ports` in `docker-compose.yml`; keep `PORT` at 8080 unless you also change the healthcheck.
The container receives SIGTERM on stop, and RenewCMS shuts down gracefully within 10 seconds. Compose uses `stop_grace_period: 15s`. The healthcheck calls `/healthz` inside the container.
## 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 (unless you use MySQL), uploads, and update records.
- `docker compose down` removes the container and keeps the volume. The data survives and `docker compose up -d` brings it back.
- **`docker compose down -v` deletes the volume and all data.** The next start is an empty instance.
### Backup
Replace `VOLUME` with the real volume name. The image has no `sqlite3`, so stop the service for a consistent copy:
```sh
docker compose stop
docker run --rm -v VOLUME:/data -v "$PWD":/backup alpine tar czf /backup/renewcms-backup.tar.gz -C /data .
docker compose start
```
### Restore into a fresh volume
```sh
docker compose down -v
docker volume create VOLUME
docker run --rm -v VOLUME:/data -v "$PWD":/backup alpine sh -c "tar xzf /backup/renewcms-backup.tar.gz -C /data && chown -R 10001:10001 /data"
docker compose up -d
```
## MySQL or MariaDB
SQLite on the `/data` volume is the default. [`docker-compose.mysql.yml`](../docker-compose.mysql.yml) runs RenewCMS next to a MySQL 8.4 container instead:
```sh
export MYSQL_ROOT_PASSWORD=<generated>
export DB_PASSWORD=<generated>
docker compose -f docker-compose.mysql.yml up -d
```
It starts the `mysql` service with a named volume `mysql-data` and a healthcheck, and starts `renewcms` only once the database is healthy (`depends_on` with `service_healthy`). RenewCMS gets `DB_DRIVER=mysql`, `DB_HOST=mysql`, `DB_NAME`, `DB_USER` and `DB_PASSWORD` from the compose file and the rest from `docker.env`. `DATA_DIR=/data` keeps update state and the `.sql` pre-update backups on the `renewcms-data` volume, which still holds the uploads. Replace the placeholders; never commit real passwords.
To use MariaDB, change the `mysql` service image to `mariadb:11`, its `MYSQL_*` variables to `MARIADB_*` and its healthcheck to `healthcheck.sh --connect --innodb_initialized`. To use a server outside Compose, drop the `mysql` service and point `DB_HOST` at it. For a server on another machine set `DB_TLS=true`.
Back the database up with `docker compose -f docker-compose.mysql.yml exec mysql mysqldump ...` (a `tar` copy of the volume is not consistent while it runs), and the `renewcms-data` volume as described above. Moving an existing SQLite volume is a manual procedure, see [deployment.md](deployment.md#moving-an-existing-sqlite-site-to-mysql).
## PostgreSQL
[`docker-compose.postgres.yml`](../docker-compose.postgres.yml) runs RenewCMS next to a PostgreSQL 17 container:
```sh
export DB_PASSWORD=<generated>
docker compose -f docker-compose.postgres.yml up -d
```
It starts the `postgres` service with a named volume `postgres-data` and a `pg_isready` healthcheck, and starts `renewcms` only once the database is healthy (`depends_on` with `service_healthy`). RenewCMS gets `DB_DRIVER=postgres`, `DB_HOST=postgres`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` and `DB_SSLMODE=disable` (the traffic stays on the Compose network) from the compose file and the rest from `docker.env`. `DATA_DIR=/data` keeps update state and the `.sql` pre-update backups on the `renewcms-data` volume. For a server on another machine drop the `postgres` service, point `DB_HOST` at it and set `DB_SSLMODE=require`. Back the database up with `docker compose -f docker-compose.postgres.yml exec postgres pg_dump -U renewcms renewcms`. Replace the placeholders; never commit real passwords.
## S3-compatible storage (SeaweedFS)
[`docker-compose.s3.yml`](../docker-compose.s3.yml) runs RenewCMS with `STORAGE_DRIVER=s3` next to a [SeaweedFS](https://github.com/seaweedfs/seaweedfs) server (Apache 2.0, image `chrislusf/seaweedfs`, override with `SEAWEEDFS_IMAGE`) with its S3 gateway enabled, and a one-shot `seaweedfs-init` step that creates the bucket with `weed shell`:
```sh
export S3_SECRET_ACCESS_KEY=<generated>
docker compose -f docker-compose.s3.yml up -d
```
RenewCMS gets `S3_ENDPOINT=http://seaweedfs:8333`, `S3_PATH_STYLE=true`, the bucket name and the access key (`S3_ACCESS_KEY_ID`, default `renewcms`, and `S3_SECRET_ACCESS_KEY`) from the compose file, and the rest from `docker.env`. SeaweedFS reads the same key pair as its admin identity. The `renewcms-data` volume then holds the SQLite database and update state but no uploads; the images live in the `seaweedfs-data` volume. The bucket is not published and the app serves the images. SeaweedFS is used because MinIO stopped publishing community images. To use AWS S3 or another provider, drop the `seaweedfs` services and set the `S3_*` variables in `docker.env` (see [configuration.md](configuration.md#image-storage)). The volume backup above covers the database only, so back the bucket up separately. To move existing uploads see [configuration.md](configuration.md#moving-existing-uploads-to-a-bucket).
`task test:s3` starts a throwaway SeaweedFS and runs the storage integration test against it; it skips with a message when Docker is not available.
## Reverse proxy and TLS
With `ENVIRONMENT=production` the site must be served over HTTPS, so put a reverse proxy in front and set `PUBLIC_URL` to the public `https://` address. Bind the published port to the host only when the proxy runs on the same host, by changing `ports` to `"127.0.0.1:8080:8080"`.
Caddy on the host:
```caddyfile
cms.example.com {
reverse_proxy 127.0.0.1:8080 {
header_up X-Real-IP {remote_host}
}
}
```
nginx on the host:
```nginx
server {
listen 443 ssl;
server_name cms.example.com;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Set `TRUSTED_PROXIES` to the proxy address as the container sees it, otherwise the login rate limiter ignores `X-Real-IP` and counts every client as the proxy. With a proxy on the host and the default bridge network, that is the bridge gateway (check with `docker network inspect`, often `172.17.0.1` or the Compose network gateway). With a proxy container on the same Compose network, it is that container's IP. The value is a semicolon-separated list of exact IPs, no CIDR. See [deployment.md](deployment.md#4-put-https-in-front).
## SMTP
RenewCMS sends registration and password-reset mail through the `SMTP_*` variables. Use your provider's host and port in `docker.env`.
To use a mail server running on the Docker host, set `SMTP_HOST=host.docker.internal`. Docker Desktop resolves that name. On Linux add the mapping to the service in `docker-compose.yml`:
```yaml
extra_hosts:
- "host.docker.internal:host-gateway"
```
The host MTA must listen on an address reachable from the container, not only on `127.0.0.1`.
If SMTP is unreachable, the server still starts. Only the actions that send mail (registration, password reset, invitations) fail, and the error appears in `docker compose logs renewcms`.
## Upgrading
1. Back up the volume (see above).
2. Pull the new image and recreate the container on the same volume:
```sh
docker compose pull
docker compose up -d
```
`task docker:upgrade` runs both. The schema is migrated at startup, and there is no down-migration.
3. Check `docker compose logs renewcms` for the new version line, or `docker run --rm git.floriansylvain.fr/fsylvain/renewcms:beta --version`. The owner's Settings page also shows the running version.
To roll back, set `RENEWCMS_IMAGE` to the previous tag, run `docker compose up -d`, and restore the backup if the schema changed.
If you built from source, upgrade with `git pull` and `task docker:up` instead.
## Maintainer: publishing
Publishing is a manual, maintainer-run task, like `task release`. There is no CI pipeline.
1. Tag the release and keep the work tree clean:
```sh
git tag v2.9.0-beta
```
2. Log in once to the registry. The task never reads or stores credentials:
```sh
docker login git.floriansylvain.fr
```
3. Make sure a buildx builder can build both platforms. Check with `docker buildx ls`. With the containerd image store (the Docker Desktop default) the default builder already works. Otherwise create one, and on a Linux host install the QEMU emulators:
```sh
docker buildx create --use
docker run --privileged --rm tonistiigi/binfmt --install arm64
```
4. Publish:
```sh
task docker:publish
```
`REGISTRY_IMAGE` defaults to `git.floriansylvain.fr/fsylvain/renewcms` and `VERSION` to `git describe --tags`. The task refuses to run, before building anything, when `REGISTRY_IMAGE` is empty, when `VERSION` is not an exact tag `vX.Y.Z` or `vX.Y.Z-<pre>` on HEAD, when the work tree is dirty, or when the builder cannot build `linux/amd64` and `linux/arm64`. It prints the tags it pushes.
### Dry run
```sh
task docker:publish -- --dry-run
task docker:publish VERSION=v2.9.0-rc.1 -- --dry-run
```
The dry run builds for the host platform only, loads the image into the local Docker, pushes nothing and needs no login. It prints the tags a real run would push. A `VERSION` override is accepted only with `--dry-run`, and a dirty tree is allowed there. Remove the loaded images afterwards with `docker rmi`.
### After pushing
- `docker buildx imagetools inspect git.floriansylvain.fr/fsylvain/renewcms:<tag>` lists `linux/amd64` and `linux/arm64`.
- The moving tag (`beta`) points at the new digest, and `latest` did not move for a pre-release.
- On a clean machine, `docker compose pull && docker compose up -d` starts the new image and Settings shows the new version.
## Limitations
Browser updates are unsupported in Docker (they need Linux with systemd, and a binary replaced inside a container is lost when it is recreated). The image and `docker-compose.yml` set `RENEWCMS_CONTAINER=true`, so the owner's Settings page shows when a newer version exists and tells them to pull a new image: `docker compose pull && docker compose up -d`. There is no Update or Restart button. Upgrade by pulling the image as above, or by rebuilding it if you build from source.
+3
View File
@@ -31,3 +31,6 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
| `v2.6.2-beta` | v2.6.2-beta - English and French interface |
| `v2.7.0-beta` | v2.7.0-beta - Markdown and block article bodies |
| `v2.8.0-beta` | v2.8.0-beta - API keys and API reference |
| `v2.8.1-beta` | v2.8.1-beta - Docker deployment |
| `v2.8.2-beta` | v2.8.2-beta - Security headers and clearer log in errors |
| `v2.8.3-beta` | v2.8.3-beta - Security policy fixes for the web app |
+36
View File
@@ -0,0 +1,36 @@
This release adds an official Docker deployment, so RenewCMS can run in a container instead of as a binary under systemd, and teaches Settings to guide container installs through updates.
## New
**Docker deployment**
- A container image holds the single RenewCMS binary on Alpine, runs as a non-root user and keeps the database and uploads on one volume. A Compose file starts it with an env file and a health check.
- Images are published for amd64 and arm64 to the project registry at `git.floriansylvain.fr/fsylvain/renewcms`. Prereleases carry the version tag and a moving `beta` tag, and `latest` is only used for stable releases.
- Upgrading is `docker compose pull && docker compose up -d`. Your data stays on the volume.
- A new Docker guide covers the quick start, backups of the data volume, the reverse proxy and TLS, reaching an SMTP server from the container, and building the image from source.
**Update guidance in containers**
- In a container, Settings no longer shows the browser update flow, which cannot work there. It shows the new version, the command to run on the server with a Copy button, and a link to the Docker guide.
- Download and restart are not offered in a container, and the API refuses them.
## API
- `GET /v1/system/update` has a new `container` boolean. It is `true` when the server runs in a container, and `can_apply` is then always `false`.
- `POST /v1/system/update/download` and `POST /v1/system/update/restart` answer `409` in a container, as they already do when browser updates are not set up.
## Under the hood
- Add a multi-stage Dockerfile, a Compose file with a separate build override, and tasks to build, run, pull, upgrade and publish the image. Publishing is a maintainer task that refuses to run unless the version is an exact tag on a clean tree.
- Add the container update guidance and the image publishing specifications and QA procedures to `features/`.
## Upgrading
- Replace the binary. No schema change and no required environment variable.
- There is one new optional variable, `RENEWCMS_CONTAINER`, which defaults to `false`. The Docker image and the Compose file set it to `true`. Leave it unset on a binary install.
- Docker users start from the Docker guide. To move an existing binary install to Docker, copy the database and uploads into the volume and keep the same environment variables.
## Known limitations
- Browser updates are not available in a container. Update by pulling a new image.
- The new container wording in Settings has no design frame yet.
+18
View File
@@ -0,0 +1,18 @@
This release hardens the server with browser security headers on every response and makes the log in form clearer when the username is wrong.
## Improvements and fixes
- RenewCMS now sends security headers on every response, including the web app and uploaded images: a Content Security Policy that forbids framing, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy` and `Cross-Origin-Opener-Policy`. With `ENVIRONMENT=production` it also sends `Strict-Transport-Security`.
- The log in form now tells you when you typed an email address instead of your username, and when the username is longer than 20 characters, instead of only asking for at least 3 characters. The messages are available in English and French.
## Under the hood
- Add a security headers middleware, mounted on the whole router, with tests.
- Document the headers, the Google Fonts and `https:` image allowances in the policy, and the advice not to duplicate them in the reverse proxy in the deployment guide.
## Upgrading
- Replace the binary, or pull the new image. No schema change and no new environment variable.
- If your reverse proxy already adds the same security headers, remove them there, or browsers will receive them twice.
- The policy allows scripts from the same origin only. A custom script injected by your proxy will be blocked.
- Serve the site over HTTPS only when `ENVIRONMENT=production`, because the server then asks browsers to remember HTTPS for a year.
+18
View File
@@ -0,0 +1,18 @@
This release fixes the security headers added in v2.8.2-beta, which blocked parts of the web app in the browser.
## Improvements and fixes
- The web app's icons now display again. They are bundled into the app instead of being fetched from an external icon service, which the security policy blocked.
- The light and dark mode script in the page is now allowed by the security policy, so the color mode no longer fails to apply on load.
- The security policy now allows WebAssembly, which parts of the web app need.
## Under the hood
- Add a Vite plugin that bundles only the Lucide icons the app uses.
- Compute a hash for each inline script in the served page and add it to the `script-src` directive, instead of allowing inline scripts in general.
- Update the security headers tests.
## Upgrading
- Replace the binary, or pull the new image. No schema change and no new environment variable.
- If you are on v2.8.2-beta, upgrade: that version's policy broke icons and color mode.
+34
View File
@@ -0,0 +1,34 @@
This release adds a duplicate purge to the media library: owners and admins can merge identical images in one action, or let RenewCMS do it on a schedule.
## New
**Media duplicate purge**
- Purge duplicates from the Duplicates tab of the media library with the new toolbar button, or from the new Media cleanup card in Settings. A summary shows how many copies would be merged and how many articles would be updated before you confirm.
- For each group of identical images used in at least one article, the most used copy is kept, with ties going to the oldest. Every other copy is merged into it and the articles that used them are rewritten. Groups where no copy is used are left alone.
- If the library changes between the summary and the confirmation, nothing is purged and you are asked to check the new summary.
- Turn on automatic purge in Settings and choose an interval from 1 to 365 days, 30 by default. The first run happens one interval after you turn it on, then once per interval after each run.
- The Media cleanup card shows the last run, with the number of copies merged and articles updated, and the date of the next one.
- The purge is reserved for owners and admins. Editors get a clear message instead of the controls.
- The new screens are available in English and French.
## API
- `GET /v1/media/purge-settings` returns the automatic purge settings: `auto_purge_enabled`, `auto_purge_interval_days`, `last_purge_at`, `last_purge_merged`, `last_purge_articles_updated` and `next_purge_at`, which is `null` while automatic purge is off.
- `PATCH /v1/media/purge-settings` updates `auto_purge_enabled` and `auto_purge_interval_days`. An interval outside 1 to 365 answers `400`.
- `GET /v1/media/purge-preview` returns what a purge would do, with a `fingerprint` of the plan. It changes nothing.
- `POST /v1/media/purge` runs the purge. Send the preview's `fingerprint` to refuse a run if the library changed: a stale fingerprint answers `409`, and so does a purge that is already running.
- All four routes answer `403` with "Only owners and admins can do this." for any other role, and accept a session only, like the rest of the media routes.
## Under the hood
- Add the purge domain, a SQLite repository for the single-row purge settings, and the use cases that build the plan, preview it, run it and decide when a scheduled run is due.
- Add an hourly scheduler, started with the server after a one-minute delay and stopped on shutdown. It reads the settings on every tick, so changes apply without a restart, and a failed run is retried at the next tick.
- Add the `RequireOwnerOrAdmin` middleware.
- Add the media purge, scheduler and Settings card specifications and QA procedures to `features/`, the new screens to `docs/design/`, and Go and Vitest suites for each part.
## Upgrading
- Replace the binary, or pull the new image. The purge settings table is created automatically at startup. No new environment variable is required.
- Automatic purge is off after the upgrade. Nothing is merged until an owner or admin runs a purge or turns it on.
- A purge cannot be undone: merged copies are removed. Check the summary before confirming.
@@ -0,0 +1,87 @@
Feature: Container-aware update guidance
As the owner of a RenewCMS site running in Docker
I want Settings to say "update by pulling a new image" instead of "Not prepared"
So that I never see a browser-update flow that cannot work in a container
Background:
Given browser updates need Linux with systemd and the helper that "renewcms enable-browser-updates" configures
And a binary replaced inside a container is lost when the container is recreated,
so in a container the update is a new image: "docker compose pull && docker compose up -d"
And config.Settings gains Container bool, read from the env var RENEWCMS_CONTAINER (default "false", set to "true" by the Dockerfile and docker-compose.yml)
And RENEWCMS_CONTAINER is added to envDefaults in cmd/server/main.go, to .env.example and to docs/configuration.md; it is not in requiredEnvVars
And only "true" (case-insensitive) enables container mode; any other value means false
And the update status use case receives the bool through its constructor from api/dependecyInjection.go; internal/domain/update stays stdlib only
And the update check, update-status.json and the release feed work unchanged in container mode
And GET /v1/system/update gains a 13th key, container (bool), after started_at; the other 12 keys are unchanged
And in container mode can_apply is false for everyone, even if update-helper.json exists in the data directory
And POST /v1/system/update/download and /restart keep their guards and answer 409 because can_apply is false (existing behaviour, not re-specified)
And errors stay plain text from http.Error
And the user approved the copy below and the 13th key on 2026-10-05; docs/design is read-only for agents, so the design files are updated by the user, and the visual gate for container states stays PENDING until a board frame exists
Scenario Outline: The status carries the container flag
Given RENEWCMS_CONTAINER is "<value>"
When a verified user sends GET /v1/system/update
Then the JSON key container is <flag>
Examples:
| value | flag |
| true | true |
| TRUE | true |
| false | false |
| | false |
| yes | false |
Scenario: Container mode disables apply even with a helper
Given RENEWCMS_CONTAINER is "true" and HelperDetector reports the helper installed
When the owner sends GET /v1/system/update
Then can_apply is false and container is true
And a newer stored result still reports available true, latest and notes
Scenario: The check still works in a container
Given container mode and a feed that reports "v2.9.0-beta" newer than the running "v2.8.0-beta"
When the owner sends POST /v1/system/update/check
Then the status is 200 and available is true and the result is stored in update-status.json
Scenario Outline: Download and restart are refused in a container
Given container mode and a stored newer release
When the owner sends POST <path>
Then the status is 409 and no job starts and the fetcher and the restart request writer are not called
Examples:
| path |
| /v1/system/update/download |
| /v1/system/update/restart |
Scenario: Host installs are unchanged
Given RENEWCMS_CONTAINER is unset
Then container is false and can_apply follows the helper as before
And "Not prepared" still shows its "renewcms enable-browser-updates" command
Scenario: Settings, owner, container, update available (route /settings)
Given GET /v1/system/update resolves available true, container true, can_apply false, latest "v2.9.0-beta"
Then the block uses the "Not prepared" frame's layout: icon info, tone info
And the title reads "Version 2.9.0-beta is available"
And the text reads "You have v2.8.0-beta. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server."
And a server-command block reads "docker compose pull && docker compose up -d" with the Copy button ("Copied" with a check for 2 s, announced)
And a link "How to update" with arrow-up-right points to docs/docker.md
And no Update, Restart or "Check for updates" button is rendered
And the "renewcms enable-browser-updates" command and its note are not rendered
Scenario: Settings, owner, container, up to date
Given container true and available false
Then "You're up to date" shows as in the existing Up to date state, the Restart button is not rendered and the container hint is not rendered
And "Check for updates" is still offered
Scenario: Settings, admin, container
Given the session role is "admin", container true and available true
Then the existing Admin view shows unchanged ("Only the owner can update RenewCMS.") with no command block
Scenario: Settings, non-container, no helper
Given container false and can_apply false
Then the existing "Not prepared" state shows unchanged
Scenario: The shipped Docker setup turns container mode on
Given the Dockerfile and docker-compose.yml
Then both set RENEWCMS_CONTAINER=true
And docs/docker.md and docs/configuration.md describe the variable
+25
View File
@@ -0,0 +1,25 @@
# QA: Container update guidance
## Automated (Go, test/, httptest)
1. Fakes: in-memory update status store, fake FeedClient, fake HelperDetector, fake JobStore and restart writer, validator.InitValidator(). Mount NewSystemRouter on a fresh chi.NewRouter() with jwtauth.Verifier + Authenticator and a test jwtauth.New("HS256", ...).
2. Parse RENEWCMS_CONTAINER through config.NewSettingsFromEnv with t.Setenv over the table in the feature (true, TRUE, false, empty, yes).
3. GET status: container key present and equal to the table; the other 12 keys are unchanged.
4. Container true with a helper present: can_apply false for the owner.
5. POST check in container mode: 200, available true, stored.
6. POST download and restart in container mode: 409, no fake called.
7. Container false: the existing updates_* tests still pass.
8. An admin receives can_apply false and container true.
## SPA (Vitest, mocked /v1)
- Container + available: the block, command, Copy, "How to update", and no buttons. Reuse the "Not prepared" sample data from the board.
- Container + up to date: no Restart.
- Admin: the existing admin view.
- Non-container "Not prepared": unchanged.
- Visual: container states are PENDING until a board frame exists; report as PENDING, never as passing. Existing states must still pass `task visual:check -- settings`.
## Manual (real Docker, ./docker-compose.yml)
1. `docker compose up -d --build`; confirm RENEWCMS_CONTAINER=true with `docker compose exec renewcms env`.
2. Point UPDATE_FEED_URL at a feed with a newer release; log in as owner; /settings shows the container block, no Update or Restart.
3. The Copy button puts the exact command on the clipboard.
4. Run the shown command on a host that has a newer image; Settings shows the new version afterwards.
5. Run the binary on the host without the variable: "Not prepared" shows the old command.
+45
View File
@@ -0,0 +1,45 @@
Feature: Docker deployment
RenewCMS can run in a container built from the repository. The container
reuses the existing configuration contract in cmd/server/main.go
(requiredEnvVars, envDefaults); no new variables are introduced.
Background:
Given the image is built with the default build, without the nofrontend tag
And the container runs the binary directly as PID 1 under a non-root user
And the environment sets DB_FILE and UPLOAD_DIR to paths on a named volume
And the required variables PUBLIC_URL, JWT_SECRET, SMTP_LOGIN, SMTP_SENDER, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT come from an env file at runtime
Scenario: Healthy start
When the container starts with every required variable set
Then it logs "RenewCMS <version> (production) listening on :8080"
And GET /healthz on the published port returns 200 with the body "ok"
Scenario: Frontend and API are both served
When the container is running
Then GET / returns 200 with the SPA index.html
And GET /v1/auth/registration returns 200 with a JSON body
Scenario Outline: A missing required variable stops the container
When the container starts without <variable>
Then the process exits with a non-zero status
And the log contains "Environment variable <variable> is not set"
Examples:
| variable |
| PUBLIC_URL |
| JWT_SECRET |
| SMTP_LOGIN |
| SMTP_SENDER |
| SMTP_PASSWORD |
| SMTP_HOST |
| SMTP_PORT |
Scenario: Data survives container recreation
Given an account was registered while the container was running
When the container is removed and recreated on the same named volume
Then the same account can log in
Scenario: Graceful shutdown on SIGTERM
When the container receives SIGTERM
Then the log contains "shutting down..."
And the container exits with status 0 within 10 seconds
+98
View File
@@ -0,0 +1,98 @@
# QA: Docker deployment
Packaging and docs only. There is no Go test in test/ and no Vitest spec for this slice. All steps are manual command steps run from a clean checkout on a machine with Docker and Compose v2. Record results in the Notes table. Anything not run is reported NOT RUN, never PASS.
## Preconditions
- Clean checkout, no bin/, tmp/, node_modules or internal/infrastructure/assets/dist present.
- docker.env created from docker.env.example. JWT_SECRET is generated (openssl rand -hex 32) and SMTP values are real or placeholders. Nothing is committed.
- For local HTTP trial, ENVIRONMENT=development (otherwise the Secure cookie is not stored over HTTP).
## Static checks (no Docker daemon needed)
1. Dockerfile, .dockerignore and docker-compose.yml contain no comment lines (no line starting with `#`, no trailing ` #`).
2. .dockerignore lists tmp/, bin/, node_modules (also web/node_modules), .git, internal/infrastructure/assets/dist, .env and docker.env.
3. Dockerfile: three stages (bun, go, final). The Go build sets CGO_ENABLED=0, has no `-tags nofrontend`, and uses `-ldflags` containing `-X main.version=` from a build arg. The final stage has USER non-root, exec-form ENTRYPOINT (no `sh -c`), and no ENV, ARG or COPY holding JWT_SECRET or any SMTP_* value.
4. docker-compose.yml: one service, a named volume mounted at the data directory, a `ports` entry, `env_file`, a healthcheck on /healthz, `stop_grace_period` >= 15s.
5. git diff --stat shows no change under api/, internal/, web/src/, cmd/ and test/. Only Dockerfile, .dockerignore, docker-compose.yml, docker.env.example, Taskfile.yml, .gitignore, README.md and docs/ changed. No new env var appears in cmd/server/main.go.
6. docs/docker.md exists and is linked from README.md and docs/deployment.md.
7. `task --list` shows docker:build, docker:run, docker:up, docker:down and docker:logs.
## Build
8. `task docker:build` succeeds from the clean checkout, with no pre-built SPA.
9. `docker run --rm <image> --version` prints the version, which is not `dev`.
10. `docker image ls` size recorded. Flag it if it is above roughly 40 MB.
11. `docker image inspect` shows a non-root User, and `docker run --rm --entrypoint id <image>` shows uid not 0.
12. `docker history --no-trunc <image>` contains none of the values in docker.env.
## Run
13. `task docker:up` starts the service. `docker compose ps` shows it healthy within the healthcheck start period.
14. `curl -i http://localhost:8080/healthz` returns 200 and body `ok`.
15. `curl -i http://localhost:8080/` returns 200 text/html, and the SPA loads in a browser.
16. `curl -i http://localhost:8080/v1/auth/registration` returns 200 with JSON.
17. Logs show the listening line, and no permission error creating DB_FILE or UPLOAD_DIR on the volume.
18. Register an account in the SPA, then upload an image, to confirm both the DB and UPLOAD_DIR are writable by the container user.
## Persistence
19. `docker compose down` (no -v), then `task docker:up`. The account from step 18 can log in and the image is still served.
20. `docker compose down -v` removes the volume, and the next start gives an empty instance. Documented in docs/docker.md.
21. The backup procedure in docs/docker.md is followed once and restores into a fresh volume.
## Failure and shutdown
22. For each required variable in turn, remove it from the env file and run `docker compose up`. The container exits non-zero and the log contains `Environment variable <NAME> is not set`.
23. `docker compose stop` (timed) completes in under 10 s, the log contains `shutting down...`, and the exit code is 0. This proves PID 1 receives SIGTERM.
24. Break the healthcheck (stop the app inside the container, or point it at the wrong port): the service becomes unhealthy.
## SMTP (manual, needs real SMTP)
25. With an external SMTP host, trigger a registration or password-reset email and confirm delivery.
26. With a host MTA via host.docker.internal, repeat. On Linux this needs the extra_hosts mapping documented.
27. Confirm the failure mode is documented when SMTP is unreachable.
## Reverse proxy and TLS (manual)
28. Put Caddy or nginx in front, per docs/docker.md, with ENVIRONMENT=production over HTTPS. Log in and confirm the Secure cookie is accepted.
29. Set TRUSTED_PROXIES to the proxy address as seen by the container and confirm the login rate limiter uses X-Real-IP.
## Upgrade
30. Follow docs/docker.md: back up, rebuild with `task docker:build`, `task docker:up`. Data is intact and `--version` shows the new version.
31. Confirm docs state that browser-update Restart is unsupported in Docker (Settings shows "Not prepared").
## Notes to record
| Item | Value |
| --- | --- |
| Image size | |
| Base images and tags used | |
| Container uid | |
| Build time (cold) | |
| Docker and Compose versions | |
| Steps 25-29 run? | |
Not run in this slice: CI, registry publishing, Kubernetes or Helm, multi-arch. SPA tests and visual checks are out of scope.
## Results
Run on Windows 11, Docker 29.8.1, Compose 5.5.1, with a throwaway docker.env (fake secrets, ENVIRONMENT=development) that was deleted afterwards.
| Steps | Result |
| --- | --- |
| 1-7, 31 | PASS |
| 8 | PASS after a fix: `bun run build` failed in the image because vue-tsc cannot resolve .vue modules under the Bun-only node fallback; the Dockerfile now runs `bun run build-only`. vue-tsc passes on the host and under real node 22 |
| 9, 11, 12 | PASS (version v2.8.0-beta-dirty, uid 10001, entrypoint exec form, no secret names in history) |
| 10 | 54.5 MB, above the 40 MB flag (binary 29.9 MB on alpine 3.22) |
| 13-17 | PASS (healthy, /healthz 200 ok, / 200 text/html, /v1/auth/registration 200 JSON, no permission errors) |
| 18-19 | PASS (register, verify, login, image upload to /data/uploads as uid 10001; all survive `down` and recreate; uploaded image still served) |
| 20 | PASS (`down -v` removed the volume) |
| 21 | PASS (tar backup, `down -v`, restore, login and media list intact) |
| 22 | PASS for all 7 vars (exit 2, `Environment variable X is not set`) |
| 23 | PASS (stop 0.4 s, exit 0, `shutting down...` logged) |
| 24 | PASS (wrong-port healthcheck reports unhealthy) |
| 25 | PARTIAL: mail delivered over STARTTLS to a Mailpit container on the compose network with the CA trusted via SSL_CERT_FILE; no real external host |
| 26-29 | NOT RUN (host MTA, Caddy/nginx TLS, TRUSTED_PROXIES) |
| 30 | NOT RUN as a before/after upgrade |
| `task docker:run` | PASS (healthz 200, graceful stop, separate volume renewcms-data) |
Notes
| Item | Value |
| --- | --- |
| Image size | 54.5 MB |
| Base images and tags used | oven/bun:1-alpine, golang:1.27.1-alpine, alpine:3.22 |
| Container uid | 10001 |
| Build time (cold) | not measured, layers were partly cached; about 18 s warm |
| Docker and Compose versions | 29.8.1, 5.5.1 |
| Steps 25-29 run? | 25 partial, 26-29 no |
+71
View File
@@ -0,0 +1,71 @@
Feature: Publish the RenewCMS image to a registry so an upgrade is "docker compose pull"
As a maintainer
I want one task that builds and pushes a versioned multi-arch image
So that site operators upgrade with "docker compose pull && docker compose up -d" and never build from source
Background:
Given there is no CI pipeline on purpose; publishing is a maintainer-run task, like "task release"
And the registry is the Gitea registry of git.floriansylvain.fr and REGISTRY_IMAGE defaults to "git.floriansylvain.fr/fsylvain/renewcms"
And the Dockerfile and docker-compose.yml from docker-deployment exist and are unchanged except where stated here
And Taskfile.yml gains "docker:publish", with variables REGISTRY_IMAGE and VERSION (default: git describe --tags)
And "docker:publish" refuses to run, before building anything, when REGISTRY_IMAGE is empty, when VERSION is not an exact tag "vX.Y.Z" or "vX.Y.Z-<pre>", or when the work tree is dirty
And the build is "docker buildx build --platform linux/amd64,linux/arm64 --build-arg VERSION=<tag>"; the tag is baked in through the existing version ldflags path, so the image's "renewcms --version" prints the tag
And tags pushed for vX.Y.Z-<pre>: "vX.Y.Z-<pre>" and "X.Y.Z-<pre>" and the moving tag "<pre-name>" (for example "beta"); "latest" is NOT moved
And tags pushed for vX.Y.Z: "vX.Y.Z", "X.Y.Z", "X.Y", and "latest"
And the maintainer authenticates beforehand with "docker login git.floriansylvain.fr"; the task never reads, stores or echoes a password or token, and never passes credentials on a command line
And no secret, .env, signing key or JWT_SECRET is copied into the image (already enforced by .dockerignore; checked again here)
And no image signing or attestation is part of this slice; the Ed25519 key signs release binaries only
And "task docker:publish -- --dry-run" builds for the host platform only with "--load", pushes nothing, needs no login and prints the tags it would push
And docker-compose.yml uses "image: ${RENEWCMS_IMAGE:-git.floriansylvain.fr/fsylvain/renewcms:beta}" (the default tag is "beta" because "latest" does not exist until the first stable release; it is changed to "latest" then) and its "build:" block moves to docker-compose.build.yml, used as "docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build"
And docs/docker.md, docs/deployment.md and README.md document pull-based upgrade, the build override, the tag scheme and the maintainer publish steps; no comments in any file
And the real push is a manual item: it needs a registry account and network
Scenario Outline: Tags for a version
Given VERSION is "<version>"
When "task docker:publish -- --dry-run" runs
Then it prints exactly these tags: <tags>
Examples:
| version | tags |
| v2.9.0 | v2.9.0, 2.9.0, 2.9, latest |
| v2.9.0-beta | v2.9.0-beta, 2.9.0-beta, beta |
| v2.9.0-rc.1 | v2.9.0-rc.1, 2.9.0-rc.1, rc |
Scenario Outline: Refusals before any build
Given <condition>
When "task docker:publish" runs
Then it exits non-zero with a message naming the problem and docker is not invoked
Examples:
| condition |
| REGISTRY_IMAGE is empty |
| VERSION is "dev" |
| VERSION is "2.9.0" (no leading v) |
| the work tree has uncommitted changes |
Scenario: Dry run builds and loads a working image
When "task docker:publish -- --dry-run" runs with VERSION "v2.9.0-beta"
Then an image is loaded locally for the host platform and no registry is contacted
And running it prints "v2.9.0-beta" for --version
And it serves GET /healthz as ok with the env from docker.env.example
And it runs as the non-root user with the data volume as in docker-deployment
Scenario: No secrets in the image
Given the dry-run image
Then "docker history" and a file listing show no .env, no release.key, no JWT_SECRET value and no SMTP password
Scenario: Compose pulls by default
Given docker-compose.yml with RENEWCMS_IMAGE unset
Then "docker compose config" shows the image "git.floriansylvain.fr/fsylvain/renewcms:beta" and no build section
And with RENEWCMS_IMAGE set to a tag, that tag is used
And with docker-compose.build.yml added, the build section is present and the image builds from the local Dockerfile
Scenario: Upgrade is a pull
Given a running stack on tag "v2.8.0-beta" with data in the volume
When the operator runs "docker compose pull && docker compose up -d" after a newer tag is pushed
Then the container is recreated on the new image and the articles, uploads and database in the volume are intact
And GET /v1/settings reports the new version
Scenario: Docs say the same thing
Then docs/docker.md, docs/deployment.md and README.md each show the pull upgrade, the tag scheme and the maintainer flow, and none claims CI exists
+29
View File
@@ -0,0 +1,29 @@
# QA: Docker image publishing
Everything runs on the real local Docker daemon, with no registry push. On Windows use MSYS_NO_PATHCONV=1 under Git Bash and put the Docker bin on PATH (memory: docker-windows-gotchas).
## Automated / scripted
1. Refusals: run "task docker:publish" with REGISTRY_IMAGE empty, VERSION=dev, VERSION=2.9.0, and a dirty tree; assert non-zero exit and that no docker build started (compare "docker buildx ls" and the image list before and after).
2. Tag table: run "task docker:publish -- --dry-run" with VERSION=v2.9.0, v2.9.0-beta and v2.9.0-rc.1 (the VERSION override is accepted only with --dry-run); compare the printed tags with the feature.
3. Dry run: the image is loaded; "docker run --rm <image> --version" prints the tag; start the stack from docker.env.example and check GET /healthz is ok, and "docker exec <ctr> id -u" is not 0.
4. Secrets: "docker history --no-trunc" and a file search for .env and release.key inside the image are clean; grep the layers for the sample JWT_SECRET value.
5. Compose: "docker compose config" with and without RENEWCMS_IMAGE, and with the build override.
6. Upgrade simulation: tag two local images v-old and v-new, run the stack on v-old, write an article, switch RENEWCMS_IMAGE to v-new, "docker compose up -d", and verify the article and the new version.
7. Docs: grep the three docs for "docker compose pull", the tag scheme, and the absence of CI claims.
8. No credentials: grep Taskfile.yml and docs for token or password literals; the task must not call "docker login".
## Manual (user performs; needs the registry)
- `docker login git.floriansylvain.fr`; "task docker:publish" with a real tag; confirm "docker buildx imagetools inspect <image>:<tag>" lists linux/amd64 and linux/arm64.
- On a clean machine, "docker compose pull && docker compose up -d" against the pushed image; confirm /settings shows the new version and the container guidance from container_update_guidance.
- Confirm the moving "beta" tag points at the intended digest and that "latest" did not move for a prerelease.
## Results
- 1 Refusals: PASS (coder and hardener runs).
- 2 Tag table: PASS (v2.9.0, v2.9.0-beta, v2.9.0-rc.1; re-seen with v2.9.0-beta and v2.9.1-beta, tags "vX, X, beta").
- 3 Dry run: PASS (--version prints the tag, /healthz ok, uid 10001).
- 4 Secrets: PASS.
- 5 Compose: PASS.
- 6 Upgrade simulation: PASS. Local images v2.9.0-beta and v2.9.1-beta, stack on the old tag, register + verify (mail captured over TLS by Mailpit) + login, article created (201), RENEWCMS_IMAGE switched, "docker compose up -d" recreated the container on the same volume. Old cookie still accepted, login 200, article 1 intact, GET /v1/settings reports v2.9.1-beta (was v2.9.0-beta).
- 7 Docs: PASS.
- 8 No credentials: PASS.
- Manual items: NOT RUN (need the registry): docker login, real multi-arch push and imagetools inspect, "docker compose pull" against the pushed image, moving beta/latest tag checks, builder failure paths of a real run.
+385
View File
@@ -0,0 +1,385 @@
Feature: Media cleanup in the SPA: Settings card, purge dialog and Media Duplicates button
As a RenewCMS owner or admin
I want to turn on automatic duplicate purging and to purge duplicates by hand
So that identical images are merged without leaving Settings or the Media library
Background:
Given the API of media_purge.feature and media_purge_scheduler.feature is live
And every call goes through useApi; 401 is global (/login?redirect=<path>&reason=expired);
a 403 on a verified account has no local copy and redirects nowhere
And the calls are:
| call | body or response |
| GET /v1/media/purge-settings | {auto_purge_enabled, auto_purge_interval_days, last_purge_at, last_purge_merged, last_purge_articles_updated, enabled_at, next_purge_at} |
| PATCH /v1/media/purge-settings | {"auto_purge_enabled": bool} or {"auto_purge_interval_days": int}, answers the settings above |
| GET /v1/media/purge-preview | {groups, copies, articles, list: [{keep_id, filename, url, used_in, merged}], more, fingerprint} |
| POST /v1/media/purge | {"fingerprint": "<from the preview>"}, answers {groups_total, groups_purged, merged, articles_updated, failed_groups} |
And errors are plain text; 409 carries "The library changed, so nothing was purged. Check the new
summary and try again." (stale fingerprint) or "A purge is already running." (busy)
And "owner or admin" is session.role in ('owner', 'admin'); only those two roles exist, so any
other role is a mocked value in tests
And /settings is the verified route of settings_page.feature; the Media cleanup card is its second
card, below Version and Updates, 20 px under the previous card, in a page at most 720 px wide
And the card is --ui-bg with a 1 px --ui-border, 20 px padding (16 px on mobile), 16 px gap, as
drawn in docs/design/source/SettingsFrame.dc.html
And the components are Nuxt UI v4 only (UCard, USwitch, UInputNumber, UFormField, UAlert, UButton,
UModal, USkeleton, useToast) and design tokens only
And PurgeDuplicatesDialog is one component shared by the card and the Media Duplicates tab: props
open, preview, status (loading, ready, purging, nothing, error, partial, conflict); it emits
purged with the counts; the parent shows the toast
And plural rules: "1 group" / "N groups", "1 copy" / "N copies", "1 article" / "N articles"
And skeleton placeholders show only after 150 ms of loading
And a Partial result is a 200 whose failed_groups is above 0 (the board's 207 is only a label)
And dates are shown in the browser's timezone, from last_purge_at and next_purge_at in fixtures
And no state here is clock-sensitive
Rule: The Media cleanup card on /settings
Scenario: Default: off, never run
Given GET /v1/media/purge-settings resolves 200 with auto_purge_enabled false,
auto_purge_interval_days 30, last_purge_at null, next_purge_at null
And the user is the owner
When the user opens /settings
Then the card titled "Media cleanup" reads "Merge identical images so your library stays tidy.
Articles that use a copy are switched to the most used one."
And "Automatic purge" with the helper "Runs in the background. Off by default." and a switch that is off
And no "Every" row and no "Next purge" line
And the line "Never run" is shown
And a divider, then "Purge now" with the helper "Merge every group of duplicates that has an image
in use, right away." and an outline button "Purge duplicates" with the merge icon
And the card has no Save button
Scenario: On: 30 days, last run
Given GET resolves auto_purge_enabled true, auto_purge_interval_days 30,
last_purge_at "2026-06-14T09:00:00Z", last_purge_merged 3, last_purge_articles_updated 5,
next_purge_at "2026-07-14T09:00:00Z"
When the user opens /settings
Then the switch is on
And "Every" with the helper "Between 1 and 365 days." and a number input showing 30 with the suffix "days"
And the lines "Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated" and "Next purge: 14 Jul 2026"
Scenario Outline: The Last purge line is pluralised
Given last_purge_at is "2026-06-14T09:00:00Z", last_purge_merged <merged> and last_purge_articles_updated <articles>
When the card renders
Then the line reads "<line>"
Examples:
| merged | articles | line |
| 3 | 5 | Last purge: 14 Jun 2026 · 3 copies merged · 5 articles updated |
| 1 | 1 | Last purge: 14 Jun 2026 · 1 copy merged · 1 article updated |
| 0 | 0 | Last purge: 14 Jun 2026 · 0 copies merged · 0 articles updated |
Scenario: Turning the switch on saves at once
Given the Default state
When the user turns the switch on
Then PATCH /v1/media/purge-settings is sent with exactly {"auto_purge_enabled": true}
And while it is pending "Saving…" with loader-circle sits in the card header and the controls stay usable
When it resolves 200 with auto_purge_interval_days 30 and next_purge_at "2026-11-04T09:00:00Z"
Then "Saving…" is replaced by "Saved" with a check, in the success colour, in the card header
And the "Every" row shows 30 and "Next purge: 4 Nov 2026" is shown
And "Saved" is announced politely and disappears after 2 s
Scenario: Turning the switch off saves at once and hides the schedule
Given the On state
When the user turns the switch off
Then PATCH is sent with exactly {"auto_purge_enabled": false}
And on 200 with next_purge_at null the "Every" row and the "Next purge" line are hidden
And the "Last purge" line is unchanged
Scenario Outline: The days field saves on blur or Enter, once
Given the On state and the days field holds 30
When the user types "<typed>" and <commit>
Then PATCH is sent exactly once with {"auto_purge_interval_days": <sent>}
And on 200 "Saved" shows in the header and "Next purge" follows the response's next_purge_at
Examples:
| typed | commit | sent |
| 45 | leaves the field | 45 |
| 45 | presses Enter | 45 |
| 1 | presses Enter | 1 |
| 365 | leaves the field | 365 |
Scenario: Enter then the resulting blur is one request
Given the On state
When the user types 45, presses Enter and the field then loses focus
Then exactly one PATCH is sent
Scenario: An unchanged value sends nothing
Given the On state and the days field holds 30
When the user focuses the field and leaves it without changing it
Then no PATCH is sent and no "Saving…" shows
Scenario Outline: Validation error: nothing is sent
Given the On state
When the user types "<typed>" and leaves the field or presses Enter
Then no PATCH is sent
And the field keeps "<typed>" and gets the error border
And under the field, with circle-alert, the message "Enter a whole number of days between 1 and 365." shows
And no "Saving…" and no "Saved" show
Examples:
| typed |
| |
| 0 |
| 366 |
| 400 |
| 1.5 |
Scenario: A validation error clears when the value becomes valid
Given the Validation error state with "400"
When the user types 60 and presses Enter
Then the message disappears and PATCH is sent with {"auto_purge_interval_days": 60}
Scenario Outline: Save failed: the control reverts
Given the On state with 30 days
When the user <action> and PATCH answers <failure>
Then the control returns to its previous value (<previous>)
And "Couldn't save. Try again in a moment." shows under the field (under the "Automatic purge" row for the switch)
And no "Saved" shows
Examples:
| action | failure | previous |
| types 45 and presses Enter | 500 | 30 |
| types 45 and presses Enter | a network error | 30 |
| turns the switch off | 500 | on |
Scenario: The save-failed message goes away on the next successful save
Given the Save failed state
When the user types 45 and presses Enter and PATCH answers 200
Then the failure message is gone and "Saved" shows
Scenario: Role changed (403 on PATCH)
Given the On state for an owner who has been changed to a role that is not owner or admin
When the user turns the switch off and PATCH answers 403
Then the switch returns to on, with no message, no toast and no redirect
And the session is refreshed and the card switches to the Read-only state
Scenario: Read-only (non-admin)
Given the session role is not owner or admin
When the user opens /settings
Then GET /v1/media/purge-settings is not sent
And the switch is off, the days input shows 30 and the line "Never run" shows, as placeholders
And the switch and the days input are disabled
And an info alert reads "Only owners and admins can change this."
And the "Purge now" divider, row and button are not rendered
And no PATCH can be sent from the card
Scenario: Loading skeleton after 150 ms
Given GET /v1/media/purge-settings has not resolved
When the user opens /settings
Then during the first 150 ms the card shows no skeleton
And after 150 ms a skeleton replaces the card body (title bar, two text bars, switch row, schedule bar, divider, button row), the card is aria-busy="true" and the button is disabled
When it resolves
Then the Default or On state replaces it and aria-busy is gone
Scenario Outline: The settings could not be loaded
Given GET /v1/media/purge-settings <failure>
When the user opens /settings
Then the card shows an error alert "Couldn't reach RenewCMS. Check your connection and try again." with "Try again" (rotate-cw)
And the Version and Updates cards are unaffected
When the user activates "Try again"
Then the loading state shows and GET is sent again
Examples:
| failure |
| answers 500 |
| fails (network) |
Scenario: Offline
Given navigator.onLine is false
When the user opens /settings
Then a banner reads "You're offline. Changing automatic purge and purging duplicates need a connection."
And the switch, the days input and "Purge duplicates" are disabled (40 % opacity)
And nothing is sent when they are activated
Scenario: 401 is global
Given any call of the card answers 401
Then the user goes to /login?redirect=/settings&reason=expired and the card shows no copy of its own
Rule: PurgeDuplicatesDialog opened from "Purge duplicates" in the card
Background:
Given an owner on /settings and the card in the On state
Scenario: Opening shows the preview loading
When the user activates "Purge duplicates"
Then GET /v1/media/purge-preview is sent
And a dialog opens (UModal, sm:max-w-md, a bottom sheet on mobile) titled "Purge duplicates?" with the description
"Articles that use a duplicate will be switched to the most used copy of that image, and the other copies will be deleted. This can't be undone."
And focus is on the first button, "Cancel"
And after 150 ms skeleton rows replace the summary and list, and the confirm button, labelled
"Purge duplicates", is disabled
Scenario: Confirm: summary, up to 5 groups and the link
Given the preview is groups 4, copies 9, articles 12, a list of 4 groups, more 0
When it resolves
Then the summary reads "4 groups · 9 copies will be deleted · 12 articles will be updated"
And a bordered list shows a row per group: a 40 x 30 thumbnail of its url, the filename, "Kept · used in N articles"
and, right-aligned, "N copies merged"
And no "and N more" line
And the link "Review in Duplicates" (arrow-right) points to /media?filter=duplicates
And the buttons are "Cancel" and the danger confirm "Purge 4 groups"
Scenario Outline: The summary and rows are pluralised
Given the preview is groups <g>, copies <c>, articles <a> with one group kept in <u> article(s) and <m> merged
Then the summary reads "<summary>"
And the confirm reads "<confirm>"
And the row reads "Kept · used in <ku>" and "<km>"
Examples:
| g | c | a | u | m | summary | confirm | ku | km |
| 4 | 9 | 12 | 4 | 2 | 4 groups · 9 copies will be deleted · 12 articles will be updated | Purge 4 groups | 4 articles | 2 copies merged |
| 1 | 1 | 2 | 2 | 1 | 1 group · 1 copy will be deleted · 2 articles will be updated | Purge 1 group | 2 articles | 1 copy merged |
| 1 | 1 | 1 | 1 | 1 | 1 group · 1 copy will be deleted · 1 article will be updated | Purge 1 group | 1 article | 1 copy merged |
Scenario: Confirm: many groups
Given the preview is groups 8, copies 15, articles 24, a list of 5 groups and more 3
When it resolves
Then exactly 5 rows show, followed by "and 3 more groups"
And the confirm reads "Purge 8 groups"
Scenario: "and 1 more group" is singular
Given the preview has more 1
Then the line reads "and 1 more group"
Scenario: Nothing to purge
Given the preview is groups 0 with an empty list
When it resolves
Then the dialog shows an info icon, "Nothing to purge" and "No duplicate is used in an article, so there is nothing to merge."
And the only button is "Close"; there is no confirm, no summary and no link
And "Close" closes the dialog and sends nothing else
Scenario: The preview could not be loaded
Given GET /v1/media/purge-preview answers 500 or fails
Then the dialog shows the error notice "Couldn't purge the duplicates. Nothing was changed. Try again in a moment."
And the confirm is disabled and offers no purge until the preview loads again
Scenario: Cancelling changes nothing
Given the Confirm state
When the user activates "Cancel", presses Escape or clicks the overlay
Then the dialog closes, no POST is sent and focus returns to "Purge duplicates"
Scenario: The fingerprint goes from the preview to the purge
Given the preview carries fingerprint "ab12"
When the user activates "Purge 4 groups"
Then POST /v1/media/purge is sent once with exactly {"fingerprint": "ab12"}
Scenario: In progress: the dialog cannot be dismissed
Given POST /v1/media/purge has not resolved
Then the confirm reads "Purging…" with loader-circle and is disabled
And "Cancel" is disabled
And Escape, a click on the overlay and a swipe-down do nothing
Scenario: Success: the dialog closes, a toast shows and Last purge updates in place
Given the preview was groups 4, copies 9, articles 12
When POST answers 200 with {groups_total 4, groups_purged 4, merged 9, articles_updated 12, failed_groups 0}
Then the dialog closes, the dialog emits purged with merged 9 and articles_updated 12
And a toast with circle-check shows for 5 s reading "Duplicates purged" / "9 copies merged. 12 articles updated."
And GET /v1/media/purge-settings is sent again
And with last_purge_at "2026-10-05T09:00:00Z", last_purge_merged 9, last_purge_articles_updated 12 the line reads
"Last purge: 5 Oct 2026 · 9 copies merged · 12 articles updated", without a page reload
And focus returns to "Purge duplicates"
Scenario: Success with automatic purge on moves Next purge forward
Given automatic purge is on with interval 30
When a purge succeeds and the refreshed settings carry next_purge_at "2026-11-04T09:00:00Z"
Then "Next purge: 4 Nov 2026" shows, and with automatic purge off no "Next purge" line shows
Scenario: The toast is pluralised
When POST answers 200 with merged 1, articles_updated 1 and failed_groups 0
Then the toast reads "Duplicates purged" / "1 copy merged. 1 article updated."
Scenario Outline: Error: the dialog stays open and confirm retries
Given the Confirm state with fingerprint "ab12"
When POST answers <failure>
Then an error notice (soft alert, circle-x) reads "Couldn't purge the duplicates. Nothing was changed. Try again in a moment." above the summary
And the dialog stays open with the summary and list unchanged, and "Cancel" and "Purge 4 groups" enabled
When the user activates "Purge 4 groups" again
Then POST is sent again with {"fingerprint": "ab12"}
And no toast shows and "Last purge" is unchanged
Examples:
| failure |
| 500 |
| a network error |
Scenario: Partial: some groups failed
Given the preview was 4 groups
When POST answers 200 with {groups_total 4, groups_purged 3, merged 8, articles_updated 10, failed_groups 1}
Then a warning notice (soft alert, triangle-alert) reads "Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish."
And GET /v1/media/purge-preview is sent again and the summary and list refresh to the remaining groups
And GET /v1/media/purge-settings is sent again so "Last purge" refreshes
And the buttons are "Close" and "Retry" (rotate-cw, danger)
And no toast shows and the dialog does not close
When the user activates "Retry"
Then POST is sent with the refreshed fingerprint
Scenario: Partial that leaves nothing to purge
Given a Partial result and the refreshed preview has groups 0
Then the dialog shows "Nothing to purge" with "Close"
Scenario: Conflict (409): the library changed
Given the Confirm state with fingerprint "ab12"
When POST answers 409 with "The library changed, so nothing was purged. Check the new summary and try again."
Then a warning notice reads "The library changed, so nothing was purged. Check the new summary and try again."
And GET /v1/media/purge-preview is sent again and the summary refreshes (for example 3 groups · 7 copies · 10 articles)
And the buttons are "Cancel" and "Purge 3 groups"
And no toast shows and "Last purge" is unchanged
When the user activates "Purge 3 groups"
Then POST is sent with the new fingerprint
Scenario: Conflict (409): a purge is already running
When POST answers 409 with "A purge is already running."
Then a warning notice shows the server's text, the preview refreshes and the dialog stays open
Scenario: Offline
Given navigator.onLine is false
Then "Purge duplicates" in the card is disabled and the dialog cannot be opened
Rule: The Duplicates toolbar button on /media
Scenario: The button shows on the Duplicates tab for an owner or admin
Given the user is owner or admin and /media?filter=duplicates holds at least one duplicate group
Then the secondary (outline) button "Purge duplicates" with the merge icon shows after the sort menu
and before the view toggle in the toolbar, as MediaFrame.dc.html draws it
And on a stacked mobile toolbar it is the last row, full width and 44 px high, under the sort and view row
Scenario Outline: The button is hidden
Given <condition>
Then the toolbar has no "Purge duplicates" button
Examples:
| condition |
| the filter is All |
| the filter is Unused |
| the Duplicates tab has no groups ("No duplicates" shows) |
| the session role is not owner or admin |
Scenario: Offline
Given navigator.onLine is false on the Duplicates tab
Then the button is disabled, the existing offline banner of Media shows and nothing opens
Scenario: The button opens the same dialog
When the user activates "Purge duplicates" on the Duplicates tab
Then GET /v1/media/purge-preview is sent and PurgeDuplicatesDialog opens in the loading state
And every status, notice, button and request of the previous Rule behaves identically
Scenario: After a purge from Media the list reloads like after a merge
Given the Duplicates tab lists 4 groups
When a purge succeeds (200, failed_groups 0)
Then the toast "Duplicates purged" / "9 copies merged. 12 articles updated." shows
And the list and the tab counts are requested again, as after "Merge into this one"
And when no group is left "No duplicates" shows and the button is gone
Scenario: Partial from Media reloads the list when the dialog closes
When the purge answers 200 with failed_groups 1 and the user activates "Close"
Then the list and the tab counts are requested again
Scenario: "Review in Duplicates" from Media only closes the dialog
Given the dialog is open on /media?filter=duplicates
When the user follows "Review in Duplicates"
Then the dialog closes and the route is unchanged
Scenario: "Merge into this one" is unchanged
Given the Duplicates tab lists a group
Then each group still shows "Merge into this one" and it still sends POST /v1/media/merge with {keep_id, merge_ids}
+102
View File
@@ -0,0 +1,102 @@
# QA: Media cleanup in the SPA (Settings card, purge dialog, Media Duplicates button)
No Go test: the API contract is already covered by media_purge.qa.md and media_purge_scheduler.qa.md. This slice has no Go change. The SPA never reaches `persistence`, `mailer` or `gorm`, and every `/v1` call goes through `useApi`.
Settled decisions: a non-admin sees the Read-only card with placeholders and no settings GET; Partial is a 200 with `failed_groups > 0`; a 403 on PATCH reverts silently, refreshes the session and switches the card to Read-only; dates use the browser timezone.
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
Suggested files, next to the existing view specs: `web/src/views/__tests__/SettingsMediaCleanup.spec.ts`, `web/src/views/__tests__/PurgeDuplicatesDialog.spec.ts`, `web/src/views/__tests__/MediaPurgeEntry.spec.ts`. Reuse `mediaTestKit.ts` and the existing Settings/Media mount helpers.
Setup:
- Mock `useApi().request` per route and method: GET and PATCH `/v1/media/purge-settings`, GET `/v1/media/purge-preview`, POST `/v1/media/purge`. Assert the exact path, method and JSON body of every call.
- Mock the session store: `role: 'owner'`, `role: 'admin'`, and for the non-admin cases a role cast to `UserRole` (for example `'editor' as never`) or undefined.
- Fake timers for the 150 ms skeleton, the 2 s "Saved" and the 5 s toast. No test freezes the system date: every date comes from a fixture ISO string with a midday UTC time (for example `2026-06-14T09:00:00Z`), formatted by the SPA.
- Offline: set `navigator.onLine` to false and dispatch `offline`, or set `isOffline` from `useApi`.
- Reject with `ApiError(status, undefined, body)` and `NetworkError` as in the existing specs.
- Fixtures reuse the board data: settings `last 14 Jun 2026 / 3 copies / 5 articles`, `next 14 Jul 2026`; preview 4 groups / 9 copies / 12 articles with floorboards.jpg (4, 2), IMG_4471.jpg (2, 1), kitchen-after.webp (3, 3), logo-renew.svg (5, 3); many: 8 / 15 / 24 with lime-plaster.jpg (2, 1) and more 3.
Cases (one `it` per scenario or Examples row; `it.each` for outlines):
Settings card
1. Default: one GET on open, title, description, label, helper, switch off, no "Every", no "Next purge", "Never run", divider, "Purge now" row, button "Purge duplicates", no Save button.
2. On: switch on, "Every" 30 with "days" suffix and helper, both lines; the Last purge pluralisation rows (3/5, 1/1, 0/0).
3. Toggle on: PATCH body is exactly `{ auto_purge_enabled: true }`; "Saving…" while pending; then "Saved" in a polite live region; gone after 2 s; the "Every" row and "Next purge" follow the response.
4. Toggle off: body exactly `{ auto_purge_enabled: false }`; schedule hidden, Last purge kept.
5. Days commit, table-driven (45 blur, 45 Enter, 1 Enter, 365 blur): exactly one PATCH with `{ auto_purge_interval_days: n }`. Enter then blur is one PATCH. Unchanged value sends nothing.
6. Validation, table-driven ("", 0, 366, 400, 1.5): no `request` call, field keeps the typed text, error message "Enter a whole number of days between 1 and 365." under the field, error border; a valid value afterwards clears it and sends.
7. Save failed (PATCH 500, then NetworkError, for the days field and for the toggle): value reverts, the "Couldn't save. Try again in a moment." copy shows in the right place, no "Saved"; a later success clears it.
8. Read-only: role not owner/admin leads to no call to `/v1/media/purge-settings`, placeholders (switch off, 30, "Never run"), disabled switch and input, the info alert "Only owners and admins can change this.", no divider, no "Purge now" row, no button.
9. 403 on PATCH: the control reverts, no toast, no redirect, `session.refresh` is called, and a role that is no longer owner/admin yields the Read-only card.
10. Loading: 149 ms no skeleton, 150 ms skeleton and `aria-busy="true"`, button disabled; resolve and assert both are gone.
11. Settings load failure (500, network): the inline alert, "Try again" reloads.
12. Offline: banner text "You're offline. Changing automatic purge and purging duplicates need a connection.", switch, input and button disabled, nothing sent.
13. 401 on any call: `redirectToLogin` path used, no local copy in the card.
Dialog
14. Open: GET preview sent, title and description, focus on "Cancel", skeleton after 150 ms (not before), confirm disabled with the label "Purge duplicates".
15. Confirm: summary, rows (thumbnail src from `url`, filename, "Kept · used in N articles", "N copies merged"), link `href="/media?filter=duplicates"` with "Review in Duplicates", buttons, confirm label "Purge 4 groups". Singular rows (1/1/2, 1/1/1).
16. Many groups: 5 rows then "and 3 more groups"; "and 1 more group" for more 1.
17. Nothing: groups 0 gives "Nothing to purge" copy and a single "Close"; no confirm, summary or link.
18. Cancel, Escape and overlay close without POST and return focus to the opener.
19. Fingerprint: POST body is exactly `{ fingerprint: '<preview fingerprint>' }`, sent once.
20. In progress (POST unresolved): label "Purging…" with the loader icon, confirm and Cancel disabled; Escape, overlay click and a close attempt do nothing.
21. Success: dialog closes, `purged` emitted with `{ merged, articles_updated }`, toast "Duplicates purged" / "9 copies merged. 12 articles updated." with a 5 s duration, a second GET `/v1/media/purge-settings`, "Last purge" and "Next purge" updated without remount, focus back on the opener; singular toast.
22. Error (500 and network): error notice, dialog open, summary unchanged, confirm retries with the same fingerprint, no toast, Last purge unchanged.
23. Partial (200, failed_groups 1, groups_total 4, groups_purged 3): the warning text with "3 of 4", a second GET preview, summary refreshed, settings refetched, buttons "Close" and "Retry", no toast, a Retry POST uses the new fingerprint; a refreshed empty preview gives "Nothing to purge".
24. Conflict (409 with the conflict text): warning, preview refetched, summary and confirm label refreshed, no toast; the next confirm sends the new fingerprint. Busy 409 text shows the server's text.
25. Preview failure: error notice and no purge possible until it loads.
26. Offline: the button is disabled and no dialog opens.
Media
27. Button presence table: shown for owner and admin on the Duplicates tab with at least one group; hidden on All, on Unused, with no groups, and for a non-owner/admin role.
28. Offline: disabled, the existing banner shows, nothing opens.
29. DOM order of the toolbar: Sort, then "Purge duplicates", then the view toggle; the button carries the mobile classes for full width and a 44 px height (`h-11`) in the stacked toolbar.
30. Opening from Media: same dialog statuses as cases 14 to 25, driven through the same mocks.
31. After a successful purge: the toast, and the media list and counts are requested again (same assertion helper as the merge success case in `MediaDuplicates.spec.ts`); the last group leaving shows "No duplicates" and removes the button. Partial then "Close" reloads the list.
32. "Review in Duplicates" from Media closes the dialog and leaves the route unchanged.
33. "Merge into this one" regression: the existing merge cases still pass unchanged.
Amendments to existing specs, in the same change: `SettingsView.spec.ts` and the other Settings specs must tolerate a second card (any assertion about "no switch or input" is scoped to the Version card). `features/settings_page.feature` wording ("one card") is updated at its next amendment, not in this slice.
## Visual (`task visual:check -- settings` and `task visual:check -- media`, desktop and mobile, light and dark)
References: `docs/design/screens/settings.md` ("Media cleanup (second card)"), `docs/design/screens/media.md`, `docs/design/source/RenewCMS Screens Media cleanup.dc.html` and the frames `SettingsFrame.dc.html`, `MediaFrame.dc.html` (exact values). Add every state below to `web/visual/states.ts`, with the mocked `/v1` calls the state needs. The gate is hard: every capture within 10 %. Nothing is skipped: no state depends on the clock, because dates come from fixture timestamps (midday UTC, so the day is stable).
Board sample data to reuse:
- Last purge fixture: `last_purge_at` 14 Jun 2026, merged 3, articles 5; "on" has 30 days and next 14 Jul 2026 (`next_purge_at` "2026-07-14T09:00:00Z").
- Success fixtures: last 5 Oct 2026, 9 copies, 12 articles. "Success" has automatic purge off (no Next purge); "Success, automatic purge on" has 30 days and next 4 Nov 2026. Both show the toast "Duplicates purged" / "9 copies merged. 12 articles updated."
- Preview groups (ids 12, 7, 2, 4, 11): floorboards.jpg (used 4, merged 2), IMG_4471.jpg (2, 1), kitchen-after.webp (3, 3), logo-renew.svg (5, 3, SVG with alpha), lime-plaster.jpg (2, 1). Default preview: 4 groups, 9 copies, 12 articles with the first four. Many: 8 / 15 / 24 with all five and more 3. Partial: refreshed preview 1 group / 1 copy / 2 articles (IMG_4471.jpg), POST answers 200 `groups_total 4, groups_purged 3, failed_groups 1`. Conflict: POST 409, refreshed preview 3 groups / 7 copies / 10 articles (floorboards.jpg, kitchen-after.webp, logo-renew.svg).
- Thumbnails: reuse the thumbnail fixtures the existing media states draw.
- Frame values: page max width 720, 20 px between cards; card padding 20 (16 mobile), 16 gap; title 15/24 semibold; switch 36 x 20 with a 44 px hit area on mobile; days input 140 px wide (100 % on mobile), 40 px high (44 mobile), IBM Plex Mono 13 px (16 px on mobile), suffix "days" in mono; rows stack on mobile; "Purge duplicates" is 32 px high (44 mobile, full width); dialog 448 px wide, 140 px from the top on desktop, bottom sheet on mobile with the buttons column-reversed; toast 356 px wide at the bottom right (full width on mobile).
Settings screen, group "Settings: Media cleanup":
- [ ] Default: off, never run
- [ ] On: 30 days, last run
- [ ] Saving (PATCH hangs, days field focused)
- [ ] Saved
- [ ] Save failed
- [ ] Validation error (days field holds "400")
- [ ] Read-only (non-admin) (mocked role, placeholders, no GET)
- [ ] Loading skeleton
- [ ] Offline
Settings screen, group "Purge duplicates: dialog":
- [ ] Loading preview, [ ] Confirm, [ ] Confirm: many groups, [ ] In progress, [ ] Nothing to purge, [ ] Error, [ ] Partial failure, [ ] Conflict (409)
Settings screen, group "Results and feedback":
- [ ] Success, [ ] Success, automatic purge on (the setup holds the toast open so its 5 s timeout cannot end before the capture)
Media screen, group "Media: Duplicates tab entry point":
- [ ] Duplicates tab with the button, [ ] Dialog from Media, [ ] Member: button hidden (mocked role), [ ] Offline (button disabled, existing banner copy)
Reuse the existing `mediaDuplicatesApi` fixtures for the Duplicates tab and add the purge-preview mock.
Not covered by screenshots, check by hand: Tab order (switch, days field, "Purge duplicates"; in the dialog Cancel then confirm then the link); focus ring on the switch and field; Enter and Escape in the dialog; focus return to the opener after close; the polite announcement of "Saving…" then "Saved"; hover states; reduced motion on the skeleton and the loader icons; swipe-down is ignored while purging on a touch device.
## Manual verification (real API, `task dev`)
- With an owner, turn automatic purge on, set 7 days, reload /settings: the values persist and "Next purge" matches `next_purge_at`.
- Upload two identical images, use the copy in an article, press "Purge duplicates": the preview matches, the purge merges them, the toast shows, "Last purge" updates, the file of the copy is gone from `UPLOAD_DIR`.
- Open the dialog, upload another duplicate from a second tab, confirm: the Conflict notice and the refreshed summary show.
- Confirm the embedded SPA (built without `-tags nofrontend`) serves /settings and /media?filter=duplicates on a hard refresh, since Vitest never exercises the embedded frontend.
+432
View File
@@ -0,0 +1,432 @@
Feature: Owners and admins can merge duplicate images in use, with persisted purge settings
As a RenewCMS owner or admin
I want to see what a duplicate purge would do, run it, and keep the automatic-purge settings
So that articles end up pointing at the most used copy of an image and the other copies are removed
Background:
Given internal/domain/purge defines the entity Settings with AutoPurgeEnabled (default false),
AutoPurgeIntervalDays (default 30, valid 1 to 365), LastPurgeAt, LastPurgeMerged,
LastPurgeArticlesUpdated and EnabledAt (unset or 0 by default), plus a Repository
interface with Get, Save and SaveLastRun, and the sentinels ErrInvalidPurgeInterval, ErrPurgeConflict
(the library changed since the fingerprint) and ErrPurgeBusy (a run is already in progress),
all asserted with errors.Is
And Repository.Get returns the defaults when nothing was ever saved (a single-row table)
And the use cases GetPurgeSettings, UpdatePurgeSettings, PreviewPurge and RunPurge depend only
on domain interfaces, take no HTTP types and are reusable by a scheduler
And the use cases receive an injectable clock and an injected *log.Logger
And an image's "used in" is the count of articles, online or draft, whose body contains the
image's stored name, read from article bodies each time, as in ListMedia
And a duplicate group is the set of images sharing one non-empty sha256, as from
image.Repository.ListDuplicates
And the purge plan is: a group is planned only if at least one copy is used in an article, the
kept copy is the most used one (ties: oldest created_at, then lowest id), and every other
copy of the group, used or unused, is merged into it. A group with no used copy is skipped
And the fingerprint is a hex SHA-256 of the planned groups ordered by kept id, with their kept
id and sorted merged ids; an empty plan has a fixed fingerprint
And useCases.MergeMediaResult keeps ArticlesUpdated and Merged, and gains UpdatedArticleIDs,
the ids of the articles actually rewritten (empty when the merge was rolled back or failed)
And RunPurge merges each group with the MergeMedia semantics and unions UpdatedArticleIDs of the
fully merged groups; it never recomputes the articles from the plan
And next_purge_at is max(last_purge_at, enabled_at) + auto_purge_interval_days when automatic
purge is on and null when it is off; with no last_purge_at it is enabled_at + interval
And /v1/media/purge-settings, /v1/media/purge-preview and /v1/media/purge are mounted in
api/router.go inside the NewMediaRouter chain (Verifier, Authenticator, RequireCurrentToken,
RequireVerifiedUser), then a group guarded by the new middleware.RequireOwnerOrAdmin, and
registered before the /{id} routes
And RequireOwnerOrAdmin is an allowlist: only the roles owner and admin pass; any other value,
including an empty role, answers 403 with apperr.OwnerOrAdminRequiredErrorMessage
("Only owners and admins can do this.") as plain text from http.Error
And a stale fingerprint answers 409 with apperr.PurgeConflictErrorMessage ("The library changed,
so nothing was purged. Check the new summary and try again.") and a busy run answers 409 with
apperr.PurgeBusyErrorMessage ("A purge is already running.")
And the purge settings range error is apperr.PurgeIntervalErrorMessage ("Enter a whole number of
days between 1 and 365.")
And request and response bodies are JSON with snake_case fields; errors are plain text
And the request body of PATCH is validated by validate tags and validator.Validate.Struct
Scenario Outline: Every purge route refuses callers that are not allowed
When the client sends <method> <path> <credentials>
Then the response status is <status>
And the body is <body>
And no purge setting, image or article is read or changed
Examples:
| method | path | credentials | status | body |
| GET | /v1/media/purge-settings | no cookie | 401 | the existing jwtauth response (status only) |
| PATCH | /v1/media/purge-settings | no cookie | 401 | the existing jwtauth response (status only) |
| GET | /v1/media/purge-preview | no cookie | 401 | the existing jwtauth response (status only) |
| POST | /v1/media/purge | no cookie | 401 | the existing jwtauth response (status only) |
| GET | /v1/media/purge-settings | a stale token_version cookie | 401 | apperr.UnauthorizedErrorMessage |
| GET | /v1/media/purge-settings | cookie of an unverified owner | 403 | apperr.VerificationRequiredErrorMessage |
| GET | /v1/media/purge-settings | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| PATCH | /v1/media/purge-settings | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| GET | /v1/media/purge-preview | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| POST | /v1/media/purge | cookie of a verified user, role "editor" | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| GET | /v1/media/purge-settings | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| PATCH | /v1/media/purge-settings | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| GET | /v1/media/purge-preview | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
| POST | /v1/media/purge | cookie of a verified user, empty role | 403 | apperr.OwnerOrAdminRequiredErrorMessage |
Scenario Outline: Owners and admins both pass the role guard
When the client sends <method> <path> with a valid cookie of a verified <role>
Then the response status is not 401 and not 403
Examples:
| role | method | path |
| owner | GET | /v1/media/purge-settings |
| admin | GET | /v1/media/purge-settings |
| owner | GET | /v1/media/purge-preview |
| admin | GET | /v1/media/purge-preview |
Rule: Purge settings
Scenario: Nothing was ever saved, so the defaults are served
When the client sends GET /v1/media/purge-settings with a valid cookie of the owner
Then the response status is 200
And the body is {"auto_purge_enabled": false, "auto_purge_interval_days": 30,
"last_purge_at": null, "last_purge_merged": 0, "last_purge_articles_updated": 0,
"enabled_at": null, "next_purge_at": null}
Scenario: Turning automatic purge on stamps enabled_at and computes next_purge_at
Given the clock reads 2026-10-05T09:00:00Z and the settings are the defaults
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": true}
Then the response status is 200
And "auto_purge_enabled" is true, "auto_purge_interval_days" is 30
And "enabled_at" is "2026-10-05T09:00:00Z" and "last_purge_at" is null
And "next_purge_at" is "2026-11-04T09:00:00Z"
And a following GET /v1/media/purge-settings returns the same body
Scenario: next_purge_at counts from the last purge when it is later than enabled_at
Given automatic purge is on with interval 10, enabled_at 2026-10-01T00:00:00Z and last_purge_at 2026-10-04T12:00:00Z
When the client sends GET /v1/media/purge-settings
Then "next_purge_at" is "2026-10-14T12:00:00Z"
Scenario: next_purge_at counts from enabled_at when it is later than the last purge
Given automatic purge is on with interval 10, enabled_at 2026-10-05T09:00:00Z and last_purge_at 2026-09-01T00:00:00Z
When the client sends GET /v1/media/purge-settings
Then "next_purge_at" is "2026-10-15T09:00:00Z"
Scenario: Turning automatic purge off and on again never yields a next_purge_at in the past
Given automatic purge was on, last_purge_at is 2026-09-01T00:00:00Z, and it was turned off
And the clock reads 2026-10-05T09:00:00Z and the interval is 30
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": true}
Then "enabled_at" is "2026-10-05T09:00:00Z"
And "last_purge_at" is still "2026-09-01T00:00:00Z"
And "next_purge_at" is "2026-11-04T09:00:00Z"
Scenario: Changing only the interval keeps enabled_at
Given automatic purge is on since 2026-10-01T00:00:00Z with interval 30, no purge yet, and the clock reads 2026-10-05T09:00:00Z
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_interval_days": 7}
Then the response status is 200
And "auto_purge_interval_days" is 7 and "enabled_at" is still "2026-10-01T00:00:00Z"
And "next_purge_at" is "2026-10-08T00:00:00Z"
Scenario: Patching "on" while already on does not restamp enabled_at
Given automatic purge is on since 2026-10-01T00:00:00Z and the clock reads 2026-10-05T09:00:00Z
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": true}
Then "enabled_at" is still "2026-10-01T00:00:00Z"
Scenario: Turning automatic purge off nulls next_purge_at and keeps the history
Given automatic purge is on with last_purge_at 2026-10-04T12:00:00Z, last_purge_merged 3 and last_purge_articles_updated 5
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": false}
Then "auto_purge_enabled" is false and "next_purge_at" is null
And "last_purge_at", "last_purge_merged" (3) and "last_purge_articles_updated" (5) are unchanged
Scenario Outline: The interval accepts whole numbers from 1 to 365
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_interval_days": <days>}
Then the response status is 200 and "auto_purge_interval_days" is <days>
Examples:
| days |
| 1 |
| 30 |
| 365 |
Scenario Outline: An invalid patch is 400 and nothing changes
When the client sends PATCH /v1/media/purge-settings with <body>
Then the response status is 400
And the body is <message>
And the stored settings are unchanged
Examples:
| body | message |
| {"auto_purge_interval_days": 0} | apperr.PurgeIntervalErrorMessage |
| {"auto_purge_interval_days": 366} | apperr.PurgeIntervalErrorMessage |
| {"auto_purge_interval_days": -1} | apperr.PurgeIntervalErrorMessage |
| {"auto_purge_interval_days": 1.5} | apperr.BodyErrorMessage |
| {"auto_purge_interval_days": "7"} | apperr.BodyErrorMessage |
| {"auto_purge_enabled": "yes"} | apperr.BodyErrorMessage |
| not json | apperr.BodyErrorMessage |
Scenario: An empty object changes nothing
When the client sends PATCH /v1/media/purge-settings with {}
Then the response status is 200 and the body equals the current settings
Scenario: The use case rejects an out-of-range interval with the domain sentinel
When UpdatePurgeSettings receives interval 0, or 366
Then errors.Is(err, purge.ErrInvalidPurgeInterval) is true
And Repository.Save is not called
Scenario: A repository failure is a generic 500
Given the purge settings repository's next Get fails
When the client sends GET /v1/media/purge-settings
Then the response status is 500 and the body is "Internal server error"
Scenario: A failing Save on PATCH is a generic 500
Given the purge settings repository's next Save fails
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_enabled": true}
Then the response status is 500 and the body is "Internal server error"
Scenario: The purge routes are not taken by the /{id} routes
When the client sends PATCH /v1/media/purge-settings with {"auto_purge_interval_days": 7}
Then the settings are updated and no image is renamed
When the client sends GET /v1/media/purge-preview
Then the preview use case is invoked
Rule: Purge plan and preview
Background:
Given the clock reads 2026-10-05T09:00:00Z
And images are created in id order, oldest first
And articles reference images by their stored name
Scenario: A group with a used copy is planned, merging every other copy into the most used
Given images 1, 2 and 3 share sha256 A with filenames "hero.jpg", "hero-copy.jpg", "hero-2.jpg"
And article 10 uses image 1, articles 11 and 12 use image 2, article 13 uses image 3
When the client sends GET /v1/media/purge-preview
Then the response status is 200
And "groups" is 1, "copies" is 2 and "articles" is 2
And "list" is [{"keep_id": 2, "filename": "hero-copy.jpg", "url": "<stored url of image 2>",
"used_in": 2, "merged": 2}]
And "more" is 0 and "fingerprint" is a 64-character hex string
Scenario: Ties on usage keep the oldest copy, then the lowest id
Given images 4 and 5 share sha256 B, created at the same instant, each used in one article
And images 6 and 7 share sha256 C, image 7 is older than image 6, each used in one article
When the client sends GET /v1/media/purge-preview
Then the "keep_id" of the group B is 4 and the "keep_id" of the group C is 7
Scenario: A single used copy with unused siblings keeps the used one
Given images 8, 9 and 10 share sha256 D and only image 10 is used, in one article
When the client sends GET /v1/media/purge-preview
Then the group has keep_id 10, used_in 1, merged 2
And "articles" is 0, since no article references a merged-away copy
Scenario: A group whose copies are all unused is skipped
Given images 11 and 12 share sha256 E and no article references either
When the client sends GET /v1/media/purge-preview
Then "groups" is 0, "copies" is 0, "articles" is 0, "list" is [] and "more" is 0
Scenario: A library with no duplicates
Given the library holds three images with distinct sha256 values
When the client sends GET /v1/media/purge-preview
Then the response status is 200
And "groups" is 0 and "list" is an empty array in the raw JSON, not null
And "fingerprint" equals the fingerprint of any other empty plan
Scenario: Images without a hash are never grouped
Given images 13 and 14 both have an empty sha256 and both are used
When the client sends GET /v1/media/purge-preview
Then "groups" is 0
Scenario: An article that uses copies of two groups counts once
Given groups A (keep 1, copy 2) and B (keep 3, copy 4) and one article that uses images 2 and 4
When the client sends GET /v1/media/purge-preview
Then "groups" is 2, "copies" is 2 and "articles" is 1
Scenario: An article that uses both the kept copy and a copy is rewritten
Given an article uses images 1 and 2 of one group where image 1 is kept
When the client sends GET /v1/media/purge-preview
Then "articles" is 1
Scenario: The list shows at most 5 groups and reports the rest
Given 7 planned groups
When the client sends GET /v1/media/purge-preview
Then "groups" is 7, "list" has 5 entries in ascending keep_id order and "more" is 2
Scenario: The preview changes nothing
Given a planned group
When the client sends GET /v1/media/purge-preview twice
Then both bodies are equal
And no image is deleted, no article body changes and no purge setting is saved
Scenario Outline: The fingerprint reacts to the plan and to nothing else
Given a planned group with kept image 2 and merged image 3
When <change>
Then the preview fingerprint is <result>
Examples:
| change | result |
| nothing changes | identical |
| a new unused copy is uploaded to the group | different |
| a copy is deleted from the group | different |
| article usage changes so that another copy becomes kept | different |
| an unrelated image with a unique sha256 is uploaded | identical |
| an article body changes without changing which is kept | identical |
| a group with no used copy gains an unused copy | identical |
Rule: Running a purge
Background:
Given the clock reads 2026-10-05T09:00:00Z
Scenario: A run merges every planned group and persists the result
Given group A: images 1 (used in article 10), 2 (used in articles 11 and 12) and 3 (unused)
And group B: images 4 (used in article 12) and 5 (unused)
And group C: images 6 and 7, both unused
When the client sends POST /v1/media/purge with {}
Then the response status is 200
And the body is {"groups_total": 2, "groups_purged": 2, "merged": 3, "articles_updated": 1, "failed_groups": 0}
And images 1 and 3 are gone from the image repository, image 2 remains
And image 5 is gone, image 4 remains, and images 6 and 7 both remain
And article 10's body now uses image 2's stored name
And the stored settings have last_purge_at "2026-10-05T09:00:00Z", last_purge_merged 3 and last_purge_articles_updated 1
And only article 10 is rewritten, since article 12 uses only kept copies
And "auto_purge_enabled", "auto_purge_interval_days" and "enabled_at" are untouched
Scenario: An article rewritten by two groups is counted once in articles_updated
Given an article uses a copy of group A and a copy of group B
When the client sends POST /v1/media/purge with {}
Then "articles_updated" counts that article once, from the union of the groups' UpdatedArticleIDs
Scenario: A single used copy with unused siblings is merged without rewriting any article
Given images 8, 9 and 10 share a hash and only image 10 is used
When the client sends POST /v1/media/purge with {}
Then the body is {"groups_total": 1, "groups_purged": 1, "merged": 2, "articles_updated": 0, "failed_groups": 0}
And images 8 and 9 are gone, image 10 remains and no article body changed
Scenario: A matching fingerprint is accepted
Given the preview returned fingerprint F
When the client sends POST /v1/media/purge with {"fingerprint": "F"}
Then the response status is 200 and the purge ran
Scenario: A stale fingerprint answers 409 and changes nothing
Given the preview returned fingerprint F
And an unused copy is then uploaded to a planned group
When the client sends POST /v1/media/purge with {"fingerprint": "F"}
Then the response status is 409
And the body is apperr.PurgeConflictErrorMessage
And no image is deleted, no article body changes and the stored last_purge_* values are unchanged
Scenario: A fingerprint that matches no plan at all is a conflict, not a validation error
When the client sends POST /v1/media/purge with {"fingerprint": "not-a-real-fingerprint"}
Then the response status is 409 and the body is apperr.PurgeConflictErrorMessage
And nothing changed
Scenario: A run without a fingerprint always runs the current plan
Given the library changed since any preview
When the client sends POST /v1/media/purge with an empty body
Then the response status is 200 and the current plan ran
Scenario: A library with nothing to purge answers zeros and is still recorded
Given the library holds no planned group
When the client sends POST /v1/media/purge with {}
Then the response status is 200
And the body is {"groups_total": 0, "groups_purged": 0, "merged": 0, "articles_updated": 0, "failed_groups": 0}
And the stored settings have last_purge_at "2026-10-05T09:00:00Z", last_purge_merged 0 and last_purge_articles_updated 0
And a scheduler reading next_purge_at sees it move forward from that run
Scenario: The fingerprint of the empty plan on an empty plan runs as a no-op
Given the library holds no planned group
When the client sends POST /v1/media/purge with the empty plan's fingerprint
Then the response status is 200 and the body has groups_total 0
Scenario: A group that fails while rewriting bodies is rolled back and contributes no ids
Given groups A, B and C are planned, in that order
And the article repository's UpdateBody fails for the second article that group B must rewrite
When the client sends POST /v1/media/purge with {}
Then the response status is 200
And the body is {"groups_total": 3, "groups_purged": 2, "merged": <copies of A and C>, "articles_updated": <distinct articles of A and C>, "failed_groups": 1}
And group B's article bodies are back to their original text and all of group B's images remain
And group B's UpdatedArticleIDs is empty, so no article of group B is in "articles_updated"
And the stored last_purge_merged and last_purge_articles_updated equal the response totals
Scenario: A group that fails after rewriting bodies is reported as failed and contributes nothing
Given the image repository's Delete fails for a merged image of group A, and group B is planned
When the client sends POST /v1/media/purge with {}
Then the response status is 200, "failed_groups" is 1 and "groups_purged" is 1
And group A contributes 0 to "merged" and its article ids are not added to "articles_updated", even though its article bodies stay switched to the kept copy
And "articles_updated" and "merged" count only the fully merged group B
And a following GET /v1/media/purge-preview plans group A again, and the next run converges because the plan is recomputed from current data
Scenario: Every group failing is still a 200 with the counters
Given every UpdateBody fails
When the client sends POST /v1/media/purge with {}
Then the response status is 200
And "groups_purged" is 0 and "failed_groups" equals "groups_total"
And the stored last_purge_at is set
Scenario: A second run while one is in progress is rejected as busy, not queued
Given a purge run is in progress and blocked inside the image repository
When a second POST /v1/media/purge arrives, with or without a fingerprint
Then it answers 409 with apperr.PurgeBusyErrorMessage at once
And it changed nothing and saved no last_purge_* values
When the first run is released
Then it finishes normally with its own counters
And a later POST /v1/media/purge is accepted
Scenario: The use case distinguishes a busy run from a stale fingerprint
When RunPurge receives a non-matching fingerprint
Then errors.Is(err, purge.ErrPurgeConflict) is true and errors.Is(err, purge.ErrPurgeBusy) is false
When RunPurge is called while another run holds the guard
Then errors.Is(err, purge.ErrPurgeBusy) is true and errors.Is(err, purge.ErrPurgeConflict) is false
And in both cases no repository write happens
Scenario: A run via the use case with no fingerprint needs no HTTP types
When a scheduler later calls RunPurge with no fingerprint
Then it gets the result struct and the same last_purge_* persistence applies
Scenario: A malformed body is 400
When the client sends POST /v1/media/purge with "not json"
Then the response status is 400 and the body is apperr.BodyErrorMessage
And nothing changed
Scenario: A repository failure while planning is a generic 500 and nothing changes
Given the image repository's ListDuplicates fails
When the client sends POST /v1/media/purge with {}
Then the response status is 500 and the body is "Internal server error"
And no last_purge_* values are saved
Scenario: A failing save of the run result is a logged 500 and the merges stay
Given the purge settings repository's SaveLastRun fails after the merges succeeded
When the client sends POST /v1/media/purge with {}
Then the response status is 500 and the body is "Internal server error"
And the merges that already happened stay done
And the error is written to the injected *log.Logger
And the next POST /v1/media/purge converges, because the plan is recomputed from current data
Scenario: The preview and the run use the same plan
When the client sends GET /v1/media/purge-preview and then POST /v1/media/purge with the returned fingerprint
Then the run's "groups_total" equals the preview's "groups"
And the run's "merged" equals the preview's "copies" and its "articles_updated" equals the preview's "articles"
Rule: MergeMedia keeps its contract while exposing the rewritten ids
Scenario: Existing merges report the same counts and now also the ids
When MergeMedia.Merge succeeds for a group that rewrites articles 1 and 2
Then Merged and ArticlesUpdated are unchanged for existing callers
And UpdatedArticleIDs is [1, 2]
Scenario: A rolled-back merge returns no ids
When MergeMedia.Merge fails while rewriting a body
Then it returns the error and an empty result
@manual
Rule: Persistence and wiring (verified by hand, see the qa.md)
Scenario: The purge settings table is a single row created by AutoMigrate
Given a database with no purge settings table
When the server boots
Then the table exists, GET /v1/media/purge-settings returns the defaults and no row is required beforehand
And repeated PATCH calls keep exactly one row
Scenario: Settings survive a restart
Given automatic purge was turned on with interval 7
When the server restarts
Then GET /v1/media/purge-settings returns the same values
Scenario: A real purge removes the merged files from UPLOAD_DIR
Given duplicates uploaded through the real server
When an owner runs POST /v1/media/purge
Then the merged copies' files are gone from UPLOAD_DIR and the kept file remains
+78
View File
@@ -0,0 +1,78 @@
# QA procedure: media_purge
Package: `test` (black-box). New files `test/media_purge_http_test.go` and `test/media_purge_test.go` (use case level). Fake type names must be unique in the package. Go only: no SPA, no scheduler, no env var.
## Setup shared by every case
1. Call `validator.InitValidator()` once.
2. Fakes:
- `mockPurgeSettingsRepo` (domain `purge.Repository`): holds one `Settings`, `Get` returns the defaults when nothing was saved, `Save` records the call count, with armed failure switches `failGet` and `failSave`.
- Reuse or extend `mockImageRepo` (`test/image_test.go`): `ListDuplicates()` (images with a non-empty sha256 shared by at least two rows, ordered `sha256, id`), `Delete` with `failDeleteFor`, `Get`, `FindByHash`, `Counts`. Add a hook that lets a test block inside `ListDuplicates` or `Delete` on a channel, to hold a run in progress.
- Reuse or extend `mockArticleRepo` (`test/article_test.go`): `FindByFilters`, `FindByBodyContaining`, `UpdateBody` with a per-id failure switch.
- The user fake: a verified owner (id 1), a verified admin (id 2), a verified user with `Role("editor")` (id 3), a verified user with an empty role (id 5), and an unverified owner (id 4).
3. Wire the real use cases over the fakes: the four purge use cases (constructors chosen by the coder), `NewGetUserUseCase(userRepo)`, plus the existing media use cases. Inject a fixed clock (2026-10-05T09:00:00Z) and a `*log.Logger` writing to a `bytes.Buffer`. Add the four purge handler methods to the media handler, built with `media.NewHandler(...)`.
4. Build `jwtauth.New("HS256", []byte("test-secret"), nil)` and mount on a fresh `chi.NewRouter()` mirroring `NewMediaRouter` in `api/router.go`:
- `jwtauth.Verifier` at the top, then `Authenticator`, `RequireCurrentToken(getUser)`, `RequireVerifiedUser(getUser)`.
- The purge routes inside `r.Group` with `middleware.RequireOwnerOrAdmin(getUser)`, registered BEFORE `Patch("/{id}")` and `Delete("/{id}")`.
- Also register `Get("/")`, `Post("/merge")`, `Patch("/{id}")` and `Delete("/{id}")` so route precedence is exercised.
- Mount under `/v1/media`.
5. Mint cookies via `token.Encode(map[string]any{"user_id": uint32(n), "token_version": ...})` as in other HTTP tests. Build a stale-token cookie with a lower `token_version`.
6. Seeding helper `seedGroup(images ...)` assigning ids in order, strictly increasing `CreatedAt` unless the case says otherwise, and `articleUsing(id, images ...)` building bodies that contain each image's stored name (`image.StoredName(path)`).
7. Compare JSON bodies by decoding, except where the case says "raw body" (null versus `[]`, null versus absent). Error bodies are plain text: compare to `message + "\n"`.
## Auth and role checks (table-driven)
8. For each row of the "refuse callers" outline (4 routes x no cookie, stale, unverified, role "editor", empty role), assert the status and body. Assert the settings, image and article fakes saw no call (only the user lookup of the middleware).
9. Owner and admin cookies: GET `/v1/media/purge-settings` and GET `/v1/media/purge-preview` return a status other than 401 and 403.
10. Allowlist guard: add a case proving a role other than `owner`/`admin` (for example "OWNER" in upper case, or "superuser") is refused with 403, so the middleware is not a blocklist.
## Settings
11. Defaults: empty repo, GET returns the exact default body. Assert the raw JSON has `"last_purge_at":null`, `"enabled_at":null` and `"next_purge_at":null` (not omitted).
12. PATCH on: assert `enabled_at` equals the fixed clock, `next_purge_at` = enabled_at + 30 days, the GET after returns the same body, and `Save` was called once.
13. next_purge_at basis (max rule): last_purge_at later than enabled_at counts from last_purge_at; enabled_at later than last_purge_at counts from enabled_at; no last_purge_at counts from enabled_at.
14. Re-enable step: seed on with `last_purge_at` 2026-09-01, PATCH `false`, then PATCH `true` at the fixed clock with interval 30. Assert `enabled_at` is the clock, `last_purge_at` is still 2026-09-01, and `next_purge_at` is 2026-11-04T09:00:00Z, strictly after the clock.
15. Interval-only PATCH keeps `enabled_at`. PATCH `true` while already on keeps `enabled_at`. PATCH `false` nulls `next_purge_at` and keeps last_purge_*.
16. Interval table (1, 30, 365 accepted). Invalid table (0, 366, -1 give `apperr.PurgeIntervalErrorMessage`; 1.5, "7", "yes", not json give `apperr.BodyErrorMessage`). Assert the repo state and the `Save` count are unchanged after each 400.
17. `{}` PATCH returns 200 equal to the current settings.
18. Use-case-level: `UpdatePurgeSettings` with interval 0 and 366 returns an error with `errors.Is(err, purge.ErrInvalidPurgeInterval)` and no `Save`. At the handler, a value of 0 must be rejected by the validate tag and never reach the use case.
19. Failure rows: `failGet` gives 500 "Internal server error\n"; `failSave` on PATCH gives 500.
20. Route precedence: PATCH `/v1/media/purge-settings` reaches the settings handler and does not touch `Rename`.
## Preview
21. Build each Background scenario with the seed helpers and assert the decoded `groups`, `copies`, `articles`, `list` and `more`:
- used group (keep 2 with used_in 2; copies 2; articles 2);
- tie-breaks: same CreatedAt gives the lowest id, different CreatedAt gives the oldest (assert both orders by swapping ids);
- a single used copy with unused siblings (articles 0);
- an all-unused group;
- no duplicates (raw `"list":[]`);
- empty-hash images ignored;
- an article touching two groups counts once;
- an article using kept plus a copy counts once;
- 7 groups give 5 list entries in ascending keep_id order and `more` 2. Each entry has `keep_id`, `filename`, `url`, `used_in`, `merged`.
22. Preview purity: call twice, assert equal bodies, assert zero `Delete`, `UpdateBody` and `Save` calls.
23. Fingerprint table: compute a baseline, apply each change from the outline, and assert equal or different. Assert it is a 64-char lowercase hex string and that two empty plans share it.
## Run
24. Full run scenario: assert the response body, the remaining images, the rewritten article bodies (check bytes), and `Save` called once with `last_purge_at` = clock, `last_purge_merged` = 3 and `last_purge_articles_updated` = 1, and that `auto_purge_*` and `enabled_at` are untouched. Article 12 uses only kept copies, so only article 10 is rewritten.
25. `articles_updated` source: it must come from the union of `MergeMediaResult.UpdatedArticleIDs` returned by the group merges, never from the plan. Prove it with the rolled-back case in step 29: the plan contains B's articles, the response does not.
26. Used-sibling run with zero article rewrites (`articles_updated` 0, `UpdateBody` never called).
27. Fingerprint: match gives 200. A stale fingerprint gives 409 with `apperr.PurgeConflictErrorMessage + "\n"`, no `Delete`, no `UpdateBody`, no `Save`. A garbage string gives 409 with the same body. No fingerprint, `{}` and an empty body all run.
28. Empty plan: 200 zeros, `Save` with `last_purge_at` = clock and zero counters, and a run with the empty plan's fingerprint is also 200.
29. Partial failure, rolled back: arm `UpdateBody` to fail for the second article of group B (plan order A, B, C). Assert the body counters, B's bodies restored byte for byte, all B images present, A and C merged, B's articles absent from `articles_updated`, and `Save` counters equal the response.
30. Partial failure, delete: arm `failDeleteFor` for one of group A's merged images and plan B too. Assert `failed_groups` 1, `groups_purged` 1, A contributes 0 to `merged` and none of its ids to `articles_updated` (only B counts), A's article bodies stay switched to the kept copy, and a following preview plans A again. Run the purge again with the failure disarmed and assert it converges (A's leftover copy is merged away). Then arm every `UpdateBody` and assert `groups_purged` 0, `failed_groups` = `groups_total` and `last_purge_at` still saved.
31. Concurrency: start run 1 in a goroutine, blocked in the image fake (wait on a "started" channel). Send run 2 (once with a fingerprint, once without). Assert both answer 409 with `apperr.PurgeBusyErrorMessage + "\n"` immediately (use a timeout) and no `Save`/`Delete` calls from them. Release run 1, assert its 200 body and that a later run is accepted. Run this test with `-race`.
32. Sentinels at the use-case level, asserted separately: a stale fingerprint gives `errors.Is(err, purge.ErrPurgeConflict)` true and `errors.Is(err, purge.ErrPurgeBusy)` false; a busy guard gives `ErrPurgeBusy` true and `ErrPurgeConflict` false. Call RunPurge without any HTTP package to prove it is reusable (that test file section imports no `net/http`).
33. Malformed body gives 400 `apperr.BodyErrorMessage`. `ListDuplicates` failure gives 500 and no `Save`.
34. Persist failure: arm `failSave` on the run-result save after merges succeed. Assert 500 "Internal server error\n", the merged images are still gone, the logger buffer contains the error text, then disarm and run again and assert 200 with no remaining groups (convergence from current data).
35. Preview versus run parity: the run's `groups_total`, `merged` and `articles_updated` equal the preview's `groups`, `copies` and `articles` on the same seed.
36. MergeMedia contract: extend the existing merge tests only by adding assertions. `Merged` and `ArticlesUpdated` are unchanged, `UpdatedArticleIDs` lists the rewritten ids in ascending order, and a rolled-back merge returns the error and an empty result. Existing callers and tests must compile and pass unchanged.
## Layering guards
37. `api/handlers/media` imports no `persistence`, `mailer`, `models` or `gorm` package. The new use cases import no `net/http`, chi, gorm, `persistence`, `mailer` or `api/...` package. `internal/domain/purge` imports only the stdlib and other `internal/domain/*` packages. Use a `go list -deps` or grep-style test as in media_library.qa.md.
## Manual verification (not exercised by `task test`)
- AutoMigrate: boot against a scratch SQLite file (`task dev`) and check the purge settings table exists, GET returns the defaults, repeated PATCH keeps one row, and values survive a restart. The mapper round-trips `last_purge_at`/`enabled_at` including null.
- Real purge against real uploads: upload duplicates through `POST /v1/media/`, reference them from articles, run the preview then the purge. The merged files disappear from `UPLOAD_DIR`, the kept file loads through `/static/uploadedImages/`, and article bodies are switched.
- Concurrent real requests with two shells (`curl` with the `jwt` cookie): the second gets 409 "A purge is already running." while the first runs.
- Role check with a real admin account (invite then accept). Use a hand-edited user row with an empty or unknown role to check the 403.
- The persist-failure log line appears in the server output (check by making the database read-only during a purge).
- Docs: `docs/architecture.md` lists the `purge` domain package, the four use cases, the settings repository, the new routes and `RequireOwnerOrAdmin`. Read it and compare. No SMTP and no env var are involved.
- Out of scope: the scheduler (part 2) and the SPA (part 3).
+286
View File
@@ -0,0 +1,286 @@
Feature: Duplicate images are purged automatically on the configured schedule
As a RenewCMS owner or admin
I want the duplicate purge to run by itself every few days once I turn it on
So that I do not have to remember to purge, and the Settings "Last purge" line stays current
Background:
Given part 1 provides purge.Settings, purge.Repository (Get, Save and SaveLastRun) and RunPurgeUseCase.Run(fingerprint *string)
And a new use case PurgeSchedulerUseCase in internal/infrastructure/useCases depends only on purge.Repository
and a RunPurge port, takes no HTTP types and imports no persistence, mailer, models, gorm, net/http, chi or api
And PurgeSchedulerUseCase.Tick(now time.Time) (result purge.RunResult, ran bool, err error) reads the settings on every call,
where result carries groups purged, copies merged and articles updated and is empty when ran is false
And a run is due when auto_purge_enabled is true, enabled_at is set and now is not before
Settings.NextPurgeAt(), that is max(last_purge_at, enabled_at) + auto_purge_interval_days days
And a scheduled run is RunPurge.Run(nil): no fingerprint, no HTTP, no user-visible message
And a run that finds nothing to purge still records last_purge_at with merged 0 and articles_updated 0,
so the anchor moves forward (this is RunPurge's existing behaviour)
And the scheduler never writes settings itself: only RunPurge records last_purge_*,
through Repository.SaveLastRun, which writes only the last_purge_* fields and never the whole settings row
And there is no env var: the schedule (auto_purge_enabled, auto_purge_interval_days, enabled_at,
last_purge_at) lives in the purge settings row, and is read again on every tick
And a runner in the new package internal/infrastructure/scheduler calls Tick on a ticker every 1 hour,
runs one Tick after a startup delay of 1 minute, and stops when its context is cancelled
And the runner takes its clock, its tick source and its startup delay by injection, so tests use a fake clock
and hand-fed channels, with no real sleeping and no real time.Ticker
And api/dependecyInjection.go builds the runner and cmd/server/main.go only starts it
And the runner uses the *log.Logger already used by RunPurge (log.Default() in the container)
Rule: When a run is due
Background:
Given the fake clock reads 2026-10-05T09:00:00Z
Scenario: Automatic purge off never runs, whatever the dates are
Given auto_purge_enabled is false, interval 1, enabled_at 2020-01-01T00:00:00Z and last_purge_at 2020-01-02T00:00:00Z
When Tick is called with the fake clock's time
Then ran is false and err is nil
And RunPurge was not called and Save was not called
Scenario: The defaults never run
Given nothing was ever saved, so the settings are the defaults
When Tick is called
Then ran is false and RunPurge was not called
Scenario: On, never ran, not due yet
Given auto_purge_enabled is true, interval 30, enabled_at 2026-09-20T09:00:00Z and no last_purge_at
When Tick is called
Then ran is false and RunPurge was not called
Scenario: On, never ran, due since enabled_at plus the interval
Given auto_purge_enabled is true, interval 10, enabled_at 2026-09-25T09:00:00Z and no last_purge_at
When Tick is called
Then RunPurge is called once with no fingerprint and ran is true
Scenario Outline: The anchor is the later of last_purge_at and enabled_at
Given auto_purge_enabled is true and interval 10
And enabled_at is <enabled_at> and last_purge_at is <last_purge_at>
When Tick is called at 2026-10-05T09:00:00Z
Then RunPurge is called <calls> time(s)
Examples:
| enabled_at | last_purge_at | calls | why |
| 2026-09-01T00:00:00Z | 2026-09-20T00:00:00Z | 1 | last purge + 10 days = 2026-09-30, due |
| 2026-09-01T00:00:00Z | 2026-09-28T00:00:00Z | 0 | last purge + 10 days = 2026-10-08, not due |
| 2026-09-28T00:00:00Z | 2026-09-01T00:00:00Z | 0 | enabled_at is later, 2026-10-08, not due |
| 2026-09-20T00:00:00Z | 2026-09-01T00:00:00Z | 1 | enabled_at is later, 2026-09-30, due |
| 2026-09-25T09:00:00Z | none | 1 | exactly now is due (2026-10-05T09:00:00Z) |
| 2026-09-25T09:00:01Z | none | 0 | one second before due |
Scenario: Due exactly at the boundary, not before
Given the next purge is 2026-10-05T09:00:00Z
When Tick is called at 2026-10-05T08:59:59Z
Then RunPurge is not called
When Tick is called at 2026-10-05T09:00:00Z
Then RunPurge is called once
Scenario: A run advances the anchor, so the next tick is not due
Given a due run with RunPurge backed by the fake settings repository
When Tick is called twice with the same time
Then RunPurge is called once
And last_purge_at equals the fake clock's time
Scenario: A due run that finds nothing to purge still moves the clock forward
Given a due schedule and a library with no planned group
When Tick is called
Then the stored last_purge_at is the clock's time, last_purge_merged is 0 and last_purge_articles_updated is 0
And a second Tick at the same time does not run
And a Tick after the interval has elapsed runs again
Scenario: A run keeps auto_purge_enabled, the interval and enabled_at
Given a due schedule
When Tick runs
Then auto_purge_enabled, auto_purge_interval_days and enabled_at are unchanged in the stored settings
Scenario: Enabled with no enabled_at is not due
Given auto_purge_enabled is true and enabled_at is unset
When Tick is called
Then RunPurge is not called and the scheduler does not stamp enabled_at itself
Rule: Settings changes take effect on the next tick, with no restart
Background:
Given the fake clock reads 2026-10-05T09:00:00Z
Scenario: Shortening the interval makes a run due on the next tick
Given auto_purge_enabled is true since 2026-09-25T09:00:00Z with interval 30 and no purge yet
And Tick did not run
When UpdatePurgeSettings sets auto_purge_interval_days to 7
And Tick is called again
Then RunPurge is called once
Scenario: Lengthening the interval postpones the run
Given auto_purge_enabled is true since 2026-09-20T09:00:00Z with interval 10 and no purge yet, so a run is due
When UpdatePurgeSettings sets auto_purge_interval_days to 30
And Tick is called
Then RunPurge is not called
Scenario: Turning it off stops runs
Given a due schedule
When UpdatePurgeSettings sets auto_purge_enabled to false
And Tick is called
Then RunPurge is not called
Scenario: Turning it on resets the anchor to the moment of the switch
Given auto_purge_enabled was turned off, last_purge_at is 2026-09-01T00:00:00Z, the interval is 30 and the clock reads 2026-10-05T09:00:00Z
When UpdatePurgeSettings sets auto_purge_enabled to true
And Tick is called at once
Then RunPurge is not called, because enabled_at is now
When Tick is called at 2026-11-04T09:00:00Z
Then RunPurge is called once
Scenario: The scheduler reads the settings at every tick
When Tick is called three times with different stored settings
Then Repository.Get is called three times and no value is cached between ticks
Rule: Failures never crash the loop and are retried at the next tick
Background:
Given a due schedule and the fake clock reads 2026-10-05T09:00:00Z
Scenario: Reading the settings fails
Given the purge settings repository's next Get fails
When Tick is called
Then ran is false and err is the repository error
And RunPurge was not called
And the next Tick, with Get working again, runs the purge
Scenario: RunPurge returns an error
Given RunPurge fails, for example ListDuplicates fails
When Tick is called
Then err is that error and last_purge_at is unchanged
And RunPurge was called once, not retried inside the same Tick
And the next Tick, with the failure cleared, runs the purge
Scenario: Saving the run result fails after the merges succeeded
Given the purge settings repository's SaveLastRun fails
When Tick is called
Then err is returned and the merges stay done
And the next Tick plans again from current data and converges
Scenario: A run that partially fails still records what it merged
Given group A merges and group B's UpdateBody fails
When Tick is called
Then ran is true and err is nil
And last_purge_at is set and last_purge_merged and last_purge_articles_updated count only group A
And group B is planned again by the next run
Scenario: A settings change made during a run survives
Given a run is in progress and about to record its result
When UpdatePurgeSettings turns auto_purge_enabled off and sets the interval to 5 meanwhile
And the run completes
Then the stored auto_purge_enabled is false and the interval is 5
And last_purge_at, last_purge_merged and last_purge_articles_updated hold the run's values
Scenario: A manual run is in progress
Given a manual POST /v1/media/purge holds the RunPurge guard
When Tick is called with a due schedule
Then the scheduler does not queue or wait: it returns at once with errors.Is(err, purge.ErrPurgeBusy)
And nothing is saved by the scheduler
And the runner treats it as a skip, not a failure, and the next tick re-evaluates
When the manual run finishes and records last_purge_at
Then the next Tick finds the schedule not due and does not run
Scenario: A failing tick does not stop the runner
Given the runner is running with a hand-fed tick channel
And the first Tick returns an error and the second returns ran true
When two ticks are fed
Then the error is written to the injected logger
And the runner is still running and the second Tick was executed
And no Tick is called between the two fed ticks (no tight retry loop)
Scenario: A completed run logs one line with the counts
Given Tick returns ran true with groups purged 3, merged 5, articles updated 2 and failed groups 1
When the runner receives the tick
Then exactly one line is logged: "scheduler: automatic purge completed: groups_purged=3 merged=5 articles_updated=2 failed_groups=1"
And a run that found nothing to purge logs the same line with every count at 0
Scenario: A due Tick returns what the run did
Given a due schedule and one planned group
When Tick is called
Then ran is true and the result holds the run's groups purged, merged and articles updated
And a Tick that is not due returns an empty result
Scenario: A busy skip is not logged as an error
Given Tick returns ErrPurgeBusy
When the runner receives the tick
Then it logs no error line and keeps waiting for the next tick
Rule: The runner starts, ticks and stops with the server
Scenario: One Tick shortly after startup
Given a runner with a hand-fed startup signal standing in for the 1 minute delay
When the startup signal fires
Then Tick is called once with the fake clock's time
And no Tick is called before the signal
Scenario: A due run is caught up at startup after downtime
Given the server was down for 40 days, the interval is 30 and last_purge_at is 40 days old
When the runner's startup Tick fires
Then RunPurge is called once, and only once however many ticks were missed
Scenario: A Tick on each hourly tick
Given the runner is running
When three hourly ticks are fed
Then Tick is called three times, each with the fake clock's time at that tick
Scenario: Cancelling the context stops the runner
Given the runner is idle
When its context is cancelled
Then Run returns promptly
And no further Tick is called even if a tick is fed after cancel
Scenario: Shutdown waits for a run in progress without a time limit
Given Tick is blocked inside RunPurge (fake image repository blocked on a channel)
When the context is cancelled
Then Run does not return yet
When the blocked run is released and completes
Then Run returns after the run has recorded last_purge_at
And the database is closed only after Run has returned
Scenario: Cancelled before the startup signal
Given the context is cancelled before the startup signal
Then Tick is never called and Run returns
Rule: The Settings page shows the result (HTTP observable, no SPA change)
Scenario: A scheduled run updates the Last purge values served by the settings endpoint
Given an owner, automatic purge on with interval 7, enabled_at 2026-09-20T09:00:00Z and a planned group
And the fake clock reads 2026-10-05T09:00:00Z
When Tick runs
And the client sends GET /v1/media/purge-settings with a valid cookie of the owner
Then "last_purge_at" is "2026-10-05T09:00:00Z"
And "last_purge_merged" and "last_purge_articles_updated" hold the run's counts
And "next_purge_at" is "2026-10-12T09:00:00Z"
And "auto_purge_enabled", "auto_purge_interval_days" and "enabled_at" are unchanged
Scenario: A scheduled run with nothing to purge shows zeros, not "Never run"
Given automatic purge on and due and no planned group
When Tick runs and the client sends GET /v1/media/purge-settings
Then "last_purge_at" is "2026-10-05T09:00:00Z", "last_purge_merged" is 0 and "last_purge_articles_updated" is 0
Scenario: A scheduled run answers no HTTP request and returns no message
When Tick runs
Then no HTTP handler is involved and the only observable results are the stored settings, the images, the article bodies and a log line
Rule: Layering and documentation
Scenario: The scheduler stays inside the domain boundary
Then PurgeSchedulerUseCase imports only the stdlib, internal/domain/* and package-local use case types
And internal/infrastructure/scheduler imports no persistence, mailer, models or gorm package
And only api/dependecyInjection.go constructs it and only cmd/server/main.go starts it
Scenario: The docs describe the scheduler and state that there is no env var
Then docs/architecture.md describes the scheduler, the hourly tick, the startup tick, the due rule, the overlap guard and the unbounded shutdown wait
And docs/configuration.md states that automatic purge has no env var and that its schedule lives in the database
And no new Go, TS, docs or config file contains a comment
@manual
Rule: Wiring verified by hand (see the qa.md)
Scenario: The runner starts with the real server and stops on SIGINT or SIGTERM
Given the server runs and automatic purge is on and due
When one minute elapses after startup
Then a purge ran and the Settings "Last purge" line shows the new date on the next visit
When SIGTERM is sent during a run
Then the process exits only after the run finished, and the SQLite file is not corrupted
+56
View File
@@ -0,0 +1,56 @@
# QA procedure: media_purge_scheduler
Package: `test` (black-box). New file `test/media_purge_scheduler_test.go`. Go only: no SPA. No env var. Fake type names must be unique in the package. Reuse part 1's `mockPurgeSettingsRepo`, `mockImageRepo`, `mockArticleRepo`, `seedGroup` and `articleUsing` from test/media_purge_test.go and test/media_purge_http_test.go. No real sleeping, no `time.Ticker`, no `time.Sleep`, no live server, no real SQLite file.
## Setup shared by every case
1. `validator.InitValidator()` once (only needed by the HTTP cases).
2. Fakes:
- `fakeClock` (mutable, `Now()` and `Set(t)`), starting at 2026-10-05T09:00:00Z. Pass `clock.Now` as the `now func() time.Time` of RunPurge and UpdatePurgeSettings so last_purge_at and enabled_at come from it.
- `mockPurgeSettingsRepo` with `Get`, `Save` and `SaveLastRun` (writes only the last_purge_* fields), failure switches for each, a write counter shared by Save and SaveLastRun, and a hook called before SaveLastRun writes.
- A `fakePurgeRunPort` wrapping the real `RunPurgeUseCase`. It counts calls, records the fingerprint argument (must be nil), and can be armed to return an error. A second variant uses the real RunPurge directly for the integration cases.
- Hand-fed `tickCh` and `startCh` channels passed to the runner in `internal/infrastructure/scheduler` in place of time.NewTicker and the startup delay. Use a `bytes.Buffer` logger.
3. Wire the real `PurgeSchedulerUseCase` over the settings fake, the run port and the clock.
## Tick decisions (table-driven)
4. Off never runs: off with ancient dates; the defaults; assert ran false, err nil, zero RunPurge calls, zero Save calls.
5. Not due, due, boundary: enabled_at + interval one second after now gives no run; exactly now gives a run; assert the fingerprint argument is nil.
6. Anchor table: the six rows of the outline. Cross-check each row against `Settings.NextPurgeAt()` to prove the scheduler reuses it.
7. Enabled with unset enabled_at never runs and the scheduler never stamps it.
8. Repeat the same Tick twice with the real RunPurge: one run (anchor moved), and `last_purge_at` equals the clock. Advance the clock past the interval: a third Tick runs.
9. Empty library: due Tick records `last_purge_at` = clock, merged 0, articles_updated 0, and leaves auto_purge_enabled, interval and enabled_at untouched.
10. Settings changes: shorten the interval (via `UpdatePurgeSettings` on the same fake) and a run becomes due; lengthen it and a due run is postponed; toggle off and no run; toggle off then on gives enabled_at = clock, no run now, a run once the clock passes +30 days. Assert `Get` is called once per Tick.
## Failures
11. `failGet`: Tick returns the error, RunPurge count 0, the next Tick with the fake healed runs.
12. RunPurge error (`ListDuplicates` failure): error returned, `last_purge_at` unchanged, one RunPurge call per Tick, recovery on the next Tick.
13. `SaveLastRun` failure after merges: error returned, merged images still gone, next Tick converges.
14. Partial failure (UpdateBody failing for group B): ran true, err nil, `last_purge_at` set, counters count group A only.
15. Busy: block a manual `RunPurge.Run(nil)` inside the image fake (started channel). Call Tick with a due schedule and assert it returns promptly (use a timeout) with `errors.Is(err, purge.ErrPurgeBusy)`, with no `Save` from the scheduler. Release; the next Tick is not due because the manual run recorded `last_purge_at`. Run with `-race`.
## Runner
16. Feed a startup signal: exactly one Tick, with the clock's time; no Tick before the signal.
17. Feed three ticks: three Ticks. Feed none: no Tick. Assert no Tick between fed ticks.
18. Error then success from Tick: the error text is in the logger buffer, the loop continues, the second Tick executes, and `Run` has not returned.
19. Busy from Tick: no error line in the logger; the loop continues.
19b. Completed run: Tick returns ran true with a known `purge.RunResult`; the logger buffer holds exactly one line with groups_purged, merged, articles_updated and failed_groups. An empty result logs zeros.
19c. Tick result: a due Tick returns the run result; a Tick that is not due returns the empty result.
19d. Lost update: block the settings fake just before `SaveLastRun` writes, PATCH auto_purge_enabled off and the interval to 5 through `UpdatePurgeSettings`, release; the stored auto_purge_* survive and last_purge_* are the run's. The persistence repository test checks that `SaveLastRun` leaves the other columns untouched and creates the default row when none exists.
20. Cancel the context while idle: `Run` returns within a timeout; then feed a tick and assert no Tick happens. Cancel before the startup signal: no Tick at all.
21. Shutdown waits without a bound: block RunPurge in the image fake, cancel the context, assert `Run` has not returned (select with a short timeout), release, assert `Run` returns and `last_purge_at` was recorded. `-race`.
22. The runner and the use case spawn no goroutine that outlives `Run` (verify with a done channel closed on return).
## HTTP-observable check (existing settings endpoint)
23. Mount the part 1 media router (same setup as media_purge.qa.md) over the same fakes. Seed on, interval 7, enabled_at 2026-09-20T09:00:00Z, one planned group. Tick, then GET `/v1/media/purge-settings` with the owner cookie. Assert `last_purge_at` 2026-10-05T09:00:00Z, counts equal the run, `next_purge_at` 2026-10-12T09:00:00Z, and the other fields unchanged. Repeat with an empty library: zeros and a non-null `last_purge_at`.
## Layering guards
24. The scheduler use case imports no `net/http`, chi, gorm, `persistence`, `mailer` or `api/...`. Same grep-style test as media_purge.qa.md. `internal/infrastructure/scheduler` imports no `persistence`, `mailer`, `models` or `gorm`, and no `api/...`.
25. Grep the new and edited files (Go, docs) for comments: none; only tool directives.
## Docs
26. `docs/architecture.md` has a scheduler paragraph (hourly tick, startup tick, due rule from `NextPurgeAt`, overlap guard from RunPurge, unbounded shutdown wait, failures retried at the next tick). `docs/configuration.md` states that there is no env var and that the schedule lives in the database. Read both files and confirm.
## Manual (not automated)
27. Real boot with `task dev:api` or the built binary: turn automatic purge on, set interval 1 and backdate `enabled_at` in the SQLite file (or wait); about a minute after start, a purge runs, `GET /v1/media/purge-settings` shows the new `last_purge_at`, and Settings "Last purge" is updated on the next visit.
28. Send SIGINT or SIGTERM during a long run: the process exits only after the run ended, with no database error on close.
29. A scheduled run and a manual "Purge now" at the same moment: one wins, the other answers 409 busy (HTTP) or is skipped (scheduler).
30. Docker: the same behaviour in the container, with state on /data.
+160
View File
@@ -0,0 +1,160 @@
Feature: Optional MySQL/MariaDB database driver
An operator who cannot use a local SQLite file runs RenewCMS against MySQL or
MariaDB by setting environment variables. SQLite remains the default and
behaves exactly as before.
Background:
Given the database drivers are registered by name in database.drivers
And the database is chosen by the DB_DRIVER environment variable
And repositories, use cases and handlers are unchanged
Scenario Outline: Driver selection
Given DB_DRIVER is "<value>"
When the server starts
Then the outcome is "<outcome>"
Examples:
| value | outcome |
| unset | the sqlite driver opens DB_FILE |
| sqlite | the sqlite driver opens DB_FILE |
| mysql | the mysql driver opens the DB_HOST connection |
| oracle | startup fails with: unsupported database driver "oracle" |
Scenario Outline: Missing MySQL variable
Given DB_DRIVER is "mysql"
And "<missing>" is not set
When the environment is validated
Then startup fails with "Environment variable <missing> is not set"
Examples:
| missing |
| DB_HOST |
| DB_USER |
| DB_PASSWORD |
| DB_NAME |
Scenario: SQLite does not require MySQL variables
Given DB_DRIVER is "sqlite"
And DB_HOST, DB_USER, DB_PASSWORD and DB_NAME are not set
When the environment is validated
Then validation succeeds
Scenario Outline: DSN built from variables
Given DB_HOST "<host>", DB_PORT "<port>", DB_USER "app", DB_NAME "cms", DB_TLS "<tls>"
When the MySQL DSN is built
Then the DSN targets "<host>:<expectedPort>" and database "cms"
And it contains parseTime=true, loc=UTC, charset=utf8mb4, clientFoundRows=true
And it contains tls="<tls>"
Examples:
| host | port | expectedPort | tls |
| db.local | | 3306 | false |
| db.local | 3307 | 3307 | true |
| mysql | 3306 | 3306 | skip-verify |
Scenario: Invalid TLS option is rejected
Given DB_DRIVER is "mysql" and DB_TLS is "maybe"
When the settings are read
Then startup fails naming DB_TLS and the accepted values
Scenario Outline: Fail fast when the server cannot be used
Given DB_DRIVER is "mysql" and "<fault>"
When the server starts
Then startup stops with "Unable to open the database"
And the message names the host and port
And the message does not contain the DB_PASSWORD value
Examples:
| fault |
| nothing listens on DB_PORT |
| the password is wrong |
| the database does not exist |
Scenario: Data directory with MySQL
Given DB_DRIVER is "mysql" and DATA_DIR is "/data"
Then update state, staging and backups live in "/data"
And DB_FILE is ignored
Scenario Outline: DataDir resolution
Given DB_FILE "<dbfile>" and DATA_DIR "<datadir>"
Then Settings.DataDir() is "<result>"
Examples:
| dbfile | datadir | result |
| ./data/renewcms.db | | ./data |
| /var/lib/cms/cms.db | | /var/lib/cms |
| ./data/renewcms.db | /srv/x | /srv/x |
Scenario: Connection pool
Given DB_MAX_OPEN_CONNS 4, DB_MAX_IDLE_CONNS 2 and DB_CONN_MAX_LIFETIME_SECONDS 60
When the MySQL connection is opened
Then the pool limits are 4 open, 2 idle and 60 seconds
Scenario: Migration on MySQL
Given an empty MySQL database
When database.Migrate runs
Then the tables articles, images, users, invites, api_keys and purge_settings exist
And the unique indexes on users and api_keys exist
And the first user lacking a role becomes "owner" and the others "admin"
Scenario: SQLite schema is untouched by the model changes
Given a SQLite file created by the models before this change, holding rows
When database.Migrate runs
Then the schema and every row are unchanged
Scenario Outline: Repository behaviour is the same on both drivers
Given the "<driver>" driver
Then the repository contract "<case>" holds
Examples:
| driver | case |
| sqlite | create user and read it back with its id and timestamps |
| mysql | create user and read it back with its id and timestamps |
| sqlite | username "Bob" and "bob" are distinct users |
| mysql | username "Bob" and "bob" are distinct users |
| sqlite | duplicate email returns an error and stores nothing |
| mysql | duplicate email returns an error and stores nothing |
| sqlite | unknown id returns domain ErrUserNotFound |
| mysql | unknown id returns domain ErrUserNotFound |
| sqlite | accept invite twice: the second returns ErrInviteNotFound |
| mysql | accept invite twice: the second returns ErrInviteNotFound |
| sqlite | accept invite with a taken username leaves the invite unaccepted |
| mysql | accept invite with a taken username leaves the invite unaccepted |
| sqlite | renew invite with unchanged values still succeeds |
| mysql | renew invite with unchanged values still succeeds |
| sqlite | duplicate image hashes are grouped and counted |
| mysql | duplicate image hashes are grouped and counted |
| sqlite | media search treats % and _ literally |
| mysql | media search treats % and _ literally |
| sqlite | article body of 1 MB round-trips |
| mysql | article body of 1 MB round-trips |
| sqlite | zero reset expiration round-trips as a zero time |
| mysql | zero reset expiration round-trips as a zero time |
| sqlite | purge settings save, then SaveLastRun with identical values succeeds |
| mysql | purge settings save, then SaveLastRun with identical values succeeds |
Scenario Outline: Backup before an update
Given the "<driver>" driver with a populated database
When the pre-update backup runs
Then a file "<pattern>" exists in DATA_DIR/backups
And only the 3 newest backups are kept
And a failure returns an error, so the update download fails instead of claiming a backup
Examples:
| driver | pattern |
| sqlite | renewcms-<time>.db |
| mysql | renewcms-<time>.sql |
Scenario: MySQL backup content
Given a MySQL database with 2 users and 1 article
When the pre-update backup runs
Then the dump contains the schema and the rows of every table
And it does not contain the DB_PASSWORD value
And replaying it into an empty database restores the same rows
Scenario: Secrets are never exposed
Given DB_DRIVER is "mysql"
Then no log line, error message or file in the repository contains the DB_PASSWORD value
Scenario: Static builds
Then the module builds with CGO_ENABLED=0 for the 6 task release targets
+42
View File
@@ -0,0 +1,42 @@
# QA: MySQL/MariaDB database driver
Backend only. Go tests in test/, black-box, no live server by default.
Decisions: separate DB_* vars, new DATA_DIR (DB_FILE ignored under mysql), pure-Go logical dump backup, `task test:mysql` against mysql:8.4 and mariadb:11, SQLite-to-MySQL data migration documented as manual export/import only.
## Automated, no server (task test)
1. Registry: Connect with Driver "sqlite" works against a temp file; Driver "oracle" returns `unsupported database driver "oracle"`; the registry contains sqlite and mysql.
2. Env validation: table-driven over the Driver selection and Missing MySQL variable scenarios. SQLite needs no DB_* vars. DB_DRIVER defaults to sqlite. An invalid DB_TLS or a non-numeric pool var returns an error naming the variable.
3. DSN builder: table-driven over the DSN scenario. Assert host:port, database, parseTime, loc=UTC, charset=utf8mb4, clientFoundRows, tls. Assert that the redacted form used in errors never contains the password.
4. Settings.DataDir: table-driven (DB_FILE only, DATA_DIR overrides).
5. Fail fast without a server: point mysql at a closed local port with a 1s timeout. Assert the error mentions the host and port, and not the password.
6. Pool settings: assert sql.DB stats MaxOpenConnections via a stub opener, or via the real mysql test below.
7. SQLite regression: build a database file using the pre-change schema (a fixture created with the old model definitions), run Migrate, and assert that the schema (sqlite_master) and the rows are unchanged. The existing suite must pass unchanged.
8. Backup (SQLite): existing tests unchanged.
9. Dump writer: unit-test the SQL dump formatter with a fake row source (quoting, NULL, time, bool, binary-safe strings, 3-file retention, existing-file handling, failure leaves no partial file).
## Automated, real server (task test:mysql)
Setup: the task runs `docker run` for mysql:8.4 and, in a second pass, mariadb:11, waits for readiness, exports RENEWCMS_TEST_MYSQL_DSN, runs `go test -tags nofrontend ./test/... -run MySQL -count=1`, then removes the container. Tests call t.Skip when the var is unset, so `task test` skips them. The repository tests are parameterised over both drivers (SQLite temp file and the MySQL DSN, each with a fresh database), and run the "Repository behaviour is the same on both drivers" Examples.
10. Migrate on an empty database; table and index existence via information_schema; the backfill role rules.
11. User create/read, case-sensitive username and email, duplicate email, ErrUserNotFound.
12. Invite accept twice, accept with a taken username (invite stays unaccepted), renew with unchanged values.
13. Image duplicates grouping and counts, search with % and _, filename backfill listing.
14. Article body 1 MB, title lookup, online filter.
15. API key unique hashed_key, revoke, last_used.
16. Zero-time round trip, UTC times.
17. Purge settings upsert.
18. Backup: run on a populated MySQL database, assert the .sql file, the 3-file retention, a replay into a second empty database giving identical rows, and that the file contains no password.
19. Wrong password and missing database fail with the "Unable to open the database" prefix and no password in the message.
## Build and lint
20. `task lint:go` and `task test` pass. `task test:mysql` passes on MySQL 8.4 and MariaDB 11 (record both; NOT RUN if Docker is unavailable).
21. `CGO_ENABLED=0 go build -tags nofrontend ./...` passes. Cross-compile the 6 `task release` targets (e.g. `GOOS=linux GOARCH=arm64`) with CGO_ENABLED=0.
22. `task mutate` impact: none, because it only targets useCases. No change.
23. No comment lines in the added Go files (the hook enforces this).
## Manual (needs Compose and real SMTP)
24. docker-compose.mysql.yml example: renewcms plus a mysql service with a named volume and a healthcheck, `depends_on: condition: service_healthy`, DB_DRIVER=mysql, DB_HOST=mysql, DATA_DIR=/data. `docker compose up`: /healthz returns 200, register, log in, create an article, restart the stack, and the data persists.
25. Stop the mysql container and start renewcms: it exits with a clear error and no password in the logs. docker.env.example holds placeholders only.
26. In the Docker image (non-root uid 10001), trigger the backup path and confirm the .sql file lands under /data/backups.
27. Docs: configuration.md, docker.md, deployment.md, `.env.example`, `docker.env.example` and the architecture.md "Storage and database seams" section list the new vars and the manual SQLite to MySQL export/import note.
28. Verify the SQLite default end to end with no new vars set: same behaviour as before.
+153
View File
@@ -0,0 +1,153 @@
Feature: Optional PostgreSQL database driver
An operator who prefers PostgreSQL runs RenewCMS against it by setting
environment variables. SQLite stays the default, and MySQL/MariaDB behaves
exactly as before.
Background:
Given the database drivers are registered by name in database.drivers
And the database is chosen by the DB_DRIVER environment variable
And repositories, use cases and handlers are unchanged
Scenario Outline: Driver selection
Given DB_DRIVER is "<value>"
When the server starts
Then the outcome is "<outcome>"
Examples:
| value | outcome |
| unset | the sqlite driver opens DB_FILE |
| sqlite | the sqlite driver opens DB_FILE |
| mysql | the mysql driver opens the DB_HOST connection |
| postgres | the postgres driver opens the DB_HOST connection |
| postgresql | the postgres driver opens the DB_HOST connection |
| oracle | startup fails with: unsupported database driver "oracle" |
Scenario Outline: Missing PostgreSQL variable
Given DB_DRIVER is "postgres"
And "<missing>" is not set
When the environment is validated
Then startup fails with "Environment variable <missing> is not set"
Examples:
| missing |
| DB_HOST |
| DB_USER |
| DB_PASSWORD |
| DB_NAME |
Scenario: SQLite and MySQL do not require PostgreSQL-only variables
Given DB_DRIVER is "sqlite" or "mysql"
And DB_SSLMODE is "maybe"
When the settings are read
Then DB_SSLMODE is ignored and validation succeeds
Scenario Outline: Connection string built from variables
Given DB_HOST "<host>", DB_PORT "<port>", DB_USER "app", DB_NAME "cms", DB_SSLMODE "<sslmode>"
When the PostgreSQL DSN is built
Then the DSN targets "<host>:<expectedPort>" and database "cms"
And it sets sslmode "<expectedSslmode>"
And it sets the session time zone to UTC
And a password containing spaces, quotes, "@" or "/" is escaped so that parsing the DSN returns it unchanged
Examples:
| host | port | expectedPort | sslmode | expectedSslmode |
| db.local | | 5432 | | disable |
| db.local | 5433 | 5433 | require | require |
| pg | 5432 | 5432 | verify-full | verify-full |
Scenario: Invalid SSL mode is rejected
Given DB_DRIVER is "postgres" and DB_SSLMODE is "maybe"
When the settings are read
Then startup fails naming DB_SSLMODE and the accepted values
Scenario Outline: Fail fast when the server cannot be used
Given DB_DRIVER is "postgres" and "<fault>"
When the server starts
Then startup stops with "Unable to open the database"
And the message names the host and port
And the message does not contain the DB_PASSWORD value
Examples:
| fault |
| nothing listens on DB_PORT |
| the password is wrong |
| the database does not exist |
| the server requires TLS and DB_SSLMODE is disable |
Scenario: Data directory with PostgreSQL
Given DB_DRIVER is "postgres" and DATA_DIR is "/data"
Then update state, staging and backups live in "/data"
And DB_FILE is ignored
Scenario: Connection pool
Given DB_MAX_OPEN_CONNS 4, DB_MAX_IDLE_CONNS 2 and DB_CONN_MAX_LIFETIME_SECONDS 60
When the PostgreSQL connection is opened
Then the pool limits are 4 open, 2 idle and 60 seconds
Scenario: Migration on PostgreSQL
Given an empty PostgreSQL database
When database.Migrate runs
Then the tables articles, images, users, invites, api_keys and purge_settings exist in schema "public"
And the unique indexes on users and api_keys exist
And the first user lacking a role becomes "owner" and the others "admin"
And running database.Migrate a second time changes nothing
Scenario: Existing drivers are untouched by the PostgreSQL addition
Given a SQLite file holding rows, and a MySQL database holding rows
When database.Migrate runs on each
Then the schema and every row are unchanged on both
Scenario Outline: Repository behaviour is the same on every driver
Given the "<driver>" driver
Then the repository contract "<case>" holds
Examples:
| driver | case |
| postgres | create user and read it back with its id and timestamps |
| postgres | username "Bob" and "bob" are distinct users |
| postgres | email "A@x.io" and "a@x.io" follow the same rule as on sqlite |
| postgres | duplicate email returns an error and stores nothing |
| postgres | the failed duplicate insert leaves the connection usable |
| postgres | unknown id returns domain ErrUserNotFound |
| postgres | accept invite twice: the second returns ErrInviteNotFound |
| postgres | accept invite with a taken username leaves the invite unaccepted |
| postgres | renew invite with unchanged values still succeeds |
| postgres | duplicate image hashes are grouped and counted |
| postgres | media search treats % and _ literally |
| postgres | media search is case-sensitive exactly as on sqlite |
| postgres | article body of 1 MB round-trips |
| postgres | article title with emoji and accents round-trips |
| postgres | zero reset expiration round-trips as a zero time |
| postgres | stored times read back in UTC |
| postgres | purge settings save, then SaveLastRun with identical values succeeds |
| postgres | api key unique hashed_key, revoke and last_used |
| postgres | offline article reads as not found publicly, online article is listed |
| postgres | listing order and pagination match sqlite for the same data |
Scenario Outline: Backup before an update
Given the "<driver>" driver with a populated database
When the pre-update backup runs
Then a file "<pattern>" exists in DATA_DIR/backups
And only the 3 newest backups are kept
And a failure returns an error, so the update download fails instead of claiming a backup
Examples:
| driver | pattern |
| sqlite | renewcms-<time>.db |
| mysql | renewcms-<time>.sql |
| postgres | renewcms-<time>.sql |
Scenario: PostgreSQL backup content
Given a PostgreSQL database with 2 users and 1 article
When the pre-update backup runs
Then the dump contains the schema, indexes and the rows of every table
And it does not contain the DB_PASSWORD value
And replaying it into an empty database restores the same rows
And sequences are reset so the next insert gets an unused id
Scenario: Secrets are never exposed
Given DB_DRIVER is "postgres"
Then no log line, error message or file in the repository contains the DB_PASSWORD value
Scenario: Static builds
Then the module builds with CGO_ENABLED=0 for the 6 task release targets
+43
View File
@@ -0,0 +1,43 @@
# QA: PostgreSQL database driver
Backend only. Go tests in test/, black-box, no live server by default.
Decisions: DB_DRIVER=postgres (alias postgresql), gorm.io/driver/postgres (pgx, pure Go), shared DB_* vars plus DB_SSLMODE, pure-Go logical dump backup, `task test:postgres` against postgres:16 and postgres:17, SQLite or MySQL to PostgreSQL migration is a documented manual export/import only.
## Automated, no server (task test)
1. Registry: contains sqlite, mysql and postgres. Alias "postgresql" resolves to postgres. "oracle" returns `unsupported database driver "oracle"`.
2. Env validation: table-driven over Driver selection and Missing PostgreSQL variable. Invalid DB_SSLMODE or a non-numeric pool var returns an error naming the variable. DB_SSLMODE is ignored under sqlite and mysql. MySQL validation unchanged.
3. DSN builder: table-driven over the DSN scenario. Parse the output with the pgx config parser and assert host, port, database, sslmode and the UTC time zone. Round-trip a password such as `s3cr3t/Value@42:x 'q'`. The form used in errors never contains the password.
4. Settings.DataDir under postgres: DATA_DIR wins, DB_FILE ignored.
5. Fail fast without a server: closed 127.0.0.1 port, 1s connect timeout. Error has the "Unable to open the database" prefix, mentions host and port, not the password.
6. Pool settings via a stub opener or the real test below.
7. Regression: the existing SQLite and MySQL tests pass unchanged. A pre-change SQLite fixture is unchanged after Migrate.
8. Dump writer: reuse the existing formatter tests. Add PostgreSQL value formatting with a fake row source: bool as true/false, bytea hex escapes, timestamptz in UTC, NULL, identifier and string quoting.
9. Migrate dialect handling: no MySQL table options are applied under postgres (tableOptionsFor).
## Automated, real server (task test:postgres)
Setup: the task runs `docker run` for postgres:16 then postgres:17, waits with pg_isready, exports RENEWCMS_TEST_POSTGRES_DSN, runs `go test -tags nofrontend ./test/... -run Postgres -count=1`, then removes the container. Tests t.Skip when the variable is unset. Repository contract tests are parameterised over sqlite, mysql and postgres with a fresh database each.
10. Migrate on an empty database: tables and indexes via information_schema and pg_indexes, role backfill, idempotent second run.
11. User create/read, case-sensitive username and email, duplicate email, ErrUserNotFound. After the unique violation the next query on the same pool succeeds.
12. Invite accept twice, accept with a taken username (invite stays unaccepted), renew with unchanged values.
13. Image duplicates grouping and counts (GROUP BY strictness), search with % and _ and its case rule, filename backfill listing.
14. Article body 1 MB, unicode title, title lookup, online filter, listing order and pagination parity with sqlite.
15. API key unique hashed_key, revoke, last_used.
16. Zero-time round trip, UTC times.
17. Purge settings upsert.
18. Backup on a populated database: .sql file, 3-file retention, replay into a second empty database gives identical rows, no password in the file, next insert after replay gets a fresh id.
19. Wrong password, missing database and TLS-required-but-disabled fail with the "Unable to open the database" prefix and no password.
## Build and lint
20. `task lint:go` and `task test` pass. `task test:postgres` passes on 16 and 17 (record both; NOT RUN if Docker is unavailable).
21. `CGO_ENABLED=0 go build -tags nofrontend ./...` passes. Cross-compile the 6 `task release` targets.
22. `task mutate` impact: none (useCases only).
23. No comment lines in the added Go files.
24. Layering: `go list -deps` on useCases and domain shows no pgx or gorm. Only api/dependecyInjection.go builds the postgres config.
## Manual (needs Compose and real SMTP)
25. docker-compose.postgres.yml: renewcms plus a postgres service with a named volume and a pg_isready healthcheck, depends_on service_healthy, DB_DRIVER=postgres, DB_HOST=postgres, DATA_DIR=/data. `docker compose up`: /healthz 200, register, log in, create an article, restart the stack, data persists.
26. Stop the postgres container and start renewcms: clear error, no password in the logs.
27. In the Docker image (uid 10001), trigger the backup path and confirm the .sql file lands under /data/backups.
28. Docs: .env.example, docker.env.example, docs/configuration.md, docs/docker.md, docs/deployment.md, docs/development.md and the docs/architecture.md "Storage and database seams" section list DB_DRIVER=postgres, DB_SSLMODE and the manual migration note. DB_SSLMODE goes into cmd/server/main.go envDefaults.
29. SQLite default end to end with no new vars set behaves as before.
+143
View File
@@ -0,0 +1,143 @@
Feature: Uploaded images can be stored in an S3-compatible bucket
As an operator whose host disk is ephemeral or who runs several hosts
I want to set STORAGE_DRIVER=s3 and bucket settings
So that uploads live in the bucket while images are still served by the app
at /static/uploadedImages/<name>, with unchanged URLs
Background:
Given STORAGE_DRIVER defaults to "local" and local disk behaves exactly as today
And an S3 storage implements image.Storage (Put, Open, Delete) in internal/infrastructure/storage
And storage.Open has a case for the driver "s3" and openImageStorage selects it from the settings
And no domain, use case, repository or staticimages handler file changes
And the bucket is faked by an in-process httptest S3 server listening on 127.0.0.1
And Open returns a Blob whose Content is an io.ReadSeeker, so http.ServeContent handles Range and conditional requests
And the Blob ModTime and Size come from the S3 object metadata
And no ETag is emitted and conditional requests are validated by Last-Modified only
Scenario Outline: Startup configuration validation
Given STORAGE_DRIVER is <driver> and the S3 variables are <vars>
When the storage settings are validated
Then the result is <result>
Examples:
| driver | vars | result |
| (unset) | none | driver local, no S3 variable required |
| local | S3_BUCKET set | driver local, S3 variables ignored |
| s3 | S3_BUCKET and S3_REGION set | valid, default AWS credential chain |
| s3 | S3_BUCKET set, S3_REGION unset | region defaults to us-east-1 |
| s3 | S3_REGION set, S3_BUCKET unset | error "Environment variable S3_BUCKET is not set" |
| s3 | key id set, secret unset | error naming S3_SECRET_ACCESS_KEY |
| s3 | secret set, key id unset | error naming S3_ACCESS_KEY_ID |
| s3 | UPLOAD_DIR set | valid, UPLOAD_DIR ignored without error |
| gcs | none | error unsupported storage driver "gcs" |
Scenario: The registry opens the S3 storage after a bucket check
Given the fake S3 answers HEAD bucket with 200
When storage.Open is called with driver "s3"
Then it returns a storage and no error
Scenario Outline: Startup fails fast with a clear secret-free error
Given the fake S3 answers HEAD bucket with <status>
When storage.Open is called with driver "s3" and secret access key "TOPSECRET"
Then it returns an error mentioning the bucket name
And the error text does not contain "TOPSECRET"
And openImageStorage panics with that same text
Examples:
| status |
| 404 |
| 403 |
| 500 |
Scenario: Put stores the object under the prefix with its content type
Given S3_KEY_PREFIX is "uploads/"
When Put is called with stored name "a1b2.png", content type "image/png" and 3 bytes
Then the fake S3 holds key "uploads/a1b2.png" with those bytes and Content-Type "image/png"
Scenario: Put failure is returned as an error
Given the fake S3 answers PUT with 500
When Put is called
Then Put returns a non-nil error that is not image.ErrBlobNotFound
Scenario: Open returns content, size and modification time
Given the fake S3 holds "uploads/a1b2.png" with 10 bytes and a known Last-Modified
When Open("a1b2.png") is called
Then the Blob has Size 10 and that ModTime
And its Content implements io.ReadSeeker and reads the 10 bytes
Scenario: Open of a missing key is not found
Given the fake S3 answers GET with 404 NoSuchKey
When Open("missing.png") is called
Then the result is image.ErrBlobNotFound, asserted with errors.Is
Scenario: Open with a backend failure is not a not-found
Given the fake S3 answers GET with 500
When Open is called
Then the error is non-nil and is not image.ErrBlobNotFound
Scenario Outline: Delete is idempotent
Given the fake S3 <state>
When Delete("a1b2.png") is called
Then it returns no error
Examples:
| state |
| holds the key |
| does not hold the key (404) |
Scenario: A backend failure on Delete is returned
Given the fake S3 answers DELETE with 500
When Delete is called
Then it returns a non-nil error
Scenario Outline: Serving /static/uploadedImages/<name> from the bucket
Given the bucket holds "a1b2.png" with 10 bytes and a known Last-Modified
When <method> /static/uploadedImages/a1b2.png is requested <with>
Then the status is <status>
And the response has <result>
Examples:
| method | with | status | result |
| GET | no extra header | 200 | Content-Type image/png, Content-Length 10, Last-Modified, Accept-Ranges bytes, Cache-Control "public, no-cache", X-Content-Type-Options nosniff |
| HEAD | no extra header | 200 | the same headers and an empty body |
| GET | If-Modified-Since = Last-Modified | 304 | an empty body |
| GET | Range bytes=0-3 | 206 | Content-Range "bytes 0-3/10" and Content-Length 4 |
| GET | Range bytes=50-60 | 416 | Content-Range "bytes */10" |
Scenario: SVG from the bucket keeps its security headers
Given the bucket holds "logo.svg"
When GET /static/uploadedImages/logo.svg is requested
Then Content-Type is image/svg+xml
And Content-Security-Policy is "default-src 'none'; style-src 'unsafe-inline'; sandbox"
Scenario: Missing object is 404 and a backend failure is 500
Given the bucket does not hold "missing.png"
When GET /static/uploadedImages/missing.png is requested
Then the status is 404
Given the fake S3 answers GET with 500
When GET /static/uploadedImages/a1b2.png is requested
Then the status is 500 and the plain text body is "Unable to read the file"
Scenario: Path traversal never reaches another key
When GET /static/uploadedImages/../secret.png is requested
Then the key requested from the fake S3 is the prefix plus "secret.png"
Scenario: Handler as-is for a non-seekable blob
Given a storage fake returning a Blob whose Content is not an io.ReadSeeker, with Size 10 and a ModTime
When GET and HEAD /static/uploadedImages/a1b2.png are requested
Then GET returns 200 with Content-Length 10, Last-Modified and the body
And HEAD returns 200 with the same headers and an empty body
And no Range or ETag behaviour is specified for this path
Scenario: Boot backfill logs how many images it read from the bucket
Given the driver is s3 and 3 stored images lack metadata
When InitContainer runs the boot backfill
Then a log line reports 3 images read from storage and contains no credential
And backfill behaviour and errors are unchanged
And no cap is applied and BackfillImageMetadataUseCase is not modified
Scenario: The local driver is unchanged
Given STORAGE_DRIVER is unset and UPLOAD_DIR is a temp directory
When an image is uploaded and requested
Then it is stored in UPLOAD_DIR and served exactly as before
+26
View File
@@ -0,0 +1,26 @@
# QA: S3 image storage
All tests live in test/ (package test). No cloud account, no real SMTP, no real SQLite file. Test names carry "S3Storage".
Settled decisions: boot backfill gets a log line only, wired in the composition root, with no cap and no use case change. No ETag is emitted; the staticimages handler is unchanged and conditional requests are validated by Last-Modified only.
## Automated
1. Config validation: table test over STORAGE_DRIVER and S3_* lookup functions (the MissingEnvVarError pattern, a `StorageRequiredEnvVars(driver)` helper like `DatabaseRequiredEnvVars`). Covers the validation table, UPLOAD_DIR ignored, unknown driver error.
2. Registry: storage.Open with "local" returns local storage, "s3" returns the S3 storage after HEAD bucket, anything else returns the "unsupported storage driver" error.
3. Fake S3: an httptest.Server on 127.0.0.1 only (never bind :port) implementing HEAD bucket, PUT, GET with Range, HEAD and DELETE object, with injectable statuses and recorded keys, headers and bodies.
4. Adapter tests against the fake: the Put, Open (found, 404, 500) and Delete scenarios, HEAD bucket 404/403/500, prefix handling, ModTime and Size from metadata, Content as io.ReadSeeker (seek then read issues a ranged GET), secret absent from error text.
5. Handler tests: staticimages.NewHandler over OpenMediaUseCase over the S3 storage on the fake server, mounted on a fresh chi.NewRouter() at /static/uploadedImages/*. Cover GET, HEAD, 304 via If-Modified-Since, 206, 416, SVG CSP, 404, 500, traversal.
6. Handler regression: a storage fake returning a non-seekable Blob. GET and HEAD behave exactly as today.
7. Backfill log: the boot log line for the number of images read, captured with a log writer, containing no credential.
8. Local regression: all existing image tests pass unchanged.
9. Gates: `task test` and `task lint:go` pass. `task mutate` is not part of this slice, but the new storage package becomes a target if the mutation scope is widened. The 404 to ErrBlobNotFound and idempotent-Delete branches need killing tests.
## Optional integration (task test:s3)
Start a SeaweedFS container with Docker, create a bucket, set RENEWCMS_TEST_S3_ENDPOINT and run the adapter tests against it. Skip with a message when Docker is unavailable. Not part of `task test`.
## Manual
- Run docker-compose.s3.yml, upload an image in the SPA, confirm the object is in the bucket and /static/uploadedImages/<name> serves it, including after the container is recreated.
- Real AWS S3, R2, B2 or Scaleway smoke test, and an instance-role credential check.
- Start with a wrong bucket and confirm a clear, secret-free startup error.
- Sync an old UPLOAD_DIR with `aws s3 sync` or `mc mirror` and confirm old images load.
- Confirm docs describe: variables (configuration.md, .env.example, docker.env.example, Docker docs), UPLOAD_DIR ignored with s3, manual migration, update backups covering the DB only and not the bucket, and the updated architecture.md "Storage and database seams" section.
+29 -2
View File
@@ -4,22 +4,46 @@ go 1.27.1
require (
github.com/MadAppGang/httplog v1.3.0
github.com/aws/aws-sdk-go-v2 v1.47.1
github.com/aws/aws-sdk-go-v2/config v1.33.6
github.com/aws/aws-sdk-go-v2/credentials v1.20.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0
github.com/glebarez/sqlite v1.11.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/go-chi/jwtauth/v5 v5.3.3
github.com/go-playground/validator/v10 v10.28.0
github.com/go-sql-driver/mysql v1.8.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/jwx/v2 v2.1.6
golang.org/x/crypto v0.57.0
golang.org/x/image v0.46.0
golang.org/x/net v0.59.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/gorm v1.31.1
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.3
gorm.io/gorm v1.31.2
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
github.com/aws/smithy-go v1.28.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
@@ -28,6 +52,9 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -35,7 +62,6 @@ require (
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -43,6 +69,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/segmentio/asm v1.2.1 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
+62 -4
View File
@@ -1,7 +1,45 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/MadAppGang/httplog v1.3.0 h1:1XU54TO8kiqTeO+7oZLKAM3RP/cJ7SadzslRcKspVHo=
github.com/MadAppGang/httplog v1.3.0/go.mod h1:gpYEdkjh/Cda6YxtDy4AB7KY+fR7mb3SqBZw74A5hJ4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 h1:ZBbLwSJqkHBuFDA6DUhhse0IGJ7T5bemHyNILUjvOq4=
github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2/go.mod h1:VSw57q4QFiWDbRnjdX8Cb3Ow0SFncRw+bA/ofY6Q83w=
github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw=
github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug=
github.com/aws/aws-sdk-go-v2/config v1.33.6/go.mod h1:grRAFzdAZJrwcbasJRg2MPvIrVjtlfXllHssN6+E1JE=
github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw=
github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 h1:8gALAAmacnIXh+z6VkdDanv4/IkG5APdg4DZLDTmLog=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 h1:VMAdYqr4Jn/8ATs9BHC5riwrs0d6m1Z2ohFriSwZwm0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 h1:DzCCWLzcIRQ77F3DEUljud7bEjTgFOIKXP52NmVRyhU=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 h1:Umtl/0YZhng4xndfW3lKJrYYP7NLEjI6bGXVomwLcs0=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/SBzUUymbUQuZbSPImghg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -31,6 +69,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@@ -41,6 +81,14 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -65,6 +113,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -74,10 +124,12 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
@@ -105,8 +157,14 @@ gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkp
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0=
gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
+9
View File
@@ -191,6 +191,15 @@ type ImageInput struct {
UploadedByID uint32
}
type Metadata struct {
Filename string
Size int64
ContentType string
Width int
Height int
SHA256 string
}
type Image struct {
ID uint32 `json:"id"`
Path string `json:"path"`
@@ -6,7 +6,7 @@ type SearchQuery struct {
}
type Repository interface {
Create(input ImageInput) (Image, error)
Create(image Image) (Image, error)
Get(id uint32) (Image, error)
Delete(id uint32) error
List(query SearchQuery) ([]Image, error)
@@ -14,4 +14,6 @@ type Repository interface {
Rename(id uint32, filename string) (Image, error)
ListDuplicates() ([]Image, error)
Counts() (Counts, error)
ListMissingMetadata() ([]Image, error)
UpdateMetadata(id uint32, metadata Metadata) error
}
+27
View File
@@ -0,0 +1,27 @@
package image
import (
"errors"
"io"
"time"
)
var ErrBlobNotFound = errors.New("stored image content not found")
const PublicPathPrefix = "/static/uploadedImages/"
func PublicPath(storedName string) string {
return PublicPathPrefix + storedName
}
type Blob struct {
Content io.ReadCloser
Size int64
ModTime time.Time
}
type Storage interface {
Put(contentType string, content io.Reader) (string, error)
Open(storedName string) (Blob, error)
Delete(storedName string) error
}
+56
View File
@@ -0,0 +1,56 @@
package purge
import (
"errors"
"time"
)
var ErrInvalidPurgeInterval = errors.New("invalid purge interval")
var ErrPurgeConflict = errors.New("the library changed since the fingerprint")
var ErrPurgeBusy = errors.New("a purge is already running")
const (
DefaultIntervalDays = 30
MinIntervalDays = 1
MaxIntervalDays = 365
)
type Settings struct {
AutoPurgeEnabled bool `json:"auto_purge_enabled"`
AutoPurgeIntervalDays int `json:"auto_purge_interval_days"`
LastPurgeAt *time.Time `json:"last_purge_at"`
LastPurgeMerged int `json:"last_purge_merged"`
LastPurgeArticlesUpdated int `json:"last_purge_articles_updated"`
EnabledAt *time.Time `json:"enabled_at"`
}
func DefaultSettings() Settings {
return Settings{AutoPurgeIntervalDays: DefaultIntervalDays}
}
func ValidateIntervalDays(days int) error {
if days < MinIntervalDays || days > MaxIntervalDays {
return ErrInvalidPurgeInterval
}
return nil
}
func (s Settings) NextPurgeAt() *time.Time {
if !s.AutoPurgeEnabled || s.EnabledAt == nil {
return nil
}
base := *s.EnabledAt
if s.LastPurgeAt != nil && s.LastPurgeAt.After(base) {
base = *s.LastPurgeAt
}
next := base.AddDate(0, 0, s.AutoPurgeIntervalDays)
return &next
}
type RunResult struct {
GroupsTotal int
GroupsPurged int
Merged int
ArticlesUpdated int
FailedGroups int
}
@@ -0,0 +1,9 @@
package purge
import "time"
type Repository interface {
Get() (Settings, error)
Save(settings Settings) error
SaveLastRun(at time.Time, merged int, articlesUpdated int) error
}
+1
View File
@@ -60,6 +60,7 @@ type UpdateStatus struct {
Error *string `json:"error"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
Container bool `json:"container"`
}
func NewResult(release Release, checkedAt time.Time) CheckResult {
@@ -0,0 +1,149 @@
package config
import (
"fmt"
"os"
"slices"
"strconv"
"strings"
"time"
)
const (
DatabaseDriverSQLite = "sqlite"
DatabaseDriverMySQL = "mysql"
DatabaseDriverPostgres = "postgres"
databaseDriverPostgresAlias = "postgresql"
DefaultDatabaseDriver = DatabaseDriverSQLite
DefaultDatabasePort = "3306"
DefaultPostgresPort = "5432"
DefaultDatabaseTLS = "false"
DefaultDatabaseSSLMode = "disable"
DefaultMaxOpenConns = "10"
DefaultMaxIdleConns = "5"
DefaultConnMaxLifetimeSeconds = "300"
EnvDatabaseDriver = "DB_DRIVER"
envDatabaseHost = "DB_HOST"
envDatabasePort = "DB_PORT"
envDatabaseUser = "DB_USER"
envDatabasePassword = "DB_PASSWORD"
envDatabaseName = "DB_NAME"
envDatabaseTLS = "DB_TLS"
envDatabaseSSLMode = "DB_SSLMODE"
envDatabaseMaxOpenConns = "DB_MAX_OPEN_CONNS"
envDatabaseMaxIdleConns = "DB_MAX_IDLE_CONNS"
envDatabaseConnMaxLifetimeSecs = "DB_CONN_MAX_LIFETIME_SECONDS"
)
var mysqlRequiredEnvVars = []string{envDatabaseHost, envDatabaseUser, envDatabasePassword, envDatabaseName}
var DatabaseTLSModes = []string{"false", "true", "skip-verify", "preferred"}
var DatabaseSSLModes = []string{"disable", "require", "verify-ca", "verify-full"}
type DatabaseSettings struct {
Driver string
Host string
Port string
User string
Password string
Name string
TLS string
SSLMode string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
}
func NormalizeDatabaseDriver(driver string) string {
if driver == databaseDriverPostgresAlias {
return DatabaseDriverPostgres
}
return driver
}
func DatabaseRequiredEnvVars(driver string) []string {
switch NormalizeDatabaseDriver(driver) {
case DatabaseDriverMySQL, DatabaseDriverPostgres:
return slices.Clone(mysqlRequiredEnvVars)
}
return nil
}
func MissingEnvVarError(names []string, lookup func(string) (string, bool)) error {
for _, name := range names {
if _, isSet := lookup(name); !isSet {
return fmt.Errorf("Environment variable %s is not set", name)
}
}
return nil
}
func databaseDriverFromEnv() string {
return NormalizeDatabaseDriver(envOrDefault(EnvDatabaseDriver, DefaultDatabaseDriver))
}
func databaseSettingsFromEnv() (DatabaseSettings, error) {
driver := databaseDriverFromEnv()
if driver != DatabaseDriverMySQL && driver != DatabaseDriverPostgres {
return DatabaseSettings{Driver: driver}, nil
}
defaultPort, tls, sslMode := DefaultDatabasePort, "", ""
if driver == DatabaseDriverPostgres {
defaultPort = DefaultPostgresPort
sslMode = envOrDefault(envDatabaseSSLMode, DefaultDatabaseSSLMode)
if !slices.Contains(DatabaseSSLModes, sslMode) {
return DatabaseSettings{}, fmt.Errorf("%s must be one of %s, got %q", envDatabaseSSLMode, strings.Join(DatabaseSSLModes, ", "), sslMode)
}
} else {
tls = envOrDefault(envDatabaseTLS, DefaultDatabaseTLS)
if !slices.Contains(DatabaseTLSModes, tls) {
return DatabaseSettings{}, fmt.Errorf("%s must be one of %s, got %q", envDatabaseTLS, strings.Join(DatabaseTLSModes, ", "), tls)
}
}
maxOpen, err := nonNegativeIntEnv(envDatabaseMaxOpenConns, DefaultMaxOpenConns)
if err != nil {
return DatabaseSettings{}, err
}
maxIdle, err := nonNegativeIntEnv(envDatabaseMaxIdleConns, DefaultMaxIdleConns)
if err != nil {
return DatabaseSettings{}, err
}
lifetimeSeconds, err := nonNegativeIntEnv(envDatabaseConnMaxLifetimeSecs, DefaultConnMaxLifetimeSeconds)
if err != nil {
return DatabaseSettings{}, err
}
return DatabaseSettings{
Driver: driver,
Host: os.Getenv(envDatabaseHost),
Port: envOrDefault(envDatabasePort, defaultPort),
User: os.Getenv(envDatabaseUser),
Password: os.Getenv(envDatabasePassword),
Name: os.Getenv(envDatabaseName),
TLS: tls,
SSLMode: sslMode,
MaxOpenConns: maxOpen,
MaxIdleConns: maxIdle,
ConnMaxLifetime: time.Duration(lifetimeSeconds) * time.Second,
}, nil
}
func envOrDefault(name string, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func nonNegativeIntEnv(name string, fallback string) (int, error) {
raw := envOrDefault(name, fallback)
value, err := strconv.Atoi(raw)
if err != nil || value < 0 {
return 0, fmt.Errorf("%s must be a non-negative whole number, got %q", name, raw)
}
return value, nil
}
@@ -2,6 +2,7 @@ package config
import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -27,12 +28,23 @@ type Settings struct {
UploadDir string
JWTSecret string
DBFile string
Database DatabaseSettings
Storage StorageSettings
DataDirPath string
Port string
Smtp SmtpSettings
AuthRateLimit AuthRateLimitSettings
TrustedProxies []string
Version string
UpdateFeedURL string
Container bool
}
func (s Settings) DataDir() string {
if s.DataDirPath != "" {
return s.DataDirPath
}
return filepath.Dir(s.DBFile)
}
func (s Settings) IsProduction() bool {
@@ -55,6 +67,16 @@ func NewSettingsFromEnv() (Settings, error) {
return Settings{}, err
}
database, err := databaseSettingsFromEnv()
if err != nil {
return Settings{}, err
}
storage, err := storageSettingsFromEnv()
if err != nil {
return Settings{}, err
}
return Settings{
Environment: os.Getenv("ENVIRONMENT"),
CorsAllowedOrigins: parseSemicolonSeparatedList(os.Getenv("CORS_ALLOWED_ORIGINS")),
@@ -62,6 +84,9 @@ func NewSettingsFromEnv() (Settings, error) {
UploadDir: os.Getenv("UPLOAD_DIR"),
JWTSecret: os.Getenv("JWT_SECRET"),
DBFile: os.Getenv("DB_FILE"),
Database: database,
Storage: storage,
DataDirPath: os.Getenv("DATA_DIR"),
Port: os.Getenv("PORT"),
Smtp: SmtpSettings{
Login: os.Getenv("SMTP_LOGIN"),
@@ -76,6 +101,7 @@ func NewSettingsFromEnv() (Settings, error) {
},
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"),
Container: strings.EqualFold(os.Getenv("RENEWCMS_CONTAINER"), "true"),
}, nil
}
@@ -0,0 +1,114 @@
package config
import (
"fmt"
"os"
"slices"
"strconv"
)
const (
StorageDriverLocal = "local"
StorageDriverS3 = "s3"
DefaultStorageDriver = StorageDriverLocal
DefaultS3Region = "us-east-1"
DefaultS3PathStyle = "false"
DefaultS3TLS = "true"
EnvStorageDriver = "STORAGE_DRIVER"
envS3Bucket = "S3_BUCKET"
envS3Region = "S3_REGION"
envS3Endpoint = "S3_ENDPOINT"
envS3AccessKeyID = "S3_ACCESS_KEY_ID"
envS3SecretKey = "S3_SECRET_ACCESS_KEY"
envS3KeyPrefix = "S3_KEY_PREFIX"
envS3PathStyle = "S3_PATH_STYLE"
envS3TLS = "S3_TLS"
)
var s3RequiredEnvVars = []string{envS3Bucket}
type S3Settings struct {
Bucket string
Region string
Endpoint string
AccessKeyID string
SecretAccessKey string
KeyPrefix string
PathStyle bool
TLS bool
}
type StorageSettings struct {
Driver string
S3 S3Settings
}
func StorageRequiredEnvVars(driver string) []string {
if driver == StorageDriverS3 {
return slices.Clone(s3RequiredEnvVars)
}
return nil
}
func storageSettingsFromEnv() (StorageSettings, error) {
driver := envOrDefault(EnvStorageDriver, DefaultStorageDriver)
switch driver {
case StorageDriverLocal:
return StorageSettings{Driver: driver}, nil
case StorageDriverS3:
s3, err := s3SettingsFromEnv()
if err != nil {
return StorageSettings{}, err
}
return StorageSettings{Driver: driver, S3: s3}, nil
default:
return StorageSettings{}, fmt.Errorf("unsupported storage driver %q", driver)
}
}
func s3SettingsFromEnv() (S3Settings, error) {
if err := MissingEnvVarError(s3RequiredEnvVars, nonEmptyEnvLookup); err != nil {
return S3Settings{}, err
}
accessKeyID := os.Getenv(envS3AccessKeyID)
secretKey := os.Getenv(envS3SecretKey)
if accessKeyID != "" && secretKey == "" {
return S3Settings{}, fmt.Errorf("Environment variable %s is not set (it is required when %s is set)", envS3SecretKey, envS3AccessKeyID)
}
if secretKey != "" && accessKeyID == "" {
return S3Settings{}, fmt.Errorf("Environment variable %s is not set (it is required when %s is set)", envS3AccessKeyID, envS3SecretKey)
}
pathStyle, err := boolEnv(envS3PathStyle, DefaultS3PathStyle)
if err != nil {
return S3Settings{}, err
}
tls, err := boolEnv(envS3TLS, DefaultS3TLS)
if err != nil {
return S3Settings{}, err
}
return S3Settings{
Bucket: os.Getenv(envS3Bucket),
Region: envOrDefault(envS3Region, DefaultS3Region),
Endpoint: os.Getenv(envS3Endpoint),
AccessKeyID: accessKeyID,
SecretAccessKey: secretKey,
KeyPrefix: os.Getenv(envS3KeyPrefix),
PathStyle: pathStyle,
TLS: tls,
}, nil
}
func nonEmptyEnvLookup(name string) (string, bool) {
value := os.Getenv(name)
return value, value != ""
}
func boolEnv(name string, fallback string) (bool, error) {
raw := envOrDefault(name, fallback)
value, err := strconv.ParseBool(raw)
if err != nil {
return false, fmt.Errorf("%s must be true or false, got %q", name, raw)
}
return value, nil
}
@@ -0,0 +1,55 @@
package database
import (
"os"
"path/filepath"
"slices"
"strings"
"time"
)
type backupFile struct {
path string
modified time.Time
}
func newBackupTarget(directory string, now time.Time, extension string) (string, error) {
if err := os.MkdirAll(directory, os.ModePerm); err != nil {
return "", err
}
target := filepath.Join(directory, backupFilePrefix+now.UTC().Format(backupTimeLayout)+extension)
if _, err := os.Stat(target); err == nil {
return "", errBackupExists
}
return target, nil
}
func pruneBackups(directory string, extension string) error {
entries, err := os.ReadDir(directory)
if err != nil {
return err
}
files := make([]backupFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), extension) {
continue
}
info, err := entry.Info()
if err != nil {
return err
}
files = append(files, backupFile{path: filepath.Join(directory, entry.Name()), modified: info.ModTime()})
}
slices.SortFunc(files, func(a, b backupFile) int {
if byTime := b.modified.Compare(a.modified); byTime != 0 {
return byTime
}
return strings.Compare(b.path, a.path)
})
for _, stale := range files[min(backupsToKeep, len(files)):] {
if err := os.Remove(stale.path); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,77 @@
package database
import (
domainUpdate "RenewCMS/internal/domain/update"
"fmt"
"time"
"gorm.io/gorm"
)
type PoolSettings struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
}
type Config struct {
Driver string
Source string
Pool *PoolSettings
}
type Driver interface {
Open(source string) (*gorm.DB, error)
NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup
}
var drivers = map[string]Driver{
DriverSQLite: sqliteDriver{},
DriverMySQL: mysqlDriver{},
DriverPostgres: postgresDriver{},
}
type Connection struct {
DB *gorm.DB
driver Driver
}
func Connect(config Config) (*Connection, error) {
driver, ok := drivers[config.Driver]
if !ok {
return nil, fmt.Errorf("unsupported database driver %q", config.Driver)
}
db, err := driver.Open(config.Source)
if err != nil {
return nil, err
}
if config.Pool != nil {
if err := applyPool(db, *config.Pool); err != nil {
return nil, err
}
}
return &Connection{DB: db, driver: driver}, nil
}
func applyPool(db *gorm.DB, pool PoolSettings) error {
sqlDb, err := db.DB()
if err != nil {
return err
}
sqlDb.SetMaxOpenConns(pool.MaxOpenConns)
sqlDb.SetMaxIdleConns(pool.MaxIdleConns)
sqlDb.SetConnMaxLifetime(pool.ConnMaxLifetime)
return nil
}
func (c *Connection) NewBackup(directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return c.driver.NewBackup(c.DB, directory, now)
}
func (c *Connection) Close() error {
sqlDb, err := c.DB.DB()
if err != nil {
return err
}
return sqlDb.Close()
}
@@ -0,0 +1,80 @@
package database
import (
domain "RenewCMS/internal/domain/update"
"bufio"
"os"
"time"
)
const (
dumpFileExtension = ".sql"
dumpPartialFileExtension = ".partial"
)
type DumpBackup struct {
open func() (DumpSource, error)
directory string
now func() time.Time
dialect dumpDialect
}
var _ domain.DatabaseBackup = &DumpBackup{}
func NewDumpBackup(open func() (DumpSource, error), directory string, now func() time.Time) *DumpBackup {
return &DumpBackup{open: open, directory: directory, now: now, dialect: mysqlDumpDialect}
}
func (b *DumpBackup) Backup() (string, error) {
target, err := newBackupTarget(b.directory, b.now(), dumpFileExtension)
if err != nil {
return "", err
}
if err := b.writeAtomically(target); err != nil {
return "", err
}
if err := pruneBackups(b.directory, dumpFileExtension); err != nil {
return "", err
}
return target, nil
}
func (b *DumpBackup) writeAtomically(target string) error {
partial := target + dumpPartialFileExtension
if err := b.writePartial(partial); err != nil {
_ = os.Remove(partial)
return err
}
if err := os.Rename(partial, target); err != nil {
_ = os.Remove(partial)
return err
}
return nil
}
func (b *DumpBackup) writePartial(partial string) error {
source, err := b.open()
if err != nil {
return err
}
defer source.Close()
file, err := os.OpenFile(partial, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
buffered := bufio.NewWriter(file)
if err := writeDump(buffered, source, b.dialect); err != nil {
_ = file.Close()
return err
}
if err := buffered.Flush(); err != nil {
_ = file.Close()
return err
}
if err := file.Sync(); err != nil {
_ = file.Close()
return err
}
return file.Close()
}
@@ -0,0 +1,15 @@
package database
import (
"RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
func Migrate(db *gorm.DB) error {
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{})
if err != nil {
return err
}
return BackfillUserRoles(db)
}
@@ -0,0 +1,86 @@
package database
import (
domainUpdate "RenewCMS/internal/domain/update"
"fmt"
"net"
"strings"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
const (
DriverMySQL = "mysql"
mysqlCharset = "utf8mb4"
mysqlLocation = "UTC"
mysqlCollation = "utf8mb4_bin"
mysqlConnectTimeout = 5 * time.Second
mysqlSessionSQLMode = "'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'"
mysqlTableOptionsKey = "gorm:table_options"
redactedPassword = "***"
)
type mysqlDriver struct{}
type MySQLConfig struct {
Host string
Port string
User string
Password string
Name string
TLS string
}
func MySQLDSN(config MySQLConfig) string {
dsn := mysqldriver.NewConfig()
dsn.User = config.User
dsn.Passwd = config.Password
dsn.Net = "tcp"
dsn.Addr = net.JoinHostPort(config.Host, config.Port)
dsn.DBName = config.Name
dsn.TLSConfig = config.TLS
dsn.ParseTime = true
dsn.ClientFoundRows = true
dsn.Collation = mysqlCollation
dsn.Timeout = mysqlConnectTimeout
dsn.Params = map[string]string{
"charset": mysqlCharset,
"loc": mysqlLocation,
"sql_mode": mysqlSessionSQLMode,
}
return dsn.FormatDSN()
}
func (mysqlDriver) Open(source string) (*gorm.DB, error) {
parsed, err := mysqldriver.ParseDSN(source)
if err != nil {
return nil, fmt.Errorf("the mysql connection settings are invalid")
}
db, err := gorm.Open(mysql.Open(source), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("cannot use the mysql server at %s: %s", parsed.Addr, redact(err.Error(), parsed.Passwd))
}
return db, nil
}
func (mysqlDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return NewMySQLBackup(db, directory, now)
}
func redact(message string, secret string) string {
if secret == "" {
return message
}
return strings.ReplaceAll(message, secret, redactedPassword)
}
func tableOptionsFor(db *gorm.DB) *gorm.DB {
if db.Dialector.Name() == DriverMySQL {
return db.Set(mysqlTableOptionsKey, "DEFAULT CHARSET="+mysqlCharset+" COLLATE="+mysqlCollation)
}
return db
}
@@ -0,0 +1,146 @@
package database
import (
"database/sql"
"fmt"
"time"
"gorm.io/gorm"
)
const (
dumpTimeLayout = "2006-01-02 15:04:05.000000"
zeroDumpTime = "0000-00-00 00:00:00"
)
func NewMySQLBackup(db *gorm.DB, directory string, now func() time.Time) *DumpBackup {
return NewDumpBackup(func() (DumpSource, error) { return openMySQLDumpSource(db) }, directory, now)
}
type txDumpSource struct {
tx *gorm.DB
quote func(name string) string
convert func(value any) []byte
}
func openTxDumpSource(db *gorm.DB, quote func(name string) string, convert func(value any) []byte) (*txDumpSource, error) {
tx := db.Begin(&sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
if tx.Error != nil {
return nil, tx.Error
}
return &txDumpSource{tx: tx, quote: quote, convert: convert}, nil
}
func (s *txDumpSource) Rows(table string) (DumpRows, error) {
rows, err := s.tx.Raw("SELECT * FROM " + s.quote(table)).Rows()
if err != nil {
return nil, err
}
return &sqlDumpRows{rows: rows, convert: s.convert}, nil
}
func (s *txDumpSource) Close() error {
return s.tx.Rollback().Error
}
type mysqlDumpSource struct {
*txDumpSource
}
var _ DumpSource = &mysqlDumpSource{}
func openMySQLDumpSource(db *gorm.DB) (*mysqlDumpSource, error) {
source, err := openTxDumpSource(db, quoteIdentifier, dumpBytes)
if err != nil {
return nil, err
}
return &mysqlDumpSource{source}, nil
}
func (s *mysqlDumpSource) Tables() ([]string, error) {
var tables []string
err := s.tx.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE' ORDER BY table_name").Scan(&tables).Error
return tables, err
}
func (s *mysqlDumpSource) CreateStatement(table string) (string, error) {
var name, statement string
row := s.tx.Raw("SHOW CREATE TABLE " + quoteIdentifier(table)).Row()
if err := row.Scan(&name, &statement); err != nil {
return "", err
}
return statement, nil
}
type sqlDumpRows struct {
rows *sql.Rows
convert func(value any) []byte
}
var _ DumpRows = &sqlDumpRows{}
func (r *sqlDumpRows) Columns() ([]DumpColumn, error) {
types, err := r.rows.ColumnTypes()
if err != nil {
return nil, err
}
columns := make([]DumpColumn, len(types))
for index, columnType := range types {
columns[index] = DumpColumn{Name: columnType.Name(), Type: columnType.DatabaseTypeName()}
}
return columns, nil
}
func (r *sqlDumpRows) Next() bool {
return r.rows.Next()
}
func (r *sqlDumpRows) Values() ([][]byte, error) {
columnCount, err := r.rows.Columns()
if err != nil {
return nil, err
}
scanned := make([]any, len(columnCount))
targets := make([]any, len(columnCount))
for index := range scanned {
targets[index] = &scanned[index]
}
if err := r.rows.Scan(targets...); err != nil {
return nil, err
}
values := make([][]byte, len(scanned))
for index, value := range scanned {
values[index] = r.convert(value)
}
return values, nil
}
func (r *sqlDumpRows) Err() error {
return r.rows.Err()
}
func (r *sqlDumpRows) Close() error {
return r.rows.Close()
}
func dumpBytes(value any) []byte {
switch typed := value.(type) {
case nil:
return nil
case []byte:
return append([]byte{}, typed...)
case string:
return []byte(typed)
case time.Time:
return []byte(formatDumpTime(typed))
default:
return []byte(fmt.Sprint(typed))
}
}
func formatDumpTime(value time.Time) string {
if value.IsZero() {
return zeroDumpTime
}
return value.UTC().Format(dumpTimeLayout)
}
@@ -0,0 +1,108 @@
package database
import (
domainUpdate "RenewCMS/internal/domain/update"
"fmt"
"net"
"net/url"
"reflect"
"time"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
const (
DriverPostgres = "postgres"
postgresTimeZone = "UTC"
postgresConnectTimeoutSeconds = 5
postgresScheme = "postgres"
postgresUTCCallback = "renewcms:postgres_utc"
gormQueryCallback = "gorm:query"
)
var timeType = reflect.TypeOf(time.Time{})
type postgresDriver struct{}
type PostgresConfig struct {
Host string
Port string
User string
Password string
Name string
SSLMode string
}
func PostgresDSN(config PostgresConfig) string {
dsn := url.URL{
Scheme: postgresScheme,
User: url.UserPassword(config.User, config.Password),
Host: net.JoinHostPort(config.Host, config.Port),
Path: "/" + config.Name,
}
query := url.Values{}
query.Set("sslmode", config.SSLMode)
query.Set("timezone", postgresTimeZone)
query.Set("connect_timeout", fmt.Sprint(postgresConnectTimeoutSeconds))
dsn.RawQuery = query.Encode()
return dsn.String()
}
func (postgresDriver) Open(source string) (*gorm.DB, error) {
parsed, err := pgconn.ParseConfig(source)
if err != nil {
return nil, fmt.Errorf("the postgres connection settings are invalid")
}
db, err := gorm.Open(postgres.Open(source), &gorm.Config{})
if err != nil {
address := net.JoinHostPort(parsed.Host, fmt.Sprint(parsed.Port))
return nil, fmt.Errorf("cannot use the postgres server at %s: %s", address, redact(err.Error(), parsed.Password))
}
if err := db.Callback().Query().After(gormQueryCallback).Register(postgresUTCCallback, readTimesInUTC); err != nil {
return nil, err
}
return db, nil
}
func readTimesInUTC(db *gorm.DB) {
if db.Error != nil || db.Statement.Dest == nil {
return
}
convertTimesToUTC(reflect.ValueOf(db.Statement.Dest))
}
func convertTimesToUTC(value reflect.Value) {
switch value.Kind() {
case reflect.Pointer, reflect.Interface:
if !value.IsNil() {
convertTimesToUTC(value.Elem())
}
case reflect.Slice, reflect.Array:
for index := 0; index < value.Len(); index++ {
convertTimesToUTC(value.Index(index))
}
case reflect.Struct:
convertStructTimesToUTC(value)
}
}
func convertStructTimesToUTC(value reflect.Value) {
if value.Type() == timeType {
if value.CanSet() {
value.Set(reflect.ValueOf(value.Interface().(time.Time).UTC()))
}
return
}
for index := 0; index < value.NumField(); index++ {
if value.Type().Field(index).IsExported() {
convertTimesToUTC(value.Field(index))
}
}
}
func (postgresDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return NewPostgresBackup(db, directory, now)
}
@@ -0,0 +1,189 @@
package database
import (
"fmt"
"strings"
"time"
"gorm.io/gorm"
)
const (
postgresSchema = "public"
postgresTablesQuery = "SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE' ORDER BY table_name"
postgresColumnsQuery = `SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS data_type, a.attnotnull AS not_null, COALESCE(pg_get_expr(d.adbin, d.adrelid), '') AS default_value, a.attidentity::text AS identity
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE n.nspname = ? AND c.relname = ? AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum`
postgresConstraintsQuery = `SELECT con.conname AS name, pg_get_constraintdef(con.oid) AS definition
FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ? AND c.relname = ? AND con.contype IN ('p', 'u', 'c')
ORDER BY CASE con.contype WHEN 'p' THEN 0 ELSE 1 END, con.conname`
postgresIndexesQuery = `SELECT i.indexdef
FROM pg_indexes i
WHERE i.schemaname = ? AND i.tablename = ?
AND i.indexname NOT IN (
SELECT con.conname FROM pg_constraint con
JOIN pg_class c ON c.oid = con.conrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ? AND c.relname = ?
)
ORDER BY i.indexname`
postgresIdentityColumn = "id"
postgresTimeLayout = "2006-01-02 15:04:05.999999999Z07:00"
postgresByteaType = "BYTEA"
)
var postgresNumericTypes = map[string]bool{
"INT2": true, "INT4": true, "INT8": true, "FLOAT4": true, "FLOAT8": true, "NUMERIC": true, "OID": true,
}
var postgresSerialTypes = map[string]string{
"smallint": "smallserial",
"integer": "serial",
"bigint": "bigserial",
}
var postgresDumpDialect = dumpDialect{
preamble: []string{
"SET client_encoding = 'UTF8';",
"SET standard_conforming_strings = on;",
"SET TimeZone = 'UTC';",
},
quote: quotePostgresIdentifier,
literal: postgresLiteral,
tableTrailer: postgresSequenceReset,
}
func NewPostgresBackup(db *gorm.DB, directory string, now func() time.Time) *DumpBackup {
backup := NewDumpBackup(func() (DumpSource, error) { return openPostgresDumpSource(db) }, directory, now)
backup.dialect = postgresDumpDialect
return backup
}
type postgresDumpSource struct {
*txDumpSource
}
var _ DumpSource = &postgresDumpSource{}
func openPostgresDumpSource(db *gorm.DB) (*postgresDumpSource, error) {
source, err := openTxDumpSource(db, quotePostgresIdentifier, postgresDumpBytes)
if err != nil {
return nil, err
}
return &postgresDumpSource{source}, nil
}
func (s *postgresDumpSource) Tables() ([]string, error) {
var tables []string
err := s.tx.Raw(postgresTablesQuery, postgresSchema).Scan(&tables).Error
return tables, err
}
type postgresColumn struct {
Name string
DataType string
NotNull bool
DefaultValue string
Identity string
}
type postgresConstraint struct {
Name string
Definition string
}
func (s *postgresDumpSource) CreateStatement(table string) (string, error) {
var columns []postgresColumn
if err := s.tx.Raw(postgresColumnsQuery, postgresSchema, table).Scan(&columns).Error; err != nil {
return "", err
}
var constraints []postgresConstraint
if err := s.tx.Raw(postgresConstraintsQuery, postgresSchema, table).Scan(&constraints).Error; err != nil {
return "", err
}
var indexes []string
if err := s.tx.Raw(postgresIndexesQuery, postgresSchema, table, postgresSchema, table).Scan(&indexes).Error; err != nil {
return "", err
}
var definitions []string
for _, column := range columns {
definitions = append(definitions, " "+postgresColumnDefinition(column))
}
for _, constraint := range constraints {
definitions = append(definitions, fmt.Sprintf(" CONSTRAINT %s %s", quotePostgresIdentifier(constraint.Name), constraint.Definition))
}
statements := []string{fmt.Sprintf("CREATE TABLE %s (\n%s\n)", quotePostgresIdentifier(table), strings.Join(definitions, ",\n"))}
statements = append(statements, indexes...)
return strings.Join(statements, ";\n"), nil
}
func postgresColumnDefinition(column postgresColumn) string {
quoted := quotePostgresIdentifier(column.Name)
serial, isSerial := postgresSerialTypes[column.DataType]
if isSerial && strings.HasPrefix(column.DefaultValue, "nextval(") {
return quoted + " " + serial + " NOT NULL"
}
if column.Identity != "" {
return quoted + " " + column.DataType + " GENERATED BY DEFAULT AS IDENTITY NOT NULL"
}
definition := quoted + " " + column.DataType
if column.DefaultValue != "" {
definition += " DEFAULT " + column.DefaultValue
}
if column.NotNull {
definition += " NOT NULL"
}
return definition
}
func postgresDumpBytes(value any) []byte {
if moment, isTime := value.(time.Time); isTime {
return []byte(moment.UTC().Format(postgresTimeLayout))
}
return dumpBytes(value)
}
func quotePostgresIdentifier(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
func postgresLiteral(databaseType string, value []byte) string {
if value == nil {
return "NULL"
}
upper := strings.ToUpper(databaseType)
if postgresNumericTypes[upper] && plainNumber.Match(value) {
return string(value)
}
if upper == postgresByteaType {
return fmt.Sprintf(`'\x%x'`, value)
}
return "'" + strings.ReplaceAll(string(value), "'", "''") + "'"
}
func postgresSequenceReset(table string, columns []DumpColumn) []string {
for _, column := range columns {
if column.Name != postgresIdentityColumn {
continue
}
quotedTable, quotedColumn := quotePostgresIdentifier(table), quotePostgresIdentifier(column.Name)
sequenceTable := strings.ReplaceAll(quotedTable, "'", "''")
return []string{fmt.Sprintf(
"SELECT setval(pg_get_serial_sequence('%s', '%s'), COALESCE((SELECT MAX(%s) FROM %s), 1), (SELECT COUNT(*) > 0 FROM %s));",
sequenceTable, column.Name, quotedColumn, quotedTable, quotedTable,
)}
}
return nil
}
@@ -0,0 +1,189 @@
package database
import (
"encoding/hex"
"fmt"
"io"
"regexp"
"strings"
)
type DumpColumn struct {
Name string
Type string
}
type DumpRows interface {
Columns() ([]DumpColumn, error)
Next() bool
Values() ([][]byte, error)
Err() error
Close() error
}
type DumpSource interface {
Tables() ([]string, error)
CreateStatement(table string) (string, error)
Rows(table string) (DumpRows, error)
Close() error
}
type dumpDialect struct {
preamble []string
postamble []string
quote func(name string) string
literal func(databaseType string, value []byte) string
tableTrailer func(table string, columns []DumpColumn) []string
}
var mysqlDumpDialect = dumpDialect{
preamble: []string{
"SET NAMES utf8mb4;",
"SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';",
"SET foreign_key_checks = 0;",
},
postamble: []string{
"SET foreign_key_checks = 1;",
},
quote: quoteIdentifier,
literal: sqlLiteral,
tableTrailer: func(string, []DumpColumn) []string { return nil },
}
var plainNumber = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$`)
var textEscapes = strings.NewReplacer(
`\`, `\\`,
`'`, `\'`,
"\x00", `\0`,
"\n", `\n`,
"\r", `\r`,
"\x1a", `\Z`,
)
func WriteDump(output io.Writer, source DumpSource) error {
return writeDump(output, source, mysqlDumpDialect)
}
func writeDump(output io.Writer, source DumpSource, dialect dumpDialect) error {
if err := writeStatements(output, dialect.preamble); err != nil {
return err
}
tables, err := source.Tables()
if err != nil {
return err
}
for _, table := range tables {
if err := writeTable(output, source, table, dialect); err != nil {
return err
}
}
return writeStatements(output, dialect.postamble)
}
func writeStatements(output io.Writer, statements []string) error {
for _, statement := range statements {
if _, err := fmt.Fprintln(output, statement); err != nil {
return err
}
}
return nil
}
func writeTable(output io.Writer, source DumpSource, table string, dialect dumpDialect) error {
create, err := source.CreateStatement(table)
if err != nil {
return err
}
if _, err := fmt.Fprintf(output, "%s;\n", create); err != nil {
return err
}
rows, err := source.Rows(table)
if err != nil {
return err
}
defer rows.Close()
return writeRows(output, table, rows, dialect)
}
func writeRows(output io.Writer, table string, rows DumpRows, dialect dumpDialect) error {
columns, err := rows.Columns()
if err != nil {
return err
}
insertPrefix := insertStatementPrefix(table, columns, dialect)
for rows.Next() {
values, err := rows.Values()
if err != nil {
return err
}
if _, err := fmt.Fprintf(output, "%s%s);\n", insertPrefix, joinLiterals(columns, values, dialect)); err != nil {
return err
}
}
if err := rows.Err(); err != nil {
return err
}
return writeStatements(output, dialect.tableTrailer(table, columns))
}
func insertStatementPrefix(table string, columns []DumpColumn, dialect dumpDialect) string {
names := make([]string, len(columns))
for index, column := range columns {
names[index] = dialect.quote(column.Name)
}
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (", dialect.quote(table), strings.Join(names, ","))
}
func joinLiterals(columns []DumpColumn, values [][]byte, dialect dumpDialect) string {
literals := make([]string, len(columns))
for index, column := range columns {
literals[index] = dialect.literal(column.Type, values[index])
}
return strings.Join(literals, ",")
}
func quoteIdentifier(name string) string {
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
}
func sqlLiteral(databaseType string, value []byte) string {
if value == nil {
return "NULL"
}
switch {
case isNumericType(databaseType) && plainNumber.Match(value):
return string(value)
case isBinaryType(databaseType):
return hexLiteral(value)
default:
return "'" + textEscapes.Replace(string(value)) + "'"
}
}
func hexLiteral(value []byte) string {
if len(value) == 0 {
return "''"
}
return "0x" + hex.EncodeToString(value)
}
func isNumericType(databaseType string) bool {
upper := strings.ToUpper(databaseType)
for _, suffix := range []string{"INT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "YEAR"} {
if strings.HasSuffix(upper, suffix) {
return true
}
}
return false
}
func isBinaryType(databaseType string) bool {
upper := strings.ToUpper(databaseType)
for _, suffix := range []string{"BLOB", "BINARY", "BIT", "GEOMETRY"} {
if strings.HasSuffix(upper, suffix) {
return true
}
}
return false
}
@@ -0,0 +1,33 @@
package database
import (
domainUpdate "RenewCMS/internal/domain/update"
"fmt"
"os"
"path/filepath"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
const DriverSQLite = "sqlite"
const sqliteBusyTimeoutMs = 5000
type sqliteDriver struct{}
func SQLiteDSN(dbName string) string {
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs)
}
func (sqliteDriver) Open(file string) (*gorm.DB, error) {
if err := os.MkdirAll(filepath.Dir(file), os.ModePerm); err != nil {
return nil, fmt.Errorf("unable to create necessary subdirectories: %w", err)
}
return gorm.Open(sqlite.Open(SQLiteDSN(file)), &gorm.Config{})
}
func (sqliteDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return NewSQLiteBackup(db, directory, now)
}
@@ -0,0 +1,46 @@
package database
import (
domain "RenewCMS/internal/domain/update"
"errors"
"os"
"time"
"gorm.io/gorm"
)
var errBackupExists = errors.New("a backup with this name already exists")
const (
backupsToKeep = 3
backupFileExtension = ".db"
backupFilePrefix = "renewcms-"
backupTimeLayout = "20060102T150405.000000000Z"
)
type SQLiteBackup struct {
db *gorm.DB
directory string
now func() time.Time
}
var _ domain.DatabaseBackup = &SQLiteBackup{}
func NewSQLiteBackup(db *gorm.DB, directory string, now func() time.Time) *SQLiteBackup {
return &SQLiteBackup{db: db, directory: directory, now: now}
}
func (b *SQLiteBackup) Backup() (string, error) {
target, err := newBackupTarget(b.directory, b.now(), backupFileExtension)
if err != nil {
return "", err
}
if err := b.db.Exec("VACUUM INTO ?", target).Error; err != nil {
_ = os.Remove(target)
return "", err
}
if err := pruneBackups(b.directory, backupFileExtension); err != nil {
return "", err
}
return target, nil
}
@@ -1,4 +1,4 @@
package persistence
package database
import (
domainUser "RenewCMS/internal/domain/user"
@@ -0,0 +1,28 @@
package mappers
import (
"RenewCMS/internal/domain/purge"
"RenewCMS/internal/infrastructure/persistence/models"
)
func PurgeSettingsToDomain(m models.PurgeSettings) purge.Settings {
return purge.Settings{
AutoPurgeEnabled: m.AutoPurgeEnabled,
AutoPurgeIntervalDays: m.AutoPurgeIntervalDays,
LastPurgeAt: m.LastPurgeAt,
LastPurgeMerged: m.LastPurgeMerged,
LastPurgeArticlesUpdated: m.LastPurgeArticlesUpdated,
EnabledAt: m.EnabledAt,
}
}
func PurgeSettingsToModel(s purge.Settings) models.PurgeSettings {
return models.PurgeSettings{
AutoPurgeEnabled: s.AutoPurgeEnabled,
AutoPurgeIntervalDays: s.AutoPurgeIntervalDays,
LastPurgeAt: s.LastPurgeAt,
LastPurgeMerged: s.LastPurgeMerged,
LastPurgeArticlesUpdated: s.LastPurgeArticlesUpdated,
EnabledAt: s.EnabledAt,
}
}
@@ -7,7 +7,7 @@ type APIKey struct {
UserID uint32 `gorm:"index;not null"`
Name string `gorm:"not null"`
Prefix string `gorm:"not null"`
HashedKey string `gorm:"uniqueIndex;not null"`
HashedKey string `gorm:"uniqueIndex;not null;size:64"`
ExpiresAt *time.Time
RevokedAt *time.Time
LastUsedAt *time.Time
@@ -15,7 +15,7 @@ type Image struct {
ContentType string
Width int
Height int
SHA256 string `gorm:"column:sha256;index"`
SHA256 string `gorm:"column:sha256;index;size:64"`
UploadedByID uint32
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
@@ -4,7 +4,7 @@ import "time"
type Invite struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Email string `gorm:"index;not null"`
Email string `gorm:"index;not null;size:255"`
HashedCode string `gorm:"not null"`
ExpiresAt time.Time
InvitedBy uint32 `gorm:"not null"`
@@ -0,0 +1,13 @@
package models
import "time"
type PurgeSettings struct {
ID uint32 `gorm:"primaryKey"`
AutoPurgeEnabled bool `gorm:"not null"`
AutoPurgeIntervalDays int `gorm:"not null"`
LastPurgeAt *time.Time
LastPurgeMerged int `gorm:"not null"`
LastPurgeArticlesUpdated int `gorm:"not null"`
EnabledAt *time.Time
}
@@ -6,14 +6,14 @@ import (
type User struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Username string `gorm:"unique;not null"`
Username string `gorm:"unique;not null;size:255"`
Password string `gorm:"not null"`
PasswordResetCode *string `gorm:"unique"`
Email string `gorm:"unique;not null"`
DisplayName string `gorm:"not null;default:''"`
PasswordResetCode *string `gorm:"unique;size:255"`
Email string `gorm:"unique;not null;size:255"`
DisplayName string `gorm:"not null;default:'';size:255"`
IsVerified bool `gorm:"default=false;not null"`
Role string `gorm:"not null;default:''"`
VerificationCode *string `gorm:"unique"`
Role string `gorm:"not null;default:'';size:32"`
VerificationCode *string `gorm:"unique;size:255"`
VerificationExpiration time.Time
PasswordResetExpiration time.Time
TokenVersion uint32 `gorm:"default:0;not null"`
@@ -126,7 +126,7 @@ func (a *ArticleRepository) Delete(id uint32) error {
func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Article, error) {
var entityArticles []entity.Article
err := a.db.Model(&entity.Article{}).Where("instr(body, ?) > 0", filename).Order("id asc").Find(&entityArticles).Error
err := a.db.Model(&entity.Article{}).Where("body LIKE ? ESCAPE '!'", containsPattern(filename)).Order("id asc").Find(&entityArticles).Error
if err != nil {
return nil, err
}
@@ -1,77 +0,0 @@
package persistence
import (
entity "RenewCMS/internal/infrastructure/persistence/models"
"bytes"
"crypto/sha256"
"encoding/hex"
stdimage "image"
"os"
"path/filepath"
_ "golang.org/x/image/webp"
_ "image/jpeg"
_ "image/png"
"gorm.io/gorm"
)
var extensionContentTypes = map[string]string{
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
}
func BackfillImageMetadata(db *gorm.DB, uploadDir string) error {
var pending []entity.Image
err := db.Where("filename = '' OR filename IS NULL").Order("id asc").Find(&pending).Error
if err != nil {
return err
}
for _, row := range pending {
metadata, ok := readStoredImage(uploadDir, row.Path)
if !ok {
continue
}
err := db.Model(&entity.Image{}).Where("id = ?", row.ID).UpdateColumns(metadata).Error
if err != nil {
return err
}
}
return nil
}
func readStoredImage(uploadDir string, storedPath string) (map[string]any, bool) {
fileName := filepath.Base(storedPath)
contentType, known := extensionContentTypes[filepath.Ext(fileName)]
if !known {
return nil, false
}
data, err := os.ReadFile(filepath.Join(uploadDir, fileName))
if err != nil {
return nil, false
}
width, height := 0, 0
if contentType != "image/svg+xml" {
config, _, err := stdimage.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, false
}
width, height = config.Width, config.Height
}
checksum := sha256.Sum256(data)
return map[string]any{
"filename": fileName,
"size": int64(len(data)),
"content_type": contentType,
"width": width,
"height": height,
"sha256": hex.EncodeToString(checksum[:]),
}, true
}
@@ -4,67 +4,24 @@ import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"io"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"gorm.io/gorm"
)
const uploadedImagesURLPrefix = "/static/uploadedImages/"
type ImageRepository struct {
db *gorm.DB
uploadDir string
}
func NewImageRepository(db *gorm.DB, uploadDir string) *ImageRepository {
return &ImageRepository{db, uploadDir}
func NewImageRepository(db *gorm.DB) *ImageRepository {
return &ImageRepository{db}
}
var contentTypeExtensions = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpeg",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
func (i ImageRepository) Create(input domain.ImageInput) (domain.Image, error) {
fileBytes, err := io.ReadAll(input.Content)
if err != nil {
return domain.Image{}, err
}
err = os.MkdirAll(i.uploadDir, 0755)
if err != nil {
return domain.Image{}, err
}
newName := uuid.NewString() + contentTypeExtensions[input.ContentType]
finalPath := filepath.Join(i.uploadDir, newName)
err = os.WriteFile(finalPath, fileBytes, 0666)
if err != nil {
return domain.Image{}, err
}
model := mappers.ImageToModel(domain.Image{
Path: uploadedImagesURLPrefix + newName,
Filename: input.Filename,
Size: input.Size,
ContentType: input.ContentType,
Width: input.Width,
Height: input.Height,
SHA256: input.SHA256,
UploadedByID: input.UploadedByID,
})
func (i ImageRepository) Create(image domain.Image) (domain.Image, error) {
model := mappers.ImageToModel(image)
if err := i.db.Create(&model).Error; err != nil {
_ = os.Remove(finalPath)
return domain.Image{}, err
}
return mappers.ImageToDomain(model), nil
}
@@ -78,24 +35,13 @@ func (i ImageRepository) Get(id uint32) (domain.Image, error) {
}
func (i ImageRepository) Delete(id uint32) error {
var model entity.Image
err := i.db.First(&model, id).Error
if err != nil {
return mapNotFoundErr(err, domain.ErrImageNotFound)
}
err = os.Remove(filepath.Join(i.uploadDir, filepath.Base(model.Path)))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return i.db.Delete(&entity.Image{}, id).Error
return deleteOrNotFound(i.db.Delete(&entity.Image{}, id), domain.ErrImageNotFound)
}
func (i ImageRepository) List(query domain.SearchQuery) ([]domain.Image, error) {
statement := i.db.Model(&entity.Image{})
if query.Search != "" {
statement = statement.Where("instr(lower(filename), ?) > 0", strings.ToLower(query.Search))
statement = statement.Where("LOWER(filename) LIKE ? ESCAPE '!'", containsPattern(strings.ToLower(query.Search)))
}
if query.Hash != "" {
statement = statement.Where("sha256 = ?", query.Hash)
@@ -171,4 +117,24 @@ func imagesToDomain(models []entity.Image) []domain.Image {
return images
}
func (i ImageRepository) ListMissingMetadata() ([]domain.Image, error) {
var models []entity.Image
err := i.db.Where("filename = '' OR filename IS NULL").Order("id asc").Find(&models).Error
if err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
func (i ImageRepository) UpdateMetadata(id uint32, metadata domain.Metadata) error {
return i.db.Model(&entity.Image{}).Where("id = ?", id).UpdateColumns(map[string]any{
"filename": metadata.Filename,
"size": metadata.Size,
"content_type": metadata.ContentType,
"width": metadata.Width,
"height": metadata.Height,
"sha256": metadata.SHA256,
}).Error
}
var _ domain.Repository = &ImageRepository{}
@@ -2,6 +2,7 @@ package persistence
import (
"errors"
"strings"
"gorm.io/gorm"
)
@@ -22,3 +23,8 @@ func deleteOrNotFound(result *gorm.DB, sentinel error) error {
}
return nil
}
func containsPattern(fragment string) string {
escaped := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(fragment)
return "%" + escaped + "%"
}
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more