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
@@ -1,51 +1,20 @@
|
|||||||
root = "."
|
root = "."
|
||||||
testdata_dir = "testdata"
|
|
||||||
tmp_dir = "tmp"
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
args_bin = []
|
cmd = "go build -tags nofrontend -o ./tmp/renewcms.exe ./cmd/server"
|
||||||
bin = "tmp/main.exe"
|
bin = "tmp/renewcms.exe"
|
||||||
cmd = "go build -o ./tmp/main.exe ./cmd/server"
|
delay = 500
|
||||||
delay = 1000
|
exclude_dir = ["tmp", "bin", "data", "web", "features", "docs", "internal/infrastructure/assets/dist", ".claude"]
|
||||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "api\\static\\tinymce", "api\\static\\bootstrap-icons", "api\\static\\uploadedImages"]
|
|
||||||
exclude_file = []
|
|
||||||
exclude_regex = ["_test.go"]
|
exclude_regex = ["_test.go"]
|
||||||
exclude_unchanged = false
|
include_ext = ["go", "html"]
|
||||||
follow_symlink = false
|
|
||||||
full_bin = ""
|
|
||||||
include_dir = []
|
|
||||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
|
||||||
include_file = []
|
|
||||||
kill_delay = "0s"
|
|
||||||
log = "build-errors.log"
|
log = "build-errors.log"
|
||||||
poll = false
|
send_interrupt = true
|
||||||
poll_interval = 0
|
kill_delay = "2s"
|
||||||
post_cmd = []
|
stop_on_error = true
|
||||||
pre_cmd = []
|
|
||||||
rerun = false
|
|
||||||
rerun_delay = 500
|
|
||||||
send_interrupt = false
|
|
||||||
stop_on_error = false
|
|
||||||
|
|
||||||
[color]
|
|
||||||
app = ""
|
|
||||||
build = "yellow"
|
|
||||||
main = "magenta"
|
|
||||||
runner = "green"
|
|
||||||
watcher = "cyan"
|
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
main_only = false
|
|
||||||
time = false
|
time = false
|
||||||
|
|
||||||
[misc]
|
[misc]
|
||||||
clean_on_exit = false
|
clean_on_exit = true
|
||||||
|
|
||||||
[proxy]
|
|
||||||
app_port = 0
|
|
||||||
enabled = false
|
|
||||||
proxy_port = 0
|
|
||||||
|
|
||||||
[screen]
|
|
||||||
clear_on_rebuild = false
|
|
||||||
keep_scroll = true
|
|
||||||
+8
-8
@@ -1,12 +1,12 @@
|
|||||||
ENVIRONMENT=development
|
PUBLIC_URL=http://localhost:5173
|
||||||
HOST=example.com
|
JWT_SECRET=change-me-to-a-long-random-string
|
||||||
PORT=8080
|
|
||||||
JWT_SECRET=abc123
|
|
||||||
CORS_ALLOWED_ORIGINS=*
|
|
||||||
DB_FILE=./renewcms.db
|
|
||||||
DOCKER_DB_FOLDER=./data
|
|
||||||
UPLOAD_DIR=./api/static/uploadedImages/
|
|
||||||
SMTP_EMAIL=a@b.c
|
SMTP_EMAIL=a@b.c
|
||||||
SMTP_PASSWORD=12341234
|
SMTP_PASSWORD=12341234
|
||||||
SMTP_HOST=mail.service.abc
|
SMTP_HOST=mail.service.abc
|
||||||
SMTP_PORT=465
|
SMTP_PORT=465
|
||||||
|
|
||||||
|
ENVIRONMENT=development
|
||||||
|
PORT=8080
|
||||||
|
DB_FILE=./data/renewcms.db
|
||||||
|
UPLOAD_DIR=./data/uploads
|
||||||
|
CORS_ALLOWED_ORIGINS=
|
||||||
+4
-1
@@ -1 +1,4 @@
|
|||||||
api/static/** linguist-vendored
|
* text=auto eol=lf
|
||||||
|
*.ico binary
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# This workflow will build a golang project
|
|
||||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
|
|
||||||
|
|
||||||
name: Go
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
pull_request:
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: test
|
|
||||||
env:
|
|
||||||
ENVIRONMENT: ${{ secrets.ENVIRONMENT }}
|
|
||||||
HOST: ${{ secrets.HOST }}
|
|
||||||
PORT: ${{ secrets.PORT }}
|
|
||||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
|
||||||
CORS_ALLOWED_ORIGINS: ${{ secrets.CORS_ALLOWED_ORIGINS }}
|
|
||||||
DB_FILE: ${{ secrets.DB_FILE }}
|
|
||||||
DOCKER_DB_FOLDER: ${{ secrets.DOCKER_DB_FOLDER }}
|
|
||||||
SMTP_EMAIL: ${{ secrets.SMTP_EMAIL }}
|
|
||||||
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
|
||||||
SMTP_HOST: ${{ secrets.SMTP_HOST }}
|
|
||||||
SMTP_PORT: ${{ secrets.SMTP_PORT }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v4
|
|
||||||
with:
|
|
||||||
go-version: "1.23.0"
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: go build -v ./...
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: go test -v ./...
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
# DB
|
# DB
|
||||||
*.db
|
*.db
|
||||||
|
data/
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
bin
|
bin
|
||||||
@@ -14,5 +15,10 @@ dist/
|
|||||||
|
|
||||||
# Generated files
|
# Generated files
|
||||||
tmp/
|
tmp/
|
||||||
|
.task/
|
||||||
|
build-errors.log
|
||||||
uploadedImages/
|
uploadedImages/
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Coding Standards
|
||||||
|
|
||||||
|
These rules apply to every file in this repo , for humans and agents alike.
|
||||||
|
|
||||||
|
## General rules
|
||||||
|
|
||||||
|
1. Follow standard conventions.
|
||||||
|
2. Keep it simple stupid. Simpler is always better. Reduce complexity as much as possible.
|
||||||
|
3. Boy scout rule. Leave the campground cleaner than you found it.
|
||||||
|
4. Always find root cause. Always look for the root cause of a problem.
|
||||||
|
|
||||||
|
## Design rules
|
||||||
|
|
||||||
|
1. Keep configurable data at high levels.
|
||||||
|
2. Prefer polymorphism to if/else or switch/case.
|
||||||
|
3. Separate multi-threading code.
|
||||||
|
4. Prevent over-configurability.
|
||||||
|
5. Use dependency injection.
|
||||||
|
6. Follow Law of Demeter. A class should know only its direct dependencies.
|
||||||
|
|
||||||
|
## Understandability tips
|
||||||
|
|
||||||
|
1. Be consistent. If you do something a certain way, do all similar things in the same way.
|
||||||
|
2. Use explanatory variables.
|
||||||
|
3. Encapsulate boundary conditions. Boundary conditions are hard to keep track of. Put the processing for them in one place.
|
||||||
|
4. Prefer dedicated value objects to primitive types.
|
||||||
|
5. Avoid logical dependency. Don't write methods which work correctly only depending on something else in the same class.
|
||||||
|
6. Avoid negative conditionals.
|
||||||
|
|
||||||
|
## Names rules
|
||||||
|
|
||||||
|
1. Choose descriptive and unambiguous names.
|
||||||
|
2. Make meaningful distinctions.
|
||||||
|
3. Use pronounceable names.
|
||||||
|
4. Use searchable names.
|
||||||
|
5. Replace magic numbers with named constants.
|
||||||
|
6. Avoid encodings. Don't append prefixes or type information.
|
||||||
|
|
||||||
|
## Functions rules
|
||||||
|
|
||||||
|
1. Small.
|
||||||
|
2. Do one thing.
|
||||||
|
3. Use descriptive names.
|
||||||
|
4. Prefer fewer arguments.
|
||||||
|
5. Have no side effects.
|
||||||
|
6. Don't use flag arguments. Split the method into several independent methods that can be called from the client without the flag.
|
||||||
|
|
||||||
|
## Comments rules
|
||||||
|
|
||||||
|
1. No comments in the codebase. This covers Go, TypeScript/Vue, HTML templates and config files (YAML, TOML, `.env.example`, `.gitattributes`, …).
|
||||||
|
2. Explain yourself in code instead: descriptive names, small functions, named constants, explanatory variables.
|
||||||
|
3. The only exceptions are tool directives the code needs to work, such as `//go:build` and `//go:embed`.
|
||||||
|
4. Don't comment out code; just remove it.
|
||||||
|
5. Prose belongs in `docs/` and `README.md`, not in source files.
|
||||||
|
|
||||||
|
## Source code structure
|
||||||
|
|
||||||
|
1. Separate concepts vertically.
|
||||||
|
2. Related code should appear vertically dense.
|
||||||
|
3. Declare variables close to their usage.
|
||||||
|
4. Dependent functions should be close.
|
||||||
|
5. Similar functions should be close.
|
||||||
|
6. Place functions in the downward direction (caller above callee).
|
||||||
|
7. Keep lines short.
|
||||||
|
8. Don't use horizontal alignment.
|
||||||
|
9. Use white space to associate related things and disassociate weakly related ones.
|
||||||
|
10. Don't break indentation.
|
||||||
|
|
||||||
|
## Objects and data structures
|
||||||
|
|
||||||
|
1. Hide internal structure.
|
||||||
|
2. Prefer data structures when there's no behavior to attach.
|
||||||
|
3. Avoid hybrid structures (half object, half data).
|
||||||
|
4. Keep classes small.
|
||||||
|
5. Each class should do one thing.
|
||||||
|
6. Keep the number of instance variables small.
|
||||||
|
7. Base classes should know nothing about their derivatives.
|
||||||
|
8. Prefer many small functions over one function with a parameter that selects behavior.
|
||||||
|
9. Prefer non-static methods to static methods.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
1. One assert per test.
|
||||||
|
2. Readable.
|
||||||
|
3. Fast.
|
||||||
|
4. Independent.
|
||||||
|
5. Repeatable.
|
||||||
|
|
||||||
|
## Code smells to watch for
|
||||||
|
|
||||||
|
1. **Rigidity** — the software is difficult to change; a small change causes a cascade of subsequent changes.
|
||||||
|
2. **Fragility** — the software breaks in many places due to a single change.
|
||||||
|
3. **Immobility** — you cannot reuse parts of the code in other projects because of the risk and effort involved.
|
||||||
|
4. **Needless complexity**.
|
||||||
|
5. **Needless repetition**.
|
||||||
|
6. **Opacity** — the code is hard to understand.
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
FROM golang:1.20.5-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
COPY adapters ./adapters
|
|
||||||
COPY api ./api
|
|
||||||
COPY domain ./domain
|
|
||||||
COPY main ./main
|
|
||||||
COPY useCases ./useCases
|
|
||||||
COPY .env ./.env
|
|
||||||
|
|
||||||
RUN go mod download
|
|
||||||
RUN go build -o startServer ./main
|
|
||||||
|
|
||||||
CMD ["./startServer"]
|
|
||||||
@@ -1,66 +1,43 @@
|
|||||||
# RenewCMS
|
# RenewCMS
|
||||||
|
|
||||||
## 🚧 This project is under development !
|
A small self-hosted CMS: a Go API and a Vue 3 admin/front SPA, shipped as **a single executable** backed by a SQLite file.
|
||||||
|
|
||||||
The features are not complete, not fully tested and subject to many changes.
|
> 🚧 **Under development.** Features are incomplete, not fully tested and subject to change.
|
||||||
|
|
||||||
## Setup
|
## Quick start
|
||||||
|
|
||||||
Don't forget to setup the [Environment variables](#environment-variables)!
|
### Run a release binary
|
||||||
|
|
||||||
### Use with Docker
|
```sh
|
||||||
|
cp .env.example .env # fill in PUBLIC_URL, JWT_SECRET and SMTP_*
|
||||||
#### Compose
|
./renewcms # → http://localhost:8080
|
||||||
|
|
||||||
The easiest way to run the project is to use docker-compose.
|
|
||||||
You still need to set up the environment variables, but you can use the `.env` file for that.
|
|
||||||
Note that some supplementary variables are required for docker-compose to work, like `DOCKER_DB_FOLDER`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up --build -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build and run it yourself
|
Data is stored in `./data/` by default. For a real server (systemd, HTTPS, backups), see **[docs/deployment.md](docs/deployment.md)**.
|
||||||
|
|
||||||
#### Build
|
### Develop
|
||||||
|
|
||||||
There are 2 scripts to build the project:
|
Requires Go 1.25+, Node.js 22, [Bun](https://bun.sh) and [Task](https://taskfile.dev).
|
||||||
|
|
||||||
- `super_build.sh`
|
```sh
|
||||||
- `super_build.bat`
|
cp .env.example .env
|
||||||
|
task setup
|
||||||
|
task dev # API with hot reload + Vite HMR → http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
They will build the project for windows, linux and macOS. The binaries will be in the `bin` folder.
|
## Documentation
|
||||||
You can also really just use `go build` to build the project for your current OS.
|
|
||||||
|
|
||||||
#### Run
|
| Doc | Contents |
|
||||||
|
|---|---|
|
||||||
Once the project is built, you can simply run the binary for your OS. Setting the environment variables is
|
| [docs/development.md](docs/development.md) | Dev setup, how Vite and the API fit together, tests, lint, `task` commands |
|
||||||
of course required, but not necessarily via the `.env` file.
|
| [docs/deployment.md](docs/deployment.md) | Building release binaries, running as a service, HTTPS, backups, upgrades |
|
||||||
|
| [docs/configuration.md](docs/configuration.md) | Every environment variable |
|
||||||
### Environment variables
|
| [docs/architecture.md](docs/architecture.md) | Backend layering and how the SPA is embedded |
|
||||||
|
|
||||||
| Name | Type | Description | Comment |
|
|
||||||
|----------------------|--------|:----------------------------------------------|-----------------------------------------------------------------------------------------------------------------------|
|
|
||||||
| ENVIRONMENT | string | environment the API is running in | required, `development` or `production` |
|
|
||||||
| HOST | string | the host domain name for emails and callbacks | required, if `localhost`: add the port (e.g. `localhost:8080`) |
|
|
||||||
| PORT | int | port the API will use | required |
|
|
||||||
| JWT_SECRET | string | secret for the jwt auth | required |
|
|
||||||
| CORS_ALLOWED_ORIGINS | string | allowed origins for CORS | required, semicolon separated list |
|
|
||||||
| DB_FILE | string | path to the sqlite db file | required, can be at the root but name still required (e.g. `./RenewCMS.db`) ; have to end up with `.db` |
|
|
||||||
| DOCKER_DB_FOLDER | string | path to the sqlite db file's folder | required with docker, this will basically be the host machine folder (e.g. `./data`) that contains the sqlite db file |
|
|
||||||
| SMTP_EMAIL | string | sender email | required |
|
|
||||||
| SMTP_PASSWORD | string | smtp account password | required |
|
|
||||||
| SMTP_HOST | string | smtp server address | required |
|
|
||||||
| SMTP_PORT | int | smtp server port | required |
|
|
||||||
|
|
||||||
## API Usage
|
## API Usage
|
||||||
|
|
||||||
TODO
|
TODO
|
||||||
|
|
||||||
## Demo
|
|
||||||
|
|
||||||
TODO
|
|
||||||
|
|
||||||
## TODOs
|
## TODOs
|
||||||
|
|
||||||
- Cancel button when email validation pending or expired
|
- Cancel button when email validation pending or expired
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
vars:
|
||||||
|
BINARY: renewcms
|
||||||
|
DIST_DIR: internal/infrastructure/assets/dist
|
||||||
|
VERSION:
|
||||||
|
sh: git describe --tags --always --dirty
|
||||||
|
LDFLAGS: -s -w -X main.version={{.VERSION}}
|
||||||
|
RELEASE_TARGETS: [windows/amd64, windows/arm64, linux/amd64, linux/arm64, darwin/amd64, darwin/arm64]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: '0'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
default:
|
||||||
|
cmds:
|
||||||
|
- task --list
|
||||||
|
silent: true
|
||||||
|
|
||||||
|
setup:
|
||||||
|
desc: Install dev tools, Go modules and frontend dependencies
|
||||||
|
cmds:
|
||||||
|
- go install github.com/air-verse/air@latest
|
||||||
|
- go install github.com/go-gremlins/gremlins/cmd/gremlins@latest
|
||||||
|
- go mod download
|
||||||
|
- task: web:install
|
||||||
|
|
||||||
|
dev:
|
||||||
|
desc: Run the API (air, hot reload) and the SPA (Vite, HMR) together → http://localhost:5173
|
||||||
|
deps: [dev:api, dev:web]
|
||||||
|
|
||||||
|
dev:api:
|
||||||
|
desc: Run the API with hot reload on :8080 (SPA not embedded)
|
||||||
|
cmds:
|
||||||
|
- air
|
||||||
|
|
||||||
|
dev:web:
|
||||||
|
desc: Run the Vite dev server on :5173, proxying /v1 to the API
|
||||||
|
dir: web
|
||||||
|
deps: [web:install]
|
||||||
|
cmds:
|
||||||
|
- bun dev
|
||||||
|
|
||||||
|
test:
|
||||||
|
desc: Run the Go tests
|
||||||
|
cmds:
|
||||||
|
- go test -tags nofrontend ./test/... -count=1 {{.CLI_ARGS}}
|
||||||
|
|
||||||
|
cover:
|
||||||
|
desc: Go test coverage of the use cases → tmp/coverage.out
|
||||||
|
cmds:
|
||||||
|
- mkdir -p tmp
|
||||||
|
- go test -tags nofrontend ./test/... -count=1 -coverpkg=./internal/infrastructure/useCases/... -coverprofile=tmp/coverage.out
|
||||||
|
- go tool cover -func=tmp/coverage.out
|
||||||
|
|
||||||
|
mutate:
|
||||||
|
desc: Gremlins mutation testing of the use cases → tmp/gremlins.json
|
||||||
|
cmds:
|
||||||
|
- mkdir -p tmp
|
||||||
|
- gremlins unleash --integration --tags nofrontend --output tmp/gremlins.json ./internal/infrastructure/useCases
|
||||||
|
|
||||||
|
lint:
|
||||||
|
desc: gofmt check, go vet, eslint (--fix) and vue-tsc
|
||||||
|
cmds:
|
||||||
|
- task: lint:go
|
||||||
|
- task: lint:web
|
||||||
|
|
||||||
|
lint:go:
|
||||||
|
cmds:
|
||||||
|
- cmd: test -z "$(gofmt -l .)" || (gofmt -l . && echo "unformatted Go files, run 'task fmt'" && exit 1)
|
||||||
|
silent: true
|
||||||
|
- go vet -tags nofrontend ./...
|
||||||
|
|
||||||
|
lint:web:
|
||||||
|
dir: web
|
||||||
|
deps: [web:install]
|
||||||
|
cmds:
|
||||||
|
- bun lint
|
||||||
|
- bun run type-check
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
desc: Format Go and frontend sources
|
||||||
|
cmds:
|
||||||
|
- gofmt -w .
|
||||||
|
- bun run --cwd web format
|
||||||
|
|
||||||
|
web:install:
|
||||||
|
internal: true
|
||||||
|
dir: web
|
||||||
|
sources: [package.json, bun.lock]
|
||||||
|
generates: ['node_modules/.bin/vite*']
|
||||||
|
cmds:
|
||||||
|
- bun install --frozen-lockfile
|
||||||
|
|
||||||
|
build:web:
|
||||||
|
desc: Type-check and build the SPA into {{.DIST_DIR}}
|
||||||
|
dir: web
|
||||||
|
deps: [web:install]
|
||||||
|
sources: [src/**/*, public/**/*, index.html, vite.config.ts, tsconfig*.json, package.json, bun.lock]
|
||||||
|
generates: ['../{{.DIST_DIR}}/index.html']
|
||||||
|
cmds:
|
||||||
|
- bun run build
|
||||||
|
|
||||||
|
build:
|
||||||
|
desc: Production binary for this machine → bin/{{.BINARY}}
|
||||||
|
deps: [build:web]
|
||||||
|
cmds:
|
||||||
|
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server
|
||||||
|
|
||||||
|
release:
|
||||||
|
desc: Cross-compile production binaries for all release targets → bin/
|
||||||
|
deps: [build:web]
|
||||||
|
cmds:
|
||||||
|
- for: { var: RELEASE_TARGETS }
|
||||||
|
task: build:target
|
||||||
|
vars:
|
||||||
|
GOOS: '{{index (splitList "/" .ITEM) 0}}'
|
||||||
|
GOARCH: '{{index (splitList "/" .ITEM) 1}}'
|
||||||
|
|
||||||
|
build:target:
|
||||||
|
internal: true
|
||||||
|
vars:
|
||||||
|
EXT: '{{if eq .GOOS "windows"}}.exe{{end}}'
|
||||||
|
env:
|
||||||
|
GOOS: '{{.GOOS}}'
|
||||||
|
GOARCH: '{{.GOARCH}}'
|
||||||
|
cmds:
|
||||||
|
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}_{{.VERSION}}_{{.GOOS}}_{{.GOARCH}}{{.EXT}} ./cmd/server
|
||||||
|
|
||||||
|
run:
|
||||||
|
desc: Build and run the production binary locally
|
||||||
|
deps: [build]
|
||||||
|
cmds:
|
||||||
|
- ./bin/{{.BINARY}}{{exeExt}}
|
||||||
|
|
||||||
|
clean:
|
||||||
|
desc: Remove build output, the built SPA and tmp/
|
||||||
|
cmds:
|
||||||
|
- rm -rf bin tmp {{.DIST_DIR}}
|
||||||
@@ -86,8 +86,18 @@ func getUseCases(repos *Repositories) *UseCases {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitContainer() *UseCases {
|
func InitContainer() (*UseCases, func() error) {
|
||||||
db := getDb()
|
db := getDb()
|
||||||
repos := getRepositories(db)
|
repos := getRepositories(db)
|
||||||
return getUseCases(repos)
|
return getUseCases(repos), closeDb(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeDb(db *gorm.DB) func() error {
|
||||||
|
return func() error {
|
||||||
|
sqlDb, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDb.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,13 +5,23 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hashedAssetsPrefix = "assets/"
|
||||||
|
|
||||||
|
const (
|
||||||
|
hashedAssetCacheControl = "public, max-age=31536000, immutable"
|
||||||
|
staticFileCacheControl = "public, max-age=3600"
|
||||||
|
indexCacheControl = "no-store, no-cache, must-revalidate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const devNotice = "RenewCMS was built with -tags nofrontend: the SPA is served by the Vite dev server (http://localhost:5173)."
|
||||||
|
|
||||||
type spaHandler struct {
|
type spaHandler struct {
|
||||||
staticFS fs.FS
|
staticFS fs.FS
|
||||||
modTime time.Time
|
modTime time.Time
|
||||||
@@ -20,9 +30,13 @@ type spaHandler struct {
|
|||||||
func NewFrontendRouter() http.Handler {
|
func NewFrontendRouter() http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
distFS, _ := fs.Sub(assets.DistEmbed, "dist")
|
if assets.Dist == nil {
|
||||||
|
r.Get("/*", serveDevNotice)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
handler := &spaHandler{
|
handler := &spaHandler{
|
||||||
staticFS: distFS,
|
staticFS: assets.Dist,
|
||||||
modTime: time.Now(),
|
modTime: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +44,12 @@ func NewFrontendRouter() http.Handler {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveDevNotice(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = io.WriteString(w, devNotice)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
path := h.cleanPath(r.URL.Path)
|
path := h.cleanPath(r.URL.Path)
|
||||||
if h.fileExists(path) {
|
if h.fileExists(path) {
|
||||||
@@ -39,8 +59,8 @@ func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.serveIndex(w, r)
|
h.serveIndex(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *spaHandler) cleanPath(path string) string {
|
func (h *spaHandler) cleanPath(p string) string {
|
||||||
return strings.TrimPrefix(filepath.Clean(path), "/")
|
return strings.TrimPrefix(path.Clean("/"+p), "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *spaHandler) fileExists(path string) bool {
|
func (h *spaHandler) fileExists(path string) bool {
|
||||||
@@ -52,11 +72,16 @@ func (h *spaHandler) fileExists(path string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
return true
|
stat, err := f.Stat()
|
||||||
|
return err == nil && !stat.IsDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *spaHandler) serveAsset(w http.ResponseWriter, r *http.Request, path string) {
|
func (h *spaHandler) serveAsset(w http.ResponseWriter, r *http.Request, path string) {
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
if strings.HasPrefix(path, hashedAssetsPrefix) {
|
||||||
|
w.Header().Set("Cache-Control", hashedAssetCacheControl)
|
||||||
|
} else {
|
||||||
|
w.Header().Set("Cache-Control", staticFileCacheControl)
|
||||||
|
}
|
||||||
http.FileServer(http.FS(h.staticFS)).ServeHTTP(w, r)
|
http.FileServer(http.FS(h.staticFS)).ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +99,6 @@ func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
w.Header().Set("Cache-Control", indexCacheControl)
|
||||||
http.ServeContent(w, r, "index.html", h.modTime, content)
|
http.ServeContent(w, r, "index.html", h.modTime, content)
|
||||||
}
|
}
|
||||||
+23
-6
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/MadAppGang/httplog"
|
"github.com/MadAppGang/httplog"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
"github.com/go-chi/jwtauth/v5"
|
"github.com/go-chi/jwtauth/v5"
|
||||||
)
|
)
|
||||||
@@ -55,6 +56,7 @@ func InitBackendRoutes(container *UseCases) *chi.Mux {
|
|||||||
|
|
||||||
enableLoggingIfDev(r, "backend")
|
enableLoggingIfDev(r, "backend")
|
||||||
|
|
||||||
|
r.Use(chimiddleware.GetHead)
|
||||||
r.Use(middleware.JsonContentTypeMiddleware)
|
r.Use(middleware.JsonContentTypeMiddleware)
|
||||||
r.Get("/", GetHelloWorld)
|
r.Get("/", GetHelloWorld)
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@@ -77,19 +79,34 @@ func InitFrontendRoutes() *chi.Mux {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitRoutes(container *UseCases) *chi.Mux {
|
func GetHealth(w http.ResponseWriter, _ *http.Request) {
|
||||||
backend := InitBackendRoutes(container)
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
frontend := InitFrontendRoutes()
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}
|
||||||
|
|
||||||
apiRouter := chi.NewRouter()
|
func enableCorsIfConfigured(r *chi.Mux) {
|
||||||
apiRouter.Use(cors.Handler(cors.Options{
|
origins := os.Getenv("CORS_ALLOWED_ORIGINS")
|
||||||
AllowedOrigins: strings.Split(os.Getenv("CORS_ALLOWED_ORIGINS"), ";"),
|
if origins == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Use(cors.Handler(cors.Options{
|
||||||
|
AllowedOrigins: strings.Split(origins, ";"),
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
ExposedHeaders: []string{"Link"},
|
ExposedHeaders: []string{"Link"},
|
||||||
AllowCredentials: false,
|
AllowCredentials: false,
|
||||||
MaxAge: 300,
|
MaxAge: 300,
|
||||||
}))
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitRoutes(container *UseCases) *chi.Mux {
|
||||||
|
backend := InitBackendRoutes(container)
|
||||||
|
frontend := InitFrontendRoutes()
|
||||||
|
|
||||||
|
apiRouter := chi.NewRouter()
|
||||||
|
enableCorsIfConfigured(apiRouter)
|
||||||
|
apiRouter.Use(chimiddleware.GetHead)
|
||||||
|
apiRouter.Get("/healthz", GetHealth)
|
||||||
apiRouter.Mount("/v1", backend)
|
apiRouter.Mount("/v1", backend)
|
||||||
apiRouter.Mount("/", frontend)
|
apiRouter.Mount("/", frontend)
|
||||||
|
|
||||||
|
|||||||
+109
-37
@@ -3,67 +3,139 @@ package main
|
|||||||
import (
|
import (
|
||||||
"RenewCMS/api"
|
"RenewCMS/api"
|
||||||
validator "RenewCMS/api/pkg/validator"
|
validator "RenewCMS/api/pkg/validator"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
const (
|
||||||
|
readHeaderTimeout = 10 * time.Second
|
||||||
|
readTimeout = 30 * time.Second
|
||||||
|
writeTimeout = 60 * time.Second
|
||||||
|
idleTimeout = 120 * time.Second
|
||||||
|
shutdownTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
var possibleEnvFileLocations = []string{".env", "../.env"}
|
var possibleEnvFileLocations = []string{".env", "../.env"}
|
||||||
var envVarsToLoad = []string{
|
|
||||||
"HOST",
|
var requiredEnvVars = []string{
|
||||||
"PORT",
|
"PUBLIC_URL",
|
||||||
"JWT_SECRET",
|
"JWT_SECRET",
|
||||||
"ENVIRONMENT",
|
|
||||||
"CORS_ALLOWED_ORIGINS",
|
|
||||||
"DB_FILE",
|
|
||||||
"UPLOAD_DIR",
|
|
||||||
"SMTP_EMAIL",
|
"SMTP_EMAIL",
|
||||||
"SMTP_PASSWORD",
|
"SMTP_PASSWORD",
|
||||||
"SMTP_HOST",
|
"SMTP_HOST",
|
||||||
"SMTP_PORT",
|
"SMTP_PORT",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var envDefaults = map[string]string{
|
||||||
|
"ENVIRONMENT": "production",
|
||||||
|
"PORT": "8080",
|
||||||
|
"DB_FILE": "./data/renewcms.db",
|
||||||
|
"UPLOAD_DIR": "./data/uploads",
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
initEnvVariables()
|
||||||
|
|
||||||
|
container, closeDb := api.InitContainer()
|
||||||
|
defer closeDatabase(closeDb)
|
||||||
|
validator.InitValidator()
|
||||||
|
|
||||||
|
server := newServer(api.InitRoutes(container))
|
||||||
|
return serveUntilStopped(server)
|
||||||
|
}
|
||||||
|
|
||||||
func initEnvVariables() {
|
func initEnvVariables() {
|
||||||
var err error
|
loadEnvFile()
|
||||||
|
applyEnvDefaults()
|
||||||
|
ensureRequiredEnvVars()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEnvFile() {
|
||||||
for _, envLocation := range possibleEnvFileLocations {
|
for _, envLocation := range possibleEnvFileLocations {
|
||||||
err = godotenv.Load(envLocation)
|
if godotenv.Load(envLocation) == nil {
|
||||||
if err == nil {
|
return
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
log.Println("WARNING: Could not load any .env file")
|
||||||
fmt.Println("WARNING: Could not load any .env file")
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for _, envVar := range envVarsToLoad {
|
func applyEnvDefaults() {
|
||||||
if _, ok := os.LookupEnv(envVar); !ok {
|
for envVar, value := range envDefaults {
|
||||||
|
if _, isSet := os.LookupEnv(envVar); !isSet {
|
||||||
|
_ = os.Setenv(envVar, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureRequiredEnvVars() {
|
||||||
|
for _, envVar := range requiredEnvVars {
|
||||||
|
if _, isSet := os.LookupEnv(envVar); !isSet {
|
||||||
panic(fmt.Sprintf("Environment variable %s is not set", envVar))
|
panic(fmt.Sprintf("Environment variable %s is not set", envVar))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func initServer() *chi.Mux {
|
func closeDatabase(closeDb func() error) {
|
||||||
initEnvVariables()
|
if err := closeDb(); err != nil {
|
||||||
|
log.Println("error closing database:", err)
|
||||||
container := api.InitContainer()
|
|
||||||
validator.InitValidator()
|
|
||||||
|
|
||||||
return api.InitRoutes(container)
|
|
||||||
}
|
|
||||||
|
|
||||||
func startServer(router *chi.Mux) error {
|
|
||||||
fmt.Println("Server starting on http://" + os.Getenv("HOST"))
|
|
||||||
err := http.ListenAndServe(":"+os.Getenv("PORT"), router)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
router := initServer()
|
|
||||||
err := startServer(router)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newServer(handler http.Handler) *http.Server {
|
||||||
|
return &http.Server{
|
||||||
|
Addr: ":" + os.Getenv("PORT"),
|
||||||
|
Handler: handler,
|
||||||
|
ReadHeaderTimeout: readHeaderTimeout,
|
||||||
|
ReadTimeout: readTimeout,
|
||||||
|
WriteTimeout: writeTimeout,
|
||||||
|
IdleTimeout: idleTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveUntilStopped(server *http.Server) error {
|
||||||
|
stopSignal, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
serverErr := make(chan error, 1)
|
||||||
|
go listen(server, serverErr)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-serverErr:
|
||||||
|
return err
|
||||||
|
case <-stopSignal.Done():
|
||||||
|
return shutdown(server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listen(server *http.Server, serverErr chan<- error) {
|
||||||
|
log.Printf("RenewCMS %s (%s) listening on %s, public URL %s", version, os.Getenv("ENVIRONMENT"), server.Addr, os.Getenv("PUBLIC_URL"))
|
||||||
|
err := server.ListenAndServe()
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
serverErr <- err
|
||||||
|
}
|
||||||
|
|
||||||
|
func shutdown(server *http.Server) error {
|
||||||
|
log.Println("shutting down...")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
return server.Shutdown(ctx)
|
||||||
|
}
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
services:
|
|
||||||
api:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "${PORT}:8080"
|
|
||||||
volumes:
|
|
||||||
- ${DOCKER_DB_FOLDER}:/app/db/
|
|
||||||
environment:
|
|
||||||
- PORT=8080
|
|
||||||
- DB_FILE=/app/db/RenewCMS.db
|
|
||||||
env_file:
|
|
||||||
- ./.env
|
|
||||||
@@ -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.
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package assets
|
|
||||||
|
|
||||||
import "embed"
|
|
||||||
|
|
||||||
//go:embed all:dist
|
|
||||||
var DistEmbed embed.FS
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//go:build !nofrontend
|
||||||
|
|
||||||
|
package assets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:dist
|
||||||
|
var distEmbed embed.FS
|
||||||
|
|
||||||
|
var Dist = mustSub(distEmbed, "dist")
|
||||||
|
|
||||||
|
func mustSub(fsys fs.FS, dir string) fs.FS {
|
||||||
|
sub, err := fs.Sub(fsys, dir)
|
||||||
|
if err != nil {
|
||||||
|
panic("assets: " + err.Error())
|
||||||
|
}
|
||||||
|
return sub
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build nofrontend
|
||||||
|
|
||||||
|
package assets
|
||||||
|
|
||||||
|
import "io/fs"
|
||||||
|
|
||||||
|
var Dist fs.FS
|
||||||
@@ -29,7 +29,7 @@ func (m MailRepository) Send(receiverAddress string, templateName string, data a
|
|||||||
|
|
||||||
d := gomail.NewDialer(smtpHost, smtpPort, from, password)
|
d := gomail.NewDialer(smtpHost, smtpPort, from, password)
|
||||||
|
|
||||||
tmpl, err := template.ParseFS(mailTemplateFiles, "mail/templates/"+templateName+".html")
|
tmpl, err := template.ParseFS(mailTemplateFiles, "templates/"+templateName+".html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("error when template.ParseFS:", err)
|
log.Println("error when template.ParseFS:", err)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
OUTPUT_DIR=./bin
|
|
||||||
GO_FILE_PATH="./cmd/server/main.go"
|
|
||||||
PROGRAM_NAME=RenewCMS
|
|
||||||
WEB_DIR="./web"
|
|
||||||
|
|
||||||
echo "Starting frontend build in $WEB_DIR..."
|
|
||||||
if [ -d "$WEB_DIR" ]; then
|
|
||||||
(cd "$WEB_DIR" && bun run build)
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Frontend build failed. Aborting process."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "Error: Directory $WEB_DIR not found."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Frontend build completed successfully."
|
|
||||||
echo "----------------------------------------"
|
|
||||||
|
|
||||||
GOOS=$(go env GOOS)
|
|
||||||
GOARCH=$(go env GOARCH)
|
|
||||||
|
|
||||||
output_name="$OUTPUT_DIR/${PROGRAM_NAME}_${GOOS}_${GOARCH}"
|
|
||||||
if [ "$GOOS" = "windows" ]; then
|
|
||||||
output_name+='.exe'
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Detected platform: $GOOS/$GOARCH"
|
|
||||||
echo "Building for $GOOS/$GOARCH..."
|
|
||||||
|
|
||||||
env GOOS=$GOOS GOARCH=$GOARCH go build -o "$output_name" "$GO_FILE_PATH"
|
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo "Compilation successful: $output_name"
|
|
||||||
else
|
|
||||||
echo "Compilation for $GOOS/$GOARCH failed."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
OUTPUT_DIR=./bin
|
|
||||||
GO_FILE_PATH="./cmd/server/main.go"
|
|
||||||
PROGRAM_NAME=RenewCMS
|
|
||||||
platforms=("windows/amd64" "windows/arm64" "linux/amd64" "linux/arm64" "darwin/amd64" "darwin/arm64")
|
|
||||||
|
|
||||||
for platform in "${platforms[@]}"
|
|
||||||
do
|
|
||||||
platform_split=(${platform//\// })
|
|
||||||
GOOS=${platform_split[0]}
|
|
||||||
GOARCH=${platform_split[1]}
|
|
||||||
output_name="$OUTPUT_DIR/${PROGRAM_NAME}_${GOOS}_${GOARCH}"
|
|
||||||
|
|
||||||
if [ "$GOOS" = "windows" ]; then
|
|
||||||
output_name+='.exe'
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Building for $GOOS/$GOARCH..."
|
|
||||||
env GOOS=$GOOS GOARCH=$GOARCH go build -o "$output_name" "$GO_FILE_PATH"
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Compilation for $GOOS/$GOARCH failed."
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Compilation successful for all platforms."
|
|
||||||
+7
-44
@@ -1,48 +1,11 @@
|
|||||||
# .
|
# RenewCMS frontend
|
||||||
|
|
||||||
This template should help get you started developing with Vue 3 in Vite.
|
Vue 3 + Vite + PrimeVue + Tailwind SPA, managed with Bun. It is built into `../internal/infrastructure/assets/dist/` and embedded into the Go binary.
|
||||||
|
|
||||||
## Recommended IDE Setup
|
Use the tasks from the repository root rather than running Vite directly:
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
- `task dev` runs Vite on http://localhost:5173 and proxies `/v1` to the API.
|
||||||
|
- `task lint:web` runs eslint and vue-tsc.
|
||||||
|
- `task build:web` produces the production build.
|
||||||
|
|
||||||
## Recommended Browser Setup
|
See [../docs/development.md](../docs/development.md).
|
||||||
|
|
||||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
|
||||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
|
||||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
|
||||||
- Firefox:
|
|
||||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
|
||||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
|
||||||
|
|
||||||
## Type Support for `.vue` Imports in TS
|
|
||||||
|
|
||||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
|
||||||
|
|
||||||
## Customize configuration
|
|
||||||
|
|
||||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
|
||||||
|
|
||||||
## Project Setup
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Compile and Hot-Reload for Development
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bun dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type-Check, Compile and Minify for Production
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bun run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lint with [ESLint](https://eslint.org/)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bun lint
|
|
||||||
```
|
|
||||||
@@ -9,6 +9,13 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
outDir: '../internal/infrastructure/assets/dist/',
|
outDir: '../internal/infrastructure/assets/dist/',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/v1': 'http://localhost:8080',
|
||||||
|
'/healthz': 'http://localhost:8080',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [vue(), vueDevTools(), tailwindcss()],
|
plugins: [vue(), vueDevTools(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in new issue
Block a user