5.4 KiB
Architecture
RenewCMS is a Go HTTP API and a Vue 3 SPA, shipped as a single binary.
Backend layering
Dependencies point inward:
api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain ◀── internal/infrastructure/{persistence,mailer}
| Layer | Location | Rules |
|---|---|---|
| Domain | internal/domain/<name>/ |
Entities, Err… sentinel errors, Repository interfaces. Imports only the stdlib and other domain packages, with no GORM tags. |
| Use cases | internal/infrastructure/useCases/ |
Application logic. Depends only on domain interfaces, never on persistence, mailer, GORM or net/http. |
| Adapters | internal/infrastructure/persistence/{models,mappers,repositories}, internal/infrastructure/mailer |
Implement a domain Repository (assert with var _ domain.Repository = &X{}). GORM structs stay in models and are converted by mappers. |
| Delivery | api/handlers/<name>/, api/router.go |
HTTP handlers receive use cases through NewHandler(...). They never import persistence, mailer or GORM. |
| Composition root | api/dependecyInjection.go |
The only place that opens the DB, builds repositories and use cases, and lists models for AutoMigrate. |
| Entry point | cmd/server/main.go |
Env/config, HTTP server, graceful shutdown. |
Nothing under internal/ imports api/.
Delivery packages
| Package | Role |
|---|---|
api/handlers/user |
Authenticated user endpoints: profile update (UpdateMe) and the users list with invitations (CreateInvite, ListUsers). |
api/handlers/media |
The media library under /v1/media (list, upload, rename, delete, merge, usage). Images belong to the library, not to an article: deleting an article never deletes an image, and an image's "used in" is read from article bodies each time. |
api/pkg/upload |
Multipart file reading and the mapping of upload errors (415, 413, 400) shared by both upload routes. |
api/pkg/publicuser |
PublicUser, the JSON shape of a user with no password, codes or token version. publicuser.New converts a domain user.User, so handlers never serialise the domain struct directly. |
Media purge
internal/domain/purge holds Settings (automatic purge on or off, interval of 1 to 365 days, last_purge_* history, enabled_at), the Repository interface (Get returns the defaults when nothing was saved, Save) and the sentinels ErrInvalidPurgeInterval, ErrPurgeConflict (stale fingerprint) and ErrPurgeBusy (a run is already in progress). Settings.NextPurgeAt is max(last_purge_at, enabled_at) plus the interval when automatic purge is on, and null otherwise. The settings live in a single-row purge_settings table (models.PurgeSettings, mappers.PurgeSettings*, persistence.PurgeSettingsRepository), created by AutoMigrate.
The use cases GetPurgeSettings, UpdatePurgeSettings, PreviewPurge and RunPurge depend only on domain interfaces, an injected clock and an injected *log.Logger, so a scheduler can call them. PreviewPurge and RunPurge share one plan (purgePlan.go): a duplicate group (same non-empty sha256) is planned only when a copy is used in an article, the most used copy is kept (ties: oldest created_at, then lowest id) and every other copy is merged into it. The fingerprint is a SHA-256 over the planned groups. RunPurge takes a non-blocking in-process lock (ErrPurgeBusy for a concurrent call), refuses a non-matching fingerprint with ErrPurgeConflict, merges group by group with MergeMedia (a failing group is counted and skipped), unions the UpdatedArticleIDs of the fully merged groups, and records last_purge_* after every run.
middleware.RequireOwnerOrAdmin lets only the roles owner and admin through (403 otherwise, including an empty role). The media router mounts these routes after the verified chain and before the /{id} routes:
| Route | Purpose |
|---|---|
GET /v1/media/purge-settings |
Settings with next_purge_at. |
PATCH /v1/media/purge-settings |
Partial update of auto_purge_enabled and auto_purge_interval_days. |
GET /v1/media/purge-preview |
Groups, copies, distinct articles, the first 5 groups, more and the fingerprint. |
POST /v1/media/purge |
Runs the purge. Optional fingerprint; 409 when stale or when a run is in progress. |
Image metadata backfill
At boot, BackfillImageMetadata fills filename, size, content_type, width, height and sha256 for image rows that lack them, reading the files in UPLOAD_DIR. It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old article_id column and the article_images table are kept and never written.
Frontend ↔ backend
web/ (Vue, Vite)
│ task build:web (vue-tsc + vite build)
▼
internal/infrastructure/assets/dist/ gitignored build output
│ //go:embed all:dist (assets_embed.go, default build)
▼
bin/renewcms ── /v1/* API
── /healthz
── /* SPA (api/handlers/frontend), index.html fallback
- Production: a single origin. The SPA calls the API with relative URLs, so the JWT cookie works without CORS. Files under
assets/are content-hashed by Vite and cached for a year.index.htmlis never cached. - Development: built with
-tags nofrontend(assets_stub.go,assets.Dist == nil). Vite serves the SPA on :5173 and proxies/v1to the API on :8080. See development.md.