A REST API service for managing blog comments with anti-spam protection via Cloudflare Turnstile. Comments are stored in SQLite and held for moderation before being served to readers.
Readers submit comments through a Turnstile-protected API endpoint. Comments are created in an unapproved state and stored in SQLite. Approved comments are served publicly via read-only endpoints. An admin interface at /admin allows moderators to review, approve, and delete comments. Email notifications can be sent to the admin when new comments arrive.
Deploy using Docker Compose:
name: replyforge
volumes:
comments_data:
services:
comments:
image: dockershepherd/replyforge:dev
env_file:
- path: ./.env.production
required: true
volumes:
- comments_data:/app/data
ports:
- 127.0.0.1:8080:8080Configuration is loaded from environment variables. Copy .env.example to .env and adjust as needed.
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
Server listen port |
DATABASE_PATH |
data/comments.db |
SQLite database file path |
LOG_LEVEL |
info |
Log level: debug, info, warn, error |
TURNSTILE_SECRET_KEY |
(required) | Cloudflare Turnstile secret key |
ALLOWED_ORIGINS |
* |
Comma-separated CORS origins |
RATE_LIMIT_RPM |
10 |
Rate limit requests per minute per IP |
RATE_LIMIT_BURST |
15 |
Rate limit burst capacity |
READ_TIMEOUT |
10s |
HTTP read timeout |
WRITE_TIMEOUT |
10s |
HTTP write timeout |
IDLE_TIMEOUT |
60s |
HTTP idle timeout |
SHUTDOWN_TIMEOUT |
30s |
Graceful shutdown deadline |
VERSION |
dev |
Version string reported in health checks |
ADMIN_PASSWORD_HASH |
(not set) | Argon2id PHC hash for the admin password |
SMTP_HOST |
(not set) | SMTP server hostname (empty = disabled) |
SMTP_PORT |
587 |
SMTP server port |
SMTP_USERNAME |
(not set) | SMTP username for PLAIN auth |
SMTP_PASSWORD |
(not set) | SMTP password for PLAIN auth |
SMTP_FROM |
(not set) | Sender email address for notifications |
SMTP_TO |
(not set) | Recipient email address for notifications |
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/comments |
Create a comment |
GET |
/api/v1/comments |
List approved comments |
GET |
/api/v1/comments/{id} |
Get a comment by ID |
GET |
/health |
Health check with database status |
GET |
/debug/vars |
Runtime metrics (expvar) |
All endpoints return Content-Type: application/json. A unique X-Request-ID header is set on every response for tracing.
Errors follow a consistent structure:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The request contains invalid parameters",
"details": [{ "field": "author_name", "message": "is required" }]
}
}| Code | HTTP Status | Description |
|---|---|---|
VALIDATION_ERROR |
400 |
Request body failed validation. details array contains per-field errors. |
TURNSTILE_FAILED |
403 |
Cloudflare Turnstile token was missing, invalid, or verification failed. |
NOT_FOUND |
404 |
Comment does not exist or is not yet approved. |
INTERNAL_ERROR |
500 |
Unexpected server error. Retry later. |
POST /api/v1/comments
Request body:
{
"post_id": "my-blog-post-1",
"author_name": "Alice",
"body": "Great article, thanks for sharing!",
"turnstile_token": "0x4AA...client-turnstile-token..."
}| Field | Type | Max Length | Required | Description |
|---|---|---|---|---|
post_id |
string | 100 | yes | Identifies which post the comment belongs to. Free-form string. |
author_name |
string | 100 | yes | Display name of the commenter. Stripped of all HTML tags. |
body |
string | 5000 | yes | Comment text. Stripped of all HTML tags. |
turnstile_token |
string | — | yes | Cloudflare Turnstile client-side token obtained from the widget. |
Validation rules:
- All four fields are required. Empty strings are rejected.
author_nameandbodyare sanitized server-side: all HTML tags stripped, trimmed, Unicode NFC normalized, whitespace collapsed, null bytes removed.turnstile_tokenmust be a valid Cloudflare Turnstile token. Verification is cached in-memory for 5 minutes (keyed by SHA256(token + client IP)).
Rate limiting:
This endpoint is rate-limited per client IP (default: 10 requests per minute, burst of 15). Exceeding the limit returns 429 Too Many Requests.
Success response (201 Created):
{
"data": {
"id": 42,
"display_id": "aB3xK7",
"post_id": "my-blog-post-1",
"author_name": "Alice",
"body": "Great article, thanks for sharing!",
"approved": false,
"created_at": "2026-07-19T12:00:00Z"
}
}All comments are created with approved: false and require admin moderation before appearing in the public listing. The display_id is a short unique identifier (generated via sqids from the numeric id), suitable for use in URL fragments or client-side references.
GET /api/v1/comments?post_id=my-blog-post-1&page=1&per_page=20&sort=created_at
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
post_id |
string | — | Filter comments belonging to a specific post. |
page |
int | 1 |
Page number (1-indexed). |
per_page |
int | 20 |
Items per page (max 100). |
sort |
string | — | Sort order. Set to created_at for ascending chronological order. Defaults to descending (newest first). |
Success response (200 OK):
{
"data": [
{
"id": 42,
"display_id": "aB3xK7",
"post_id": "my-blog-post-1",
"author_name": "Alice",
"body": "Great article, thanks for sharing!",
"approved": true,
"created_at": "2026-07-19T12:00:00Z"
}
],
"pagination": {
"page": 1,
"per_page": 20,
"total": 1,
"total_pages": 1
}
}Only approved comments are returned. Unapproved comments are invisible to this endpoint.
GET /api/v1/comments/{id}
{id} is the numeric comment ID (e.g. 42). Returns 404 Not Found if the comment does not exist or is not yet approved.
Success response (200 OK):
{
"data": {
"id": 42,
"display_id": "aB3xK7",
"post_id": "my-blog-post-1",
"author_name": "Alice",
"body": "Great article, thanks for sharing!",
"approved": true,
"created_at": "2026-07-19T12:00:00Z"
}
}An HTML admin interface for comment moderation is available at GET /admin. Generate an admin password hash with just hash and add ADMIN_PASSWORD_HASH to your .env. Four admin API endpoints are available:
| Action | Endpoint | Auth Required |
|---|---|---|
| Get a comment | GET /api/v1/admin/comments/{id} |
No |
| List all comments | GET /api/v1/admin/comments |
No |
| Toggle approval | POST /api/v1/admin/comments/{id}/toggle-approval |
Password in JSON body |
| Delete a comment | DELETE /api/v1/admin/comments/{id} |
Password in JSON body |
Available on Docker Hub
Example:
services:
replyforge:
image: dockershepherd/replyforge:dev-36f3fc4
ports:
- "8080:8080"
volumes:
- ./data:/app/data
env_file:
- .env
restart: unless-stoppedgo build -ldflags="-s -w" -o bin/server ./cmd/server
./bin/serverEnsure the parent directory of DATABASE_PATH exists and is writable.
just fmt # Format code with goimports
just lint # Run golangci-lint
just test # Run all tests with race detector
just build # Build the server binary
just run # Build and run