fix: apply code-review and insight findings across nuntius

This commit is contained in:
2026-07-26 20:40:16 +02:00
parent 6339771a24
commit 83c95dff08
8 changed files with 94 additions and 18 deletions
+8 -3
View File
@@ -2,7 +2,7 @@ name: Release
on: on:
push: push:
tags: ["v*"] tags: ["v*.*.*"]
jobs: jobs:
build: build:
@@ -53,9 +53,9 @@ jobs:
VERSION_NO_V="${VERSION#v}" VERSION_NO_V="${VERSION#v}"
echo "version_no_v=${VERSION_NO_V}" >> "$GITEA_OUTPUT" echo "version_no_v=${VERSION_NO_V}" >> "$GITEA_OUTPUT"
LDFLAGS="-s -w -X sourcedock.dev/petrbalvin/nuntius/internal/version.Version=${VERSION}" LDFLAGS="-s -w -X sourcedock.dev/petrbalvin/nuntius/internal/version.Version=${VERSION_NO_V}"
mkdir -p bin mkdir -p bin
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \ GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} CGO_ENABLED=0 \
go build -ldflags "$LDFLAGS" \ go build -ldflags "$LDFLAGS" \
-o "bin/nuntius-${VERSION_NO_V}-${{ matrix.goos }}-${{ matrix.goarch }}" \ -o "bin/nuntius-${VERSION_NO_V}-${{ matrix.goos }}-${{ matrix.goarch }}" \
./cmd/server ./cmd/server
@@ -67,6 +67,11 @@ jobs:
path: bin/nuntius-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} path: bin/nuntius-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
if-no-files-found: error if-no-files-found: error
- name: Smoke test
run: |
chmod +x bin/nuntius-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
./bin/nuntius-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }} --version
release: release:
runs-on: fedora runs-on: fedora
needs: build needs: build
+29 -1
View File
@@ -22,7 +22,13 @@ jobs:
- name: gofmt - name: gofmt
# `gofmt -l` prints the names of unformatted files. An empty # `gofmt -l` prints the names of unformatted files. An empty
# output is the only acceptable result. # output is the only acceptable result.
run: gofmt -l cmd internal pkg run: |
unformatted=$(gofmt -l cmd internal pkg)
if [ -n "$unformatted" ]; then
echo "The following files need gofmt:"
echo "$unformatted"
exit 1
fi
- name: go vet - name: go vet
run: go vet ./... run: go vet ./...
@@ -45,3 +51,25 @@ jobs:
- name: go test ./examples - name: go test ./examples
run: go build ./examples/... run: go build ./examples/...
build:
runs-on: fedora
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
- name: Download dependencies
run: go mod download
- name: Build
run: go build -ldflags="-s -w" -o bin/nuntius ./cmd/server
- name: Test
run: go test ./...
- name: Smoke test
run: ./bin/nuntius --version
+2 -2
View File
@@ -231,7 +231,7 @@ Triggered by pushing a tag that matches `v*.*.*` (e.g. `v1.0.0`, `v1.2.3`) onto
1. **`build`** — Runs on a `fedora` runner in a 7-way matrix: `linux/amd64`, `linux/arm64`, `linux/riscv64`, `linux/loong64`, `freebsd/amd64`, `freebsd/arm64`, `freebsd/riscv64`. The default `actions/checkout@v4` checks out the tagged commit (the trigger was a tag push), which is exactly the commit on `main` we want to ship. The version is baked in via `-ldflags`: 1. **`build`** — Runs on a `fedora` runner in a 7-way matrix: `linux/amd64`, `linux/arm64`, `linux/riscv64`, `linux/loong64`, `freebsd/amd64`, `freebsd/arm64`, `freebsd/riscv64`. The default `actions/checkout@v4` checks out the tagged commit (the trigger was a tag push), which is exactly the commit on `main` we want to ship. The version is baked in via `-ldflags`:
```bash ```bash
-X sourcedock.dev/petrbalvin/nuntius/internal/version.Version=${VERSION#v} -X sourcedock.dev/petrbalvin/nuntius/internal/version.Version=${VERSION_NO_V}
``` ```
The resulting binary is uploaded as a workflow artifact and smoke-tested with `--version` on the build host. The resulting binary is uploaded as a workflow artifact and smoke-tested with `--version` on the build host.
2. **`release`** — Runs on a `fedora` runner (no Go toolchain needed). Downloads all artifacts, extracts the matching section from `CHANGELOG.md` for the tagged version, creates a release via the Gitea REST API, and uploads each binary as a release asset. The release is **non-draft, non-prerelease**; if the CHANGELOG section is missing, the job fails fast so a release cannot ship without notes. 2. **`release`** — Runs on a `fedora` runner (no Go toolchain needed). Downloads all artifacts, extracts the matching section from `CHANGELOG.md` for the tagged version, creates a release via the Gitea REST API, and uploads each binary as a release asset. The release is **non-draft, non-prerelease**; if the CHANGELOG section is missing, the job fails fast so a release cannot ship without notes.
@@ -248,7 +248,7 @@ Triggered by pushing a tag that matches `v*.*.*` (e.g. `v1.0.0`, `v1.2.3`) onto
git push origin main git push origin main
git push origin vX.Y.Z git push origin vX.Y.Z
``` ```
6. The release workflow builds the four platform binaries and creates the release with the CHANGELOG section as the body. No CI step performs the merge — that already happened in step 4. 6. The release workflow builds the seven platform binaries and creates the release with the CHANGELOG section as the body. No CI step performs the merge — that already happened in step 4.
If the release job fails, the tag can be deleted and re-pushed; the workflow re-runs on the new tag push. Do not re-tag an already-released commit — the Gitea API rejects duplicate release tag names. If the release job fails, the tag can be deleted and re-pushed; the workflow re-runs on the new tag push. Do not re-tag an already-released commit — the Gitea API rejects duplicate release tag names.
+1 -1
View File
@@ -48,7 +48,7 @@ func main() {
h.Register(mux) h.Register(mux)
srv := &http.Server{ srv := &http.Server{
Addr: ":" + itoa(cfg.Server.Port), Addr: "[::]:" + itoa(cfg.Server.Port),
Handler: withRequestLog(mux, logger), Handler: withRequestLog(mux, logger),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
+1 -1
View File
@@ -139,4 +139,4 @@ The graceful shutdown budget is 15 s (`srv.Shutdown` with `context.WithTimeout`)
## Reporting a Vulnerability ## Reporting a Vulnerability
Email **petrbalvin@yandex.com** with a description and a reproducer. Please do not file a public issue for security-sensitive reports. Email **opensource@petrbalvin.org** with a description and a reproducer. Please do not file a public issue for security-sensitive reports.
+7 -2
View File
@@ -198,14 +198,19 @@ func compose(from, to string, req contactform.Request, formName, formType string
case "generic": case "generic":
tmpl = emailTemplateGeneric tmpl = emailTemplateGeneric
} }
_ = tmpl.Execute(&htmlBuf, map[string]string{ if err := tmpl.Execute(&htmlBuf, map[string]string{
"FormName": formName, "FormName": formName,
"Name": req.Name, "Name": req.Name,
"Email": req.Email, "Email": req.Email,
"Service": req.Service, "Service": req.Service,
"Message": req.Message, "Message": req.Message,
"Received": received, "Received": received,
}) }); err != nil {
// template.Must guarantees valid templates; this path is
// unreachable in normal operation.
htmlBuf.Reset()
fmt.Fprintf(&htmlBuf, "<p>Email generation error: %v</p>", err)
}
// --- Subject + plain-text body per form type --- // --- Subject + plain-text body per form type ---
var subject, text string var subject, text string
+42 -8
View File
@@ -114,10 +114,12 @@ func (h *ContactHandler) makeHandler(path string) http.HandlerFunc {
if errs := contactform.Validate(&req, form.Type); len(errs) > 0 { if errs := contactform.Validate(&req, form.Type); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(contactform.ErrorResponse{ if err := json.NewEncoder(w).Encode(contactform.ErrorResponse{
Error: "validation", Error: "validation",
Details: errs, Details: errs,
}) }); err != nil {
slog.Error("failed to encode json response", "err", err)
}
return return
} }
@@ -155,10 +157,12 @@ func (h *ContactHandler) makeHandler(path string) http.HandlerFunc {
// Health handles GET /health. // Health handles GET /health.
func (h *ContactHandler) Health(w http.ResponseWriter, r *http.Request) { func (h *ContactHandler) Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{ if err := json.NewEncoder(w).Encode(map[string]any{
"status": "ok", "status": "ok",
"forms": len(h.forms), "forms": len(h.forms),
}) }); err != nil {
slog.Error("failed to encode health response", "err", err)
}
} }
// --- internals --- // --- internals ---
@@ -201,10 +205,36 @@ type bucket struct {
} }
func newRateLimiter(perHour int) *rateLimiter { func newRateLimiter(perHour int) *rateLimiter {
return &rateLimiter{ r := &rateLimiter{
perHour: perHour, perHour: perHour,
buckets: make(map[string]*bucket), buckets: make(map[string]*bucket),
} }
// Clean up entries older than twice the refill window every hour.
r.startCleanup(1*time.Hour, 2*time.Hour)
return r
}
// startCleanup launches a background goroutine that periodically removes
// expired bucket entries to prevent unbounded memory growth.
func (r *rateLimiter) startCleanup(interval, maxAge time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
r.cleanup(maxAge)
}
}()
}
func (r *rateLimiter) cleanup(maxAge time.Duration) {
r.mu.Lock()
defer r.mu.Unlock()
cutoff := time.Now().Add(-maxAge)
for ip, b := range r.buckets {
if b.last.Before(cutoff) {
delete(r.buckets, ip)
}
}
} }
func (r *rateLimiter) allow(ip string) bool { func (r *rateLimiter) allow(ip string) bool {
@@ -239,16 +269,20 @@ func minF(a, b float64) float64 {
func respondOK(w http.ResponseWriter) { func respondOK(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(contactform.Response{OK: true}) if err := json.NewEncoder(w).Encode(contactform.Response{OK: true}); err != nil {
slog.Error("failed to encode ok response", "err", err)
}
} }
func respondError(w http.ResponseWriter, code int, err, msg string) { func respondError(w http.ResponseWriter, code int, err, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code) w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(contactform.ErrorResponse{ if encErr := json.NewEncoder(w).Encode(contactform.ErrorResponse{
Error: err, Error: err,
Message: msg, Message: msg,
}) }); encErr != nil {
slog.Error("failed to encode error response", "err", encErr)
}
} }
func clientIP(r *http.Request) string { func clientIP(r *http.Request) string {
+4
View File
@@ -25,3 +25,7 @@ build:
test: test:
@echo "→ Running tests…" @echo "→ Running tests…"
go test ./... go test ./...
# Format Go source files
fmt:
gofmt -w cmd internal pkg