Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1e73bda81 | ||
|
|
508a8ab77e | ||
|
|
1e5f324579 | ||
|
|
1d4f4bd2cc | ||
|
|
d8f171b8cc | ||
|
|
8ad3941f6d | ||
|
|
d275ba3c14 |
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
|
||||
@@ -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:
|
||||
|
||||
@@ -151,7 +151,7 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
|
||||
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(),
|
||||
@@ -197,7 +197,7 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||
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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -280,6 +280,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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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=
|
||||
@@ -30,6 +30,7 @@ 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`. |
|
||||
|
||||
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
|
||||
|
||||
+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:
|
||||
|
||||
+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.
|
||||
@@ -31,3 +31,6 @@ Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create -
|
||||
| `v2.6.2-beta` | v2.6.2-beta - English and French interface |
|
||||
| `v2.7.0-beta` | v2.7.0-beta - Markdown and block article bodies |
|
||||
| `v2.8.0-beta` | v2.8.0-beta - API keys and API reference |
|
||||
| `v2.8.1-beta` | v2.8.1-beta - Docker deployment |
|
||||
| `v2.8.2-beta` | v2.8.2-beta - Security headers and clearer log in errors |
|
||||
| `v2.8.3-beta` | v2.8.3-beta - Security policy fixes for the web app |
|
||||
@@ -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,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.
|
||||
@@ -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,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()
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/frontend"
|
||||
"RenewCMS/api/middleware"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func serveWithSecurityHeaders(hsts bool) http.Header {
|
||||
handler := middleware.SecurityHeaders(hsts)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
return rec.Result().Header
|
||||
}
|
||||
|
||||
func TestSecurityHeadersAlwaysSetBaseline(t *testing.T) {
|
||||
headers := serveWithSecurityHeaders(false)
|
||||
|
||||
expected := map[string]string{
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
}
|
||||
for name, want := range expected {
|
||||
if got := headers.Get(name); got != want {
|
||||
t.Errorf("expected %s %q, got %q", name, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersContentSecurityPolicy(t *testing.T) {
|
||||
csp := serveWithSecurityHeaders(false).Get("Content-Security-Policy")
|
||||
|
||||
for _, directive := range []string{
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'wasm-unsafe-eval';",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
} {
|
||||
if !strings.Contains(csp, directive) {
|
||||
t.Errorf("expected CSP to contain %q, got %q", directive, csp)
|
||||
}
|
||||
}
|
||||
if strings.Contains(csp, "'unsafe-eval'") || strings.Contains(csp, "script-src 'self' 'unsafe-inline'") {
|
||||
t.Errorf("script-src must not allow unsafe sources, got %q", csp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowInlineScriptsHashesOnlyInlineScripts(t *testing.T) {
|
||||
policy := "default-src 'self'; script-src 'self'; object-src 'none'"
|
||||
html := []byte(`<html><head><script>var a=1</script><script type="module" src="/a.js"></script><script> </script></head></html>`)
|
||||
|
||||
got := frontend.AllowInlineScripts(policy, html)
|
||||
|
||||
if !strings.Contains(got, "script-src 'self' 'sha256-") || strings.Count(got, "sha256-") != 1 {
|
||||
t.Errorf("expected exactly one inline hash in script-src, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "; object-src 'none'") {
|
||||
t.Errorf("expected other directives untouched, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowInlineScriptsLeavesPolicyWithoutInlineScripts(t *testing.T) {
|
||||
policy := "script-src 'self'"
|
||||
html := []byte(`<script type="module" src="/a.js"></script>`)
|
||||
|
||||
if got := frontend.AllowInlineScripts(policy, html); got != policy {
|
||||
t.Errorf("expected policy unchanged, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersHSTSOnlyWhenEnabled(t *testing.T) {
|
||||
if got := serveWithSecurityHeaders(false).Get("Strict-Transport-Security"); got != "" {
|
||||
t.Errorf("expected no HSTS header, got %q", got)
|
||||
}
|
||||
if got := serveWithSecurityHeaders(true).Get("Strict-Transport-Security"); got != "max-age=31536000" {
|
||||
t.Errorf("expected HSTS max-age=31536000, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -386,7 +386,7 @@ func (r *downloadRig) rebuild() {
|
||||
FeedURL: downloadFeedURL,
|
||||
Now: func() time.Time { return updateTestCheckedAt },
|
||||
})
|
||||
r.status = useCases.NewGetUpdateStatusUseCase(r.store, r.jobs, r.helper, r.results, r.requests, updateRunning, time.Now)
|
||||
r.status = useCases.NewGetUpdateStatusUseCase(r.store, r.jobs, r.helper, r.results, r.requests, updateRunning, false, time.Now)
|
||||
}
|
||||
|
||||
func (r *downloadRig) checksumsFor(name string, content []byte) string {
|
||||
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
updateRunning = "v2.2.0-beta"
|
||||
)
|
||||
|
||||
var updateStatusKeys = []string{"available", "can_apply", "checked_at", "current", "error", "finished_at", "latest", "notes", "progress", "release_url", "started_at", "state"}
|
||||
var updateStatusKeys = []string{"available", "can_apply", "checked_at", "container", "current", "error", "finished_at", "latest", "notes", "progress", "release_url", "started_at", "state"}
|
||||
|
||||
type updateHarness struct {
|
||||
router http.Handler
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func newUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, running string) *useCases.GetUpdateStatusUseCase {
|
||||
return useCases.NewGetUpdateStatusUseCase(store, jobs, helper, &fakeResultStore{}, &fakeRestartRequestStore{}, running, time.Now)
|
||||
return useCases.NewGetUpdateStatusUseCase(store, jobs, helper, &fakeResultStore{}, &fakeRestartRequestStore{}, running, false, time.Now)
|
||||
}
|
||||
|
||||
type fakeRestartRequestStore struct {
|
||||
|
||||
@@ -58,7 +58,7 @@ func newRestartHTTP(t *testing.T) *restartHTTP {
|
||||
Token: h.token,
|
||||
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
|
||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(
|
||||
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, h.clock.Now),
|
||||
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, false, h.clock.Now),
|
||||
RequestRestartUseCase: useCases.NewRequestRestartUseCase(
|
||||
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, h.clock.Now),
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func newRestartRig(running string) *restartRig {
|
||||
clock: &fakeClock{now: restartBaseTime},
|
||||
}
|
||||
rig.request = useCases.NewRequestRestartUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, rig.clock.Now)
|
||||
rig.status = useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, rig.clock.Now)
|
||||
rig.status = useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, false, rig.clock.Now)
|
||||
return rig
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,13 @@ import { t } from '@/i18n'
|
||||
import { computed } from 'vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
|
||||
const props = defineProps<{
|
||||
line: string
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
line: string
|
||||
showNote?: boolean
|
||||
}>(),
|
||||
{ showNote: true },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: []
|
||||
@@ -50,7 +54,7 @@ async function copyLine() {
|
||||
{{ buttonLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
<span class="text-xs/5 text-muted">{{
|
||||
<span v-if="showNote" class="text-xs/5 text-muted">{{
|
||||
t('They run this once on the server. After that, Update and Restart appear here.')
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,11 @@ import type { RestartKind, UpdateStatus } from '@/composables/useUpdate'
|
||||
import type { UserRole } from '@/composables/useApi'
|
||||
import type { UpdateApplyPhase, UpdateCheckPhase } from '@/stores/update'
|
||||
import {
|
||||
CONTAINER_UPDATE_COMMAND,
|
||||
ENABLE_BROWSER_UPDATES_COMMAND,
|
||||
VERIFYING_ANNOUNCEMENT,
|
||||
browserUpdatesDocsUrl,
|
||||
containerUpdatesDocsUrl,
|
||||
displayVersion,
|
||||
downloadAnnouncement,
|
||||
formatDownloadProgress,
|
||||
@@ -383,6 +385,21 @@ const block = computed<Block>(() => {
|
||||
meta: props.status.finished_at ? formatUpdatedAt(props.status.finished_at) : undefined,
|
||||
}
|
||||
}
|
||||
if (showContainerGuidance.value) {
|
||||
return {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
get title() {
|
||||
return t('Version {0} is available', [newVersion.value])
|
||||
},
|
||||
get text() {
|
||||
return t(
|
||||
'You have {0}. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server.',
|
||||
[props.status.current],
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
if (props.status.available && isOwner.value) {
|
||||
return {
|
||||
tone: 'info',
|
||||
@@ -435,9 +452,15 @@ const restartAvailable = computed(
|
||||
() => canApplyView.value && (props.status.state === 'idle' || isDone.value),
|
||||
)
|
||||
|
||||
const showContainerGuidance = computed(
|
||||
() =>
|
||||
isOwner.value && props.status.container && props.status.available && props.phase === 'idle',
|
||||
)
|
||||
|
||||
const showServerGuidance = computed(
|
||||
() =>
|
||||
isOwner.value &&
|
||||
!props.status.container &&
|
||||
!props.status.can_apply &&
|
||||
props.status.available &&
|
||||
props.phase === 'idle',
|
||||
@@ -550,7 +573,10 @@ function onAction(button: ActionButton) {
|
||||
|
||||
const showNotes = computed(
|
||||
() =>
|
||||
showServerGuidance.value || applyView.value === 'available' || applyView.value === 'ready',
|
||||
showServerGuidance.value ||
|
||||
showContainerGuidance.value ||
|
||||
applyView.value === 'available' ||
|
||||
applyView.value === 'ready',
|
||||
)
|
||||
|
||||
const notes = computed(() => {
|
||||
@@ -625,6 +651,12 @@ const notes = computed(() => {
|
||||
|
||||
<ServerCommand v-if="showServerGuidance" :line="ENABLE_BROWSER_UPDATES_COMMAND" />
|
||||
|
||||
<ServerCommand
|
||||
v-if="showContainerGuidance"
|
||||
:line="CONTAINER_UPDATE_COMMAND"
|
||||
:show-note="false"
|
||||
/>
|
||||
|
||||
<div v-if="rowButtons.length" class="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<UButton
|
||||
v-for="button in rowButtons"
|
||||
@@ -660,6 +692,17 @@ const notes = computed(() => {
|
||||
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="showContainerGuidance && status.release_url"
|
||||
:href="containerUpdatesDocsUrl(status.release_url)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
|
||||
>
|
||||
{{ t('How to update') }}
|
||||
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
v-if="isDone && status.release_url"
|
||||
:href="status.release_url"
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface UpdateStatus {
|
||||
error: UpdateErrorKind | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
container: boolean
|
||||
}
|
||||
|
||||
export function useUpdate() {
|
||||
|
||||
@@ -54,6 +54,9 @@ export const messages = {
|
||||
'Logging in…': 'Connexion…',
|
||||
'Try again in {0}': 'Réessayez dans {0}',
|
||||
'Use at least 3 characters.': 'Utilisez au moins 3 caractères.',
|
||||
'Use your username, not your email address.':
|
||||
"Utilisez votre nom d'utilisateur, pas votre adresse e-mail.",
|
||||
'Use 20 characters or fewer.': 'Utilisez 20 caractères au maximum.',
|
||||
'Enter your password.': 'Saisissez votre mot de passe.',
|
||||
'Hide password': 'Masquer le mot de passe',
|
||||
'Show password': 'Afficher le mot de passe',
|
||||
@@ -629,6 +632,8 @@ export const messages = {
|
||||
"This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.":
|
||||
'Ce site ne peut pas encore se mettre à jour depuis le navigateur. La personne qui gère votre serveur peut l’activer ou mettre à jour RenewCMS pour vous.',
|
||||
'How to update': 'Comment mettre à jour',
|
||||
'You have {0}. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server.':
|
||||
'Vous avez {0}. Ce site fonctionne dans Docker : il se met à jour en récupérant une nouvelle image. Exécutez les commandes ci-dessous sur le serveur.',
|
||||
'For whoever manages the server': 'Pour la personne qui gère le serveur',
|
||||
'They run this once on the server. After that, Update and Restart appear here.':
|
||||
'Cette commande s’exécute une fois sur le serveur. Les actions de mise à jour et de redémarrage apparaissent ensuite ici.',
|
||||
|
||||
@@ -72,13 +72,23 @@ export function downloadAnnouncement(version: string, percent: number) {
|
||||
export const VERIFYING_ANNOUNCEMENT = 'Checking the download'
|
||||
|
||||
const BROWSER_UPDATES_DOCS_PATH = '/src/branch/main/docs/deployment.md#browser-updates'
|
||||
const CONTAINER_UPDATES_DOCS_PATH = '/src/branch/main/docs/docker.md'
|
||||
export const CONTAINER_UPDATE_COMMAND = 'docker compose pull && docker compose up -d'
|
||||
const RELEASES_SEGMENT = /^(.*)\/releases\//
|
||||
|
||||
export function browserUpdatesDocsUrl(releaseUrl: string) {
|
||||
return docsUrl(releaseUrl, BROWSER_UPDATES_DOCS_PATH)
|
||||
}
|
||||
|
||||
export function containerUpdatesDocsUrl(releaseUrl: string) {
|
||||
return docsUrl(releaseUrl, CONTAINER_UPDATES_DOCS_PATH)
|
||||
}
|
||||
|
||||
function docsUrl(releaseUrl: string, docsPath: string) {
|
||||
try {
|
||||
const url = new URL(releaseUrl)
|
||||
const repository = RELEASES_SEGMENT.exec(url.pathname)
|
||||
return repository ? `${url.origin}${repository[1]}${BROWSER_UPDATES_DOCS_PATH}` : releaseUrl
|
||||
return repository ? `${url.origin}${repository[1]}${docsPath}` : releaseUrl
|
||||
} catch {
|
||||
return releaseUrl
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module 'virtual:lucide-subset' {
|
||||
import type { IconifyJSON } from '@iconify/types'
|
||||
const subset: IconifyJSON
|
||||
export default subset
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ui from '@nuxt/ui/vue-plugin'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
import '@/assets/main.css'
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
|
||||
@@ -134,8 +134,22 @@ function focusFirstInvalid() {
|
||||
}
|
||||
}
|
||||
|
||||
function usernameProblem(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.includes('@')) {
|
||||
return 'Use your username, not your email address.'
|
||||
}
|
||||
if (trimmed.length < 3) {
|
||||
return 'Use at least 3 characters.'
|
||||
}
|
||||
if (trimmed.length > 20) {
|
||||
return 'Use 20 characters or fewer.'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function validate() {
|
||||
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
||||
usernameError.value = usernameProblem(username.value)
|
||||
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
|
||||
return !usernameError.value && !passwordError.value
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ function neverCheckedStatus(): UpdateStatus {
|
||||
error: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
container: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,25 @@ describe('Validation errors — nothing is sent for a short username and an empt
|
||||
})
|
||||
})
|
||||
|
||||
describe('Validation errors — specific username messages', () => {
|
||||
it.each([
|
||||
['marta@example.com', 'Use your username, not your email address.'],
|
||||
['a'.repeat(21), 'Use 20 characters or fewer.'],
|
||||
])('explains why %s is rejected without calling the API', async (value, message) => {
|
||||
const login = vi.fn(() => mockResponse(200))
|
||||
const wrapper = await mountLogin('/login', {
|
||||
'POST /v1/auth/login': login,
|
||||
})
|
||||
|
||||
await wrapper.find('input[placeholder="Your username"]').setValue(value)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(login).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Submitting — fields and button are disabled while the request is in flight', () => {
|
||||
it('disables the fields and shows the loading label', async () => {
|
||||
let resolveLogin: (() => void) | null = null
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useApi, type UserRole } from '@/composables/useApi'
|
||||
import type { UpdateStatus } from '@/composables/useUpdate'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
import {
|
||||
RUNNING_VERSION,
|
||||
STATUS_PATH,
|
||||
findButton,
|
||||
newerAvailable,
|
||||
stubClipboard,
|
||||
upToDate,
|
||||
visibleButtonLabels,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return {
|
||||
...actual,
|
||||
useApi: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const CONTAINER_COMMAND = 'docker compose pull && docker compose up -d'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
async function mountSettings(role: UserRole, status: UpdateStatus) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, role }
|
||||
session.status = 'ready'
|
||||
const request = vi.fn((path: string) => {
|
||||
if (path === '/v1/settings/') {
|
||||
return Promise.resolve({ version: RUNNING_VERSION })
|
||||
}
|
||||
if (path === STATUS_PATH) {
|
||||
return Promise.resolve(status)
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role,
|
||||
}),
|
||||
request,
|
||||
})
|
||||
await router.push('/settings')
|
||||
wrapper = mount(SettingsView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function updatesCard() {
|
||||
return document.body.querySelector('[data-testid="updates-card"]')!
|
||||
}
|
||||
|
||||
function cardText() {
|
||||
return updatesCard().textContent ?? ''
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Settings, owner, container, update available', () => {
|
||||
it('shows the pull-image guidance, the command, Copy and the docs link, with no other button', async () => {
|
||||
await mountSettings('owner', newerAvailable({ container: true }))
|
||||
|
||||
expect(cardText()).toContain('Version 2.3.0-beta is available')
|
||||
expect(cardText()).toContain(
|
||||
'You have v2.2.0-beta. This site runs in Docker, so it updates by pulling a new image. Run the commands below on the server.',
|
||||
)
|
||||
expect(updatesCard().querySelector('code')?.textContent).toBe(CONTAINER_COMMAND)
|
||||
const link = Array.from(updatesCard().querySelectorAll('a')).find(
|
||||
(anchor) => anchor.textContent?.trim() === 'How to update',
|
||||
)!
|
||||
expect(link.getAttribute('href')).toBe(
|
||||
'https://git.example/fsylvain/RenewCMS/src/branch/main/docs/docker.md',
|
||||
)
|
||||
expect(visibleButtonLabels(updatesCard())).toEqual(["What's new in 2.3.0-beta", 'Copy'])
|
||||
expect(cardText()).not.toContain('renewcms enable-browser-updates')
|
||||
expect(cardText()).not.toContain('They run this once on the server')
|
||||
expect(cardText()).not.toContain("can't update itself from the browser")
|
||||
})
|
||||
|
||||
it('copies the container command and reads Copied for 2 s', async () => {
|
||||
const writeText = stubClipboard()
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
await mountSettings('owner', newerAvailable({ container: true }))
|
||||
|
||||
findButton('Copy')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(CONTAINER_COMMAND)
|
||||
expect(findButton('Copied')!.getAttribute('aria-live')).toBe('polite')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(findButton('Copy')?.textContent?.trim()).toBe('Copy')
|
||||
expect(findButton('Copied')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, owner, container, up to date', () => {
|
||||
it("shows You're up to date with only Check for updates and no container hint", async () => {
|
||||
await mountSettings('owner', upToDate({ container: true }))
|
||||
|
||||
expect(cardText()).toContain("You're up to date")
|
||||
expect(visibleButtonLabels(updatesCard())).toEqual(['Check for updates'])
|
||||
expect(cardText()).not.toContain('Docker')
|
||||
expect(updatesCard().querySelector('code')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, admin, container', () => {
|
||||
it('keeps the admin view with no command block', async () => {
|
||||
await mountSettings('admin', newerAvailable({ container: true }))
|
||||
|
||||
expect(cardText()).toContain('Only the owner can update RenewCMS.')
|
||||
expect(cardText()).not.toContain('Docker')
|
||||
expect(updatesCard().querySelector('code')).toBeNull()
|
||||
expect(visibleButtonLabels(updatesCard())).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, owner, not container, not prepared', () => {
|
||||
it('still shows the enable-browser-updates command', async () => {
|
||||
await mountSettings('owner', newerAvailable({ container: false }))
|
||||
|
||||
expect(updatesCard().querySelector('code')?.textContent).toBe(
|
||||
'renewcms enable-browser-updates',
|
||||
)
|
||||
expect(cardText()).toContain("This site can't update itself from the browser yet.")
|
||||
expect(cardText()).not.toContain('Docker')
|
||||
})
|
||||
})
|
||||
@@ -37,6 +37,7 @@ export function upToDate(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
|
||||
error: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
container: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vite/**/*.ts",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
|
||||
@@ -5,6 +5,7 @@ import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import ui from '@nuxt/ui/vite'
|
||||
import { renewUi } from './src/theme/ui.config'
|
||||
import { lucideSubset } from './vite/lucideSubset'
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
@@ -21,6 +22,10 @@ export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
lucideSubset([
|
||||
fileURLToPath(new URL('./src', import.meta.url)),
|
||||
fileURLToPath(new URL('./node_modules/@nuxt/ui/dist', import.meta.url)),
|
||||
]),
|
||||
ui({
|
||||
ui: renewUi,
|
||||
colorMode: true,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { icons } from '@iconify-json/lucide'
|
||||
import { getIcons } from '@iconify/utils'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const virtualId = 'virtual:lucide-subset'
|
||||
const resolvedId = `\0${virtualId}`
|
||||
const iconPattern = /i-lucide-([a-z0-9]+(?:-[a-z0-9]+)*)/g
|
||||
const sourceExtensions = /\.(vue|ts|js|mjs)$/
|
||||
|
||||
function walk(dir: string, found: Set<string>) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry)
|
||||
const stat = statSync(full)
|
||||
if (stat.isDirectory()) {
|
||||
if (entry !== '__tests__') walk(full, found)
|
||||
continue
|
||||
}
|
||||
if (!sourceExtensions.test(entry)) continue
|
||||
for (const match of readFileSync(full, 'utf8').matchAll(iconPattern)) {
|
||||
found.add(match[1]!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function lucideSubset(roots: string[]): Plugin {
|
||||
return {
|
||||
name: 'lucide-subset',
|
||||
resolveId(id) {
|
||||
return id === virtualId ? resolvedId : null
|
||||
},
|
||||
load(id) {
|
||||
if (id !== resolvedId) return null
|
||||
const found = new Set<string>()
|
||||
for (const root of roots) walk(root, found)
|
||||
const subset = getIcons(icons, [...found].sort())
|
||||
return `export default ${JSON.stringify(subset)}`
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user