fix: apply code-review and insight findings across nuntius
This commit is contained in:
@@ -2,7 +2,7 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
tags: ["v*.*.*"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -53,9 +53,9 @@ jobs:
|
||||
VERSION_NO_V="${VERSION#v}"
|
||||
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
|
||||
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
|
||||
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} CGO_ENABLED=0 \
|
||||
go build -ldflags "$LDFLAGS" \
|
||||
-o "bin/nuntius-${VERSION_NO_V}-${{ matrix.goos }}-${{ matrix.goarch }}" \
|
||||
./cmd/server
|
||||
@@ -67,6 +67,11 @@ jobs:
|
||||
path: bin/nuntius-${{ steps.build.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
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:
|
||||
runs-on: fedora
|
||||
needs: build
|
||||
|
||||
@@ -22,7 +22,13 @@ jobs:
|
||||
- name: gofmt
|
||||
# `gofmt -l` prints the names of unformatted files. An empty
|
||||
# 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
|
||||
run: go vet ./...
|
||||
@@ -45,3 +51,25 @@ jobs:
|
||||
|
||||
- name: go test ./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`:
|
||||
```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.
|
||||
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 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.
|
||||
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ func main() {
|
||||
h.Register(mux)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + itoa(cfg.Server.Port),
|
||||
Addr: "[::]:" + itoa(cfg.Server.Port),
|
||||
Handler: withRequestLog(mux, logger),
|
||||
ReadHeaderTimeout: 10 * 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
|
||||
|
||||
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":
|
||||
tmpl = emailTemplateGeneric
|
||||
}
|
||||
_ = tmpl.Execute(&htmlBuf, map[string]string{
|
||||
if err := tmpl.Execute(&htmlBuf, map[string]string{
|
||||
"FormName": formName,
|
||||
"Name": req.Name,
|
||||
"Email": req.Email,
|
||||
"Service": req.Service,
|
||||
"Message": req.Message,
|
||||
"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 ---
|
||||
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 {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(contactform.ErrorResponse{
|
||||
if err := json.NewEncoder(w).Encode(contactform.ErrorResponse{
|
||||
Error: "validation",
|
||||
Details: errs,
|
||||
})
|
||||
}); err != nil {
|
||||
slog.Error("failed to encode json response", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -155,10 +157,12 @@ func (h *ContactHandler) makeHandler(path string) http.HandlerFunc {
|
||||
// Health handles GET /health.
|
||||
func (h *ContactHandler) Health(w http.ResponseWriter, r *http.Request) {
|
||||
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",
|
||||
"forms": len(h.forms),
|
||||
})
|
||||
}); err != nil {
|
||||
slog.Error("failed to encode health response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- internals ---
|
||||
@@ -201,10 +205,36 @@ type bucket struct {
|
||||
}
|
||||
|
||||
func newRateLimiter(perHour int) *rateLimiter {
|
||||
return &rateLimiter{
|
||||
r := &rateLimiter{
|
||||
perHour: perHour,
|
||||
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 {
|
||||
@@ -239,16 +269,20 @@ func minF(a, b float64) float64 {
|
||||
|
||||
func respondOK(w http.ResponseWriter) {
|
||||
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) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(contactform.ErrorResponse{
|
||||
if encErr := json.NewEncoder(w).Encode(contactform.ErrorResponse{
|
||||
Error: err,
|
||||
Message: msg,
|
||||
})
|
||||
}); encErr != nil {
|
||||
slog.Error("failed to encode error response", "err", encErr)
|
||||
}
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
|
||||
Reference in New Issue
Block a user