Split compose dev/prod and relax contact form friction.

Use compose.dev.yaml and compose.prod.yaml with fixed Go cache mounts, block sudo make dev, build Air outside app/tmp for rootless Docker, soften English spam checks, and simplify contact error copy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-07 11:41:13 +12:00
parent 3f5235daaf
commit bf110d0bc7
13 changed files with 125 additions and 76 deletions

View File

@@ -19,6 +19,6 @@ HERO_IMAGE=connectionmachine/20220723_231556.jpg
ADMIN_USER=admin
ADMIN_PASSWORD=changeme
# Host Go caches mounted into the dev container (make sets ~/go/pkg/mod and ~/.cache/go-build if unset)
# Host Go caches for compose.dev.yaml volume mounts (make sets defaults from $HOME)
# GOMODCACHE=/home/you/go/pkg/mod
# GOCACHE=/home/you/.cache/go-build

View File

@@ -1,12 +1,15 @@
COMPOSE ?= docker compose
COMPOSE_DEV := $(COMPOSE) -f compose.dev.yaml
COMPOSE_PROD := $(COMPOSE) -f compose.prod.yaml
# Compose does not support nested ${VAR:-${HOME}/...}; set defaults here (override in .env).
# Compose does not support nested ${VAR:-${HOME}/...}; set host cache paths here (override in .env).
-include .env
export GOMODCACHE ?= $(HOME)/go/pkg/mod
export GOCACHE ?= $(HOME)/.cache/go-build
HOST_GOMODCACHE := $(if $(strip $(GOMODCACHE)),$(strip $(GOMODCACHE)),$(HOME)/go/pkg/mod)
HOST_GOCACHE := $(if $(strip $(GOCACHE)),$(strip $(GOCACHE)),$(HOME)/.cache/go-build)
export HOST_GOMODCACHE HOST_GOCACHE
# One-off commands in the dev image (same caches and ./app mount as `make dev`)
DEV_RUN := $(COMPOSE) run --rm --no-deps -T dev
DEV_ENV := env HOST_GOMODCACHE="$(HOST_GOMODCACHE)" HOST_GOCACHE="$(HOST_GOCACHE)"
DEV_RUN := $(DEV_ENV) $(COMPOSE_DEV) run --rm --no-deps -T dev
.PHONY: dev build up down generate tidy logs thumbs sync-media publish convert-videos
@@ -14,17 +17,23 @@ DEV_RUN := $(COMPOSE) run --rm --no-deps -T dev
dev:
@test -f .env || cp -n .env.example .env
$(COMPOSE) up --build dev
@if [ "$$(id -u)" = "0" ]; then \
echo "Do not run 'sudo make dev' — it creates root-owned files under app/ and breaks rootless Docker."; \
echo "Run: make dev"; \
exit 1; \
fi
$(DEV_ENV) $(COMPOSE_DEV) up --build dev
build:
$(COMPOSE) build website
$(COMPOSE_PROD) build website
up:
@test -f .env || cp -n .env.example .env
$(COMPOSE) up --build website
$(COMPOSE_PROD) up --build website
down:
$(COMPOSE) down
-$(COMPOSE_DEV) down
-$(COMPOSE_PROD) down
generate:
$(DEV_RUN) sh -c "templ generate"
@@ -33,7 +42,7 @@ tidy:
$(DEV_RUN) go mod tidy
logs:
$(COMPOSE) logs -f dev
$(COMPOSE_DEV) logs -f dev
# Pre-build gallery thumbnails and video poster JPEGs (ffmpeg in dev image).
thumbs:

View File

