Skip to content

Commit 5da0d73

Browse files
committed
mailing list
1 parent 3c2deb5 commit 5da0d73

13 files changed

Lines changed: 436 additions & 9 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ content/
1010
*.AVI
1111
*.mov
1212

13+
# Local database
14+
*.db
15+

Caddyfile

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
:{$PORT}
22

3-
root * .
4-
file_server
3+
handle /api/* {
4+
reverse_proxy localhost:8090
5+
}
6+
7+
handle {
8+
root * .
9+
file_server
10+
}

api/Makefile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
test:
2+
go test -v ./...
3+
4+
build:
5+
GOOS=linux GOARCH=amd64 go build -o sayless-api .
6+
7+
clean:
8+
rm -f sayless-api
9+
10+
.PHONY: test build clean

api/go.mod

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module sayless/api
2+
3+
go 1.25.0
4+
5+
require (
6+
github.com/dustin/go-humanize v1.0.1 // indirect
7+
github.com/google/uuid v1.6.0 // indirect
8+
github.com/mattn/go-isatty v0.0.20 // indirect
9+
github.com/ncruces/go-strftime v1.0.0 // indirect
10+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
11+
golang.org/x/sys v0.42.0 // indirect
12+
modernc.org/libc v1.70.0 // indirect
13+
modernc.org/mathutil v1.7.1 // indirect
14+
modernc.org/memory v1.11.0 // indirect
15+
modernc.org/sqlite v1.47.0 // indirect
16+
)

api/go.sum

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
2+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
3+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
4+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
5+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
6+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
7+
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
8+
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
9+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
10+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
11+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
12+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
13+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
14+
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
15+
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
16+
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
17+
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
18+
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
19+
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
20+
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
21+
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=

api/main.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package main
2+
3+
import (
4+
"database/sql"
5+
"encoding/json"
6+
"flag"
7+
"log"
8+
"net/http"
9+
"strings"
10+
11+
_ "modernc.org/sqlite"
12+
)
13+
14+
func initDB(db *sql.DB) error {
15+
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS subscribers (
16+
id INTEGER PRIMARY KEY AUTOINCREMENT,
17+
email TEXT UNIQUE NOT NULL,
18+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
19+
)`)
20+
return err
21+
}
22+
23+
func makeHandler(db *sql.DB) http.HandlerFunc {
24+
return func(w http.ResponseWriter, r *http.Request) {
25+
w.Header().Set("Content-Type", "application/json")
26+
27+
if r.Method != "POST" {
28+
w.WriteHeader(405)
29+
json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": "method not allowed"})
30+
return
31+
}
32+
33+
email := strings.TrimSpace(r.FormValue("email"))
34+
35+
if email == "" || !strings.Contains(email, "@") || len(email) > 254 {
36+
w.WriteHeader(400)
37+
json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": "invalid email"})
38+
return
39+
}
40+
41+
_, err := db.Exec("INSERT OR IGNORE INTO subscribers (email) VALUES (?)", email)
42+
if err != nil {
43+
log.Printf("db error: %v", err)
44+
w.WriteHeader(500)
45+
json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": "server error"})
46+
return
47+
}
48+
49+
json.NewEncoder(w).Encode(map[string]any{"ok": true})
50+
}
51+
}
52+
53+
func main() {
54+
dbPath := flag.String("db", "/var/lib/sayless/sayless.db", "path to sqlite db")
55+
addr := flag.String("addr", "127.0.0.1:8090", "listen address")
56+
flag.Parse()
57+
58+
db, err := sql.Open("sqlite", *dbPath)
59+
if err != nil {
60+
log.Fatal(err)
61+
}
62+
defer db.Close()
63+
64+
if err := initDB(db); err != nil {
65+
log.Fatal(err)
66+
}
67+
68+
http.HandleFunc("/api/subscribe", makeHandler(db))
69+
70+
log.Printf("listening on %s", *addr)
71+
log.Fatal(http.ListenAndServe(*addr, nil))
72+
}

api/main_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package main
2+
3+
import (
4+
"database/sql"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
11+
_ "modernc.org/sqlite"
12+
)
13+
14+
func setupTestDB(t *testing.T) *sql.DB {
15+
t.Helper()
16+
db, err := sql.Open("sqlite", ":memory:")
17+
if err != nil {
18+
t.Fatal(err)
19+
}
20+
if err := initDB(db); err != nil {
21+
t.Fatal(err)
22+
}
23+
t.Cleanup(func() { db.Close() })
24+
return db
25+
}
26+
27+
func postSubscribe(handler http.HandlerFunc, body string) *httptest.ResponseRecorder {
28+
req := httptest.NewRequest("POST", "/api/subscribe", strings.NewReader(body))
29+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
30+
w := httptest.NewRecorder()
31+
handler(w, req)
32+
return w
33+
}
34+
35+
type response struct {
36+
OK bool `json:"ok"`
37+
Error string `json:"error,omitempty"`
38+
}
39+
40+
func parseResponse(t *testing.T, w *httptest.ResponseRecorder) response {
41+
t.Helper()
42+
var r response
43+
if err := json.Unmarshal(w.Body.Bytes(), &r); err != nil {
44+
t.Fatalf("invalid json: %s", w.Body.String())
45+
}
46+
return r
47+
}
48+
49+
func subscriberCount(t *testing.T, db *sql.DB) int {
50+
t.Helper()
51+
var count int
52+
if err := db.QueryRow("SELECT COUNT(*) FROM subscribers").Scan(&count); err != nil {
53+
t.Fatal(err)
54+
}
55+
return count
56+
}
57+
58+
func TestSubscribe(t *testing.T) {
59+
db := setupTestDB(t)
60+
handler := makeHandler(db)
61+
62+
t.Run("valid email", func(t *testing.T) {
63+
w := postSubscribe(handler, "email=test@example.com")
64+
if w.Code != 200 {
65+
t.Fatalf("expected 200, got %d", w.Code)
66+
}
67+
r := parseResponse(t, w)
68+
if !r.OK {
69+
t.Fatal("expected ok=true")
70+
}
71+
if n := subscriberCount(t, db); n != 1 {
72+
t.Fatalf("expected 1 subscriber, got %d", n)
73+
}
74+
})
75+
76+
t.Run("duplicate email", func(t *testing.T) {
77+
w := postSubscribe(handler, "email=dupe@example.com")
78+
if w.Code != 200 {
79+
t.Fatalf("expected 200, got %d", w.Code)
80+
}
81+
before := subscriberCount(t, db)
82+
w = postSubscribe(handler, "email=dupe@example.com")
83+
if w.Code != 200 {
84+
t.Fatalf("expected 200, got %d", w.Code)
85+
}
86+
r := parseResponse(t, w)
87+
if !r.OK {
88+
t.Fatal("expected ok=true for duplicate")
89+
}
90+
after := subscriberCount(t, db)
91+
if after != before {
92+
t.Fatalf("duplicate inserted: before=%d after=%d", before, after)
93+
}
94+
})
95+
96+
t.Run("missing email", func(t *testing.T) {
97+
w := postSubscribe(handler, "")
98+
if w.Code != 400 {
99+
t.Fatalf("expected 400, got %d", w.Code)
100+
}
101+
r := parseResponse(t, w)
102+
if r.OK {
103+
t.Fatal("expected ok=false")
104+
}
105+
})
106+
107+
t.Run("invalid email", func(t *testing.T) {
108+
w := postSubscribe(handler, "email=notanemail")
109+
if w.Code != 400 {
110+
t.Fatalf("expected 400, got %d", w.Code)
111+
}
112+
r := parseResponse(t, w)
113+
if r.OK {
114+
t.Fatal("expected ok=false")
115+
}
116+
})
117+
118+
t.Run("email too long", func(t *testing.T) {
119+
long := "email=" + strings.Repeat("a", 250) + "@b.com"
120+
w := postSubscribe(handler, long)
121+
if w.Code != 400 {
122+
t.Fatalf("expected 400, got %d", w.Code)
123+
}
124+
})
125+
126+
t.Run("wrong method", func(t *testing.T) {
127+
req := httptest.NewRequest("GET", "/api/subscribe", nil)
128+
w := httptest.NewRecorder()
129+
handler(w, req)
130+
if w.Code != 405 {
131+
t.Fatalf("expected 405, got %d", w.Code)
132+
}
133+
})
134+
}

dev.sh

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/bin/bash
2+
# Local dev: starts API + Caddy
3+
# Press 'r' to restart the API
4+
cd "$(dirname "$0")"
5+
ROOT="$PWD"
6+
export PORT=${PORT:-8080}
7+
API_PID=""
8+
9+
start_api() {
10+
if [ -n "$API_PID" ]; then
11+
kill -- -"$API_PID" 2>/dev/null || true
12+
wait "$API_PID" 2>/dev/null || true
13+
fi
14+
echo "Starting API on :8090..."
15+
set -m
16+
(cd api && go run . -db "$ROOT/sayless.db" -addr 127.0.0.1:8090) &
17+
API_PID=$!
18+
set +m
19+
}
20+
21+
CADDY_PID=""
22+
23+
cleanup() {
24+
kill -9 -- -"$API_PID" 2>/dev/null || true
25+
kill -9 "$CADDY_PID" 2>/dev/null || true
26+
exit 0
27+
}
28+
trap cleanup INT TERM
29+
30+
start_api
31+
32+
echo "Starting Caddy on :$PORT..."
33+
caddy run &
34+
CADDY_PID=$!
35+
36+
echo ""
37+
echo "Press 'r' to restart API"
38+
echo ""
39+
40+
while true; do
41+
read -rsn1 key
42+
if [ "$key" = "r" ]; then
43+
echo "Restarting API..."
44+
start_api
45+
fi
46+
done

hetzner/deploy.sh

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,16 @@ rsync -avz --delete \
2424
"$PROJECT_DIR/" "$SERVER:$REMOTE_PATH"
2525

2626
echo ""
27-
echo "Reloading Caddy..."
28-
ssh $SERVER 'systemctl reload caddy'
27+
echo "Building API..."
28+
cd "$PROJECT_DIR/api" && GOOS=linux GOARCH=amd64 go build -o sayless-api .
29+
echo "Deploying API binary..."
30+
scp sayless-api "$SERVER:/usr/local/bin/"
31+
rm sayless-api
32+
cd "$PROJECT_DIR"
33+
34+
echo ""
35+
echo "Reloading Caddy and restarting API..."
36+
ssh $SERVER 'systemctl reload caddy && systemctl restart sayless-api'
2937

3038
echo ""
3139
echo "=== Deploy complete ==="

0 commit comments

Comments
 (0)