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_USER=admin
|
||||||
ADMIN_PASSWORD=changeme
|
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
|
# GOMODCACHE=/home/you/go/pkg/mod
|
||||||
# GOCACHE=/home/you/.cache/go-build
|
# GOCACHE=/home/you/.cache/go-build
|
||||||
|
|||||||
29
Makefile
29
Makefile
@@ -1,12 +1,15 @@
|
|||||||
COMPOSE ?= docker compose
|
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
|
-include .env
|
||||||
export GOMODCACHE ?= $(HOME)/go/pkg/mod
|
HOST_GOMODCACHE := $(if $(strip $(GOMODCACHE)),$(strip $(GOMODCACHE)),$(HOME)/go/pkg/mod)
|
||||||
export GOCACHE ?= $(HOME)/.cache/go-build
|
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_ENV := env HOST_GOMODCACHE="$(HOST_GOMODCACHE)" HOST_GOCACHE="$(HOST_GOCACHE)"
|
||||||
DEV_RUN := $(COMPOSE) run --rm --no-deps -T dev
|
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
|
.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:
|
dev:
|
||||||
@test -f .env || cp -n .env.example .env
|
@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:
|
build:
|
||||||
$(COMPOSE) build website
|
$(COMPOSE_PROD) build website
|
||||||
|
|
||||||
up:
|
up:
|
||||||
@test -f .env || cp -n .env.example .env
|
@test -f .env || cp -n .env.example .env
|
||||||
$(COMPOSE) up --build website
|
$(COMPOSE_PROD) up --build website
|
||||||
|
|
||||||
down:
|
down:
|
||||||
$(COMPOSE) down
|
-$(COMPOSE_DEV) down
|
||||||
|
-$(COMPOSE_PROD) down
|
||||||
|
|
||||||
generate:
|
generate:
|
||||||
$(DEV_RUN) sh -c "templ generate"
|
$(DEV_RUN) sh -c "templ generate"
|
||||||
@@ -33,7 +42,7 @@ tidy:
|
|||||||
$(DEV_RUN) go mod tidy
|
$(DEV_RUN) go mod tidy
|
||||||
|
|
||||||
logs:
|
logs:
|
||||||
$(COMPOSE) logs -f dev
|
$(COMPOSE_DEV) logs -f dev
|
||||||
|
|
||||||
# Pre-build gallery thumbnails and video poster JPEGs (ffmpeg in dev image).
|
# Pre-build gallery thumbnails and video poster JPEGs (ffmpeg in dev image).
|
||||||
thumbs:
|
thumbs:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ make dev # live reload → http://localhost:7331
|
|||||||
| `make dev` | Dev with Air + templ browser live reload (default) |
|
| `make dev` | Dev with Air + templ browser live reload (default) |
|
||||||
| `make build` | Build production `website` image |
|
| `make build` | Build production `website` image |
|
||||||
| `make up` | Run production `website` service |
|
| `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 generate` | Run `templ generate` in dev container |
|
||||||
| `make tidy` | Run `go mod tidy` in dev container |
|
| `make tidy` | Run `go mod tidy` in dev container |
|
||||||
| `make logs` | Follow dev container logs |
|
| `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
|
## 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.
|
**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.
|
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
|
## Project layout
|
||||||
|
|
||||||
@@ -86,7 +86,8 @@ app/
|
|||||||
upload/ Raw media — run `make sync-media` to publish into app/images/
|
upload/ Raw media — run `make sync-media` to publish into app/images/
|
||||||
Dockerfile Production image
|
Dockerfile Production image
|
||||||
Dockerfile.dev Dev image (Go + templ + Air)
|
Dockerfile.dev Dev image (Go + templ + Air)
|
||||||
docker-compose.yaml
|
compose.dev.yaml
|
||||||
|
compose.prod.yaml
|
||||||
.env.example
|
.env.example
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ root = "."
|
|||||||
tmp_dir = "tmp"
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
cmd = "templ generate --notify-proxy && go build -o ./tmp/main ./cmd/server"
|
cmd = "templ generate --notify-proxy && go build -o /tmp/website-dev-main ./cmd/server"
|
||||||
entrypoint = ["./tmp/main"]
|
entrypoint = ["/tmp/website-dev-main"]
|
||||||
delay = 300
|
delay = 300
|
||||||
exclude_dir = ["tmp", "bin", "images", "vendor", "testdata"]
|
exclude_dir = ["tmp", "bin", "images", "vendor", "testdata"]
|
||||||
exclude_regex = ["_templ.go"]
|
exclude_regex = ["_templ.go"]
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ func Parse(name, email, message string) (Submission, Errors) {
|
|||||||
case errors.Is(err, contactcheck.ErrName):
|
case errors.Is(err, contactcheck.ErrName):
|
||||||
errs["name"] = "Name is too long."
|
errs["name"] = "Name is too long."
|
||||||
case errors.Is(err, contactcheck.ErrNotEnglish):
|
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:
|
default:
|
||||||
errs["message"] = "Could not accept this message."
|
errs["message"] = "Could not accept this message."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ const (
|
|||||||
minMessageRunes = 20
|
minMessageRunes = 20
|
||||||
maxMessageRunes = 8000
|
maxMessageRunes = 8000
|
||||||
maxNameRunes = 120
|
maxNameRunes = 120
|
||||||
|
|
||||||
|
// Reject non-English only when the detector is fairly confident.
|
||||||
|
englishRejectConfidence = 0.80
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -38,13 +41,15 @@ func ValidateEnglish(name, message string) error {
|
|||||||
combined := strings.TrimSpace(name) + "\n\n" + msg
|
combined := strings.TrimSpace(name) + "\n\n" + msg
|
||||||
info := whatlanggo.Detect(combined)
|
info := whatlanggo.Detect(combined)
|
||||||
|
|
||||||
|
// Block clearly non-Latin scripts (CJK, Cyrillic, etc.).
|
||||||
if info.Script != nil && info.Script != unicode.Latin {
|
if info.Script != nil && info.Script != unicode.Latin {
|
||||||
return ErrNotEnglish
|
return ErrNotEnglish
|
||||||
}
|
}
|
||||||
if info.Lang != whatlanggo.Eng {
|
// Allow uncertain detection; block only confident non-English.
|
||||||
|
if info.Lang != whatlanggo.Eng && info.IsReliable() {
|
||||||
return ErrNotEnglish
|
return ErrNotEnglish
|
||||||
}
|
}
|
||||||
if !info.IsReliable() && info.Confidence < 0.55 {
|
if info.Lang != whatlanggo.Eng && info.Confidence >= englishRejectConfidence {
|
||||||
return ErrNotEnglish
|
return ErrNotEnglish
|
||||||
}
|
}
|
||||||
return nil
|
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"
|
const HoneypotField = "website"
|
||||||
|
|
||||||
// MinFormFillDuration is the minimum time between showing the form and submit.
|
// 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).
|
// SpamHoneypot reports whether the honeypot was filled (likely spam).
|
||||||
func SpamHoneypot(value string) bool {
|
func SpamHoneypot(value string) bool {
|
||||||
return strings.TrimSpace(value) != ""
|
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 {
|
func FormFilledTooFast(seenUnix int64, now time.Time) bool {
|
||||||
if seenUnix <= 0 {
|
if seenUnix <= 0 {
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
return now.Sub(time.Unix(seenUnix, 0)) < MinFormFillDuration
|
return now.Sub(time.Unix(seenUnix, 0)) < MinFormFillDuration
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ func TestFormFilledTooFast(t *testing.T) {
|
|||||||
now := time.Unix(1_000_000, 0)
|
now := time.Unix(1_000_000, 0)
|
||||||
seen := now.Add(-MinFormFillDuration).Unix()
|
seen := now.Add(-MinFormFillDuration).Unix()
|
||||||
|
|
||||||
if !FormFilledTooFast(0, now) {
|
if FormFilledTooFast(0, now) {
|
||||||
t.Error("missing seen time should be too fast")
|
t.Error("missing seen time should be allowed")
|
||||||
}
|
}
|
||||||
if !FormFilledTooFast(seen+1, now) {
|
if !FormFilledTooFast(seen+1, now) {
|
||||||
t.Error("submit before min duration should be too fast")
|
t.Error("submit before min duration should be too fast")
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ templ ContactForm(enabled bool) {
|
|||||||
<section id="contact" class="contact">
|
<section id="contact" class="contact">
|
||||||
<h2>Contact</h2>
|
<h2>Contact</h2>
|
||||||
<p class="contact-intro">
|
<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>
|
</p>
|
||||||
if !enabled {
|
if !enabled {
|
||||||
<p class="contact-unavailable">
|
<p class="contact-unavailable">
|
||||||
The contact form is not configured yet. Email
|
The contact form is not configured yet.
|
||||||
<a href="mailto:hello@technical.kiwi">hello@technical.kiwi</a>
|
|
||||||
directly.
|
|
||||||
</p>
|
</p>
|
||||||
} else {
|
} else {
|
||||||
<form
|
<form
|
||||||
@@ -33,7 +31,7 @@ templ ContactForm(enabled bool) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="message">Message</label>
|
<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>
|
</div>
|
||||||
<input
|
<input
|
||||||
class="hp-field"
|
class="hp-field"
|
||||||
@@ -70,8 +68,7 @@ templ ContactValidationAlert(errs map[string]string) {
|
|||||||
|
|
||||||
templ ContactSendError() {
|
templ ContactSendError() {
|
||||||
<div class="alert alert-error" role="alert">
|
<div class="alert alert-error" role="alert">
|
||||||
<p>Something went wrong sending your message. Please try again later or email
|
<p>Something went wrong sending your message. Please try again later.</p>
|
||||||
<a href="mailto:hello@technical.kiwi">hello@technical.kiwi</a>.</p>
|
|
||||||
</div>
|
</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