@@ -20,7 +20,7 @@ make dev # live reload → http://localhost:7331
| `make dev` | Dev with Air + templ browser live reload (default) |
| `make build` | Build production `website` image |
| `make up` | Run production `website` service |
| `make down` | Stop compose services |
| `make down` | Stop dev and prod compose stacks |
| `make generate` | Run `templ generate` in dev container |
| `make tidy` | Run `go mod tidy` in dev container |
| `make logs` | Follow dev container logs |
@@ -63,13 +63,13 @@ If SMTP is not configured, the page shows a mailto fallback instead of the form.
## Dev container
`make dev` mounts `./app` and `./upload` (raw media staging) plus your host Go caches (`~/go/pkg/mod`, `~/.cache/go-build` by default). Override with `GOMODCACHE` / `GOCACHE` in `.env`.
`make dev` uses `compose.dev.yaml`: mounts `./app` and `./upload` (raw media staging) plus your host Go caches (`~/go/pkg/mod`, `~/.cache/go-build` by default). Override with `GOMODCACHE` / `GOCACHE` in `.env`. Prefer `make dev` without `sudo` so those paths resolve correctly.
**Use [http://localhost:7331](http://localhost:7331)** in the browser — the templ proxy injects live reload on `.templ`, `.go`, and `.css` changes. Port 8080 hits the app directly without auto-reload.
Only run **`dev`** or **`website`** at a time if you map both to the same host ports.
Production `website` mounts `./app/images` into the container. It joins the external `caddy` Docker network for reverse proxy labels.
`make up` uses `compose.prod.yaml`: production `website` mounts `./app/images` into the container. Uncomment the Caddy network/labels in that file when deploying behind your reverse proxy.
## Project layout
@@ -86,7 +86,8 @@ app/
upload/ Raw media — run `make sync-media` to publish into app/images/
Dockerfile Production image
Dockerfile.dev Dev image (Go + templ + Air)
docker-compose.yaml
compose.dev.yaml
compose.prod.yaml
.env.example
```

View File

@@ -2,8 +2,8 @@ root = "."
tmp_dir = "tmp"
[build]
cmd = "templ generate --notify-proxy && go build -o ./tmp/main ./cmd/server"
entrypoint = ["./tmp/main"]
cmd = "templ generate --notify-proxy && go build -o /tmp/website-dev-main ./cmd/server"
entrypoint = ["/tmp/website-dev-main"]
delay = 300
exclude_dir = ["tmp", "bin", "images", "vendor", "testdata"]
exclude_regex = ["_templ.go"]

View File

@@ -75,7 +75,7 @@ func Parse(name, email, message string) (Submission, Errors) {
case errors.Is(err, contactcheck.ErrName):
errs["name"] = "Name is too long."
case errors.Is(err, contactcheck.ErrNotEnglish):
errs["message"] = "This site only accepts messages written in English. If your message is in English, try adding a few more clear sentences so we can detect the language reliably."
errs["message"] = "Please write your message in English."
default:
errs["message"] = "Could not accept this message."
}

View File

@@ -12,6 +12,9 @@ const (
minMessageRunes = 20
maxMessageRunes = 8000
maxNameRunes = 120
// Reject non-English only when the detector is fairly confident.
englishRejectConfidence = 0.80
)
var (
@@ -38,13 +41,15 @@ func ValidateEnglish(name, message string) error {
combined := strings.TrimSpace(name) + "\n\n" + msg
info := whatlanggo.Detect(combined)
// Block clearly non-Latin scripts (CJK, Cyrillic, etc.).
if info.Script != nil && info.Script != unicode.Latin {
return ErrNotEnglish
}
if info.Lang != whatlanggo.Eng {
// Allow uncertain detection; block only confident non-English.
if info.Lang != whatlanggo.Eng && info.IsReliable() {
return ErrNotEnglish
}
if !info.IsReliable() && info.Confidence < 0.55 {
if info.Lang != whatlanggo.Eng && info.Confidence >= englishRejectConfidence {
return ErrNotEnglish
}
return nil

View File

@@ -0,0 +1,29 @@
package contactcheck
import "testing"
func TestValidateEnglish_acceptsPlainEnglish(t *testing.T) {
if err := ValidateEnglish("Jimmy", "Hello, I need help with an LED install."); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateEnglish_acceptsTechnicalEnglish(t *testing.T) {
msg := "Need a quote for 50m WS2812 strip + ESP32 controller in Auckland."
if err := ValidateEnglish("Alex", msg); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestValidateEnglish_rejectsNonLatinScript(t *testing.T) {
msg := "これは日本語のテストメッセージです。十分な長さがあります。"
if err := ValidateEnglish("Jimmy", msg); err != ErrNotEnglish {
t.Fatalf("got %v, want ErrNotEnglish", err)
}
}
func TestValidateEnglish_rejectsTooShort(t *testing.T) {
if err := ValidateEnglish("Jimmy", "Hi there"); err != ErrTooShort {
t.Fatalf("got %v, want ErrTooShort", err)
}
}

View File

@@ -9,17 +9,18 @@ import (
const HoneypotField = "website"
// MinFormFillDuration is the minimum time between showing the form and submit.
const MinFormFillDuration = 3 * time.Second
const MinFormFillDuration = 5 * time.Second
// SpamHoneypot reports whether the honeypot was filled (likely spam).
func SpamHoneypot(value string) bool {
return strings.TrimSpace(value) != ""
}
// FormFilledTooFast reports whether the form was submitted before seenUnix (0 = never seen).
// FormFilledTooFast reports whether the form was submitted before seenUnix.
// Missing timing data (seenUnix <= 0) is allowed — honeypot and rate limits still apply.
func FormFilledTooFast(seenUnix int64, now time.Time) bool {
if seenUnix <= 0 {
return true
return false
}
return now.Sub(time.Unix(seenUnix, 0)) < MinFormFillDuration
}

View File

@@ -18,8 +18,8 @@ func TestFormFilledTooFast(t *testing.T) {
now := time.Unix(1_000_000, 0)
seen := now.Add(-MinFormFillDuration).Unix()
if !FormFilledTooFast(0, now) {
t.Error("missing seen time should be too fast")
if FormFilledTooFast(0, now) {
t.Error("missing seen time should be allowed")
}
if !FormFilledTooFast(seen+1, now) {
t.Error("submit before min duration should be too fast")

View File

@@ -4,13 +4,11 @@ templ ContactForm(enabled bool) {
<section id="contact" class="contact">
<h2>Contact</h2>
<p class="contact-intro">
Interested in a project or collaboration? Send a message below. Messages must be in English.
Interested in a project or collaboration? Send a message below.
</p>
if !enabled {
<p class="contact-unavailable">
The contact form is not configured yet. Email
<a href="mailto:hello@technical.kiwi">hello@technical.kiwi</a>
directly.
The contact form is not configured yet.
</p>
} else {
<form
@@ -33,7 +31,7 @@ templ ContactForm(enabled bool) {
</div>
<div class="form-row">
<label for="message">Message</label>
<textarea id="message" name="message" required minlength="20" rows="6" maxlength="8000" placeholder="Write at least a few sentences in English."></textarea>
<textarea id="message" name="message" required minlength="20" rows="6" maxlength="8000" placeholder="Tell us about your project or question."></textarea>
</div>
<input
class="hp-field"
@@ -70,8 +68,7 @@ templ ContactValidationAlert(errs map[string]string) {
templ ContactSendError() {
<div class="alert alert-error" role="alert">
<p>Something went wrong sending your message. Please try again later or email
<a href="mailto:hello@technical.kiwi">hello@technical.kiwi</a>.</p>
<p>Something went wrong sending your message. Please try again later.</p>
</div>
}

24
compose.dev.yaml Normal file
View File

@@ -0,0 +1,24 @@
# Local development: live reload, source mount, host Go caches.
# Run: make dev
services:
dev:
build:
context: .
dockerfile: Dockerfile.dev
container_name: technicalkiwi-dev
ports:
- "7331:7331" # templ proxy — use http://localhost:7331 in the browser
- "8080:8080" # app direct (no auto-reload)
volumes:
- ./app:/app
- ./upload:/upload
- ${HOST_GOMODCACHE}:/go/pkg/mod
- ${HOST_GOCACHE}:/root/.cache/go-build
env_file:
- .env
environment:
ADDR: ":8080"
GOMODCACHE: /go/pkg/mod
GOCACHE: /root/.cache/go-build
working_dir: /app

27
compose.prod.yaml Normal file
View File

@@ -0,0 +1,27 @@
# Production website (built image, gallery on host).
# Run: make up
services:
website:
image: technical.kiwi/website
container_name: technicalkiwi
build:
context: .
dockerfile: Dockerfile
volumes:
- ./app/images:/app/images
env_file:
- .env
environment:
IMAGES_DIR: /app/images
networks:
- caddy
labels:
caddy: technical.kiwi
caddy.reverse_proxy: "{{upstreams 8080}}"
caddy.tls: "admin@technical.kiwi"
networks:
caddy:
external: true
name: caddy

View File

@@ -1,44 +0,0 @@
version: '3.9'
services:
website:
image: technical.kiwi/website
container_name: technicalkiwi
build: ./
volumes:
- ./app/images:/app/images
env_file:
- .env
environment:
IMAGES_DIR: /app/images
#networks:
# - caddy
labels:
caddy: technicalkiwi
caddy.reverse_proxy: "{{upstreams 8080}}"
dev:
build:
context: .
dockerfile: Dockerfile.dev
container_name: technicalkiwi-dev
ports:
- "7331:7331" # templ live-reload proxy — open this in the browser
- "8080:8080" # app direct (no auto-reload)
volumes:
- ./app:/app
- ./upload:/upload
- ${GOMODCACHE}:/go/pkg/mod
- ${GOCACHE}:/root/.cache/go-build
env_file:
- .env
environment:
ADDR: ":8080"
GOMODCACHE: /go/pkg/mod
GOCACHE: /root/.cache/go-build
working_dir: /app
#networks:
# caddy:
# external: true
# name: caddy