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:
@@ -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
|
||||
|
||||
29
Makefile
29
Makefile
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
29
app/internal/contactcheck/english_test.go
Normal file
29
app/internal/contactcheck/english_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
24
compose.dev.yaml
Normal 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
27
compose.prod.yaml
Normal 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user