Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
421cfafec3 | ||
|
|
2303b12377 | ||
|
|
1a55a39425 | ||
|
|
e82d0ce254 | ||
|
|
161ddc0f7d | ||
|
|
c1e73bda81 | ||
|
|
508a8ab77e | ||
|
|
1e5f324579 | ||
|
|
1d4f4bd2cc | ||
|
|
d8f171b8cc | ||
|
|
8ad3941f6d | ||
|
|
d275ba3c14 | ||
|
|
3e01c29657 | ||
|
|
acd392c29d | ||
|
|
514a082c96 | ||
|
|
844842fdae |
No files matched your search
@@ -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
|
||||
@@ -15,3 +15,4 @@ AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
|
||||
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
|
||||
TRUSTED_PROXIES=
|
||||
UPDATE_FEED_URL=https://git.floriansylvain.fr
|
||||
RENEWCMS_CONTAINER=false
|
||||
+2
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
# DB
|
||||
*.db
|
||||
data/
|
||||
/data/
|
||||
|
||||
# Build
|
||||
bin
|
||||
@@ -12,6 +12,7 @@ dist/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
docker.env
|
||||
|
||||
# Generated files
|
||||
tmp/
|
||||
|
||||
+30
@@ -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"]
|
||||
@@ -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 |
|
||||
|
||||
|
||||
+126
@@ -170,6 +170,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:
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
domainAPIKey "RenewCMS/internal/domain/apikey"
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
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"
|
||||
@@ -13,14 +15,17 @@ import (
|
||||
"RenewCMS/internal/infrastructure/markdown"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/scheduler"
|
||||
"RenewCMS/internal/infrastructure/update"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
@@ -29,7 +34,10 @@ import (
|
||||
)
|
||||
|
||||
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,12 +65,20 @@ type UseCases struct {
|
||||
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
|
||||
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
|
||||
RequestRestartUseCase *useCases.RequestRestartUseCase
|
||||
RecordRunningInfoUseCase *useCases.RecordRunningInfoUseCase
|
||||
CreateAPIKeyUseCase *useCases.CreateAPIKeyUseCase
|
||||
ListAPIKeysUseCase *useCases.ListAPIKeysUseCase
|
||||
RevokeAPIKeyUseCase *useCases.RevokeAPIKeyUseCase
|
||||
AuthenticateAPIKeyUseCase *useCases.AuthenticateAPIKeyUseCase
|
||||
}
|
||||
|
||||
type Repositories struct {
|
||||
@@ -70,6 +86,8 @@ type Repositories struct {
|
||||
UserRepo domainUser.Repository
|
||||
ImageRepo domainImage.Repository
|
||||
InviteRepo domainInvite.Repository
|
||||
APIKeyRepo domainAPIKey.Repository
|
||||
PurgeSettingsRepo domainPurge.Repository
|
||||
MailRepo domainMail.Repository
|
||||
ReleaseFeed domainUpdate.ReleaseFeed
|
||||
UpdateStore domainUpdate.StatusStore
|
||||
@@ -106,7 +124,7 @@ func getDb(dbName string, uploadDir string) *gorm.DB {
|
||||
panic("Unable to open the database: " + err.Error())
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}); err != nil {
|
||||
panic("Failed to migrate database: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -136,11 +154,13 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
|
||||
UserRepo: persistence.NewUserRepository(db),
|
||||
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
|
||||
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(),
|
||||
@@ -154,8 +174,14 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
|
||||
func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||
hasher := hashing.NewBcryptHasher()
|
||||
renderer := markdown.NewConverter()
|
||||
keyGenerator := hashing.NewSHA256KeyGenerator()
|
||||
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo)
|
||||
|
||||
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),
|
||||
@@ -168,7 +194,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||
VerifyUserUseCase: useCases.NewVerifyUserUseCase(repos.UserRepo, hasher),
|
||||
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(repos.UserRepo),
|
||||
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL),
|
||||
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher),
|
||||
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now),
|
||||
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo),
|
||||
ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo),
|
||||
CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher, repos.MailRepo, settings.PublicURL),
|
||||
@@ -182,14 +208,22 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||
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),
|
||||
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),
|
||||
RecordRunningInfoUseCase: useCases.NewRecordRunningInfoUseCase(repos.RunningInfo, settings.Version, os.Getpid(), time.Now),
|
||||
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, time.Now),
|
||||
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
|
||||
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(repos.APIKeyRepo, repos.UserRepo, time.Now),
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(repos.APIKeyRepo, keyGenerator, repos.UserRepo, time.Now),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
CreateUseCase *useCases.CreateAPIKeyUseCase
|
||||
ListUseCase *useCases.ListAPIKeysUseCase
|
||||
RevokeUseCase *useCases.RevokeAPIKeyUseCase
|
||||
}
|
||||
|
||||
type CreateAPIKeyRequest struct {
|
||||
Name string `json:"name" validate:"required,max=60"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type createdKeyResponse struct {
|
||||
ID uint32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
Key string `json:"key"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type keyListItem struct {
|
||||
ID uint32 `json:"id"`
|
||||
UserID uint32 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
Status domain.Status `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
CreatedByName *string `json:"created_by_name,omitempty"`
|
||||
}
|
||||
|
||||
func NewHandler(create *useCases.CreateAPIKeyUseCase, list *useCases.ListAPIKeysUseCase, revoke *useCases.RevokeAPIKeyUseCase) *Handler {
|
||||
return &Handler{CreateUseCase: create, ListUseCase: list, RevokeUseCase: revoke}
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var request CreateAPIKeyRequest
|
||||
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, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := h.CreateUseCase.CreateAPIKey(useCases.CreateAPIKeyCommand{
|
||||
UserID: userId,
|
||||
Name: request.Name,
|
||||
ExpiresAt: request.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrExpiryNotInFuture) {
|
||||
http.Error(w, apperr.APIKeyExpiryErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(createdKeyResponse{
|
||||
ID: created.Key.ID,
|
||||
Name: created.Key.Name,
|
||||
Prefix: created.Key.Prefix,
|
||||
Key: created.RawKey,
|
||||
CreatedAt: created.Key.CreatedAt,
|
||||
ExpiresAt: created.Key.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.ListUseCase.ListAPIKeys(userId)
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]keyListItem, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
key := entry.Key
|
||||
item := keyListItem{
|
||||
ID: key.ID,
|
||||
UserID: key.UserID,
|
||||
Name: key.Name,
|
||||
Prefix: key.Prefix,
|
||||
Status: entry.Status,
|
||||
CreatedAt: key.CreatedAt,
|
||||
LastUsedAt: key.LastUsedAt,
|
||||
ExpiresAt: key.ExpiresAt,
|
||||
RevokedAt: key.RevokedAt,
|
||||
}
|
||||
if entry.ListsCreator {
|
||||
item.CreatedByName = &entry.CreatorName
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
userId, ok := claims.UserId(r)
|
||||
if !ok {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.RevokeUseCase.RevokeAPIKey(userId, uint32(id)); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrAPIKeyNotFound):
|
||||
http.Error(w, apperr.APIKeyNotFoundErrorMessage, http.StatusNotFound)
|
||||
case errors.Is(err, user.ErrUserNotFound):
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -19,12 +19,16 @@ import (
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
CreateUseCase *useCases.CreateImageUseCase
|
||||
ListUseCase *useCases.ListMediaUseCase
|
||||
RenameUseCase *useCases.RenameMediaUseCase
|
||||
DeleteUseCase *useCases.DeleteMediaUseCase
|
||||
MergeUseCase *useCases.MergeMediaUseCase
|
||||
UsageUseCase *useCases.GetMediaUsageUseCase
|
||||
CreateUseCase *useCases.CreateImageUseCase
|
||||
ListUseCase *useCases.ListMediaUseCase
|
||||
RenameUseCase *useCases.RenameMediaUseCase
|
||||
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,14 +78,18 @@ 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,
|
||||
RenameUseCase: rename,
|
||||
DeleteUseCase: deleteUseCase,
|
||||
MergeUseCase: merge,
|
||||
UsageUseCase: usage,
|
||||
CreateUseCase: create,
|
||||
ListUseCase: list,
|
||||
RenameUseCase: rename,
|
||||
DeleteUseCase: deleteUseCase,
|
||||
MergeUseCase: merge,
|
||||
UsageUseCase: usage,
|
||||
PurgeSettingsUseCase: purgeSettings,
|
||||
UpdatePurgeSettingsUseCase: updatePurgeSettings,
|
||||
PreviewPurgeUseCase: previewPurge,
|
||||
RunPurgeUseCase: runPurge,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
)
|
||||
|
||||
const bearerScheme = "bearer "
|
||||
|
||||
type apiKeyIdContextKey struct{}
|
||||
|
||||
func bearerAPIKey(r *http.Request) (string, bool) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if len(header) < len(bearerScheme) || !strings.EqualFold(header[:len(bearerScheme)], bearerScheme) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
credential := strings.TrimSpace(header[len(bearerScheme):])
|
||||
return credential, strings.HasPrefix(credential, apikey.RawKeyPrefix)
|
||||
}
|
||||
|
||||
func apiKeyIdFrom(r *http.Request) (uint32, bool) {
|
||||
id, ok := r.Context().Value(apiKeyIdContextKey{}).(uint32)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
func RequireSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, isKey := bearerAPIKey(r); isKey {
|
||||
http.Error(w, apperr.SessionRequiredErrorMessage, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func APIKeyAuth(authenticate *useCases.AuthenticateAPIKeyUseCase, maxRequests int, window time.Duration) func(http.Handler) http.Handler {
|
||||
limiter := newKeyedRateLimiter(maxRequests, window, apiKeyRateLimitKey)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
limited := limiter(next)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rawKey, isKey := bearerAPIKey(r)
|
||||
if !isKey {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
authenticated, err := authenticate.AuthenticateAPIKey(rawKey)
|
||||
if err != nil {
|
||||
writeAPIKeyError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := sessionTokenFor(authenticated)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := jwtauth.NewContext(r.Context(), token, nil)
|
||||
ctx = context.WithValue(ctx, apiKeyIdContextKey{}, authenticated.Key.ID)
|
||||
limited.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeAPIKeyError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, apikey.ErrAPIKeyInvalid) {
|
||||
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func sessionTokenFor(authenticated useCases.AuthenticatedAPIKey) (jwt.Token, error) {
|
||||
token := jwt.New()
|
||||
if err := token.Set("user_id", float64(authenticated.User.ID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := token.Set("token_version", float64(authenticated.User.TokenVersion)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func apiKeyRateLimitKey(r *http.Request) string {
|
||||
id, _ := apiKeyIdFrom(r)
|
||||
return strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -35,3 +35,10 @@ const NothingToUpdateErrorMessage = "There is nothing to update."
|
||||
const UpdateRefusedErrorMessage = "This update cannot be downloaded."
|
||||
const UpdateRestartMismatchErrorMessage = "The update state changed. Reload the page and try again."
|
||||
const ArticleBodyFormatErrorMessage = "The format must be one of: markdown, html, json."
|
||||
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."
|
||||
+53
-3
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/apikey"
|
||||
"RenewCMS/api/handlers/article"
|
||||
"RenewCMS/api/handlers/auth"
|
||||
"RenewCMS/api/handlers/frontend"
|
||||
@@ -33,8 +34,21 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write(msg)
|
||||
}
|
||||
|
||||
const (
|
||||
apiKeyMaxRequests = 120
|
||||
apiKeyWindow = time.Minute
|
||||
)
|
||||
|
||||
func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler {
|
||||
c.apiKeyAuthOnce.Do(func() {
|
||||
c.apiKeyAuthMiddleware = middleware.APIKeyAuth(c.AuthenticateAPIKeyUseCase, apiKeyMaxRequests, apiKeyWindow)
|
||||
})
|
||||
return c.apiKeyAuthMiddleware
|
||||
}
|
||||
|
||||
func NewArticleRouter(container *UseCases) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(container.apiKeyAuth())
|
||||
|
||||
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase, container.GetUserUseCase)
|
||||
|
||||
@@ -85,11 +99,18 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
|
||||
r.Post("/invite/accept", acceptInviteHandler.AcceptInvite)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Post("/verify", verificationHandler.Verify)
|
||||
r.Delete("/register", verificationHandler.CancelRegistration)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(container.apiKeyAuth())
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Get("/me", meHandler.Me)
|
||||
})
|
||||
return r
|
||||
@@ -102,6 +123,7 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings,
|
||||
inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase)
|
||||
|
||||
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))
|
||||
@@ -124,6 +146,7 @@ func NewSettingsRouter(container *UseCases) http.Handler {
|
||||
settingsHandler := settings.NewHandler(container.GetSettingsUseCase)
|
||||
|
||||
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))
|
||||
@@ -143,6 +166,7 @@ func NewSystemRouter(container *UseCases) http.Handler {
|
||||
|
||||
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.RequestRestartUseCase, container.GetUserUseCase)
|
||||
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
@@ -165,15 +189,39 @@ func NewSystemRouter(container *UseCases) http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
func NewMediaRouter(container *UseCases) http.Handler {
|
||||
func NewAPIKeyRouter(container *UseCases) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase)
|
||||
apiKeyHandler := apikey.NewHandler(container.CreateAPIKeyUseCase, container.ListAPIKeysUseCase, container.RevokeAPIKeyUseCase)
|
||||
|
||||
r.Use(middleware.RequireSession)
|
||||
r.Use(jwtauth.Verifier(container.Token))
|
||||
r.Use(jwtauth.Authenticator(container.Token))
|
||||
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
|
||||
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
|
||||
r.Post("/", apiKeyHandler.Create)
|
||||
r.Get("/", apiKeyHandler.List)
|
||||
r.Delete("/{id}", apiKeyHandler.Revoke)
|
||||
return r
|
||||
}
|
||||
|
||||
func NewMediaRouter(container *UseCases) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
mediaHandler := media.NewHandler(container.CreateImageUseCase, container.ListMediaUseCase, container.RenameMediaUseCase, container.DeleteMediaUseCase, container.MergeMediaUseCase, container.GetMediaUsageUseCase, container.GetPurgeSettingsUseCase, container.UpdatePurgeSettingsUseCase, container.PreviewPurgeUseCase, container.RunPurgeUseCase)
|
||||
|
||||
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)
|
||||
@@ -198,6 +246,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
|
||||
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
||||
r.Mount("/settings", NewSettingsRouter(container))
|
||||
r.Mount("/media", NewMediaRouter(container))
|
||||
r.Mount("/api-key", NewAPIKeyRouter(container))
|
||||
r.Mount("/system", NewSystemRouter(container))
|
||||
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||
|
||||
@@ -225,7 +274,7 @@ func enableCorsIfConfigured(r *chi.Mux, origins []string) {
|
||||
}
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: origins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: false,
|
||||
@@ -238,6 +287,7 @@ 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)
|
||||
|
||||
+13
-3
@@ -49,6 +49,7 @@ var envDefaults = map[string]string{
|
||||
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
|
||||
"TRUSTED_PROXIES": "",
|
||||
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
|
||||
"RENEWCMS_CONTAINER": "false",
|
||||
}
|
||||
|
||||
const versionFlag = "--version"
|
||||
@@ -80,7 +81,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() {
|
||||
@@ -131,9 +132,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)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
renewcms:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VERSION: ${VERSION:-dev}
|
||||
image: renewcms:${VERSION:-dev}
|
||||
@@ -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:
|
||||
@@ -0,0 +1,13 @@
|
||||
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=
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# API reference
|
||||
|
||||
The full, searchable reference is the Integration page of the running app (`/integration`). It
|
||||
renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for
|
||||
readers without the UI.
|
||||
|
||||
## Base URL
|
||||
The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`,
|
||||
for example `https://cms.example/v1/article/`.
|
||||
|
||||
## Authentication
|
||||
- Public routes need nothing: `GET /v1/article/` and `GET /v1/article/{id}`. They return online
|
||||
articles only.
|
||||
- Everything else needs a signed-in browser session (the `jwt` cookie) or an API key:
|
||||
`Authorization: Bearer rcms_...`. Create keys at `/integration/api-keys`. A key acts as its
|
||||
creator, with that user's role, and shows in full once.
|
||||
- A key cannot call `POST /v1/auth/verify`, `DELETE /v1/auth/register`, `/v1/api-key/*` or
|
||||
`/v1/system/*`. Those answer 403 "This action needs a real session." An invalid, unknown, expired
|
||||
or revoked key answers 401, even on public routes.
|
||||
- Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header.
|
||||
|
||||
## Routes a key can call (20)
|
||||
| Group | Routes |
|
||||
| --- | --- |
|
||||
| Articles | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
|
||||
| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage |
|
||||
| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me |
|
||||
| Settings | GET /v1/settings |
|
||||
|
||||
Article routes accept `?format=markdown|html|json` (default `markdown`). `GET /v1/media/` accepts
|
||||
`q`, `hash`, `cursor`, `filter`, `sort` and `limit`.
|
||||
|
||||
## Errors
|
||||
Every error is a plain-text body with one sentence; the status is in the HTTP status line. The
|
||||
statuses are 400, 401, 403, 404, 409, 429 and 500. Messages are listed on the Integration page.
|
||||
|
||||
## Notes
|
||||
- Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL.
|
||||
- Browsers can call the API only from origins in the server's CORS setting.
|
||||
@@ -0,0 +1,23 @@
|
||||
# API keys
|
||||
|
||||
Developers can authenticate against `/v1` with a named API key sent as `Authorization: Bearer rcms_...` instead of the `jwt` cookie. A key acts with exactly the rights of the user who created it.
|
||||
|
||||
## Managing keys
|
||||
|
||||
Key management needs a real session (the `jwt` cookie from a verified user).
|
||||
|
||||
- `POST /v1/api-key` with `{"name": "ci", "expires_at": "<RFC3339, optional>"}` answers `201` with `id`, `name`, `prefix`, `key`, `created_at`, `expires_at`. The full `key` is shown once and never again.
|
||||
- `GET /v1/api-key` lists the caller's keys (all keys for the owner), newest first, with a `status` of `active`, `expired` or `revoked`.
|
||||
- `DELETE /v1/api-key/{id}` revokes a key and answers `204`. Revoking twice keeps the first `revoked_at`.
|
||||
|
||||
Only the SHA-256 hash and a 12 character prefix are stored.
|
||||
|
||||
## Where a key is accepted
|
||||
|
||||
`/v1/article` (public and protected routes), `/v1/user`, `/v1/media`, `/v1/settings` and `GET /v1/auth/me`. An invalid, expired or revoked `rcms_` key answers `401` and is never treated as anonymous. Each key is limited to 120 requests per minute (`429` with `Retry-After`). `last_used_at` is updated at most once per minute.
|
||||
|
||||
## Session-only routes
|
||||
|
||||
`/v1/api-key`, `POST /v1/auth/verify`, `DELETE /v1/auth/register` and every `/v1/system` route answer `403 This action needs a real session.` to any `rcms_` Bearer header. The public auth routes ignore the header.
|
||||
|
||||
A successful password reset revokes every key of that user.
|
||||
@@ -30,6 +30,29 @@ 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.
|
||||
|
||||
@@ -30,8 +30,11 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|
||||
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. |
|
||||
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
|
||||
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
|
||||
| `RENEWCMS_CONTAINER` | `false` | Set to `true` (case-insensitive; any other value means `false`) when the app runs in a container. Settings then tells the owner to update by pulling a new image instead of offering browser updates, and `can_apply` is always `false`. The Dockerfile and `docker-compose.yml` set it to `true`. Update checks still work. |
|
||||
| `UPDATE_FEED_URL` | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, next to `DB_FILE`. |
|
||||
|
||||
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.
|
||||
|
||||
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
|
||||
|
||||
For example, `WorkingDirectory=/var/lib/renewcms` with `DB_FILE=./data/renewcms.db` puts the database in `/var/lib/renewcms/data`. Browser-update `--data-dir` must name that directory, because the app reads helper status and update records beside the database. It is not necessarily `WorkingDirectory` or `ReadWritePaths`. Do not change an existing `DB_FILE` merely to match the helper: that could open a different, empty database. See [browser-update setup](deployment.md#browser-updates).
|
||||
|
||||
+9
-1
@@ -10,6 +10,8 @@ RenewCMS ships as **one self-contained executable**. It serves:
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
On any machine with the [dev prerequisites](development.md#prerequisites):
|
||||
@@ -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,6 +270,12 @@ 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.
|
||||
|
||||
## Backups
|
||||
|
||||
Everything worth backing up is under the data directory. Take a consistent SQLite copy while the service is running:
|
||||
|
||||
@@ -31,9 +31,10 @@ The visual spec of record for the back-office SPA and the transactional emails,
|
||||
| [Editor](screens/editor.md) | `/article/:id/edit` | 22 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Media](screens/media.md) | `/media` | 41 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Media picker](screens/media-picker.md) | `/article/:id/edit` (dialog) | 13 | `source/RenewCMS Screens Back-office.dc.html` |
|
||||
| [Integration](screens/integration.md) | `/integration` | 2 | `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` |
|
||||
| [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` |
|
||||
@@ -41,6 +42,8 @@ The visual spec of record for the back-office SPA and the transactional emails,
|
||||
## Other boards
|
||||
|
||||
- `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`.
|
||||
- `source/RenewCMS Components Product.dc.html`: not regenerated for the new screens either. The matching component changes are in `components.md`, under "Added with the Users and Profile screens" and "Added with roles, invite actions and Settings".
|
||||
|
||||
@@ -30,7 +30,12 @@
|
||||
| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset, Accept invite |
|
||||
| ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Accept invite, Register (403) |
|
||||
| CodeBlock | custom (shiki) + UButton | code: string, lang: string, label? | none | none | Integration |
|
||||
| EndpointCard | UCard, UBadge, UTabs | method: 'GET', path, description, examples: { fetch, curl, response } | none | none | Integration |
|
||||
| EndpointCard | UCollapsible, UBadge, UTable, UTabs | method: 'GET'\|'POST'\|'PATCH'\|'DELETE', path, description, access, keyAccess, body?, examples: { fetch, curl, response }, errors, sessionOnly? | none | none | Integration |
|
||||
| SectionIndex | custom nav (desktop), USelectMenu "Jump to" (mobile) | sections: { id, label }[], active: string | jump | none | Integration |
|
||||
| SessionOnlyGroup | UAlert-style note + list rows | routes: { method, path }[] | none | none | Integration |
|
||||
| ApiKeysTable | UTable, UBadge | keys: ApiKey[], showCreatedBy: boolean | revoke | none | API keys |
|
||||
| CreateApiKeyModal | UModal, UForm, UFormField, UInput | open: boolean | create | none | API keys |
|
||||
| ApiKeyReveal | UModal, UAlert, UButton | key: string | done | none | API keys |
|
||||
| ErrorView | custom + UButton | kind: 'not-found'\|'error' | retry | none | Not found, error boundary |
|
||||
| OfflineBanner | UBanner | none (reads useOnline) | none | none | DashboardShell |
|
||||
| AppBoot | USkeleton inside DashboardShell | none | none | none | First load of any verified route |
|
||||
@@ -809,3 +814,18 @@ 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).
|
||||
@@ -15,10 +15,11 @@ A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it
|
||||
| `/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 |
|
||||
| `/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 · 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 |
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Integration: API keys
|
||||
|
||||
Route: `/integration/api-keys`. Component: `ApiKeysPage.dc.html`, mounted by `AppFrame`.
|
||||
|
||||
- **Visual reference:** `source/RenewCMS Screens API keys.dc.html`, group "API keys", drawn by `source/ApiKeysPage.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
|
||||
|
||||
## API
|
||||
`POST /v1/api-key` · `GET /v1/api-key` · `DELETE /v1/api-key/{id}`. Access: Session only. An API key cannot manage keys; with a key these routes answer 403 "This action needs a real session."
|
||||
- `POST /v1/api-key` body: `name` (string, required, max 60), `expires_at` (RFC3339 string, optional, in the future). A past date answers 400 "The expiry must be a date in the future."
|
||||
- `DELETE /v1/api-key/{id}`: unknown id answers 404 "API key not found".
|
||||
|
||||
## Placement
|
||||
Under Integration, as a sub-page linked from the Authentication section ("Manage API keys") and the sidebar item. Keys exist to call the API and admins can create them; Settings is owner-only today. The mobile top bar has a back arrow that returns to Integration.
|
||||
|
||||
## Content
|
||||
- Columns: Name, Key (prefix, mono), Status (active, expired, revoked), Created by (owners only), Created, Last used, Expires, Revoke. Owners see all keys; admins see their own.
|
||||
- Create key modal: Name (required, up to 60, counter), Expiry date (optional, must be in the future). Errors: "Enter a name." / "Use 60 characters or fewer." / "Choose a date in the future."
|
||||
- The Expiry field is a native date input. The displayed format follows the browser's locale, so there is no custom format. The board draws one example date only.
|
||||
- One-time reveal: warning "You won't be able to see this key again.", monospace key, Copy / Copied, "Closing this window discards the key from the screen. Copy it first." Button "Done".
|
||||
- Revoke: "Revoke this key?" / ""Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can't be undone." Buttons "Keep key", "Revoke key". Toast "Key revoked".
|
||||
- Empty: "No API keys yet" / "Create a key to call the API from a script or service." Error: "Couldn't load your API keys" / "Check your connection and try again."
|
||||
- Note: "Resetting a user's password revokes all of their keys."
|
||||
|
||||
## Mobile: pinned Create key
|
||||
On mobile the "Create key" button is pinned to the bottom of the viewport (44 px high, full width, on a bar with a top border). It shows in Owner list, Admin own keys only, Loading, Empty, Error and Revoked, and is hidden behind every modal state. In Empty it replaces the inline button. The content has bottom padding so nothing sits under the bar, and the toast sits above it. Desktop keeps "Create key" in the page header (and in the Empty card).
|
||||
|
||||
## States
|
||||
|
||||
Names are matched exactly by the visual-check script.
|
||||
|
||||
### Owner: list
|
||||
|
||||
### Admin: own keys only
|
||||
|
||||
### Loading
|
||||
the list request is in flight. Skeleton rows appear only after 150 ms.
|
||||
|
||||
### Empty
|
||||
|
||||
### Error
|
||||
a 500 or network failure on the list request. If a key is created while this state is showing, the list reloads, the new key appears and the error alert is cleared.
|
||||
|
||||
### Create key
|
||||
|
||||
### Create key: validation
|
||||
|
||||
### Create key: name too long
|
||||
|
||||
### One-time reveal
|
||||
|
||||
### One-time reveal: copied
|
||||
|
||||
### Revoke: confirm
|
||||
|
||||
### Revoked
|
||||
|
||||
## Component inventory (Nuxt UI v4)
|
||||
`UTable`, `UBadge`, `UModal` with `UForm`/`UFormField`/`UInput` (type date), `UAlert` color warning, `UButton`, `useClipboard`.
|
||||
@@ -1,22 +1,82 @@
|
||||
# Connect your website
|
||||
# Integration (API reference)
|
||||
|
||||
- **Route:** `/integration`
|
||||
- **API:** none (static)
|
||||
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Integration" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
|
||||
Route: `/integration`. Component: `IntegrationPage.dc.html`, mounted by `AppFrame`. The API keys sub-page is in `screens/integration-api-keys.md`.
|
||||
|
||||
Written for a developer. The base URL is window.location.origin.
|
||||
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Integration", drawn by `source/IntegrationPage.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
|
||||
|
||||
## Navigation decision
|
||||
Sticky section index on the right (desktop), "Jump to" select (mobile). A reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page and deep links; the index keeps one page with anchors and shows position.
|
||||
|
||||
## Page copy
|
||||
- Header: breadcrumb "Integration", title "API reference", description "Everything a developer needs to build on RenewCMS."
|
||||
- Intro: "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office."
|
||||
- Base URL: label "Base URL", button "Copy" / "Copied" (2 s, announced).
|
||||
- Authentication, card "Public": "No key needed. Two read-only routes, and only online articles are returned." `GET /v1/article/`, `GET /v1/article/{id}`.
|
||||
- Card "API key": "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator." CodeBlock tabs curl, fetch (GET /v1/auth/me). "Keys start with `rcms_` and are shown in full once, when you create them." Link "Manage API keys".
|
||||
- Note 1: "A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
|
||||
- Note 2: "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
|
||||
- Groups: Articles (7), Media (6), Users and invites (6), Settings (1), Session only (12 routes, 3 sub-groups; every `/v1/system` route is in this group). Session-only banner: "These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session.""
|
||||
- Errors: "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain." Table 400, 401, 403, 404, 429, 500 with the real messages below. Sample: a status line plus the text body (`HTTP/1.1 403 Forbidden`, `Content-Type: text/plain`, blank line, `This action needs a real session.`).
|
||||
- Notes: image URL prefix, CORS (existing copy), and "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default."
|
||||
|
||||
## EndpointCard states
|
||||
- Collapsed (44 px row): method badge, mono path, one-line description, chips, chevron. Desktop is one line; mobile is three (method and path, description, chips).
|
||||
- Expanded: "Request body" table (Name, Type, Required, Rule; "No request body." when empty), tabs fetch / curl / Response with one CodeBlock and Copy, then "Errors": status chip plus one-line caption (also its `title` tooltip).
|
||||
- Session only: dashed border, muted fill, lock icon, "Session only" chip, no method colour, no expand.
|
||||
- Chips: access "Public", "Verified user", "Owner only"; key "API key OK" (success soft) or "Session only" (dashed, muted).
|
||||
- Method badges: GET `--ui-bg-inverted`, POST success soft, PATCH warning soft, DELETE error soft. Existing tokens only.
|
||||
|
||||
## States
|
||||
Desktop 1440×900, mobile 390×844, light and dark. Names are matched exactly by the visual-check script.
|
||||
|
||||
### Default
|
||||
### 1. Default
|
||||
|
||||
**Trigger:** Opened from the sidebar or the Home card.
|
||||
### 2. Group scrolled, one card expanded
|
||||
|
||||
- Breadcrumbs: Integration (mobile title "Integration")
|
||||
### 3a. Copied: base URL
|
||||
|
||||
### Copied
|
||||
### 3b. Copied: CodeBlock
|
||||
|
||||
**Trigger:** The base URL was copied. The button reads "Copied" for 2 s and this is announced.
|
||||
### 4. Authentication
|
||||
|
||||
- Breadcrumbs: Integration (mobile title "Integration")
|
||||
- copied: true
|
||||
### 5. Session only
|
||||
|
||||
### 6. Errors
|
||||
|
||||
### 7. Navigation
|
||||
|
||||
## Error messages
|
||||
Plain text, one sentence. Used in the table and in the per-endpoint error captions.
|
||||
- 400: "The request cannot be processed due to a mismatch in the format of the body." / "The expiry must be a date in the future."
|
||||
- 401: "You must be logged in to access this resource."
|
||||
- 403: "This action needs a real session." (session-only routes called with a key) / "Only the owner can do this." / "You must verify your account to access this resource."
|
||||
- 404: "Article not found" / "Image not found" / "Invite not found" / "API key not found"
|
||||
- 429: "Too many requests. Please try again later." A per-key limit also sends `Retry-After`.
|
||||
- 500: "Internal server error"
|
||||
- 409, only as examples under their endpoints: `POST /v1/user/invite` "This address already has an account."; `POST /v1/media/merge` "Some of the images to merge changed. Refresh and try again." There is no 409 or 422 row in the table.
|
||||
- 413 and 415 on `POST /v1/media/` keep their earlier plain captions; no real message is known.
|
||||
|
||||
## Request bodies
|
||||
- `POST /v1/article/`: `title` (string, required, 3 to 200 characters), `body` (string, required, max 10000).
|
||||
- `PATCH /v1/article/{id}`: `body` (string, required, max 10000).
|
||||
- `PATCH /v1/media/{id}` (rename): `filename` (string, required).
|
||||
- `POST /v1/media/merge`: `keep_id` (number, required, > 0), `merge_ids` (number[], required, at least 1, each > 0).
|
||||
- `POST /v1/media/`: multipart/form-data, 1 file field (shown as "multipart/form-data, 1 file field").
|
||||
- `PATCH /v1/user/me`: `display_name` (string, required, max 60 characters).
|
||||
- `POST /v1/user/invite`: `email` (string, required, valid email).
|
||||
- `POST /v1/system/update/restart`: optional body `{ "kind": "update" | "restart" }`.
|
||||
|
||||
## Component inventory and Nuxt UI v4
|
||||
- EndpointCard (extended): `UCollapsible` (trigger is a button with aria-expanded), `UBadge` (method, chips, status), `UTable` (body fields), `UTabs` (fetch, curl, Response), CodeBlock.
|
||||
- SectionIndex (new): custom `<nav>` of anchor buttons with `aria-current`; sticky, scroll-spy. Mobile: `USelectMenu` labelled "Jump to".
|
||||
- SessionOnlyGroup (new): `UAlert`-style info note plus list rows; no new colour.
|
||||
- ErrorTable: `UTable`. Info notes: existing note style (or `UAlert` variant `naked`).
|
||||
|
||||
## Assumptions to confirm
|
||||
- Paths not in the brief: `/v1/settings`, `/v1/system/update/*`, `POST /v1/auth/register|verify|logout`.
|
||||
- [to confirm] The multipart field name on `POST /v1/media/` is written as `file` as a placeholder.
|
||||
- Response field names in the samples (for example `articlesUpdated`, `display_name` in the profile response) are illustrative.
|
||||
- Code samples use a simple keyword and string colouring.
|
||||
|
||||
## Tokens
|
||||
No new tokens. Foundations check: colours, radius (0) and spacing come from existing `--ui-*` and `--rc-*` values; every control has a 2 px primary focus ring; index, select and cards are keyboard operable. Open question: the board hard-codes a few pixel sizes, as the other screens do.
|
||||
@@ -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. |
|
||||
|
||||
@@ -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,55 @@ 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`.
|
||||
@@ -0,0 +1,94 @@
|
||||
<!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="flex:1;min-height:0;overflow:hidden;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px;padding:{{ pad }};position:relative;display:flex;flex-direction:column;gap:16px">
|
||||
<div style="display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap">
|
||||
<div style="flex:1;min-width:200px;display:flex;flex-direction:column;gap:4px"><span style="color:var(--ui-text-muted)">Integration › API keys</span><span style="font:600 {{ h1 }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API keys</span><span style="color:var(--ui-text-muted);text-wrap:pretty">{{ sub }}</span></div>
|
||||
<sc-if value="{{ desk }}"><span style="height:36px;padding:0 14px;display:flex;align-items:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></sc-if>
|
||||
</div>
|
||||
<sc-if value="{{ err }}"><div role="alert" style="display:flex;gap:10px;padding:12px 14px;background:var(--rc-error-soft);color:var(--ui-error)"><span style="flex:1"><strong>Couldn't load your API keys</strong><br>Check your connection and try again.</span><span style="font-weight:500;color:var(--ui-text-highlighted)">Try again</span></div></sc-if>
|
||||
<sc-if value="{{ empty }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:48px 24px;display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">No API keys yet</span><span style="color:var(--ui-text-toned);max-width:360px;text-wrap:pretty">Create a key to call the API from a script or service.</span><sc-if value="{{ desk }}"><span style="height:36px;padding:0 14px;display:flex;align-items:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></sc-if></div></sc-if>
|
||||
<sc-if value="{{ showList }}">
|
||||
<sc-if value="{{ desk }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||
<div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:36px;align-items:center;padding:0 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Key</span><span>Status</span><sc-if value="{{ owner }}"><span>Created by</span></sc-if><span>Created</span><span>Last used</span><span>Expires</span><span></span></div>
|
||||
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="4"><div style="display:grid;grid-template-columns:{{ cols }};gap:12px;height:48px;align-items:center;padding:0 12px;border-top:1px solid var(--ui-border);background:{{ r.bg }}">
|
||||
<sc-if value="{{ r.skel }}"><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:70%;background:var(--ui-bg-accented)"></span><span style="height:20px;width:56px;background:var(--ui-bg-accented)"></span><span style="height:12px;width:60%;background:var(--ui-bg-accented)"></span><span></span><span></span><span></span></sc-if>
|
||||
<sc-if value="{{ r.name }}"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;color:{{ r.fg }}">{{ r.name }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span><span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></span>
|
||||
<sc-if value="{{ owner }}"><span style="color:{{ r.fg2 }};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ r.by }}</span></sc-if><span style="color:{{ r.fg2 }}">{{ r.created }}</span><span style="color:{{ r.fg2 }}">{{ r.used }}</span><span style="color:{{ r.fg2 }}">{{ r.exp }}</span>
|
||||
<span style="display:flex;justify-content:flex-end"><sc-if value="{{ r.canRevoke }}"><span style="height:28px;padding:0 10px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></span></sc-if>
|
||||
</div></sc-for></div></sc-if>
|
||||
<sc-if value="{{ mob }}"><div style="background:var(--ui-bg);border:1px solid var(--ui-border);margin:0 -16px;border-left:0;border-right:0">
|
||||
<sc-for list="{{ rows }}" as="r" hint-placeholder-count="4"><div style="display:flex;flex-direction:column;gap:6px;padding:12px 16px;border-bottom:1px solid var(--ui-border);background:{{ r.bg }}">
|
||||
<sc-if value="{{ r.skel }}"><span style="height:14px;width:55%;background:var(--ui-bg-accented)"></span><span style="height:12px;width:40%;background:var(--ui-bg-accented)"></span></sc-if>
|
||||
<sc-if value="{{ r.name }}"><div style="display:flex;align-items:center;gap:8px"><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;font-size:15px;color:{{ r.fg }}">{{ r.name }}</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:{{ r.sbg }};color:{{ r.sfg }};font-size:12px;font-weight:500">{{ r.status }}</span></div>
|
||||
<span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ r.fg2 }}">{{ r.prefix }}</span>
|
||||
<span style="color:{{ r.fg2 }}">Created {{ r.created }}<sc-if value="{{ owner }}"> by {{ r.by }}</sc-if> · Last used {{ r.used }} · Expires {{ r.exp }}</span>
|
||||
<sc-if value="{{ r.canRevoke }}"><span style="align-self:flex-start;height:44px;padding:0 14px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">Revoke</span></sc-if></sc-if>
|
||||
</div></sc-for></div></sc-if>
|
||||
</sc-if>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;font-size:14px;line-height:22px;color:var(--ui-text-toned)">
|
||||
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Resetting a user's password revokes all of their keys.</span></div>
|
||||
</div>
|
||||
<sc-if value="{{ pinned }}"><div style="position:absolute;left:0;right:0;bottom:0;z-index:40;padding:12px 16px;background:var(--ui-bg);border-top:1px solid var(--ui-border)"><span style="height:44px;display:flex;align-items:center;justify-content:center;gap:6px;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">Create key</span></div></sc-if>
|
||||
<sc-if value="{{ toast }}"><div role="status" style="position:absolute;{{ toastPos }};z-index:60;display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);color:var(--ui-success)"><span style="display:flex;flex-direction:column"><strong>Key revoked</strong><span style="color:var(--ui-text-toned)">"Nightly media import" can no longer be used.</span></span></div></sc-if>
|
||||
</div>
|
||||
<sc-if value="{{ modal }}"><div style="position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:{{ mAlign }};justify-content:center;padding:{{ mPad }}">
|
||||
<div role="dialog" aria-modal="true" style="width:{{ mW }};max-width:100%;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-modal);padding:20px;display:flex;flex-direction:column;gap:14px;color:var(--ui-text)">
|
||||
<div style="display:flex;flex-direction:column;gap:4px"><span style="font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ m.title }}</span><span style="color:var(--ui-text-toned);font-size:14px;line-height:22px;text-wrap:pretty">{{ m.desc }}</span></div>
|
||||
<sc-if value="{{ m.form }}">
|
||||
<div style="display:flex;flex-direction:column;gap:6px"><div style="display:flex;justify-content:space-between"><span style="font-weight:500;color:var(--ui-text-highlighted)">Name</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:{{ m.cntFg }}">{{ m.count }}</span></div><span style="height:40px;padding:0 12px;display:flex;align-items:center;border:1px solid {{ m.nameBd }};background:var(--ui-bg);color:var(--ui-text-highlighted)">{{ m.name }}</span><span style="color:{{ m.nameFg }}">{{ m.nameHelp }}</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;color:var(--ui-text-highlighted)">Expiry date <span style="font-weight:400;color:var(--ui-text-muted)">(optional)</span></span><span style="height:40px;padding:0 12px;display:flex;align-items:center;border:1px solid {{ m.expBd }};background:var(--ui-bg);color:{{ m.expTx }}">{{ m.exp }}</span><span style="color:{{ m.expFg }}">{{ m.expHelp }}</span></div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ m.reveal }}">
|
||||
<div style="display:flex;gap:10px;padding:10px 12px;background:var(--rc-warning-soft);color:var(--ui-warning)"><span style="display:flex;padding-top:2px"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/triangle-alert.svg') center / contain no-repeat"></span></span><span style="font-weight:500">You won't be able to see this key again.</span></div>
|
||||
<div style="display:flex;flex-direction:{{ keyDir }};border:1px solid var(--ui-border-accented);background:var(--ui-bg-elevated)"><span style="flex:1;min-width:0;padding:10px 12px;font:400 12px/20px 'IBM Plex Mono',monospace;word-break:break-all;color:var(--ui-text-highlighted)">rcms_Kf3x9QwL0aBcT7nV2uYdE5sHjR8mPz4WqXg6C1oIyNe</span><span style="height:44px;padding:0 14px;display:flex;align-items:center;justify-content:center;gap:6px;border-left:1px solid var(--ui-border-accented);font-weight:500;color:{{ m.cpFg }}"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ m.cpIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ m.cpIcon }}.svg') center / contain no-repeat"></span>{{ m.cpT }}</span></div>
|
||||
<span style="color:var(--ui-text-muted)">Closing this window discards the key from the screen. Copy it first.</span>
|
||||
</sc-if>
|
||||
<div style="display:flex;flex-direction:{{ btnDir }};justify-content:flex-end;gap:8px"><sc-if value="{{ m.cancel }}"><span style="height:44px;padding:0 14px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">{{ m.cancel }}</span></sc-if><span style="height:44px;padding:0 14px;display:flex;align-items:center;justify-content:center;font-weight:500;background:{{ m.cbg }};color:{{ m.cfg }};opacity:{{ m.cop }}">{{ m.confirm }}</span></div>
|
||||
</div></div></sc-if>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script>
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
const mob = !!this.props.mobile, k = (this.props.s && this.props.s.ak) || {}
|
||||
const owner = k.view !== 'admin'
|
||||
const R = (name, prefix, status, by, created, used, exp) => {
|
||||
const rv = status === 'revoked', tone = { active: ['var(--rc-success-soft)', 'var(--ui-success)'], expired: ['var(--rc-warning-soft)', 'var(--ui-warning)'], revoked: ['var(--ui-bg-elevated)', 'var(--ui-text-muted)'] }[status]
|
||||
return { name, prefix, status, by, created, used, exp, sbg: tone[0], sfg: tone[1], bg: rv ? 'var(--ui-bg-muted)' : 'var(--ui-bg)', fg: rv ? 'var(--ui-text-muted)' : 'var(--ui-text-highlighted)', fg2: rv ? 'var(--ui-text-muted)' : 'var(--ui-text-toned)', canRevoke: !rv }
|
||||
}
|
||||
let all = owner
|
||||
? [R('Website build', 'rcms_Kf3x…', 'active', 'Marta Alves', '12 Mar 2026', '3 Oct 2026', 'None'), R('Nightly media import', 'rcms_Mq7b…', k.revoked ? 'revoked' : 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Staging preview', 'rcms_Zt2d…', 'expired', 'Marta Alves', '14 Jun 2026', '20 Aug 2026', '30 Aug 2026'), R('Old migration script', 'rcms_Hn5w…', 'revoked', 'Inès Duarte', '3 Apr 2026', '9 May 2026', 'None')]
|
||||
: [R('Nightly media import', 'rcms_Mq7b…', 'active', 'Jonas', '2 Apr 2026', '3 Oct 2026', '31 Dec 2026'), R('Old CI token', 'rcms_Lp9c…', 'revoked', 'Jonas', '18 Jan 2026', '2 Feb 2026', 'None')]
|
||||
const st = k.state || 'list'
|
||||
const rows = st === 'loading' ? Array.from({ length: 4 }, () => ({ skel: true, bg: 'var(--ui-bg)' })) : all
|
||||
const cp = { reveal: true }
|
||||
const b = (o) => ({ cancel: 'Cancel', cbg: 'var(--ui-primary)', cfg: 'var(--ui-bg)', cop: '1', ...o })
|
||||
const form = (o) => b({ form: true, title: 'Create API key', desc: 'The key acts as you, with your role. It is shown in full once.', confirm: 'Create key', name: 'Nightly media import', count: '20 / 60', cntFg: 'var(--ui-text-muted)', nameBd: 'var(--ui-border-accented)', nameFg: 'var(--ui-text-muted)', nameHelp: 'Required. Up to 60 characters.', exp: '31 Dec 2026', expTx: 'var(--ui-text-highlighted)', expBd: 'var(--ui-border-accented)', expFg: 'var(--ui-text-muted)', expHelp: 'Optional. Must be in the future. Leave it empty for a key that does not expire.', ...o })
|
||||
const M = {
|
||||
create: form({}),
|
||||
invalid: form({ name: '', count: '0 / 60', nameBd: 'var(--ui-error)', nameFg: 'var(--ui-error)', nameHelp: 'Enter a name.', exp: '12 Mar 2026', expBd: 'var(--ui-error)', expFg: 'var(--ui-error)', expHelp: 'Choose a date in the future.' }),
|
||||
toolong: form({ name: 'Nightly import of the new media library from the Brussels office file server', count: '75 / 60', cntFg: 'var(--ui-error)', nameBd: 'var(--ui-error)', nameFg: 'var(--ui-error)', nameHelp: 'Use 60 characters or fewer.' }),
|
||||
reveal: b({ reveal: true, title: 'Your new API key', desc: '"Nightly media import" is ready. Copy the key and store it somewhere safe.', cancel: '', confirm: 'Done', cpT: 'Copy', cpIcon: 'copy', cpFg: 'var(--ui-text-highlighted)' }),
|
||||
copied: b({ reveal: true, title: 'Your new API key', desc: '"Nightly media import" is ready. Copy the key and store it somewhere safe.', cancel: '', confirm: 'Done', cpT: 'Copied', cpIcon: 'check', cpFg: 'var(--ui-success)' }),
|
||||
revoke: b({ title: 'Revoke this key?', desc: '"Nightly media import" (rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can\'t be undone.', cancel: 'Keep key', confirm: 'Revoke key', cbg: 'var(--ui-error)', cfg: 'var(--ui-bg)' })
|
||||
}
|
||||
const m = k.modal ? M[k.modal] : null
|
||||
return {
|
||||
mob, desk: !mob, owner, rows, pad: mob ? '20px 16px 84px' : '32px', pinned: mob && !m, h1: mob ? '20px/28px' : '28px/36px',
|
||||
sub: owner ? 'Keys act as the user who created them. As an owner you see everyone\'s keys.' : 'Keys act as you, with your role. You only see your own keys.',
|
||||
err: st === 'error', empty: st === 'empty', showList: st === 'list' || st === 'loading',
|
||||
cols: owner ? 'minmax(0,1.3fr) 110px 76px minmax(0,1fr) 100px 100px 100px 80px' : 'minmax(0,1.3fr) 110px 76px 100px 100px 100px 80px',
|
||||
toast: !!k.toast, toastPos: mob ? 'left:16px;right:16px;bottom:84px' : 'right:24px;bottom:24px;width:320px',
|
||||
modal: !!m, m: m || {}, mAlign: mob ? 'flex-end' : 'flex-start', mPad: mob ? '16px' : '120px 16px 16px', mW: mob ? '100%' : '480px', btnDir: mob ? 'column-reverse' : 'row', keyDir: mob ? 'column' : 'row'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -177,25 +177,8 @@
|
||||
</sc-if>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ isInteg }}">
|
||||
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:24px;max-width:860px;overflow:hidden">
|
||||
<div style="display:flex;flex-direction:column;gap:4px"><span style="font:600 20px/28px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Connect your website</span><span style="color:var(--ui-text-muted)">A guide for the developer who builds your public site.</span></div>
|
||||
<p style="margin:0;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty">RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors. Your website fetches published articles from the two addresses below and displays them in its own design.</p>
|
||||
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span><div style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)"><span style="flex:1;min-width:0;height:40px;padding:0 12px;display:flex;align-items:center;font:400 13px 'IBM Plex Mono',monospace;overflow:hidden;white-space:nowrap">https://cms.renew.example</span><span data-act="copy" style="height:40px;padding:0 12px;display:flex;align-items:center;gap:6px;border-left:1px solid var(--ui-border-accented);font-weight:500;color:{{ copyFg }}"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ copyIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ copyIcon }}.svg') center / contain no-repeat"></span>{{ copyText }}</span></div></div>
|
||||
<sc-for list="{{ endpoints }}" as="e" hint-placeholder-count="2">
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border)">
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:8px;border-bottom:1px solid var(--ui-border)"><div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"><span style="height:22px;padding:0 7px;display:inline-flex;align-items:center;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font:500 12px 'IBM Plex Mono',monospace">GET</span><span style="font:500 14px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ e.path }}</span></div><span style="color:var(--ui-text-toned)">{{ e.desc }}</span></div>
|
||||
<div style="display:flex;gap:20px;padding:0 16px;border-bottom:1px solid var(--ui-border)"><span style="height:36px;display:flex;align-items:center;border-bottom:2px solid {{ e.l1 }};margin-bottom:-1px;font-weight:500;color:var(--ui-text-highlighted)">fetch</span><span style="height:36px;display:flex;align-items:center;color:var(--ui-text-toned)">curl</span><span style="height:36px;display:flex;align-items:center;color:var(--ui-text-toned)">Response</span></div>
|
||||
<div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 8px 0 16px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">JavaScript</span><span style="display:flex;align-items:center;gap:6px;color:var(--ui-text-toned);font-weight:500"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/copy.svg') center / contain no-repeat"></span>Copy</span></div><pre style="margin:0;padding:14px 16px;overflow:hidden;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text);white-space:pre">{{ e.code }}</pre></div>
|
||||
</div>
|
||||
</sc-for>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)">
|
||||
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Anonymous callers only ever get online articles. Drafts stay private until you publish them.</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Images in <code>body</code> use paths like <code>/static/uploadedImages/<uuid>.png</code>. Prefix them with the base URL before rendering.</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ isInteg }}"><div style="position:relative;flex:1;min-height:0;overflow:hidden"><dc-import name="IntegrationPage" s="{{ s }}" mobile="{{ mob }}" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden" hint-size="100%,100%"></dc-import></div></sc-if>
|
||||
<sc-if value="{{ isKeys }}"><div style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden"><dc-import name="ApiKeysPage" s="{{ s }}" mobile="{{ mob }}" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden" hint-size="100%,100%"></dc-import></div></sc-if>
|
||||
|
||||
<sc-if value="{{ isUsers }}">
|
||||
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:16px;flex:1;min-height:0">
|
||||
@@ -442,7 +425,7 @@ class Component extends DCLogic {
|
||||
desk: !m, mob: m, notBoot: !s.boot,
|
||||
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
|
||||
mLead: s.mLead || 'menu',
|
||||
isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isError: k === 'error', isUsers: k === 'users',
|
||||
isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users',
|
||||
usRows, usError: !!US.error, usOnly: !!US.only && !US.error, usTable: !m && !US.error, usCards: m && !US.error,
|
||||
pad: m ? '20px 16px 88px' : '32px', gap: m ? '20px' : '28px', cols3: m ? 'repeat(3,minmax(0,1fr))' : 'repeat(3,minmax(0,1fr))',
|
||||
upBannerAlign: m ? 'stretch' : 'center', upCmdDir: m ? 'column' : 'row', btnH: m ? '44px' : '32px', rowH: m ? '52px' : '44px', inF: m ? '16px' : '14px',
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<!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 ref="{{ rootRef }}" onScroll="{{ onScroll }}" style="position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;scrollbar-width:thin;background:var(--ui-bg-muted);color:var(--ui-text);font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px">
|
||||
<sc-if value="{{ mob }}">
|
||||
<div style="position:sticky;top:0;z-index:5;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:8px 16px;display:flex;flex-direction:column;gap:4px">
|
||||
<button aria-haspopup="listbox" aria-expanded="{{ jumpOpen }}" aria-label="Jump to section" onClick="{{ toggleJump }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:44px;padding:0 12px;display:flex;align-items:center;gap:8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);font-size:14px" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px"><span style="color:var(--ui-text-muted)">Jump to</span><span style="flex:1;font-weight:500;color:var(--ui-text-highlighted)">{{ activeLabel }}</span><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:var(--ui-text-muted);-webkit-mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/chevrons-up-down.svg') center / contain no-repeat"></span></button>
|
||||
<sc-if value="{{ jumpOpen }}"><div role="listbox" aria-label="Sections" style="position:absolute;left:16px;right:16px;top:56px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:4px">
|
||||
<sc-for list="{{ index }}" as="i" hint-placeholder-count="8"><button role="option" aria-selected="{{ i.on }}" onClick="{{ i.go }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;height:44px;padding:0 10px;display:flex;align-items:center;justify-content:space-between;font-size:14px;background:{{ i.bg }};color:var(--ui-text-highlighted);font-weight:{{ i.fw }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ i.t }}<span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;opacity:{{ i.op }};-webkit-mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/check.svg') center / contain no-repeat"></span></button></sc-for>
|
||||
</div></sc-if>
|
||||
</div>
|
||||
</sc-if>
|
||||
<div style="position:relative;padding:{{ pad }};display:flex;gap:40px;align-items:flex-start">
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:{{ secGap }}">
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="color:var(--ui-text-muted)">Integration</span>
|
||||
<span style="font:600 {{ h1 }} 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API reference</span>
|
||||
<span style="color:var(--ui-text-muted);font-size:14px;line-height:22px">Everything a developer needs to build on RenewCMS.</span>
|
||||
</div>
|
||||
<p style="margin:0;font-size:14px;line-height:22px;color:var(--ui-text-toned);text-wrap:pretty;max-width:720px">RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office.</p>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<span style="font-weight:500;color:var(--ui-text-highlighted)">Base URL</span>
|
||||
<div style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)"><span style="flex:1;min-width:0;height:40px;padding:0 12px;display:flex;align-items:center;font:400 13px 'IBM Plex Mono',monospace;overflow:hidden;white-space:nowrap">https://cms.renew.example</span>
|
||||
<button aria-label="Copy base URL" onClick="{{ copyBase }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:40px;padding:0 12px;display:flex;align-items:center;gap:6px;border-left:1px solid var(--ui-border-accented);font-weight:500;color:{{ baseCopy.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ baseCopy.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ baseCopy.icon }}.svg') center / contain no-repeat"></span><span aria-live="polite">{{ baseCopy.t }}</span></button></div>
|
||||
</div>
|
||||
|
||||
<section data-anchor="auth" aria-labelledby="h-auth" style="display:flex;flex-direction:column;gap:14px">
|
||||
<h2 id="h-auth" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Authentication</h2>
|
||||
<div style="display:grid;grid-template-columns:{{ authCols }};gap:12px;align-items:start">
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;align-items:center;gap:8px"><span style="font:600 15px/22px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Public</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font-size:12px">No key</span></div>
|
||||
<span style="color:var(--ui-text-toned);text-wrap:pretty">No key needed. Two read-only routes, and only online articles are returned.</span>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)"><span>GET /v1/article/</span><span>GET /v1/article/{id}</span></div>
|
||||
</div>
|
||||
<div style="background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:8px"><span style="font:600 15px/22px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">API key</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;background:var(--rc-success-soft);color:var(--ui-success);font-size:12px;font-weight:500">Full access</span></div>
|
||||
<span style="color:var(--ui-text-toned);text-wrap:pretty">Send <code>Authorization: Bearer rcms_…</code>. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator.</span>
|
||||
<div style="border:1px solid var(--ui-border);min-width:0">
|
||||
<div role="tablist" aria-label="Example language" style="display:flex;gap:16px;padding:0 12px;border-bottom:1px solid var(--ui-border)"><sc-for list="{{ authTabs }}" as="t" hint-placeholder-count="2"><button role="tab" aria-selected="{{ t.on }}" onClick="{{ t.pick }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:36px;display:flex;align-items:center;border-bottom:2px solid {{ t.bd }};margin-bottom:-1px;font-weight:500;color:{{ t.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ t.t }}</button></sc-for></div>
|
||||
<div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 4px 0 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ authLang }}</span><button aria-label="Copy example" onClick="{{ copyAuth }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:28px;padding:0 8px;display:flex;align-items:center;gap:6px;font-weight:500;color:{{ authCopy.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ authCopy.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ authCopy.icon }}.svg') center / contain no-repeat"></span><span aria-live="polite">{{ authCopy.t }}</span></button></div>
|
||||
<pre tabindex="0" style="margin:0;padding:12px;overflow-x:auto;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text)">{{ authCode }}</pre></div>
|
||||
</div>
|
||||
<span style="color:var(--ui-text-muted);text-wrap:pretty">Keys start with <code>rcms_</code> and are shown in full once, when you create them.</span>
|
||||
<a href="#" style="align-self:flex-start;display:inline-flex;align-items:center;gap:6px;min-height:{{ linkH }};font-weight:500;color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">Manage API keys<span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-right.svg') center / contain no-repeat"></span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)">
|
||||
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">A key can't create, list or revoke API keys, log in, register, verify or reset a password, or call anything under <code>/v1/system</code>. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified.</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Each key can make 120 requests per minute. Beyond that the API answers 429 with a <code>Retry-After</code> header.</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="4">
|
||||
<section data-anchor="{{ g.key }}" style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2><span style="color:var(--ui-text-muted)">{{ g.count }}</span></div>
|
||||
<span style="color:var(--ui-text-toned);text-wrap:pretty">{{ g.desc }}</span>
|
||||
<div style="border:1px solid var(--ui-border);border-top:0;background:var(--ui-bg)">
|
||||
<sc-for list="{{ g.eps }}" as="ep" hint-placeholder-count="6">
|
||||
<div data-anchor="{{ ep.anchor }}" style="border-top:1px solid var(--ui-border)">
|
||||
<button aria-expanded="{{ ep.open }}" aria-label="{{ ep.label }}" onClick="{{ ep.toggle }}" style="all:unset;box-sizing:border-box;cursor:pointer;width:100%;display:{{ rowDisplay }};grid-template-columns:{{ rowCols }};flex-direction:column;align-items:{{ rowAlign }};gap:{{ rowGap }};padding:{{ rowPad }};min-height:44px;background:{{ ep.rowBg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">
|
||||
<span style="display:flex;align-items:center;gap:10px;min-width:0"><span style="width:56px;height:22px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:{{ ep.mBg }};color:{{ ep.mFg }};border:1px solid {{ ep.mBd }};font:500 11px 'IBM Plex Mono',monospace">{{ ep.m }}</span><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ ep.p }}</span><sc-if value="{{ mob }}"><span style="flex:1"></span><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;color:var(--ui-text-muted);background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat"></span></sc-if></span>
|
||||
<span style="min-width:0;color:var(--ui-text-toned);overflow:hidden;text-overflow:ellipsis;white-space:{{ descWrap }}">{{ ep.d }}</span>
|
||||
<span style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-start"><sc-for list="{{ ep.chips }}" as="c" hint-placeholder-count="2"><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;background:{{ c.bg }};color:{{ c.fg }};border:1px {{ c.bs }} {{ c.bd }};font-size:12px;font-weight:{{ c.fw }}"><sc-if value="{{ c.icon }}"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ c.icon }}.svg') center / contain no-repeat"></span></sc-if>{{ c.t }}</span></sc-for></span>
|
||||
<sc-if value="{{ desk }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;color:var(--ui-text-muted);background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ ep.chev }}.svg') center / contain no-repeat"></span></sc-if>
|
||||
</button>
|
||||
<sc-if value="{{ ep.open }}">
|
||||
<div style="padding:4px {{ detX }} 16px;display:flex;flex-direction:column;gap:14px;border-top:1px solid var(--ui-border-muted)">
|
||||
<div style="padding-top:10px;display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">{{ ep.bodyTitle }}</span>
|
||||
<sc-if value="{{ ep.hasFields }}"><div style="border:1px solid var(--ui-border)">
|
||||
<div style="display:grid;grid-template-columns:{{ fCols }};gap:4px 12px;padding:6px 10px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Name</span><span>Type</span><span>Required</span><sc-if value="{{ desk }}"><span>Rule</span></sc-if></div>
|
||||
<sc-for list="{{ ep.fields }}" as="f" hint-placeholder-count="2"><div style="display:grid;grid-template-columns:{{ fCols }};gap:2px 12px;padding:8px 10px;border-top:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ f.n }}</span><span style="font:400 12px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ f.t }}</span><span style="color:var(--ui-text-toned)">{{ f.req }}</span><span style="grid-column:{{ ruleCol }};color:var(--ui-text-toned);text-wrap:pretty">{{ f.rule }}</span></div></sc-for></div></sc-if>
|
||||
<sc-if value="{{ ep.noFields }}"><span style="color:var(--ui-text-muted)">No request body.</span></sc-if></div>
|
||||
<div style="border:1px solid var(--ui-border);min-width:0">
|
||||
<div role="tablist" aria-label="Example" style="display:flex;gap:16px;padding:0 12px;border-bottom:1px solid var(--ui-border)"><sc-for list="{{ ep.tabs }}" as="t" hint-placeholder-count="3"><button role="tab" aria-selected="{{ t.on }}" onClick="{{ t.pick }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:{{ tabH }};display:flex;align-items:center;border-bottom:2px solid {{ t.bd }};margin-bottom:-1px;font-weight:500;color:{{ t.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ t.t }}</button></sc-for></div>
|
||||
<div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 4px 0 12px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--ui-border)"><span style="font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ ep.lang }}</span><button aria-label="{{ ep.copyLabel }}" onClick="{{ ep.copy }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:28px;padding:0 8px;display:flex;align-items:center;gap:6px;font-weight:500;color:{{ ep.cFg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px"><span aria-hidden="true" style="display:inline-block;width:14px;height:14px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ ep.cIcon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ ep.cIcon }}.svg') center / contain no-repeat"></span><span aria-live="polite">{{ ep.cT }}</span></button></div>
|
||||
<pre tabindex="0" style="margin:0;padding:12px;overflow-x:auto;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text)">{{ ep.code }}</pre></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px"><span style="font-weight:600;color:var(--ui-text-highlighted)">Errors</span>
|
||||
<sc-for list="{{ ep.errs }}" as="e" hint-placeholder-count="2"><div title="{{ e.cap }}" style="display:flex;align-items:baseline;gap:10px"><span style="width:36px;height:20px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned);font:500 11px 'IBM Plex Mono',monospace">{{ e.c }}</span><span style="min-width:0;color:var(--ui-text-toned);text-wrap:pretty">{{ e.cap }}</span></div></sc-for></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
<section data-anchor="session" aria-labelledby="h-sess" style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap"><h2 id="h-sess" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Session only</h2><span style="color:var(--ui-text-muted)">12 routes, not available to API keys</span></div>
|
||||
<div style="background:var(--ui-bg-muted);border:1px dashed var(--ui-border-accented)">
|
||||
<div style="display:flex;gap:10px;padding:12px 14px;color:var(--ui-text-toned);text-wrap:pretty"><span style="display:flex;padding-top:2px;color:var(--ui-text-muted)"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span></span><span>These routes need a signed-in browser session. With an API key they answer 403 "This action needs a real session."</span></div>
|
||||
<sc-for list="{{ sessGroups }}" as="sg" hint-placeholder-count="3">
|
||||
<div style="border-top:1px dashed var(--ui-border-accented)">
|
||||
<div style="padding:8px 14px 4px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">{{ sg.t }}</div>
|
||||
<sc-for list="{{ sg.rows }}" as="r" hint-placeholder-count="4"><div style="display:{{ rowDisplay }};grid-template-columns:{{ sessCols }};flex-direction:column;align-items:{{ rowAlign }};gap:{{ sessGap }};padding:{{ sessPad }};min-height:36px"><span style="display:flex;align-items:center;gap:10px;min-width:0"><span style="width:56px;flex-shrink:0;font:500 11px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ r.m }}</span><span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:400 13px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)">{{ r.p }}</span></span><span style="color:var(--ui-text-muted)">{{ r.d }}</span><span style="height:20px;padding:0 6px;display:inline-flex;align-items:center;gap:4px;align-self:{{ chipSelf }};border:1px dashed var(--ui-border-accented);color:var(--ui-text-muted);font-size:12px;white-space:nowrap"><span aria-hidden="true" style="display:inline-block;width:12px;height:12px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/lock.svg') center / contain no-repeat"></span>Session only</span></div></sc-for>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section data-anchor="errors" aria-labelledby="h-err" style="display:flex;flex-direction:column;gap:10px">
|
||||
<h2 id="h-err" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Errors</h2>
|
||||
<span style="color:var(--ui-text-toned);text-wrap:pretty">Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.</span>
|
||||
<div style="border:1px solid var(--ui-border);background:var(--ui-bg);overflow:hidden">
|
||||
<div style="display:grid;grid-template-columns:{{ errCols }};gap:12px;padding:8px 12px;background:var(--ui-bg-elevated);color:var(--ui-text-muted);font-weight:500"><span>Status</span><span>Meaning</span><sc-if value="{{ desk }}"><span>Typical cause</span></sc-if></div>
|
||||
<sc-for list="{{ errRows }}" as="r" hint-placeholder-count="8"><div style="display:grid;grid-template-columns:{{ errCols }};gap:2px 12px;padding:10px 12px;border-top:1px solid var(--ui-border)"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-highlighted)">{{ r.c }}</span><span style="font-weight:500;color:var(--ui-text-highlighted)">{{ r.m }}</span><span style="grid-column:{{ errCause }};color:var(--ui-text-toned);text-wrap:pretty">{{ r.w }}</span></div></sc-for>
|
||||
</div>
|
||||
<div style="border:1px solid var(--ui-border);min-width:0"><div style="background:var(--ui-bg-elevated)"><div style="height:32px;padding:0 12px;display:flex;align-items:center;border-bottom:1px solid var(--ui-border);font:500 12px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">Error response · text/plain</div>
|
||||
<pre tabindex="0" style="margin:0;padding:12px;overflow-x:auto;font:400 12px/19px 'IBM Plex Mono',monospace;color:var(--ui-text)">{{ errBody }}</pre></div></div>
|
||||
</section>
|
||||
|
||||
<section data-anchor="notes" aria-labelledby="h-notes" style="display:flex;flex-direction:column;gap:10px">
|
||||
<h2 id="h-notes" style="margin:0;font:600 18px/26px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">Notes</h2>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;font-size:14px;line-height:22px;color:var(--ui-text-toned)">
|
||||
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Images in <code>body</code> use paths like <code>/static/uploadedImages/<uuid>.png</code>. Prefix them with the base URL before rendering.</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="display:flex;padding-top:3px;color:var(--ui-info)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/info.svg') center / contain no-repeat"></span></span><span style="text-wrap:pretty">Article routes accept <code>?format=markdown</code>, <code>?format=html</code> or <code>?format=json</code> to choose how <code>body</code> is returned. With <code>json</code>, <code>body</code> is the stored JSON structure. Articles have <code>body_format: markdown</code> by default.</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<sc-if value="{{ desk }}">
|
||||
<nav aria-label="On this page" style="position:sticky;top:32px;width:168px;flex-shrink:0;display:flex;flex-direction:column;gap:2px;border-left:1px solid var(--ui-border)">
|
||||
<span style="padding:0 12px 6px;font-size:11px;font-weight:500;letter-spacing:.04em;text-transform:uppercase;color:var(--ui-text-muted)">On this page</span>
|
||||
<sc-for list="{{ index }}" as="i" hint-placeholder-count="8"><button aria-current="{{ i.cur }}" onClick="{{ i.go }}" style="all:unset;box-sizing:border-box;cursor:pointer;height:28px;padding:0 12px;margin-left:-1px;display:flex;align-items:center;border-left:2px solid {{ i.bd }};color:{{ i.fg }};font-weight:{{ i.fw }}" style-hover="color:var(--ui-text-highlighted)" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:-2px">{{ i.t }}</button></sc-for>
|
||||
</nav>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script>
|
||||
class Component extends DCLogic {
|
||||
state = (() => { const i = (this.props.s && this.props.s.int) || {}; return { open: i.open ? { [i.open]: true } : {}, tab: i.open ? { [i.open]: i.tab || 'fetch' } : {}, copied: i.copied || null, jump: !!i.jump, active: i.scroll === 'ep-' + i.open ? 'media' : (i.scroll || 'auth'), authTab: i.authTab || 'curl' } })()
|
||||
rootRef = React.createRef()
|
||||
BASE = 'https://cms.renew.example'
|
||||
KEY = 'rcms_Kf3x9QwL0aBc…'
|
||||
IDX = [['auth', 'Authentication'], ['articles', 'Articles'], ['media', 'Media'], ['users', 'Users and invites'], ['settings', 'Settings'], ['session', 'Session only'], ['errors', 'Errors'], ['notes', 'Notes']]
|
||||
componentDidMount() {
|
||||
const i = (this.props.s && this.props.s.int) || {}
|
||||
const go = () => { if (i.scroll) this.scrollTo(i.scroll, !!this.props.mobile) }
|
||||
go(); setTimeout(go, 250); setTimeout(go, 900)
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(go)
|
||||
}
|
||||
scrollTo(a, mob) {
|
||||
const r = this.rootRef.current; if (!r) return
|
||||
const el = r.querySelector('[data-anchor="' + a + '"]'); if (!el) return
|
||||
const off = a.indexOf('ep-') === 0 ? 24 : 0
|
||||
r.scrollTop = Math.max(0, this.yOf(el, r) - (mob ? 64 : 32) - off)
|
||||
}
|
||||
yOf(el, r) { const w = el.offsetParent; return el.offsetTop + (w && w !== r ? w.offsetTop : 0) }
|
||||
onScroll = () => {
|
||||
const r = this.rootRef.current; if (!r) return
|
||||
let cur = this.IDX[0][0]
|
||||
for (const [k] of this.IDX) { const el = r.querySelector('[data-anchor="' + k + '"]'); if (el && this.yOf(el, r) <= r.scrollTop + 120) cur = k }
|
||||
if (cur !== this.state.active) this.setState({ active: cur })
|
||||
}
|
||||
flash(k) {
|
||||
this.setState({ copied: k }); clearTimeout(this._t); this._t = setTimeout(() => this.setState({ copied: null }), 2000)
|
||||
}
|
||||
hl(code) {
|
||||
const re = /("[^"\n]*"|'[^'\n]*')|\b(const|await|fetch|curl|new)\b|\b(\d+)\b/g
|
||||
const out = []; let last = 0, m, k = 0
|
||||
while ((m = re.exec(code))) {
|
||||
if (m.index > last) out.push(code.slice(last, m.index))
|
||||
const c = m[1] ? 'var(--ui-success)' : m[2] ? 'var(--ui-info)' : 'var(--ui-warning)'
|
||||
out.push(React.createElement('span', { key: k++, style: { color: c } }, m[0])); last = m.index + m[0].length
|
||||
}
|
||||
out.push(code.slice(last)); return out
|
||||
}
|
||||
eps() {
|
||||
const A = (id, m, p, d, acc, key, errs, fields, x = {}) => ({ id, m, p, d, acc, key, errs: acc === 'Verified user' ? errs.flatMap(e => e[0] === 401 ? [e, e403] : [e]) : errs, fields, ...x })
|
||||
const S = (n, t, req, rule) => ({ n, t, req, rule })
|
||||
const e401 = [401, 'You must be logged in to access this resource.']
|
||||
const e403 = [403, 'You must verify your account to access this resource.']
|
||||
const e404 = [404, 'Article not found']
|
||||
const m404 = [404, 'Image not found']
|
||||
const i404 = [404, 'Invite not found']
|
||||
const mm = 'The request cannot be processed due to a mismatch in the format of the body.'
|
||||
const j = o => JSON.stringify(o, null, 2)
|
||||
const G = {
|
||||
articles: { title: 'Articles', desc: 'Read, write and publish articles. Titles can\'t be changed after creation.', eps: [
|
||||
A('art-list', 'GET', '/v1/article/', 'List articles. Anonymous callers get online articles only.', 'Public', 1, [e401], [], { pub: 1, r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) }),
|
||||
A('art-get', 'GET', '/v1/article/{id}', 'Get one article. Drafts answer 404 to anonymous callers.', 'Public', 1, [e401, e404], [], { pub: 1, r: j({ id: 12, title: 'How we price a bathroom renovation', status: 'online', body: 'A bathroom renovation is priced by room size, plumbing changes and finish level.', body_format: 'markdown' }) }),
|
||||
A('art-create', 'POST', '/v1/article/', 'Create a draft article.', 'Verified user', 1, [[400, mm], e401], [S('title', 'string', 'Yes', '3 to 200 characters. Can\'t be changed later.'), S('body', 'string', 'Yes', 'Up to 10 000 characters.')], { body: { title: 'Choosing tiles for a small bathroom', body: 'Small rooms show every grout line.' }, r: j({ id: 13, status: 'draft' }) }),
|
||||
A('art-update', 'PATCH', '/v1/article/{id}', 'Update the body of an article.', 'Verified user', 1, [[400, mm], e401, e404], [S('body', 'string', 'Yes', 'Up to 10 000 characters.')], { body: { body: 'Updated text.' }, r: j({ id: 12, status: 'draft' }) }),
|
||||
A('art-pub', 'PATCH', '/v1/article/{id}/publish', 'Publish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'online' }) }),
|
||||
A('art-unpub', 'PATCH', '/v1/article/{id}/unpublish', 'Unpublish an article.', 'Verified user', 1, [e401, e404], [], { r: j({ id: 12, status: 'draft' }) }),
|
||||
A('art-del', 'DELETE', '/v1/article/{id}', 'Delete an article and its images.', 'Verified user', 1, [e401, e404], [], { r: '204 No Content' })
|
||||
] },
|
||||
media: { title: 'Media', desc: 'The image library. One image can be used in many articles.', eps: [
|
||||
A('med-list', 'GET', '/v1/media/', 'List images.', 'Verified user', 1, [e401], [], { r: j([{ id: 4, filename: 'north-wall.jpg', size: 3900000 }]) }),
|
||||
A('med-up', 'POST', '/v1/media/', 'Upload an image (multipart).', 'Verified user', 1, [[400, mm], e401, [413, 'The file is over 10 MB.'], [415, 'Not a PNG, JPEG, WEBP or SVG image.']], [S('file', 'file', 'Yes', 'PNG, JPEG, WEBP or SVG, up to 10 MB.')], { multi: true, form: [['file', '@north-wall.jpg']], r: j({ id: 4, filename: 'north-wall.jpg', url: '/static/uploadedImages/3f9c.jpg' }) }),
|
||||
A('med-ren', 'PATCH', '/v1/media/{id}', 'Rename an image. The URL stays the same.', 'Verified user', 1, [[400, mm], e401, m404], [S('filename', 'string', 'Yes', 'The new filename.')], { body: { filename: 'north-wall-01.jpg' }, r: j({ id: 4, filename: 'north-wall-01.jpg' }) }),
|
||||
A('med-del', 'DELETE', '/v1/media/{id}', 'Delete an image. Articles that use it show a broken image.', 'Verified user', 1, [e401, m404], [], { r: '204 No Content' }),
|
||||
A('med-merge', 'POST', '/v1/media/merge', 'Merge identical copies into one image.', 'Verified user', 1, [[400, mm], e401, [409, 'Some of the images to merge changed. Refresh and try again.']], [S('keep_id', 'number', 'Yes', 'Greater than 0. The image to keep.'), S('merge_ids', 'number[]', 'Yes', 'At least 1, each greater than 0. Identical copies to delete. Articles switch to the kept image.')], { body: { keep_id: 4, merge_ids: [9, 11] }, r: j({ merged: 2, articlesUpdated: 5 }) }),
|
||||
A('med-use', 'GET', '/v1/media/{id}/usage', 'List the articles that use an image.', 'Verified user', 1, [e401, m404], [], { r: j([{ id: 12, title: 'How we price a bathroom renovation', status: 'online' }]) })
|
||||
] },
|
||||
users: { title: 'Users and invites', desc: 'People who can sign in, and pending invitations.', eps: [
|
||||
A('usr-list', 'GET', '/v1/user/', 'List users and invites.', 'Verified user', 1, [e401], [], { r: j([{ email: 'marta@renew.example', role: 'owner', status: 'accepted' }]) }),
|
||||
A('usr-me', 'PATCH', '/v1/user/me', 'Update my profile.', 'Verified user', 1, [[400, mm], e401], [S('display_name', 'string', 'Yes', 'Up to 60 characters.')], { body: { display_name: 'Marta Alves' }, r: j({ display_name: 'Marta Alves' }) }),
|
||||
A('usr-inv', 'POST', '/v1/user/invite', 'Invite a user by email.', 'Verified user', 1, [[400, mm], e401, [409, 'This address already has an account.']], [S('email', 'string', 'Yes', 'A valid email address. The link expires after 3 days.')], { body: { email: 'lea.martin@renew.example' }, r: j({ id: 7, status: 'pending' }) }),
|
||||
A('usr-cancel', 'DELETE', '/v1/user/invite/{id}', 'Cancel a pending invite.', 'Verified user', 1, [e401, i404], [], { r: '204 No Content' }),
|
||||
A('usr-renew', 'POST', '/v1/user/invite/{id}/renew', 'Renew an expired invite.', 'Verified user', 1, [e401, i404], [], { r: j({ id: 7, status: 'pending' }) }),
|
||||
A('auth-me', 'GET', '/v1/auth/me', 'Get the current user. With a key, the key\'s owner.', 'Verified user', 1, [e401], [], { r: j({ username: 'marta', email: 'marta@renew.example', role: 'owner' }) })
|
||||
] },
|
||||
settings: { title: 'Settings', desc: 'Read the site settings.', eps: [
|
||||
A('set-get', 'GET', '/v1/settings', 'Get settings.', 'Verified user', 1, [e401], [], { r: j({ version: 'v2.2.0-beta' }) })
|
||||
] }
|
||||
}
|
||||
const MB = { GET: ['var(--ui-bg-inverted)', 'var(--ui-text-inverted)', 'var(--ui-bg-inverted)'], POST: ['var(--rc-success-soft)', 'var(--ui-success)', 'var(--ui-success)'], PATCH: ['var(--rc-warning-soft)', 'var(--ui-warning)', 'var(--ui-warning)'], DELETE: ['var(--rc-error-soft)', 'var(--ui-error)', 'var(--ui-error)'] }
|
||||
const mob = !!this.props.mobile
|
||||
return Object.keys(G).map(key => {
|
||||
const g = G[key]
|
||||
return { key, title: g.title, desc: g.desc, count: g.eps.length + (g.eps.length === 1 ? ' route' : ' routes'), eps: g.eps.map(e => {
|
||||
const open = !!this.state.open[e.id], tab = this.state.tab[e.id] || 'fetch', url = this.BASE + e.p.replace('{id}', '12')
|
||||
const hdr = e.pub ? [] : ['Authorization: Bearer ' + this.KEY]
|
||||
let curl = 'curl ' + (e.m === 'GET' ? '' : '-X ' + e.m + ' ') + '"' + url + '"'
|
||||
hdr.forEach(h => curl += ' \\\n -H "' + h + '"')
|
||||
if (e.body) curl += ' \\\n -H "Content-Type: application/json" \\\n -d \'' + JSON.stringify(e.body) + '\''
|
||||
if (e.form) e.form.forEach(f => curl += ' \\\n -F "' + f[0] + '=' + f[1] + '"')
|
||||
let fe = (e.form ? 'const form = new FormData()\n' + e.form.map(f => f[1][0] === '@' ? "form.append('file', file)" : "form.append('" + f[0] + "', '" + f[1] + "')").join('\n') + '\n\n' : '') + "const res = await fetch('" + url + "'"
|
||||
const opts = []
|
||||
if (e.m !== 'GET') opts.push("method: '" + e.m + "'")
|
||||
if (hdr.length || e.body) opts.push('headers: { ' + [hdr.length ? "Authorization: 'Bearer " + this.KEY + "'" : null, e.body ? "'Content-Type': 'application/json'" : null].filter(Boolean).join(', ') + ' }')
|
||||
if (e.body) opts.push('body: JSON.stringify(' + JSON.stringify(e.body) + ')')
|
||||
if (e.form) opts.push('body: form')
|
||||
fe += opts.length ? ', {\n ' + opts.join(',\n ') + '\n})' : ')'
|
||||
const code = tab === 'curl' ? curl : tab === 'fetch' ? fe : e.r
|
||||
const lang = tab === 'curl' ? 'Shell' : tab === 'fetch' ? 'JavaScript' : 'JSON'
|
||||
const cp = this.state.copied === e.id
|
||||
const chip = (t, o) => ({ t, bg: 'transparent', fg: 'var(--ui-text-toned)', bd: 'var(--ui-border-accented)', bs: 'solid', fw: 400, ...o })
|
||||
const chips = [chip(e.acc, e.acc === 'Owner only' ? { bg: 'var(--ui-bg-elevated)', fg: 'var(--ui-text-highlighted)', fw: 500 } : {}), chip('API key OK', { icon: 'check', bg: 'var(--rc-success-soft)', fg: 'var(--ui-success)', bd: 'transparent', fw: 500 })]
|
||||
return { anchor: 'ep-' + e.id, open, m: e.m, p: e.p, d: e.d, mBg: MB[e.m][0], mFg: MB[e.m][1], mBd: MB[e.m][2], chev: open ? 'chevron-up' : 'chevron-down', rowBg: open ? 'var(--ui-bg-muted)' : 'transparent', label: e.m + ' ' + e.p + '. ' + e.d, chips,
|
||||
toggle: () => this.setState(s => ({ open: { ...s.open, [e.id]: !s.open[e.id] } })),
|
||||
bodyTitle: e.multi ? 'Request body · multipart/form-data, 1 file field' : e.body ? 'Request body · JSON' : 'Request body', hasFields: e.fields.length > 0, noFields: e.fields.length === 0, fields: e.fields,
|
||||
tabs: ['fetch', 'curl', 'Response'].map(t => { const k = t === 'Response' ? 'response' : t, on = (k === tab) || (t === tab); return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState(s => ({ tab: { ...s.tab, [e.id]: k } })) } }),
|
||||
lang, code: this.hl(code), copyLabel: 'Copy ' + lang + ' example', cT: cp ? 'Copied' : 'Copy', cIcon: cp ? 'check' : 'copy', cFg: cp ? 'var(--ui-success)' : 'var(--ui-text-toned)',
|
||||
copy: () => { try { navigator.clipboard && navigator.clipboard.writeText(code) } catch (x) { } this.flash(e.id) },
|
||||
errs: e.errs.map(([c, cap]) => ({ c, cap })) }
|
||||
}) }
|
||||
})
|
||||
}
|
||||
renderVals() {
|
||||
const mob = !!this.props.mobile, st = this.state
|
||||
const code = {
|
||||
curl: 'curl "' + this.BASE + '/v1/auth/me" \\\n -H "Authorization: Bearer ' + this.KEY + '"',
|
||||
fetch: "const res = await fetch('" + this.BASE + "/v1/auth/me', {\n headers: { Authorization: 'Bearer " + this.KEY + "' }\n})\nconst me = await res.json()"
|
||||
}[st.authTab]
|
||||
const cp = k => ({ t: st.copied === k ? 'Copied' : 'Copy', icon: st.copied === k ? 'check' : 'copy', fg: st.copied === k ? 'var(--ui-success)' : 'var(--ui-text-toned)' })
|
||||
const bc = cp('base'); if (st.copied !== 'base') bc.fg = 'var(--ui-text-highlighted)'
|
||||
const index = this.IDX.map(([k, t]) => { const on = st.active === k; return { t, on, cur: on ? 'true' : undefined, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', fw: on ? 500 : 400, bg: on ? 'var(--ui-bg-elevated)' : 'transparent', op: on ? 1 : 0, go: () => { this.setState({ jump: false, active: k }); this.scrollTo(k, mob) } } })
|
||||
const sg = (t, rows) => ({ t, rows: rows.map(([m, p, d]) => ({ m, p, d })) })
|
||||
const D = (a) => 'rowDisplay'
|
||||
return {
|
||||
rootRef: this.rootRef, onScroll: this.onScroll, mob, desk: !mob, jumpOpen: st.jump, toggleJump: () => this.setState(s => ({ jump: !s.jump })),
|
||||
activeLabel: (this.IDX.find(x => x[0] === st.active) || this.IDX[0])[1], index,
|
||||
pad: mob ? '20px 16px 96px' : '32px', secGap: mob ? '28px' : '32px', h1: mob ? '20px/28px' : '28px/36px', detX: mob ? '12px' : '16px', linkH: mob ? '44px' : '28px', tabH: mob ? '44px' : '36px',
|
||||
authCols: mob ? 'minmax(0,1fr)' : 'minmax(0,5fr) minmax(0,7fr)',
|
||||
baseCopy: bc, copyBase: () => { try { navigator.clipboard && navigator.clipboard.writeText(this.BASE) } catch (x) { } this.flash('base') },
|
||||
authTabs: ['curl', 'fetch'].map(t => { const on = st.authTab === t; return { t, on, bd: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)', pick: () => this.setState({ authTab: t }) } }),
|
||||
authLang: st.authTab === 'curl' ? 'Shell' : 'JavaScript', authCode: this.hl(code), authCopy: cp('auth'), copyAuth: () => { try { navigator.clipboard && navigator.clipboard.writeText(code) } catch (x) { } this.flash('auth') },
|
||||
groups: this.eps(),
|
||||
rowDisplay: mob ? 'flex' : 'grid', rowCols: mob ? 'none' : '300px minmax(0,1fr) auto 16px', rowAlign: mob ? 'stretch' : 'center', rowGap: mob ? '6px' : '16px', rowPad: mob ? '12px 12px' : '0 16px', descWrap: mob ? 'normal' : 'nowrap',
|
||||
fCols: mob ? 'minmax(0,1fr) auto auto' : '120px 72px 72px minmax(0,1fr)', ruleCol: mob ? '1 / -1' : 'auto',
|
||||
sessGroups: [sg('System · owner only', [['GET', '/v1/system/update', 'Update status'], ['POST', '/v1/system/update/check', 'Check for update'], ['POST', '/v1/system/update/download', 'Download update'], ['POST', '/v1/system/update/restart', 'Restart']]), sg('Sign in and account', [['POST', '/v1/auth/login', 'Log in'], ['POST', '/v1/auth/logout', 'Log out'], ['POST', '/v1/auth/register', 'Register'], ['POST', '/v1/auth/verify', 'Verify email'], ['POST', '/v1/auth/password-reset', 'Reset password']]), sg('API key management', [['GET', '/v1/api-key', 'List keys'], ['POST', '/v1/api-key', 'Create a key'], ['DELETE', '/v1/api-key/{id}', 'Revoke a key']])],
|
||||
sessCols: mob ? 'none' : '280px minmax(0,1fr) auto', sessGap: mob ? '2px' : '16px', sessPad: mob ? '8px 14px' : '0 14px', chipSelf: mob ? 'flex-start' : 'center',
|
||||
errCols: mob ? '56px minmax(0,1fr)' : '64px 200px minmax(0,1fr)', errCause: mob ? '2' : 'auto',
|
||||
errRows: [
|
||||
{ c: 400, m: 'Bad request', w: '"The request cannot be processed due to a mismatch in the format of the body." or "The expiry must be a date in the future."' },
|
||||
{ c: 401, m: 'Unauthorized', w: '"You must be logged in to access this resource." Sent for an invalid, unknown, expired or revoked key, even on public routes, and for a key whose user was deleted or unverified.' },
|
||||
{ c: 403, m: 'Forbidden', w: '"This action needs a real session." (a session-only route called with a key), "Only the owner can do this." or "You must verify your account to access this resource."' },
|
||||
{ c: 404, m: 'Not found', w: '"Article not found", "Image not found", "Invite not found" or "API key not found". Drafts answer 404 to anonymous callers.' },
|
||||
{ c: 429, m: 'Too many requests', w: '"Too many requests. Please try again later." A per-key limit of 120 requests per minute also sends a Retry-After header.' },
|
||||
{ c: 500, m: 'Server error', w: '"Internal server error"' }
|
||||
],
|
||||
errBody: 'HTTP/1.1 403 Forbidden\nContent-Type: text/plain\n\nThis action needs a real session.'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<!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="Back-office 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: API keys</h1>
|
||||
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">The screen the Authentication section of <a href="RenewCMS Screens Back-office.dc.html">the Integration screen</a> links to. 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="AppFrame" 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="AppFrame" 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="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"system","tsType":"'system' | 'light' | 'dark'"}}">
|
||||
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 withM = (st, mo = {}) => ({ ...st, m: { ...st, ...mo } })
|
||||
return this.finish([
|
||||
{ label: 'API keys', title: 'API keys', route: '/integration/api-keys', api: 'GET /v1/api-key · POST /v1/api-key · DELETE /v1/api-key/{id} (session only)', note: 'Recommendation: place it under Integration, as a sub-page linked from the Authentication section and from the sidebar item. Keys exist to call the API, so they belong next to the reference. Settings is owner-only today, but admins can also create keys. The screen is session only: an API key cannot manage keys. Key name: required, up to 60 characters. Expiry: optional, in the future. A key is shown in full once; afterwards only its prefix is visible.', states: [
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Owner: list", trigger: "Owners see every user's keys and a \"Created by\" column. Expired keys keep their Revoke action.", ak: {"view":"owner"} }, { ak: {"view":"owner"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Admin: own keys only", trigger: "Admins see only the keys they created, with no \"Created by\" column.", ak: {"view":"admin"} }, { ak: {"view":"admin"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Loading", trigger: "The list request is in flight. Skeleton rows appear after 150 ms.", ak: {"state":"loading"} }, { ak: {"state":"loading"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Empty", trigger: "No keys yet.", ak: {"state":"empty"} }, { ak: {"state":"empty"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Error", trigger: "A 500 or network failure on the list request.", ak: {"state":"error"} }, { ak: {"state":"error"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Create key", trigger: "\"Create key\" in the page header, or the bottom action on mobile. Name is required and limited to 60 characters; expiry is optional.", ak: {"modal":"create"} }, { ak: {"modal":"create"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Create key: validation", trigger: "On submit or blur. Nothing is sent. The name is empty and the expiry is in the past.", ak: {"modal":"invalid"} }, { ak: {"modal":"invalid"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Create key: name too long", trigger: "Over 60 characters. Checked as you type.", ak: {"modal":"toolong"} }, { ak: {"modal":"toolong"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "One-time reveal", trigger: "201: the full key is shown once. Closing the dialog discards it from the screen.", ak: {"modal":"reveal"} }, { ak: {"modal":"reveal"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "One-time reveal: copied", trigger: "Copy was pressed. The button reads \"Copied\" for 2 s and this is announced.", ak: {"modal":"copied"} }, { ak: {"modal":"copied"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Revoke: confirm", trigger: "Revoke on an active or expired row.", ak: {"modal":"revoke"} }, { ak: {"modal":"revoke"} }),
|
||||
withM({ kind: 'apikeys', nav: 'integration', crumbs: ['Integration','API keys'], mTitle: 'API keys', mLead: 'arrow-left', name: "Revoked", trigger: "204: the key stays in the list, greyed, with no Revoke action. A toast confirms.", ak: {"revoked":1,"toast":1} }, { ak: {"revoked":1,"toast":1} })
|
||||
] }
|
||||
])
|
||||
}
|
||||
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>
|
||||
@@ -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.' }),
|
||||
@@ -265,9 +267,15 @@ class Component extends DCLogic {
|
||||
MS({ kind: 'picker', inArticle: true, picker: false, modal: { k: 'remove' }, name: 'Remove image: confirm', trigger: 'Remove image on an image in the article. It now only removes it from this article, and the copy says the file stays in the library. The confirm button is neutral, not red, because nothing is deleted. Title: "Remove this image from the article?"' }),
|
||||
MS({ kind: 'picker', name: 'Removed', trigger: 'Confirmed. The image leaves the article body, which now has unsaved changes.', toasts: [TT('success', 'circle-check', 'Image removed', 'It\'s still in your library.')] })
|
||||
] },
|
||||
{ label: 'Integration', title: 'Connect your website', route: '/integration', api: 'none (static)', note: 'Written for a developer. The base URL is window.location.origin.', states: [
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: 'Default', trigger: 'Opened from the sidebar or the Home card.' }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', copied: true, name: 'Copied', trigger: 'The base URL was copied. The button reads "Copied" for 2 s and this is announced.' })
|
||||
{ label: 'Integration', title: 'Integration: API reference', route: '/integration', api: 'none (static). Replaces "Connect your website".', note: 'Navigation: a sticky section index on the right on desktop, and a "Jump to" select on mobile. A long reference is read in one scroll and searched with the browser, so tabs would hide content and break find-in-page; the index keeps every group on one page, links to anchors and shows where you are. Written for a developer. The base URL is window.location.origin. The API keys screen has its own board.', states: [
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "1. Default", trigger: "Opened from the sidebar or the Home card. Every endpoint card is collapsed. Desktop shows the sticky index on the right; mobile shows the \"Jump to\" select above the content.", int: {} }, { int: {} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "2. Group scrolled, one card expanded", trigger: "POST /v1/media/ opened in the Media group: multipart body with 1 file field, curl tab shown. Only one example block is visible per card. Other cards stay collapsed.", int: {"scroll":"media","open":"med-up","tab":"curl"} }, { int: {"scroll":"ep-med-up","open":"med-up","tab":"curl"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "3a. Copied: base URL", trigger: "The base URL was copied. The button reads \"Copied\" for 2 s and this is announced.", int: {"copied":"base"} }, { int: {"copied":"base"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "3b. Copied: CodeBlock", trigger: "The Copy button in the API key example after a click. Back to \"Copy\" after 2 s.", int: {"scroll":"auth","copied":"auth"} }, { int: {"scroll":"auth","copied":"auth"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "4. Authentication", trigger: "The API key card with its example, the \"Manage API keys\" link (goes to the API keys board) and the two notes.", int: {"scroll":"auth"} }, { int: {"scroll":"auth"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "5. Session only", trigger: "Routes API keys cannot call. Dashed, muted and locked, so they read as unavailable and not as an error.", int: {"scroll":"session"} }, { int: {"scroll":"session"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "6. Errors", trigger: "Status table and the plain-text error response (status line plus a one-sentence body). Key-specific causes are in the 401, 403 and 429 rows.", int: {"scroll":"errors"} }, { int: {"scroll":"errors"} }),
|
||||
withM({ kind: 'integration', nav: 'integration', crumbs: ['Integration'], mTitle: 'Integration', name: "7. Navigation", trigger: "Desktop: the sticky index with the active item (Media). Mobile: the \"Jump to\" select open. Keyboard: the index buttons and the select are in the tab order, Enter jumps, Escape closes the list.", int: {"scroll":"media"} }, { int: {"scroll":"media","jump":true} })
|
||||
] },
|
||||
{ label: 'Users', title: 'Users', route: '/users', api: 'GET /v1/user/ · POST /v1/user/invite · DELETE /v1/user/invite/:id (pending) · POST /v1/user/invite/:id/renew (pending)', note: 'One shared workspace: every user sees the same articles and this same list. Columns: email, display name, role (owner or admin), invite status and created date. A pending invite can be cancelled and an expired one renewed; accepted accounts have no row actions. An invite is Pending until its link is used, Expired after 3 days, and Accepted once the account is created.', states: [
|
||||
withM({ ...U(), name: 'Default', trigger: 'The list loaded, newest first.' }),
|
||||
@@ -288,7 +296,7 @@ class Component extends DCLogic {
|
||||
withM({ ...U({ toasts: [T('error', 'circle-x', 'Couldn\'t renew the invite', 'Try again in a moment.')] }), name: 'Renew invite: failed', trigger: '500 or network failure. The row stays Expired and the button can be pressed again.' }),
|
||||
withM({ ...U({ users: { rows: [{ email: 'lea.martin@renew.example', role: 'admin', st: 'pending', c: 'Today', hl: true }, ...people] } , toasts: [T('success', 'circle-check', 'Invite sent', 'lea.martin@renew.example has 3 days to accept.')] }), name: 'Invite sent', trigger: '201: the dialog closes, the new invite appears at the top as Pending, and a toast confirms.' })
|
||||
] },
|
||||
{ label: 'Settings', title: 'Settings: Updates', route: '/settings', api: 'GET /v1/system/update · POST …/check · POST …/download · POST …/restart (all pending)', note: 'New Updates card under Version, owner only. Update downloads and checks the new version and changes nothing. Restart installs it and restarts the site (about 10 seconds). "Restart later" is simply leaving the card or the dialog. A plain Restart shows only when no update is waiting. Admins see the version and a note. When the server isn\'t prepared, the card shows the notice and a "How to update" link with no buttons. Progress is a progressbar with a value, announced at 25% steps and when each step changes. The card is a polite live region. After the restart the page reloads, focus lands on the "Updates" heading, and a toast confirms. Buttons are disabled while any step runs, and any owner on any device sees the same state from the server. Cue elsewhere: see Home. Out of scope: choosing versions, downgrades, automatic updates, update history.', states: [
|
||||
{ label: 'Settings', title: 'Settings: Updates', route: '/settings', api: 'GET /v1/system/update · POST …/check · POST …/download · POST …/update/restart (all pending)', note: 'New Updates card under Version, owner only. Update downloads and checks the new version and changes nothing. Restart installs it and restarts the site (about 10 seconds). "Restart later" is simply leaving the card or the dialog. A plain Restart shows only when no update is waiting. Admins see the version and a note. When the server isn\'t prepared, the card shows the notice and a "How to update" link with no buttons. Progress is a progressbar with a value, announced at 25% steps and when each step changes. The card is a polite live region. After the restart the page reloads, focus lands on the "Updates" heading, and a toast confirms. Buttons are disabled while any step runs, and any owner on any device sees the same state from the server. Cue elsewhere: see Home. Out of scope: choosing versions, downgrades, automatic updates, update history.', states: [
|
||||
SU({ icon: 'circle-check', tone: 'success', title: 'You\'re up to date', text: 'You have the latest version of RenewCMS.', meta: LAST, btns: [B('Check for updates', 'sec', 'refresh-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Up to date', trigger: 'Opened with no newer version. Restart is available but not emphasised.' }),
|
||||
SU({ icon: 'loader-circle', title: 'Checking for updates…', text: 'This usually takes a few seconds.', btns: [B('Checking…', 'sec', 'loader-circle', true)] }, { name: 'Checking', trigger: 'Check for updates pressed, or the first open after 24 hours. Announced as "Checking for updates". Buttons are disabled.' }),
|
||||
SU({ icon: 'wifi-off', tone: 'warning', title: 'Couldn\'t check for updates', text: 'RenewCMS couldn\'t reach the update server. Check your connection and try again. Your site isn\'t affected.', meta: 'Last checked yesterday at 18:12', btns: [B('Try again', 'primary', 'rotate-cw'), B('Restart', 'sec', 'power')], hint: RESTART_FOOT }, { name: 'Check failed', trigger: 'Offline, or the update server didn\'t answer (500, network). The last known result is kept.' }),
|
||||
|
||||
@@ -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="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"system","tsType":"'system' | 'light' | 'dark'"}}">
|
||||
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>
|
||||
@@ -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="{"$preview":{"width":1440,"height":900},"s":{"editor":null,"tsType":"object"},"mobile":{"editor":null,"tsType":"boolean"}}">
|
||||
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>
|
||||
+44
-2
@@ -134,8 +134,26 @@ Every state is drawn at desktop and mobile on the screen boards, with final copy
|
||||
| Media picker | `/article/:id/edit` | Inserted | Insert image from either tab | [media-picker](screens/media-picker.md) |
|
||||
| Media picker | `/article/:id/edit` | Remove image: confirm | Remove image on an image in the article | [media-picker](screens/media-picker.md) |
|
||||
| Media picker | `/article/:id/edit` | Removed | Confirmed | [media-picker](screens/media-picker.md) |
|
||||
| Integration | `/integration` | Default | Page load | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Copied | Copy clicked (2 s) | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Default | Page load, every endpoint card collapsed | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Group scrolled, one card expanded | A card opened, one example visible | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Copied: base URL | Copy clicked (2 s) | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Copied: CodeBlock | Copy clicked in a code block (2 s) | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Authentication | API key card, link and notes | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Session only | Routes API keys cannot call | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Errors | Status table and plain-text error shape | [integration](screens/integration.md) |
|
||||
| Integration | `/integration` | Navigation | Section index active item; mobile Jump to open | [integration](screens/integration.md) |
|
||||
| API keys | `/integration/api-keys` | Owner: list | Owner sees every key, with Created by | [integration-api-keys](screens/integration-api-keys.md) |
|
||||
| API keys | `/integration/api-keys` | Admin: own keys only | Admin sees only their own keys | [integration-api-keys](screens/integration-api-keys.md) |
|
||||
| API keys | `/integration/api-keys` | Loading | First load or retry | [integration-api-keys](screens/integration-api-keys.md) |
|
||||
| API keys | `/integration/api-keys` | Empty | No keys yet | [integration-api-keys](screens/integration-api-keys.md) |
|
||||
| API keys | `/integration/api-keys` | Error | 500 or network | [integration-api-keys](screens/integration-api-keys.md) |
|
||||
| API keys | `/integration/api-keys` | Create key | Modal opened | [integration-api-keys](screens/integration-api-keys.md) |
|
||||
| API keys | `/integration/api-keys` | Create key: validation | Name empty or expiry not in the future | [integration-api-keys](screens/integration-api-keys.md) |
|
||||
| API keys | `/integration/api-keys` | Create key: name too long | More than 60 characters | [integration-api-keys](screens/integration-api-keys.md) |
|
||||
| API keys | `/integration/api-keys` | One-time reveal | Key created, shown once | [integration-api-keys](screens/integration-api-keys.md) |
|
||||
| API keys | `/integration/api-keys` | One-time reveal: copied | Copy clicked (2 s) | [integration-api-keys](screens/integration-api-keys.md) |
|
||||
| API keys | `/integration/api-keys` | Revoke: confirm | Revoke pressed on a key | [integration-api-keys](screens/integration-api-keys.md) |
|
||||
| API keys | `/integration/api-keys` | Revoked | Confirmed, key stays listed as revoked | [integration-api-keys](screens/integration-api-keys.md) |
|
||||
| Users | `/users` | Default | List loaded, newest first | [users](screens/users.md) |
|
||||
| Users | `/users` | Loading | First load or retry | [users](screens/users.md) |
|
||||
| Users | `/users` | Only you | Nobody invited yet | [users](screens/users.md) |
|
||||
@@ -263,3 +281,27 @@ 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.
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
# 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, 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
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -30,3 +30,7 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
|
||||
| `v2.6.0-beta` | v2.6.0-beta - Signed updates and browser restart |
|
||||
| `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 |
|
||||
@@ -0,0 +1,41 @@
|
||||
This release adds API keys, so a script or service can use the whole API without the web interface, and turns the Integration page into a complete API reference.
|
||||
|
||||
## New
|
||||
|
||||
**API keys**
|
||||
|
||||
- Create named API keys from the new API keys screen under Integration, with an optional expiry date. The full key is shown once, when you create it, and only its prefix is visible afterwards.
|
||||
- Send a key as `Authorization: Bearer rcms_...` to manage articles, media, users and invites, and to read settings. A key acts with exactly the rights of the user who created it, so an admin's key cannot do owner-only things.
|
||||
- Revoke a key at any time. Active, expired and revoked keys all stay listed with their status, creation date and last use.
|
||||
- The owner sees every key and who created it. Admins see only their own.
|
||||
- Each key is limited to 120 requests per minute.
|
||||
- Resetting a user's password revokes all of that user's keys.
|
||||
|
||||
**API reference**
|
||||
|
||||
- The Integration page now documents every route a developer can call, grouped by area: articles, media, users and invites, and settings. Each endpoint lists who can call it, its request body, ready-to-copy fetch and curl examples, a sample response and its error statuses.
|
||||
- A new Authentication section explains public access and API keys, and a section index lets you jump between groups.
|
||||
- Routes that need a signed-in browser session are listed separately, and the page shows the real error messages the API returns.
|
||||
- The Integration page and the API keys screen are available in English and French.
|
||||
|
||||
## API
|
||||
|
||||
- `POST /v1/api-key` creates a key. It answers `201` with the key, shown once.
|
||||
- `GET /v1/api-key` lists keys, newest first, each with a `status` of `active`, `expired` or `revoked`. For the owner, each entry also carries `created_by_name`.
|
||||
- `DELETE /v1/api-key/{id}` revokes a key and answers `204`, also when the key is already revoked.
|
||||
- An invalid, unknown, expired or revoked key answers `401` on every route, including public ones, and is never treated as anonymous.
|
||||
- A key calling a session-only route answers `403` with "This action needs a real session." These routes are `/v1/api-key`, `POST /v1/auth/verify`, `DELETE /v1/auth/register` and everything under `/v1/system`.
|
||||
- `GET /v1/auth/me` accepts an API key, so a client can check who a key belongs to.
|
||||
- A key beyond 120 requests per minute gets `429` with a `Retry-After` header.
|
||||
- Cross-origin requests now allow the `PATCH` method.
|
||||
|
||||
## Under the hood
|
||||
|
||||
- Add the API key domain, use cases, SQLite repository and an authentication middleware that runs after each token verifier. Keys are generated by a new domain interface and stored as SHA-256 hashes, never in clear.
|
||||
- Add a typed API catalogue that drives the Integration page, with a test that every route has a description.
|
||||
- Add the API keys and Integration specifications and QA procedures to `features/`, and the API key reference to `docs/api/`.
|
||||
|
||||
## Upgrading
|
||||
|
||||
- Replace the binary. The new API keys table is created automatically at startup. No new environment variable is required.
|
||||
- Browsers calling the API from another origin can now use `PATCH`. Check your CORS configuration only if you restricted methods in a reverse proxy.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,457 @@
|
||||
Feature: Developers authenticate against /v1 with named API keys instead of the jwt cookie
|
||||
|
||||
As a developer integrating with RenewCMS
|
||||
I want to create named API keys and send them as a Bearer token
|
||||
So that I can manage articles, media and users without the UI, with exactly the rights of the
|
||||
user who created the key and never more
|
||||
|
||||
Background:
|
||||
Given internal/domain/apikey defines the entity APIKey with ID uint32, UserID uint32, Name,
|
||||
Prefix, HashedKey, ExpiresAt *time.Time, RevokedAt *time.Time, LastUsedAt *time.Time and
|
||||
CreatedAt, with snake_case json tags
|
||||
And internal/domain/apikey defines the sentinels apikey.ErrAPIKeyNotFound,
|
||||
apikey.ErrAPIKeyInvalid and apikey.ErrExpiryNotInFuture
|
||||
And apikey.ErrAPIKeyInvalid covers an unknown, malformed, expired or revoked key and a key
|
||||
whose user is deleted or unverified
|
||||
And internal/domain/apikey defines the interface Repository with Create, Get(id),
|
||||
GetByHashedKey(hashedKey), ListByUser(userId), ListAll(), Revoke(id, at),
|
||||
RevokeAllByUser(userId, at) and TouchLastUsed(id, at)
|
||||
And RevokeAllByUser sets RevokedAt to at on every key of that user that is not yet revoked and
|
||||
leaves the RevokedAt of already revoked keys and every other user's keys untouched
|
||||
And internal/domain/apikey defines the interface KeyGenerator with Generate() returning the
|
||||
raw key, its display prefix and its SHA-256 hash, and Hash(rawKey) returning the SHA-256 hash
|
||||
And internal/infrastructure/hashing implements KeyGenerator, asserted with
|
||||
var _ apikey.KeyGenerator = &SHA256KeyGenerator{}
|
||||
And a generated raw key is "rcms_" followed by the base64url encoding, without padding, of 32
|
||||
random bytes, so it matches ^rcms_[A-Za-z0-9_-]{43}$
|
||||
And the display prefix is the first 12 characters of the raw key
|
||||
And only the SHA-256 hash and the prefix are persisted, never the raw key
|
||||
And the use cases CreateAPIKey, ListAPIKeys, RevokeAPIKey and AuthenticateAPIKey depend only
|
||||
on apikey.Repository, apikey.KeyGenerator, user.Repository and an injected clock
|
||||
And ResetPasswordUseCase additionally receives apikey.Repository and an injected clock, and
|
||||
after a successful password reset calls RevokeAllByUser(userId, now); it never reaches
|
||||
persistence directly
|
||||
And persistence has a GORM model, a mapper and a repository for the entity, the model is
|
||||
added to the AutoMigrate list in api/dependecyInjection.go, and wiring happens only in
|
||||
api/dependecyInjection.go
|
||||
And a new middleware resolves "Authorization: Bearer rcms_<...>" to a user and places a jwt in
|
||||
the same jwtauth request context the cookie path fills, with the claims user_id and
|
||||
token_version taken from the user's current record
|
||||
And that middleware runs after jwtauth.Verifier and ahead of jwtauth.Authenticator on the
|
||||
/article routes (public and protected), /user, /media, /settings and GET /auth/me
|
||||
And jwtauth.Authenticator, RequireCurrentToken, RequireVerifiedUser, RequireOwner and every
|
||||
handler stay unchanged
|
||||
And a new middleware RequireSession, mounted ahead of jwtauth.Verifier, answers 403 with
|
||||
apperr.SessionRequiredErrorMessage whenever the Authorization header is a Bearer token that
|
||||
starts with "rcms_", whether that key is valid, expired, revoked, unknown or garbage
|
||||
And RequireSession is mounted on the key management routes POST /v1/api-key, GET /v1/api-key
|
||||
and DELETE /v1/api-key/{id}, on the session-only auth routes POST /v1/auth/verify and
|
||||
DELETE /v1/auth/register, and on every /v1/system route (all under /v1/system/update)
|
||||
And a request on those routes without a "Bearer rcms_" header passes through RequireSession
|
||||
unchanged, so cookie-only and jwt-Bearer behaviour there does not change
|
||||
And the public auth routes login, register, registration, logout, password-reset,
|
||||
password-reset/confirm, invite and invite/accept do not use the key middleware or
|
||||
RequireSession and behave exactly as they do without a key header
|
||||
And the routes are mounted under /v1 inside the jwtauth.Verifier, jwtauth.Authenticator,
|
||||
RequireCurrentToken and RequireVerifiedUser group:
|
||||
POST /v1/api-key, GET /v1/api-key and DELETE /v1/api-key/{id}
|
||||
And a Bearer key on a key-accepting route that cannot authenticate gets 401 with
|
||||
apperr.UnauthorizedErrorMessage, and a request without an rcms_ key keeps the existing
|
||||
jwtauth.Authenticator response, status 401, unchanged by this feature
|
||||
And new apperr strings: APIKeyNotFoundErrorMessage = "API key not found",
|
||||
APIKeyExpiryErrorMessage = "The expiry must be a date in the future." and
|
||||
SessionRequiredErrorMessage = "This action needs a real session."
|
||||
And the per-key rate limit is 120 requests per minute, as constants in api/router.go
|
||||
And enableCorsIfConfigured allows the methods GET, POST, PUT, PATCH, DELETE and OPTIONS
|
||||
And users: owner id 1 "marta" (verified), admin id 2 "dev" (verified), admin id 3 "pat"
|
||||
(verified), unverified id 4 "quinn"
|
||||
|
||||
Scenario: Creating a key returns the full key exactly once
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends POST /v1/api-key with body {"name": "ci-deploy"}
|
||||
Then the response status is 201
|
||||
And the header Cache-Control is "no-store"
|
||||
And the body has exactly the keys id, name, prefix, key, created_at, expires_at
|
||||
And name is "ci-deploy", expires_at is null, and key matches ^rcms_[A-Za-z0-9_-]{43}$
|
||||
And prefix equals the first 12 characters of key
|
||||
And the stored record has user_id 2 and HashedKey equal to the SHA-256 of key
|
||||
And neither the raw key nor the stored hash appears in any later response
|
||||
|
||||
Scenario: The key can't be retrieved afterwards
|
||||
Given admin 2 created the key "ci-deploy" and holds its raw key
|
||||
When the same jwt cookie sends GET /v1/api-key
|
||||
Then the response status is 200
|
||||
And the body is a JSON array with one entry whose keys are exactly id, user_id, name, prefix,
|
||||
status, created_at, last_used_at, expires_at, revoked_at
|
||||
And the entry has no "key" and no "hashed_key" field
|
||||
And the raw key and its hash appear nowhere in the body
|
||||
And the entry's prefix is the first 12 characters of the raw key
|
||||
|
||||
Scenario: Creating a key with an expiry
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends POST /v1/api-key with body
|
||||
{"name": "temp", "expires_at": "<now + 30 days, RFC3339>"}
|
||||
Then the response status is 201
|
||||
And expires_at in the body equals the requested instant
|
||||
|
||||
Scenario Outline: Creating a key with an invalid body is refused and creates nothing
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends POST /v1/api-key with <body>
|
||||
Then the response status is 400
|
||||
And the response body is <message>
|
||||
And no key exists in the repository
|
||||
|
||||
Examples:
|
||||
| body | message |
|
||||
| body not-json | apperr.BodyErrorMessage |
|
||||
| body {} | text containing 'Name' and 'required' |
|
||||
| body {"name": ""} | text containing 'Name' and 'required' |
|
||||
| body {"name": "<61 characters>"} | text containing 'Name' and 'max' |
|
||||
| body {"name": "x", "expires_at": "yesterday"} | apperr.BodyErrorMessage |
|
||||
| body {"name": "x", "expires_at": "<now - 1h>"} | apperr.APIKeyExpiryErrorMessage |
|
||||
|
||||
Scenario: A request body can't choose the owner or the role of a new key
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends POST /v1/api-key with body {"name": "sneaky", "user_id": 1, "role": "owner"}
|
||||
Then the response status is 201
|
||||
And the stored key belongs to user 2
|
||||
And using that key on POST /v1/user/invite returns 403 with apperr.OwnerRequiredErrorMessage
|
||||
|
||||
Scenario Outline: Key management needs a verified real session
|
||||
Given <caller>
|
||||
When the client sends POST /v1/api-key with body {"name": "x"}
|
||||
Then the response status is <status>
|
||||
And the response body is <message>
|
||||
And no key exists in the repository
|
||||
|
||||
Examples:
|
||||
| caller | status | message |
|
||||
| no credentials | 401 | the existing Authenticator response |
|
||||
| a jwt cookie for user 4 | 403 | apperr.VerificationRequiredErrorMessage |
|
||||
| a stale token_version cookie | 401 | the existing Authenticator response |
|
||||
|
||||
Scenario Outline: A valid key acts as its creator on every key-accepting route
|
||||
Given an active key created by admin 2
|
||||
When the client sends <method> <path> with "Authorization: Bearer <that key>" and no cookie <body>
|
||||
Then the response status is <status>
|
||||
And the effect is attributed to user 2
|
||||
|
||||
Examples:
|
||||
| method | path | body | status |
|
||||
| GET | /v1/settings | | 200 |
|
||||
| GET | /v1/user | | 200 |
|
||||
| PATCH | /v1/user/me | and body {"display_name": "Dev"} | 200 |
|
||||
| POST | /v1/article | and body {"title": "A valid title", "body": "x"} | 201 |
|
||||
| PATCH | /v1/article/1/publish | | 200 |
|
||||
| DELETE | /v1/article/1 | | 204 |
|
||||
| GET | /v1/media | | 200 |
|
||||
| DELETE | /v1/media/1 | | 204 |
|
||||
|
||||
Scenario: A key can read its creator through GET /v1/auth/me
|
||||
Given an active key created by admin 2
|
||||
When the client sends GET /v1/auth/me with that key and no cookie
|
||||
Then the response status is 200
|
||||
And the body is the same JSON the jwt cookie of admin 2 gets on GET /v1/auth/me
|
||||
And the body describes user 2 "dev"
|
||||
|
||||
Scenario: A key that can't authenticate gets 401 on GET /v1/auth/me
|
||||
When the client sends GET /v1/auth/me with "Authorization: Bearer rcms_unknown"
|
||||
Then the response status is 401
|
||||
And the response body is apperr.UnauthorizedErrorMessage
|
||||
|
||||
Scenario: A key sees offline articles on the public article routes, an anonymous caller does not
|
||||
Given an offline article 5 and an online article 6
|
||||
And an active key created by admin 2
|
||||
When the client sends GET /v1/article with that key
|
||||
Then the body lists articles 5 and 6
|
||||
When the client sends GET /v1/article with no credentials
|
||||
Then the body lists only article 6
|
||||
When the client sends GET /v1/article/5 with that key
|
||||
Then the response status is 200
|
||||
When the client sends GET /v1/article/5 with no credentials
|
||||
Then the response status is 404
|
||||
|
||||
Scenario: An invalid key on a public article route is refused, not treated as anonymous
|
||||
When the client sends GET /v1/article with "Authorization: Bearer rcms_unknown"
|
||||
Then the response status is 401
|
||||
And the response body is apperr.UnauthorizedErrorMessage
|
||||
|
||||
Scenario: An owner's key can do owner-only things
|
||||
Given an active key created by owner 1
|
||||
When the client sends POST /v1/user/invite with that key and body {"email": "new@example.com"}
|
||||
Then the response status is 201
|
||||
And the invite's invited_by is 1
|
||||
|
||||
Scenario: An admin's key can not do owner-only things
|
||||
Given an active key created by admin 2
|
||||
When the client sends POST /v1/user/invite with that key and body {"email": "new@example.com"}
|
||||
Then the response status is 403
|
||||
And the response body is apperr.OwnerRequiredErrorMessage
|
||||
And no invite exists
|
||||
|
||||
Scenario Outline: A key that can't authenticate gets the same 401 as a bad jwt
|
||||
Given <state>
|
||||
When the client sends GET /v1/settings with "Authorization: Bearer <credential>"
|
||||
Then the response status is 401
|
||||
And the response body is apperr.UnauthorizedErrorMessage
|
||||
And no handler or use case behind the route runs
|
||||
|
||||
Examples:
|
||||
| state | credential |
|
||||
| no key stored for it | rcms_unknownunknownunknownunknownunknownunknown1 |
|
||||
| a key whose expires_at is one second ago | that key |
|
||||
| a key that was revoked | that key |
|
||||
| a key of user 1 whose user record was deleted | that key |
|
||||
| a key of an unverified user | that key |
|
||||
| nothing after the prefix | rcms_ |
|
||||
| a key with one character changed | that key, last character changed |
|
||||
| a key's hash passed instead of the key | the stored HashedKey, status 401 only |
|
||||
|
||||
Scenario: Key behaviour does not change the jwt cookie path
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends GET /v1/settings with only that cookie
|
||||
Then the response status is 200
|
||||
When the client sends GET /v1/settings with an Authorization header "Bearer <a valid jwt>" and no cookie
|
||||
Then the response status is 200
|
||||
When the client sends GET /v1/settings with a stale token_version cookie
|
||||
Then the response status is 401
|
||||
|
||||
Scenario: A Bearer key wins over the cookie, and a bad key is not rescued by a good cookie
|
||||
Given an active key created by admin 3 and a valid jwt cookie for owner 1
|
||||
When the client sends POST /v1/user/invite with both the key and the cookie
|
||||
Then the response status is 403
|
||||
When the client sends GET /v1/settings with "Authorization: Bearer rcms_unknown" and the owner cookie
|
||||
Then the response status is 401
|
||||
|
||||
Scenario Outline: Session-only routes refuse any rcms_ credential with 403
|
||||
Given <state>
|
||||
When the client sends <method> <path> with "Authorization: Bearer <credential>" <body>
|
||||
Then the response status is 403
|
||||
And the response body is apperr.SessionRequiredErrorMessage
|
||||
And no use case behind the route runs
|
||||
And no key was created, listed or revoked
|
||||
|
||||
Examples:
|
||||
| state | credential | method | path | body |
|
||||
| an active key of owner 1 | that key | POST | /v1/api-key | and body {"name": "child"} |
|
||||
| an active key of owner 1 | that key | GET | /v1/api-key | |
|
||||
| an active key of owner 1 | that key | DELETE | /v1/api-key/1 | |
|
||||
| an active key of owner 1 | that key | POST | /v1/auth/verify | and body {"code": "x"} |
|
||||
| an active key of owner 1 | that key | DELETE | /v1/auth/register | |
|
||||
| an active key of owner 1 | that key | GET | /v1/system/update | |
|
||||
| an active key of owner 1 | that key | POST | /v1/system/update/check | |
|
||||
| an active key of owner 1 | that key | POST | /v1/system/update/download | |
|
||||
| an active key of owner 1 | that key | POST | /v1/system/update/restart | |
|
||||
| a revoked key | that key | GET | /v1/api-key | |
|
||||
| no key stored for it | rcms_unknownunknownunknownunknownunknownunknown1 | POST | /v1/system/update/check | |
|
||||
| nothing | rcms_ | DELETE | /v1/auth/register | |
|
||||
|
||||
Scenario Outline: The session-only routes still work as before with a session
|
||||
Given a valid jwt cookie for owner 1
|
||||
When the client sends <method> <path> with only that cookie <body>
|
||||
Then the response is the same as before this feature
|
||||
And a request with no credentials still gets status 401 from the existing Authenticator
|
||||
|
||||
Examples:
|
||||
| method | path | body |
|
||||
| POST | /v1/api-key | and body {"name": "x"} |
|
||||
| GET | /v1/api-key | |
|
||||
| GET | /v1/system/update | |
|
||||
| POST | /v1/system/update/check | |
|
||||
|
||||
Scenario Outline: A key never obtains a session through the public auth routes
|
||||
Given an active key created by owner 1
|
||||
When the client sends <method> <path> with that key and body <body>
|
||||
Then the response is the same as without the key
|
||||
And no jwt cookie is set unless the credentials in the body are valid
|
||||
And the stored user and password are unchanged
|
||||
|
||||
Examples:
|
||||
| method | path | body |
|
||||
| POST | /v1/auth/login | {"username": "marta", "password": "wrong-pass-1"} |
|
||||
| POST | /v1/auth/password-reset/confirm | {"email": "marta@example.com", "code": "nope", "password": "newpassword1"} |
|
||||
|
||||
Scenario: A successful password reset revokes every key of that user
|
||||
Given user 2 has active keys "a" and "b", and each works on GET /v1/settings
|
||||
And user 2's stored PasswordResetCode is the hash of "reset-code-1" with an unexpired expiration
|
||||
When the client sends POST /v1/auth/password-reset/confirm with body
|
||||
{"email": "dev@example.com", "code": "reset-code-1", "password": "newpassword1"}
|
||||
Then the response status is 200
|
||||
And GET /v1/settings with key "a" returns 401 with apperr.UnauthorizedErrorMessage
|
||||
And GET /v1/settings with key "b" returns 401 with apperr.UnauthorizedErrorMessage
|
||||
|
||||
Scenario: A password reset leaves other users' keys untouched
|
||||
Given user 2 has an active key "a" and user 3 has an active key "c"
|
||||
And user 2 has a valid password reset code "reset-code-1"
|
||||
When user 2's password reset is confirmed with "reset-code-1"
|
||||
Then GET /v1/settings with key "c" returns 200
|
||||
And key "c" has no revoked_at
|
||||
|
||||
Scenario: Keys revoked by a password reset show as revoked
|
||||
Given user 2 has active keys "a" and "b", and a clock at T
|
||||
And user 2 has a valid password reset code "reset-code-1"
|
||||
When user 2's password reset is confirmed with "reset-code-1"
|
||||
And admin 2 logs in again with the new password and sends GET /v1/api-key with the new cookie
|
||||
Then the entries "a" and "b" have status "revoked" and revoked_at T
|
||||
|
||||
Scenario: A password reset does not move the revoked_at of a key that was already revoked
|
||||
Given user 2 has a key "old" revoked at T0 and an active key "a"
|
||||
And user 2 has a valid password reset code "reset-code-1"
|
||||
When user 2's password reset is confirmed with "reset-code-1" at a later time T
|
||||
Then key "old" still has revoked_at T0
|
||||
And key "a" has revoked_at T
|
||||
|
||||
Scenario Outline: A failed password reset confirmation revokes nothing
|
||||
Given user 2 has an active key "a"
|
||||
And user 2's stored PasswordResetCode is the hash of "reset-code-1" with <expiration>
|
||||
When the client sends POST /v1/auth/password-reset/confirm with <body>
|
||||
Then the response status is not 200
|
||||
And key "a" has no revoked_at
|
||||
And GET /v1/settings with key "a" returns 200
|
||||
|
||||
Examples:
|
||||
| expiration | body |
|
||||
| an unexpired expiration | {"email": "dev@example.com", "code": "wrong", "password": "newpassword1"} |
|
||||
| an expiration in the past | {"email": "dev@example.com", "code": "reset-code-1", "password": "newpassword1"} |
|
||||
| an unexpired expiration | {"email": "nobody@example.com", "code": "reset-code-1", "password": "newpassword1"} |
|
||||
|
||||
Scenario: Listing as an admin shows only that admin's keys
|
||||
Given keys "a" and "b" created by admin 2 and key "c" created by admin 3
|
||||
When admin 2 sends GET /v1/api-key with a jwt cookie
|
||||
Then the body lists exactly "a" and "b"
|
||||
|
||||
Scenario: Listing as the owner shows every user's keys, with the creator's id
|
||||
Given the same three keys and key "d" created by owner 1
|
||||
When owner 1 sends GET /v1/api-key with a jwt cookie
|
||||
Then the body lists "a", "b", "c" and "d" newest first
|
||||
And each entry has the user_id of its creator
|
||||
|
||||
Scenario Outline: The list reports each key's state
|
||||
Given a key that is <state>
|
||||
When its creator sends GET /v1/api-key with a jwt cookie
|
||||
Then the entry's status is "<status>", revoked_at is <revoked_at>, and last_used_at is <last_used>
|
||||
|
||||
Examples:
|
||||
| state | status | revoked_at | last_used |
|
||||
| active, never used | active | null | null |
|
||||
| active, used 5 minutes ago | active | null | that time |
|
||||
| expired one hour ago | expired | null | unchanged |
|
||||
| revoked | revoked | a time | unchanged |
|
||||
| both expired and revoked | revoked | a time | unchanged |
|
||||
|
||||
Scenario: Revoking a key ends it immediately
|
||||
Given an active key "ci-deploy" created by admin 2
|
||||
And the key works on GET /v1/settings
|
||||
When admin 2 sends DELETE /v1/api-key/<its id> with a jwt cookie
|
||||
Then the response status is 204 and the body is empty
|
||||
And GET /v1/settings with that key returns 401 with apperr.UnauthorizedErrorMessage
|
||||
And GET /v1/api-key lists it with status "revoked"
|
||||
|
||||
Scenario: Revoking twice is idempotent
|
||||
Given a key revoked at time T
|
||||
When its creator sends DELETE /v1/api-key/<its id> again
|
||||
Then the response status is 204
|
||||
And revoked_at is still T
|
||||
|
||||
Scenario: An admin can't revoke someone else's key, and learns nothing about it
|
||||
Given a key created by admin 3
|
||||
When admin 2 sends DELETE /v1/api-key/<its id> with a jwt cookie
|
||||
Then the response status is 404
|
||||
And the response body is apperr.APIKeyNotFoundErrorMessage
|
||||
And the key still works
|
||||
|
||||
Scenario: The owner can revoke any key
|
||||
Given a key created by admin 3
|
||||
When owner 1 sends DELETE /v1/api-key/<its id> with a jwt cookie
|
||||
Then the response status is 204
|
||||
And the key gets 401 on GET /v1/settings
|
||||
|
||||
Scenario Outline: Revoking with a bad id
|
||||
Given a valid jwt cookie for admin 2
|
||||
When the client sends DELETE /v1/api-key/<id>
|
||||
Then the response status is <status>
|
||||
And the response body is <message>
|
||||
|
||||
Examples:
|
||||
| id | status | message |
|
||||
| abc | 400 | apperr.IdUint32ErrorMessage |
|
||||
| -1 | 400 | apperr.IdUint32ErrorMessage |
|
||||
| 4294967296 | 400 | apperr.IdUint32ErrorMessage |
|
||||
| 999 | 404 | apperr.APIKeyNotFoundErrorMessage |
|
||||
|
||||
Scenario: Using a key records last_used_at, at most once per minute
|
||||
Given an active key never used, and a fake clock at T
|
||||
When the key authenticates a request at T
|
||||
Then last_used_at is T
|
||||
When it authenticates again at T + 30s
|
||||
Then no write happened and last_used_at is still T
|
||||
When it authenticates again at T + 61s
|
||||
Then last_used_at is T + 61s
|
||||
|
||||
Scenario: A failure to record last_used_at doesn't fail the request
|
||||
Given an active key and a repository whose TouchLastUsed returns an error
|
||||
When the client sends GET /v1/settings with that key
|
||||
Then the response status is 200
|
||||
|
||||
Scenario: A rejected key never records last_used_at
|
||||
Given an expired key and a revoked key
|
||||
When each is used on GET /v1/settings
|
||||
Then each gets 401 and no last_used_at is written
|
||||
|
||||
Scenario: Each key has its own rate limit
|
||||
Given two active keys "a" and "b" and a limit of N requests per minute per key
|
||||
When key "a" sends N+1 requests to GET /v1/settings
|
||||
Then request N+1 gets 429 with apperr.TooManyRequestsErrorMessage and a Retry-After header
|
||||
And key "b" still gets 200
|
||||
And a jwt cookie request still gets 200
|
||||
|
||||
Scenario: The rate limit does not apply to unauthenticated requests
|
||||
When the client sends N+1 requests with "Authorization: Bearer rcms_unknown"
|
||||
Then every response is 401, never 429
|
||||
|
||||
Scenario: The CORS preflight allows PATCH
|
||||
Given settings with CorsAllowedOrigins ["https://app.example.com"]
|
||||
When the client sends OPTIONS /v1/article/1 with Origin "https://app.example.com" and
|
||||
Access-Control-Request-Method "PATCH"
|
||||
Then the response is a successful preflight
|
||||
And Access-Control-Allow-Methods includes PATCH
|
||||
And a preflight for method "TRACE" is not allowed
|
||||
|
||||
Scenario: Use case: creating a key hashes it and stores no plaintext
|
||||
Given a fake KeyGenerator returning raw "rcms_fake", prefix "rcms_fake", hash "hash:rcms_fake"
|
||||
When CreateAPIKey is called with user 2 and name "ci"
|
||||
Then the repository's Create receives HashedKey "hash:rcms_fake", Prefix "rcms_fake", UserID 2
|
||||
And the result carries the raw key, which is not stored
|
||||
|
||||
Scenario: Use case: authenticating returns the user and rejects every invalid case with one sentinel
|
||||
Given a fake KeyGenerator and keys that are unknown, expired, revoked, and owned by a missing or
|
||||
unverified user
|
||||
When AuthenticateAPIKey is called with each raw key
|
||||
Then every call returns apikey.ErrAPIKeyInvalid, asserted with errors.Is
|
||||
And a key expiring exactly at the clock's now is expired
|
||||
And a valid key returns the user from user.Repository
|
||||
|
||||
Scenario: Use case: a failing repository is not reported as an invalid key
|
||||
Given a repository whose GetByHashedKey returns an unexpected error
|
||||
When AuthenticateAPIKey is called
|
||||
Then the error is returned and errors.Is(err, apikey.ErrAPIKeyInvalid) is false
|
||||
And the middleware answers 500 "Internal server error"
|
||||
|
||||
Scenario: Use case: ResetPassword revokes the user's keys through the domain repository
|
||||
Given a fake apikey.Repository holding active keys of users 2 and 3, a fake clock at T, and a
|
||||
user.Repository fake with a valid reset code for user 2
|
||||
When ResetPasswordUseCase.ResetPassword succeeds for user 2
|
||||
Then the fake apikey.Repository received exactly one RevokeAllByUser(2, T)
|
||||
And user 3's keys are untouched
|
||||
|
||||
Scenario: Use case: ResetPassword does not call RevokeAllByUser when the reset fails
|
||||
Given a fake apikey.Repository and a user.Repository fake with a wrong or expired reset code
|
||||
When ResetPasswordUseCase.ResetPassword is called
|
||||
Then it returns the existing invalid or expired sentinel
|
||||
And RevokeAllByUser is never called
|
||||
|
||||
Scenario: Use case: a failure to revoke keys after a reset is reported
|
||||
Given a valid reset code and an apikey.Repository whose RevokeAllByUser returns an error
|
||||
When ResetPasswordUseCase.ResetPassword is called
|
||||
Then the use case returns that error
|
||||
And the password was already changed
|
||||
@@ -0,0 +1,171 @@
|
||||
# QA procedure: API keys
|
||||
|
||||
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
|
||||
|
||||
## Setup shared by every case
|
||||
1. Call `validator.InitValidator()` once per test.
|
||||
2. Add in-memory fakes with unique type names across the package: `mockAPIKeyRepo`
|
||||
(implements `apikey.Repository` including `RevokeAllByUser`, with a `fail` switch per method such as
|
||||
`failNext("TouchLastUsed")`, a `touchCalls` counter and a `revokeAllCalls` log of `(userId, at)`) and
|
||||
`stubKeyGenerator` (deterministic raw/prefix/hash). Reuse `mockUserRepo`, `stubHasher` and the
|
||||
article, media and invite fakes.
|
||||
3. HTTP tests use the real SHA-256 generator, so key format and hashing are real. Unit-level tests
|
||||
use `stubKeyGenerator`.
|
||||
4. Build the handler stack from a `*api.UseCases` container holding fakes and mount
|
||||
`api.InitBackendRoutes(container, settings)` on a test server (unused use cases stay nil). It is the
|
||||
most faithful way to test the middleware order and the exclusions. If a nil use case breaks a
|
||||
handler constructor, fall back to a fresh `chi.NewRouter()` that mirrors `api/router.go`:
|
||||
`jwtauth.Verifier`, the key middleware, `jwtauth.Authenticator`, `RequireCurrentToken`,
|
||||
`RequireVerifiedUser` (and `RequireOwner` on the invite group) for the key-accepting routes
|
||||
(including `GET /auth/me`); `RequireSession` ahead of `jwtauth.Verifier` on `/api-key`,
|
||||
`POST /auth/verify`, `DELETE /auth/register` and `/system`; and the public auth routes with neither.
|
||||
The test token is `jwtauth.New("HS256", []byte("test-secret"), nil)`.
|
||||
5. Inject a fake clock (`now func() time.Time`) into the key use cases and `ResetPasswordUseCase`.
|
||||
6. Seed users: owner 1 "marta", admin 2 "dev", admin 3 "pat" (all verified), unverified 4 "quinn".
|
||||
7. Helpers: `cookieFor(userId, tokenVersion)` mints a jwt cookie with
|
||||
`token.Encode(map[string]any{"user_id": id, "token_version": v})`; `bearer(raw)` sets the header.
|
||||
Create real keys through `POST /v1/api-key` with the cookie so the shown-once value is genuine.
|
||||
|
||||
## POST /v1/api-key
|
||||
8. Admin 2 cookie, `{"name":"ci-deploy"}` -> `201`, `Cache-Control: no-store`, key set is exactly `id`,
|
||||
`name`, `prefix`, `key`, `created_at`, `expires_at`; `key` matches `^rcms_[A-Za-z0-9_-]{43}$`; prefix
|
||||
is `key[:12]`; stored `HashedKey` equals the generator's `Hash(key)`; `UserID` 2; the raw key is
|
||||
stored nowhere in the fake.
|
||||
9. Two creations give two different keys and ids.
|
||||
10. With `expires_at` 30 days ahead -> `201`, echoed instant.
|
||||
11. Invalid bodies (table): not-json -> `400` `apperr.BodyErrorMessage`; `{}`, `{"name":""}` -> `400`,
|
||||
body contains `'Name'` and `required`; 61-char name -> `400`, contains `max`; `expires_at`
|
||||
"yesterday" -> `400` `BodyErrorMessage`; past `expires_at` -> `400` `apperr.APIKeyExpiryErrorMessage`.
|
||||
Each: repository still empty.
|
||||
12. Body `{"name":"sneaky","user_id":1,"role":"owner"}` with admin 2 -> `201`; stored `UserID` 2; that
|
||||
key on `POST /v1/user/invite` -> `403` `apperr.OwnerRequiredErrorMessage`, no invite.
|
||||
13. Access table: no credentials -> `401`; user 4 cookie -> `403` `apperr.VerificationRequiredErrorMessage`;
|
||||
stale `token_version` -> `401`. Each: repository empty.
|
||||
|
||||
## Shown once
|
||||
14. After case 8, `GET /v1/api-key` -> entry keys exactly `id`, `user_id`, `name`, `prefix`, `status`,
|
||||
`created_at`, `last_used_at`, `expires_at`, `revoked_at`; assert the response bytes contain neither
|
||||
the raw key nor the stored hash. Also assert the raw key and hash are absent from the `DELETE`
|
||||
response and from every error body in this file.
|
||||
15. There is no route that returns a key again: `GET /v1/api-key/{id}` -> `404` or `405` (whatever the
|
||||
router yields; assert not `200`).
|
||||
|
||||
## Authentication
|
||||
16. Table over `GET /v1/settings`, `GET /v1/user`, `PATCH /v1/user/me`, `POST /v1/article`,
|
||||
`PATCH /v1/article/{id}/publish`, `DELETE /v1/article/{id}`, `GET /v1/media`, `DELETE /v1/media/{id}`
|
||||
with only the Bearer key of admin 2 -> `200` each; the stored side effects (author, display name)
|
||||
belong to user 2.
|
||||
17. Public article routes: seed offline article 5, online article 6. Key -> list has both, `GET /5` is
|
||||
`200`. No credentials -> list has only 6, `GET /5` is `404`. `Bearer rcms_unknown` on `GET /v1/article`
|
||||
-> `401` `apperr.UnauthorizedErrorMessage`.
|
||||
18. Owner key `POST /v1/user/invite` -> `201`, `invited_by` 1. Admin key -> `403`
|
||||
`apperr.OwnerRequiredErrorMessage`, no invite and no mail.
|
||||
19. Table of 401 cases on `GET /v1/settings`, all with body `apperr.UnauthorizedErrorMessage` exactly (the
|
||||
stored-hash case has no `rcms_` prefix, so it takes the jwt path: assert status `401` only):
|
||||
unknown well-formed key; expired (clock one second past `expires_at`); revoked; creator deleted from
|
||||
the user fake; creator unverified (set `IsVerified=false` in the fake); `Bearer rcms_`; one
|
||||
character changed; the stored hash sent as the key. Assert each also leaves `touchCalls == 0`.
|
||||
Without an `rcms_` key the existing `jwtauth.Authenticator` response is unchanged: assert status `401`.
|
||||
20. A key whose `ExpiresAt` equals the clock's now is rejected; one second later than now is accepted.
|
||||
21. Cookie path unchanged: valid cookie -> `200`; valid jwt in `Authorization: Bearer` with no cookie ->
|
||||
`200`; stale `token_version` -> `401`; no credentials on a protected route -> `401`.
|
||||
22. Precedence: valid key of admin 3 plus an owner cookie on `POST /v1/user/invite` -> `403`
|
||||
(acts as admin 3); `Bearer rcms_unknown` plus an owner cookie on `GET /v1/settings` -> `401`.
|
||||
23. Role is read live: change the stored role of user 2 from admin to owner in the fake (test-only) and
|
||||
assert the key is now owner-capable, then back, and assert it is not. This pins "never more
|
||||
rights than the creator's current rights".
|
||||
24. `GET /v1/auth/me` with only admin 2's key -> `200`, body byte-equal to the body admin 2's cookie gets
|
||||
on the same route, describing user 2. `Bearer rcms_unknown` on `GET /v1/auth/me` -> `401`
|
||||
`apperr.UnauthorizedErrorMessage`.
|
||||
|
||||
## Session-only routes (RequireSession)
|
||||
25. Table, each with body `apperr.SessionRequiredErrorMessage`, status `403`, assert the underlying use
|
||||
cases (key repository, verify, cancel registration, update status/check/download/restart) were
|
||||
not invoked and the key repository is unchanged:
|
||||
- owner 1's active key on `POST /v1/api-key`, `GET /v1/api-key`, `DELETE /v1/api-key/1`,
|
||||
`POST /v1/auth/verify`, `DELETE /v1/auth/register`, `GET /v1/system/update`,
|
||||
`POST /v1/system/update/check`, `POST /v1/system/update/download`, `POST /v1/system/update/restart`;
|
||||
- a revoked key on `GET /v1/api-key`;
|
||||
- an unknown well-formed `rcms_...` key on `POST /v1/system/update/check`;
|
||||
- the bare string `rcms_` on `DELETE /v1/auth/register`.
|
||||
The 403 comes before any authentication, so assert the same 403 for an owner key and a garbage key.
|
||||
26. Session routes unchanged for sessions: owner cookie on `POST /v1/api-key`, `GET /v1/api-key`,
|
||||
`GET /v1/system/update`, `POST /v1/system/update/check` -> the same status and body as before this
|
||||
feature; no credentials -> status `401` (existing Authenticator); a jwt in `Authorization: Bearer`
|
||||
(not starting with `rcms_`) -> the same result as the cookie.
|
||||
27. Public auth routes with an owner key attached: `POST /v1/auth/login` with wrong credentials -> `403`
|
||||
`apperr.LogsErrorMessage`, no `jwt` Set-Cookie; `POST /v1/auth/password-reset/confirm` with a wrong
|
||||
code -> the same response as without the key, and the key is not rejected with
|
||||
`SessionRequiredErrorMessage`. The user's stored password hash and `TokenVersion` are unchanged.
|
||||
|
||||
## Password reset revokes keys
|
||||
28. Seed user 2 with active keys "a" and "b" (each `200` on `GET /v1/settings`), user 3 with key "c", and a
|
||||
reset code for user 2 (hash of "reset-code-1", unexpired). `POST /v1/auth/password-reset/confirm`
|
||||
`{"email":"dev@example.com","code":"reset-code-1","password":"newpassword1"}` -> `200`; then keys "a"
|
||||
and "b" get `401` `apperr.UnauthorizedErrorMessage` on `GET /v1/settings`.
|
||||
29. Same seed: key "c" still gets `200` and has no `revoked_at`.
|
||||
30. Same seed with the fake clock at T: log in as user 2 with the new password, `GET /v1/api-key` with the
|
||||
new cookie; "a" and "b" have status `revoked` and `revoked_at` equal to T.
|
||||
31. A key of user 2 already revoked at T0 keeps `revoked_at` T0 after a reset at a later T; the active
|
||||
key gets T.
|
||||
32. Failed confirms (table: wrong code; expired code; unknown email) -> status not `200`; key "a" has no
|
||||
`revoked_at`, still `200` on `GET /v1/settings`, and `revokeAllCalls` is empty.
|
||||
|
||||
## Listing
|
||||
33. Admin 2 sees only own keys; owner 1 sees all, newest first, each with its `user_id`.
|
||||
34. Status table: active never used (`active`, nulls), active used (timestamp kept), expired
|
||||
(`expired`), revoked (`revoked`, `revoked_at` set), expired-and-revoked (`revoked`).
|
||||
|
||||
## Revoke
|
||||
35. Own key -> `204`, empty body; the key then gets `401` on `GET /v1/settings`; the list shows `revoked`.
|
||||
36. Second `DELETE` -> `204`, `revoked_at` unchanged.
|
||||
37. Admin 2 revoking admin 3's key -> `404` `apperr.APIKeyNotFoundErrorMessage`; the key still works.
|
||||
Owner revoking it -> `204`; it then gets `401`.
|
||||
38. Ids `abc`, `-1`, `4294967296` -> `400` `apperr.IdUint32ErrorMessage`; unknown id -> `404`.
|
||||
39. Repository failure on `Revoke` -> `500` "Internal server error"; the key still works.
|
||||
|
||||
## last_used_at
|
||||
40. Use-case level with the fake clock: first auth at T writes T; at T+30s no write
|
||||
(`touchCalls` unchanged); at T+61s writes T+61s.
|
||||
41. `TouchLastUsed` failing -> the request still returns `200`.
|
||||
42. Rejected keys (case 19) never write.
|
||||
|
||||
## Rate limit
|
||||
43. Mirror a group using the new per-key limiter with a limit of 2 (an exported constructor taking max
|
||||
attempts and window): third request with key A -> `429`, body `apperr.TooManyRequestsErrorMessage`,
|
||||
`Retry-After` header; key B and a cookie request still `200`; invalid keys stay `401`, never `429`.
|
||||
44. Through `api.InitBackendRoutes`, send 121 requests with one key to `GET /v1/settings` -> the 121st is
|
||||
`429`, the first 120 are `200`.
|
||||
|
||||
## CORS
|
||||
45. Through `api.InitRoutes` with `CorsAllowedOrigins` `["https://app.example.com"]`: `OPTIONS /v1/article/1`
|
||||
with `Origin` and `Access-Control-Request-Method: PATCH` -> success, `Access-Control-Allow-Methods`
|
||||
includes `PATCH`; with `TRACE` it is not allowed.
|
||||
|
||||
## Use case level (no HTTP)
|
||||
46. `CreateAPIKey` with `stubKeyGenerator`: repository receives the hash and the prefix and the user id;
|
||||
the raw key appears only in the return value; a generator error -> returned, nothing stored; past
|
||||
expiry -> `errors.Is(err, apikey.ErrExpiryNotInFuture)`.
|
||||
47. `AuthenticateAPIKey`: each invalid case in 19 and 20 -> `errors.Is(err, apikey.ErrAPIKeyInvalid)`; a
|
||||
valid key returns the user; an unexpected repository error is returned and is not `ErrAPIKeyInvalid`.
|
||||
48. `ListAPIKeys` for admin and owner; `RevokeAPIKey` own, other's (`ErrAPIKeyNotFound` for admin),
|
||||
owner any, unknown (`ErrAPIKeyNotFound`).
|
||||
49. `ResetPasswordUseCase` built over `mockUserRepo`, `stubHasher`, `mockAPIKeyRepo` and a fake clock at T:
|
||||
a successful reset for user 2 -> exactly one `RevokeAllByUser(2, T)` and user 3's keys untouched; a
|
||||
wrong or expired code -> the existing sentinel and `revokeAllCalls` empty; `RevokeAllByUser`
|
||||
failing -> that error is returned (the password was already changed).
|
||||
50. `mockAPIKeyRepo.RevokeAllByUser` semantics: sets `RevokedAt` only on that user's not-yet-revoked
|
||||
keys.
|
||||
51. SHA-256 generator: raw matches the regex, `Hash(raw)` equals the generated hash, two generated keys
|
||||
differ, prefix is the first 12 characters.
|
||||
|
||||
## Manual verification (not exercised by the Go tests)
|
||||
- Real SQLite: start `task dev:api`, confirm `AutoMigrate` creates the api_keys table with a unique index
|
||||
on the hashed key, create a key, restart, and check it still authenticates.
|
||||
- With `curl`, run `Authorization: Bearer <key>` against a running server for an article create, a media
|
||||
upload, an owner invite and `GET /v1/auth/me`, and confirm `PATCH` works from a browser origin listed
|
||||
in `CORS_ALLOWED_ORIGINS`.
|
||||
- Confirm the full key is shown only in the creation response and appears in no server log
|
||||
(`ENVIRONMENT=development` enables httplog).
|
||||
- A real reverse proxy: confirm `Authorization` is forwarded and `Retry-After` reaches the client.
|
||||
- A real password reset by email: confirm the keys stop working after the link is used.
|
||||
@@ -0,0 +1,352 @@
|
||||
Feature: API keys screen
|
||||
|
||||
As a verified RenewCMS owner or admin
|
||||
I want /integration/api-keys to list my API keys, create new ones and revoke them
|
||||
So that a script or service can call the API with my rights, and I can cut it off at any time
|
||||
|
||||
Background:
|
||||
Given /integration/api-keys is a verified route (meta.auth "verified") rendered as ApiKeysPage
|
||||
with CreateApiKeyModal, ApiKeyReveal and a revoke confirmation dialog, inside
|
||||
DashboardShell, per docs/design/routes.md
|
||||
And the screen is a sub-page of Integration, per docs/design/screens/integration.md
|
||||
And the screen calls, with relative URLs through useApi only, GET /v1/api-key,
|
||||
POST /v1/api-key and DELETE /v1/api-key/{id}
|
||||
And a GET /v1/api-key entry has the fields id, user_id, name, prefix (12 characters), status
|
||||
("active", "expired" or "revoked"), created_at, last_used_at, expires_at and revoked_at, and
|
||||
never a key or a hash, per features/api_keys.feature
|
||||
And an owner's entries also carry created_by_name, the creator's display name or username (see
|
||||
the API scenarios below)
|
||||
And the list arrives newest first; an owner receives every user's keys, an admin only their own
|
||||
And the PageHeader shows breadcrumb "Integration › API keys" (Integration links to
|
||||
/integration), title "API keys" and a primary "Create key" button on the right at desktop
|
||||
And the description reads "Keys act as the user who created them. As an owner you see
|
||||
everyone's keys." for an owner, and "Keys act as you, with your role. You only see your own
|
||||
keys." for an admin; a session without a role gets the admin rendering
|
||||
And the table, on desktop, has the columns Name, Key, Status, Created by (owner only), Created,
|
||||
Last used, Expires and an action column, in a UTable with a 36 px header and 48 px rows
|
||||
And below md, each key is a card: name and status badge, prefix in monospace, then
|
||||
"Created {date}[ by {name}] · Last used {date} · Expires {date}", then a 44 px "Revoke" button
|
||||
And the Key column and the card show the prefix cut to its first 9 characters followed by "…",
|
||||
for example "rcms_Kf3x…", and the full key is never shown in the list
|
||||
And the revoke text uses the same 9-character prefix plus "…"
|
||||
And dates are absolute and formatted for the current locale, for example "12 Mar 2026" in
|
||||
English; a null expires_at reads "None" and a null last_used_at reads "Never"
|
||||
And a Created by cell with an empty created_by_name shows "—"
|
||||
And the Status badge is a UBadge: active success soft, expired warning soft, revoked neutral
|
||||
with a muted row
|
||||
And "Revoke" is offered on active and expired keys, never on revoked keys
|
||||
And a note with an info icon always closes the page: "Resetting a user's password revokes all of
|
||||
their keys."
|
||||
And the Integration page (/integration, features/integration.feature) gains a "Manage API
|
||||
keys" link to /integration/api-keys (see the Integration link scenario)
|
||||
And on /integration/api-keys the sidebar and drawer item "Integration" carries
|
||||
aria-current="page"
|
||||
And every visible string goes through t(...), with the English text as the key and its French
|
||||
entry in web/src/i18n/messages.ts
|
||||
And the screen uses Nuxt UI components and design tokens only, with no raw colors
|
||||
And the owner fixture is, newest first as the board draws it:
|
||||
| name | prefix | status | created by | created | last used | expires |
|
||||
| Website build | rcms_Kf3x… | active | Marta Alves | 12 Mar 2026 | 3 Oct 2026 | None |
|
||||
| Nightly media import | rcms_Mq7b… | active | Jonas | 2 Apr 2026 | 3 Oct 2026 | 31 Dec 2026 |
|
||||
| Staging preview | rcms_Zt2d… | expired | Marta Alves | 14 Jun 2026 | 20 Aug 2026 | 30 Aug 2026 |
|
||||
| Old migration script | rcms_Hn5w… | revoked | Inès Duarte | 3 Apr 2026 | 9 May 2026 | None |
|
||||
And the admin fixture (user "Jonas") is:
|
||||
| name | prefix | status | created | last used | expires |
|
||||
| Nightly media import | rcms_Mq7b… | active | 2 Apr 2026 | 3 Oct 2026 | 31 Dec 2026 |
|
||||
| Old CI token | rcms_Lp9c… | revoked | 18 Jan 2026 | 2 Feb 2026 | None |
|
||||
|
||||
Scenario: Owner: list
|
||||
Given the session role is "owner"
|
||||
And GET /v1/api-key answers 200 with the owner fixture
|
||||
When the user navigates to /integration/api-keys
|
||||
Then the four rows show in fixture order with the columns Name, Key, Status, Created by,
|
||||
Created, Last used, Expires
|
||||
And "Staging preview" shows an "expired" badge and "Revoke"
|
||||
And "Old migration script" shows a "revoked" badge, a muted row and no "Revoke"
|
||||
And the description is the owner description
|
||||
|
||||
Scenario: Admin: own keys only
|
||||
Given the session role is "admin"
|
||||
And GET /v1/api-key answers 200 with the admin fixture
|
||||
When the user navigates to /integration/api-keys
|
||||
Then the two rows show with no "Created by" column
|
||||
And on mobile the cards read "Created 2 Apr 2026 · Last used 3 Oct 2026 · Expires 31 Dec 2026"
|
||||
without " by {name}"
|
||||
And the description is the admin description
|
||||
|
||||
Scenario: Loading
|
||||
Given GET /v1/api-key is pending
|
||||
When the user navigates to /integration/api-keys
|
||||
Then four skeleton rows are drawn in the table (two-bar cards below md) and the table has
|
||||
aria-busy "true"
|
||||
And the header "Create key" button is shown
|
||||
|
||||
Scenario: Empty
|
||||
Given GET /v1/api-key answers 200 with []
|
||||
When the user navigates to /integration/api-keys
|
||||
Then a panel shows title "No API keys yet", text "Create a key to call the API from a script or
|
||||
service." and a primary "Create key" button
|
||||
And no table is drawn
|
||||
|
||||
Scenario Outline: Error on load
|
||||
Given GET /v1/api-key fails with <failure>
|
||||
When the user navigates to /integration/api-keys
|
||||
Then an alert (role "alert") shows "Couldn't load your API keys" and "Check your connection and
|
||||
try again." with a "Try again" action
|
||||
And no table, no cards and no empty panel are drawn
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| status 500 |
|
||||
| a network error |
|
||||
|
||||
Scenario: Try again after an error
|
||||
Given the load failed and the error alert is shown
|
||||
When the user activates "Try again"
|
||||
Then GET /v1/api-key is requested again and, on 200, the list replaces the alert
|
||||
|
||||
Scenario Outline: Session problems use the app-wide handling
|
||||
Given GET /v1/api-key answers <status>
|
||||
When the user navigates to /integration/api-keys
|
||||
Then <outcome>
|
||||
|
||||
Examples:
|
||||
| status | outcome |
|
||||
| 401 | the user lands on /login?redirect=/integration/api-keys&reason=expired |
|
||||
| 403 | the user lands on /register/pending |
|
||||
|
||||
Scenario: Create key: modal opened
|
||||
Given the list is shown
|
||||
When the user activates "Create key"
|
||||
Then a modal titled "Create API key" opens with the text "The key acts as you, with your role. It
|
||||
is shown in full once."
|
||||
And it has a Name field with a "0 / 60" counter and help "Required. Up to 60 characters."
|
||||
And it has an "Expiry date (optional)" date input with help "Optional. Must be in the future.
|
||||
Leave it empty for a key that does not expire."
|
||||
And it has the buttons "Cancel" and "Create key"
|
||||
And Name has focus
|
||||
|
||||
Scenario Outline: Create key: validation
|
||||
Given the Create API key modal is open
|
||||
When the user submits with name "<name>" and expiry "<expiry>"
|
||||
Then no request is sent
|
||||
And <name_error>
|
||||
And <expiry_error>
|
||||
|
||||
Examples:
|
||||
| name | expiry | name_error | expiry_error |
|
||||
| | 12 Mar 2026 | Name shows the error "Enter a name." in red | Expiry shows the error "Choose a date in the future." in red |
|
||||
| | | Name shows the error "Enter a name." in red | Expiry shows no error |
|
||||
| Deploy | 12 Mar 2026 | Name shows no error | Expiry shows the error "Choose a date in the future." in red |
|
||||
|
||||
Scenario: Create key: name too long
|
||||
Given the Create API key modal is open
|
||||
When the user types a name of 75 characters
|
||||
Then the counter reads "75 / 60" in the error color
|
||||
And Name shows the error "Use 60 characters or fewer." in red
|
||||
And submitting sends no request
|
||||
|
||||
Scenario: Create key: success reveals the key once
|
||||
Given the Create API key modal is open
|
||||
When the user submits name "Nightly media import" and an empty expiry
|
||||
Then exactly one POST /v1/api-key is sent with body {"name": "Nightly media import"} and no
|
||||
expires_at
|
||||
And on 201 the form modal is replaced by a modal titled "Your new API key" with the text
|
||||
"\"Nightly media import\" is ready. Copy the key and store it somewhere safe."
|
||||
And a warning alert (UAlert, color warning, triangle-alert icon) reads "You won't be able to
|
||||
see this key again."
|
||||
And the full key from the response is shown in a monospace box with a "Copy" button (icon copy)
|
||||
And the note "Closing this window discards the key from the screen. Copy it first." is shown
|
||||
And the only button is a primary "Done"
|
||||
|
||||
Scenario: Create key: with an expiry
|
||||
Given the Create API key modal is open
|
||||
And the local date is D
|
||||
When the user submits name "temp" and the expiry date D + 30 days
|
||||
Then the POST body carries name "temp" and expires_at equal to 23:59:59 local time on D + 30
|
||||
days, as an RFC3339 instant with the local offset
|
||||
|
||||
Scenario Outline: The expiry date is checked against the same instant the API checks
|
||||
Given the Create API key modal is open and the current time is T
|
||||
When the user submits a name and the expiry date <date>
|
||||
Then <outcome>
|
||||
|
||||
Examples:
|
||||
| date | outcome |
|
||||
| today | the request is sent, because 23:59:59 today is after T |
|
||||
| yesterday | no request is sent and Expiry shows "Choose a date in the future." |
|
||||
| tomorrow | the request is sent |
|
||||
|
||||
Scenario: One-time reveal: copied
|
||||
Given the key reveal is open
|
||||
When the user activates "Copy"
|
||||
Then the full key is written to the clipboard
|
||||
And the button shows icon check and text "Copied" in the success color, announced to assistive
|
||||
technology
|
||||
When 2 seconds elapse
|
||||
Then the button reverts to icon copy and text "Copy"
|
||||
|
||||
Scenario: One-time reveal: Done discards the key
|
||||
Given the key reveal is open
|
||||
When the user activates "Done"
|
||||
Then the modal closes
|
||||
And the full key is no longer in the DOM, the component state, the URL or any storage
|
||||
And the new key is listed first with its 9-character prefix and "…" only, status "active",
|
||||
created by the current user (owner view), last used "Never", expires per the request, and no
|
||||
second GET /v1/api-key is made
|
||||
And reopening "Create key" shows an empty form
|
||||
|
||||
Scenario Outline: Dismissing the modals
|
||||
Given <modal> is open
|
||||
When the user <action>
|
||||
Then <outcome>
|
||||
|
||||
Examples:
|
||||
| modal | action | outcome |
|
||||
| the key reveal | presses Escape | the modal closes and the key is discarded, as for Done |
|
||||
| the key reveal | clicks the backdrop | the modal stays open |
|
||||
| the Create key form | presses Escape | the modal closes with no request |
|
||||
| the Create key form | clicks the backdrop | the modal stays open |
|
||||
|
||||
Scenario: Create key: Cancel discards the form
|
||||
Given the Create API key modal is open with typed values
|
||||
When the user activates "Cancel"
|
||||
Then the modal closes without a request, focus returns to the opening button, and reopening it
|
||||
shows an empty form
|
||||
|
||||
Scenario: Revoke: confirm
|
||||
Given the owner list is shown
|
||||
When the user activates "Revoke" on "Nightly media import"
|
||||
Then a dialog titled "Revoke this key?" opens with the text "\"Nightly media import\"
|
||||
(rcms_Mq7b…) will stop working right away. Requests that use it answer 401. This can't be
|
||||
undone."
|
||||
And its buttons are "Keep key" and a destructive "Revoke key"
|
||||
And "Keep key" closes the dialog with no request and the row unchanged
|
||||
|
||||
Scenario: Revoked
|
||||
Given the revoke dialog is open for "Nightly media import" (id 2)
|
||||
When the user activates "Revoke key"
|
||||
Then exactly one DELETE /v1/api-key/2 is sent
|
||||
And on 204 the dialog closes and the row stays listed with status "revoked", a muted row and no
|
||||
"Revoke" button
|
||||
And a success toast shows title "Key revoked" and text "\"Nightly media import\" can no longer
|
||||
be used."
|
||||
And no second GET /v1/api-key is made
|
||||
|
||||
Scenario: Mobile Create key button, not drawn on board
|
||||
Given the viewport is below md
|
||||
When the list, the loading skeleton or the error alert is shown
|
||||
Then a primary full-width 48 px "Create key" button is pinned to the bottom of the screen
|
||||
And it is hidden while any modal or dialog is open, and in the Empty state where the panel has
|
||||
its own "Create key" button
|
||||
And the header has no "Create key" button below md
|
||||
|
||||
Scenario: Integration page links to the keys screen
|
||||
Given the user is on /integration
|
||||
Then a "Manage API keys" link (UButton variant link, arrow-right trailing icon) is shown below
|
||||
the three info notes
|
||||
When the user activates it
|
||||
Then the user lands on /integration/api-keys and the sidebar "Integration" item keeps
|
||||
aria-current="page"
|
||||
|
||||
Scenario: Language: every string has a French entry
|
||||
Given the language is "fr"
|
||||
When the user opens each state of the screen
|
||||
Then every visible string is the French message and none falls back to English
|
||||
And dates are formatted for "fr"
|
||||
|
||||
Scenario: Create key: sending, not drawn on board, no visual state
|
||||
Given the Create API key modal is open with a valid name
|
||||
When the user submits and POST /v1/api-key is pending
|
||||
Then the primary button reads "Creating…", Name, Expiry and "Cancel" are disabled
|
||||
And Escape does not close the modal
|
||||
|
||||
Scenario Outline: Create key: failed, not drawn on board, no visual state
|
||||
Given the Create API key modal is open with name "Nightly media import"
|
||||
When POST /v1/api-key fails with <failure>
|
||||
Then an alert in the modal reads "<copy>"
|
||||
And the typed values are kept, the form is enabled again, no row is added, and no field error
|
||||
is shown
|
||||
|
||||
Examples:
|
||||
| failure | copy |
|
||||
| status 500 | Couldn't create the key. Try again in a moment. |
|
||||
| a network error | Couldn't reach RenewCMS. Check your connection and try again. |
|
||||
| status 429 | Too many requests. Wait a moment, then try again. |
|
||||
| status 400 | Something in the form isn't valid. Check the highlighted fields. |
|
||||
|
||||
Scenario: Create key: retry after failure, not drawn on board, no visual state
|
||||
Given a failed create left the modal open
|
||||
When the user submits again and POST answers 201
|
||||
Then the reveal opens and the row is added
|
||||
|
||||
Scenario: Revoke: sending, not drawn on board, no visual state
|
||||
Given the revoke dialog is open
|
||||
When the user activates "Revoke key" and DELETE is pending
|
||||
Then "Revoke key" reads "Revoking…", both buttons are disabled, and Escape does not close the
|
||||
dialog
|
||||
|
||||
Scenario Outline: Revoke: failed, not drawn on board, no visual state
|
||||
Given the revoke dialog is open for "Nightly media import"
|
||||
When DELETE fails with <failure>
|
||||
Then a notice in the dialog reads "<copy>"
|
||||
And the dialog stays open, the buttons are enabled again, and the row is unchanged
|
||||
|
||||
Examples:
|
||||
| failure | copy |
|
||||
| status 500 | Couldn't revoke the key. Try again in a moment. |
|
||||
| a network error | Couldn't reach RenewCMS. Check your connection and try again. |
|
||||
|
||||
Scenario: Revoke: key already gone, 404, not drawn on board, no visual state
|
||||
Given the revoke dialog is open for "Nightly media import" (id 2)
|
||||
When DELETE /v1/api-key/2 answers 404
|
||||
Then the dialog closes, the row is removed from the list, and an error toast reads "That key no
|
||||
longer exists."
|
||||
|
||||
Scenario: Last used, never, not drawn on board, no visual state
|
||||
Given an entry whose last_used_at is null
|
||||
Then its Last used cell and its mobile card text read "Never"
|
||||
|
||||
Scenario: API: an owner's list carries the creator's name
|
||||
Given owner 1 "marta" with display name "Marta Alves", admin 2 "dev" with display name "Dev Two"
|
||||
and admin 3 "pat" with an empty display name
|
||||
And keys "a" by user 1, "b" by user 2 and "c" by user 3
|
||||
When owner 1 sends GET /v1/api-key with a jwt cookie
|
||||
Then each entry has the key created_by_name
|
||||
And entry "a" has "Marta Alves", "b" has "Dev Two" and "c" has "pat"
|
||||
And the other nine fields are unchanged from features/api_keys.feature
|
||||
|
||||
Scenario: API: a whitespace-only display name falls back to the username
|
||||
Given admin 3 "pat" with display name " " and a key "c"
|
||||
When owner 1 sends GET /v1/api-key
|
||||
Then entry "c" has created_by_name "pat"
|
||||
|
||||
Scenario: API: an admin's list is unchanged
|
||||
Given admin 2 with keys "a" and "b"
|
||||
When admin 2 sends GET /v1/api-key with a jwt cookie
|
||||
Then every entry has exactly the keys id, user_id, name, prefix, status, created_at,
|
||||
last_used_at, expires_at, revoked_at
|
||||
And no entry has created_by_name
|
||||
|
||||
Scenario: API: a key whose creator no longer exists
|
||||
Given a key whose user record was deleted
|
||||
When owner 1 sends GET /v1/api-key
|
||||
Then its entry has created_by_name ""
|
||||
And the response status is 200 and the other entries are unaffected
|
||||
|
||||
Scenario: API: a failing user lookup fails the list
|
||||
Given the user repository fake returns an unexpected error on Get for a creator
|
||||
When owner 1 sends GET /v1/api-key
|
||||
Then the response status is 500 and the body is "Internal server error"
|
||||
|
||||
Scenario: API: the creator name is resolved once per user
|
||||
Given owner 1 has 3 keys and admin 2 has 2 keys
|
||||
When owner 1 sends GET /v1/api-key
|
||||
Then the user repository received one Get per distinct creator, not one per key
|
||||
|
||||
Scenario: Use case: ListAPIKeys returns the creator name only to an owner
|
||||
Given a fake apikey.Repository and a fake user.Repository
|
||||
When ListAPIKeys is called for an owner
|
||||
Then every APIKeyEntry carries the creator's name
|
||||
When ListAPIKeys is called for an admin
|
||||
Then every APIKeyEntry has an empty creator name and the handler omits the field
|
||||
@@ -0,0 +1,235 @@
|
||||
# QA: API keys screen
|
||||
|
||||
Two parts: the Go API change (HTTP-level, in `test/`) and the SPA (Vitest and visual).
|
||||
|
||||
## What changes in the backend
|
||||
|
||||
Only `GET /v1/api-key` called by an owner changes: each entry gains `created_by_name`, the creator's
|
||||
display name (trimmed, non-empty) or else username, and `""` when the creator no longer exists.
|
||||
Admin entries keep exactly the nine existing fields and never carry `created_by_name`.
|
||||
`features/api_keys.feature` is not edited. Its exact-fields scenario is made as an admin and stays
|
||||
true. No domain or persistence change: `ListAPIKeysUseCase` already holds `user.Repository` and
|
||||
resolves each distinct creator once through `Get`.
|
||||
|
||||
## Go: created_by_name on GET /v1/api-key
|
||||
|
||||
Extend `test/api_keys_http_test.go`. Build the handler from `NewHandler(...)` over use cases wired
|
||||
to the existing in-memory fakes (apikey.Repository, user.Repository, KeyGenerator, fake clock).
|
||||
Mount it on a fresh `chi.NewRouter()` that mirrors `api/router.go`, wrapped in `jwtauth.Verifier`
|
||||
and `jwtauth.Authenticator` with a test `jwtauth.New("HS256", ...)`; call
|
||||
`validator.InitValidator()`; exercise it with `httptest`. No live server, no real SQLite, no real
|
||||
SMTP. Users: owner 1 "marta" (display name "Marta Alves"), admin 2 "dev" ("Dev Two"), admin 3
|
||||
"pat" (empty display name), all verified.
|
||||
|
||||
1. **Owner sees names** - keys a (user 1), b (user 2), c (user 3); owner cookie GET /v1/api-key;
|
||||
assert 200, every entry has `created_by_name`, a "Marta Alves", b "Dev Two", c "pat", and the
|
||||
other nine fields as before (newest first).
|
||||
2. **Whitespace display name** - user 3 display name " "; assert "pat".
|
||||
3. **Admin unchanged** - admin 2 cookie; assert every entry has exactly the nine existing fields
|
||||
(use `apiKeyAssertExactFields`) and no `created_by_name`. Check that the existing exact-fields
|
||||
test at line 341 is made as an admin; if it is made as an owner, update it and report it.
|
||||
4. **Deleted creator** - key whose user was removed from the fake; owner GET; assert 200 and
|
||||
`created_by_name` "" for it.
|
||||
5. **User lookup fails** - the fake user repository returns an error on Get for a creator; assert
|
||||
500 and body "Internal server error".
|
||||
6. **One lookup per creator** - the fake counts Get calls; owner with 3 + 2 keys over 2 creators;
|
||||
assert the creator lookups equal the distinct creators (plus the requester's own Get).
|
||||
7. **Use case level** - ListAPIKeys for an owner returns entries with the creator name; for an
|
||||
admin returns empty creator names; the other existing ListAPIKeys tests pass unchanged.
|
||||
8. **Regression** - the existing api_keys tests still pass; list order, statuses, 401 and 403
|
||||
behaviour are unchanged.
|
||||
|
||||
Run with `task test`; one test with `task test -- -run 'TestName'`.
|
||||
|
||||
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s `request` for GET /v1/api-key, POST /v1/api-key and DELETE /v1/api-key/{id} per
|
||||
case. Mount ApiKeysPage inside DashboardShell with a resolved verified session (id 1, "marta",
|
||||
display name "Marta Alves", role "owner") and a test router at /integration/api-keys; admin cases
|
||||
use role "admin", username "jonas". Mocked owner entries carry `created_by_name`. Stub
|
||||
`navigator.clipboard.writeText` per case. No test freezes the system date; specs assert on the
|
||||
fixtures' absolute dates only, and the expiry cases build their dates from the real clock. Run
|
||||
layout cases at desktop and below-md (mock `matchMedia`).
|
||||
|
||||
1. **Owner: list** - breadcrumb (with the Integration link), title, owner description, header
|
||||
labels, rows in fixture order with every cell (prefix cut to 9 characters plus "…", Created by
|
||||
from `created_by_name`), badge words, "Revoke" on rows 1 to 3 and not row 4, row 4 muted, the
|
||||
note; the below-md cards. An entry with an empty `created_by_name` shows "—".
|
||||
2. **Admin: own keys only** - no "Created by" header or cell, admin description, two rows, cards
|
||||
without " by {name}"; a session with no role gets the same rendering.
|
||||
3. **Loading** - unresolved GET; 4 skeleton rows, aria-busy "true", header "Create key"; resolve
|
||||
and assert rows.
|
||||
4. **Empty** - `[]`; the panel copy and its "Create key", no table.
|
||||
5. **Error, Examples-driven** - ApiError(500), then NetworkError; alert copy as drawn, "Try again",
|
||||
no rows; click "Try again" and assert the second request and the rows.
|
||||
6. **401 and 403** - the app-wide redirects, no alert on the page.
|
||||
7. **Create key: opened** - title, text, "0 / 60", helps, buttons, focus on Name.
|
||||
8. **Create key: validation, Examples-driven** - the three Outline rows; error text, no request,
|
||||
errors clear when fixed.
|
||||
9. **Create key: name too long** - 75 characters; counter, error class and text, no request.
|
||||
10. **Create key: success** - one POST with `{"name":"Nightly media import"}` and no expires_at
|
||||
key; the reveal: title, text, warning alert, key equal to the mocked `key`, "Copy", note, only
|
||||
"Done".
|
||||
11. **Create key: expiry** - date D + 30 days; assert expires_at is 23:59:59 local on that day
|
||||
with the local offset. Examples today (sent), yesterday (blocked), tomorrow (sent); build the
|
||||
dates from `new Date()` in the test.
|
||||
12. **Reveal: copied** - fake timers; click "Copy"; writeText with the full key, icon check,
|
||||
"Copied", aria-live announcement; advance 2 s; "Copy" again.
|
||||
13. **Reveal: Done and Escape discard** - assert the modal is gone, the key string appears nowhere
|
||||
in `wrapper.html()`, the router URL, localStorage or sessionStorage; the new row shows the
|
||||
prefix only, status "active", "Never", created by the session user; no second GET; a reopened
|
||||
form is empty. A backdrop click leaves the reveal open.
|
||||
14. **Create key: Cancel and Escape** - no request, empty on reopen, focus back on the opener;
|
||||
a backdrop click does not close.
|
||||
15. **Revoke: confirm and Keep key** - dialog copy with the 9-character prefix, both buttons;
|
||||
"Keep key" closes with no request.
|
||||
16. **Revoked** - confirm; one DELETE with the key's id; row "revoked", muted, no "Revoke"; toast
|
||||
title and text; no second GET.
|
||||
17. **Not drawn: Create sending** - pending POST; "Creating…", disabled fields and Cancel, Escape
|
||||
ignored.
|
||||
18. **Not drawn: Create failed, Examples-driven** - ApiError(500) "Couldn't create the key. Try
|
||||
again in a moment.", NetworkError "Couldn't reach RenewCMS. Check your connection and try
|
||||
again.", ApiError(429) "Too many requests. Wait a moment, then try again.", ApiError(400)
|
||||
"Something in the form isn't valid. Check the highlighted fields."; values kept, form
|
||||
re-enabled, no row added; submit again, resolve 201, assert the reveal.
|
||||
19. **Not drawn: Revoke sending** - "Revoking…", both buttons disabled, Escape ignored.
|
||||
20. **Not drawn: Revoke failed, Examples-driven** - ApiError(500) "Couldn't revoke the key. Try
|
||||
again in a moment." and NetworkError "Couldn't reach RenewCMS. Check your connection and try
|
||||
again."; the notice in the open dialog, row unchanged; retry succeeds.
|
||||
21. **Not drawn: Revoke 404** - the dialog closes, the row is removed, the error toast "That key no
|
||||
longer exists.".
|
||||
22. **Not drawn: Last used "Never"** - null `last_used_at`, desktop cell and mobile card.
|
||||
23. **Mobile pinned button** - below md the pinned 48 px "Create key" exists in list, loading and
|
||||
error; hidden while a modal is open and in Empty; no header button below md.
|
||||
24. **Integration link and navigation** - on /integration assert "Manage API keys" is a link to
|
||||
/integration/api-keys below the three info notes; navigate; assert the sidebar and drawer
|
||||
"Integration" item has aria-current="page" on /integration/api-keys. Update the existing
|
||||
integration specs (features/integration.qa.md case 1) for the extra link.
|
||||
25. **French** - `setLocale('fr')`; walk cases 1, 4, 7, 10, 15, 16, 17, 21; no visible English
|
||||
source string remains; dates use the French format.
|
||||
26. **Messages** - every new `t()` key has a French entry (extend the i18n spec pattern).
|
||||
|
||||
Un-run web tests are reported as NOT RUN, never as passing.
|
||||
|
||||
## Visual (`task visual:check -- integration-api-keys`, desktop and mobile, light and dark)
|
||||
|
||||
Reference: docs/design/screens/integration.md, board `docs/design/source/RenewCMS Screens API
|
||||
keys.dc.html`, exact values in `docs/design/source/ApiKeysPage.dc.html`. Add each state's setup to
|
||||
`web/visual/states.ts` with the board's own sample data (mocked GET /v1/api-key responses include
|
||||
`created_by_name` and 12-character prefixes whose first 9 characters match the board). Hard gate:
|
||||
every capture of every state within 10 %.
|
||||
|
||||
Sample data: the owner and admin fixtures from the feature's Background; Create modal name
|
||||
"Nightly media import" (20 / 60) and expiry 31 Dec 2026; validation: empty name (0 / 60) and
|
||||
expiry 12 Mar 2026; too long: "Nightly import of the new media library from the Brussels office
|
||||
file server" (75 / 60); reveal key rcms_Kf3x9QwL0aBcT7nV2uYdE5sHjR8mPz4WqXg6C1oIyNe for
|
||||
"Nightly media import"; revoke and toast for "Nightly media import" (rcms_Mq7b…).
|
||||
|
||||
No state is clock-sensitive, since every date is fixed text. The Create sample expiry 31 Dec 2026
|
||||
becomes a past date after that day, but the state only displays it, so it gets no skip.
|
||||
|
||||
States:
|
||||
- [ ] Owner: list
|
||||
- [ ] Admin: own keys only
|
||||
- [ ] Loading (the setup holds the request)
|
||||
- [ ] Empty
|
||||
- [ ] Error
|
||||
- [ ] Create key
|
||||
- [ ] Create key: validation
|
||||
- [ ] Create key: name too long
|
||||
- [ ] One-time reveal
|
||||
- [ ] One-time reveal: copied (timer-driven, so it is checked)
|
||||
- [ ] Revoke: confirm
|
||||
- [ ] Revoked (list with the revoked row and the toast)
|
||||
- [ ] Layout from the page component, not components.md: 36 px header, 48 px rows, 20 px status
|
||||
badge; desktop modal 480 px wide 120 px from the top; mobile modal full width, bottom-aligned,
|
||||
buttons stacked with Cancel under the primary; mobile card Revoke 44 px; toast 320 px wide
|
||||
bottom-right on desktop and 16 px inset on mobile.
|
||||
- [ ] Not drawn on the board, no visual state, Vitest only: Create sending, Create failed (500,
|
||||
network, 429, 400), Revoke sending, Revoke failed, Revoke 404, "Never", the dismissal rules,
|
||||
and the Integration "Manage API keys" link.
|
||||
|
||||
Risk: the mobile pinned "Create key" button is not on the board, and the Integration link is not
|
||||
on the board's frames for the current Integration page. Either may push a mobile or Integration
|
||||
capture over 10 %. Re-run `task visual:check -- integration` and the API keys screen. If either
|
||||
fails, the user must decide: an approved per-state exception needs `approvedBy`, or a design
|
||||
update.
|
||||
|
||||
Manual checks screenshots cannot cover:
|
||||
- Keyboard and focus order: Tab reaches "Create key", then each "Revoke" in row order; modal focus
|
||||
starts on Name, is trapped, and returns to the opener on close; the reveal's initial focus lands
|
||||
on "Copy".
|
||||
- Hover and motion: outline and primary button hover; modal motion; skeleton pulse stopped under
|
||||
reduced motion.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- Real round trip against `task dev`: create a key, copy it, run
|
||||
`curl -H "Authorization: Bearer <key>" <origin>/v1/auth/me` and get 200; revoke it from the
|
||||
screen and get 401; confirm the key gets 403 "This action needs a real session." on
|
||||
/v1/api-key.
|
||||
- Owner and admin: as owner confirm the Created by names (display name, else username); as admin
|
||||
confirm only your own keys and no Created by column.
|
||||
- Reset-password revocation: reset a user's password and confirm their keys show "revoked" after
|
||||
reload.
|
||||
- Clipboard on a real HTTPS or localhost origin.
|
||||
|
||||
## French strings for web/src/i18n/messages.ts
|
||||
|
||||
Keyed by the English text. `{0}` and `{1}` are the `t()` placeholders.
|
||||
|
||||
- API keys = Clés d'API
|
||||
- Integration › API keys = Intégration › Clés d'API
|
||||
- Create key = Créer une clé
|
||||
- Name = Nom
|
||||
- Key = Clé
|
||||
- Status = Statut
|
||||
- Created by = Créée par
|
||||
- Created = Créée le
|
||||
- Last used = Dernière utilisation
|
||||
- Expires = Expire le
|
||||
- active = active
|
||||
- expired = expirée
|
||||
- revoked = révoquée
|
||||
- Revoke = Révoquer
|
||||
- None = Aucune
|
||||
- Never = Jamais
|
||||
- Keys act as the user who created them. As an owner you see everyone's keys. = Les clés agissent comme l'utilisateur qui les a créées. En tant que propriétaire, vous voyez les clés de tout le monde.
|
||||
- Keys act as you, with your role. You only see your own keys. = Les clés agissent en votre nom, avec votre rôle. Vous ne voyez que vos propres clés.
|
||||
- Resetting a user's password revokes all of their keys. = Réinitialiser le mot de passe d'un utilisateur révoque toutes ses clés.
|
||||
- No API keys yet = Aucune clé d'API pour l'instant
|
||||
- Create a key to call the API from a script or service. = Créez une clé pour appeler l'API depuis un script ou un service.
|
||||
- Couldn't load your API keys = Impossible de charger vos clés d'API
|
||||
- Check your connection and try again. = Vérifiez votre connexion et réessayez.
|
||||
- Try again = Réessayer
|
||||
- Create API key = Créer une clé d'API
|
||||
- The key acts as you, with your role. It is shown in full once. = La clé agit en votre nom, avec votre rôle. Elle n'est affichée en entier qu'une seule fois.
|
||||
- Required. Up to 60 characters. = Obligatoire. 60 caractères maximum.
|
||||
- Expiry date (optional) = Date d'expiration (facultative)
|
||||
- Optional. Must be in the future. Leave it empty for a key that does not expire. = Facultative. Doit être dans le futur. Laissez vide pour une clé qui n'expire pas.
|
||||
- Enter a name. = Saisissez un nom.
|
||||
- Use 60 characters or fewer. = Utilisez 60 caractères ou moins.
|
||||
- Choose a date in the future. = Choisissez une date dans le futur.
|
||||
- Cancel = Annuler
|
||||
- Your new API key = Votre nouvelle clé d'API
|
||||
- "{0}" is ready. Copy the key and store it somewhere safe. = « {0} » est prête. Copiez la clé et conservez-la en lieu sûr.
|
||||
- You won't be able to see this key again. = Vous ne pourrez plus revoir cette clé.
|
||||
- Copy = Copier
|
||||
- Copied = Copié
|
||||
- Closing this window discards the key from the screen. Copy it first. = Fermer cette fenêtre supprime la clé de l'écran. Copiez-la d'abord.
|
||||
- Done = Terminé
|
||||
- Revoke this key? = Révoquer cette clé ?
|
||||
- "{0}" ({1}) will stop working right away. Requests that use it answer 401. This can't be undone. = « {0} » ({1}) cessera de fonctionner immédiatement. Les requêtes qui l'utilisent recevront une réponse 401. Cette action est irréversible.
|
||||
- Keep key = Garder la clé
|
||||
- Revoke key = Révoquer la clé
|
||||
- Key revoked = Clé révoquée
|
||||
- "{0}" can no longer be used. = « {0} » ne peut plus être utilisée.
|
||||
- Creating… = Création…
|
||||
- Revoking… = Révocation…
|
||||
- Couldn't create the key. Try again in a moment. = Impossible de créer la clé. Réessayez dans un instant.
|
||||
- Couldn't reach RenewCMS. Check your connection and try again. = Impossible de joindre RenewCMS. Vérifiez votre connexion et réessayez.
|
||||
- Too many requests. Wait a moment, then try again. = Trop de requêtes. Patientez un instant, puis réessayez.
|
||||
- Something in the form isn't valid. Check the highlighted fields. = Un élément du formulaire n'est pas valide. Vérifiez les champs signalés.
|
||||
- Couldn't revoke the key. Try again in a moment. = Impossible de révoquer la clé. Réessayez dans un instant.
|
||||
- That key no longer exists. = Cette clé n'existe plus.
|
||||
- Manage API keys = Gérer les clés d'API
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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.
|
||||
+182
-52
@@ -1,63 +1,193 @@
|
||||
Feature: Integration screen
|
||||
Feature: Integration screen (API reference)
|
||||
|
||||
As a verified RenewCMS owner
|
||||
I want /integration to show my site's base API URL and ready-to-use endpoint examples
|
||||
So that I, or the developer who builds my public site, can wire it up without reading the
|
||||
source
|
||||
As a developer building a frontend or an API-key client for a RenewCMS site
|
||||
I want /integration to be a complete, searchable API reference
|
||||
So that I can call every route a key may call without reading the source
|
||||
|
||||
Background:
|
||||
Given /integration is a verified route (meta.auth "verified") rendered as IntegrationPage
|
||||
inside DashboardShell, per docs/design/routes.md
|
||||
And the screen makes no /v1 calls; api: none (static)
|
||||
And the PageHeader shows title "Connect your website", description "A guide for the developer
|
||||
who builds your public site.", breadcrumb "Integration" (mobile title "Integration"), and no
|
||||
actions
|
||||
And an introductory paragraph reads "RenewCMS is headless: it stores and publishes your
|
||||
articles but doesn't show them to visitors. Your website fetches published articles from the
|
||||
two addresses below and displays them in its own design."
|
||||
And a "Base URL" row shows window.location.origin in a monospace box, with a "Copy" button
|
||||
(icon copy) beside it
|
||||
And two EndpointCard entries follow, in this order:
|
||||
| method | path | description |
|
||||
| GET | /v1/article/ | Lists every online article. |
|
||||
| GET | /v1/article/{id} | Returns one online article. Drafts and unknown ids both return 404. |
|
||||
And each EndpointCard's fetch example uses the current origin as the base of its URL:
|
||||
| path | fetch example |
|
||||
| /v1/article/ | const res = await fetch('{origin}/v1/article/')\nconst articles = await res.json() |
|
||||
| /v1/article/{id} | const res = await fetch('{origin}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json() |
|
||||
And each EndpointCard has fetch, curl and Response tabs, with fetch active by default, and its
|
||||
CodeBlock is labelled "JavaScript" with its own ghost "Copy" button
|
||||
And the curl tab's content is a plain curl command built from the current origin (e.g.
|
||||
"curl {origin}/v1/article/" and "curl {origin}/v1/article/12"), and the Response tab's
|
||||
content is a JSON sample built from the real domain Article fields; neither tab is active by
|
||||
default, and neither is visually checked, since the fetch tab is the one shown in the board
|
||||
And three info notes follow the endpoint cards, in this order:
|
||||
- "Anonymous callers only ever get online articles. Drafts stay private until you publish
|
||||
them."
|
||||
- "Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the
|
||||
base URL before rendering."
|
||||
- "Browsers can only call the API from origins listed in the server's CORS setting. Ask
|
||||
whoever runs the server to add your site's address."
|
||||
And the PageHeader shows breadcrumb "Integration", title "API reference" and description
|
||||
"Everything a developer needs to build on RenewCMS."
|
||||
And the endpoint catalogue is a typed static data file at web/src/data/apiCatalogue.ts, and the view only renders it
|
||||
And the catalogue holds 20 key-callable endpoints in 4 expandable groups, in this order:
|
||||
| group | routes | endpoints |
|
||||
| Articles | 7 | GET /v1/article/, GET /v1/article/{id}, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
|
||||
| Media | 6 | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage |
|
||||
| Users and invites | 6 | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite, DELETE /v1/user/invite/{id}, POST /v1/user/invite/{id}/renew, GET /v1/auth/me |
|
||||
| Settings | 1 | GET /v1/settings |
|
||||
And the "Session only" group has 13 non-expandable rows in 3 sub-groups:
|
||||
| sub-group | rows |
|
||||
| System · owner only | GET /v1/system/update, POST /v1/system/update/check, POST /v1/system/update/download, POST /v1/system/update/restart |
|
||||
| Sign in and account | POST /v1/auth/login, POST /v1/auth/logout, POST /v1/auth/register, POST /v1/auth/verify, DELETE /v1/auth/register, POST /v1/auth/password-reset |
|
||||
| API key management | GET /v1/api-key, POST /v1/api-key, DELETE /v1/api-key/{id} |
|
||||
And a "Public auth flow" sub-list follows with 6 non-expandable rows and the chip "Public":
|
||||
| method | path | description |
|
||||
| GET | /v1/ | Hello world. |
|
||||
| GET | /v1/auth/registration | Whether registration is open. |
|
||||
| POST | /v1/auth/password-reset/confirm | Set a new password with the emailed code. |
|
||||
| GET | /v1/auth/invite | Check an invite code. |
|
||||
| POST | /v1/auth/invite/accept | Accept an invite. |
|
||||
| GET | /healthz | Plain text "ok". Outside /v1. |
|
||||
And every catalogue endpoint has a non-empty description, method, path, access chip, key chip,
|
||||
query-parameter table (or "No query parameters."), request-body table (or "No request body."),
|
||||
fetch, curl and Response examples, and error captions
|
||||
|
||||
Scenario: Default — the page loads with the app's own origin
|
||||
Given the app is served from origin "{origin}"
|
||||
Scenario: Default, every card collapsed
|
||||
When the user navigates to /integration
|
||||
Then the Base URL row shows exactly "{origin}"
|
||||
And the Base URL "Copy" button reads "Copy", not "Copied"
|
||||
And both EndpointCards' fetch examples use "{origin}" as the base of their URL
|
||||
Then the intro reads "RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users without the back-office."
|
||||
And a "Base URL" row shows window.location.origin in a monospace box with a "Copy" button (icon copy)
|
||||
And every EndpointCard is collapsed as a 44 px row: method badge, mono path, one-line description, chips, chevron
|
||||
And the sticky section index lists the sections on desktop and a "Jump to" select lists them on mobile
|
||||
|
||||
Scenario: Copied — the Base URL copy button
|
||||
Given the user is on /integration, served from origin "{origin}"
|
||||
When the user activates the Base URL row's "Copy" button
|
||||
Scenario Outline: Method badge and chips per endpoint
|
||||
Then <method> <path> shows the <method> badge, access chip "<access>" and key chip "API key OK"
|
||||
Examples:
|
||||
| method | path | access |
|
||||
| GET | /v1/article/ | Public |
|
||||
| GET | /v1/article/{id} | Public |
|
||||
| POST | /v1/article/ | Verified user |
|
||||
| DELETE | /v1/article/{id} | Verified user |
|
||||
| GET | /v1/media/ | Verified user |
|
||||
| PATCH | /v1/user/me | Verified user |
|
||||
| GET | /v1/auth/me | Verified user |
|
||||
| POST | /v1/user/invite | Owner only |
|
||||
| DELETE | /v1/user/invite/{id} | Owner only |
|
||||
| POST | /v1/user/invite/{id}/renew | Owner only |
|
||||
| GET | /v1/settings | Verified user |
|
||||
|
||||
Scenario: Owner-only invite cards, user-approved divergence from the board
|
||||
Then the three invite cards show "Owner only", where the board shows "Verified user"
|
||||
And each lists a 403 error "Only the owner can do this."
|
||||
And each says it is rate limited and that too many requests answer 429, with no number
|
||||
|
||||
Scenario: Group scrolled, one card expanded
|
||||
When the user activates the "POST /v1/article/" row
|
||||
Then the row's button reports aria-expanded "true"
|
||||
And a "Query parameters" table lists "format", string, optional, "markdown, html or json. Default markdown."
|
||||
And a "Request body" table lists:
|
||||
| Name | Type | Required | Rule |
|
||||
| title | string | Yes | 3 to 200 characters. Can't be changed later. |
|
||||
| body | string | Yes | Up to 10 000 characters. |
|
||||
And tabs fetch, curl and Response show, fetch active, with a CodeBlock labelled "JavaScript" and a ghost "Copy" button
|
||||
And an "Errors" list shows a status chip with a one-line caption for 400, 401
|
||||
|
||||
Scenario: Query parameters table, new UI beyond the board, user-approved
|
||||
Then the "Query parameters" table sits above the "Request body" table in each card
|
||||
And it lists:
|
||||
| route | name | type | required | rule |
|
||||
| every /v1/article route | format | string | no | markdown, html or json. Default markdown. |
|
||||
| GET /v1/media/ | q | string | no | Search text. |
|
||||
| GET /v1/media/ | hash | string | no | SHA-256 of the content, lowercased. |
|
||||
| GET /v1/media/ | cursor | string | no | The next_cursor of the previous page. |
|
||||
| GET /v1/media/ | filter | string | no | all, unused or duplicates. Default all. |
|
||||
| GET /v1/media/ | sort | string | no | newest, oldest, name or largest. Default newest. |
|
||||
| GET /v1/media/ | limit | number | no | 1 to 100. Default 24. |
|
||||
And a card without query parameters shows "No query parameters."
|
||||
|
||||
Scenario Outline: Request body fields come from the handler validate tags
|
||||
Then <method> <path> lists these fields: <fields>
|
||||
Examples:
|
||||
| method | path | fields |
|
||||
| POST | /v1/article/ | title string required 3-200; body string required max 10000 |
|
||||
| PATCH | /v1/article/{id} | body string required max 10000 |
|
||||
| PATCH | /v1/media/{id} | filename string required |
|
||||
| POST | /v1/media/merge | keep_id number required >0; merge_ids number[] required min 1 each >0 |
|
||||
| POST | /v1/media/ | multipart/form-data, 1 file field named "file" |
|
||||
| PATCH | /v1/user/me | display_name string required max 60 |
|
||||
| POST | /v1/user/invite | email string required valid email |
|
||||
| GET | /v1/article/ | "No request body." |
|
||||
|
||||
Scenario: Response samples use the real domain JSON field names
|
||||
Then the GET /v1/article/{id} Response sample has exactly the keys id, title, body, body_format, is_online, created_at, updated_at
|
||||
And the sample has no "images" key and no "status" key
|
||||
And POST /v1/media/merge has the keys articles_updated and merged
|
||||
And GET /v1/media/ has the keys items, next_cursor and counts
|
||||
And GET /v1/media/{id}/usage items have the keys id, title, published
|
||||
And PATCH /v1/article/{id}/publish and /unpublish samples are articles with is_online true and false
|
||||
|
||||
Scenario: Article body format
|
||||
Then a note reads "Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default."
|
||||
And every article card's Errors list includes 400 "The format must be one of: markdown, html, json."
|
||||
|
||||
Scenario: Authentication section
|
||||
Then card "Public" reads "No key needed. Two read-only routes, and only online articles are returned." and names GET /v1/article/ and GET /v1/article/{id}
|
||||
And card "API key" reads "Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user's role (owner or admin). A key never has more rights than its creator."
|
||||
And its CodeBlock has tabs curl and fetch for GET /v1/auth/me, using the current origin
|
||||
And it says "Keys start with `rcms_` and are shown in full once, when you create them."
|
||||
And a "Manage API keys" link navigates to /integration/api-keys
|
||||
And Note 1 reads "A key can't create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified."
|
||||
And Note 2 reads "Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header."
|
||||
|
||||
Scenario: Session only group
|
||||
Then the banner reads "These routes are made for a signed-in browser session. With an API key, verify, DELETE /auth/register, /api-key/* and /system/* answer 403 "This action needs a real session.""
|
||||
And each row has a dashed border and muted fill, uses no method colour and cannot be expanded
|
||||
And rows that answer 403 to a key show the lock icon and the chip "Session only"
|
||||
And Log in, Log out, Register and Reset password show the caption "Made for the browser. A key is ignored here, with no 403." and no 403 claim
|
||||
And no /v1/system route appears in any expandable group
|
||||
And the "Public auth flow" rows carry the chip "Public" and no lock icon
|
||||
|
||||
Scenario: Errors section
|
||||
Then the intro reads "Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain."
|
||||
And the table lists:
|
||||
| status | message |
|
||||
| 400 | The request cannot be processed due to a mismatch in the format of the body. / The expiry must be a date in the future. |
|
||||
| 401 | You must be logged in to access this resource. |
|
||||
| 403 | This action needs a real session. / Only the owner can do this. / You must verify your account to access this resource. |
|
||||
| 404 | Article not found / Image not found / Invite not found / API key not found |
|
||||
| 409 | There's already a pending invite for this address. / This invite has already been accepted. |
|
||||
| 429 | Too many requests. Please try again later. |
|
||||
| 500 | Internal server error |
|
||||
And a sample shows "HTTP/1.1 403 Forbidden", "Content-Type: text/plain", a blank line, and "This action needs a real session."
|
||||
And the table does not use the SPA copy of docs/design/states.md
|
||||
|
||||
Scenario Outline: Per-endpoint error captions use the real strings
|
||||
Then <method> <path> lists <status> "<caption>"
|
||||
Examples:
|
||||
| method | path | status | caption |
|
||||
| POST | /v1/media/ | 400 | the image could not be read |
|
||||
| POST | /v1/media/ | 413 | the file exceeds the maximum upload size |
|
||||
| POST | /v1/media/ | 415 | the file must be a PNG, JPEG, WEBP, or SVG image |
|
||||
| GET | /v1/media/ | 400 | The media query is invalid. |
|
||||
| POST | /v1/media/merge | 400 | Choose an image to keep and at least one other image to merge into it. |
|
||||
| POST | /v1/media/merge | 409 | Some of the images to merge changed. Refresh and try again. |
|
||||
| PATCH | /v1/media/{id} | 400 | Enter a filename. |
|
||||
| POST | /v1/article/ | 400 | Article title must be between 3 and 200 characters. |
|
||||
| GET | /v1/article/{id} | 400 | The server expects the ID to be in the format of an unsigned 32-bit integer (uint32). |
|
||||
| PATCH | /v1/user/me | 400 | Display name must be 60 characters or fewer. |
|
||||
| POST | /v1/user/invite | 409 | This address already has an account. |
|
||||
| POST | /v1/user/invite | 409 | There's already a pending invite for this address. |
|
||||
| POST | /v1/user/invite/{id}/renew | 409 | This invite has already been accepted. |
|
||||
| POST | /v1/user/invite | 403 | Only the owner can do this. |
|
||||
|
||||
Scenario: Notes
|
||||
Then the image-prefix note reads "Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering."
|
||||
And the CORS note reads "Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address."
|
||||
|
||||
Scenario: Copied, Base URL
|
||||
Given the app is served from origin "{origin}"
|
||||
When the user activates the Base URL "Copy" button
|
||||
Then "{origin}" is written to the clipboard
|
||||
And the button's icon changes to check and its text changes to "Copied"
|
||||
And the change is announced to assistive technology
|
||||
When 2 seconds elapse without a further click
|
||||
Then the button reverts to icon copy and text "Copy"
|
||||
And the button shows icon check and "Copied", announced to assistive technology
|
||||
When 2 seconds elapse
|
||||
Then the button reverts to icon copy and "Copy"
|
||||
|
||||
Scenario: Each EndpointCard's own Copy button is independent of the Base URL's
|
||||
Given the user is on /integration
|
||||
When the user activates the "/v1/article/" EndpointCard's "JavaScript" code block's "Copy"
|
||||
button
|
||||
Then that card's own code is written to the clipboard, and only that button reads "Copied"
|
||||
And the Base URL row's "Copy" button and the other EndpointCard's "Copy" button are unaffected
|
||||
Scenario: Copied, CodeBlock is independent of Base URL
|
||||
Given a card is expanded
|
||||
When the user activates that CodeBlock's "Copy JavaScript example" button
|
||||
Then that card's exact code string is written to the clipboard and only that button reads "Copied"
|
||||
And the Base URL button and other CodeBlocks are unaffected
|
||||
|
||||
Scenario: Examples use the current origin and the API key header
|
||||
Then every fetch and curl example starts from "{origin}" and carries no ?format= query
|
||||
And examples for non-public routes carry "Authorization: Bearer rcms_…"
|
||||
And examples for public routes carry no Authorization header
|
||||
|
||||
Scenario: Navigation
|
||||
When the user activates a section in the index (desktop)
|
||||
Then the page scrolls to that anchor and the item reports aria-current "true"
|
||||
And on mobile the "Jump to" select opens and lists the same sections
|
||||
|
||||
Scenario: The page stays static
|
||||
Then no /v1 request is made while the page renders or while any card is expanded
|
||||
+86
-54
@@ -1,66 +1,98 @@
|
||||
# QA: Integration screen
|
||||
# QA: Integration screen (API reference)
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at
|
||||
/integration. Stub `window.location.origin` (or the equivalent composable/helper the coder uses to
|
||||
read it) to a fixed value for the test, and stub `navigator.clipboard.writeText` per case. No
|
||||
`/v1` mocks are needed; the screen makes no calls.
|
||||
/integration. Stub `window.location.origin` to `https://cms.renew.example` (the board's sample) and
|
||||
`navigator.clipboard.writeText` per case. No `/v1` mocks are needed. A spec fails if any `/v1` call
|
||||
is made.
|
||||
|
||||
1. **Default** — mount at /integration with origin stubbed to a fixed value (e.g.
|
||||
`https://cms.renew.example`, matching the design board's sample so the assertions read the same
|
||||
as the visual reference); assert breadcrumb "Integration"; assert the PageHeader title
|
||||
"Connect your website" and description "A guide for the developer who builds your public
|
||||
site."; assert the intro paragraph text; assert the Base URL row shows exactly the stubbed
|
||||
origin; assert the Base URL "Copy" button reads "Copy"; assert both EndpointCards render in
|
||||
order (GET /v1/article/, GET /v1/article/{id}) with their descriptions and their fetch examples
|
||||
containing the stubbed origin; assert the fetch tab is active by default on both cards; assert
|
||||
the three info notes render in order.
|
||||
2. **Copied** — from Default, use fake timers; click the Base URL row's "Copy" button; assert
|
||||
`navigator.clipboard.writeText` was called with the stubbed origin; assert the button's icon is
|
||||
check and its text is "Copied"; assert the change is exposed to assistive technology (e.g. an
|
||||
aria-live region or a status role holds "Copied"); advance the fake timer by 2 s; assert the
|
||||
button reverts to icon copy and text "Copy".
|
||||
3. **EndpointCard's own Copy button is independent** — from Default, click the "/v1/article/"
|
||||
card's "JavaScript" CodeBlock's "Copy" button; assert `navigator.clipboard.writeText` was called
|
||||
with that card's exact code string; assert only that button reads "Copied"; assert the Base URL
|
||||
button still reads "Copy" and the other card's Copy button still reads "Copy". This behavior is
|
||||
Vitest-only; it has no visual-check state of its own (see Visual below).
|
||||
4. **curl and Response tabs render, unchecked visually** — from Default, switch the "/v1/article/"
|
||||
card to its curl tab; assert its content is a plain curl command built from the stubbed origin
|
||||
(e.g. "curl https://cms.renew.example/v1/article/"); switch to its Response tab; assert its
|
||||
content is a JSON sample built from the real domain Article fields. Neither tab's content is
|
||||
asserted pixel-for-pixel anywhere, since the design board only ever shows the fetch tab active.
|
||||
Catalogue spec (pure data, no mount, imports web/src/data/apiCatalogue.ts):
|
||||
1. The catalogue holds exactly the 20 expected key-callable routes from an explicit hand-maintained
|
||||
list in the spec (Articles 7, Media 6, Users and invites 6, Settings 1), each once, with no other
|
||||
expandable route. Session only has the 13 rows and Public auth flow the 6 rows listed in the
|
||||
feature, from the same kind of explicit list.
|
||||
2. Every catalogue route has a non-empty `description`. An empty or missing one fails the spec and
|
||||
names the route.
|
||||
3. Every route has method, path, access chip, key chip, a query-parameter list and a body-field list
|
||||
(possibly empty), and fetch, curl and Response examples.
|
||||
4. The Response sample of each route parses as JSON and uses only keys from a fixture of the real
|
||||
fields:
|
||||
- article: id, title, body, body_format, is_online, created_at, updated_at;
|
||||
- media item: id, filename, url, size, content_type, width, height, sha256, uploaded_by,
|
||||
created_at, used_in, duplicate;
|
||||
- media list: items, next_cursor, counts (all, unused, duplicates);
|
||||
- merge: articles_updated, merged;
|
||||
- usage: id, title, published;
|
||||
- user list item: id, kind, username, display_name, email, role, status, created_at;
|
||||
- invite: id, kind, email, status, created_at, expires_at;
|
||||
- settings: version.
|
||||
Any other key fails the spec. "images" and "status" are explicitly rejected on articles.
|
||||
5. Request-body tables equal the validate tags (title 3-200, body max 10000, filename, keep_id,
|
||||
merge_ids, display_name, email). The multipart field is "file". Query tables: `format` on every
|
||||
article route, and q, hash, cursor, filter, sort, limit on GET /v1/media/.
|
||||
6. The three invite routes carry "Owner only", a 403 "Only the owner can do this." caption and the
|
||||
rate-limit sentence. No /v1/system route is in an expandable group. Login, Log out, Register and
|
||||
Reset password rows carry no 403 claim. Verify, DELETE /auth/register, /api-key/* and
|
||||
/system/* are the only rows that claim 403.
|
||||
7. Error captions equal fixtures copied from api/pkg/apperr/messages.go and the image domain
|
||||
errors (413, 415, 400 on upload).
|
||||
|
||||
Component specs, one per design state:
|
||||
1. **Default**: breadcrumb, title "API reference", description, intro, Base URL equals the stubbed
|
||||
origin, all cards collapsed (aria-expanded "false"), section index entries present, the three
|
||||
invite rows show "Owner only".
|
||||
2. **Group scrolled, one card expanded**: click the POST /v1/article/ row. Assert the Query
|
||||
parameters table above the Request body table, the body rows, tabs fetch/curl/Response with fetch
|
||||
active, the "JavaScript" CodeBlock, and the Errors list with 400 and 401.
|
||||
3. **Copied: base URL**: fake timers; click Copy. Clipboard gets the origin; check icon and "Copied";
|
||||
announced; reverts after 2 s.
|
||||
4. **Copied: CodeBlock**: expand a card; click "Copy JavaScript example". Clipboard gets that code,
|
||||
only that button reads "Copied", and it reverts after 2 s.
|
||||
5. **Authentication**: both cards, curl/fetch tabs for GET /v1/auth/me, the "Manage API keys" link
|
||||
href /integration/api-keys, Note 1, Note 2.
|
||||
6. **Session only**: the corrected banner; lock icon and "Session only" chip only on rows that
|
||||
answer 403; the caption on the login family; no expand control; the "Public auth flow" rows with
|
||||
the "Public" chip.
|
||||
7. **Errors**: table rows 400, 401, 403, 404, 409, 429, 500 with the real strings and the 403
|
||||
sample block.
|
||||
8. **Navigation**: clicking an index item sets aria-current and scrolls to its anchor; the mobile
|
||||
"Jump to" lists the same sections.
|
||||
9. **Tabs**: curl and Response render for a card. curl contains the stubbed origin and the
|
||||
Authorization header only on non-public routes. Not visually checked.
|
||||
10. **Notes**: format note, image-prefix note, CORS note.
|
||||
|
||||
## Visual (`task visual:check -- integration`, desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/screens/integration.md` and
|
||||
`docs/design/source/RenewCMS Screens Back-office.dc.html`, group "Integration"; layout detail from
|
||||
`docs/design/source/AppFrame.dc.html`'s `isInteg` block.
|
||||
Reference: docs/design/screens/integration.md; board "Integration" drawn by
|
||||
docs/design/source/IntegrationPage.dc.html. Serve the SPA under `https://cms.renew.example`. Mocks
|
||||
reuse the board's script data: BASE, KEY, group and endpoint descriptions, the request bodies
|
||||
(for example the "Choosing tiles for a small bathroom" article). Response samples are not compared;
|
||||
the Response tab is never active in a board frame.
|
||||
|
||||
The visual-check setup for this screen serves the SPA under `https://cms.renew.example` via
|
||||
Playwright request routing, so the real `window.location.origin` the running app reads matches the
|
||||
board's sample text exactly; no production test hook or origin stub is needed in app code.
|
||||
Pre-approved exceptions: the user pre-approved visual exceptions for Session only, Group scrolled
|
||||
one card expanded, Errors and Default. For each of these four states the coder measures the real
|
||||
diff, sets the smallest `maxPercent` that is sufficient, writes the reason, and records
|
||||
`approvedBy` "Florian Sylvain". The other states must still pass 10 %.
|
||||
|
||||
- [ ] Default state: PageHeader ("Connect your website" / description), intro paragraph, Base URL
|
||||
row with monospace text and a "Copy" button (icon copy), two EndpointCards in order with
|
||||
their GET badge, path, description, fetch/curl/Response tabs (fetch active), JavaScript
|
||||
CodeBlock and its own "Copy" button, and the three info notes below — matches the board
|
||||
exactly at desktop and mobile, light and dark. Sample data for the mocked run: origin
|
||||
`https://cms.renew.example`, served as above.
|
||||
- [ ] Copied state: the Base URL button shows the check icon and "Copied" text, in the success
|
||||
color, matching the board's `copied: true` frame. This state is timer-driven, not
|
||||
date-driven, so it is not clock-sensitive and gets a real visual check, not a skip.
|
||||
- [ ] Keyboard/focus (manual, not a screenshot check): Tab order reaches the Base URL "Copy"
|
||||
button, then each EndpointCard's tab strip (fetch/curl/Response, reachable and operable with
|
||||
arrow keys per UTabs), then that card's own CodeBlock "Copy" button, then the pre's
|
||||
`tabindex="0"` for scrolling; Enter/Space activates each Copy button.
|
||||
- [ ] Hover (manual): ghost Copy buttons show a hover state consistent with other ghost buttons
|
||||
elsewhere in the app (UserMenu, ArticleTable row menu).
|
||||
- [ ] Default (exception: owner-only chips and rate-limit line)
|
||||
- [ ] Group scrolled, one card expanded (exception: Query parameters table)
|
||||
- [ ] Copied: base URL (must pass 10 %)
|
||||
- [ ] Copied: CodeBlock (must pass 10 %)
|
||||
- [ ] Authentication (must pass 10 %; Note 1 text changed)
|
||||
- [ ] Session only (exception: banner, 13th row, captions, Public auth flow block)
|
||||
- [ ] Errors (exception: 409 row)
|
||||
- [ ] Navigation (must pass 10 %)
|
||||
|
||||
## Manual verification
|
||||
None of these states is clock-sensitive, so none is skipped.
|
||||
|
||||
- Confirm the real running app's `window.location.origin` (e.g. `https://your-domain.example` in
|
||||
production, or `http://localhost:8080` under `task dev`) renders correctly in the Base URL row
|
||||
and in both fetch examples, since Vitest only ever exercises a stubbed origin, and
|
||||
`visual:check` only ever exercises the routed `https://cms.renew.example` origin.
|
||||
## Manual
|
||||
- Keyboard and focus order: index, Base URL Copy, each row (Enter/Space), tab strip (arrow keys),
|
||||
CodeBlock Copy, the pre (tabindex 0), Jump to on mobile.
|
||||
- Hover and motion on ghost Copy buttons and rows.
|
||||
- Find-in-page and a deep link to an `#ep-...` anchor.
|
||||
- Real origin in a production build and under `task dev`.
|
||||
- Run each documented example against a running server with a real `rcms_` key, including the
|
||||
403 on a session-only route and the owner-only 403 for an admin key on the invite routes. This
|
||||
needs a real server and is not automated.
|
||||
- docs/api.md matches the catalogue (hand check).
|
||||
@@ -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}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,42 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrAPIKeyNotFound = errors.New("api key not found")
|
||||
var ErrAPIKeyInvalid = errors.New("api key invalid")
|
||||
var ErrExpiryNotInFuture = errors.New("api key expiry not in the future")
|
||||
|
||||
const RawKeyPrefix = "rcms_"
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusActive Status = "active"
|
||||
StatusExpired Status = "expired"
|
||||
StatusRevoked Status = "revoked"
|
||||
)
|
||||
|
||||
type APIKey struct {
|
||||
ID uint32 `json:"id"`
|
||||
UserID uint32 `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
HashedKey string `json:"hashed_key"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (k APIKey) StatusAt(now time.Time) Status {
|
||||
if k.RevokedAt != nil {
|
||||
return StatusRevoked
|
||||
}
|
||||
if k.ExpiresAt != nil && !now.Before(*k.ExpiresAt) {
|
||||
return StatusExpired
|
||||
}
|
||||
return StatusActive
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package apikey
|
||||
|
||||
type KeyGenerator interface {
|
||||
Generate() (rawKey string, prefix string, hashedKey string)
|
||||
Hash(rawKey string) string
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package apikey
|
||||
|
||||
import "time"
|
||||
|
||||
type Repository interface {
|
||||
Create(key APIKey) (APIKey, error)
|
||||
Get(id uint32) (APIKey, error)
|
||||
GetByHashedKey(hashedKey string) (APIKey, error)
|
||||
ListByUser(userId uint32) ([]APIKey, error)
|
||||
ListAll() ([]APIKey, error)
|
||||
Revoke(id uint32, at time.Time) error
|
||||
RevokeAllByUser(userId uint32, at time.Time) error
|
||||
TouchLastUsed(id uint32, at time.Time) error
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -33,6 +33,7 @@ type Settings struct {
|
||||
TrustedProxies []string
|
||||
Version string
|
||||
UpdateFeedURL string
|
||||
Container bool
|
||||
}
|
||||
|
||||
func (s Settings) IsProduction() bool {
|
||||
@@ -76,6 +77,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,34 @@
|
||||
package hashing
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
const (
|
||||
apiKeyRandomBytes = 32
|
||||
apiKeyPrefixLen = 12
|
||||
)
|
||||
|
||||
type SHA256KeyGenerator struct{}
|
||||
|
||||
func NewSHA256KeyGenerator() *SHA256KeyGenerator {
|
||||
return &SHA256KeyGenerator{}
|
||||
}
|
||||
|
||||
func (g *SHA256KeyGenerator) Generate() (string, string, string) {
|
||||
random := make([]byte, apiKeyRandomBytes)
|
||||
_, _ = rand.Read(random)
|
||||
rawKey := apikey.RawKeyPrefix + base64.RawURLEncoding.EncodeToString(random)
|
||||
return rawKey, rawKey[:apiKeyPrefixLen], g.Hash(rawKey)
|
||||
}
|
||||
|
||||
func (g *SHA256KeyGenerator) Hash(rawKey string) string {
|
||||
sum := sha256.Sum256([]byte(rawKey))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
var _ apikey.KeyGenerator = &SHA256KeyGenerator{}
|
||||
@@ -0,0 +1,34 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
)
|
||||
|
||||
func APIKeyToDomain(m models.APIKey) apikey.APIKey {
|
||||
return apikey.APIKey{
|
||||
ID: m.ID,
|
||||
UserID: m.UserID,
|
||||
Name: m.Name,
|
||||
Prefix: m.Prefix,
|
||||
HashedKey: m.HashedKey,
|
||||
ExpiresAt: m.ExpiresAt,
|
||||
RevokedAt: m.RevokedAt,
|
||||
LastUsedAt: m.LastUsedAt,
|
||||
CreatedAt: m.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func APIKeyToModel(k apikey.APIKey) models.APIKey {
|
||||
return models.APIKey{
|
||||
ID: k.ID,
|
||||
UserID: k.UserID,
|
||||
Name: k.Name,
|
||||
Prefix: k.Prefix,
|
||||
HashedKey: k.HashedKey,
|
||||
ExpiresAt: k.ExpiresAt,
|
||||
RevokedAt: k.RevokedAt,
|
||||
LastUsedAt: k.LastUsedAt,
|
||||
CreatedAt: k.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type APIKey struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
UserID uint32 `gorm:"index;not null"`
|
||||
Name string `gorm:"not null"`
|
||||
Prefix string `gorm:"not null"`
|
||||
HashedKey string `gorm:"uniqueIndex;not null"`
|
||||
ExpiresAt *time.Time
|
||||
RevokedAt *time.Time
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domainAPIKey "RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type APIKeyRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAPIKeyRepository(db *gorm.DB) *APIKeyRepository {
|
||||
return &APIKeyRepository{db}
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) Create(key domainAPIKey.APIKey) (domainAPIKey.APIKey, error) {
|
||||
model := mappers.APIKeyToModel(key)
|
||||
model.ID = 0
|
||||
if err := a.db.Create(&model).Error; err != nil {
|
||||
return domainAPIKey.APIKey{}, err
|
||||
}
|
||||
return mappers.APIKeyToDomain(model), nil
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) Get(id uint32) (domainAPIKey.APIKey, error) {
|
||||
var model entity.APIKey
|
||||
if err := a.db.First(&model, id).Error; err != nil {
|
||||
return domainAPIKey.APIKey{}, mapNotFoundErr(err, domainAPIKey.ErrAPIKeyNotFound)
|
||||
}
|
||||
return mappers.APIKeyToDomain(model), nil
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) GetByHashedKey(hashedKey string) (domainAPIKey.APIKey, error) {
|
||||
var model entity.APIKey
|
||||
if err := a.db.Where("hashed_key = ?", hashedKey).First(&model).Error; err != nil {
|
||||
return domainAPIKey.APIKey{}, mapNotFoundErr(err, domainAPIKey.ErrAPIKeyNotFound)
|
||||
}
|
||||
return mappers.APIKeyToDomain(model), nil
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) ListByUser(userId uint32) ([]domainAPIKey.APIKey, error) {
|
||||
return a.list(a.db.Where("user_id = ?", userId))
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) ListAll() ([]domainAPIKey.APIKey, error) {
|
||||
return a.list(a.db)
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) list(query *gorm.DB) ([]domainAPIKey.APIKey, error) {
|
||||
var found []entity.APIKey
|
||||
if err := query.Order("created_at DESC, id DESC").Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys := make([]domainAPIKey.APIKey, 0, len(found))
|
||||
for _, model := range found {
|
||||
keys = append(keys, mappers.APIKeyToDomain(model))
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) Revoke(id uint32, at time.Time) error {
|
||||
result := a.db.Model(&entity.APIKey{}).
|
||||
Where("id = ? AND revoked_at IS NULL", id).
|
||||
Update("revoked_at", at)
|
||||
return result.Error
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) RevokeAllByUser(userId uint32, at time.Time) error {
|
||||
return a.db.Model(&entity.APIKey{}).
|
||||
Where("user_id = ? AND revoked_at IS NULL", userId).
|
||||
Update("revoked_at", at).Error
|
||||
}
|
||||
|
||||
func (a *APIKeyRepository) TouchLastUsed(id uint32, at time.Time) error {
|
||||
return a.db.Model(&entity.APIKey{}).
|
||||
Where("id = ?", id).
|
||||
Update("last_used_at", at).Error
|
||||
}
|
||||
|
||||
var _ domainAPIKey.Repository = &APIKeyRepository{}
|
||||
@@ -0,0 +1,57 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const purgeSettingsRowID uint32 = 1
|
||||
|
||||
type PurgeSettingsRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
var _ domainPurge.Repository = &PurgeSettingsRepository{}
|
||||
|
||||
func NewPurgeSettingsRepository(db *gorm.DB) *PurgeSettingsRepository {
|
||||
return &PurgeSettingsRepository{db}
|
||||
}
|
||||
|
||||
func (p *PurgeSettingsRepository) Get() (domainPurge.Settings, error) {
|
||||
var model entity.PurgeSettings
|
||||
err := p.db.First(&model, purgeSettingsRowID).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return domainPurge.DefaultSettings(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return domainPurge.Settings{}, err
|
||||
}
|
||||
return mappers.PurgeSettingsToDomain(model), nil
|
||||
}
|
||||
|
||||
func (p *PurgeSettingsRepository) Save(settings domainPurge.Settings) error {
|
||||
model := mappers.PurgeSettingsToModel(settings)
|
||||
model.ID = purgeSettingsRowID
|
||||
return p.db.Save(&model).Error
|
||||
}
|
||||
|
||||
func (p *PurgeSettingsRepository) SaveLastRun(at time.Time, merged int, articlesUpdated int) error {
|
||||
result := p.db.Model(&entity.PurgeSettings{}).Where("id = ?", purgeSettingsRowID).Updates(map[string]any{
|
||||
"last_purge_at": at,
|
||||
"last_purge_merged": merged,
|
||||
"last_purge_articles_updated": articlesUpdated,
|
||||
})
|
||||
if result.Error != nil || result.RowsAffected > 0 {
|
||||
return result.Error
|
||||
}
|
||||
settings := domainPurge.DefaultSettings()
|
||||
settings.LastPurgeAt = &at
|
||||
settings.LastPurgeMerged = merged
|
||||
settings.LastPurgeArticlesUpdated = articlesUpdated
|
||||
return p.Save(settings)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TickInterval = time.Hour
|
||||
StartupDelay = time.Minute
|
||||
)
|
||||
|
||||
type Job interface {
|
||||
Tick(now time.Time) (purge.RunResult, bool, error)
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
job Job
|
||||
now func() time.Time
|
||||
ticks <-chan time.Time
|
||||
startup <-chan time.Time
|
||||
logger *log.Logger
|
||||
release func()
|
||||
}
|
||||
|
||||
func NewRunner(job Job, now func() time.Time, ticks <-chan time.Time, startup <-chan time.Time, logger *log.Logger) *Runner {
|
||||
return &Runner{job: job, now: now, ticks: ticks, startup: startup, logger: logger, release: func() {}}
|
||||
}
|
||||
|
||||
func NewSystemRunner(job Job, logger *log.Logger) *Runner {
|
||||
ticker := time.NewTicker(TickInterval)
|
||||
startup := time.NewTimer(StartupDelay)
|
||||
runner := NewRunner(job, time.Now, ticker.C, startup.C, logger)
|
||||
runner.release = func() {
|
||||
ticker.Stop()
|
||||
startup.Stop()
|
||||
}
|
||||
return runner
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context) {
|
||||
defer r.release()
|
||||
startup := r.startup
|
||||
for ctx.Err() == nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-startup:
|
||||
startup = nil
|
||||
r.tick(ctx)
|
||||
case <-r.ticks:
|
||||
r.tick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) tick(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
result, ran, err := r.job.Tick(r.now())
|
||||
if errors.Is(err, purge.ErrPurgeBusy) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
r.logger.Printf("scheduler: automatic purge failed: %v", err)
|
||||
return
|
||||
}
|
||||
if ran {
|
||||
r.logger.Printf("scheduler: automatic purge completed: groups_purged=%d merged=%d articles_updated=%d failed_groups=%d", result.GroupsPurged, result.Merged, result.ArticlesUpdated, result.FailedGroups)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package update
|
||||
|
||||
import domain "RenewCMS/internal/domain/update"
|
||||
|
||||
type containerHelperDetector struct {
|
||||
inner domain.HelperDetector
|
||||
container bool
|
||||
}
|
||||
|
||||
func NewContainerHelperDetector(inner domain.HelperDetector, container bool) domain.HelperDetector {
|
||||
return containerHelperDetector{inner: inner, container: container}
|
||||
}
|
||||
|
||||
func (d containerHelperDetector) Installed() bool {
|
||||
return !d.container && d.inner.Installed()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const lastUsedWriteInterval = time.Minute
|
||||
|
||||
type AuthenticateAPIKeyUseCase struct {
|
||||
apiKeyRepository apikey.Repository
|
||||
keyGenerator apikey.KeyGenerator
|
||||
userRepository user.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type AuthenticatedAPIKey struct {
|
||||
Key apikey.APIKey
|
||||
User user.User
|
||||
}
|
||||
|
||||
func NewAuthenticateAPIKeyUseCase(apiKeyRepository apikey.Repository, keyGenerator apikey.KeyGenerator, userRepository user.Repository, now func() time.Time) *AuthenticateAPIKeyUseCase {
|
||||
return &AuthenticateAPIKeyUseCase{apiKeyRepository, keyGenerator, userRepository, now}
|
||||
}
|
||||
|
||||
func (a *AuthenticateAPIKeyUseCase) AuthenticateAPIKey(rawKey string) (AuthenticatedAPIKey, error) {
|
||||
if !strings.HasPrefix(rawKey, apikey.RawKeyPrefix) {
|
||||
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||
}
|
||||
|
||||
key, err := a.apiKeyRepository.GetByHashedKey(a.keyGenerator.Hash(rawKey))
|
||||
if err != nil {
|
||||
if errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||
}
|
||||
return AuthenticatedAPIKey{}, err
|
||||
}
|
||||
|
||||
now := a.now()
|
||||
if key.StatusAt(now) != apikey.StatusActive {
|
||||
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||
}
|
||||
|
||||
owner, err := a.userRepository.Get(key.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||
}
|
||||
return AuthenticatedAPIKey{}, err
|
||||
}
|
||||
if !owner.IsVerified {
|
||||
return AuthenticatedAPIKey{}, apikey.ErrAPIKeyInvalid
|
||||
}
|
||||
|
||||
a.recordUse(key, now)
|
||||
return AuthenticatedAPIKey{Key: key, User: owner}, nil
|
||||
}
|
||||
|
||||
func (a *AuthenticateAPIKeyUseCase) recordUse(key apikey.APIKey, now time.Time) {
|
||||
if key.LastUsedAt != nil && now.Sub(*key.LastUsedAt) < lastUsedWriteInterval {
|
||||
return
|
||||
}
|
||||
_ = a.apiKeyRepository.TouchLastUsed(key.ID, now)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateAPIKeyUseCase struct {
|
||||
apiKeyRepository apikey.Repository
|
||||
keyGenerator apikey.KeyGenerator
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type CreateAPIKeyCommand struct {
|
||||
UserID uint32
|
||||
Name string
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
type CreatedAPIKey struct {
|
||||
Key apikey.APIKey
|
||||
RawKey string
|
||||
}
|
||||
|
||||
func NewCreateAPIKeyUseCase(apiKeyRepository apikey.Repository, keyGenerator apikey.KeyGenerator, now func() time.Time) *CreateAPIKeyUseCase {
|
||||
return &CreateAPIKeyUseCase{apiKeyRepository, keyGenerator, now}
|
||||
}
|
||||
|
||||
func (c *CreateAPIKeyUseCase) CreateAPIKey(command CreateAPIKeyCommand) (CreatedAPIKey, error) {
|
||||
now := c.now()
|
||||
if command.ExpiresAt != nil && !command.ExpiresAt.After(now) {
|
||||
return CreatedAPIKey{}, apikey.ErrExpiryNotInFuture
|
||||
}
|
||||
|
||||
rawKey, prefix, hashedKey := c.keyGenerator.Generate()
|
||||
stored, err := c.apiKeyRepository.Create(apikey.APIKey{
|
||||
UserID: command.UserID,
|
||||
Name: command.Name,
|
||||
Prefix: prefix,
|
||||
HashedKey: hashedKey,
|
||||
ExpiresAt: command.ExpiresAt,
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return CreatedAPIKey{}, err
|
||||
}
|
||||
|
||||
return CreatedAPIKey{Key: stored, RawKey: rawKey}, nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package useCases
|
||||
|
||||
import "RenewCMS/internal/domain/purge"
|
||||
|
||||
type GetPurgeSettingsUseCase struct {
|
||||
repository purge.Repository
|
||||
}
|
||||
|
||||
func NewGetPurgeSettingsUseCase(repository purge.Repository) *GetPurgeSettingsUseCase {
|
||||
return &GetPurgeSettingsUseCase{repository}
|
||||
}
|
||||
|
||||
func (g *GetPurgeSettingsUseCase) Get() (purge.Settings, error) {
|
||||
return g.repository.Get()
|
||||
}
|
||||
@@ -6,17 +6,21 @@ import (
|
||||
)
|
||||
|
||||
type GetUpdateStatusUseCase struct {
|
||||
status updateStatusBuilder
|
||||
helper update.HelperDetector
|
||||
status updateStatusBuilder
|
||||
helper update.HelperDetector
|
||||
container bool
|
||||
}
|
||||
|
||||
func NewGetUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, results update.ResultStore, requests update.RestartRequestStore, currentVersion string, now func() time.Time) *GetUpdateStatusUseCase {
|
||||
func NewGetUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, results update.ResultStore, requests update.RestartRequestStore, currentVersion string, container bool, now func() time.Time) *GetUpdateStatusUseCase {
|
||||
return &GetUpdateStatusUseCase{
|
||||
status: updateStatusBuilder{store: store, jobs: jobs, results: results, requests: requests, currentVersion: currentVersion, now: now},
|
||||
helper: helper,
|
||||
status: updateStatusBuilder{store: store, jobs: jobs, results: results, requests: requests, currentVersion: currentVersion, now: now},
|
||||
helper: helper,
|
||||
container: container,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GetUpdateStatusUseCase) GetStatus(isOwner bool) update.UpdateStatus {
|
||||
return g.status.build(isOwner && g.helper.Installed())
|
||||
status := g.status.build(isOwner && g.helper.Installed())
|
||||
status.Container = g.container
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ListAPIKeysUseCase struct {
|
||||
apiKeyRepository apikey.Repository
|
||||
userRepository user.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type APIKeyEntry struct {
|
||||
Key apikey.APIKey
|
||||
Status apikey.Status
|
||||
CreatorName string
|
||||
ListsCreator bool
|
||||
}
|
||||
|
||||
func NewListAPIKeysUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time) *ListAPIKeysUseCase {
|
||||
return &ListAPIKeysUseCase{apiKeyRepository, userRepository, now}
|
||||
}
|
||||
|
||||
func (l *ListAPIKeysUseCase) ListAPIKeys(requesterId uint32) ([]APIKeyEntry, error) {
|
||||
requester, err := l.userRepository.Get(requesterId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys, err := l.keysVisibleTo(requester)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.SliceStable(keys, func(i, j int) bool {
|
||||
if keys[i].CreatedAt.Equal(keys[j].CreatedAt) {
|
||||
return keys[i].ID > keys[j].ID
|
||||
}
|
||||
return keys[i].CreatedAt.After(keys[j].CreatedAt)
|
||||
})
|
||||
|
||||
creatorNames, err := l.creatorNamesFor(requester, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := l.now()
|
||||
entries := make([]APIKeyEntry, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
entries = append(entries, APIKeyEntry{Key: key, Status: key.StatusAt(now), CreatorName: creatorNames[key.UserID], ListsCreator: requester.Role == user.RoleOwner})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (l *ListAPIKeysUseCase) creatorNamesFor(requester user.User, keys []apikey.APIKey) (map[uint32]string, error) {
|
||||
names := map[uint32]string{}
|
||||
if requester.Role != user.RoleOwner {
|
||||
return names, nil
|
||||
}
|
||||
names[requester.ID] = displayNameOrUsername(requester)
|
||||
for _, key := range keys {
|
||||
if _, resolved := names[key.UserID]; resolved {
|
||||
continue
|
||||
}
|
||||
creator, err := l.userRepository.Get(key.UserID)
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
names[key.UserID] = ""
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names[key.UserID] = displayNameOrUsername(creator)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func displayNameOrUsername(account user.User) string {
|
||||
if trimmed := strings.TrimSpace(account.DisplayName); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return account.Username
|
||||
}
|
||||
|
||||
func (l *ListAPIKeysUseCase) keysVisibleTo(requester user.User) ([]apikey.APIKey, error) {
|
||||
if requester.Role == user.RoleOwner {
|
||||
return l.apiKeyRepository.ListAll()
|
||||
}
|
||||
return l.apiKeyRepository.ListByUser(requester.ID)
|
||||
}
|
||||
@@ -10,8 +10,9 @@ import (
|
||||
)
|
||||
|
||||
type MergeMediaResult struct {
|
||||
ArticlesUpdated int
|
||||
Merged int
|
||||
ArticlesUpdated int
|
||||
Merged int
|
||||
UpdatedArticleIDs []uint32
|
||||
}
|
||||
|
||||
type MergeMediaUseCase struct {
|
||||
@@ -57,7 +58,15 @@ func (u *MergeMediaUseCase) Merge(keepId uint32, mergeIds []uint32) (MergeMediaR
|
||||
}
|
||||
}
|
||||
|
||||
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies)}, nil
|
||||
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
|
||||
}
|
||||
|
||||
func rewrittenIds(rewrites []bodyRewrite) []uint32 {
|
||||
ids := make([]uint32, 0, len(rewrites))
|
||||
for _, plan := range rewrites {
|
||||
ids = append(ids, plan.articleId)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func validateMergeRequest(keepId uint32, mergeIds []uint32) error {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
)
|
||||
|
||||
const purgePreviewListLimit = 5
|
||||
|
||||
type PurgePreviewGroup struct {
|
||||
KeepID uint32
|
||||
Filename string
|
||||
URL string
|
||||
UsedIn int
|
||||
Merged int
|
||||
}
|
||||
|
||||
type PurgePreview struct {
|
||||
Groups int
|
||||
Copies int
|
||||
Articles int
|
||||
List []PurgePreviewGroup
|
||||
More int
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
type PreviewPurgeUseCase struct {
|
||||
planner *purgePlanner
|
||||
}
|
||||
|
||||
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository) *PreviewPurgeUseCase {
|
||||
return &PreviewPurgeUseCase{&purgePlanner{imageRepository, articleRepository}}
|
||||
}
|
||||
|
||||
func (u *PreviewPurgeUseCase) Preview() (PurgePreview, error) {
|
||||
plan, err := u.planner.plan()
|
||||
if err != nil {
|
||||
return PurgePreview{}, err
|
||||
}
|
||||
|
||||
shown := min(len(plan.groups), purgePreviewListLimit)
|
||||
list := make([]PurgePreviewGroup, 0, shown)
|
||||
for _, group := range plan.groups[:shown] {
|
||||
list = append(list, PurgePreviewGroup{
|
||||
KeepID: group.kept.ID,
|
||||
Filename: group.kept.Filename,
|
||||
URL: group.kept.Path,
|
||||
UsedIn: group.keptUsage,
|
||||
Merged: len(group.mergedIds),
|
||||
})
|
||||
}
|
||||
|
||||
return PurgePreview{
|
||||
Groups: len(plan.groups),
|
||||
Copies: plan.copies,
|
||||
Articles: plan.articles,
|
||||
List: list,
|
||||
More: len(plan.groups) - shown,
|
||||
Fingerprint: plan.fingerprint,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PurgeRunner interface {
|
||||
Run(fingerprint *string) (purge.RunResult, error)
|
||||
}
|
||||
|
||||
type PurgeSchedulerUseCase struct {
|
||||
repository purge.Repository
|
||||
runner PurgeRunner
|
||||
}
|
||||
|
||||
func NewPurgeSchedulerUseCase(repository purge.Repository, runner PurgeRunner) *PurgeSchedulerUseCase {
|
||||
return &PurgeSchedulerUseCase{repository, runner}
|
||||
}
|
||||
|
||||
func (u *PurgeSchedulerUseCase) Tick(now time.Time) (purge.RunResult, bool, error) {
|
||||
settings, err := u.repository.Get()
|
||||
if err != nil {
|
||||
return purge.RunResult{}, false, err
|
||||
}
|
||||
next := settings.NextPurgeAt()
|
||||
if next == nil || now.Before(*next) {
|
||||
return purge.RunResult{}, false, nil
|
||||
}
|
||||
result, err := u.runner.Run(nil)
|
||||
if err != nil {
|
||||
return purge.RunResult{}, false, err
|
||||
}
|
||||
return result, true, nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"time"
|
||||
@@ -9,10 +10,12 @@ import (
|
||||
type ResetPasswordUseCase struct {
|
||||
userRepository user.Repository
|
||||
hasher hash.Hasher
|
||||
apiKeys apikey.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewResetPasswordUseCase(userRepository user.Repository, hasher hash.Hasher) *ResetPasswordUseCase {
|
||||
return &ResetPasswordUseCase{userRepository, hasher}
|
||||
func NewResetPasswordUseCase(userRepository user.Repository, hasher hash.Hasher, apiKeys apikey.Repository, now func() time.Time) *ResetPasswordUseCase {
|
||||
return &ResetPasswordUseCase{userRepository, hasher, apiKeys, now}
|
||||
}
|
||||
|
||||
func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassword string) error {
|
||||
@@ -25,7 +28,7 @@ func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassw
|
||||
return user.ErrInvalidPasswordResetCode
|
||||
}
|
||||
|
||||
if time.Now().After(dbUser.PasswordResetExpiration) {
|
||||
if r.now().After(dbUser.PasswordResetExpiration) {
|
||||
return user.ErrPasswordResetCodeExpired
|
||||
}
|
||||
|
||||
@@ -34,6 +37,9 @@ func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassw
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = r.userRepository.ResetPassword(dbUser.ID, hashedPassword)
|
||||
return err
|
||||
if _, err = r.userRepository.ResetPassword(dbUser.ID, hashedPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.apiKeys.RevokeAllByUser(dbUser.ID, r.now())
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RevokeAPIKeyUseCase struct {
|
||||
apiKeyRepository apikey.Repository
|
||||
userRepository user.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time) *RevokeAPIKeyUseCase {
|
||||
return &RevokeAPIKeyUseCase{apiKeyRepository, userRepository, now}
|
||||
}
|
||||
|
||||
func (r *RevokeAPIKeyUseCase) RevokeAPIKey(requesterId uint32, keyId uint32) error {
|
||||
key, err := r.apiKeyRepository.Get(keyId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requester, err := r.userRepository.Get(requesterId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if requester.Role != user.RoleOwner && key.UserID != requester.ID {
|
||||
return apikey.ErrAPIKeyNotFound
|
||||
}
|
||||
|
||||
if key.RevokedAt != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.apiKeyRepository.Revoke(key.ID, r.now())
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"log"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RunPurgeUseCase struct {
|
||||
planner *purgePlanner
|
||||
merge *MergeMediaUseCase
|
||||
repository purge.Repository
|
||||
now func() time.Time
|
||||
logger *log.Logger
|
||||
guard sync.Mutex
|
||||
}
|
||||
|
||||
func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger) *RunPurgeUseCase {
|
||||
return &RunPurgeUseCase{
|
||||
planner: &purgePlanner{imageRepository, articleRepository},
|
||||
merge: merge,
|
||||
repository: repository,
|
||||
now: now,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) Run(fingerprint *string) (purge.RunResult, error) {
|
||||
if !u.guard.TryLock() {
|
||||
return purge.RunResult{}, purge.ErrPurgeBusy
|
||||
}
|
||||
defer u.guard.Unlock()
|
||||
|
||||
plan, err := u.planner.plan()
|
||||
if err != nil {
|
||||
return purge.RunResult{}, err
|
||||
}
|
||||
if fingerprint != nil && *fingerprint != plan.fingerprint {
|
||||
return purge.RunResult{}, purge.ErrPurgeConflict
|
||||
}
|
||||
|
||||
result := u.mergeGroups(plan)
|
||||
if err := u.recordRun(result); err != nil {
|
||||
u.logger.Printf("purge: could not record the run result: %v", err)
|
||||
return purge.RunResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) mergeGroups(plan purgePlan) purge.RunResult {
|
||||
result := purge.RunResult{GroupsTotal: len(plan.groups)}
|
||||
updated := map[uint32]bool{}
|
||||
for _, group := range plan.groups {
|
||||
merged, err := u.merge.Merge(group.kept.ID, slices.Clone(group.mergedIds))
|
||||
if err != nil {
|
||||
result.FailedGroups++
|
||||
continue
|
||||
}
|
||||
result.GroupsPurged++
|
||||
result.Merged += merged.Merged
|
||||
for _, id := range merged.UpdatedArticleIDs {
|
||||
updated[id] = true
|
||||
}
|
||||
}
|
||||
result.ArticlesUpdated = len(updated)
|
||||
return result
|
||||
}
|
||||
|
||||
func (u *RunPurgeUseCase) recordRun(result purge.RunResult) error {
|
||||
return u.repository.SaveLastRun(u.now().UTC(), result.Merged, result.ArticlesUpdated)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/purge"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UpdatePurgeSettingsCommand struct {
|
||||
AutoPurgeEnabled *bool
|
||||
AutoPurgeIntervalDays *int
|
||||
}
|
||||
|
||||
type UpdatePurgeSettingsUseCase struct {
|
||||
repository purge.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewUpdatePurgeSettingsUseCase(repository purge.Repository, now func() time.Time) *UpdatePurgeSettingsUseCase {
|
||||
return &UpdatePurgeSettingsUseCase{repository, now}
|
||||
}
|
||||
|
||||
func (u *UpdatePurgeSettingsUseCase) Update(command UpdatePurgeSettingsCommand) (purge.Settings, error) {
|
||||
if command.AutoPurgeIntervalDays != nil {
|
||||
if err := purge.ValidateIntervalDays(*command.AutoPurgeIntervalDays); err != nil {
|
||||
return purge.Settings{}, err
|
||||
}
|
||||
}
|
||||
|
||||
settings, err := u.repository.Get()
|
||||
if err != nil {
|
||||
return purge.Settings{}, err
|
||||
}
|
||||
|
||||
if command.AutoPurgeEnabled != nil {
|
||||
if *command.AutoPurgeEnabled && !settings.AutoPurgeEnabled {
|
||||
enabledAt := u.now().UTC()
|
||||
settings.EnabledAt = &enabledAt
|
||||
}
|
||||
settings.AutoPurgeEnabled = *command.AutoPurgeEnabled
|
||||
}
|
||||
if command.AutoPurgeIntervalDays != nil {
|
||||
settings.AutoPurgeIntervalDays = *command.AutoPurgeIntervalDays
|
||||
}
|
||||
|
||||
if err := u.repository.Save(settings); err != nil {
|
||||
return purge.Settings{}, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type purgeGroup struct {
|
||||
kept image.Image
|
||||
keptUsage int
|
||||
mergedIds []uint32
|
||||
}
|
||||
|
||||
type purgePlan struct {
|
||||
groups []purgeGroup
|
||||
copies int
|
||||
articles int
|
||||
fingerprint string
|
||||
}
|
||||
|
||||
type purgePlanner struct {
|
||||
imageRepository image.Repository
|
||||
articleRepository article.Repository
|
||||
}
|
||||
|
||||
func (p *purgePlanner) plan() (purgePlan, error) {
|
||||
duplicates, err := p.imageRepository.ListDuplicates()
|
||||
if err != nil {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
articles, err := p.articleRepository.FindByFilters(article.Filters{})
|
||||
if err != nil {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
|
||||
groups := []purgeGroup{}
|
||||
copies := 0
|
||||
touched := map[uint32]bool{}
|
||||
for _, members := range groupByHash(duplicates) {
|
||||
group, planned := planGroup(members, articles)
|
||||
if !planned {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, group)
|
||||
copies += len(group.mergedIds)
|
||||
for _, id := range articlesUsingCopies(group, members, articles) {
|
||||
touched[id] = true
|
||||
}
|
||||
}
|
||||
slices.SortFunc(groups, func(a, b purgeGroup) int { return int(a.kept.ID) - int(b.kept.ID) })
|
||||
|
||||
return purgePlan{groups: groups, copies: copies, articles: len(touched), fingerprint: fingerprintOf(groups)}, nil
|
||||
}
|
||||
|
||||
func groupByHash(images []image.Image) [][]image.Image {
|
||||
byHash := map[string][]image.Image{}
|
||||
hashes := []string{}
|
||||
for _, img := range images {
|
||||
if img.SHA256 == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := byHash[img.SHA256]; !seen {
|
||||
hashes = append(hashes, img.SHA256)
|
||||
}
|
||||
byHash[img.SHA256] = append(byHash[img.SHA256], img)
|
||||
}
|
||||
groups := make([][]image.Image, 0, len(hashes))
|
||||
for _, hash := range hashes {
|
||||
groups = append(groups, byHash[hash])
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func usageOf(img image.Image, articles []article.Article) int {
|
||||
storedName := image.StoredName(img.Path)
|
||||
used := 0
|
||||
for _, a := range articles {
|
||||
if strings.Contains(a.Body, storedName) {
|
||||
used++
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
func planGroup(members []image.Image, articles []article.Article) (purgeGroup, bool) {
|
||||
usage := make(map[uint32]int, len(members))
|
||||
anyUsed := false
|
||||
for _, member := range members {
|
||||
usage[member.ID] = usageOf(member, articles)
|
||||
anyUsed = anyUsed || usage[member.ID] > 0
|
||||
}
|
||||
if !anyUsed {
|
||||
return purgeGroup{}, false
|
||||
}
|
||||
|
||||
kept := members[0]
|
||||
for _, member := range members[1:] {
|
||||
if isBetterToKeep(member, kept, usage) {
|
||||
kept = member
|
||||
}
|
||||
}
|
||||
|
||||
mergedIds := []uint32{}
|
||||
for _, member := range members {
|
||||
if member.ID != kept.ID {
|
||||
mergedIds = append(mergedIds, member.ID)
|
||||
}
|
||||
}
|
||||
slices.Sort(mergedIds)
|
||||
return purgeGroup{kept: kept, keptUsage: usage[kept.ID], mergedIds: mergedIds}, true
|
||||
}
|
||||
|
||||
func isBetterToKeep(candidate, current image.Image, usage map[uint32]int) bool {
|
||||
if usage[candidate.ID] != usage[current.ID] {
|
||||
return usage[candidate.ID] > usage[current.ID]
|
||||
}
|
||||
if !candidate.CreatedAt.Equal(current.CreatedAt) {
|
||||
return candidate.CreatedAt.Before(current.CreatedAt)
|
||||
}
|
||||
return candidate.ID < current.ID
|
||||
}
|
||||
|
||||
func articlesUsingCopies(group purgeGroup, members []image.Image, articles []article.Article) []uint32 {
|
||||
mergedNames := []string{}
|
||||
for _, member := range members {
|
||||
if member.ID != group.kept.ID {
|
||||
mergedNames = append(mergedNames, image.StoredName(member.Path))
|
||||
}
|
||||
}
|
||||
ids := []uint32{}
|
||||
for _, a := range articles {
|
||||
for _, name := range mergedNames {
|
||||
if strings.Contains(a.Body, name) {
|
||||
ids = append(ids, a.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func fingerprintOf(groups []purgeGroup) string {
|
||||
var builder strings.Builder
|
||||
for _, group := range groups {
|
||||
builder.WriteString(strconv.FormatUint(uint64(group.kept.ID), 10))
|
||||
builder.WriteString(":")
|
||||
for position, id := range group.mergedIds {
|
||||
if position > 0 {
|
||||
builder.WriteString(",")
|
||||
}
|
||||
builder.WriteString(strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
builder.WriteString(";")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(builder.String()))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type apiKeyCreatorLookup struct {
|
||||
user.Repository
|
||||
gets map[uint32]int
|
||||
failOn uint32
|
||||
}
|
||||
|
||||
func newAPIKeyCreatorLookup(inner user.Repository) *apiKeyCreatorLookup {
|
||||
return &apiKeyCreatorLookup{Repository: inner, gets: map[uint32]int{}}
|
||||
}
|
||||
|
||||
func (a *apiKeyCreatorLookup) Get(id uint32) (user.User, error) {
|
||||
a.gets[id]++
|
||||
if id == a.failOn {
|
||||
return user.User{}, errRepositoryFailure
|
||||
}
|
||||
return a.Repository.Get(id)
|
||||
}
|
||||
|
||||
var _ user.Repository = &apiKeyCreatorLookup{}
|
||||
|
||||
func (e *apiKeyEnv) useCreatorLookup(lookup *apiKeyCreatorLookup) {
|
||||
clock := func() time.Time { return e.now }
|
||||
e.container.ListAPIKeysUseCase = useCases.NewListAPIKeysUseCase(e.keys, lookup, clock)
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v1", api.InitBackendRoutes(e.container, e.settings))
|
||||
e.router = router
|
||||
}
|
||||
|
||||
func (e *apiKeyEnv) nameUsers() {
|
||||
owner := e.users.users[apiKeyOwnerId]
|
||||
owner.DisplayName = "Marta Alves"
|
||||
e.users.users[apiKeyOwnerId] = owner
|
||||
dev := e.users.users[apiKeyDevId]
|
||||
dev.DisplayName = "Dev Two"
|
||||
e.users.users[apiKeyDevId] = dev
|
||||
}
|
||||
|
||||
func TestAPIKeysCreatorNameHttp(t *testing.T) {
|
||||
allFields := []string{"id", "user_id", "name", "prefix", "status", "created_at", "last_used_at", "expires_at", "revoked_at"}
|
||||
|
||||
t.Run("API: an owner's list carries the creator's name", func(t *testing.T) {
|
||||
env := newAPIKeyEnv(t)
|
||||
env.nameUsers()
|
||||
env.issueKey(apiKeyOwnerId, "a")
|
||||
env.issueKey(apiKeyDevId, "b")
|
||||
env.issueKey(apiKeyPatId, "c")
|
||||
|
||||
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId)
|
||||
|
||||
assertAPIKeyStatus(t, rec, http.StatusOK)
|
||||
entries := apiKeyListedByName(t, rec)
|
||||
want := map[string]string{"a": "Marta Alves", "b": "Dev Two", "c": "pat"}
|
||||
for name, creator := range want {
|
||||
if entries[name]["created_by_name"] != creator {
|
||||
t.Errorf("entry %s: expected created_by_name %q, got %v", name, creator, entries[name]["created_by_name"])
|
||||
}
|
||||
apiKeyAssertExactFields(t, entries[name], append([]string{"created_by_name"}, allFields...)...)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("API: a whitespace-only display name falls back to the username", func(t *testing.T) {
|
||||
env := newAPIKeyEnv(t)
|
||||
pat := env.users.users[apiKeyPatId]
|
||||
pat.DisplayName = " "
|
||||
env.users.users[apiKeyPatId] = pat
|
||||
env.issueKey(apiKeyPatId, "c")
|
||||
|
||||
entries := apiKeyListedByName(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId))
|
||||
|
||||
if entries["c"]["created_by_name"] != "pat" {
|
||||
t.Errorf("expected pat, got %v", entries["c"]["created_by_name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("API: an admin's list is unchanged", func(t *testing.T) {
|
||||
env := newAPIKeyEnv(t)
|
||||
env.nameUsers()
|
||||
env.issueKey(apiKeyDevId, "a")
|
||||
env.issueKey(apiKeyDevId, "b")
|
||||
|
||||
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyDevId)
|
||||
|
||||
assertAPIKeyStatus(t, rec, http.StatusOK)
|
||||
list := apiKeyDecodeList(t, rec)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("expected two entries, got %d", len(list))
|
||||
}
|
||||
for _, entry := range list {
|
||||
apiKeyAssertExactFields(t, entry, allFields...)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("API: a key whose creator no longer exists", func(t *testing.T) {
|
||||
env := newAPIKeyEnv(t)
|
||||
env.issueKey(apiKeyPatId, "gone")
|
||||
env.issueKey(apiKeyDevId, "kept")
|
||||
delete(env.users.users, apiKeyPatId)
|
||||
|
||||
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId)
|
||||
|
||||
assertAPIKeyStatus(t, rec, http.StatusOK)
|
||||
entries := apiKeyListedByName(t, rec)
|
||||
if name, ok := entries["gone"]["created_by_name"]; !ok || name != "" {
|
||||
t.Errorf("expected an empty created_by_name, got %v", name)
|
||||
}
|
||||
if entries["kept"]["created_by_name"] != "dev" {
|
||||
t.Errorf("expected dev, got %v", entries["kept"]["created_by_name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("API: a failing user lookup fails the list", func(t *testing.T) {
|
||||
env := newAPIKeyEnv(t)
|
||||
env.issueKey(apiKeyDevId, "a")
|
||||
lookup := newAPIKeyCreatorLookup(env.users)
|
||||
lookup.failOn = apiKeyDevId
|
||||
env.useCreatorLookup(lookup)
|
||||
|
||||
rec := env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId)
|
||||
|
||||
assertAPIKeyMessage(t, rec, http.StatusInternalServerError, "Internal server error")
|
||||
})
|
||||
|
||||
t.Run("API: the creator name is resolved once per user", func(t *testing.T) {
|
||||
env := newAPIKeyEnv(t)
|
||||
baseline := newAPIKeyCreatorLookup(env.users)
|
||||
env.useCreatorLookup(baseline)
|
||||
assertAPIKeyStatus(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId), http.StatusOK)
|
||||
|
||||
env.issueKey(apiKeyOwnerId, "o1")
|
||||
env.issueKey(apiKeyOwnerId, "o2")
|
||||
env.issueKey(apiKeyOwnerId, "o3")
|
||||
env.issueKey(apiKeyDevId, "d1")
|
||||
env.issueKey(apiKeyDevId, "d2")
|
||||
lookup := newAPIKeyCreatorLookup(env.users)
|
||||
env.useCreatorLookup(lookup)
|
||||
|
||||
assertAPIKeyStatus(t, env.withCookie(t, http.MethodGet, "/v1/api-key", "", apiKeyOwnerId), http.StatusOK)
|
||||
|
||||
if lookup.gets[apiKeyDevId] != 1 {
|
||||
t.Errorf("expected one Get for the admin creator, got %d", lookup.gets[apiKeyDevId])
|
||||
}
|
||||
if lookup.gets[apiKeyOwnerId] != baseline.gets[apiKeyOwnerId] {
|
||||
t.Errorf("expected no extra Get for the owner, got %d against %d", lookup.gets[apiKeyOwnerId], baseline.gets[apiKeyOwnerId])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListAPIKeysCreatorName(t *testing.T) {
|
||||
seed := func() *mockAPIKeyRepo {
|
||||
repo := newMockAPIKeyRepo()
|
||||
seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 1 })
|
||||
seedStubKey(repo, "rcms_b", func(k *apikey.APIKey) { k.UserID = 2 })
|
||||
return repo
|
||||
}
|
||||
users := func() *mockUserRepo {
|
||||
repo := apiKeyUnitUsers()
|
||||
marta := repo.users[1]
|
||||
marta.DisplayName = "Marta Alves"
|
||||
repo.users[1] = marta
|
||||
return repo
|
||||
}
|
||||
|
||||
t.Run("Use case: ListAPIKeys returns the creator name only to an owner", func(t *testing.T) {
|
||||
uc := useCases.NewListAPIKeysUseCase(seed(), users(), apiKeyUnitClock)
|
||||
|
||||
ownerEntries, err := uc.ListAPIKeys(1)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
names := map[string]string{}
|
||||
for _, entry := range ownerEntries {
|
||||
names[entry.Key.Prefix] = entry.CreatorName
|
||||
}
|
||||
if names["rcms_a"] != "Marta Alves" || names["rcms_b"] != "dev" {
|
||||
t.Errorf("unexpected creator names %v", names)
|
||||
}
|
||||
|
||||
adminEntries, err := uc.ListAPIKeys(2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(adminEntries) != 1 || adminEntries[0].CreatorName != "" {
|
||||
t.Errorf("expected one entry with an empty creator name, got %+v", adminEntries)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type apiKeyDuplicateIdRepo struct {
|
||||
apikey.Repository
|
||||
keys []apikey.APIKey
|
||||
}
|
||||
|
||||
func (a *apiKeyDuplicateIdRepo) ListAll() ([]apikey.APIKey, error) {
|
||||
return append([]apikey.APIKey(nil), a.keys...), nil
|
||||
}
|
||||
|
||||
var _ apikey.Repository = &apiKeyDuplicateIdRepo{}
|
||||
|
||||
func TestListAPIKeysTieKeepsRepositoryOrder(t *testing.T) {
|
||||
createdAt := apiKeyUnitNow.Add(-time.Hour)
|
||||
repo := &apiKeyDuplicateIdRepo{keys: []apikey.APIKey{
|
||||
{ID: 7, UserID: 1, Name: "first", CreatedAt: createdAt},
|
||||
{ID: 7, UserID: 1, Name: "second", CreatedAt: createdAt},
|
||||
}}
|
||||
uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||
|
||||
entries, err := uc.ListAPIKeys(1)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(entries) != 2 || entries[0].Key.Name != "first" || entries[1].Key.Name != "second" {
|
||||
t.Errorf("expected a stable order for identical keys, got %+v", entries)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,642 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/hashing"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"regexp"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type apiKeyRevocation struct {
|
||||
userId uint32
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type mockAPIKeyRepo struct {
|
||||
keys map[uint32]apikey.APIKey
|
||||
nextID uint32
|
||||
fail failSwitch
|
||||
createCalls []apikey.APIKey
|
||||
touchCalls []time.Time
|
||||
revokeCalls int
|
||||
revokeAllCalls []apiKeyRevocation
|
||||
getByHashedCalls int
|
||||
}
|
||||
|
||||
func newMockAPIKeyRepo() *mockAPIKeyRepo {
|
||||
return &mockAPIKeyRepo{keys: make(map[uint32]apikey.APIKey)}
|
||||
}
|
||||
|
||||
func (m *mockAPIKeyRepo) sorted() []apikey.APIKey {
|
||||
result := make([]apikey.APIKey, 0, len(m.keys))
|
||||
for _, key := range m.keys {
|
||||
result = append(result, key)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *mockAPIKeyRepo) Create(key apikey.APIKey) (apikey.APIKey, error) {
|
||||
if err := m.fail.trigger("Create"); err != nil {
|
||||
return apikey.APIKey{}, err
|
||||
}
|
||||
m.createCalls = append(m.createCalls, key)
|
||||
m.nextID++
|
||||
key.ID = m.nextID
|
||||
m.keys[key.ID] = key
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (m *mockAPIKeyRepo) Get(id uint32) (apikey.APIKey, error) {
|
||||
if err := m.fail.trigger("Get"); err != nil {
|
||||
return apikey.APIKey{}, err
|
||||
}
|
||||
key, ok := m.keys[id]
|
||||
if !ok {
|
||||
return apikey.APIKey{}, apikey.ErrAPIKeyNotFound
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (m *mockAPIKeyRepo) GetByHashedKey(hashedKey string) (apikey.APIKey, error) {
|
||||
m.getByHashedCalls++
|
||||
if err := m.fail.trigger("GetByHashedKey"); err != nil {
|
||||
return apikey.APIKey{}, err
|
||||
}
|
||||
for _, key := range m.sorted() {
|
||||
if key.HashedKey == hashedKey {
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
return apikey.APIKey{}, apikey.ErrAPIKeyNotFound
|
||||
}
|
||||
|
||||
func (m *mockAPIKeyRepo) ListByUser(userId uint32) ([]apikey.APIKey, error) {
|
||||
if err := m.fail.trigger("ListByUser"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]apikey.APIKey, 0)
|
||||
for _, key := range m.sorted() {
|
||||
if key.UserID == userId {
|
||||
result = append(result, key)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockAPIKeyRepo) ListAll() ([]apikey.APIKey, error) {
|
||||
if err := m.fail.trigger("ListAll"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.sorted(), nil
|
||||
}
|
||||
|
||||
func (m *mockAPIKeyRepo) Revoke(id uint32, at time.Time) error {
|
||||
m.revokeCalls++
|
||||
if err := m.fail.trigger("Revoke"); err != nil {
|
||||
return err
|
||||
}
|
||||
key, ok := m.keys[id]
|
||||
if !ok || key.RevokedAt != nil {
|
||||
return nil
|
||||
}
|
||||
key.RevokedAt = &at
|
||||
m.keys[id] = key
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAPIKeyRepo) RevokeAllByUser(userId uint32, at time.Time) error {
|
||||
m.revokeAllCalls = append(m.revokeAllCalls, apiKeyRevocation{userId, at})
|
||||
if err := m.fail.trigger("RevokeAllByUser"); err != nil {
|
||||
return err
|
||||
}
|
||||
for id, key := range m.keys {
|
||||
if key.UserID == userId && key.RevokedAt == nil {
|
||||
revokedAt := at
|
||||
key.RevokedAt = &revokedAt
|
||||
m.keys[id] = key
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAPIKeyRepo) TouchLastUsed(id uint32, at time.Time) error {
|
||||
m.touchCalls = append(m.touchCalls, at)
|
||||
if err := m.fail.trigger("TouchLastUsed"); err != nil {
|
||||
return err
|
||||
}
|
||||
key, ok := m.keys[id]
|
||||
if !ok {
|
||||
return apikey.ErrAPIKeyNotFound
|
||||
}
|
||||
key.LastUsedAt = &at
|
||||
m.keys[id] = key
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ apikey.Repository = &mockAPIKeyRepo{}
|
||||
|
||||
type stubKeyGenerator struct {
|
||||
raw string
|
||||
prefix string
|
||||
}
|
||||
|
||||
func (s *stubKeyGenerator) Generate() (string, string, string) {
|
||||
return s.raw, s.prefix, s.Hash(s.raw)
|
||||
}
|
||||
|
||||
func (s *stubKeyGenerator) Hash(rawKey string) string {
|
||||
return "hash:" + rawKey
|
||||
}
|
||||
|
||||
var _ apikey.KeyGenerator = &stubKeyGenerator{}
|
||||
|
||||
var apiKeyUnitNow = time.Date(2026, time.October, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func apiKeyUnitClock() time.Time {
|
||||
return apiKeyUnitNow
|
||||
}
|
||||
|
||||
func seedStubKey(repo *mockAPIKeyRepo, raw string, mutate func(*apikey.APIKey)) apikey.APIKey {
|
||||
key := apikey.APIKey{UserID: 2, Name: "k", Prefix: raw, HashedKey: "hash:" + raw, CreatedAt: apiKeyUnitNow.Add(-time.Hour)}
|
||||
if mutate != nil {
|
||||
mutate(&key)
|
||||
}
|
||||
stored, _ := repo.Create(key)
|
||||
return stored
|
||||
}
|
||||
|
||||
func apiKeyUnitUsers() *mockUserRepo {
|
||||
users := newMockUserRepo()
|
||||
users.users[1] = user.User{ID: 1, Username: "marta", IsVerified: true, Role: user.RoleOwner}
|
||||
users.users[2] = user.User{ID: 2, Username: "dev", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 3}
|
||||
users.users[3] = user.User{ID: 3, Username: "pat", IsVerified: true, Role: user.RoleAdmin}
|
||||
users.users[4] = user.User{ID: 4, Username: "quinn", IsVerified: false, Role: user.RoleAdmin}
|
||||
return users
|
||||
}
|
||||
|
||||
func TestSHA256KeyGenerator(t *testing.T) {
|
||||
generator := hashing.NewSHA256KeyGenerator()
|
||||
rawKeyPattern := regexp.MustCompile(`^rcms_[A-Za-z0-9_-]{43}$`)
|
||||
|
||||
t.Run("A generated key has the rcms_ format, a 12 character prefix and its SHA-256 hash", func(t *testing.T) {
|
||||
raw, prefix, hashed := generator.Generate()
|
||||
if !rawKeyPattern.MatchString(raw) {
|
||||
t.Fatalf("unexpected raw key %q", raw)
|
||||
}
|
||||
if prefix != raw[:12] {
|
||||
t.Errorf("expected prefix %q, got %q", raw[:12], prefix)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
if hashed != hex.EncodeToString(sum[:]) {
|
||||
t.Errorf("expected the SHA-256 of the raw key, got %q", hashed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Hash is deterministic and never the raw key", func(t *testing.T) {
|
||||
if generator.Hash("rcms_abc") != generator.Hash("rcms_abc") {
|
||||
t.Errorf("expected a deterministic hash")
|
||||
}
|
||||
if generator.Hash("rcms_abc") == generator.Hash("rcms_abd") {
|
||||
t.Errorf("expected different hashes for different keys")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Two generated keys differ", func(t *testing.T) {
|
||||
first, _, _ := generator.Generate()
|
||||
second, _, _ := generator.Generate()
|
||||
if first == second {
|
||||
t.Errorf("expected two different keys")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateAPIKeyUseCase(t *testing.T) {
|
||||
t.Run("Use case: creating a key hashes it and stores no plaintext", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock)
|
||||
|
||||
created, err := uc.CreateAPIKey(useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(repo.createCalls) != 1 {
|
||||
t.Fatalf("expected one Create call, got %d", len(repo.createCalls))
|
||||
}
|
||||
stored := repo.createCalls[0]
|
||||
if stored.HashedKey != "hash:rcms_fake" || stored.Prefix != "rcms_fake" || stored.UserID != 2 || stored.Name != "ci" {
|
||||
t.Errorf("unexpected stored key: %+v", stored)
|
||||
}
|
||||
if !stored.CreatedAt.Equal(apiKeyUnitNow) {
|
||||
t.Errorf("expected CreatedAt from the clock, got %v", stored.CreatedAt)
|
||||
}
|
||||
if created.RawKey != "rcms_fake" {
|
||||
t.Errorf("expected the raw key in the result, got %q", created.RawKey)
|
||||
}
|
||||
if stored.HashedKey == created.RawKey || stored.Prefix == "" {
|
||||
t.Errorf("the raw key must not be stored")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An expiry that is not in the future is refused and nothing is stored", func(t *testing.T) {
|
||||
for name, expiry := range map[string]time.Time{"in the past": apiKeyUnitNow.Add(-time.Hour), "exactly now": apiKeyUnitNow} {
|
||||
repo := newMockAPIKeyRepo()
|
||||
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock)
|
||||
|
||||
_, err := uc.CreateAPIKey(useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci", ExpiresAt: &expiry})
|
||||
if !errors.Is(err, apikey.ErrExpiryNotInFuture) {
|
||||
t.Errorf("%s: expected ErrExpiryNotInFuture, got %v", name, err)
|
||||
}
|
||||
if len(repo.createCalls) != 0 {
|
||||
t.Errorf("%s: expected nothing stored", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A repository failure is returned", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
repo.fail.failNext("Create")
|
||||
uc := useCases.NewCreateAPIKeyUseCase(repo, &stubKeyGenerator{raw: "rcms_fake", prefix: "rcms_fake"}, apiKeyUnitClock)
|
||||
|
||||
if _, err := uc.CreateAPIKey(useCases.CreateAPIKeyCommand{UserID: 2, Name: "ci"}); !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected the repository failure, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthenticateAPIKeyUseCase(t *testing.T) {
|
||||
newUseCase := func(repo *mockAPIKeyRepo, users *mockUserRepo) *useCases.AuthenticateAPIKeyUseCase {
|
||||
return useCases.NewAuthenticateAPIKeyUseCase(repo, &stubKeyGenerator{}, users, apiKeyUnitClock)
|
||||
}
|
||||
|
||||
t.Run("Use case: authenticating returns the user and rejects every invalid case with one sentinel", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
expiredAt := apiKeyUnitNow.Add(-time.Second)
|
||||
exactlyNow := apiKeyUnitNow
|
||||
revokedAt := apiKeyUnitNow.Add(-time.Minute)
|
||||
seedStubKey(repo, "rcms_expired", func(k *apikey.APIKey) { k.ExpiresAt = &expiredAt })
|
||||
seedStubKey(repo, "rcms_exactlynow", func(k *apikey.APIKey) { k.ExpiresAt = &exactlyNow })
|
||||
seedStubKey(repo, "rcms_revoked", func(k *apikey.APIKey) { k.RevokedAt = &revokedAt })
|
||||
seedStubKey(repo, "rcms_missinguser", func(k *apikey.APIKey) { k.UserID = 99 })
|
||||
seedStubKey(repo, "rcms_unverified", func(k *apikey.APIKey) { k.UserID = 4 })
|
||||
uc := newUseCase(repo, apiKeyUnitUsers())
|
||||
|
||||
for _, raw := range []string{"rcms_unknown", "rcms_", "", "nope", "rcms_expired", "rcms_exactlynow", "rcms_revoked", "rcms_missinguser", "rcms_unverified"} {
|
||||
if _, err := uc.AuthenticateAPIKey(raw); !errors.Is(err, apikey.ErrAPIKeyInvalid) {
|
||||
t.Errorf("%q: expected ErrAPIKeyInvalid, got %v", raw, err)
|
||||
}
|
||||
}
|
||||
if len(repo.touchCalls) != 0 {
|
||||
t.Errorf("a rejected key must never record last_used_at")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A valid key returns the user from the user repository", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
stored := seedStubKey(repo, "rcms_valid", nil)
|
||||
uc := newUseCase(repo, apiKeyUnitUsers())
|
||||
|
||||
authenticated, err := uc.AuthenticateAPIKey("rcms_valid")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if authenticated.User.ID != 2 || authenticated.User.TokenVersion != 3 || authenticated.Key.ID != stored.ID {
|
||||
t.Errorf("unexpected result: %+v", authenticated)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A key expiring in the future is valid", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
later := apiKeyUnitNow.Add(time.Second)
|
||||
seedStubKey(repo, "rcms_later", func(k *apikey.APIKey) { k.ExpiresAt = &later })
|
||||
|
||||
if _, err := newUseCase(repo, apiKeyUnitUsers()).AuthenticateAPIKey("rcms_later"); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Use case: a failing repository is not reported as an invalid key", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
seedStubKey(repo, "rcms_valid", nil)
|
||||
repo.fail.failNext("GetByHashedKey")
|
||||
|
||||
_, err := newUseCase(repo, apiKeyUnitUsers()).AuthenticateAPIKey("rcms_valid")
|
||||
if !errors.Is(err, errRepositoryFailure) || errors.Is(err, apikey.ErrAPIKeyInvalid) {
|
||||
t.Errorf("expected the raw repository failure, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failing user repository is not reported as an invalid key", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
seedStubKey(repo, "rcms_valid", nil)
|
||||
users := apiKeyUnitUsers()
|
||||
users.fail.failNext("Get")
|
||||
|
||||
_, err := newUseCase(repo, users).AuthenticateAPIKey("rcms_valid")
|
||||
if !errors.Is(err, errRepositoryFailure) || errors.Is(err, apikey.ErrAPIKeyInvalid) {
|
||||
t.Errorf("expected the raw repository failure, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Using a key records last_used_at at most once per minute", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
stored := seedStubKey(repo, "rcms_valid", nil)
|
||||
now := apiKeyUnitNow
|
||||
uc := useCases.NewAuthenticateAPIKeyUseCase(repo, &stubKeyGenerator{}, apiKeyUnitUsers(), func() time.Time { return now })
|
||||
|
||||
steps := []struct {
|
||||
offset time.Duration
|
||||
writes int
|
||||
lastUsed time.Duration
|
||||
}{
|
||||
{0, 1, 0},
|
||||
{30 * time.Second, 1, 0},
|
||||
{60 * time.Second, 2, 60 * time.Second},
|
||||
{61*time.Second + 30*time.Second, 2, 60 * time.Second},
|
||||
{130 * time.Second, 3, 130 * time.Second},
|
||||
}
|
||||
for _, step := range steps {
|
||||
now = apiKeyUnitNow.Add(step.offset)
|
||||
if _, err := uc.AuthenticateAPIKey("rcms_valid"); err != nil {
|
||||
t.Fatalf("unexpected error at +%v: %v", step.offset, err)
|
||||
}
|
||||
if len(repo.touchCalls) != step.writes {
|
||||
t.Errorf("at +%v: expected %d writes, got %d", step.offset, step.writes, len(repo.touchCalls))
|
||||
}
|
||||
if got := repo.keys[stored.ID].LastUsedAt; got == nil || !got.Equal(apiKeyUnitNow.Add(step.lastUsed)) {
|
||||
t.Errorf("at +%v: expected last_used_at +%v, got %v", step.offset, step.lastUsed, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failure to record last_used_at doesn't fail the authentication", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
seedStubKey(repo, "rcms_valid", nil)
|
||||
repo.fail.failNext("TouchLastUsed")
|
||||
|
||||
if _, err := newUseCase(repo, apiKeyUnitUsers()).AuthenticateAPIKey("rcms_valid"); err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListAPIKeysUseCase(t *testing.T) {
|
||||
seed := func(repo *mockAPIKeyRepo) {
|
||||
seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 2; k.CreatedAt = apiKeyUnitNow.Add(-3 * time.Hour) })
|
||||
seedStubKey(repo, "rcms_b", func(k *apikey.APIKey) { k.UserID = 2; k.CreatedAt = apiKeyUnitNow.Add(-2 * time.Hour) })
|
||||
seedStubKey(repo, "rcms_c", func(k *apikey.APIKey) { k.UserID = 3; k.CreatedAt = apiKeyUnitNow.Add(-90 * time.Minute) })
|
||||
seedStubKey(repo, "rcms_d", func(k *apikey.APIKey) { k.UserID = 1; k.CreatedAt = apiKeyUnitNow.Add(-time.Hour) })
|
||||
}
|
||||
prefixes := func(entries []useCases.APIKeyEntry) []string {
|
||||
result := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
result = append(result, entry.Key.Prefix)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
t.Run("An admin lists only their own keys, newest first", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
seed(repo)
|
||||
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := prefixes(entries); len(got) != 2 || got[0] != "rcms_b" || got[1] != "rcms_a" {
|
||||
t.Errorf("unexpected listing %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The owner lists every key, newest first", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
seed(repo)
|
||||
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(1)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := prefixes(entries)
|
||||
want := []string{"rcms_d", "rcms_c", "rcms_b", "rcms_a"}
|
||||
for i := range want {
|
||||
if len(got) != len(want) || got[i] != want[i] {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Keys created at the same instant are listed by descending id", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
same := apiKeyUnitNow.Add(-time.Hour)
|
||||
for _, raw := range []string{"rcms_first", "rcms_second", "rcms_third"} {
|
||||
seedStubKey(repo, raw, func(k *apikey.APIKey) { k.CreatedAt = same })
|
||||
}
|
||||
entries, err := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
got := prefixes(entries)
|
||||
want := []string{"rcms_third", "rcms_second", "rcms_first"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
|
||||
t.Errorf("expected %v, got %v", want, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Each entry carries its status at the clock's now", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
expired := apiKeyUnitNow.Add(-time.Hour)
|
||||
revoked := apiKeyUnitNow.Add(-time.Minute)
|
||||
seedStubKey(repo, "rcms_active", nil)
|
||||
seedStubKey(repo, "rcms_expired", func(k *apikey.APIKey) { k.ExpiresAt = &expired })
|
||||
seedStubKey(repo, "rcms_both", func(k *apikey.APIKey) { k.ExpiresAt = &expired; k.RevokedAt = &revoked })
|
||||
|
||||
entries, _ := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock).ListAPIKeys(2)
|
||||
statuses := map[string]apikey.Status{}
|
||||
for _, entry := range entries {
|
||||
statuses[entry.Key.Prefix] = entry.Status
|
||||
}
|
||||
if statuses["rcms_active"] != apikey.StatusActive || statuses["rcms_expired"] != apikey.StatusExpired || statuses["rcms_both"] != apikey.StatusRevoked {
|
||||
t.Errorf("unexpected statuses %v", statuses)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unknown requester and repository failures are returned", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
uc := useCases.NewListAPIKeysUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||
if _, err := uc.ListAPIKeys(99); !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Errorf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
repo.fail.failNext("ListByUser")
|
||||
if _, err := uc.ListAPIKeys(2); !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected the repository failure, got %v", err)
|
||||
}
|
||||
repo.fail.failNext("ListAll")
|
||||
if _, err := uc.ListAPIKeys(1); !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected the repository failure, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRevokeAPIKeyUseCase(t *testing.T) {
|
||||
t.Run("A creator revokes their own key at the clock's now", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
stored := seedStubKey(repo, "rcms_a", nil)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||
|
||||
if err := uc.RevokeAPIKey(2, stored.ID); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(apiKeyUnitNow) {
|
||||
t.Errorf("expected revoked_at %v, got %v", apiKeyUnitNow, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Another admin gets not found and the key stays active", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
stored := seedStubKey(repo, "rcms_a", nil)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||
|
||||
if err := uc.RevokeAPIKey(3, stored.ID); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||
t.Errorf("expected ErrAPIKeyNotFound, got %v", err)
|
||||
}
|
||||
if repo.keys[stored.ID].RevokedAt != nil || repo.revokeCalls != 0 {
|
||||
t.Errorf("expected the key untouched")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The owner revokes any key", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.UserID = 3 })
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||
|
||||
if err := uc.RevokeAPIKey(1, stored.ID); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if repo.keys[stored.ID].RevokedAt == nil {
|
||||
t.Errorf("expected the key revoked")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Revoking an already revoked key keeps its original revoked_at", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
original := apiKeyUnitNow.Add(-time.Hour)
|
||||
stored := seedStubKey(repo, "rcms_a", func(k *apikey.APIKey) { k.RevokedAt = &original })
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||
|
||||
if err := uc.RevokeAPIKey(2, stored.ID); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := repo.keys[stored.ID].RevokedAt; got == nil || !got.Equal(original) {
|
||||
t.Errorf("expected revoked_at %v, got %v", original, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An unknown key, an unknown requester and repository failures are returned", func(t *testing.T) {
|
||||
repo := newMockAPIKeyRepo()
|
||||
stored := seedStubKey(repo, "rcms_a", nil)
|
||||
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitUsers(), apiKeyUnitClock)
|
||||
|
||||
if err := uc.RevokeAPIKey(2, 999); !errors.Is(err, apikey.ErrAPIKeyNotFound) {
|
||||
t.Errorf("expected ErrAPIKeyNotFound, got %v", err)
|
||||
}
|
||||
if err := uc.RevokeAPIKey(99, stored.ID); !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Errorf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
repo.fail.failNext("Revoke")
|
||||
if err := uc.RevokeAPIKey(2, stored.ID); !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected the repository failure, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResetPasswordRevokesAPIKeys(t *testing.T) {
|
||||
resetUser := func(expiration time.Time) *mockUserRepo {
|
||||
repo := newMockUserRepo()
|
||||
repo.users[2] = user.User{ID: 2, Email: "dev@example.com", Password: "hashed:old", PasswordResetCode: "hashed:reset-code-1", PasswordResetExpiration: expiration}
|
||||
repo.users[3] = user.User{ID: 3, Email: "pat@example.com"}
|
||||
return repo
|
||||
}
|
||||
|
||||
t.Run("Use case: ResetPassword revokes the user's keys through the domain repository", func(t *testing.T) {
|
||||
keys := newMockAPIKeyRepo()
|
||||
seedStubKey(keys, "rcms_a", func(k *apikey.APIKey) { k.UserID = 2 })
|
||||
other := seedStubKey(keys, "rcms_c", func(k *apikey.APIKey) { k.UserID = 3 })
|
||||
uc := useCases.NewResetPasswordUseCase(resetUser(apiKeyUnitNow.Add(time.Hour)), &stubHasher{}, keys, apiKeyUnitClock)
|
||||
|
||||
if err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(keys.revokeAllCalls) != 1 || keys.revokeAllCalls[0].userId != 2 || !keys.revokeAllCalls[0].at.Equal(apiKeyUnitNow) {
|
||||
t.Errorf("expected exactly one RevokeAllByUser(2, T), got %+v", keys.revokeAllCalls)
|
||||
}
|
||||
if keys.keys[other.ID].RevokedAt != nil {
|
||||
t.Errorf("expected user 3's key untouched")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Use case: ResetPassword does not call RevokeAllByUser when the reset fails", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
expiration time.Time
|
||||
code string
|
||||
email string
|
||||
want error
|
||||
}{
|
||||
{"wrong code", apiKeyUnitNow.Add(time.Hour), "wrong", "dev@example.com", user.ErrInvalidPasswordResetCode},
|
||||
{"expired code", apiKeyUnitNow.Add(-time.Second), "reset-code-1", "dev@example.com", user.ErrPasswordResetCodeExpired},
|
||||
{"unknown email", apiKeyUnitNow.Add(time.Hour), "reset-code-1", "nobody@example.com", user.ErrInvalidPasswordResetCode},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
keys := newMockAPIKeyRepo()
|
||||
uc := useCases.NewResetPasswordUseCase(resetUser(tc.expiration), &stubHasher{}, keys, apiKeyUnitClock)
|
||||
|
||||
if err := uc.ResetPassword(tc.email, tc.code, "newpassword1"); !errors.Is(err, tc.want) {
|
||||
t.Errorf("expected %v, got %v", tc.want, err)
|
||||
}
|
||||
if len(keys.revokeAllCalls) != 0 {
|
||||
t.Errorf("expected RevokeAllByUser never called")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A failed password change and a failed hash revoke nothing", func(t *testing.T) {
|
||||
keys := newMockAPIKeyRepo()
|
||||
users := resetUser(apiKeyUnitNow.Add(time.Hour))
|
||||
users.fail.failNext("ResetPassword")
|
||||
uc := useCases.NewResetPasswordUseCase(users, &stubHasher{}, keys, apiKeyUnitClock)
|
||||
if err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1"); !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected the repository failure, got %v", err)
|
||||
}
|
||||
|
||||
hashErr := errors.New("hash failed")
|
||||
uc = useCases.NewResetPasswordUseCase(resetUser(apiKeyUnitNow.Add(time.Hour)), &stubHasher{hashErr: hashErr}, keys, apiKeyUnitClock)
|
||||
if err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1"); !errors.Is(err, hashErr) {
|
||||
t.Errorf("expected the hash error, got %v", err)
|
||||
}
|
||||
if len(keys.revokeAllCalls) != 0 {
|
||||
t.Errorf("expected RevokeAllByUser never called")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Use case: a failure to revoke keys after a reset is reported", func(t *testing.T) {
|
||||
keys := newMockAPIKeyRepo()
|
||||
keys.fail.failNext("RevokeAllByUser")
|
||||
users := resetUser(apiKeyUnitNow.Add(time.Hour))
|
||||
uc := useCases.NewResetPasswordUseCase(users, &stubHasher{}, keys, apiKeyUnitClock)
|
||||
|
||||
err := uc.ResetPassword("dev@example.com", "reset-code-1", "newpassword1")
|
||||
if !errors.Is(err, errRepositoryFailure) {
|
||||
t.Errorf("expected the revoke failure, got %v", err)
|
||||
}
|
||||
if users.users[2].Password != "hashed:newpassword1" {
|
||||
t.Errorf("expected the password already changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api"
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
"RenewCMS/internal/domain/update"
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
infraupdate "RenewCMS/internal/infrastructure/update"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
type containerHarness struct {
|
||||
*updateHarness
|
||||
fetcher *fakeAssetFetcher
|
||||
requests *fakeRestartRequestStore
|
||||
jobs *fakeJobStore
|
||||
}
|
||||
|
||||
func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *containerHarness {
|
||||
t.Helper()
|
||||
validator.InitValidator()
|
||||
rig := newDownloadRig(t)
|
||||
rig.helper.installed = helperInstalled
|
||||
helper := infraupdate.NewContainerHelperDetector(rig.helper, container)
|
||||
now := func() time.Time { return updateTestCheckedAt }
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
feed := &fakeReleaseFeed{release: update.Release{
|
||||
Tag: "v2.3.0-beta",
|
||||
URL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta",
|
||||
PublishedAt: time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC),
|
||||
Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}},
|
||||
}}
|
||||
rig.store.found = true
|
||||
start := useCases.NewStartUpdateDownloadUseCase(useCases.StartUpdateDownloadDependencies{
|
||||
Store: rig.store,
|
||||
Jobs: rig.jobs,
|
||||
Helper: helper,
|
||||
Results: rig.results,
|
||||
Requests: rig.requests,
|
||||
Fetcher: rig.fetcher,
|
||||
Verifier: nil,
|
||||
Runner: rig.runner,
|
||||
Backup: rig.backup,
|
||||
Staging: rig.staging,
|
||||
Logger: rig.logger,
|
||||
CurrentVersion: updateRunning,
|
||||
Platform: rig.platform,
|
||||
FeedURL: downloadFeedURL,
|
||||
Now: now,
|
||||
})
|
||||
routes := &api.UseCases{
|
||||
Token: token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now),
|
||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now),
|
||||
StartUpdateDownloadUseCase: start,
|
||||
RequestRestartUseCase: useCases.NewRequestRestartUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, now),
|
||||
}
|
||||
return &containerHarness{
|
||||
updateHarness: &updateHarness{router: api.NewSystemRouter(routes), token: token, feed: feed, store: rig.store},
|
||||
fetcher: rig.fetcher,
|
||||
requests: rig.requests,
|
||||
jobs: rig.jobs,
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerUpdateGuidanceHTTP(t *testing.T) {
|
||||
t.Run("The status carries the container flag", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
value string
|
||||
flag bool
|
||||
}{{"true", true}, {"TRUE", true}, {"false", false}, {"", false}, {"yes", false}}
|
||||
for _, tc := range cases {
|
||||
t.Run("value "+tc.value, func(t *testing.T) {
|
||||
t.Setenv("SMTP_PORT", "1")
|
||||
t.Setenv("AUTH_RATE_LIMIT_MAX_ATTEMPTS", "1")
|
||||
t.Setenv("AUTH_RATE_LIMIT_WINDOW_SECONDS", "1")
|
||||
t.Setenv("RENEWCMS_CONTAINER", tc.value)
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.Container != tc.flag {
|
||||
t.Fatalf("Container = %v, want %v", settings.Container, tc.flag)
|
||||
}
|
||||
h := newContainerHarness(t, settings.Container, false)
|
||||
|
||||
body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId))
|
||||
|
||||
assertUpdateKeys(t, body)
|
||||
if body["container"] != tc.flag {
|
||||
t.Errorf("container = %v, want %v", body["container"], tc.flag)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Container mode disables apply even with a helper", func(t *testing.T) {
|
||||
h := newContainerHarness(t, true, true)
|
||||
h.store.result = newStoredUpdateResult()
|
||||
|
||||
for name, userId := range map[string]uint32{"owner": updateOwnerId, "admin": updateAdminId} {
|
||||
body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", userId))
|
||||
|
||||
if body["can_apply"] != false || body["container"] != true {
|
||||
t.Errorf("%s: body = %v", name, body)
|
||||
}
|
||||
if body["available"] != true || body["latest"] != "v2.3.0-beta" || body["notes"] == nil {
|
||||
t.Errorf("%s: body = %v", name, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Host installs are unchanged", func(t *testing.T) {
|
||||
h := newContainerHarness(t, false, true)
|
||||
h.store.result = newStoredUpdateResult()
|
||||
|
||||
body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId))
|
||||
|
||||
if body["container"] != false || body["can_apply"] != true {
|
||||
t.Errorf("body = %v", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The check still works in a container", func(t *testing.T) {
|
||||
h := newContainerHarness(t, true, true)
|
||||
h.store.found = false
|
||||
|
||||
rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
|
||||
|
||||
body := decodeUpdateBody(t, rec)
|
||||
if rec.Code != http.StatusOK || body["available"] != true || body["container"] != true {
|
||||
t.Fatalf("status = %d body = %v", rec.Code, body)
|
||||
}
|
||||
if h.store.saves != 1 || h.store.result.Latest != "v2.3.0-beta" {
|
||||
t.Errorf("saves = %d result = %+v", h.store.saves, h.store.result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Download and restart are refused in a container", func(t *testing.T) {
|
||||
for _, path := range []string{"/update/download", "/update/restart"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
h := newContainerHarness(t, true, true)
|
||||
h.store.result = newStoredUpdateResult()
|
||||
|
||||
rec := h.sendAs(t, http.MethodPost, path, updateOwnerId)
|
||||
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, found := h.jobs.Load(); found {
|
||||
t.Error("no job may start")
|
||||
}
|
||||
if len(h.fetcher.calls) != 0 {
|
||||
t.Errorf("fetcher calls = %v", h.fetcher.calls)
|
||||
}
|
||||
if h.requests.writes != 0 {
|
||||
t.Errorf("restart request writes = %d", h.requests.writes)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestContainerHelperDetectorTruthTable(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
container, inner, want bool
|
||||
}{{false, false, false}, {false, true, true}, {true, false, false}, {true, true, false}} {
|
||||
got := infraupdate.NewContainerHelperDetector(&fakeHelperDetector{installed: tc.inner}, tc.container).Installed()
|
||||
if got != tc.want {
|
||||
t.Errorf("container=%v inner=%v: Installed() = %v, want %v", tc.container, tc.inner, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerStatusUseCaseCanApplyTruthTable(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
isOwner, container, helper, want bool
|
||||
}{
|
||||
{true, false, true, true},
|
||||
{false, false, true, false},
|
||||
{true, true, true, false},
|
||||
{true, false, false, false},
|
||||
{false, true, true, false},
|
||||
} {
|
||||
rig := newDownloadRig(t)
|
||||
rig.helper.installed = tc.helper
|
||||
status := useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, infraupdate.NewContainerHelperDetector(rig.helper, tc.container), rig.results, rig.requests, updateRunning, tc.container, func() time.Time { return updateTestCheckedAt }).GetStatus(tc.isOwner)
|
||||
if status.CanApply != tc.want || status.Container != tc.container {
|
||||
t.Errorf("%+v: CanApply=%v Container=%v", tc, status.CanApply, status.Container)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user