fix: apply code-review and insight findings across nuntius
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user