refac: rethought the complete workflow and deployement flow ; docs etc.
This commit is contained in:
1 parent
4a6209a8db
commit
09bd5afa4a
26 files changed
+800
-334
No files matched your search
@@ -0,0 +1,39 @@
|
||||
# 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/`.
|
||||
|
||||
## 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.html` is never cached.
|
||||
- **Development:** built with `-tags nofrontend` (`assets_stub.go`, `assets.Dist == nil`). Vite serves the SPA on :5173 and proxies `/v1` to the API on :8080. See [development.md](development.md).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Configuration
|
||||
|
||||
RenewCMS reads its configuration from environment variables. On startup it also loads a `.env` file from the working directory (or its parent) if one exists. Real environment variables take precedence over `.env`.
|
||||
|
||||
Copy [`.env.example`](../.env.example) to `.env` to get started. When a required variable is missing, the server refuses to start and names the variable.
|
||||
|
||||
## Variables
|
||||
|
||||
### Required
|
||||
|
||||
| Name | Description | Example |
|
||||
|-----------------|---------------------------------------------------------------------------------------------------------------------|----------------------------|
|
||||
| `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. | `https://cms.example.com` |
|
||||
| `JWT_SECRET` | Secret used to sign the auth JWT. Use a long random value; changing it logs everyone out. | `openssl rand -hex 32` |
|
||||
| `SMTP_EMAIL` | Sender address and SMTP username. | `noreply@example.com` |
|
||||
| `SMTP_PASSWORD` | SMTP password. | |
|
||||
| `SMTP_HOST` | SMTP server host. | `smtp.example.com` |
|
||||
| `SMTP_PORT` | SMTP server port. | `465` |
|
||||
|
||||
### Optional
|
||||
|
||||
| Name | Default | Description |
|
||||
|------------------------|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `ENVIRONMENT` | `production` | `production` or `development`. In `production` the auth cookie is `Secure`, so it is only sent over HTTPS. `development` turns on HTTP request logging. |
|
||||
| `PORT` | `8080` | Port the HTTP server listens on (all interfaces). |
|
||||
| `DB_FILE` | `./data/renewcms.db` | Path to the SQLite database file. Missing parent directories are created. The schema is migrated automatically at startup. |
|
||||
| `UPLOAD_DIR` | `./data/uploads` | Directory where uploaded images are stored. |
|
||||
| `CORS_ALLOWED_ORIGINS` | *(empty)* | Semicolon-separated list of origins allowed to call the API. When empty, no CORS headers are sent. That is the right setting whenever the SPA is served by RenewCMS itself (production) or through the Vite proxy (development). |
|
||||
|
||||
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
|
||||
|
||||
## Adding a variable
|
||||
|
||||
1. Read it in the code where it is needed (infrastructure or `api/`, never `internal/domain`).
|
||||
2. Register it in [`cmd/server/main.go`](../cmd/server/main.go): add it to `requiredEnvVars` if it is required, or to `envDefaults` if it has a default.
|
||||
3. Add it to [`.env.example`](../.env.example) and to the tables above.
|
||||
|
||||
Never hardcode a secret, and never commit `.env`.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Deployment
|
||||
|
||||
RenewCMS ships as **one self-contained executable**. It serves:
|
||||
|
||||
| Path | What |
|
||||
|------------|-----------------------------------------|
|
||||
| `/v1/*` | JSON API |
|
||||
| `/healthz` | Liveness check, returns `200 ok` |
|
||||
| `/*` | The Vue SPA (embedded), with fallback to `index.html` for client-side routes |
|
||||
|
||||
Its only runtime state is the SQLite database (`DB_FILE`) and the uploads directory (`UPLOAD_DIR`). Nothing else needs to be installed on the server.
|
||||
|
||||
## 1. Build
|
||||
|
||||
On any machine with the [dev prerequisites](development.md#prerequisites):
|
||||
|
||||
```sh
|
||||
task build # this OS/arch → bin/renewcms[.exe]
|
||||
task release # all targets → bin/renewcms_<version>_<os>_<arch>[.exe]
|
||||
```
|
||||
|
||||
The frontend is built once and embedded into every binary. The binaries are static (`CGO_ENABLED=0`), so a Linux build runs on any distribution. The version comes from `git describe`: tag a release (`git tag v1.2.0`) before building, and the binary logs its version at startup.
|
||||
|
||||
## 2. Lay out the server
|
||||
|
||||
The following is a suggested layout for Linux:
|
||||
|
||||
```
|
||||
/opt/renewcms/renewcms the binary
|
||||
/etc/renewcms/renewcms.env configuration (chmod 600, owned by root)
|
||||
/var/lib/renewcms/ data: renewcms.db, uploads/ (owned by the service user)
|
||||
```
|
||||
|
||||
```sh
|
||||
sudo useradd --system --home /var/lib/renewcms --shell /usr/sbin/nologin renewcms
|
||||
sudo install -d -o renewcms -g renewcms /var/lib/renewcms
|
||||
sudo install -D -m 755 bin/renewcms_v1.2.0_linux_amd64 /opt/renewcms/renewcms
|
||||
sudo install -D -m 600 /dev/null /etc/renewcms/renewcms.env # then edit it
|
||||
```
|
||||
|
||||
`/etc/renewcms/renewcms.env` looks like this (see [configuration.md](configuration.md) for every variable):
|
||||
|
||||
```sh
|
||||
ENVIRONMENT=production
|
||||
PUBLIC_URL=https://cms.example.com
|
||||
PORT=8080
|
||||
DB_FILE=/var/lib/renewcms/renewcms.db
|
||||
UPLOAD_DIR=/var/lib/renewcms/uploads
|
||||
JWT_SECRET=<openssl rand -hex 32>
|
||||
SMTP_EMAIL=noreply@example.com
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=465
|
||||
```
|
||||
|
||||
## 3. Run it as a service (systemd)
|
||||
|
||||
`/etc/systemd/system/renewcms.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=RenewCMS
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=renewcms
|
||||
Group=renewcms
|
||||
WorkingDirectory=/var/lib/renewcms
|
||||
EnvironmentFile=/etc/renewcms/renewcms.env
|
||||
ExecStart=/opt/renewcms/renewcms
|
||||
Restart=on-failure
|
||||
# RenewCMS shuts down gracefully on SIGTERM (up to 10 s to finish requests and close the DB)
|
||||
TimeoutStopSec=15
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/lib/renewcms
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```sh
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now renewcms
|
||||
journalctl -u renewcms -f
|
||||
curl -fsS http://localhost:8080/healthz
|
||||
```
|
||||
|
||||
On Windows, register the binary as a service with a wrapper such as [NSSM](https://nssm.cc/) or [WinSW](https://github.com/winsw/winsw), and set the same environment variables.
|
||||
|
||||
## 4. Put HTTPS in front
|
||||
|
||||
With `ENVIRONMENT=production` the auth cookie is `Secure`, so **the site must be served over HTTPS**. Terminate TLS in a reverse proxy and keep RenewCMS on localhost.
|
||||
|
||||
[Caddy](https://caddyserver.com) handles certificates automatically:
|
||||
|
||||
```caddyfile
|
||||
cms.example.com {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
nginx:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name cms.example.com;
|
||||
# ssl_certificate ... (e.g. certbot)
|
||||
|
||||
client_max_body_size 20m; # image uploads
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If RenewCMS runs on the same host as the proxy, consider firewalling port 8080.
|
||||
|
||||
## Backups
|
||||
|
||||
Everything worth backing up is under the data directory. Take a consistent SQLite copy while the service is running:
|
||||
|
||||
```sh
|
||||
sqlite3 /var/lib/renewcms/renewcms.db ".backup '/backups/renewcms-$(date +%F).db'"
|
||||
rsync -a /var/lib/renewcms/uploads/ /backups/uploads/
|
||||
```
|
||||
|
||||
Alternatively, run `systemctl stop renewcms`, copy the directory, and run `systemctl start renewcms`.
|
||||
|
||||
## Upgrading
|
||||
|
||||
1. Back up (see above). The schema is migrated automatically at startup (GORM `AutoMigrate`), and there is no down-migration.
|
||||
2. Replace `/opt/renewcms/renewcms` with the new binary.
|
||||
3. Run `sudo systemctl restart renewcms`, then check `journalctl -u renewcms` for the new version line.
|
||||
|
||||
To roll back, put the previous binary back and restore the database backup if the schema changed.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Development
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version | Install |
|
||||
|-------------------------------|----------------------------|----------------------------------------------------------|
|
||||
| [Go](https://go.dev/dl/) | 1.25+ (see `go.mod`) | |
|
||||
| [Node.js](https://nodejs.org) | 20.19+ or 22.12+ (22 LTS recommended) | `nvm install 22` — Vite and vue-tsc run on Node |
|
||||
| [Bun](https://bun.sh) (package manager and script runner) | 1.x | `powershell -c "irm bun.sh/install.ps1 \| iex"` or `curl -fsSL https://bun.sh/install \| bash` |
|
||||
| [Task](https://taskfile.dev) | 3.43+ | `go install github.com/go-task/task/v3/cmd/task@latest` |
|
||||
| Git | any | |
|
||||
|
||||
`task setup` installs the rest: [air](https://github.com/air-verse/air) for hot reload, [gremlins](https://github.com/go-gremlins/gremlins) for mutation testing, Go modules and frontend dependencies. Make sure `$(go env GOPATH)/bin` is on your `PATH`.
|
||||
|
||||
No C compiler is needed. The SQLite driver (`glebarez/sqlite`, backed by `modernc.org/sqlite`) is pure Go, and every build runs with `CGO_ENABLED=0`.
|
||||
|
||||
## First run
|
||||
|
||||
```sh
|
||||
cp .env.example .env # set at least JWT_SECRET; SMTP is only needed to actually send mail
|
||||
task setup
|
||||
task dev
|
||||
```
|
||||
|
||||
Open **http://localhost:5173**.
|
||||
|
||||
## How the dev setup works
|
||||
|
||||
```
|
||||
browser ──▶ http://localhost:5173 Vite dev server (HMR) task dev:web
|
||||
│ /v1/*, /healthz
|
||||
└──proxy──▶ http://localhost:8080 Go API via air task dev:api
|
||||
```
|
||||
|
||||
- **Always use the Vite URL (5173).** The Vite dev server proxies API calls to Go (see `server.proxy` in [`web/vite.config.ts`](../web/vite.config.ts)). The browser therefore sees a single origin, so the `jwt` cookie works and no CORS setup is needed. In the frontend, call the API with relative URLs (`/v1/...`) and never hardcode `localhost:8080`.
|
||||
- **air** rebuilds and restarts the API when a `.go` or `.html` file (such as a mail template) changes. Its config is in [`.air.toml`](../.air.toml).
|
||||
- Run the two halves separately with `task dev:api` and `task dev:web` if you want their logs in separate terminals.
|
||||
|
||||
### The `nofrontend` build tag
|
||||
|
||||
The production binary embeds the built SPA from `internal/infrastructure/assets/dist/` (the `//go:embed` in `assets_embed.go`). That directory only exists after `task build:web`.
|
||||
|
||||
To keep Go work independent of the frontend toolchain, all dev and quality tasks build with **`-tags nofrontend`**. That tag compiles `assets_stub.go` instead of the embed. The resulting binary serves the API normally and answers `/` with a notice that points to Vite.
|
||||
|
||||
| Command | Tag | Needs the frontend built? |
|
||||
|------------------------------------|----------------|---------------------------|
|
||||
| `task dev:api`, `test`, `cover`, `mutate`, `lint:go` | `nofrontend` | no |
|
||||
| `task build`, `release`, `run` | *(none)* | yes (the task builds it first) |
|
||||
| plain `go build ./...`, `go vet ./...` | *(none)* | yes, so add `-tags nofrontend` |
|
||||
|
||||
If your editor reports `pattern all:dist: no matching files found`, either run `task build:web` once or set the gopls build flags to `-tags=nofrontend`. In VS Code, add this to `settings.json`: `"gopls": { "buildFlags": ["-tags=nofrontend"] }`.
|
||||
|
||||
## Everyday commands
|
||||
|
||||
```sh
|
||||
task --list # everything available
|
||||
task test # Go tests; extra args pass through: task test -- -run TestCreateArticle -v
|
||||
task cover # coverage of the use cases → tmp/coverage.out
|
||||
task mutate # gremlins mutation testing → tmp/gremlins.json
|
||||
task lint # gofmt check, go vet, eslint --fix, vue-tsc
|
||||
task fmt # gofmt -w + prettier
|
||||
task run # build the production binary and run it (SPA embedded) on :8080
|
||||
task clean # remove bin/, tmp/ and the built SPA
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
- All Go tests live in [`test/`](../test) (package `test`, black-box). They use in-memory fakes of the domain `Repository` interfaces, so they need no database, SMTP server or network.
|
||||
- HTTP-level tests use `net/http/httptest` against a chi router. Call `validator.InitValidator()` before invoking a handler.
|
||||
- The frontend has no test runner yet. `task lint:web` runs eslint and a type check.
|
||||
|
||||
## Data
|
||||
|
||||
With the default configuration, local state lives in `./data/`: `renewcms.db` and `uploads/`. The directory is gitignored. Delete it to start from scratch.
|
||||
Reference in new issue
Block a user