diff --git a/.env.example b/.env.example index dd1b20d..f8069cb 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Makefile b/Makefile index 0a8386a..fbe5f29 100644 --- a/Makefile +++ b/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: diff --git a/README.md b/README.md index 48af4e3..3510d15 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/app/.air.toml b/app/.air.toml index e190862..d5debf9 100644 --- a/app/.air.toml +++ b/app/.air.toml @@ -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"] diff --git a/app/internal/contact/validate.go b/app/internal/contact/validate.go index f295760..786c7bc 100644 --- a/app/internal/contact/validate.go +++ b/app/internal/contact/validate.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." } diff --git a/app/internal/contactcheck/english.go b/app/internal/contactcheck/english.go index d3bed76..0f6334e 100644 --- a/app/internal/contactcheck/english.go +++ b/app/internal/contactcheck/english.go @@ -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 diff --git a/app/internal/contactcheck/english_test.go b/app/internal/contactcheck/english_test.go new file mode 100644 index 0000000..4b3447e --- /dev/null +++ b/app/internal/contactcheck/english_test.go @@ -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) + } +} diff --git a/app/internal/contactcheck/form.go b/app/internal/contactcheck/form.go index 2c10e15..5ada797 100644 --- a/app/internal/contactcheck/form.go +++ b/app/internal/contactcheck/form.go @@ -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 } diff --git a/app/internal/contactcheck/form_test.go b/app/internal/contactcheck/form_test.go index 9ae4e09..5e69f18 100644 --- a/app/internal/contactcheck/form_test.go +++ b/app/internal/contactcheck/form_test.go @@ -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") diff --git a/app/templates/contact.templ b/app/templates/contact.templ index ecdca80..43c2c86 100644 --- a/app/templates/contact.templ +++ b/app/templates/contact.templ @@ -4,13 +4,11 @@ templ ContactForm(enabled bool) {

Contact

- 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.

if !enabled {

- The contact form is not configured yet. Email - hello@technical.kiwi - directly. + The contact form is not configured yet.

} else {
- +
-

Something went wrong sending your message. Please try again later or email - hello@technical.kiwi.

+

Something went wrong sending your message. Please try again later.

} diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..6623b4f --- /dev/null +++ b/compose.dev.yaml @@ -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 diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 0000000..ce91425 --- /dev/null +++ b/compose.prod.yaml @@ -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 diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index 17b49a7..0000000 --- a/docker-compose.yaml +++ /dev/null @@ -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