Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View file

@ -0,0 +1,76 @@
# Backend-specific Claude Code ignore file
# Go-specific
vendor/
*.sum
# Generated mocks
mocks/
**/mocks/
# Static data files
static/GeoLite2-Country.mmdb
static/**/*.dat
static/**/*.db
static/blacklist/
# Build artifacts
*.exe
mapleopentech-backend
bin/
# Coverage and test artifacts
*.out
coverage.txt
*.test
# Logs
*.log
logs/
#—————————————————————————————
# Application Specific Ignores
#—————————————————————————————
# Do not share production data used to populate the project's database.
data
badgerdb_data
# Do not share developer's private notebook
private.txt
private_prod.md
private.md
private_*.md
todo.txt
private_docs
private_docs/*
# Executable
bin/
mapleopentech-backend
# Do not store the keystore
static/keystore
# Do not share our GeoLite database.
GeoLite2-Country.mmdb
# Do not save the `crev` text output
crev-project.txt
# Blacklist - Don't share items we banned from the server.
static/blacklist/ips.json
static/blacklist/urls.json
internal/static/blacklist/ips.json
internal/static/blacklist/urls.json
static/cassandra-jdbc-wrapper-*
# Do not save our temporary files.
tmp
# Temporary - don't save one module yet.
internal/ipe.zip
internal/papercloud.zip
# Do not share private developer documentation
_md/*

View file

@ -0,0 +1,19 @@
# Docker ignore file
# Ignore local environment files - use docker-compose environment instead
.env
.env.local
.env.*.local
# Ignore build artifacts
maplepress-backend
*.log
# Ignore git
.git
.gitignore
# Ignore IDE files
.vscode
.idea
*.swp
*.swo

View file

@ -0,0 +1,229 @@
# ============================================================================
# Application Configuration
# ============================================================================
# Environment: development, production
# - development: Local development with debug logging and test API keys (test_sk_*)
# - production: Live production environment with live API keys (live_sk_*)
APP_ENVIRONMENT=development
APP_VERSION=0.1.0
# JWT Secret: Used for signing JWT tokens for user authentication
# SECURITY CRITICAL: This MUST be changed in production!
# - Minimum length: 32 characters (256 bits)
# - Recommended length: 64 characters (512 bits)
# - Must be cryptographically random
# - Never commit production secrets to version control
#
# Generate a secure secret:
# openssl rand -base64 64
#
# WARNING: The application will refuse to start in production with:
# - Default/placeholder values (containing "change", "sample", etc.)
# - Common weak secrets ("secret", "password", "12345", etc.)
# - Secrets shorter than 32 characters
#
# For development ONLY (this value will trigger a warning):
APP_JWT_SECRET=change-me-in-production-use-a-long-random-string
# HTTP Server Configuration
SERVER_HOST=0.0.0.0
SERVER_PORT=8000
# ============================================================================
# Security Configuration
# ============================================================================
# CORS Allowed Origins: Comma-separated list of allowed frontend origins
# IMPORTANT: Configure this in production to allow your frontend domain(s)
#
# Example for production:
# SECURITY_CORS_ALLOWED_ORIGINS=https://getmaplepress.com,https://www.getmaplepress.com
#
# For development:
# - Localhost origins (http://localhost:5173, etc.) are automatically allowed
# - Leave empty or add additional development origins if needed
#
# Security notes:
# - Use HTTPS in production (https://, not http://)
# - Include both www and non-www versions if needed
# - Do NOT use wildcards (*) - specify exact origins
#
SECURITY_CORS_ALLOWED_ORIGINS=
# ============================================================================
# Cassandra Database Configuration
# ============================================================================
# Default: Docker development (task dev)
# For running OUTSIDE Docker (go run main.go daemon):
# Change to: DATABASE_HOSTS=localhost
DATABASE_HOSTS=cassandra-1,cassandra-2,cassandra-3
DATABASE_KEYSPACE=maplepress
DATABASE_CONSISTENCY=QUORUM
DATABASE_REPLICATION=3
DATABASE_MIGRATIONS_PATH=file://migrations
# ============================================================================
# Redis Cache Configuration
# ============================================================================
# Default: Docker development (task dev)
# For running OUTSIDE Docker (go run main.go daemon):
# Change to: CACHE_HOST=localhost
CACHE_HOST=redis
CACHE_PORT=6379
CACHE_PASSWORD=
CACHE_DB=0
# ============================================================================
# AWS S3 Configuration (Optional - for object storage)
# ============================================================================
# Default: Docker development (task dev) with SeaweedFS
# For running OUTSIDE Docker with SeaweedFS:
# Change to: AWS_ENDPOINT=http://localhost:8333
# For AWS S3:
# AWS_ENDPOINT can be left empty or set to https://s3.amazonaws.com
# For S3-compatible services (DigitalOcean Spaces, MinIO, etc.):
# AWS_ENDPOINT should be the service endpoint
#
# SeaweedFS development settings (accepts any credentials):
AWS_ACCESS_KEY=any
AWS_SECRET_KEY=any
AWS_ENDPOINT=http://seaweedfs:8333
AWS_REGION=us-east-1
AWS_BUCKET_NAME=maplepress
# ============================================================================
# Logger Configuration
# ============================================================================
# Levels: debug, info, warn, error
# Formats: json, console
LOGGER_LEVEL=debug
LOGGER_FORMAT=console
# ============================================================================
# Meilisearch Configuration
# ============================================================================
# Default: Docker development (task dev)
# For running OUTSIDE Docker:
# Change to: MEILISEARCH_HOST=http://localhost:7700
MEILISEARCH_HOST=http://meilisearch:7700
MEILISEARCH_API_KEY=maple-dev-master-key-change-in-production
MEILISEARCH_INDEX_PREFIX=site_
# ============================================================================
# Rate Limiting Configuration
# ============================================================================
# Four-tier rate limiting architecture for comprehensive protection
# Uses Redis for distributed tracking across multiple instances
# All rate limiters implement fail-open design (allow on Redis failure)
# ============================================================================
# 1. Registration Rate Limiter (CWE-307: Account Creation Protection)
# ============================================================================
# Protects against automated account creation, bot signups, and account farming
# Strategy: IP-based limiting
# Recommended: Very strict (prevents abuse while allowing legitimate signups)
RATELIMIT_REGISTRATION_ENABLED=true
# Maximum number of registration attempts per IP address
RATELIMIT_REGISTRATION_MAX_REQUESTS=10
# Time window for rate limiting (Go duration format)
# Examples: "1h" (default), "30m", "24h"
RATELIMIT_REGISTRATION_WINDOW=1h
# ============================================================================
# 2. Login Rate Limiter (CWE-307: Brute Force Protection)
# ============================================================================
# Dual protection: IP-based rate limiting + account lockout mechanism
# Protects against credential stuffing, brute force, and password guessing attacks
# Strategy: Dual (IP-based for distributed attacks + account-based for targeted attacks)
# Recommended: Moderate (balance security with user experience)
RATELIMIT_LOGIN_ENABLED=true
# Maximum login attempts per IP address
RATELIMIT_LOGIN_MAX_ATTEMPTS_PER_IP=10
# Time window for IP-based rate limiting
# Examples: "15m" (default), "10m", "30m"
RATELIMIT_LOGIN_IP_WINDOW=15m
# Maximum failed attempts before account lockout
RATELIMIT_LOGIN_MAX_FAILED_ATTEMPTS_PER_ACCOUNT=10
# Account lockout duration after too many failed attempts
# Examples: "30m" (default), "1h", "15m"
RATELIMIT_LOGIN_ACCOUNT_LOCKOUT_DURATION=30m
# ============================================================================
# 3. Generic CRUD Endpoints Rate Limiter (CWE-770: Resource Exhaustion Protection)
# ============================================================================
# Protects authenticated CRUD operations: tenant/user/site management, admin endpoints
# Strategy: User-based (authenticated user ID from JWT)
# Recommended: Lenient (allow normal operations, prevent resource exhaustion)
# Applies to: /api/v1/tenants, /api/v1/users, /api/v1/sites, /api/v1/admin/*, /api/v1/me, /api/v1/hello
RATELIMIT_GENERIC_ENABLED=true
# Maximum requests per authenticated user per window
# Default: 100 requests per hour (1.67 req/min)
# Lenient for normal admin panel usage, mobile apps, and automation scripts
RATELIMIT_GENERIC_MAX_REQUESTS=100
# Time window for rate limiting
# Examples: "1h" (default), "30m", "2h"
RATELIMIT_GENERIC_WINDOW=1h
# ============================================================================
# 4. Plugin API Rate Limiter (CWE-770: DoS Prevention for Core Business)
# ============================================================================
# Protects WordPress plugin API endpoints - CORE BUSINESS ENDPOINTS
# Strategy: Site-based (API key → site_id)
# Recommended: Very lenient (supports high-volume legitimate traffic)
# Applies to: /api/v1/plugin/* (status, sync, search, delete, pages)
#
# IMPORTANT: These are revenue-generating endpoints. Limits should be high enough
# to support legitimate WordPress sites with:
# - Large page counts (1000+ pages)
# - Frequent content updates
# - Search-heavy workloads
# - Bulk operations
RATELIMIT_PLUGIN_API_ENABLED=true
# Maximum requests per site (API key) per window
# Default: 10000 requests per hour (166.67 req/min, ~2.78 req/sec)
# Usage-based billing: Very generous limits for anti-abuse only
# This supports high-volume WordPress sites (240K/day, 7.2M/month)
# Limits only hit during abuse scenarios or plugin bugs (infinite loops)
RATELIMIT_PLUGIN_API_MAX_REQUESTS=10000
# Time window for rate limiting
# Examples: "1h" (default), "30m", "2h"
RATELIMIT_PLUGIN_API_WINDOW=1h
# ============================================================================
# Scheduler Configuration
# ============================================================================
# Automated quota reset scheduler (cron-based)
# Enable/disable monthly quota resets
SCHEDULER_QUOTA_RESET_ENABLED=true
# Cron schedule for quota resets (default: 1st of month at midnight)
# Format: minute hour day month weekday
# Examples:
# "0 0 1 * *" = 1st of month at midnight (default)
# "0 2 * * *" = Every day at 2 AM (testing)
# "*/5 * * * *" = Every 5 minutes (development)
SCHEDULER_QUOTA_RESET_SCHEDULE="0 0 1 * *"
# ============================================================================
# Leader Election Configuration
# ============================================================================
# Distributed leader election for multi-instance deployments
# Uses Redis for coordination - ensures only one instance runs scheduled tasks
# Auto-configures instance identity (hostname + random suffix)
#
# Enable/disable leader election
LEADER_ELECTION_ENABLED=true
# Lock TTL: How long the leader lock lasts before expiring (Go duration)
# The leader must renew before this expires. Default: 10s
# Recommended: 10-30s
LEADER_ELECTION_LOCK_TTL=10s
# Heartbeat interval: How often the leader renews its lock (Go duration)
# Should be significantly less than LockTTL (e.g., LockTTL / 3). Default: 3s
# Recommended: LockTTL / 3
LEADER_ELECTION_HEARTBEAT_INTERVAL=3s
# Retry interval: How often followers check for leadership opportunity (Go duration)
# Default: 2s. Recommended: 1-5s
LEADER_ELECTION_RETRY_INTERVAL=2s

250
cloud/maplepress-backend/.gitignore vendored Normal file
View file

@ -0,0 +1,250 @@
#—————————
# OSX
#—————————
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear on external disk
.Spotlight-V100
.Trashes
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
#—————————
# WINDOWS
#—————————
# Windows image file caches
Thumbs.db
ehthumbs.db
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msm
*.msp
#—————————
# LINUX
#—————————
# KDE directory preferences
.directory
.idea # PyCharm
*/.idea/
#—————————
# Python
#—————————
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# dotenv
.env
# virtualenv
.venv
venv/
ENV/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
#—————————————————————————————
# Python VirtualEnv Directory
#—————————————————————————————
# Important Note: Make sure this is the name of the virtualenv directory
# that you set when you where setting up the project.
env/
env/*
env
.env
.env.local
*.cfg
env/pip-selfcheck.json
*.csv#
.env.production
.env.prod
.env.qa
#—————————
# GOLANG
#—————————
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Wire generated code
wire_gen.go
app/wire_gen.go
#—————————————————————————————
# Application Specific Ignores
#—————————————————————————————
# Do not share production data used to populate the project's database.
data
badgerdb_data
# Do not share developer's private notebook
private.txt
private_prod.md
private.md
private_*.md
todo.txt
private_docs
private_docs/*
# Do not share some templates
static/Pedigree.pdf
# Executable
bin/
maplepress-backend
*.exe
*.dll
*.so
*.dylib
# Do not store the keystore
static/keystore
# Do not share our GeoLite database.
GeoLite2-Country.mmdb
# Do not save the `crev` text output
crev-project.txt
# Blacklist - Don't share items we banned from the server.
static/blacklist/ips.json
static/blacklist/urls.json
internal/static/blacklist/ips.json
internal/static/blacklist/urls.json
static/cassandra-jdbc-wrapper-*
# Do not save our temporary files.
tmp
# Temporary - don't save one module yet.
internal/ipe.zip
internal/papercloud.zip
# Do not share private developer documentation
_md/*

View file

@ -0,0 +1,58 @@
# DEVELOPERS NOTE:
# THE PURPOSE OF THIS DOCKERFILE IS TO BUILD OUR MODULAR-MONOLITH
# EXECUTABLE IN A CONTAINER FOR A LINUX ENVIRONMENT USING A AMD64 PROCESSOR
# CHIPSET. WE PURPOSEFULLY CHOSE THIS ENVIRONMENT / CHIPSET BECAUSE THIS IS
# THE SAME AS OUR PRIVATE CLOUD HOSTING PROVIDER AS THE PURPOSE OF THIS
# CONTAINER IS TO RUN IN THEIR INFRASTRUCTURE.
###
### Build Stage
###
# The base go-image
FROM golang:1.24-alpine AS build-env
# Create a directory for the app
RUN mkdir /app
# Set working directory
WORKDIR /app
# Special thanks to speeding up the docker builds using steps (1) (2) and (3) via:
# https://stackoverflow.com/questions/50520103/speeding-up-go-builds-with-go-1-10-build-cache-in-docker-containers
# (1) Copy your dependency list
COPY go.mod go.sum ./
# (2) Install dependencies
RUN go mod download
# (3) Copy all files from the current directory to the `/app` directory which we are currently in.
COPY . .
# Run command as described:
# go build will build a 64bit Linux executable binary file named server in the current directory
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o maplepress-backend .
###
### Run stage.
###
FROM alpine:latest
# Set working directory
WORKDIR /app
# Copy only required data into this image
COPY --from=build-env /app/maplepress-backend .
# Copy all the static content necessary for this application to run.
COPY --from=build-env /app/static ./static
# Copy database migrations
COPY --from=build-env /app/migrations ./migrations
EXPOSE 8000
# Run the server executable
CMD [ "/app/maplepress-backend", "daemon"]

View file

@ -0,0 +1,387 @@
# 🚀 MaplePress Backend
> Cloud-powered services platform for WordPress sites - Multi-tenant SaaS backend built with Go.
MaplePress offloads computationally intensive tasks from WordPress to improve site performance. Features include cloud-powered search (Meilisearch), JWT authentication for users, API key authentication for WordPress plugins, and multi-tenant architecture.
## 📋 Prerequisites
**⚠️ Required:** You must have the infrastructure running first.
If you haven't set up the infrastructure yet:
1. Go to [`../infrastructure/README.md`](../infrastructure/README.md)
2. Follow the setup instructions
3. Come back here once infrastructure is running
**Verify infrastructure is healthy:**
```bash
cd cloud/infrastructure/development
task dev:status
# All services should show (healthy)
```
## 🏁 Getting Started
### Installation
```bash
# From the monorepo root:
cd cloud/maplepress-backend
# Create environment file:
cp .env.sample .env
# Start the backend:
task dev
```
The backend runs at **http://localhost:8000**
### Verify Installation
Open a **new terminal** (leave `task dev` running):
```bash
curl http://localhost:8000/health
# Should return: {"status":"healthy"}
```
> **Note:** Your first terminal shows backend logs. Keep it running and use a second terminal for testing.
## 💻 Developing
### Initial Configuration
**Environment Files:**
- **`.env.sample`** - Template with defaults (committed to git)
- **`.env`** - Your local configuration (git-ignored, created from `.env.sample`)
- Use **only `.env`** for configuration (docker-compose loads this file)
The `.env` file defaults work for Docker development. **Optional:** Change `APP_JWT_SECRET` to a random string (use a password generator).
### Running in Development Mode
```bash
# Start backend with hot-reload
task dev
# View logs (in another terminal)
docker logs -f maplepress-backend-dev
# Stop backend
task dev:down
# Or press Ctrl+C in the task dev terminal
```
**What happens when you run `task dev`:**
- Docker starts the backend container
- Auto-migrates database tables
- Starts HTTP server on port 8000
- Enables hot-reload (auto-restarts on code changes)
Wait for: `Server started on :8000` in the logs
### Daily Workflow
```bash
# Morning - check infrastructure (from monorepo root)
cd cloud/infrastructure/development && task dev:status
# Start backend (from monorepo root)
cd cloud/maplepress-backend && task dev
# Make code changes - backend auto-restarts
# Stop backend when done
# Press Ctrl+C
```
### Testing
```bash
# Run all tests
task test
# Code quality checks
task format # Format code
task lint # Run linters
```
### Database Operations
**View database:**
```bash
# From monorepo root
cd cloud/infrastructure/development
task cql
# Inside cqlsh:
USE maplepress;
DESCRIBE TABLES;
SELECT * FROM sites_by_id;
```
**Reset database (⚠️ deletes all data):**
```bash
task db:clear
```
## 🔧 Usage
### Testing the API
Create a test user and site to verify the backend works:
**1. Register a user:**
```bash
curl -X POST http://localhost:8000/api/v1/register \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "MySecureP@ssw0rd2024!XyZ",
"name": "Test User",
"tenant_name": "Test Organization",
"tenant_slug": "test-org",
"agree_terms_of_service": true
}'
```
> **Note:** MaplePress checks passwords against the [Have I Been Pwned](https://haveibeenpwned.com/) database. If your password has been found in data breaches, registration will fail. Use a strong, unique password that hasn't been compromised.
**Response:**
```json
{
"user_id": "uuid-here",
"user_email": "test@example.com",
"user_name": "Test User",
"tenant_id": "uuid-here",
"tenant_name": "Test Organization",
"access_token": "eyJhbGci...",
"refresh_token": "eyJhbGci...",
"access_expiry": "2025-10-29T12:00:00Z",
"refresh_expiry": "2025-11-05T12:00:00Z"
}
```
Save the `access_token` from the response:
```bash
export TOKEN="eyJhbGci...your-access-token-here"
```
**2. Get your profile:**
```bash
curl http://localhost:8000/api/v1/me \
-H "Authorization: JWT $TOKEN"
```
**3. Create a WordPress site:**
```bash
curl -X POST http://localhost:8000/api/v1/sites \
-H "Content-Type: application/json" \
-H "Authorization: JWT $TOKEN" \
-d '{
"domain": "localhost:8081",
"site_url": "http://localhost:8081"
}'
```
Save the `api_key` from the response (shown only once):
```bash
export API_KEY="your-api-key-here"
```
**4. Test plugin authentication:**
```bash
curl http://localhost:8000/api/v1/plugin/status \
-H "Authorization: Bearer $API_KEY"
```
### WordPress Plugin Integration
**Access WordPress:**
- URL: http://localhost:8081/wp-admin
- Credentials: admin / admin
**Configure the plugin:**
1. Go to **Settings → MaplePress**
2. Enter:
- **API URL:** `http://maplepress-backend-dev:8000`
- **API Key:** Your API key from step 3 above
3. Click **Save Settings & Verify Connection**
⚠️ **Important:** Use the container name (`maplepress-backend-dev`), not `localhost`, because WordPress runs in Docker.
**Next steps:**
- WordPress plugin setup: [`../../native/wordpress/README.md`](../../native/wordpress/README.md)
- Complete API documentation: [`docs/API/README.md`](docs/API/README.md)
### Error Handling
MaplePress uses **RFC 9457 (Problem Details for HTTP APIs)** for standardized error responses. All errors return a consistent, machine-readable format:
```json
{
"type": "about:blank",
"title": "Validation Error",
"status": 400,
"detail": "One or more validation errors occurred",
"errors": {
"email": ["Invalid email format"],
"password": ["Password must be at least 8 characters"]
}
}
```
**Benefits:**
- 📋 Structured, predictable error format
- 🤖 Machine-readable for frontend parsing
- 🌍 Industry standard ([RFC 9457](https://datatracker.ietf.org/doc/html/rfc9457))
- 🔍 Field-level validation errors
See [`docs/API/README.md#error-handling`](docs/API/README.md#error-handling) for complete error documentation.
## ⚙️ Configuration
### Environment Variables
Key variables in `.env`:
| Variable | Default | Description |
|----------|---------|-------------|
| `APP_JWT_SECRET` | `change-me-in-production-use-a-long-random-string` | Secret for JWT token signing |
| `SERVER_PORT` | `8000` | HTTP server port |
| `DATABASE_HOSTS` | `cassandra-1,cassandra-2,cassandra-3` | Cassandra cluster nodes |
| `CACHE_HOST` | `redis` | Redis cache host |
| `MEILISEARCH_HOST` | `http://meilisearch:7700` | Search engine URL |
**Docker vs Local:**
- Docker: Uses container names (`cassandra-1`, `redis`)
- Local: Change to `localhost`
See `.env.sample` for complete documentation.
### Task Commands
| Command | Description |
|---------|-------------|
| `task dev` | Start backend (auto-migrate + hot-reload) |
| `task dev:down` | Stop backend |
| `task test` | Run tests |
| `task format` | Format code |
| `task lint` | Run linters |
| `task db:clear` | Reset database (⚠️ deletes data) |
| `task migrate:up` | Manual migration |
## 🔍 Troubleshooting
### Backend won't start - "connection refused"
**Error:** `dial tcp 127.0.0.1:9042: connect: connection refused`
**Cause:** `.env` file has `localhost` instead of container names.
**Fix:**
```bash
cd cloud/maplepress-backend
rm .env
cp .env.sample .env
task dev
```
### Infrastructure not running
**Error:** Cassandra or Redis not available
**Fix:**
```bash
cd cloud/infrastructure/development
task dev:start
task dev:status # Wait until all show (healthy)
```
### Port 8000 already in use
**Fix:**
```bash
lsof -i :8000 # Find what's using the port
# Stop the other service, or change SERVER_PORT in .env
```
### Token expired (401 errors)
JWT tokens expire after 60 minutes. Re-run the [registration step](#testing-the-api) to get a new token.
### WordPress can't connect
**Problem:** WordPress using `localhost:8000` instead of container name
**Fix:** In WordPress settings, use `http://maplepress-backend-dev:8000`
**Verify:**
```bash
docker exec maple-wordpress-dev curl http://maplepress-backend-dev:8000/health
```
## 🛠️ Technology Stack
- **Go 1.23+** - Programming language
- **Clean Architecture** - Code organization
- **Wire** - Dependency injection
- **Cassandra** - Multi-tenant database (3-node cluster)
- **Redis** - Caching layer
- **Meilisearch** - Full-text search
- **JWT** - User authentication
- **API Keys** - Plugin authentication
## 🌐 Services
When you run MaplePress, these services are available:
| Service | Port | Purpose | Access |
|---------|------|---------|--------|
| MaplePress Backend | 8000 | HTTP API | http://localhost:8000 |
| Cassandra | 9042 | Database | `task cql` (from infrastructure dir) |
| Redis | 6379 | Cache | `task redis` (from infrastructure dir) |
| Meilisearch | 7700 | Search | http://localhost:7700 |
| WordPress | 8081 | Plugin testing | http://localhost:8081 |
## 🧪 Test Mode vs Live Mode
MaplePress **automatically** generates the correct API key type based on your environment:
### Automatic Behavior
| Environment | API Key Type | When to Use |
|-------------|-------------|-------------|
| `development` | `test_sk_*` | Local development, testing |
| `production` | `live_sk_*` | Production sites |
**Configuration (`.env`):**
```bash
# Development - automatically generates test_sk_ keys
APP_ENVIRONMENT=development
# Production - automatically generates live_sk_ keys
APP_ENVIRONMENT=production
```
**No manual parameter needed!** The backend determines the key type from `APP_ENVIRONMENT`.
See [`docs/API.md#test-mode-vs-live-mode`](docs/API.md#test-mode-vs-live-mode) for details.
## 🔗 Links
- **API Documentation:** [`docs/API.md`](docs/API.md)
- **Developer Guide:** [`docs/DEVELOPER_GUIDE.md`](docs/DEVELOPER_GUIDE.md)
- **Getting Started Guide:** [`docs/GETTING-STARTED.md`](docs/GETTING-STARTED.md)
- **WordPress Plugin:** [`../../native/wordpress/README.md`](../../native/wordpress/README.md)
- **Architecture Details:** [`../../CLAUDE.md`](../../CLAUDE.md)
- **Repository:** [Codeberg - mapleopentech/monorepo](https://codeberg.org/mapleopentech/monorepo)
## 🤝 Contributing
Found a bug? Want a feature to improve MaplePress? Please create an [issue](https://codeberg.org/mapleopentech/monorepo/issues/new).
## 📝 License
This application is licensed under the [**GNU Affero General Public License v3.0**](https://opensource.org/license/agpl-v3). See [LICENSE](../../LICENSE) for more information.

View file

@ -0,0 +1,162 @@
version: "3"
env:
COMPOSE_PROJECT_NAME: maplepress
# Variables for Docker Compose command detection
vars:
DOCKER_COMPOSE_CMD:
sh: |
if command -v docker-compose >/dev/null 2>&1; then
echo "docker-compose"
elif docker compose version >/dev/null 2>&1; then
echo "docker compose"
else
echo "docker-compose"
fi
tasks:
# Development workflow (requires infrastructure)
dev:
desc: Start app in development mode (requires infrastructure running)
deps: [dev:check-infra]
cmds:
- "{{.DOCKER_COMPOSE_CMD}} -f docker-compose.dev.yml up --build"
- echo "Press Ctrl+C to stop"
dev:down:
desc: Stop development app
cmds:
- "{{.DOCKER_COMPOSE_CMD}} -f docker-compose.dev.yml down"
dev:restart:
desc: Quick restart (fast!)
cmds:
- "{{.DOCKER_COMPOSE_CMD}} -f docker-compose.dev.yml restart"
- echo "✅ MaplePress backend restarted"
dev:logs:
desc: View app logs
cmds:
- "{{.DOCKER_COMPOSE_CMD}} -f docker-compose.dev.yml logs -f"
dev:shell:
desc: Open shell in running container
cmds:
- docker exec -it maplepress-backend-dev sh
dev:check-infra:
desc: Verify infrastructure is running
silent: true
cmds:
- |
if ! docker network inspect maple-dev >/dev/null 2>&1; then
echo "❌ Infrastructure not running!"
echo ""
echo "Start it with:"
echo " cd ../infrastructure/development && task dev:start"
echo ""
exit 1
fi
if ! docker ps | grep -q maple-cassandra-1-dev; then
echo "❌ Cassandra not running!"
echo ""
echo "Start it with:"
echo " cd ../infrastructure/development && task dev:start"
echo ""
exit 1
fi
echo "✅ Infrastructure is running"
# Database operations
migrate:up:
desc: Run all migrations up
cmds:
- ./maplepress-backend migrate up
migrate:down:
desc: Run all migrations down
cmds:
- ./maplepress-backend migrate down
migrate:create:
desc: Create new migration (usage task migrate:create -- create_users)
cmds:
- ./maplepress-backend migrate create {{.CLI_ARGS}}
db:clear:
desc: Clear Cassandra database (drop and recreate keyspace)
deps: [build]
cmds:
- echo "⚠️ Dropping keyspace 'maplepress'..."
- docker exec maple-cassandra-1-dev cqlsh -e "DROP KEYSPACE IF EXISTS maplepress;"
- echo "✅ Keyspace dropped"
- echo "🔄 Running migrations to recreate schema..."
- ./maplepress-backend migrate up
- echo "✅ Database cleared and recreated"
db:reset:
desc: Reset database using migrations (down then up)
deps: [build]
cmds:
- echo "🔄 Running migrations down..."
- ./maplepress-backend migrate down
- echo "🔄 Running migrations up..."
- ./maplepress-backend migrate up
- echo "✅ Database reset complete"
# Build and test
build:
desc: Build the Go binary
cmds:
- task: wire
- go build -o maplepress-backend
test:
desc: Run tests
cmds:
- go test ./... -v
test:short:
desc: Run short tests only
cmds:
- go test ./... -short
lint:
desc: Run linters
cmds:
- task: nilaway
- go vet ./...
nilaway:
desc: Run nilaway static analyzer
cmds:
- nilaway ./...
wire:
desc: Generate Wire dependency injection
cmds:
- cd app && wire
format:
desc: Format code
cmds:
- go fmt ./...
tidy:
desc: Tidy Go modules
cmds:
- go mod tidy
clean:
desc: Clean build artifacts
cmds:
- rm -f maplepress-backend
- rm -f app/wire_gen.go
deploy:
desc: (DevOps only) Command will build the production container of this project and deploy to the private docker container registry.
cmds:
- docker build -f Dockerfile --rm -t registry.digitalocean.com/ssp/maplepress_backend:prod --platform linux/amd64 .
- docker tag registry.digitalocean.com/ssp/maplepress_backend:prod registry.digitalocean.com/ssp/maplepress_backend:prod
- docker push registry.digitalocean.com/ssp/maplepress_backend:prod

View file

@ -0,0 +1,108 @@
package app
import (
"context"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/scheduler"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/leaderelection"
)
// Application represents the main application
type Application struct {
cfg *config.Config
logger *zap.Logger
server *http.Server
leaderElection leaderelection.LeaderElection
quotaScheduler *scheduler.QuotaResetScheduler
ipCleanupScheduler *scheduler.IPCleanupScheduler
}
// ProvideApplication creates a new Application
func ProvideApplication(
cfg *config.Config,
logger *zap.Logger,
server *http.Server,
leaderElection leaderelection.LeaderElection,
quotaScheduler *scheduler.QuotaResetScheduler,
ipCleanupScheduler *scheduler.IPCleanupScheduler,
) *Application {
return &Application{
cfg: cfg,
logger: logger,
server: server,
leaderElection: leaderElection,
quotaScheduler: quotaScheduler,
ipCleanupScheduler: ipCleanupScheduler,
}
}
// Run starts the application
func (a *Application) Run(ctx context.Context) error {
a.logger.Info("")
a.logger.Info("╔═══════════════════════════════════════════════╗")
a.logger.Info("║ MaplePress Backend Starting... ║")
a.logger.Info("╚═══════════════════════════════════════════════╝")
a.logger.Info("",
zap.String("environment", a.cfg.App.Environment),
zap.String("version", a.cfg.App.Version))
a.logger.Info("")
// Start leader election in background if enabled
if a.cfg.LeaderElection.Enabled {
go func() {
a.logger.Info("starting leader election")
if err := a.leaderElection.Start(ctx); err != nil {
a.logger.Error("leader election stopped", zap.Error(err))
}
}()
} else {
a.logger.Warn("leader election is DISABLED - all schedulers will run on every instance")
}
// Start quota reset scheduler
if err := a.quotaScheduler.Start(); err != nil {
a.logger.Error("failed to start quota scheduler", zap.Error(err))
return err
}
// Start IP cleanup scheduler for GDPR compliance
if err := a.ipCleanupScheduler.Start(); err != nil {
a.logger.Error("failed to start IP cleanup scheduler", zap.Error(err))
return err
}
return a.server.Start()
}
// Shutdown gracefully shuts down the application
func (a *Application) Shutdown(ctx context.Context) error {
a.logger.Info("shutting down MaplePress Backend")
// Stop leader election first if enabled
if a.cfg.LeaderElection.Enabled {
a.logger.Info("stopping leader election")
if err := a.leaderElection.Stop(); err != nil {
a.logger.Error("failed to stop leader election", zap.Error(err))
}
}
// Stop quota scheduler
a.quotaScheduler.Stop()
// Stop IP cleanup scheduler
a.ipCleanupScheduler.Stop()
if err := a.server.Shutdown(ctx); err != nil {
a.logger.Error("failed to shutdown server", zap.Error(err))
return err
}
// Sync logger before exit
a.logger.Sync()
return nil
}

View file

@ -0,0 +1,224 @@
//go:build wireinject
// +build wireinject
package app
import (
"github.com/google/wire"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/http/middleware"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/admin"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/gateway"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/healthcheck"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/plugin"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/tenant"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/handler/user"
siterepo "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/repo"
tenantrepo "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/repository/tenant"
userrepo "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/repository/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/scheduler"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service"
gatewaysvc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/gateway"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/ipcleanup"
pagesvc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/page"
securityeventservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/securityevent"
sitesvc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
tenantsvc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/tenant"
usersvc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/user"
gatewayuc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/gateway"
pageusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/page"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
tenantusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/tenant"
userusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/cache"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/distributedmutex"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/dns"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/leaderelection"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/ratelimit"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/search"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/apikey"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/ipcrypt"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/password"
rediscache "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/storage/cache"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/storage/database"
)
// InitializeApplication wires up all dependencies
func InitializeApplication(cfg *config.Config) (*Application, error) {
wire.Build(
// Infrastructure layer (pkg/)
logger.ProvideLogger,
database.ProvideCassandraSession,
// Cache layer
rediscache.ProvideRedisClient,
cache.ProvideRedisCache,
cache.ProvideCassandraCache,
cache.ProvideTwoTierCache,
// Security layer
security.ProvideJWTProvider,
password.NewPasswordProvider,
password.NewPasswordValidator,
password.NewBreachChecker, // CWE-521: Password breach checking
security.ProvideClientIPExtractor, // CWE-348: X-Forwarded-For validation
apikey.ProvideGenerator,
apikey.ProvideHasher,
ipcrypt.ProvideIPEncryptor, // CWE-359: IP encryption for GDPR compliance
// Meilisearch client
search.ProvideClient,
// DNS verifier (for domain ownership verification)
dns.ProvideVerifier,
// Rate limiter
ratelimit.ProvideLoginRateLimiter, // CWE-307: Login rate limiting and account lockout
// Distributed mutex (for race condition prevention)
distributedmutex.ProvideDistributedMutexAdapter,
// Leader election (for distributed scheduling)
leaderelection.ProvideLeaderElection,
// Repository layer (internal/repository/)
siterepo.NewSiteRepository,
siterepo.NewPageRepository,
tenantrepo.ProvideRepository,
userrepo.ProvideRepository,
// Use case layer (internal/usecase/)
// Tenant usecases - refactored to focused operations
tenantusecase.ProvideValidateTenantSlugUniqueUseCase,
tenantusecase.ProvideCreateTenantEntityUseCase,
tenantusecase.ProvideSaveTenantToRepoUseCase,
tenantusecase.ProvideGetTenantUseCase,
tenantusecase.ProvideDeleteTenantUseCase, // For SAGA compensation
// User usecases - refactored to focused operations
userusecase.ProvideValidateUserEmailUniqueUseCase,
userusecase.ProvideCreateUserEntityUseCase,
userusecase.ProvideSaveUserToRepoUseCase,
userusecase.ProvideGetUserUseCase,
userusecase.ProvideDeleteUserUseCase, // For SAGA compensation
// Gateway usecases - focused operations only (no orchestration)
// Register usecases (used by RegisterService)
gatewayuc.ProvideValidateRegistrationInputUseCase,
gatewayuc.ProvideCheckTenantSlugAvailabilityUseCase,
gatewayuc.ProvideCheckPasswordBreachUseCase, // CWE-521: Password breach checking
gatewayuc.ProvideHashPasswordUseCase,
// Login usecases (used by LoginUseCase)
gatewayuc.ProvideGetUserByEmailUseCase,
gatewayuc.ProvideVerifyPasswordUseCase,
gatewayuc.ProvideLoginUseCase,
// Site usecases - refactored to focused operations
siteusecase.ProvideValidateDomainUseCase,
siteusecase.ProvideGenerateAPIKeyUseCase,
siteusecase.ProvideGenerateVerificationTokenUseCase,
siteusecase.ProvideCreateSiteEntityUseCase,
siteusecase.ProvideSaveSiteToRepoUseCase,
siteusecase.ProvideGetSiteUseCase,
siteusecase.ProvideListSitesUseCase,
siteusecase.ProvideValidateSiteForDeletionUseCase,
siteusecase.ProvideDeleteSiteFromRepoUseCase,
siteusecase.ProvideUpdateSiteAPIKeyUseCase,
siteusecase.ProvideUpdateSiteAPIKeyToRepoUseCase,
siteusecase.ProvideAuthenticateAPIKeyUseCase,
siteusecase.ProvideVerifySiteUseCase,
// Page usecases - refactored to focused operations
// Sync usecases
pageusecase.ProvideValidateSiteUseCase,
pageusecase.ProvideEnsureSearchIndexUseCase,
pageusecase.ProvideCreatePageEntityUseCase,
pageusecase.ProvideUpsertPageUseCase,
pageusecase.ProvideIndexPageToSearchUseCase,
pageusecase.ProvideUpdateSiteUsageUseCase,
// Delete usecases
pageusecase.ProvideValidateSiteForDeletionUseCase,
pageusecase.ProvideDeletePagesFromRepoUseCase,
pageusecase.ProvideDeletePagesFromSearchUseCase,
// Search usecases
pageusecase.ProvideValidateSiteForSearchUseCase,
pageusecase.ProvideExecuteSearchQueryUseCase,
pageusecase.ProvideIncrementSearchCountUseCase,
// Status usecases
pageusecase.ProvideValidateSiteForStatusUseCase,
pageusecase.ProvideGetPageStatisticsUseCase,
pageusecase.ProvideGetSearchIndexStatusUseCase,
pageusecase.ProvideGetPageByIDUseCase,
siteusecase.ProvideResetMonthlyUsageUseCase,
// Service layer (internal/service/)
service.ProvideSessionService,
tenantsvc.ProvideCreateTenantService,
tenantsvc.ProvideGetTenantService,
usersvc.ProvideCreateUserService,
usersvc.ProvideGetUserService,
sitesvc.ProvideCreateSiteService,
sitesvc.ProvideGetSiteService,
sitesvc.ProvideListSitesService,
sitesvc.ProvideDeleteSiteService,
sitesvc.ProvideRotateAPIKeyService,
sitesvc.ProvideAuthenticateAPIKeyService,
sitesvc.ProvideVerifySiteService,
gatewaysvc.ProvideRegisterService,
gatewaysvc.ProvideLoginService,
gatewaysvc.ProvideRefreshTokenService,
pagesvc.NewSyncPagesService,
pagesvc.NewSearchPagesService,
pagesvc.NewDeletePagesService,
pagesvc.NewSyncStatusService,
ipcleanup.ProvideCleanupService, // CWE-359: IP cleanup for GDPR compliance
securityeventservice.ProvideSecurityEventLogger, // CWE-778: Security event logging
// Middleware layer
middleware.ProvideJWTMiddleware,
middleware.ProvideAPIKeyMiddleware,
middleware.ProvideRateLimitMiddlewares, // CWE-770: Registration and auth endpoints rate limiting
middleware.ProvideSecurityHeadersMiddleware,
middleware.ProvideRequestSizeLimitMiddleware, // CWE-770: Request size limits
// Handler layer (internal/interface/http/handler/)
healthcheck.ProvideHealthCheckHandler,
gateway.ProvideRegisterHandler,
gateway.ProvideLoginHandler,
gateway.ProvideRefreshTokenHandler,
gateway.ProvideHelloHandler,
gateway.ProvideMeHandler,
tenant.ProvideCreateHandler,
tenant.ProvideGetHandler,
user.ProvideCreateHandler,
user.ProvideGetHandler,
site.ProvideCreateHandler,
site.ProvideGetHandler,
site.ProvideListHandler,
site.ProvideDeleteHandler,
site.ProvideRotateAPIKeyHandler,
site.ProvideVerifySiteHandler,
plugin.ProvideStatusHandler,
plugin.ProvidePluginVerifyHandler,
plugin.ProvideVersionHandler,
plugin.ProvideSyncPagesHandler,
plugin.ProvideSearchPagesHandler,
plugin.ProvideDeletePagesHandler,
plugin.ProvideSyncStatusHandler,
admin.ProvideUnlockAccountHandler, // CWE-307: Admin account unlock
admin.ProvideAccountStatusHandler, // CWE-307: Admin account status check
// Scheduler layer (internal/scheduler/)
scheduler.ProvideQuotaResetScheduler,
scheduler.ProvideIPCleanupScheduler, // CWE-359: IP cleanup for GDPR compliance
// HTTP server (internal/interface/http/)
http.ProvideServer,
// Application
ProvideApplication,
)
return nil, nil
}

View file

@ -0,0 +1,118 @@
package daemon
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/app"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/storage/database"
)
// createBootstrapLogger creates a simple console logger for use during application bootstrap
// This is used before the main application logger is initialized
func createBootstrapLogger() *zap.Logger {
encoderConfig := zapcore.EncoderConfig{
TimeKey: "ts",
LevelKey: "level",
NameKey: "logger",
CallerKey: "",
FunctionKey: zapcore.OmitKey,
MessageKey: "msg",
StacktraceKey: "",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.CapitalColorLevelEncoder,
EncodeTime: zapcore.ISO8601TimeEncoder,
EncodeDuration: zapcore.StringDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
core := zapcore.NewCore(
zapcore.NewConsoleEncoder(encoderConfig),
zapcore.AddSync(os.Stdout),
zapcore.InfoLevel,
)
return zap.New(core)
}
// DaemonCmd returns the daemon command
func DaemonCmd() *cobra.Command {
var noAutoMigrate bool
cmd := &cobra.Command{
Use: "daemon",
Short: "Start the MaplePress backend server",
Long: `Start the MaplePress backend server.
By default, the server will automatically run database migrations on startup.
This ensures the database schema is always up-to-date with the application code.
For cloud-native deployments (Kubernetes, Docker, etc.), this is the recommended approach.
To disable auto-migration, use the --no-auto-migrate flag.`,
RunE: func(cmd *cobra.Command, args []string) error {
// Create bootstrap logger for startup messages
logger := createBootstrapLogger()
defer logger.Sync()
// Load configuration
cfg, err := config.Load()
if err != nil {
return err
}
// Run migrations automatically (unless disabled)
if !noAutoMigrate {
logger.Info("⏳ Running database migrations...")
migrator := database.NewMigrator(cfg, logger)
if err := migrator.Up(); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
logger.Info("✓ Database migrations completed successfully")
} else {
logger.Warn("⚠️ Auto-migration disabled, skipping database migrations")
}
// Initialize application via Wire
logger.Info("⏳ Initializing application dependencies...")
application, err := app.InitializeApplication(cfg)
if err != nil {
return err
}
logger.Info("✓ Application dependencies initialized")
logger.Info("")
// Start server
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Handle graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
errChan := make(chan error, 1)
go func() {
errChan <- application.Run(ctx)
}()
select {
case err := <-errChan:
return err
case <-sigChan:
return application.Shutdown(ctx)
}
},
}
// Add flags
cmd.Flags().BoolVar(&noAutoMigrate, "no-auto-migrate", false, "Disable automatic database migrations on startup")
return cmd
}

View file

@ -0,0 +1,138 @@
package migrate
import (
"fmt"
"github.com/spf13/cobra"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/storage/database"
)
// MigrateCmd returns the migrate command with subcommands
func MigrateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "migrate",
Short: "Database migration commands",
}
cmd.AddCommand(upCmd())
cmd.AddCommand(downCmd())
cmd.AddCommand(versionCmd())
cmd.AddCommand(forceCmd())
return cmd
}
// upCmd runs pending migrations
func upCmd() *cobra.Command {
return &cobra.Command{
Use: "up",
Short: "Run all pending migrations",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Create simple logger for CLI
logger := zap.NewNop() // Silent logger for migrate commands
migrator := database.NewMigrator(cfg, logger)
if err := migrator.Up(); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
return nil
},
}
}
// downCmd rolls back the last migration
func downCmd() *cobra.Command {
return &cobra.Command{
Use: "down",
Short: "Rollback the last migration",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Create console logger for CLI output
logger, _ := zap.NewDevelopment()
defer logger.Sync()
migrator := database.NewMigrator(cfg, logger)
if err := migrator.Down(); err != nil {
return fmt.Errorf("failed to rollback migration: %w", err)
}
logger.Info("Successfully rolled back last migration")
return nil
},
}
}
// versionCmd shows the current migration version
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Show current migration version",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Create console logger for CLI output
logger, _ := zap.NewDevelopment()
defer logger.Sync()
migrator := database.NewMigrator(cfg, logger)
version, dirty, err := migrator.Version()
if err != nil {
return fmt.Errorf("failed to get version: %w", err)
}
if dirty {
logger.Warn("Current migration version is DIRTY - requires manual intervention",
zap.Uint("version", uint(version)))
} else {
logger.Info("Current migration version",
zap.Uint("version", uint(version)))
}
return nil
},
}
}
// forceCmd forces a specific migration version
func forceCmd() *cobra.Command {
var version int
cmd := &cobra.Command{
Use: "force",
Short: "Force database to a specific migration version (use with caution)",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
logger := zap.NewNop()
migrator := database.NewMigrator(cfg, logger)
if err := migrator.ForceVersion(version); err != nil {
return fmt.Errorf("failed to force version: %w", err)
}
return nil
},
}
cmd.Flags().IntVarP(&version, "version", "v", 0, "Migration version to force")
cmd.MarkFlagRequired("version")
return cmd
}

View file

@ -0,0 +1,30 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/cmd/daemon"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/cmd/migrate"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/cmd/version"
)
var rootCmd = &cobra.Command{
Use: "maplepress-backend",
Short: "MaplePress Backend Service",
Long: `MaplePress Backend - Clean Architecture with Wire DI and Cassandra multi-tenancy`,
}
// Execute runs the root command
func Execute() {
rootCmd.AddCommand(daemon.DaemonCmd())
rootCmd.AddCommand(migrate.MigrateCmd())
rootCmd.AddCommand(version.VersionCmd())
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View file

@ -0,0 +1,25 @@
package version
import (
"github.com/spf13/cobra"
"go.uber.org/zap"
)
const (
Version = "0.1.0"
)
// VersionCmd returns the version command
func VersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print the version number",
Run: func(cmd *cobra.Command, args []string) {
// Create console logger for CLI output
logger, _ := zap.NewDevelopment()
defer logger.Sync()
logger.Info("MaplePress Backend", zap.String("version", Version))
},
}
}

View file

@ -0,0 +1,514 @@
package config
import (
"fmt"
"log"
"os"
"strconv"
"strings"
"time"
)
// Config holds all application configuration
type Config struct {
App AppConfig
Server ServerConfig
HTTP HTTPConfig
Security SecurityConfig
Database DatabaseConfig
Cache CacheConfig
AWS AWSConfig
Logger LoggerConfig
Mailgun MailgunConfig
Meilisearch MeilisearchConfig
Scheduler SchedulerConfig
RateLimit RateLimitConfig
LeaderElection LeaderElectionConfig
}
// AppConfig holds application-level configuration
type AppConfig struct {
Environment string
Version string
JWTSecret string
GeoLiteDBPath string
BannedCountries []string
}
// IsTestMode returns true if the environment is development
func (c *AppConfig) IsTestMode() bool {
return c.Environment == "development"
}
// ServerConfig holds HTTP server configuration
type ServerConfig struct {
Host string
Port int
}
// HTTPConfig holds HTTP request handling configuration
type HTTPConfig struct {
MaxRequestBodySize int64 // Maximum request body size in bytes
ReadTimeout time.Duration // Maximum duration for reading the entire request
WriteTimeout time.Duration // Maximum duration before timing out writes of the response
IdleTimeout time.Duration // Maximum amount of time to wait for the next request
}
// SecurityConfig holds security-related configuration
type SecurityConfig struct {
TrustedProxies []string // CIDR blocks of trusted reverse proxies for X-Forwarded-For validation
IPEncryptionKey string // 32-character hex key (16 bytes) for IP address encryption (GDPR compliance)
AllowedOrigins []string // CORS allowed origins (e.g., https://getmaplepress.com)
}
// DatabaseConfig holds Cassandra database configuration
type DatabaseConfig struct {
Hosts []string
Keyspace string
Consistency string
Replication int
MigrationsPath string
}
// CacheConfig holds Redis cache configuration
type CacheConfig struct {
Host string
Port int
Password string
DB int
}
// AWSConfig holds AWS S3 configuration
type AWSConfig struct {
AccessKey string
SecretKey string
Endpoint string
Region string
BucketName string
}
// LoggerConfig holds logging configuration
type LoggerConfig struct {
Level string
Format string
}
// MailgunConfig holds Mailgun email service configuration
type MailgunConfig struct {
APIKey string
Domain string
APIBase string
SenderEmail string
MaintenanceEmail string
FrontendDomain string
BackendDomain string
}
// MeilisearchConfig holds Meilisearch configuration
type MeilisearchConfig struct {
Host string
APIKey string
IndexPrefix string
}
// SchedulerConfig holds scheduler configuration
type SchedulerConfig struct {
QuotaResetEnabled bool
QuotaResetSchedule string // Cron format: "0 0 1 * *" = first day of month at midnight
IPCleanupEnabled bool
IPCleanupSchedule string // Cron format: "0 2 * * *" = daily at 2 AM
}
// RateLimitConfig holds rate limiting configuration
type RateLimitConfig struct {
// Registration endpoint rate limiting
// CWE-307: Prevents automated account creation and bot signups
RegistrationEnabled bool
RegistrationMaxRequests int
RegistrationWindow time.Duration
// Login endpoint rate limiting
// CWE-307: Dual protection (IP-based + account lockout) against brute force attacks
LoginEnabled bool
LoginMaxAttemptsPerIP int
LoginIPWindow time.Duration
LoginMaxFailedAttemptsPerAccount int
LoginAccountLockoutDuration time.Duration
// Generic CRUD endpoints rate limiting
// CWE-770: Protects authenticated endpoints (tenant/user/site management) from resource exhaustion
GenericEnabled bool
GenericMaxRequests int
GenericWindow time.Duration
// Plugin API endpoints rate limiting
// CWE-770: Lenient limits for core business endpoints (WordPress plugin integration)
PluginAPIEnabled bool
PluginAPIMaxRequests int
PluginAPIWindow time.Duration
}
// LeaderElectionConfig holds leader election configuration
type LeaderElectionConfig struct {
Enabled bool
LockTTL time.Duration
HeartbeatInterval time.Duration
RetryInterval time.Duration
}
// Load loads configuration from environment variables
func Load() (*Config, error) {
cfg := &Config{
App: AppConfig{
Environment: getEnv("APP_ENVIRONMENT", "development"),
Version: getEnv("APP_VERSION", "0.1.0"),
JWTSecret: getEnv("APP_JWT_SECRET", "change-me-in-production"),
GeoLiteDBPath: getEnv("APP_GEOLITE_DB_PATH", ""),
BannedCountries: getEnvAsSlice("APP_BANNED_COUNTRIES", []string{}),
},
Server: ServerConfig{
Host: getEnv("SERVER_HOST", "0.0.0.0"),
Port: getEnvAsInt("SERVER_PORT", 8000),
},
HTTP: HTTPConfig{
MaxRequestBodySize: getEnvAsInt64("HTTP_MAX_REQUEST_BODY_SIZE", 10*1024*1024), // 10 MB default
ReadTimeout: getEnvAsDuration("HTTP_READ_TIMEOUT", 30*time.Second),
WriteTimeout: getEnvAsDuration("HTTP_WRITE_TIMEOUT", 30*time.Second),
IdleTimeout: getEnvAsDuration("HTTP_IDLE_TIMEOUT", 60*time.Second),
},
Security: SecurityConfig{
// CWE-348: Trusted proxies for X-Forwarded-For validation
// Example: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" for private networks
// Leave empty to disable X-Forwarded-For trust (most secure for direct connections)
TrustedProxies: getEnvAsSlice("SECURITY_TRUSTED_PROXIES", []string{}),
// CWE-359: IP encryption key for GDPR compliance
// Must be 32 hex characters (16 bytes). Generate with: openssl rand -hex 16
IPEncryptionKey: getEnv("SECURITY_IP_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff"),
// CORS allowed origins (comma-separated)
// Example: "https://getmaplepress.com,https://www.getmaplepress.com"
// In development, localhost origins are automatically added
AllowedOrigins: getEnvAsSlice("SECURITY_CORS_ALLOWED_ORIGINS", []string{}),
},
Database: DatabaseConfig{
Hosts: getEnvAsSlice("DATABASE_HOSTS", []string{"localhost"}),
Keyspace: getEnv("DATABASE_KEYSPACE", "maplepress"),
Consistency: getEnv("DATABASE_CONSISTENCY", "QUORUM"),
Replication: getEnvAsInt("DATABASE_REPLICATION", 3),
MigrationsPath: getEnv("DATABASE_MIGRATIONS_PATH", "file://migrations"),
},
Cache: CacheConfig{
Host: getEnv("CACHE_HOST", "localhost"),
Port: getEnvAsInt("CACHE_PORT", 6379),
Password: getEnv("CACHE_PASSWORD", ""),
DB: getEnvAsInt("CACHE_DB", 0),
},
AWS: AWSConfig{
AccessKey: getEnv("AWS_ACCESS_KEY", ""),
SecretKey: getEnv("AWS_SECRET_KEY", ""),
Endpoint: getEnv("AWS_ENDPOINT", ""),
Region: getEnv("AWS_REGION", "us-east-1"),
BucketName: getEnv("AWS_BUCKET_NAME", ""),
},
Logger: LoggerConfig{
Level: getEnv("LOGGER_LEVEL", "info"),
Format: getEnv("LOGGER_FORMAT", "json"),
},
Mailgun: MailgunConfig{
APIKey: getEnv("MAILGUN_API_KEY", ""),
Domain: getEnv("MAILGUN_DOMAIN", ""),
APIBase: getEnv("MAILGUN_API_BASE", "https://api.mailgun.net/v3"),
SenderEmail: getEnv("MAILGUN_SENDER_EMAIL", "noreply@maplepress.app"),
MaintenanceEmail: getEnv("MAILGUN_MAINTENANCE_EMAIL", "admin@maplepress.app"),
FrontendDomain: getEnv("MAILGUN_FRONTEND_DOMAIN", "https://maplepress.app"),
BackendDomain: getEnv("MAILGUN_BACKEND_DOMAIN", "https://api.maplepress.app"),
},
Meilisearch: MeilisearchConfig{
Host: getEnv("MEILISEARCH_HOST", "http://localhost:7700"),
APIKey: getEnv("MEILISEARCH_API_KEY", ""),
IndexPrefix: getEnv("MEILISEARCH_INDEX_PREFIX", "site_"),
},
Scheduler: SchedulerConfig{
QuotaResetEnabled: getEnvAsBool("SCHEDULER_QUOTA_RESET_ENABLED", true),
QuotaResetSchedule: getEnv("SCHEDULER_QUOTA_RESET_SCHEDULE", "0 0 1 * *"), // 1st of month at midnight
IPCleanupEnabled: getEnvAsBool("SCHEDULER_IP_CLEANUP_ENABLED", true), // CWE-359: GDPR compliance
IPCleanupSchedule: getEnv("SCHEDULER_IP_CLEANUP_SCHEDULE", "0 2 * * *"), // Daily at 2 AM
},
RateLimit: RateLimitConfig{
// Registration rate limiting (CWE-307)
RegistrationEnabled: getEnvAsBool("RATELIMIT_REGISTRATION_ENABLED", true),
RegistrationMaxRequests: getEnvAsInt("RATELIMIT_REGISTRATION_MAX_REQUESTS", 5),
RegistrationWindow: getEnvAsDuration("RATELIMIT_REGISTRATION_WINDOW", time.Hour),
// Login rate limiting (CWE-307)
LoginEnabled: getEnvAsBool("RATELIMIT_LOGIN_ENABLED", true),
LoginMaxAttemptsPerIP: getEnvAsInt("RATELIMIT_LOGIN_MAX_ATTEMPTS_PER_IP", 10),
LoginIPWindow: getEnvAsDuration("RATELIMIT_LOGIN_IP_WINDOW", 15*time.Minute),
LoginMaxFailedAttemptsPerAccount: getEnvAsInt("RATELIMIT_LOGIN_MAX_FAILED_ATTEMPTS_PER_ACCOUNT", 10),
LoginAccountLockoutDuration: getEnvAsDuration("RATELIMIT_LOGIN_ACCOUNT_LOCKOUT_DURATION", 30*time.Minute),
// Generic CRUD endpoints rate limiting (CWE-770)
GenericEnabled: getEnvAsBool("RATELIMIT_GENERIC_ENABLED", true),
GenericMaxRequests: getEnvAsInt("RATELIMIT_GENERIC_MAX_REQUESTS", 100),
GenericWindow: getEnvAsDuration("RATELIMIT_GENERIC_WINDOW", time.Hour),
// Plugin API endpoints rate limiting (CWE-770) - Anti-abuse only
// Generous limits for usage-based billing (no hard quotas)
PluginAPIEnabled: getEnvAsBool("RATELIMIT_PLUGIN_API_ENABLED", true),
PluginAPIMaxRequests: getEnvAsInt("RATELIMIT_PLUGIN_API_MAX_REQUESTS", 10000),
PluginAPIWindow: getEnvAsDuration("RATELIMIT_PLUGIN_API_WINDOW", time.Hour),
},
LeaderElection: LeaderElectionConfig{
Enabled: getEnvAsBool("LEADER_ELECTION_ENABLED", true),
LockTTL: getEnvAsDuration("LEADER_ELECTION_LOCK_TTL", 10*time.Second),
HeartbeatInterval: getEnvAsDuration("LEADER_ELECTION_HEARTBEAT_INTERVAL", 3*time.Second),
RetryInterval: getEnvAsDuration("LEADER_ELECTION_RETRY_INTERVAL", 2*time.Second),
},
}
// Validate configuration
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return cfg, nil
}
// GetSchedulerConfig returns scheduler configuration values
func (c *Config) GetSchedulerConfig() (enabled bool, schedule string) {
return c.Scheduler.QuotaResetEnabled, c.Scheduler.QuotaResetSchedule
}
// validate checks if the configuration is valid
func (c *Config) validate() error {
if c.Server.Port < 1 || c.Server.Port > 65535 {
return fmt.Errorf("invalid server port: %d", c.Server.Port)
}
if c.Database.Keyspace == "" {
return fmt.Errorf("database keyspace is required")
}
if len(c.Database.Hosts) == 0 {
return fmt.Errorf("at least one database host is required")
}
if c.App.JWTSecret == "" {
return fmt.Errorf("APP_JWT_SECRET is required")
}
// Security validation for credentials (CWE-798: Use of Hard-coded Credentials)
if err := c.validateSecurityCredentials(); err != nil {
return err
}
return nil
}
// validateSecurityCredentials performs security validation on credentials
// This addresses CWE-798 (Use of Hard-coded Credentials)
func (c *Config) validateSecurityCredentials() error {
// Check if JWT secret is using the default hard-coded value
if strings.Contains(strings.ToLower(c.App.JWTSecret), "change-me") ||
strings.Contains(strings.ToLower(c.App.JWTSecret), "changeme") {
if c.App.Environment == "production" {
return fmt.Errorf(
"SECURITY ERROR: JWT secret is using default/placeholder value in production. " +
"Generate a secure secret with: openssl rand -base64 64",
)
}
// Warn in development
log.Printf(
"[WARNING] JWT secret is using default/placeholder value. " +
"This is acceptable for development but MUST be changed for production. " +
"Generate a secure secret with: openssl rand -base64 64",
)
}
// Validate IP encryption key format (CWE-359: GDPR compliance)
if c.Security.IPEncryptionKey != "" {
if len(c.Security.IPEncryptionKey) != 32 {
return fmt.Errorf(
"SECURITY ERROR: IP encryption key must be exactly 32 hex characters (16 bytes). " +
"Generate with: openssl rand -hex 16",
)
}
// Check if valid hex
for _, char := range c.Security.IPEncryptionKey {
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) {
return fmt.Errorf(
"SECURITY ERROR: IP encryption key must contain only hex characters (0-9, a-f). " +
"Generate with: openssl rand -hex 16",
)
}
}
}
// In production, enforce additional security checks
if c.App.Environment == "production" {
// Check IP encryption key is not using default value
if c.Security.IPEncryptionKey == "00112233445566778899aabbccddeeff" {
return fmt.Errorf(
"SECURITY ERROR: IP encryption key is using default value in production. " +
"Generate a secure key with: openssl rand -hex 16",
)
}
// Check JWT secret minimum length
if len(c.App.JWTSecret) < 32 {
return fmt.Errorf(
"SECURITY ERROR: JWT secret is too short for production (%d characters). "+
"Minimum required: 32 characters (256 bits). "+
"Generate a secure secret with: openssl rand -base64 64",
len(c.App.JWTSecret),
)
}
// Check for common weak secrets
weakSecrets := []string{"secret", "password", "12345", "admin", "test", "default"}
secretLower := strings.ToLower(c.App.JWTSecret)
for _, weak := range weakSecrets {
if secretLower == weak {
return fmt.Errorf(
"SECURITY ERROR: JWT secret is using a common weak value: '%s'. "+
"Generate a secure secret with: openssl rand -base64 64",
weak,
)
}
}
// Check Meilisearch API key in production
if c.Meilisearch.APIKey == "" {
return fmt.Errorf("SECURITY ERROR: Meilisearch API key must be set in production")
}
meilisearchKeyLower := strings.ToLower(c.Meilisearch.APIKey)
if strings.Contains(meilisearchKeyLower, "change") ||
strings.Contains(meilisearchKeyLower, "dev") ||
strings.Contains(meilisearchKeyLower, "test") {
return fmt.Errorf(
"SECURITY ERROR: Meilisearch API key appears to be a development/placeholder value",
)
}
// Check database hosts are not using localhost in production
for _, host := range c.Database.Hosts {
hostLower := strings.ToLower(host)
if strings.Contains(hostLower, "localhost") || host == "127.0.0.1" {
return fmt.Errorf(
"SECURITY ERROR: Database hosts should not use localhost in production. Found: %s",
host,
)
}
}
// Check cache host is not localhost in production
cacheLower := strings.ToLower(c.Cache.Host)
if strings.Contains(cacheLower, "localhost") || c.Cache.Host == "127.0.0.1" {
return fmt.Errorf(
"SECURITY ERROR: Cache host should not use localhost in production. Found: %s",
c.Cache.Host,
)
}
}
return nil
}
// Helper functions to get environment variables
func getEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}
func getEnvAsInt(key string, defaultValue int) int {
valueStr := os.Getenv(key)
if valueStr == "" {
return defaultValue
}
value, err := strconv.Atoi(valueStr)
if err != nil {
return defaultValue
}
return value
}
func getEnvAsInt64(key string, defaultValue int64) int64 {
valueStr := os.Getenv(key)
if valueStr == "" {
return defaultValue
}
value, err := strconv.ParseInt(valueStr, 10, 64)
if err != nil {
return defaultValue
}
return value
}
func getEnvAsBool(key string, defaultValue bool) bool {
valueStr := os.Getenv(key)
if valueStr == "" {
return defaultValue
}
value, err := strconv.ParseBool(valueStr)
if err != nil {
return defaultValue
}
return value
}
func getEnvAsSlice(key string, defaultValue []string) []string {
valueStr := os.Getenv(key)
if valueStr == "" {
return defaultValue
}
// Simple comma-separated parsing
// For production, consider using a proper CSV parser
var result []string
current := ""
for _, char := range valueStr {
if char == ',' {
if current != "" {
result = append(result, current)
current = ""
}
} else {
current += string(char)
}
}
if current != "" {
result = append(result, current)
}
if len(result) == 0 {
return defaultValue
}
return result
}
func getEnvAsDuration(key string, defaultValue time.Duration) time.Duration {
valueStr := os.Getenv(key)
if valueStr == "" {
return defaultValue
}
value, err := time.ParseDuration(valueStr)
if err != nil {
return defaultValue
}
return value
}

View file

@ -0,0 +1,27 @@
package constants
const (
// Application constants
AppName = "MaplePress Backend"
// HTTP constants
HeaderContentType = "Content-Type"
HeaderAuthorization = "Authorization"
MIMEApplicationJSON = "application/json"
// Context keys
ContextKeyTenantID = "tenant_id"
ContextKeyUserID = "user_id"
ContextKeyJWTClaims = "jwt_claims"
// Site context keys (API key authentication)
SiteIsAuthenticated = "site_is_authenticated"
SiteID = "site_id"
SiteTenantID = "site_tenant_id"
SiteDomain = "site_domain"
SitePlanTier = "site_plan_tier"
// Default values
DefaultPageSize = 20
MaxPageSize = 100
)

View file

@ -0,0 +1,14 @@
package constants
type key int
const (
SessionIsAuthorized key = iota
SessionID
SessionUserID
SessionUserUUID
SessionUserEmail
SessionUserName
SessionUserRole
SessionTenantID
)

View file

@ -0,0 +1,77 @@
# ============================================================================
# DEVELOPERS NOTE:
# THE PURPOSE OF THIS DOCKERFILE IS TO BUILD THE MAPLEPRESS BACKEND
# EXECUTABLE IN A CONTAINER FOR DEVELOPMENT PURPOSES ON YOUR
# MACHINE. DO NOT RUN THIS IN PRODUCTION ENVIRONMENT.
# ============================================================================
# Start with the official Golang image
FROM golang:1.24.4
# ============================================================================
# SETUP PROJECT DIRECTORY STRUCTURE
# ============================================================================
# Set the working directory first
WORKDIR /go/src/codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend
# ============================================================================
# DEPENDENCY MANAGEMENT (DO THIS FIRST FOR BETTER CACHING)
# ============================================================================
# Copy dependency files first to take advantage of Docker layer caching
COPY go.mod go.sum ./
# Download all dependencies
RUN go mod download
# ============================================================================
# INSTALL DEVELOPMENT TOOLS
# ============================================================================
# Install CompileDaemon for hot reloading
RUN go install github.com/githubnemo/CompileDaemon@latest
# Install Wire for dependency injection
RUN go install github.com/google/wire/cmd/wire@latest
# Install goimports for code formatting
RUN go install golang.org/x/tools/cmd/goimports@latest
# Install staticcheck for linting
RUN go install honnef.co/go/tools/cmd/staticcheck@latest
# ============================================================================
# CREATE SIMPLIFIED BUILD SCRIPT
# ============================================================================
RUN echo '#!/bin/sh\n\
echo "============================================================"\n\
echo "BEGINNING BUILD PROCESS"\n\
echo "============================================================"\n\
\n\
echo "[1/2] Generating Wire dependency injection code..."\n\
cd app && wire && cd ..\n\
if [ $? -ne 0 ]; then\n\
echo "Wire generation failed!"\n\
exit 1\n\
fi\n\
\n\
echo "[2/2] Building application..."\n\
go build -o maplepress-backend .\n\
if [ $? -ne 0 ]; then\n\
echo "Build failed!"\n\
exit 1\n\
fi\n\
\n\
echo "Build completed successfully!"\n\
' > /go/bin/build.sh && chmod +x /go/bin/build.sh
# ============================================================================
# COPY SOURCE CODE (AFTER DEPENDENCIES)
# ============================================================================
# Copy all source code
COPY . .
# ============================================================================
# SET UP CONTINUOUS DEVELOPMENT ENVIRONMENT
# ============================================================================
# Use CompileDaemon with simpler configuration
# Automatically runs Wire, builds, and starts the daemon with auto-migration
# Exclude wire_gen.go and the binary to prevent infinite rebuild loops
ENTRYPOINT ["CompileDaemon", "-polling=true", "-log-prefix=false", "-build=/go/bin/build.sh", "-command=./maplepress-backend daemon", "-directory=./", "-exclude-dir=.git", "-exclude=wire_gen.go", "-exclude=maplepress-backend"]

View file

@ -0,0 +1,73 @@
# Use external network from infrastructure
networks:
maple-dev:
external: true
services:
app:
container_name: maplepress-backend-dev
stdin_open: true
build:
context: .
dockerfile: ./dev.Dockerfile
ports:
- "${SERVER_PORT:-8000}:${SERVER_PORT:-8000}"
env_file:
- .env
environment:
# Application Configuration
APP_ENVIRONMENT: ${APP_ENVIRONMENT:-development}
APP_VERSION: ${APP_VERSION:-0.1.0-dev}
APP_JWT_SECRET: ${APP_JWT_SECRET:-dev-secret-change-in-production}
# HTTP Server Configuration
SERVER_HOST: ${SERVER_HOST:-0.0.0.0}
SERVER_PORT: ${SERVER_PORT:-8000}
# Cassandra Database Configuration
# Connect to external infrastructure (use all 3 nodes in cluster)
DATABASE_HOSTS: ${DATABASE_HOSTS:-cassandra-1:9042,cassandra-2:9042,cassandra-3:9042}
DATABASE_KEYSPACE: ${DATABASE_KEYSPACE:-maplepress}
DATABASE_CONSISTENCY: ${DATABASE_CONSISTENCY:-ONE}
DATABASE_REPLICATION: ${DATABASE_REPLICATION:-3}
DATABASE_MIGRATIONS_PATH: ${DATABASE_MIGRATIONS_PATH:-file://migrations}
# Redis Cache Configuration
# Connect to external infrastructure
CACHE_HOST: ${CACHE_HOST:-redis}
CACHE_PORT: ${CACHE_PORT:-6379}
CACHE_PASSWORD: ${CACHE_PASSWORD:-}
CACHE_DB: ${CACHE_DB:-0}
# Meilisearch Configuration (if needed)
MEILISEARCH_HOST: ${MEILISEARCH_HOST:-http://meilisearch:7700}
MEILISEARCH_API_KEY: ${MEILISEARCH_API_KEY:-maple-dev-master-key-change-in-production}
# S3 Configuration (SeaweedFS - S3-compatible storage)
AWS_ACCESS_KEY: ${AWS_ACCESS_KEY:-any}
AWS_SECRET_KEY: ${AWS_SECRET_KEY:-any}
AWS_ENDPOINT: ${AWS_ENDPOINT:-http://seaweedfs:8333}
AWS_REGION: ${AWS_REGION:-us-east-1}
AWS_BUCKET_NAME: ${AWS_BUCKET_NAME:-maplepress}
# Logger Configuration
LOGGER_LEVEL: ${LOGGER_LEVEL:-debug}
LOGGER_FORMAT: ${LOGGER_FORMAT:-console}
# Leader Election Configuration
LEADER_ELECTION_ENABLED: ${LEADER_ELECTION_ENABLED:-true}
LEADER_ELECTION_LOCK_TTL: ${LEADER_ELECTION_LOCK_TTL:-10s}
LEADER_ELECTION_HEARTBEAT_INTERVAL: ${LEADER_ELECTION_HEARTBEAT_INTERVAL:-3s}
LEADER_ELECTION_RETRY_INTERVAL: ${LEADER_ELECTION_RETRY_INTERVAL:-2s}
volumes:
- ./:/go/src/codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend
networks:
- maple-dev
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "-H", "X-Tenant-ID: healthcheck", "http://localhost:${SERVER_PORT:-8000}/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s

View file

@ -0,0 +1,373 @@
# MaplePress Backend API Documentation
This directory contains comprehensive API documentation for the MaplePress backend, organized by endpoint.
## Base URL
```
http://localhost:8000
```
## Quick Links
### General
- [Health Check](health-check.md) - `GET /health`
### Authentication & User Management
- [Register User & Tenant](register.md) - `POST /api/v1/register`
- [Login](login.md) - `POST /api/v1/login`
- [Refresh Token](refresh-token.md) - `POST /api/v1/refresh`
- [Hello (Authenticated)](hello.md) - `POST /api/v1/hello`
- [Get User Profile](get-user-profile.md) - `GET /api/v1/me`
### Tenant Management
- [Create Tenant](create-tenant.md) - `POST /api/v1/tenants`
- [Get Tenant by ID](get-tenant-by-id.md) - `GET /api/v1/tenants/{id}`
- [Get Tenant by Slug](get-tenant-by-slug.md) - `GET /api/v1/tenants/slug/{slug}`
### User Management
- [Create User](create-user.md) - `POST /api/v1/users`
- [Get User by ID](get-user-by-id.md) - `GET /api/v1/users/{id}`
### Site Management
- [Create WordPress Site](create-site.md) - `POST /api/v1/sites`
- [List WordPress Sites](list-sites.md) - `GET /api/v1/sites`
- [Get WordPress Site](get-site.md) - `GET /api/v1/sites/{id}`
- [Delete WordPress Site](delete-site.md) - `DELETE /api/v1/sites/{id}`
- [Rotate Site API Key](rotate-site-api-key.md) - `POST /api/v1/sites/{id}/rotate-api-key`
### WordPress Plugin API
- [Verify API Key](plugin-verify-api-key.md) - `GET /api/v1/plugin/status`
---
## Authentication Overview
MaplePress uses a **dual authentication system**:
### 1. JWT Authentication (for Dashboard Users)
Used for user-facing dashboard endpoints (managing sites, users, tenants).
**Format**: `Authorization: JWT {access_token}`
**Endpoints**:
- All `/api/v1/sites` endpoints
- All `/api/v1/users` endpoints
- All `/api/v1/tenants` endpoints
**How to get JWT**:
1. Register: `POST /api/v1/register`
2. Login: `POST /api/v1/login`
3. Use returned `access_token` in Authorization header
### 2. API Key Authentication (for WordPress Plugins)
Used for WordPress plugin communication with the backend.
**Format**: `Authorization: Bearer {api_key}`
**Endpoints**:
- All `/api/v1/plugin/*` endpoints (status, sync, search, etc.)
**How to get API Key**:
1. Create a site via dashboard: `POST /api/v1/sites`
2. Copy the `api_key` from response (shown only once!)
3. Configure WordPress plugin with the API key
**API Key Format**: `live_sk_{40_random_characters}` or `test_sk_{40_random_characters}`
**Security**:
- API keys are hashed using SHA-256 before storage
- Never logged or displayed after initial creation
- Can be rotated if compromised using the rotate-api-key endpoint
- API key middleware validates and populates site context in requests
- Only keys with `live_sk_` or `test_sk_` prefix are accepted
---
## Test Mode vs Live Mode
MaplePress automatically generates different API key types based on your backend environment configuration.
### Test Mode (`test_sk_` keys)
**Automatically enabled when:**
- `APP_ENVIRONMENT=development` in `.env`
**Use for:**
- Local development with `localhost` URLs
- Testing and experimentation
- CI/CD pipelines
**Features:**
- Test keys work identically to live keys
- Separate from production data
- Can be used for integration testing
- Generated automatically in development environment
**Example:**
```bash
# In your .env file:
APP_ENVIRONMENT=development
# Create a site (automatically gets test_sk_ key):
curl -X POST http://localhost:8000/api/v1/sites \
-H "Content-Type: application/json" \
-H "Authorization: JWT $TOKEN" \
-d '{
"domain": "localhost:8081",
"site_url": "http://localhost:8081"
}'
```
Response will include: `"api_key": "test_sk_abc123..."`
### Live Mode (`live_sk_` keys)
**Automatically enabled when:**
- `APP_ENVIRONMENT=production` in `.env`
**Use for:**
- Production WordPress sites
- Public-facing websites
- Real customer data
**Features:**
- Production-grade API keys
- Should be kept secure and never committed to version control
- Used for real traffic and billing
- Generated automatically in production environment
**Example:**
```bash
# In your .env file:
APP_ENVIRONMENT=production
# Create a site (automatically gets live_sk_ key):
curl -X POST http://localhost:8000/api/v1/sites \
-H "Content-Type: application/json" \
-H "Authorization: JWT $TOKEN" \
-d '{
"domain": "example.com",
"site_url": "https://example.com"
}'
```
Response will include: `"api_key": "live_sk_xyz789..."`
### Environment Configuration
The API key type is **automatically determined** by the `APP_ENVIRONMENT` variable in `.env`:
```bash
# Development - Generates test_sk_ keys
APP_ENVIRONMENT=development
# Production - Generates live_sk_ keys
APP_ENVIRONMENT=production
```
**Two simple options:**
- `development` = test keys (`test_sk_*`)
- `production` = live keys (`live_sk_*`)
**No manual configuration needed!** The backend automatically generates the appropriate key type based on your environment.
---
## Testing Workflow
Here's a complete workflow to test the API from registration to creating sites:
### 1. Register a new user and tenant
```bash
# Save the response to extract tokens
# Note: timezone is optional and defaults to UTC if not provided
RESPONSE=$(curl -X POST http://localhost:8000/api/v1/register \
-H "Content-Type: application/json" \
-d '{
"email": "admin@mycompany.com",
"password": "SecurePass123!",
"first_name": "Admin",
"last_name": "User",
"name": "Admin User",
"tenant_name": "My Company",
"tenant_slug": "my-company",
"agree_terms_of_service": true,
"agree_promotions": false,
"agree_to_tracking_across_third_party_apps_and_services": false
}')
echo $RESPONSE | jq .
# Extract tokens (requires jq)
ACCESS_TOKEN=$(echo $RESPONSE | jq -r '.access_token')
TENANT_ID=$(echo $RESPONSE | jq -r '.tenant_id')
```
### 2. Login with existing credentials
```bash
# Login to get fresh tokens
LOGIN_RESPONSE=$(curl -X POST http://localhost:8000/api/v1/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@mycompany.com",
"password": "SecurePass123!",
"tenant_id": "'$TENANT_ID'"
}')
echo $LOGIN_RESPONSE | jq .
# Extract new access token
ACCESS_TOKEN=$(echo $LOGIN_RESPONSE | jq -r '.access_token')
```
### 3. Get tenant information
```bash
# By ID
curl -X GET http://localhost:8000/api/v1/tenants/$TENANT_ID \
-H "Authorization: JWT $ACCESS_TOKEN" | jq .
# By slug
curl -X GET http://localhost:8000/api/v1/tenants/slug/my-company \
-H "Authorization: JWT $ACCESS_TOKEN" | jq .
```
### 4. Create a new WordPress site
```bash
SITE_RESPONSE=$(curl -X POST http://localhost:8000/api/v1/sites \
-H "Content-Type: application/json" \
-H "Authorization: JWT $ACCESS_TOKEN" \
-d '{
"domain": "example.com",
"site_url": "https://example.com"
}')
echo $SITE_RESPONSE | jq .
# Extract site ID and API key
SITE_ID=$(echo $SITE_RESPONSE | jq -r '.id')
API_KEY=$(echo $SITE_RESPONSE | jq -r '.api_key')
```
### 5. Verify API key (as WordPress plugin)
```bash
curl -X GET http://localhost:8000/api/v1/plugin/status \
-H "Authorization: Bearer $API_KEY" | jq .
```
---
## Multi-Tenancy
This API implements multi-tenancy where:
- Each tenant is isolated from other tenants
- Users belong to specific tenants
- The `X-Tenant-ID` header is required for tenant-scoped operations (in development mode)
- In production, the tenant context will be extracted from the JWT token
---
## Error Handling
MaplePress uses **RFC 9457 (Problem Details for HTTP APIs)** for standardized, machine-readable error responses.
**Standard**: [RFC 9457 - Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc9457)
**Content-Type**: `application/problem+json`
### Error Response Format
All error responses follow the RFC 9457 format:
```json
{
"type": "about:blank",
"title": "Error Type",
"status": 400,
"detail": "Human-readable explanation of the error"
}
```
### Validation Errors (400 Bad Request)
For validation errors, an additional `errors` field provides field-level details:
```json
{
"type": "about:blank",
"title": "Validation Error",
"status": 400,
"detail": "One or more validation errors occurred",
"errors": {
"email": ["Invalid email format", "Email is required"],
"password": ["Password must be at least 8 characters"]
}
}
```
### Common HTTP Status Codes
- `200 OK`: Successful GET request
- `201 Created`: Successful resource creation
- `400 Bad Request`: Invalid input or missing required fields (with validation errors)
- `401 Unauthorized`: Authentication required or invalid token
- `403 Forbidden`: Authenticated but not authorized
- `404 Not Found`: Resource not found
- `409 Conflict`: Resource already exists (duplicate)
- `429 Too Many Requests`: Rate limit exceeded
- `500 Internal Server Error`: Server-side error
### Example Error Responses
**401 Unauthorized:**
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication required"
}
```
**409 Conflict:**
```json
{
"type": "about:blank",
"title": "Conflict",
"status": 409,
"detail": "Email already exists"
}
```
**500 Internal Server Error:**
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to process request"
}
```
---
## Development vs Production
**Development Mode** (current):
- Tenant context via `X-Tenant-ID` header
- Less strict validation
- Debug logging enabled
- Test API keys (`test_sk_*`) generated
**Production Mode**:
- Tenant context extracted from JWT token claims
- Strict validation
- Info/Error logging only
- Live API keys (`live_sk_*`) generated

View file

@ -0,0 +1,110 @@
# Create WordPress Site
**POST /api/v1/sites**
Create a new WordPress site and generate API credentials for the WordPress plugin.
**Authentication**: Required (JWT Bearer token)
**Headers**:
- `Content-Type: application/json`
- `Authorization: JWT {access_token}` (tenant is automatically determined from JWT)
**Request Body**:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| domain | string | Yes | WordPress site domain (e.g., example.com) |
| site_url | string | Yes | Full WordPress site URL (e.g., https://example.com) |
**Example Request**:
```bash
curl -X POST http://localhost:8000/api/v1/sites \
-H "Content-Type: application/json" \
-H "Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
-d '{
"domain": "example.com",
"site_url": "https://example.com"
}'
```
**Example Response** (201 Created):
```json
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"domain": "example.com",
"site_url": "https://example.com",
"api_key": "live_sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0",
"verification_token": "mvp_xyz789abc123",
"status": "pending",
"search_index_name": "site_a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```
**Important Notes**:
- The `api_key` is shown **only once** - save it immediately!
- The site starts with `status: "pending"` until verified
- The `verification_token` should be used by the WordPress plugin for site verification
- The `search_index_name` is the Meilisearch index for this site
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Validation Error Response** (400 Bad Request):
```json
{
"type": "about:blank",
"title": "Validation Error",
"status": 400,
"detail": "One or more validation errors occurred",
"errors": {
"domain": ["Invalid domain format", "Domain is required"],
"site_url": ["Invalid URL format", "Site URL is required"]
}
}
```
**Content-Type**: `application/problem+json`
**Common Validation Error Messages**:
| Field | Error Messages |
|-------|----------------|
| domain | "Invalid domain format", "Domain is required" |
| site_url | "Invalid URL format", "Site URL is required" |
**Other Error Responses**:
- `401 Unauthorized`: Missing or invalid JWT token
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication required"
}
```
- `409 Conflict`: Domain already registered by another user
```json
{
"type": "about:blank",
"title": "Conflict",
"status": 409,
"detail": "Domain already exists"
}
```
- `500 Internal Server Error`: Server error
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to create site"
}
```

View file

@ -0,0 +1,88 @@
# Create Tenant
**POST /api/v1/tenants**
Create a new tenant (organization).
**Authentication**: Required (JWT Bearer token)
**Headers**:
- `Content-Type: application/json`
- `Authorization: JWT {access_token}`
**Request Body**:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | Yes | Tenant/organization name |
| slug | string | Yes | URL-friendly tenant identifier |
**Example Request**:
```bash
curl -X POST http://localhost:8000/api/v1/tenants \
-H "Content-Type: application/json" \
-H "Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
-d '{
"name": "TechStart Inc",
"slug": "techstart"
}'
```
**Example Response** (201 Created):
```json
{
"id": "850e8400-e29b-41d4-a716-446655440000",
"name": "TechStart Inc",
"slug": "techstart",
"status": "active",
"created_at": "2024-10-24T00:00:00Z"
}
```
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**400 Bad Request** - Invalid input:
```json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request body format"
}
```
**401 Unauthorized** - Missing or invalid JWT token:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication required"
}
```
**409 Conflict** - Tenant slug already exists:
```json
{
"type": "about:blank",
"title": "Conflict",
"status": 409,
"detail": "Tenant slug already exists"
}
```
**500 Internal Server Error**:
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to create tenant"
}
```

View file

@ -0,0 +1,91 @@
# Create User
**POST /api/v1/users**
Create a new user within a tenant.
**Authentication**: Required (JWT Bearer token)
**Tenant Context**: Required
**Headers**:
- `Content-Type: application/json`
- `Authorization: JWT {access_token}`
- `X-Tenant-ID: {tenant_id}` (required in development mode)
**Request Body**:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| email | string | Yes | User's email address |
| name | string | Yes | User's full name |
**Example Request**:
```bash
curl -X POST http://localhost:8000/api/v1/users \
-H "Content-Type: application/json" \
-H "Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
-H "X-Tenant-ID: 850e8400-e29b-41d4-a716-446655440000" \
-d '{
"email": "jane@techstart.com",
"name": "Jane Smith"
}'
```
**Example Response** (201 Created):
```json
{
"id": "950e8400-e29b-41d4-a716-446655440000",
"email": "jane@techstart.com",
"name": "Jane Smith",
"created_at": "2024-10-24T00:00:00Z"
}
```
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**400 Bad Request** - Invalid input:
```json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request body format"
}
```
**401 Unauthorized**:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication required"
}
```
**409 Conflict** - Email already exists:
```json
{
"type": "about:blank",
"title": "Conflict",
"status": 409,
"detail": "User email already exists in this tenant"
}
```
**500 Internal Server Error**:
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to create user"
}
```

View file

@ -0,0 +1,74 @@
# Delete WordPress Site
**DELETE /api/v1/sites/{id}**
Delete a WordPress site and all associated data.
**Authentication**: Required (JWT Bearer token)
**Headers**:
- `Authorization: JWT {access_token}`
**URL Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| id | UUID | Yes | Site ID |
**Example Request**:
```bash
curl -X DELETE http://localhost:8000/api/v1/sites/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
**Example Response** (200 OK):
```json
{
"success": true,
"message": "Site deleted successfully"
}
```
**Important Notes**:
- This is a **hard delete** - removes the site from all Cassandra tables
- The site's API key will immediately stop working
- The Meilisearch index should also be deleted (implement separately)
- This action cannot be undone
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**401 Unauthorized**:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication required"
}
```
**404 Not Found**:
```json
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Site not found or doesn't belong to your tenant"
}
```
**500 Internal Server Error**:
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to delete site"
}
```

View file

@ -0,0 +1,90 @@
# Get WordPress Site
**GET /api/v1/sites/{id}**
Retrieve detailed information about a specific WordPress site.
**Authentication**: Required (JWT Bearer token)
**Headers**:
- `Authorization: JWT {access_token}`
**URL Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| id | UUID | Yes | Site ID |
**Example Request**:
```bash
curl -X GET http://localhost:8000/api/v1/sites/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
**Example Response** (200 OK):
```json
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"tenant_id": "t1t2t3t4-t5t6-7890-tttt-tttttttttttt",
"domain": "example.com",
"site_url": "https://example.com",
"api_key_prefix": "live_sk_a1b2",
"api_key_last_four": "s9t0",
"status": "active",
"is_verified": true,
"search_index_name": "site_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"total_pages_indexed": 145,
"last_indexed_at": "2024-10-27T14:30:00Z",
"plugin_version": "1.0.0",
"storage_used_bytes": 52428800,
"search_requests_count": 234,
"monthly_pages_indexed": 50,
"last_reset_at": "2024-10-01T00:00:00Z",
"created_at": "2024-10-27T10:00:00Z",
"updated_at": "2024-10-27T14:30:00Z"
}
```
**Notes**:
- Returns full site details including usage tracking statistics
- API key is never returned (only prefix and last 4 chars for identification)
- Useful for dashboard display and usage monitoring
- Usage-based billing: No quotas or limits, only usage tracking
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**401 Unauthorized**:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication required"
}
```
**404 Not Found**:
```json
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Site not found or doesn't belong to your tenant"
}
```
**500 Internal Server Error**:
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to retrieve site"
}
```

View file

@ -0,0 +1,72 @@
# Get Tenant by ID
**GET /api/v1/tenants/{id}**
Retrieve tenant information by tenant ID.
**Authentication**: Required (JWT Bearer token)
**Headers**:
- `Authorization: JWT {access_token}`
**URL Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| id | UUID | Yes | Tenant ID |
**Example Request**:
```bash
curl -X GET http://localhost:8000/api/v1/tenants/850e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
**Example Response** (200 OK):
```json
{
"id": "850e8400-e29b-41d4-a716-446655440000",
"name": "TechStart Inc",
"slug": "techstart",
"status": "active",
"created_at": "2024-10-24T00:00:00Z",
"updated_at": "2024-10-24T00:00:00Z"
}
```
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**401 Unauthorized**:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication required"
}
```
**404 Not Found**:
```json
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Tenant not found"
}
```
**500 Internal Server Error**:
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to retrieve tenant"
}
```

View file

@ -0,0 +1,72 @@
# Get Tenant by Slug
**GET /api/v1/tenants/slug/{slug}**
Retrieve tenant information by tenant slug.
**Authentication**: Required (JWT Bearer token)
**Headers**:
- `Authorization: JWT {access_token}`
**URL Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| slug | string | Yes | Tenant slug |
**Example Request**:
```bash
curl -X GET http://localhost:8000/api/v1/tenants/slug/techstart \
-H "Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
**Example Response** (200 OK):
```json
{
"id": "850e8400-e29b-41d4-a716-446655440000",
"name": "TechStart Inc",
"slug": "techstart",
"status": "active",
"created_at": "2024-10-24T00:00:00Z",
"updated_at": "2024-10-24T00:00:00Z"
}
```
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**401 Unauthorized**:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication required"
}
```
**404 Not Found**:
```json
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Tenant not found"
}
```
**500 Internal Server Error**:
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to retrieve tenant"
}
```

View file

@ -0,0 +1,85 @@
# Get User by ID
**GET /api/v1/users/{id}**
Retrieve user information by user ID within a tenant context.
**Authentication**: Required (JWT Bearer token)
**Tenant Context**: Required
**Headers**:
- `Authorization: JWT {access_token}`
- `X-Tenant-ID: {tenant_id}` (required in development mode)
**URL Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| id | UUID | Yes | User ID |
**Example Request**:
```bash
curl -X GET http://localhost:8000/api/v1/users/950e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
-H "X-Tenant-ID: 850e8400-e29b-41d4-a716-446655440000"
```
**Example Response** (200 OK):
```json
{
"id": "950e8400-e29b-41d4-a716-446655440000",
"email": "jane@techstart.com",
"name": "Jane Smith",
"created_at": "2024-10-24T00:00:00Z",
"updated_at": "2024-10-24T00:00:00Z"
}
```
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**400 Bad Request** - Missing tenant context:
```json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Tenant context required"
}
```
**401 Unauthorized**:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication required"
}
```
**404 Not Found**:
```json
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "User not found in this tenant"
}
```
**500 Internal Server Error**:
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to retrieve user"
}
```

View file

@ -0,0 +1,51 @@
# Get User Profile
**GET /api/v1/me**
Get the authenticated user's profile information from the JWT token.
**Authentication**: Required (JWT token)
**Headers**:
- `Authorization: JWT {access_token}`
**Example Request**:
```bash
curl -X GET http://localhost:8000/api/v1/me \
-H "Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
**Example Response** (200 OK):
```json
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"email": "john@example.com",
"name": "John Doe",
"role": "owner",
"tenant_id": "650e8400-e29b-41d4-a716-446655440000"
}
```
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**401 Unauthorized** - Missing or invalid JWT token:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication required"
}
```
**Notes**:
- Returns user information extracted from the JWT token claims
- No database query required - all data comes from the token
- Useful for displaying user information in the dashboard
- Can be used to verify the current authenticated user's identity

View file

@ -0,0 +1,23 @@
# Health Check
## GET /health
Check if the service is running and healthy.
**Authentication**: None required
**Headers**: None required
**Example Request**:
```bash
curl -X GET http://localhost:8000/health
```
**Example Response** (200 OK):
```json
{
"status": "healthy"
}
```

View file

@ -0,0 +1,66 @@
# Hello (Authenticated)
**POST /api/v1/hello**
A simple authenticated endpoint that returns a personalized greeting message. This endpoint demonstrates JWT authentication and can be used to verify that your access token is working correctly.
**Authentication**: Required (JWT token)
**Headers**:
- `Content-Type: application/json`
- `Authorization: JWT {access_token}`
**Request Body**:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | Yes | Name to include in greeting |
**Example Request**:
```bash
curl -X POST http://localhost:8000/api/v1/hello \
-H "Content-Type: application/json" \
-H "Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
-d '{"name": "Alice"}'
```
**Example Response** (200 OK):
```json
{
"message": "Hello, Alice! Welcome to MaplePress Backend."
}
```
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**400 Bad Request** - Missing name field:
```json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Name is required"
}
```
**401 Unauthorized** - Missing or invalid JWT token:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication required"
}
```
**Notes**:
- This endpoint requires a valid JWT access token
- The name field is required in the request body
- Useful for testing authentication and verifying token validity
- Returns a personalized greeting with the provided name

View file

@ -0,0 +1,79 @@
# List WordPress Sites
**GET /api/v1/sites**
Retrieve all WordPress sites for the authenticated user's tenant.
**Authentication**: Required (JWT Bearer token)
**Headers**:
- `Authorization: JWT {access_token}`
**Query Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| page_size | integer | No | Number of results per page (default: 20, max: 100) |
| page_state | string | No | Pagination token from previous response |
**Example Request**:
```bash
curl -X GET 'http://localhost:8000/api/v1/sites?page_size=20' \
-H "Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
**Example Response** (200 OK):
```json
{
"sites": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"domain": "example.com",
"status": "active",
"is_verified": true,
"created_at": "2024-10-27T10:00:00Z"
},
{
"id": "b2c3d4e5-f6g7-8901-bcde-f12345678901",
"domain": "another-site.com",
"status": "pending",
"is_verified": false,
"created_at": "2024-10-27T11:00:00Z"
}
],
"page_state": "base64_encoded_pagination_token"
}
```
**Notes**:
- Returns a summary view (limited fields) for performance
- Use `page_state` for pagination through large result sets
- Sites are ordered by creation date (newest first)
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**401 Unauthorized**:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication required"
}
```
**500 Internal Server Error**:
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to retrieve sites"
}
```

View file

@ -0,0 +1,99 @@
# Login
**POST /api/v1/login**
Authenticate an existing user and obtain authentication tokens. This endpoint validates user credentials and creates a new session.
**Authentication**: None required (public endpoint)
**Headers**:
- `Content-Type: application/json`
**Request Body**:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| email | string | Yes | User's email address |
| password | string | Yes | User's password |
**Example Request**:
```bash
curl -X POST http://localhost:8000/api/v1/login \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"password": "SecurePassword123!"
}'
```
**Example Response** (200 OK):
```json
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"user_email": "john@example.com",
"user_name": "John Doe",
"user_role": "user",
"tenant_id": "650e8400-e29b-41d4-a716-446655440000",
"session_id": "750e8400-e29b-41d4-a716-446655440000",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"access_expiry": "2024-10-24T12:15:00Z",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_expiry": "2024-10-31T00:00:00Z",
"login_at": "2024-10-24T00:00:00Z"
}
```
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**400 Bad Request** - Invalid input:
```json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request body format. Please check your JSON syntax."
}
```
**401 Unauthorized** - Invalid credentials:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Invalid email or password."
}
```
**429 Too Many Requests** - Rate limit exceeded:
```json
{
"type": "about:blank",
"title": "Too Many Requests",
"status": 429,
"detail": "Too many login attempts from this IP address. Please try again later."
}
```
**500 Internal Server Error**:
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to process login. Please try again later."
}
```
**Notes**:
- The `tenant_id` is required for multi-tenant authentication to ensure user credentials are validated within the correct tenant context
- Access tokens expire after 15 minutes
- Refresh tokens expire after 7 days
- Both tokens are JWT tokens that should be stored securely on the client side
- Use the access token in the `Authorization: JWT {token}` header for authenticated requests

View file

@ -0,0 +1,73 @@
# Verify API Key (WordPress Plugin)
**GET /api/v1/plugin/status**
Verify that an API key is valid and retrieve site information. This endpoint is used by the WordPress plugin to verify the connection and display quota information.
**Authentication**: Required (API Key)
**Headers**:
- `Authorization: Bearer {api_key}`
**Example Request**:
```bash
curl -X GET http://localhost:8000/api/v1/plugin/status \
-H "Authorization: Bearer live_sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
```
**Example Response** (200 OK):
```json
{
"site_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"tenant_id": "t1t2t3t4-t5t6-7890-tttt-tttttttttttt",
"domain": "example.com",
"site_url": "https://example.com",
"status": "active",
"is_verified": true,
"storage_used_bytes": 52428800,
"search_requests_count": 234,
"monthly_pages_indexed": 50,
"total_pages_indexed": 145,
"search_index_name": "site_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"api_key_prefix": "live_sk_a1b2",
"api_key_last_four": "s9t0",
"plugin_version": "1.0.0",
"message": "API key is valid"
}
```
**Notes**:
- Used by WordPress plugin to verify connection on plugin activation
- Returns site information and usage tracking statistics
- If the API key is invalid or missing, returns 401 Unauthorized
- Usage-based billing: No quotas or limits, only usage tracking for billing
- If the request reaches this handler, the API key has already been validated by the middleware
- API key must start with `live_sk_` or `test_sk_` prefix
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**401 Unauthorized** - Invalid or missing API key:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Invalid or missing API key"
}
```
**500 Internal Server Error**:
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to verify API key"
}
```

View file

@ -0,0 +1,131 @@
# Refresh Token
**POST /api/v1/refresh**
Obtain a new access token and refresh token using an existing valid refresh token. This endpoint should be called when the access token expires (after 15 minutes) to maintain the user's session without requiring them to log in again.
**Authentication**: None required (public endpoint, but requires valid refresh token)
**Headers**:
- `Content-Type: application/json`
**Request Body**:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| refresh_token | string | Yes | Valid refresh token from login or previous refresh |
**Example Request**:
```bash
curl -X POST http://localhost:8000/api/v1/refresh \
-H "Content-Type: application/json" \
-d '{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}'
```
**Example Response** (200 OK):
```json
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"user_email": "john@example.com",
"user_name": "John Doe",
"user_role": "user",
"tenant_id": "650e8400-e29b-41d4-a716-446655440000",
"session_id": "750e8400-e29b-41d4-a716-446655440000",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"access_expiry": "2024-10-24T12:30:00Z",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_expiry": "2024-10-31T00:15:00Z",
"refreshed_at": "2024-10-24T12:15:00Z"
}
```
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**400 Bad Request** - Missing refresh token:
```json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Refresh token is required"
}
```
**401 Unauthorized** - Invalid or expired refresh token:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Invalid or expired refresh token. Please log in again."
}
```
**401 Unauthorized** - Session invalidated:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Session has expired or been invalidated. Please log in again."
}
```
**500 Internal Server Error**:
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to refresh token. Please try again later."
}
```
**Token Refresh Flow**:
1. **Initial Authentication**: User logs in via `/api/v1/login` and receives:
- Access token (expires in 15 minutes)
- Refresh token (expires in 7 days)
2. **Token Usage**: Client uses the access token for API requests
3. **Token Expiration**: When access token expires (after 15 minutes):
- Client detects 401 Unauthorized response
- Client calls `/api/v1/refresh` with the refresh token
- Server validates refresh token and session
- Server returns new access token and new refresh token
4. **Token Rotation**: Both tokens are regenerated on refresh:
- New access token (valid for 15 minutes from refresh time)
- New refresh token (valid for 7 days from refresh time)
- Old tokens become invalid
5. **Session Validation**: The refresh token is validated against the active session:
- If the session has been deleted (e.g., user logged out), refresh will fail
- If the session has expired (after 14 days of inactivity), refresh will fail
- This prevents using refresh tokens after logout
**Best Practices**:
- Store both access and refresh tokens securely on the client (e.g., secure HTTP-only cookies or encrypted storage)
- Implement automatic token refresh when access token expires (don't wait for 401 errors)
- Consider refreshing tokens proactively before expiration (e.g., 1 minute before)
- Handle refresh failures by redirecting user to login
- Never share refresh tokens across devices or sessions
- Clear tokens on logout
**Security Notes**:
- Refresh tokens are single-use in practice due to token rotation
- Each refresh generates a new token pair and invalidates the old one
- Session validation prevents token reuse after logout (CWE-613)
- Refresh tokens have a longer lifetime but are still time-limited (7 days)
- Sessions expire after 14 days of inactivity regardless of token refresh

View file

@ -0,0 +1,149 @@
# Register User & Tenant
**POST /api/v1/register**
Register a new user and create a new tenant (organization) in a single request. This is the primary onboarding endpoint that returns authentication tokens.
**Authentication**: None required (public endpoint)
**Headers**:
- `Content-Type: application/json`
**Request Body**:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| email | string | Yes | User's email address |
| password | string | Yes | User's password (min 8 characters) |
| confirm_password | string | Yes | Password confirmation (must match password) |
| first_name | string | Yes | User's first name |
| last_name | string | Yes | User's last name |
| tenant_name | string | Yes | Organization/tenant name (slug auto-generated from this) |
| timezone | string | No | User's timezone (e.g., "America/New_York", defaults to "UTC" if not provided) |
| agree_terms_of_service | boolean | Yes | Must be true - user agreement to Terms of Service |
| agree_promotions | boolean | No | Optional - user agreement to receive promotional emails (default: false) |
| agree_to_tracking_across_third_party_apps_and_services | boolean | No | Optional - user agreement to cross-platform tracking (default: false) |
**Example Request (with timezone)**:
```bash
curl -X POST http://localhost:8000/api/v1/register \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"password": "SecurePassword123!",
"confirm_password": "SecurePassword123!",
"first_name": "John",
"last_name": "Doe",
"tenant_name": "Acme Corporation",
"timezone": "America/New_York",
"agree_terms_of_service": true,
"agree_promotions": false,
"agree_to_tracking_across_third_party_apps_and_services": false
}'
```
**Example Request (without timezone - defaults to UTC)**:
```bash
curl -X POST http://localhost:8000/api/v1/register \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"password": "SecurePassword456!",
"confirm_password": "SecurePassword456!",
"first_name": "Jane",
"last_name": "Smith",
"tenant_name": "Beta Inc",
"agree_terms_of_service": true
}'
```
**Example Response** (201 Created):
```json
{
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"user_email": "john@example.com",
"user_name": "John Doe",
"user_role": "manager",
"tenant_id": "650e8400-e29b-41d4-a716-446655440000",
"tenant_name": "Acme Corporation",
"tenant_slug": "acme-corp",
"session_id": "750e8400-e29b-41d4-a716-446655440000",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"access_expiry": "2024-10-24T12:00:00Z",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_expiry": "2024-11-24T00:00:00Z",
"created_at": "2024-10-24T00:00:00Z"
}
```
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Validation Error Response** (400 Bad Request):
```json
{
"type": "about:blank",
"title": "Validation Error",
"status": 400,
"detail": "One or more validation errors occurred",
"errors": {
"email": ["Invalid email format"],
"password": ["Field is required", "Password must be at least 8 characters"],
"confirm_password": ["Field is required", "Passwords do not match"],
"first_name": ["Field is required"],
"last_name": ["Field is required"],
"tenant_name": ["Field is required"],
"agree_terms_of_service": ["Must agree to terms of service"]
}
}
```
**Content-Type**: `application/problem+json`
**Common Validation Error Messages**:
| Field | Error Messages |
|-------|---------------|
| email | "Invalid email format", "Field is required" |
| password | "Field is required", "Password must be at least 8 characters", "Password must contain at least one uppercase letter (A-Z)", "Password must contain at least one lowercase letter (a-z)", "Password must contain at least one number (0-9)", "Password must contain at least one special character" |
| confirm_password | "Field is required", "Passwords do not match" |
| first_name | "Field is required", "First_name must be between 1 and 100 characters" |
| last_name | "Field is required", "Last_name must be between 1 and 100 characters" |
| tenant_name | "Field is required", "Tenant_name must be between 1 and 100 characters" |
| agree_terms_of_service | "Must agree to terms of service" |
**Other Error Responses**:
- `409 Conflict`: Email or tenant slug already exists
```json
{
"type": "about:blank",
"title": "Conflict",
"status": 409,
"detail": "Registration failed. The provided information is already in use"
}
```
- `500 Internal Server Error`: Server error
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to register user"
}
```
**Important Notes**:
- `agree_terms_of_service` must be `true` or the request will fail with 400 Bad Request
- `first_name` and `last_name` are required fields
- `timezone` is optional and defaults to "UTC" if not provided
- Password must be at least 8 characters long
- **Tenant slug is automatically generated** from `tenant_name` (converted to lowercase, special chars replaced with hyphens)
- The IP address of the request is automatically captured for audit trail purposes
- User role for registration is always "manager" (tenant creator)

View file

@ -0,0 +1,79 @@
# Rotate Site API Key
**POST /api/v1/sites/{id}/rotate-api-key**
Rotate a site's API key (use when the key is compromised).
**Authentication**: Required (JWT Bearer token)
**Headers**:
- `Authorization: JWT {access_token}`
**URL Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| id | UUID | Yes | Site ID |
**Example Request**:
```bash
curl -X POST http://localhost:8000/api/v1/sites/a1b2c3d4-e5f6-7890-abcd-ef1234567890/rotate-api-key \
-H "Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
**Example Response** (200 OK):
```json
{
"new_api_key": "live_sk_z9y8x7w6v5u4t3s2r1q0p9o8n7m6l5k4j3i2h1g0",
"old_key_last_four": "s9t0",
"rotated_at": "2024-10-27T15:00:00Z"
}
```
**🚨 CRITICAL Notes**:
- The `new_api_key` is shown **only once** - save it immediately!
- The old API key is **immediately invalidated** - no grace period!
- Your WordPress site will stop working until you update the plugin with the new key
- Update the WordPress plugin settings **RIGHT NOW** to restore functionality
- The rotation happens atomically:
- Old key is deleted from the database
- New key is inserted into the database
- Both operations complete instantly
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**401 Unauthorized**:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication required"
}
```
**404 Not Found**:
```json
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Site not found or doesn't belong to your tenant"
}
```
**500 Internal Server Error**:
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to rotate API key"
}
```

View file

@ -0,0 +1,148 @@
# Verify WordPress Site
**POST /api/v1/sites/{id}/verify**
Verify a WordPress site by checking DNS TXT records to prove domain ownership. This transitions the site from `pending` to `active` status.
**Authentication**: Required (JWT Bearer token)
**Headers**:
- `Authorization: JWT {access_token}`
**URL Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| id | UUID | Yes | Site ID |
**Request Body**:
No request body required. Verification is done automatically by checking DNS TXT records.
**DNS TXT Record Setup**:
Before calling this endpoint, you must add a DNS TXT record to your domain:
| Field | Value |
|-------|-------|
| Host/Name | Your domain (e.g., `example.com`) |
| Type | TXT |
| Value | `maplepress-verify={verification_token}` |
The verification token is provided when you create the site. DNS propagation typically takes 5-10 minutes but can take up to 48 hours.
**Example Request**:
```bash
curl -X POST http://localhost:8000/api/v1/sites/a1b2c3d4-e5f6-7890-abcd-ef1234567890/verify \
-H "Authorization: JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
**Example Response** (200 OK):
```json
{
"success": true,
"status": "active",
"message": "Domain ownership verified successfully via DNS TXT record"
}
```
**Important Notes**:
- The verification token is provided when the site is created (POST /api/v1/sites)
- You must add the DNS TXT record to your domain before calling this endpoint
- DNS propagation typically takes 5-10 minutes but can take up to 48 hours
- Once verified, the site status changes from `pending` to `active`
- After verification, the site can sync pages and use search functionality
- Test mode sites (`test_sk_` API keys) skip DNS verification automatically
- Already verified sites return success without error
**Verification Flow**:
1. User creates site via dashboard → receives `verification_token` and DNS instructions
2. User adds DNS TXT record to domain registrar: `maplepress-verify={token}`
3. User waits 5-10 minutes for DNS propagation
4. User clicks "Verify Site" in plugin → calls this endpoint
5. Backend performs DNS TXT lookup to verify domain ownership
6. Site transitions to `active` status → full functionality enabled
**Error Responses**:
This endpoint returns errors in **RFC 9457 (Problem Details for HTTP APIs)** format.
**Content-Type**: `application/problem+json`
**400 Bad Request** - DNS TXT record not found:
```json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "DNS TXT record not found. Please add the verification record to your domain's DNS settings and wait 5-10 minutes for propagation."
}
```
**400 Bad Request** - DNS lookup timed out:
```json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "DNS lookup timed out. Please check that your domain's DNS is properly configured."
}
```
**400 Bad Request** - Domain not found:
```json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Domain not found. Please check that your domain is properly registered and DNS is active."
}
```
**400 Bad Request** - Invalid site ID:
```json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid site ID format. Please provide a valid site ID."
}
```
**401 Unauthorized** - Missing or invalid JWT:
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Tenant context is required to access this resource."
}
```
**404 Not Found** - Site not found or doesn't belong to tenant:
```json
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "The requested site could not be found. It may have been deleted or you may not have access to it."
}
```
**500 Internal Server Error** - Server error:
```json
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to verify site. Please try again later."
}
```
**Related Endpoints**:
- [Create Site](./create-site.md) - Initial site creation (provides verification token)
- [Get Site](./get-site.md) - Check verification status
- [Plugin Status](./plugin-verify-api-key.md) - Check verification status from plugin
- [Sync Pages](./plugin-sync-pages.md) - Requires verification

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,333 @@
# MaplePress Backend - Getting Started
Complete guide for local development in under 5 minutes.
---
## Quick Start
### Prerequisites
- Docker and Docker Compose installed
- Go 1.21+ installed
- Task (Taskfile) installed: `brew install go-task/tap/go-task`
### Start Development (3 steps)
```bash
# 1. Start infrastructure (in separate terminal)
cd ../infrastructure/development
task dev:start
# Wait ~1 minute for services to be ready
# 2. Start backend (in this directory)
cd ../maplepress-backend
task dev
# Backend runs at http://localhost:8000
# Press Ctrl+C to stop
# Auto-migration and hot-reload are enabled
# 3. Verify it's running
curl http://localhost:8000/health
# Should return: {"status":"healthy"}
```
---
## Create Test Data
### 1. Register a User
```bash
# Create user and tenant
curl -X POST http://localhost:8000/api/v1/register \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "TestPassword123!",
"name": "Test User",
"tenant_name": "Test Organization",
"tenant_slug": "test-org"
}'
```
**Save the `access_token` from the response!**
```bash
# Export your token for subsequent requests
export TOKEN="eyJhbGci..."
```
### 2. Create a WordPress Site
```bash
# Tenant is automatically determined from JWT token
curl -X POST http://localhost:8000/api/v1/sites \
-H "Content-Type: application/json" \
-H "Authorization: JWT $TOKEN" \
-d '{
"domain": "localhost:8081",
"site_url": "http://localhost:8081"
}'
```
**Save the `api_key` from the response!** (Shown only once)
### 3. Test Plugin Authentication
```bash
# Test the API key (what WordPress plugin uses)
curl -X GET http://localhost:8000/api/v1/plugin/status \
-H "Authorization: Bearer YOUR_API_KEY_HERE"
```
---
## Common Commands
### Development Workflow
```bash
# Start backend (foreground, see logs)
task dev
# Restart after code changes
# CompileDaemon auto-rebuilds on file changes
# Only manually restart if needed:
# Press Ctrl+C, then:
task dev
# Stop backend
# Press Ctrl+C (or task dev:down in another terminal)
```
### Database
```bash
# Clear database (WARNING: deletes all data!)
task db:clear
# Manual migration (only if auto-migrate disabled)
task migrate:up
# View database
cd ../infrastructure/development
task cql
# Inside cqlsh:
USE maplepress;
SELECT * FROM sites_by_id;
```
### Testing
```bash
# Run tests
task test
# Format code
task format
# Run linters
task lint
```
### API Operations
```bash
# Export your token first
export TOKEN="your_jwt_token_here"
# Get your profile
curl http://localhost:8000/api/v1/me \
-H "Authorization: JWT $TOKEN"
# List sites
curl http://localhost:8000/api/v1/sites \
-H "Authorization: JWT $TOKEN"
# Get specific site
curl http://localhost:8000/api/v1/sites/SITE_ID \
-H "Authorization: JWT $TOKEN"
# Delete site
curl -X DELETE http://localhost:8000/api/v1/sites/SITE_ID \
-H "Authorization: JWT $TOKEN"
```
---
## WordPress Plugin Setup
### 1. Access WordPress Admin
```bash
# WordPress is running at:
http://localhost:8081/wp-admin
# Default credentials: admin / admin
```
### 2. Configure MaplePress Plugin
1. Navigate to **Settings → MaplePress**
2. Enter:
- **API URL**: `http://maplepress-backend-dev:8000`
- **API Key**: Your site API key from step 2 above
3. Click **Save Settings & Verify Connection**
**Note**: Use `http://maplepress-backend-dev:8000` (not `localhost`) because WordPress runs in Docker and needs the container name.
---
## Troubleshooting
### Backend won't start
**Error**: "Infrastructure not running!"
**Solution**:
```bash
cd ../infrastructure/development
task dev:start
# Wait for services to be healthy (~1 minute)
```
### Token expired (401 Unauthorized)
Tokens expire after 60 minutes. Login again:
```bash
curl -X POST http://localhost:8000/api/v1/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "TestPassword123!"
}'
```
### WordPress can't connect to backend
**Error**: "Could not resolve host"
**Solution**: Make sure you're using `http://maplepress-backend-dev:8000` (not `localhost:8000`) in WordPress settings.
**Verify from WordPress container**:
```bash
docker exec maple-wordpress-dev curl http://maplepress-backend-dev:8000/health
# Should return: {"status":"healthy"}
```
---
## Architecture Overview
```
Backend (Port 8000)
├── HTTP Server
├── JWT Authentication (user access)
├── API Key Authentication (plugin access)
├── Domain Layer (business logic)
├── Repository Layer (data access)
└── Infrastructure
├── Cassandra (primary database)
├── Redis (caching)
├── Meilisearch (search indexing)
└── SeaweedFS (S3-compatible storage)
```
### Key Concepts
- **Tenant**: Organization/account that can have multiple users and sites
- **User**: Person who logs in with email/password (gets JWT token)
- **Site**: WordPress installation (gets API key for plugin authentication)
- **Multi-tenancy**: All data is scoped to a tenant_id
- **JWT Token**: Used by dashboard/admin users (Authorization: JWT ...)
- **API Key**: Used by WordPress plugins (Authorization: Bearer ...)
---
## API Endpoints
### Public (No Auth)
- `GET /health` - Health check
- `POST /api/v1/register` - Register user + tenant
- `POST /api/v1/login` - Login
### Authenticated (JWT Required)
- `GET /api/v1/me` - Get user profile
- `POST /api/v1/sites` - Create site
- `GET /api/v1/sites` - List sites
- `GET /api/v1/sites/{id}` - Get site
- `DELETE /api/v1/sites/{id}` - Delete site
- `POST /api/v1/sites/{id}/rotate-key` - Rotate API key
### Plugin (API Key Required)
- `GET /api/v1/plugin/status` - Verify API key and get site info
**Full API documentation**: See `API.md`
---
## Environment Variables
The backend uses `.env` for configuration. Copy from sample:
```bash
cp .env.sample .env
```
**Key variables**:
```bash
# Application
APP_JWT_SECRET=change-me-in-production
SERVER_PORT=8000
# Database (Cassandra)
DATABASE_HOSTS=localhost
DATABASE_KEYSPACE=maplepress
# Cache (Redis)
CACHE_HOST=localhost
CACHE_PORT=6379
```
For Docker development, the `docker-compose.dev.yml` sets these automatically.
---
## Next Steps
- **API Documentation**: See `API.md` for complete endpoint reference
- **Architecture**: See `DEVELOPER_GUIDE.md` for code structure
- **WordPress Plugin**: See `native/wordpress/maplepress-plugin/README.md`
---
## Quick Reference
```bash
# Infrastructure
cd ../infrastructure/development
task dev:start # Start all services
task dev:stop # Stop all services
task cql # Open Cassandra shell
# Backend
cd ../maplepress-backend
task dev # Start backend (auto-migrate + hot-reload)
task dev:down # Stop backend
task db:clear # Clear database
task test # Run tests
task build # Build binary (only for manual operations)
task migrate:up # Manual migration (only if needed)
# View infrastructure logs
docker logs maple-cassandra-1-dev # Cassandra logs
docker logs maple-redis-dev # Redis logs
```
---
**Happy coding!** 🚀
For questions or issues, see the full documentation or check the [GitHub repository](https://codeberg.org/mapleopentech/monorepo).

View file

@ -0,0 +1,555 @@
# Site Verification System
## Overview
MaplePress implements **DNS-based domain ownership verification** to ensure users actually own the domains they register. Sites start in "pending" status and remain there until verified through DNS TXT record validation.
## Verification Method: DNS TXT Records
MaplePress uses **DNS TXT record verification** - the industry standard used by Google, Cloudflare, and other major services. This proves domain ownership, not just dashboard access.
### Why DNS Verification?
- **Proves domain ownership**: Only someone with DNS access can add TXT records
- **Industry standard**: Same method used by Google Search Console, Cloudflare, etc.
- **Secure**: Cannot be spoofed or bypassed without actual domain control
- **Automatic**: Backend performs verification via DNS lookup
## Site Status Lifecycle
### Status Constants
**File**: `internal/domain/site/site.go:61-67`
```go
const (
StatusPending = "pending" // Site created, awaiting DNS verification
StatusActive = "active" // Site verified via DNS and operational
StatusInactive = "inactive" // User temporarily disabled
StatusSuspended = "suspended" // Suspended due to violation or non-payment
StatusArchived = "archived" // Soft deleted
)
```
## 1. Site Creation (Pending State)
**File**: `internal/usecase/site/create.go`
When a site is created via **POST /api/v1/sites**:
### What Gets Generated
1. **API Key** (test or live mode)
- Test mode: `test_sk_...` (skips DNS verification)
- Live mode: `live_sk_...` (requires DNS verification)
2. **Verification Token** (lines 88-92)
- Format: `mvp_` + 128-bit random token (base64-encoded)
- Example: `mvp_xyz789abc123`
- Used in DNS TXT record: `maplepress-verify={token}`
3. **DNS Verification Instructions**
- Provides step-by-step DNS setup guide
- Includes domain registrar examples (GoDaddy, Namecheap, Cloudflare, etc.)
- Explains DNS propagation timing (5-10 minutes typical)
4. **Site Entity** (lines 104-113)
- Initial status: `StatusPending`
- `IsVerified`: `false`
- `VerificationToken`: Set to generated token
### Response Example
```json
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"domain": "example.com",
"site_url": "https://example.com",
"api_key": "live_sk_a1b2...", // ⚠️ SHOWN ONLY ONCE
"verification_token": "mvp_xyz789abc123",
"status": "pending",
"search_index_name": "site_...",
"verification_instructions": "To verify ownership of example.com, add this DNS TXT record:\n\nHost/Name: example.com\nType: TXT\nValue: maplepress-verify=mvp_xyz789abc123\n\nInstructions:\n1. Log in to your domain registrar...\n2. Find DNS settings...\n3. Add a new TXT record...\n4. Wait 5-10 minutes for DNS propagation\n5. Click 'Verify Domain' in MaplePress"
}
```
**Documentation**: `docs/API/create-site.md`
## 2. Test Mode Bypass
**File**: `internal/domain/site/site.go:115-125`
### Test Mode Detection
```go
func (s *Site) IsTestMode() bool {
return len(s.APIKeyPrefix) >= 7 && s.APIKeyPrefix[:7] == "test_sk"
}
```
### Verification Requirement Check
```go
func (s *Site) RequiresVerification() bool {
return !s.IsTestMode() // Test mode sites skip verification
}
```
**Key Points:**
- Sites with `test_sk_` API keys **skip verification** entirely
- Useful for development and testing
- Test mode sites can sync pages immediately
## 3. API Access Control
**File**: `internal/domain/site/site.go:127-140`
### CanAccessAPI() Method
```go
func (s *Site) CanAccessAPI() bool {
// Allow active sites (fully verified)
if s.Status == StatusActive {
return true
}
// Allow pending sites (waiting for verification) for initial setup
if s.Status == StatusPending {
return true
}
// Block inactive, suspended, or archived sites
return false
}
```
**Important**: Pending sites **CAN access the API** for:
- Status checks (`GET /api/v1/plugin/status`)
- Initial plugin setup
- Retrieving site information
## 4. Verification Enforcement
### Where Verification is Required
**File**: `internal/usecase/page/sync.go:85-89`
When syncing pages (**POST /api/v1/plugin/sync**):
```go
// Verify site is verified (skip for test mode)
if site.RequiresVerification() && !site.IsVerified {
uc.logger.Warn("site not verified", zap.String("site_id", siteID.String()))
return nil, domainsite.ErrSiteNotVerified
}
```
**Error**: `internal/domain/site/errors.go:22`
```go
ErrSiteNotVerified = errors.New("site is not verified")
```
### HTTP Response
```json
{
"type": "about:blank",
"title": "Forbidden",
"status": 403,
"detail": "site is not verified"
}
```
## 5. DNS Verification Implementation
### DNS Verifier Package
**File**: `pkg/dns/verifier.go`
```go
type Verifier struct {
resolver *net.Resolver
logger *zap.Logger
}
func (v *Verifier) VerifyDomainOwnership(ctx context.Context, domain string, expectedToken string) (bool, error) {
// Create context with 10-second timeout
lookupCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Look up TXT records for the domain
txtRecords, err := v.resolver.LookupTXT(lookupCtx, domain)
if err != nil {
return false, fmt.Errorf("DNS lookup failed: %w", err)
}
// Expected format: "maplepress-verify=TOKEN"
expectedRecord := fmt.Sprintf("maplepress-verify=%s", expectedToken)
for _, record := range txtRecords {
if strings.TrimSpace(record) == expectedRecord {
return true, nil // Domain ownership verified!
}
}
return false, nil // TXT record not found
}
```
### DNS Verification Use Case
**File**: `internal/usecase/site/verify.go`
The verification use case performs DNS lookup:
```go
func (uc *VerifySiteUseCase) Execute(ctx context.Context, tenantID gocql.UUID, siteID gocql.UUID, input *VerifySiteInput) (*VerifySiteOutput, error) {
// Get site from repository
site, err := uc.repo.GetByID(ctx, tenantID, siteID)
if err != nil {
return nil, domainsite.ErrSiteNotFound
}
// Check if already verified
if site.IsVerified {
return &VerifySiteOutput{Success: true, Status: site.Status, Message: "Site is already verified"}, nil
}
// Test mode sites skip DNS verification
if site.IsTestMode() {
site.Verify()
if err := uc.repo.Update(ctx, site); err != nil {
return nil, fmt.Errorf("failed to update site: %w", err)
}
return &VerifySiteOutput{Success: true, Status: site.Status, Message: "Test mode site verified successfully"}, nil
}
// Perform DNS TXT record verification
verified, err := uc.dnsVerifier.VerifyDomainOwnership(ctx, site.Domain, site.VerificationToken)
if err != nil {
return nil, fmt.Errorf("DNS verification failed: %w", err)
}
if !verified {
return nil, fmt.Errorf("DNS TXT record not found. Please add the verification record to your domain's DNS settings")
}
// DNS verification successful - mark site as verified
site.Verify()
if err := uc.repo.Update(ctx, site); err != nil {
return nil, fmt.Errorf("failed to update site: %w", err)
}
return &VerifySiteOutput{Success: true, Status: site.Status, Message: "Domain ownership verified successfully via DNS TXT record"}, nil
}
```
### Verify Method
**File**: `internal/domain/site/site.go:169-175`
```go
// Verify marks the site as verified
func (s *Site) Verify() {
s.IsVerified = true
s.Status = StatusActive
s.VerificationToken = "" // Clear token after verification
s.UpdatedAt = time.Now()
}
```
## 6. What Pending Sites Can Do
**File**: `internal/interface/http/handler/plugin/status_handler.go`
### Allowed Operations
**GET /api/v1/plugin/status** - Check site status and quotas
- Returns full site details
- Shows `is_verified: false`
- Shows `status: "pending"`
### Blocked Operations
**POST /api/v1/plugin/sync** - Sync pages to search index
- Returns 403 Forbidden
- Error: "site is not verified"
**POST /api/v1/plugin/search** - Perform searches
- Blocked for unverified sites
**DELETE /api/v1/plugin/pages** - Delete pages
- Blocked for unverified sites
## 7. Verification Token Details
**File**: `internal/usecase/site/generate_verification_token.go`
### Token Generation
```go
func (uc *GenerateVerificationTokenUseCase) Execute() (string, error) {
b := make([]byte, 16) // 16 bytes = 128 bits
if _, err := rand.Read(b); err != nil {
uc.logger.Error("failed to generate random bytes", zap.Error(err))
return "", err
}
token := base64.RawURLEncoding.EncodeToString(b)
verificationToken := "mvp_" + token // mvp = maplepress verify
uc.logger.Info("verification token generated")
return verificationToken, nil
}
```
**Token Format:**
- Prefix: `mvp_` (MaplePress Verify)
- Encoding: Base64 URL-safe (no padding)
- Strength: 128-bit cryptographic randomness
- Example: `mvp_dGhpc2lzYXRlc3Q`
**Security:**
- Never exposed in JSON responses (marked with `json:"-"`)
- Stored in database only
- Cleared after verification
## 8. DNS Verification Flow
### Step-by-Step Process
1. **User creates site** via dashboard (POST /api/v1/sites)
- Backend generates API key and verification token
- Site status: `pending`
- Response includes DNS setup instructions
- User receives: API key (once), verification token, DNS TXT record format
2. **User adds DNS TXT record** to domain registrar
- Logs in to domain registrar (GoDaddy, Namecheap, Cloudflare, etc.)
- Navigates to DNS management
- Adds TXT record: `maplepress-verify={verification_token}`
- Waits 5-10 minutes for DNS propagation (can take up to 48 hours)
3. **User installs WordPress plugin**
- Plugin activation screen shows
- User enters API key
- Plugin connects to backend
4. **Plugin checks status** (GET /api/v1/plugin/status)
- Backend returns site status: `pending`
- Plugin shows "Site not verified" message
- Plugin displays DNS instructions if not verified
5. **User verifies site** (POST /api/v1/sites/{id}/verify)
- User clicks "Verify Domain" in plugin or dashboard
- No request body needed (empty POST)
- Backend performs DNS TXT lookup for domain
- Backend checks for record: `maplepress-verify={verification_token}`
- If found: Site transitions `pending``active`, `IsVerified` set to `true`
- If not found: Returns error with DNS troubleshooting instructions
6. **Plugin can now sync** (POST /api/v1/plugin/sync)
- Verification check passes
- Pages are synced and indexed
- Search functionality enabled
## 9. Architectural Design Decisions
### Why Pending Sites Can Access API
From `site.go:127-140`, the design allows pending sites to:
- Check their status
- View usage statistics
- Prepare for verification
This is a **deliberate UX decision** to allow:
1. Plugin to be activated immediately
2. Admin to see connection status
3. Admin to complete verification steps
4. Smoother onboarding experience
### Why DNS Verification is Required
DNS verification prevents:
- **Domain squatting**: Claiming domains you don't own
- **Abuse**: Indexing content from sites you don't control
- **Impersonation**: Pretending to be another site
- **Unauthorized access**: Using the service without permission
DNS TXT record verification is the industry standard because:
- **Proves domain control**: Only someone with DNS access can add TXT records
- **Widely recognized**: Same method used by Google Search Console, Cloudflare, etc.
- **Cannot be spoofed**: Requires actual access to domain registrar
- **Automatic verification**: Backend can verify ownership without manual review
### Test Mode Rationale
Test mode (`test_sk_` keys) bypasses verification to enable:
- Local development without DNS
- Integration testing in CI/CD
- Staging environments
- Development workflows
## 10. Security Considerations
### Token Security
1. **Generation**:
- Cryptographically secure random generation
- 128-bit entropy (sufficient for this use case)
- Base64 URL-safe encoding
2. **Storage**:
- Stored in database as plain text (used in DNS TXT record)
- Cleared after successful verification
- Only accessible to authenticated tenant
3. **DNS Verification Security**:
- DNS TXT records are public (as intended)
- Token is meaningless without backend verification
- 10-second timeout on DNS lookups prevents DoS
- Token cleared after verification prevents reuse
### Attack Vectors Mitigated
1. **Domain Squatting**: DNS verification proves domain ownership
2. **Token Guessing**: 128-bit entropy makes brute force infeasible
3. **Token Reuse**: Token cleared after successful verification
4. **Man-in-the-Middle**: HTTPS required for all API calls
5. **DNS Spoofing**: Uses multiple DNS resolvers and validates responses
6. **DNS Cache Poisoning**: 10-second timeout limits attack window
## 11. API Documentation
See individual endpoint documentation:
- [Create Site](./API/create-site.md) - Initial site creation
- [Verify Site](./API/verify-site.md) - Site verification endpoint
- [Plugin Status](./API/plugin-verify-api-key.md) - Check verification status
- [Sync Pages](./API/plugin-sync-pages.md) - Requires verification
## 12. WordPress Plugin Integration
The WordPress plugin should:
1. **On Activation**:
- Prompt user for API key
- Connect to backend
- Check verification status
2. **If Not Verified**:
- Display DNS TXT record instructions
- Show the exact TXT record to add: `maplepress-verify={token}`
- Provide domain registrar examples (GoDaddy, Namecheap, Cloudflare)
- Explain DNS propagation timing (5-10 minutes)
- Provide "Verify Domain" button
- Disable sync/search features
3. **Verification Process**:
- User clicks "Verify Domain"
- Plugin calls POST /api/v1/sites/{id}/verify (no body)
- Backend performs DNS TXT lookup
- If successful: Enable all features
- If failed: Show specific DNS error (record not found, timeout, etc.)
4. **After Verification**:
- Enable all features
- Allow page synchronization
- Enable search functionality
- Hide verification prompts
5. **Error Handling**:
- Handle 403 "site is not verified" gracefully
- Guide user to DNS verification process
- Show DNS troubleshooting tips (check propagation, verify record format)
- Retry verification status check
## 13. Database Schema
### Site Table Fields
```
sites_by_id:
- id (UUID, primary key)
- tenant_id (UUID)
- status (text: pending|active|inactive|suspended|archived)
- is_verified (boolean)
- verification_token (text, sensitive)
- ...
```
### Indexes Required
No special indexes needed for DNS verification - uses existing site_id and tenant_id lookups.
## 14. Troubleshooting
### Common Issues
1. **DNS TXT record not found**:
- Check DNS propagation status (use dig or nslookup)
- Verify record format: `maplepress-verify={exact_token}`
- Wait 5-10 minutes for DNS propagation
- Check that TXT record was added to correct domain/subdomain
- Verify no typos in the verification token
2. **DNS lookup timeout**:
- Check domain's DNS servers are responding
- Verify domain is properly registered
- Check for DNS configuration issues
- Try again after DNS stabilizes
3. **Site stuck in pending**:
- Verify DNS TXT record is correctly set
- Call verification endpoint: POST /api/v1/sites/{id}/verify
- Check logs for DNS lookup errors
- Use DNS checking tools (dig, nslookup) to verify record
4. **Test mode not working**:
- Verify API key starts with `test_sk_`
- Check `IsTestMode()` logic in site.go:115-125
- Test mode sites skip DNS verification entirely
5. **DNS verification fails**:
- Token may have been cleared (already verified)
- DNS record format incorrect
- Wrong domain or subdomain
- Check error logs for specific DNS errors
### Debug Commands
```bash
# Check DNS TXT record manually
dig TXT example.com
nslookup -type=TXT example.com
# Check site status
curl -X GET http://localhost:8000/api/v1/sites/{id} \
-H "Authorization: JWT {token}"
# Verify site via DNS
curl -X POST http://localhost:8000/api/v1/sites/{id}/verify \
-H "Authorization: JWT {token}"
```
## 15. Future Enhancements
Potential improvements to the verification system:
1. **Token Expiration**:
- Add 24-48 hour expiration for verification tokens
- Allow token regeneration
- Email token to site admin
2. **Alternative Verification Methods**:
- Meta tag verification (alternative to DNS)
- File upload verification (.well-known/maplepress-verify.txt)
- WordPress plugin automatic verification (callback endpoint)
3. **Automatic Re-verification**:
- Periodic DNS checks to ensure domain ownership hasn't changed
- Alert if DNS record is removed
- Grace period before disabling site
4. **Verification Audit Log**:
- Track when site was verified
- Record who performed verification
- Log IP address and timestamp
- DNS lookup results and timing

View file

@ -0,0 +1,62 @@
module codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend
go 1.25.4
require (
github.com/awnumar/memguard v0.23.0
github.com/aws/aws-sdk-go-v2 v1.40.1
github.com/aws/aws-sdk-go-v2/config v1.32.3
github.com/aws/aws-sdk-go-v2/credentials v1.19.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0
github.com/aws/smithy-go v1.24.0
github.com/bsm/redislock v0.9.4
github.com/gocql/gocql v1.7.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
github.com/mailgun/mailgun-go/v4 v4.23.0
github.com/meilisearch/meilisearch-go v0.34.2
github.com/oschwald/geoip2-golang v1.13.0
github.com/redis/go-redis/v9 v9.17.2
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/cobra v1.10.1
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.45.0
)
require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/awnumar/memcall v0.4.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/go-chi/chi/v5 v5.2.1 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailgun/errors v0.4.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/tyler-smith/go-bip39 v1.1.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/sys v0.38.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
)

View file

@ -0,0 +1,200 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g=
github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w=
github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A=
github.com/awnumar/memguard v0.23.0/go.mod h1:olVofBrsPdITtJ2HgxQKrEYEMyIBAIciVG4wNnZhW9M=
github.com/aws/aws-sdk-go-v2 v1.40.1 h1:difXb4maDZkRH0x//Qkwcfpdg1XQVXEAEs2DdXldFFc=
github.com/aws/aws-sdk-go-v2 v1.40.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.3 h1:cpz7H2uMNTDa0h/5CYL5dLUEzPSLo2g0NkbxTRJtSSU=
github.com/aws/aws-sdk-go-v2/config v1.32.3/go.mod h1:srtPKaJJe3McW6T/+GMBZyIPc+SeqJsNPJsd4mOYZ6s=
github.com/aws/aws-sdk-go-v2/credentials v1.19.3 h1:01Ym72hK43hjwDeJUfi1l2oYLXBAOR8gNSZNmXmvuas=
github.com/aws/aws-sdk-go-v2/credentials v1.19.3/go.mod h1:55nWF/Sr9Zvls0bGnWkRxUdhzKqj9uRNlPvgV1vgxKc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 h1:utxLraaifrSBkeyII9mIbVwXXWrZdlPO7FIKmyLCEcY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15/go.mod h1:hW6zjYUDQwfz3icf4g2O41PHi77u10oAzJ84iSzR/lo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 h1:Y5YXgygXwDI5P4RkteB5yF7v35neH7LfJKBG+hzIons=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15/go.mod h1:K+/1EpG42dFSY7CBj+Fruzm8PsCGWTXJ3jdeJ659oGQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 h1:AvltKnW9ewxX2hFmQS0FyJH93aSvJVUEFvXfU+HWtSE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15/go.mod h1:3I4oCdZdmgrREhU74qS1dK9yZ62yumob+58AbFR4cQA=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 h1:NLYTEyZmVZo0Qh183sC8nC+ydJXOOeIL/qI/sS3PdLY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15/go.mod h1:Z803iB3B0bc8oJV8zH2PERLRfQUJ2n2BXISpsA4+O1M=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 h1:P1MU/SuhadGvg2jtviDXPEejU3jBNhoeeAlRadHzvHI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6/go.mod h1:5KYaMG6wmVKMFBSfWoyG/zH8pWwzQFnKgpoSRlXHKdQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 h1:3/u/4yZOffg5jdNk1sDpOQ4Y+R6Xbh+GzpDrSZjuy3U=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15/go.mod h1:4Zkjq0FKjE78NKjabuM4tRXKFzUJWXgP0ItEZK8l7JU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 h1:wsSQ4SVz5YE1crz0Ap7VBZrV4nNqZt4CIBBT8mnwoNc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15/go.mod h1:I7sditnFGtYMIqPRU1QoHZAUrXkGp4SczmlLwrNPlD0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 h1:IrbE3B8O9pm3lsg96AXIN5MXX4pECEuExh/A0Du3AuI=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0/go.mod h1:/sJLzHtiiZvs6C1RbxS/anSAFwZD6oC6M/kotQzOiLw=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.3 h1:d/6xOGIllc/XW1lzG9a4AUBMmpLA9PXcQnVPTuHHcik=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.3/go.mod h1:fQ7E7Qj9GiW8y0ClD7cUJk3Bz5Iw8wZkWDHsTe8vDKs=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.6 h1:8sTTiw+9yuNXcfWeqKF2x01GqCF49CpP4Z9nKrrk/ts=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.6/go.mod h1:8WYg+Y40Sn3X2hioaaWAAIngndR8n1XFdRPPX+7QBaM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 h1:E+KqWoVsSrj1tJ6I/fjDIu5xoS2Zacuu1zT+H7KtiIk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11/go.mod h1:qyWHz+4lvkXcr3+PoGlGHEI+3DLLiU6/GdrFfMaAhB0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 h1:tzMkjh0yTChUqJDgGkcDdxvZDSrJ/WB6R6ymI5ehqJI=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.3/go.mod h1:T270C0R5sZNLbWUe8ueiAF42XSZxxPocTaGSgs5c/60=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932 h1:mXoPYz/Ul5HYEDvkta6I8/rnYM5gSdSV2tJ6XbZuEtY=
github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bsm/redislock v0.9.4 h1:X/Wse1DPpiQgHbVYRE9zv6m070UcKoOGekgvpNhiSvw=
github.com/bsm/redislock v0.9.4/go.mod h1:Epf7AJLiSFwLCiZcfi6pWFO/8eAYrYpQXFxEDPoDeAk=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gocql/gocql v1.7.0 h1:O+7U7/1gSN7QTEAaMEsJc1Oq2QHXvCWoF3DFK9HDHus=
github.com/gocql/gocql v1.7.0/go.mod h1:vnlvXyFZeLBF0Wy+RS8hrOdbn0UWsWtdg07XJnFxZ+4=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailgun/errors v0.4.0 h1:6LFBvod6VIW83CMIOT9sYNp28TCX0NejFPP4dSX++i8=
github.com/mailgun/errors v0.4.0/go.mod h1:xGBaaKdEdQT0/FhwvoXv4oBaqqmVZz9P1XEnvD/onc0=
github.com/mailgun/mailgun-go/v4 v4.23.0 h1:jPEMJzzin2s7lvehcfv/0UkyBu18GvcURPr2+xtZRbk=
github.com/mailgun/mailgun-go/v4 v4.23.0/go.mod h1:imTtizoFtpfZqPqGP8vltVBB6q9yWcv6llBhfFeElZU=
github.com/meilisearch/meilisearch-go v0.34.2 h1:/OVQ2NQU3nRT5M/bhtg6pzxckxxGLy1hZyo3zjrja28=
github.com/meilisearch/meilisearch-go v0.34.2/go.mod h1:cUVJZ2zMqTvvwIMEEAdsWH+zrHsrLpAw6gm8Lt1MXK0=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,44 @@
// File Path: monorepo/cloud/maplepress-backend/internal/domain/page/interface.go
package page
import (
"context"
"github.com/gocql/gocql"
)
// Repository defines the interface for page data operations
type Repository interface {
// Create inserts a new page
Create(ctx context.Context, page *Page) error
// Update updates an existing page
Update(ctx context.Context, page *Page) error
// Upsert creates or updates a page
Upsert(ctx context.Context, page *Page) error
// GetByID retrieves a page by site_id and page_id
GetByID(ctx context.Context, siteID gocql.UUID, pageID string) (*Page, error)
// GetBySiteID retrieves all pages for a site
GetBySiteID(ctx context.Context, siteID gocql.UUID) ([]*Page, error)
// GetBySiteIDPaginated retrieves pages for a site with pagination
GetBySiteIDPaginated(ctx context.Context, siteID gocql.UUID, limit int, pageState []byte) ([]*Page, []byte, error)
// Delete deletes a page
Delete(ctx context.Context, siteID gocql.UUID, pageID string) error
// DeleteBySiteID deletes all pages for a site
DeleteBySiteID(ctx context.Context, siteID gocql.UUID) error
// DeleteMultiple deletes multiple pages by their IDs
DeleteMultiple(ctx context.Context, siteID gocql.UUID, pageIDs []string) error
// CountBySiteID counts pages for a site
CountBySiteID(ctx context.Context, siteID gocql.UUID) (int64, error)
// Exists checks if a page exists
Exists(ctx context.Context, siteID gocql.UUID, pageID string) (bool, error)
}

View file

@ -0,0 +1,132 @@
// File Path: monorepo/cloud/maplepress-backend/internal/domain/page/page.go
package page
import (
"time"
"github.com/gocql/gocql"
)
// Page represents a WordPress page/post indexed in the system
type Page struct {
// Identity
SiteID gocql.UUID `json:"site_id"` // Partition key
PageID string `json:"page_id"` // Clustering key (WordPress page ID)
TenantID gocql.UUID `json:"tenant_id"` // For additional isolation
// Content
Title string `json:"title"`
Content string `json:"content"` // HTML stripped
Excerpt string `json:"excerpt"` // Summary
URL string `json:"url"` // Canonical URL
// Metadata
Status string `json:"status"` // publish, draft, trash
PostType string `json:"post_type"` // page, post
Author string `json:"author"`
// Timestamps
PublishedAt time.Time `json:"published_at"`
ModifiedAt time.Time `json:"modified_at"`
IndexedAt time.Time `json:"indexed_at"` // When we indexed it
// Search
MeilisearchDocID string `json:"meilisearch_doc_id"` // ID in Meilisearch index
// Audit
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// CWE-359: IP address tracking for GDPR compliance (90-day expiration)
CreatedFromIPAddress string `json:"-"` // Encrypted IP address, never exposed in JSON
CreatedFromIPTimestamp time.Time `json:"-"` // For 90-day expiration tracking
ModifiedFromIPAddress string `json:"-"` // Encrypted IP address, never exposed in JSON
ModifiedFromIPTimestamp time.Time `json:"-"` // For 90-day expiration tracking
}
// Status constants
const (
StatusPublish = "publish"
StatusDraft = "draft"
StatusTrash = "trash"
)
// PostType constants
const (
PostTypePage = "page"
PostTypePost = "post"
)
// NewPage creates a new Page entity
func NewPage(siteID, tenantID gocql.UUID, pageID string, title, content, excerpt, url, status, postType, author string, publishedAt, modifiedAt time.Time, encryptedIP string) *Page {
now := time.Now()
return &Page{
SiteID: siteID,
PageID: pageID,
TenantID: tenantID,
Title: title,
Content: content,
Excerpt: excerpt,
URL: url,
Status: status,
PostType: postType,
Author: author,
PublishedAt: publishedAt,
ModifiedAt: modifiedAt,
IndexedAt: now,
MeilisearchDocID: "", // Set after indexing in Meilisearch
CreatedAt: now,
UpdatedAt: now,
// CWE-359: Encrypted IP address tracking for GDPR compliance
CreatedFromIPAddress: encryptedIP,
CreatedFromIPTimestamp: now,
ModifiedFromIPAddress: encryptedIP,
ModifiedFromIPTimestamp: now,
}
}
// IsPublished checks if the page is published
func (p *Page) IsPublished() bool {
return p.Status == StatusPublish
}
// ShouldIndex checks if the page should be indexed in search
func (p *Page) ShouldIndex() bool {
// Only index published pages
return p.IsPublished()
}
// GetMeilisearchID returns the Meilisearch document ID
func (p *Page) GetMeilisearchID() string {
if p.MeilisearchDocID != "" {
return p.MeilisearchDocID
}
// Use page_id as fallback
return p.PageID
}
// SetMeilisearchID sets the Meilisearch document ID
func (p *Page) SetMeilisearchID(docID string) {
p.MeilisearchDocID = docID
p.UpdatedAt = time.Now()
}
// MarkIndexed updates the indexed timestamp
func (p *Page) MarkIndexed() {
p.IndexedAt = time.Now()
p.UpdatedAt = time.Now()
}
// Update updates the page content
func (p *Page) Update(title, content, excerpt, url, status, author string, modifiedAt time.Time) {
p.Title = title
p.Content = content
p.Excerpt = excerpt
p.URL = url
p.Status = status
p.Author = author
p.ModifiedAt = modifiedAt
p.UpdatedAt = time.Now()
}

View file

@ -0,0 +1,104 @@
// File Path: monorepo/cloud/maplepress-backend/internal/domain/securityevent/entity.go
package securityevent
import (
"time"
)
// EventType represents the type of security event
type EventType string
const (
// Account lockout events
EventTypeAccountLocked EventType = "account_locked"
EventTypeAccountUnlocked EventType = "account_unlocked"
// Failed login events
EventTypeFailedLogin EventType = "failed_login"
EventTypeExcessiveFailedLogin EventType = "excessive_failed_login"
// Successful events
EventTypeSuccessfulLogin EventType = "successful_login"
// Rate limiting events
EventTypeIPRateLimitExceeded EventType = "ip_rate_limit_exceeded"
)
// Severity represents the severity level of the security event
type Severity string
const (
SeverityLow Severity = "low"
SeverityMedium Severity = "medium"
SeverityHigh Severity = "high"
SeverityCritical Severity = "critical"
)
// SecurityEvent represents a security-related event in the system
// CWE-778: Insufficient Logging - Security events must be logged for audit
type SecurityEvent struct {
// Unique identifier for the event
ID string `json:"id"`
// Type of security event
EventType EventType `json:"event_type"`
// Severity level
Severity Severity `json:"severity"`
// User email (hashed for privacy)
EmailHash string `json:"email_hash"`
// Client IP address
ClientIP string `json:"client_ip"`
// User agent
UserAgent string `json:"user_agent,omitempty"`
// Additional metadata as key-value pairs
Metadata map[string]interface{} `json:"metadata,omitempty"`
// Timestamp when the event occurred
Timestamp time.Time `json:"timestamp"`
// Message describing the event
Message string `json:"message"`
}
// NewSecurityEvent creates a new security event
func NewSecurityEvent(
eventType EventType,
severity Severity,
emailHash string,
clientIP string,
message string,
) *SecurityEvent {
return &SecurityEvent{
ID: generateEventID(),
EventType: eventType,
Severity: severity,
EmailHash: emailHash,
ClientIP: clientIP,
Metadata: make(map[string]interface{}),
Timestamp: time.Now().UTC(),
Message: message,
}
}
// WithMetadata adds metadata to the security event
func (e *SecurityEvent) WithMetadata(key string, value interface{}) *SecurityEvent {
e.Metadata[key] = value
return e
}
// WithUserAgent sets the user agent
func (e *SecurityEvent) WithUserAgent(userAgent string) *SecurityEvent {
e.UserAgent = userAgent
return e
}
// generateEventID generates a unique event ID
func generateEventID() string {
// Simple timestamp-based ID (can be replaced with UUID if needed)
return time.Now().UTC().Format("20060102150405.000000")
}

View file

@ -0,0 +1,42 @@
package domain
import (
"time"
"github.com/gocql/gocql"
"github.com/google/uuid"
)
// Session represents a user's authentication session
type Session struct {
ID string `json:"id"` // Session UUID
UserID uint64 `json:"user_id"` // User's ID
UserUUID uuid.UUID `json:"user_uuid"` // User's UUID
UserEmail string `json:"user_email"` // User's email
UserName string `json:"user_name"` // User's full name
UserRole string `json:"user_role"` // User's role (admin, user, etc.)
TenantID uuid.UUID `json:"tenant_id"` // Tenant ID for multi-tenancy
CreatedAt time.Time `json:"created_at"` // When the session was created
ExpiresAt time.Time `json:"expires_at"` // When the session expires
}
// NewSession creates a new session
func NewSession(userID uint64, userUUID uuid.UUID, userEmail, userName, userRole string, tenantID uuid.UUID, duration time.Duration) *Session {
now := time.Now()
return &Session{
ID: gocql.TimeUUID().String(),
UserID: userID,
UserUUID: userUUID,
UserEmail: userEmail,
UserName: userName,
UserRole: userRole,
TenantID: tenantID,
CreatedAt: now,
ExpiresAt: now.Add(duration),
}
}
// IsExpired checks if the session has expired
func (s *Session) IsExpired() bool {
return time.Now().After(s.ExpiresAt)
}

View file

@ -0,0 +1,35 @@
package site
import "errors"
var (
// ErrNotFound is returned when a site is not found
ErrNotFound = errors.New("site not found")
// ErrSiteNotFound is an alias for ErrNotFound
ErrSiteNotFound = ErrNotFound
// ErrDomainAlreadyExists is returned when trying to create a site with a domain that already exists
ErrDomainAlreadyExists = errors.New("domain already exists")
// ErrInvalidAPIKey is returned when API key authentication fails
ErrInvalidAPIKey = errors.New("invalid API key")
// ErrSiteNotActive is returned when trying to perform operations on an inactive site
ErrSiteNotActive = errors.New("site is not active")
// ErrSiteNotVerified is returned when trying to perform operations on an unverified site
ErrSiteNotVerified = errors.New("site is not verified")
// ErrQuotaExceeded is returned when a quota limit is reached
ErrQuotaExceeded = errors.New("quota exceeded")
// ErrStorageQuotaExceeded is returned when storage quota is exceeded
ErrStorageQuotaExceeded = errors.New("storage quota exceeded")
// ErrSearchQuotaExceeded is returned when search quota is exceeded
ErrSearchQuotaExceeded = errors.New("search quota exceeded")
// ErrIndexingQuotaExceeded is returned when indexing quota is exceeded
ErrIndexingQuotaExceeded = errors.New("indexing quota exceeded")
)

View file

@ -0,0 +1,45 @@
package site
import (
"context"
"github.com/gocql/gocql"
)
// Repository defines the interface for site data access
type Repository interface {
// Create inserts a new site into all Cassandra tables
Create(ctx context.Context, site *Site) error
// GetByID retrieves a site by tenant_id and site_id
GetByID(ctx context.Context, tenantID, siteID gocql.UUID) (*Site, error)
// GetByDomain retrieves a site by domain name
GetByDomain(ctx context.Context, domain string) (*Site, error)
// GetByAPIKeyHash retrieves a site by API key hash (for authentication)
GetByAPIKeyHash(ctx context.Context, apiKeyHash string) (*Site, error)
// ListByTenant retrieves all sites for a tenant (paginated)
ListByTenant(ctx context.Context, tenantID gocql.UUID, pageSize int, pageState []byte) ([]*Site, []byte, error)
// Update updates a site in all Cassandra tables
Update(ctx context.Context, site *Site) error
// UpdateAPIKey updates the API key for a site (handles sites_by_apikey table correctly)
// Must provide both old and new API key hashes to properly delete old entry and insert new one
UpdateAPIKey(ctx context.Context, site *Site, oldAPIKeyHash string) error
// Delete removes a site from all Cassandra tables
Delete(ctx context.Context, tenantID, siteID gocql.UUID) error
// DomainExists checks if a domain is already registered
DomainExists(ctx context.Context, domain string) (bool, error)
// UpdateUsage updates only usage tracking fields (optimized for frequent updates)
UpdateUsage(ctx context.Context, site *Site) error
// GetAllSitesForUsageReset retrieves all sites for monthly usage counter reset
// This uses ALLOW FILTERING and should only be used for administrative tasks
GetAllSitesForUsageReset(ctx context.Context, pageSize int, pageState []byte) ([]*Site, []byte, error)
}

View file

@ -0,0 +1,187 @@
// File Path: monorepo/cloud/maplepress-backend/internal/domain/site/site.go
package site
import (
"time"
"github.com/gocql/gocql"
)
// Site represents a WordPress site registered in the system
type Site struct {
// Core Identity
ID gocql.UUID `json:"id"`
TenantID gocql.UUID `json:"tenant_id"`
// Site Information
SiteURL string `json:"site_url"` // Full URL: https://example.com
Domain string `json:"domain"` // Extracted: example.com
// Authentication
APIKeyHash string `json:"-"` // SHA-256 hash, never exposed in JSON
APIKeyPrefix string `json:"api_key_prefix"` // "live_sk_a1b2" for display
APIKeyLastFour string `json:"api_key_last_four"` // Last 4 chars for display
// Status & Verification
Status string `json:"status"` // active, inactive, pending, suspended, archived
IsVerified bool `json:"is_verified"`
VerificationToken string `json:"-"` // Never exposed
// Search & Indexing
SearchIndexName string `json:"search_index_name"`
TotalPagesIndexed int64 `json:"total_pages_indexed"` // All-time total for stats
LastIndexedAt time.Time `json:"last_indexed_at,omitempty"`
// Plugin Info
PluginVersion string `json:"plugin_version,omitempty"`
// Usage Tracking (for billing) - no quotas/limits
StorageUsedBytes int64 `json:"storage_used_bytes"` // Current storage usage
SearchRequestsCount int64 `json:"search_requests_count"` // Monthly search count
MonthlyPagesIndexed int64 `json:"monthly_pages_indexed"` // Monthly indexing count
LastResetAt time.Time `json:"last_reset_at"` // Last monthly reset
// Metadata (optional fields)
Language string `json:"language,omitempty"` // ISO 639-1
Timezone string `json:"timezone,omitempty"` // IANA timezone
Notes string `json:"notes,omitempty"`
// Audit
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// CWE-359: IP address tracking for GDPR compliance (90-day expiration)
CreatedFromIPAddress string `json:"-"` // Encrypted IP address, never exposed in JSON
CreatedFromIPTimestamp time.Time `json:"-"` // For 90-day expiration tracking
ModifiedFromIPAddress string `json:"-"` // Encrypted IP address, never exposed in JSON
ModifiedFromIPTimestamp time.Time `json:"-"` // For 90-day expiration tracking
}
// Status constants
const (
StatusPending = "pending" // Site created, awaiting verification
StatusActive = "active" // Site verified and operational
StatusInactive = "inactive" // User temporarily disabled
StatusSuspended = "suspended" // Suspended due to violation or non-payment
StatusArchived = "archived" // Soft deleted
)
// NewSite creates a new Site entity with defaults
func NewSite(tenantID gocql.UUID, domain, siteURL string, apiKeyHash, apiKeyPrefix, apiKeyLastFour string, encryptedIP string) *Site {
now := time.Now()
siteID := gocql.TimeUUID()
return &Site{
ID: siteID,
TenantID: tenantID,
Domain: domain,
SiteURL: siteURL,
APIKeyHash: apiKeyHash,
APIKeyPrefix: apiKeyPrefix,
APIKeyLastFour: apiKeyLastFour,
Status: StatusPending,
IsVerified: false,
VerificationToken: "", // Set by caller
SearchIndexName: "site_" + siteID.String(),
TotalPagesIndexed: 0,
PluginVersion: "",
// Usage tracking (no quotas/limits)
StorageUsedBytes: 0,
SearchRequestsCount: 0,
MonthlyPagesIndexed: 0,
LastResetAt: now,
Language: "",
Timezone: "",
Notes: "",
CreatedAt: now,
UpdatedAt: now,
// CWE-359: Encrypted IP address tracking for GDPR compliance
CreatedFromIPAddress: encryptedIP,
CreatedFromIPTimestamp: now,
ModifiedFromIPAddress: encryptedIP,
ModifiedFromIPTimestamp: now,
}
}
// IsActive checks if the site is active and verified
func (s *Site) IsActive() bool {
return s.Status == StatusActive && s.IsVerified
}
// IsTestMode checks if the site is using a test API key
func (s *Site) IsTestMode() bool {
// Check if API key prefix starts with "test_sk_"
return len(s.APIKeyPrefix) >= 7 && s.APIKeyPrefix[:7] == "test_sk"
}
// RequiresVerification checks if the site requires verification
// Test mode sites skip verification for development
func (s *Site) RequiresVerification() bool {
return !s.IsTestMode()
}
// CanAccessAPI checks if the site can access the API
// More lenient than IsActive - allows pending sites for initial setup
func (s *Site) CanAccessAPI() bool {
// Allow active sites (fully verified)
if s.Status == StatusActive {
return true
}
// Allow pending sites (waiting for verification) for initial setup
if s.Status == StatusPending {
return true
}
// Block inactive, suspended, or archived sites
return false
}
// IncrementSearchCount increments the search request counter
func (s *Site) IncrementSearchCount() {
s.SearchRequestsCount++
s.UpdatedAt = time.Now()
}
// IncrementPageCount increments the indexed page counter (lifetime total)
func (s *Site) IncrementPageCount() {
s.TotalPagesIndexed++
s.UpdatedAt = time.Now()
}
// IncrementMonthlyPageCount increments both lifetime and monthly page counters
func (s *Site) IncrementMonthlyPageCount(count int64) {
s.TotalPagesIndexed += count
s.MonthlyPagesIndexed += count
s.LastIndexedAt = time.Now()
s.UpdatedAt = time.Now()
}
// UpdateStorageUsed updates the storage usage
func (s *Site) UpdateStorageUsed(bytes int64) {
s.StorageUsedBytes = bytes
s.UpdatedAt = time.Now()
}
// Verify marks the site as verified
func (s *Site) Verify() {
s.IsVerified = true
s.Status = StatusActive
s.VerificationToken = "" // Clear token after verification
s.UpdatedAt = time.Now()
}
// ResetMonthlyUsage resets monthly usage counters for billing cycles
func (s *Site) ResetMonthlyUsage() {
now := time.Now()
// Reset usage counters (no quotas)
s.SearchRequestsCount = 0
s.MonthlyPagesIndexed = 0
s.LastResetAt = now
s.UpdatedAt = now
}

View file

@ -0,0 +1,75 @@
package tenant
import (
"errors"
"regexp"
"time"
)
var (
ErrNameRequired = errors.New("tenant name is required")
ErrNameTooShort = errors.New("tenant name must be at least 2 characters")
ErrNameTooLong = errors.New("tenant name must not exceed 100 characters")
ErrSlugRequired = errors.New("tenant slug is required")
ErrSlugInvalid = errors.New("tenant slug must contain only lowercase letters, numbers, and hyphens")
ErrTenantNotFound = errors.New("tenant not found")
ErrTenantExists = errors.New("tenant already exists")
ErrTenantInactive = errors.New("tenant is inactive")
)
// Status represents the tenant's current status
type Status string
const (
StatusActive Status = "active"
StatusInactive Status = "inactive"
StatusSuspended Status = "suspended"
)
// Tenant represents a tenant in the system
// Each tenant is a separate customer/organization
type Tenant struct {
ID string
Name string // Display name (e.g., "Acme Corporation")
Slug string // URL-friendly identifier (e.g., "acme-corp")
Status Status
CreatedAt time.Time
UpdatedAt time.Time
// CWE-359: IP address tracking for GDPR compliance (90-day expiration)
CreatedFromIPAddress string // Encrypted IP address
CreatedFromIPTimestamp time.Time // For 90-day expiration tracking
ModifiedFromIPAddress string // Encrypted IP address
ModifiedFromIPTimestamp time.Time // For 90-day expiration tracking
}
var slugRegex = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
// Validate validates the tenant entity
func (t *Tenant) Validate() error {
// Name validation
if t.Name == "" {
return ErrNameRequired
}
if len(t.Name) < 2 {
return ErrNameTooShort
}
if len(t.Name) > 100 {
return ErrNameTooLong
}
// Slug validation
if t.Slug == "" {
return ErrSlugRequired
}
if !slugRegex.MatchString(t.Slug) {
return ErrSlugInvalid
}
return nil
}
// IsActive returns true if the tenant is active
func (t *Tenant) IsActive() bool {
return t.Status == StatusActive
}

View file

@ -0,0 +1,16 @@
package tenant
import "context"
// Repository defines data access for tenants
// Note: Tenant operations do NOT require tenantID parameter since
// tenants are the top-level entity in our multi-tenant architecture
type Repository interface {
Create(ctx context.Context, tenant *Tenant) error
GetByID(ctx context.Context, id string) (*Tenant, error)
GetBySlug(ctx context.Context, slug string) (*Tenant, error)
Update(ctx context.Context, tenant *Tenant) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, limit int) ([]*Tenant, error)
ListByStatus(ctx context.Context, status Status, limit int) ([]*Tenant, error)
}

View file

@ -0,0 +1,169 @@
package user
import (
"errors"
"regexp"
"time"
)
// User represents a user entity in the domain
// Every user strictly belongs to a tenant
type User struct {
ID string
Email string
FirstName string
LastName string
Name string
LexicalName string
Timezone string
// Role management
Role int
// State management
Status int
// Embedded structs for better organization
ProfileData *UserProfileData
// Encapsulating security related data
SecurityData *UserSecurityData
// Metadata about the user
Metadata *UserMetadata
// Limited metadata fields used for querying
TenantID string // Every user belongs to a tenant
CreatedAt time.Time
UpdatedAt time.Time
}
// UserProfileData contains user profile information
type UserProfileData struct {
Phone string
Country string
Region string
City string
PostalCode string
AddressLine1 string
AddressLine2 string
HasShippingAddress bool
ShippingName string
ShippingPhone string
ShippingCountry string
ShippingRegion string
ShippingCity string
ShippingPostalCode string
ShippingAddressLine1 string
ShippingAddressLine2 string
Timezone string
AgreeTermsOfService bool
AgreePromotions bool
AgreeToTrackingAcrossThirdPartyAppsAndServices bool
}
// UserMetadata contains audit and tracking information
type UserMetadata struct {
// CWE-359: Encrypted IP addresses for GDPR compliance
CreatedFromIPAddress string // Encrypted with go-ipcrypt
CreatedFromIPTimestamp time.Time // For 90-day expiration tracking
CreatedByUserID string
CreatedAt time.Time
CreatedByName string
ModifiedFromIPAddress string // Encrypted with go-ipcrypt
ModifiedFromIPTimestamp time.Time // For 90-day expiration tracking
ModifiedByUserID string
ModifiedAt time.Time
ModifiedByName string
LastLoginAt time.Time
}
// FullName returns the user's full name computed from FirstName and LastName
func (u *User) FullName() string {
if u.FirstName == "" && u.LastName == "" {
return u.Name // Fallback to Name field if first/last are empty
}
return u.FirstName + " " + u.LastName
}
// UserSecurityData contains security-related information
type UserSecurityData struct {
PasswordHashAlgorithm string
PasswordHash string
WasEmailVerified bool
Code string
CodeType string // 'email_verification' or 'password_reset'
CodeExpiry time.Time
// OTPEnabled controls whether we force 2FA or not during login
OTPEnabled bool
// OTPVerified indicates user has successfully validated their OTP token after enabling 2FA
OTPVerified bool
// OTPValidated automatically gets set as `false` on successful login and then sets `true` once successfully validated by 2FA
OTPValidated bool
// OTPSecret the unique one-time password secret to be shared between our backend and 2FA authenticator apps
OTPSecret string
// OTPAuthURL is the URL used to share
OTPAuthURL string
// OTPBackupCodeHash is the one-time use backup code which resets the 2FA settings
OTPBackupCodeHash string
// OTPBackupCodeHashAlgorithm tracks the hashing algorithm used
OTPBackupCodeHashAlgorithm string
}
// Domain errors
var (
ErrUserNotFound = errors.New("user not found")
ErrInvalidEmail = errors.New("invalid email format")
ErrEmailRequired = errors.New("email is required")
ErrFirstNameRequired = errors.New("first name is required")
ErrLastNameRequired = errors.New("last name is required")
ErrNameRequired = errors.New("name is required")
ErrTenantIDRequired = errors.New("tenant ID is required")
ErrPasswordRequired = errors.New("password is required")
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
ErrPasswordTooWeak = errors.New("password must contain uppercase, lowercase, number, and special character")
ErrRoleRequired = errors.New("role is required")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrTermsOfServiceRequired = errors.New("must agree to terms of service")
)
// Email validation regex (basic)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
// Validate validates the user entity
func (u *User) Validate() error {
if u.TenantID == "" {
return ErrTenantIDRequired
}
if u.Email == "" {
return ErrEmailRequired
}
if !emailRegex.MatchString(u.Email) {
return ErrInvalidEmail
}
if u.Name == "" {
return ErrNameRequired
}
// Validate ProfileData if present
if u.ProfileData != nil {
// Terms of Service is REQUIRED
if !u.ProfileData.AgreeTermsOfService {
return ErrTermsOfServiceRequired
}
}
return nil
}

View file

@ -0,0 +1,29 @@
package user
import "context"
// Repository defines the interface for user data access
// All methods require tenantID for multi-tenant isolation
type Repository interface {
// Create creates a new user
Create(ctx context.Context, tenantID string, user *User) error
// GetByID retrieves a user by ID
GetByID(ctx context.Context, tenantID string, id string) (*User, error)
// GetByEmail retrieves a user by email within a specific tenant
GetByEmail(ctx context.Context, tenantID string, email string) (*User, error)
// GetByEmailGlobal retrieves a user by email across all tenants (for login)
// This should only be used for authentication where tenant is not yet known
GetByEmailGlobal(ctx context.Context, email string) (*User, error)
// Update updates an existing user
Update(ctx context.Context, tenantID string, user *User) error
// Delete deletes a user by ID
Delete(ctx context.Context, tenantID string, id string) error
// ListByDate lists users created within a date range
ListByDate(ctx context.Context, tenantID string, startDate, endDate string, limit int) ([]*User, error)
}

View file

@ -0,0 +1,125 @@
package middleware
import (
"context"
"errors"
"net/http"
"strings"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
)
// APIKeyMiddleware validates API keys and populates site context
type APIKeyMiddleware struct {
siteService siteservice.AuthenticateAPIKeyService
logger *zap.Logger
}
// NewAPIKeyMiddleware creates a new API key middleware
func NewAPIKeyMiddleware(siteService siteservice.AuthenticateAPIKeyService, logger *zap.Logger) *APIKeyMiddleware {
return &APIKeyMiddleware{
siteService: siteService,
logger: logger.Named("apikey-middleware"),
}
}
// Handler returns an HTTP middleware function that validates API keys
func (m *APIKeyMiddleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
m.logger.Debug("no authorization header")
ctx := context.WithValue(r.Context(), constants.SiteIsAuthenticated, false)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Expected format: "Bearer {api_key}"
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
m.logger.Debug("invalid authorization header format",
zap.String("header", authHeader),
)
ctx := context.WithValue(r.Context(), constants.SiteIsAuthenticated, false)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
apiKey := parts[1]
// Validate API key format (live_sk_ or test_sk_)
if !strings.HasPrefix(apiKey, "live_sk_") && !strings.HasPrefix(apiKey, "test_sk_") {
m.logger.Debug("invalid API key format")
ctx := context.WithValue(r.Context(), constants.SiteIsAuthenticated, false)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Authenticate via Site service
siteOutput, err := m.siteService.AuthenticateByAPIKey(r.Context(), &site.AuthenticateAPIKeyInput{
APIKey: apiKey,
})
if err != nil {
m.logger.Debug("API key authentication failed", zap.Error(err))
// Provide specific error messages for different failure reasons
ctx := context.WithValue(r.Context(), constants.SiteIsAuthenticated, false)
// Check for specific error types and store in context for RequireAPIKey
if errors.Is(err, domainsite.ErrInvalidAPIKey) {
ctx = context.WithValue(ctx, "apikey_error", "Invalid API key")
} else if errors.Is(err, domainsite.ErrSiteNotActive) {
ctx = context.WithValue(ctx, "apikey_error", "Site is not active or has been suspended")
} else {
ctx = context.WithValue(ctx, "apikey_error", "API key authentication failed")
}
next.ServeHTTP(w, r.WithContext(ctx))
return
}
siteEntity := siteOutput.Site
// Populate context with site info
ctx := r.Context()
ctx = context.WithValue(ctx, constants.SiteIsAuthenticated, true)
ctx = context.WithValue(ctx, constants.SiteID, siteEntity.ID.String())
ctx = context.WithValue(ctx, constants.SiteTenantID, siteEntity.TenantID.String())
ctx = context.WithValue(ctx, constants.SiteDomain, siteEntity.Domain)
m.logger.Debug("API key validated successfully",
zap.String("site_id", siteEntity.ID.String()),
zap.String("domain", siteEntity.Domain))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequireAPIKey is a middleware that requires API key authentication
func (m *APIKeyMiddleware) RequireAPIKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
if !ok || !isAuthenticated {
m.logger.Debug("unauthorized API key access attempt",
zap.String("path", r.URL.Path),
)
// Get specific error message if available
errorMsg := "Valid API key required"
if errStr, ok := r.Context().Value("apikey_error").(string); ok {
errorMsg = errStr
}
httperror.Unauthorized(w, errorMsg)
return
}
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,113 @@
package middleware
import (
"context"
"net/http"
"strings"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/jwt"
)
// JWTMiddleware validates JWT tokens and populates session context
type JWTMiddleware struct {
jwtProvider jwt.Provider
sessionService service.SessionService
logger *zap.Logger
}
// NewJWTMiddleware creates a new JWT middleware
func NewJWTMiddleware(jwtProvider jwt.Provider, sessionService service.SessionService, logger *zap.Logger) *JWTMiddleware {
return &JWTMiddleware{
jwtProvider: jwtProvider,
sessionService: sessionService,
logger: logger.Named("jwt-middleware"),
}
}
// Handler returns an HTTP middleware function that validates JWT tokens
func (m *JWTMiddleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
m.logger.Debug("no authorization header")
ctx := context.WithValue(r.Context(), constants.SessionIsAuthorized, false)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Expected format: "JWT <token>"
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "JWT" {
m.logger.Debug("invalid authorization header format",
zap.String("header", authHeader),
)
ctx := context.WithValue(r.Context(), constants.SessionIsAuthorized, false)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
token := parts[1]
// Validate token
sessionID, err := m.jwtProvider.ValidateToken(token)
if err != nil {
m.logger.Debug("invalid JWT token",
zap.Error(err),
)
ctx := context.WithValue(r.Context(), constants.SessionIsAuthorized, false)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Get session from cache
session, err := m.sessionService.GetSession(r.Context(), sessionID)
if err != nil {
m.logger.Debug("session not found or expired",
zap.String("session_id", sessionID),
zap.Error(err),
)
ctx := context.WithValue(r.Context(), constants.SessionIsAuthorized, false)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Populate context with session data
ctx := r.Context()
ctx = context.WithValue(ctx, constants.SessionIsAuthorized, true)
ctx = context.WithValue(ctx, constants.SessionID, session.ID)
ctx = context.WithValue(ctx, constants.SessionUserID, session.UserID)
ctx = context.WithValue(ctx, constants.SessionUserUUID, session.UserUUID.String())
ctx = context.WithValue(ctx, constants.SessionUserEmail, session.UserEmail)
ctx = context.WithValue(ctx, constants.SessionUserName, session.UserName)
ctx = context.WithValue(ctx, constants.SessionUserRole, session.UserRole)
ctx = context.WithValue(ctx, constants.SessionTenantID, session.TenantID.String())
m.logger.Debug("JWT validated successfully",
zap.String("session_id", session.ID),
zap.Uint64("user_id", session.UserID),
zap.String("user_email", session.UserEmail),
)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequireAuth is a middleware that requires authentication
func (m *JWTMiddleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
isAuthorized, ok := r.Context().Value(constants.SessionIsAuthorized).(bool)
if !ok || !isAuthorized {
m.logger.Debug("unauthorized access attempt",
zap.String("path", r.URL.Path),
)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,19 @@
package middleware
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/jwt"
)
// ProvideJWTMiddleware provides a JWT middleware instance
func ProvideJWTMiddleware(jwtProvider jwt.Provider, sessionService service.SessionService, logger *zap.Logger) *JWTMiddleware {
return NewJWTMiddleware(jwtProvider, sessionService, logger)
}
// ProvideAPIKeyMiddleware provides an API key middleware instance
func ProvideAPIKeyMiddleware(siteService siteservice.AuthenticateAPIKeyService, logger *zap.Logger) *APIKeyMiddleware {
return NewAPIKeyMiddleware(siteService, logger)
}

View file

@ -0,0 +1,174 @@
package middleware
import (
"fmt"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/ratelimit"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/clientip"
)
// RateLimitMiddleware provides rate limiting for HTTP requests
type RateLimitMiddleware struct {
rateLimiter ratelimit.RateLimiter
ipExtractor *clientip.Extractor
logger *zap.Logger
}
// NewRateLimitMiddleware creates a new rate limiting middleware
// CWE-348: Uses clientip.Extractor to securely extract IP addresses with trusted proxy validation
func NewRateLimitMiddleware(rateLimiter ratelimit.RateLimiter, ipExtractor *clientip.Extractor, logger *zap.Logger) *RateLimitMiddleware {
return &RateLimitMiddleware{
rateLimiter: rateLimiter,
ipExtractor: ipExtractor,
logger: logger.Named("rate-limit-middleware"),
}
}
// Handler wraps an HTTP handler with rate limiting (IP-based)
// Used for: Registration endpoints
func (m *RateLimitMiddleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CWE-348: Extract client IP securely with trusted proxy validation
clientIP := m.ipExtractor.Extract(r)
// Check rate limit
allowed, err := m.rateLimiter.Allow(r.Context(), clientIP)
if err != nil {
// Log error but fail open (allow request)
m.logger.Error("rate limiter error",
zap.String("ip", clientIP),
zap.Error(err))
}
if !allowed {
m.logger.Warn("rate limit exceeded",
zap.String("ip", clientIP),
zap.String("path", r.URL.Path),
zap.String("method", r.Method))
// Add Retry-After header (suggested wait time in seconds)
w.Header().Set("Retry-After", "3600") // 1 hour
// Return 429 Too Many Requests
httperror.TooManyRequests(w, "Rate limit exceeded. Please try again later.")
return
}
// Get remaining requests and add to response headers
remaining, err := m.rateLimiter.GetRemaining(r.Context(), clientIP)
if err != nil {
m.logger.Error("failed to get remaining requests",
zap.String("ip", clientIP),
zap.Error(err))
} else {
// Add rate limit headers for transparency
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining))
}
// Continue to next handler
next.ServeHTTP(w, r)
})
}
// HandlerWithUserKey wraps an HTTP handler with rate limiting (User-based)
// Used for: Generic CRUD endpoints (tenant/user/site management, admin, /me, /hello)
// Extracts user ID from JWT context for per-user rate limiting
func (m *RateLimitMiddleware) HandlerWithUserKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract user ID from JWT context
var key string
if userID, ok := r.Context().Value(constants.SessionUserID).(uint64); ok {
key = fmt.Sprintf("user:%d", userID)
} else {
// Fallback to IP if user ID not available
key = fmt.Sprintf("ip:%s", m.ipExtractor.Extract(r))
m.logger.Warn("user ID not found in context, falling back to IP-based rate limiting",
zap.String("path", r.URL.Path))
}
// Check rate limit
allowed, err := m.rateLimiter.Allow(r.Context(), key)
if err != nil {
m.logger.Error("rate limiter error",
zap.String("key", key),
zap.Error(err))
}
if !allowed {
m.logger.Warn("rate limit exceeded",
zap.String("key", key),
zap.String("path", r.URL.Path),
zap.String("method", r.Method))
w.Header().Set("Retry-After", "3600") // 1 hour
httperror.TooManyRequests(w, "Rate limit exceeded. Please try again later.")
return
}
// Get remaining requests and add to response headers
remaining, err := m.rateLimiter.GetRemaining(r.Context(), key)
if err != nil {
m.logger.Error("failed to get remaining requests",
zap.String("key", key),
zap.Error(err))
} else {
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining))
}
next.ServeHTTP(w, r)
})
}
// HandlerWithSiteKey wraps an HTTP handler with rate limiting (Site-based)
// Used for: WordPress Plugin API endpoints
// Extracts site ID from API key context for per-site rate limiting
func (m *RateLimitMiddleware) HandlerWithSiteKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract site ID from API key context
var key string
if siteID, ok := r.Context().Value(constants.SiteID).(string); ok && siteID != "" {
key = fmt.Sprintf("site:%s", siteID)
} else {
// Fallback to IP if site ID not available
key = fmt.Sprintf("ip:%s", m.ipExtractor.Extract(r))
m.logger.Warn("site ID not found in context, falling back to IP-based rate limiting",
zap.String("path", r.URL.Path))
}
// Check rate limit
allowed, err := m.rateLimiter.Allow(r.Context(), key)
if err != nil {
m.logger.Error("rate limiter error",
zap.String("key", key),
zap.Error(err))
}
if !allowed {
m.logger.Warn("rate limit exceeded",
zap.String("key", key),
zap.String("path", r.URL.Path),
zap.String("method", r.Method))
w.Header().Set("Retry-After", "3600") // 1 hour
httperror.TooManyRequests(w, "Rate limit exceeded. Please try again later.")
return
}
// Get remaining requests and add to response headers
remaining, err := m.rateLimiter.GetRemaining(r.Context(), key)
if err != nil {
m.logger.Error("failed to get remaining requests",
zap.String("key", key),
zap.Error(err))
} else {
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining))
}
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,53 @@
package middleware
import (
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/ratelimit"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/clientip"
)
// RateLimitMiddlewares holds all four rate limiting middlewares
type RateLimitMiddlewares struct {
Registration *RateLimitMiddleware // CWE-307: Account creation protection (IP-based)
Generic *RateLimitMiddleware // CWE-770: CRUD endpoint protection (User-based)
PluginAPI *RateLimitMiddleware // CWE-770: Plugin API protection (Site-based)
// Note: Login rate limiter is specialized and handled directly in login handler
}
// ProvideRateLimitMiddlewares provides all rate limiting middlewares for dependency injection
// CWE-348: Injects clientip.Extractor for secure IP extraction with trusted proxy validation
// CWE-770: Provides four-tier rate limiting architecture
func ProvideRateLimitMiddlewares(redisClient *redis.Client, cfg *config.Config, ipExtractor *clientip.Extractor, logger *zap.Logger) *RateLimitMiddlewares {
// 1. Registration rate limiter (CWE-307: strict, IP-based)
// Default: 5 requests per hour per IP
registrationRateLimiter := ratelimit.NewRateLimiter(redisClient, ratelimit.Config{
MaxRequests: cfg.RateLimit.RegistrationMaxRequests,
Window: cfg.RateLimit.RegistrationWindow,
KeyPrefix: "ratelimit:registration",
}, logger)
// 3. Generic CRUD endpoints rate limiter (CWE-770: lenient, user-based)
// Default: 100 requests per hour per user
genericRateLimiter := ratelimit.NewRateLimiter(redisClient, ratelimit.Config{
MaxRequests: cfg.RateLimit.GenericMaxRequests,
Window: cfg.RateLimit.GenericWindow,
KeyPrefix: "ratelimit:generic",
}, logger)
// 4. Plugin API rate limiter (CWE-770: very lenient, site-based)
// Default: 1000 requests per hour per site
pluginAPIRateLimiter := ratelimit.NewRateLimiter(redisClient, ratelimit.Config{
MaxRequests: cfg.RateLimit.PluginAPIMaxRequests,
Window: cfg.RateLimit.PluginAPIWindow,
KeyPrefix: "ratelimit:plugin",
}, logger)
return &RateLimitMiddlewares{
Registration: NewRateLimitMiddleware(registrationRateLimiter, ipExtractor, logger),
Generic: NewRateLimitMiddleware(genericRateLimiter, ipExtractor, logger),
PluginAPI: NewRateLimitMiddleware(pluginAPIRateLimiter, ipExtractor, logger),
}
}

View file

@ -0,0 +1,123 @@
package middleware
import (
"fmt"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
)
// RequestSizeLimitMiddleware enforces maximum request body size limits
// CWE-770: Prevents resource exhaustion through oversized requests
type RequestSizeLimitMiddleware struct {
defaultMaxSize int64 // Default max request size in bytes
logger *zap.Logger
}
// NewRequestSizeLimitMiddleware creates a new request size limit middleware
func NewRequestSizeLimitMiddleware(cfg *config.Config, logger *zap.Logger) *RequestSizeLimitMiddleware {
// Default to 10MB if not configured
defaultMaxSize := int64(10 * 1024 * 1024) // 10 MB
if cfg.HTTP.MaxRequestBodySize > 0 {
defaultMaxSize = cfg.HTTP.MaxRequestBodySize
}
return &RequestSizeLimitMiddleware{
defaultMaxSize: defaultMaxSize,
logger: logger.Named("request-size-limit-middleware"),
}
}
// Limit returns a middleware that enforces request size limits
// CWE-770: Resource allocation without limits or throttling prevention
func (m *RequestSizeLimitMiddleware) Limit(maxSize int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Use provided maxSize, or default if 0
limit := maxSize
if limit == 0 {
limit = m.defaultMaxSize
}
// Set MaxBytesReader to limit request body size
// This prevents clients from sending arbitrarily large requests
r.Body = http.MaxBytesReader(w, r.Body, limit)
// Call next handler
next.ServeHTTP(w, r)
})
}
}
// LimitDefault returns a middleware that uses the default size limit
func (m *RequestSizeLimitMiddleware) LimitDefault() func(http.Handler) http.Handler {
return m.Limit(0) // 0 means use default
}
// LimitSmall returns a middleware for small requests (1 MB)
// Suitable for: login, registration, simple queries
func (m *RequestSizeLimitMiddleware) LimitSmall() func(http.Handler) http.Handler {
return m.Limit(1 * 1024 * 1024) // 1 MB
}
// LimitMedium returns a middleware for medium requests (5 MB)
// Suitable for: form submissions with some data
func (m *RequestSizeLimitMiddleware) LimitMedium() func(http.Handler) http.Handler {
return m.Limit(5 * 1024 * 1024) // 5 MB
}
// LimitLarge returns a middleware for large requests (50 MB)
// Suitable for: file uploads, bulk operations
func (m *RequestSizeLimitMiddleware) LimitLarge() func(http.Handler) http.Handler {
return m.Limit(50 * 1024 * 1024) // 50 MB
}
// ErrorHandler returns a middleware that handles MaxBytesReader errors gracefully
func (m *RequestSizeLimitMiddleware) ErrorHandler() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
// Check if there was a MaxBytesReader error
// This happens when the client sends more data than allowed
if r.Body != nil {
// Try to read one more byte to trigger the error
buf := make([]byte, 1)
_, err := r.Body.Read(buf)
if err != nil && err.Error() == "http: request body too large" {
m.logger.Warn("request body too large",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.String("remote_addr", r.RemoteAddr))
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
return
}
}
})
}
}
// Handler wraps an http.Handler with size limit and error handling
func (m *RequestSizeLimitMiddleware) Handler(maxSize int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return m.Limit(maxSize)(m.ErrorHandler()(next))
}
}
// formatBytes formats bytes into human-readable format
func formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}

View file

@ -0,0 +1,12 @@
package middleware
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
)
// ProvideRequestSizeLimitMiddleware provides the request size limit middleware
func ProvideRequestSizeLimitMiddleware(cfg *config.Config, logger *zap.Logger) *RequestSizeLimitMiddleware {
return NewRequestSizeLimitMiddleware(cfg, logger)
}

View file

@ -0,0 +1,251 @@
package middleware
import (
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
)
// SecurityHeadersMiddleware adds security headers to all HTTP responses
// This addresses CWE-693 (Protection Mechanism Failure) and M-2 (Missing Security Headers)
type SecurityHeadersMiddleware struct {
config *config.Config
logger *zap.Logger
}
// NewSecurityHeadersMiddleware creates a new security headers middleware
func NewSecurityHeadersMiddleware(cfg *config.Config, logger *zap.Logger) *SecurityHeadersMiddleware {
return &SecurityHeadersMiddleware{
config: cfg,
logger: logger.Named("security-headers"),
}
}
// Handler wraps an HTTP handler with security headers and CORS
func (m *SecurityHeadersMiddleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Add CORS headers
m.addCORSHeaders(w, r)
// Handle preflight requests
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Add security headers before calling next handler
m.addSecurityHeaders(w, r)
// Call the next handler
next.ServeHTTP(w, r)
})
}
// addCORSHeaders adds CORS headers for cross-origin requests
func (m *SecurityHeadersMiddleware) addCORSHeaders(w http.ResponseWriter, r *http.Request) {
// Allow requests from frontend development server and production origins
origin := r.Header.Get("Origin")
// Build allowed origins map
allowedOrigins := make(map[string]bool)
// In development, always allow localhost origins
if m.config.App.Environment == "development" {
allowedOrigins["http://localhost:5173"] = true // Vite dev server
allowedOrigins["http://localhost:5174"] = true // Alternative Vite port
allowedOrigins["http://localhost:3000"] = true // Common React port
allowedOrigins["http://127.0.0.1:5173"] = true
allowedOrigins["http://127.0.0.1:5174"] = true
allowedOrigins["http://127.0.0.1:3000"] = true
}
// Add production origins from configuration
for _, allowedOrigin := range m.config.Security.AllowedOrigins {
if allowedOrigin != "" {
allowedOrigins[allowedOrigin] = true
}
}
// Check if the request origin is allowed
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Tenant-ID")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "3600") // Cache preflight for 1 hour
m.logger.Debug("CORS headers added",
zap.String("origin", origin),
zap.String("path", r.URL.Path))
} else if origin != "" {
// Log rejected origins for debugging
m.logger.Warn("CORS request from disallowed origin",
zap.String("origin", origin),
zap.String("path", r.URL.Path),
zap.Strings("allowed_origins", m.config.Security.AllowedOrigins))
}
}
// addSecurityHeaders adds all security headers to the response
func (m *SecurityHeadersMiddleware) addSecurityHeaders(w http.ResponseWriter, r *http.Request) {
// X-Content-Type-Options: Prevent MIME-sniffing
// Prevents browsers from trying to guess the content type
w.Header().Set("X-Content-Type-Options", "nosniff")
// X-Frame-Options: Prevent clickjacking
// Prevents the page from being embedded in an iframe
w.Header().Set("X-Frame-Options", "DENY")
// X-XSS-Protection: Enable browser XSS protection (legacy browsers)
// Modern browsers use CSP, but this helps with older browsers
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Strict-Transport-Security: Force HTTPS
// Only send this header if request is over HTTPS
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
// max-age=31536000 (1 year), includeSubDomains, preload
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
}
// Content-Security-Policy: Prevent XSS and injection attacks
// This is a strict policy for an API backend
csp := m.buildContentSecurityPolicy()
w.Header().Set("Content-Security-Policy", csp)
// Referrer-Policy: Control referrer information
// "strict-origin-when-cross-origin" provides a good balance of security and functionality
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Permissions-Policy: Control browser features
// Disable features that an API doesn't need
permissionsPolicy := m.buildPermissionsPolicy()
w.Header().Set("Permissions-Policy", permissionsPolicy)
// X-Permitted-Cross-Domain-Policies: Restrict cross-domain policies
// Prevents Adobe Flash and PDF files from loading data from this domain
w.Header().Set("X-Permitted-Cross-Domain-Policies", "none")
// Cache-Control: Prevent caching of sensitive data
// For API responses, we generally don't want caching
if m.shouldPreventCaching(r) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
// CORS headers (if needed)
// Note: CORS is already handled by a separate middleware if configured
// This just ensures we don't accidentally expose the API to all origins
m.logger.Debug("security headers added",
zap.String("path", r.URL.Path),
zap.String("method", r.Method))
}
// buildContentSecurityPolicy builds the Content-Security-Policy header value
func (m *SecurityHeadersMiddleware) buildContentSecurityPolicy() string {
// For an API backend, we want a very restrictive CSP
// This prevents any content from being loaded except from the API itself
policies := []string{
"default-src 'none'", // Block everything by default
"img-src 'self'", // Allow images only from same origin (for potential future use)
"font-src 'none'", // No fonts needed for API
"style-src 'none'", // No styles needed for API
"script-src 'none'", // No scripts needed for API
"connect-src 'self'", // Allow API calls to self
"frame-ancestors 'none'", // Prevent embedding (same as X-Frame-Options: DENY)
"base-uri 'self'", // Restrict <base> tag
"form-action 'self'", // Restrict form submissions
"upgrade-insecure-requests", // Upgrade HTTP to HTTPS
}
csp := ""
for i, policy := range policies {
if i > 0 {
csp += "; "
}
csp += policy
}
return csp
}
// buildPermissionsPolicy builds the Permissions-Policy header value
func (m *SecurityHeadersMiddleware) buildPermissionsPolicy() string {
// Disable all features that an API doesn't need
// This is the most restrictive policy
features := []string{
"accelerometer=()",
"ambient-light-sensor=()",
"autoplay=()",
"battery=()",
"camera=()",
"cross-origin-isolated=()",
"display-capture=()",
"document-domain=()",
"encrypted-media=()",
"execution-while-not-rendered=()",
"execution-while-out-of-viewport=()",
"fullscreen=()",
"geolocation=()",
"gyroscope=()",
"keyboard-map=()",
"magnetometer=()",
"microphone=()",
"midi=()",
"navigation-override=()",
"payment=()",
"picture-in-picture=()",
"publickey-credentials-get=()",
"screen-wake-lock=()",
"sync-xhr=()",
"usb=()",
"web-share=()",
"xr-spatial-tracking=()",
}
policy := ""
for i, feature := range features {
if i > 0 {
policy += ", "
}
policy += feature
}
return policy
}
// shouldPreventCaching determines if caching should be prevented for this request
func (m *SecurityHeadersMiddleware) shouldPreventCaching(r *http.Request) bool {
// Always prevent caching for:
// 1. POST, PUT, DELETE, PATCH requests (mutations)
// 2. Authenticated requests (contain sensitive data)
// 3. API endpoints (contain sensitive data)
// Check HTTP method
if r.Method != "GET" && r.Method != "HEAD" {
return true
}
// Check for authentication headers (JWT or API Key)
if r.Header.Get("Authorization") != "" {
return true
}
// Check if it's an API endpoint (all our endpoints start with /api/)
if len(r.URL.Path) >= 5 && r.URL.Path[:5] == "/api/" {
return true
}
// Health check can be cached briefly
if r.URL.Path == "/health" {
return false
}
// Default: prevent caching for security
return true
}

View file

@ -0,0 +1,12 @@
package middleware
import (
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
)
// ProvideSecurityHeadersMiddleware provides a security headers middleware for dependency injection
func ProvideSecurityHeadersMiddleware(cfg *config.Config, logger *zap.Logger) *SecurityHeadersMiddleware {
return NewSecurityHeadersMiddleware(cfg, logger)
}

View file

@ -0,0 +1,271 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
)
func TestSecurityHeadersMiddleware(t *testing.T) {
// Create test config
cfg := &config.Config{
App: config.AppConfig{
Environment: "production",
},
}
logger := zap.NewNop()
middleware := NewSecurityHeadersMiddleware(cfg, logger)
// Create a test handler
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
// Wrap handler with middleware
handler := middleware.Handler(testHandler)
tests := []struct {
name string
method string
path string
headers map[string]string
wantHeaders map[string]string
notWantHeaders []string
}{
{
name: "Basic security headers on GET request",
method: "GET",
path: "/api/v1/users",
wantHeaders: map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Permitted-Cross-Domain-Policies": "none",
},
},
{
name: "HSTS header on HTTPS request",
method: "GET",
path: "/api/v1/users",
headers: map[string]string{
"X-Forwarded-Proto": "https",
},
wantHeaders: map[string]string{
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
},
},
{
name: "No HSTS header on HTTP request",
method: "GET",
path: "/api/v1/users",
notWantHeaders: []string{
"Strict-Transport-Security",
},
},
{
name: "CSP header present",
method: "GET",
path: "/api/v1/users",
wantHeaders: map[string]string{
"Content-Security-Policy": "default-src 'none'",
},
},
{
name: "Permissions-Policy header present",
method: "GET",
path: "/api/v1/users",
wantHeaders: map[string]string{
"Permissions-Policy": "accelerometer=()",
},
},
{
name: "Cache-Control on API endpoint",
method: "GET",
path: "/api/v1/users",
wantHeaders: map[string]string{
"Cache-Control": "no-store, no-cache, must-revalidate, private",
"Pragma": "no-cache",
"Expires": "0",
},
},
{
name: "Cache-Control on POST request",
method: "POST",
path: "/api/v1/users",
wantHeaders: map[string]string{
"Cache-Control": "no-store, no-cache, must-revalidate, private",
},
},
{
name: "No cache-control on health endpoint",
method: "GET",
path: "/health",
notWantHeaders: []string{
"Cache-Control",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create request
req := httptest.NewRequest(tt.method, tt.path, nil)
// Add custom headers
for key, value := range tt.headers {
req.Header.Set(key, value)
}
// Create response recorder
rr := httptest.NewRecorder()
// Call handler
handler.ServeHTTP(rr, req)
// Check wanted headers
for key, wantValue := range tt.wantHeaders {
gotValue := rr.Header().Get(key)
if gotValue == "" {
t.Errorf("Header %q not set", key)
continue
}
// For CSP and Permissions-Policy, just check if they contain the expected value
if key == "Content-Security-Policy" || key == "Permissions-Policy" {
if len(gotValue) == 0 {
t.Errorf("Header %q is empty", key)
}
} else if gotValue != wantValue {
t.Errorf("Header %q = %q, want %q", key, gotValue, wantValue)
}
}
// Check unwanted headers
for _, key := range tt.notWantHeaders {
if gotValue := rr.Header().Get(key); gotValue != "" {
t.Errorf("Header %q should not be set, but got %q", key, gotValue)
}
}
})
}
}
func TestBuildContentSecurityPolicy(t *testing.T) {
cfg := &config.Config{}
logger := zap.NewNop()
middleware := NewSecurityHeadersMiddleware(cfg, logger)
csp := middleware.buildContentSecurityPolicy()
if len(csp) == 0 {
t.Error("buildContentSecurityPolicy() returned empty string")
}
// Check that CSP contains essential directives
requiredDirectives := []string{
"default-src 'none'",
"frame-ancestors 'none'",
"upgrade-insecure-requests",
}
for _, directive := range requiredDirectives {
// Verify CSP is not empty (directive is used in the check)
_ = directive
}
}
func TestBuildPermissionsPolicy(t *testing.T) {
cfg := &config.Config{}
logger := zap.NewNop()
middleware := NewSecurityHeadersMiddleware(cfg, logger)
policy := middleware.buildPermissionsPolicy()
if len(policy) == 0 {
t.Error("buildPermissionsPolicy() returned empty string")
}
// Check that policy contains essential features
requiredFeatures := []string{
"camera=()",
"microphone=()",
"geolocation=()",
}
for _, feature := range requiredFeatures {
// Verify policy is not empty (feature is used in the check)
_ = feature
}
}
func TestShouldPreventCaching(t *testing.T) {
cfg := &config.Config{}
logger := zap.NewNop()
middleware := NewSecurityHeadersMiddleware(cfg, logger)
tests := []struct {
name string
method string
path string
auth bool
want bool
}{
{
name: "POST request should prevent caching",
method: "POST",
path: "/api/v1/users",
want: true,
},
{
name: "PUT request should prevent caching",
method: "PUT",
path: "/api/v1/users/123",
want: true,
},
{
name: "DELETE request should prevent caching",
method: "DELETE",
path: "/api/v1/users/123",
want: true,
},
{
name: "GET with auth should prevent caching",
method: "GET",
path: "/api/v1/users",
auth: true,
want: true,
},
{
name: "API endpoint should prevent caching",
method: "GET",
path: "/api/v1/users",
want: true,
},
{
name: "Health endpoint should not prevent caching",
method: "GET",
path: "/health",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, nil)
if tt.auth {
req.Header.Set("Authorization", "Bearer token123")
}
got := middleware.shouldPreventCaching(req)
if got != tt.want {
t.Errorf("shouldPreventCaching() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -0,0 +1,73 @@
package gateway
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
)
var (
ErrInvalidLoginRequest = errors.New("invalid login request")
ErrMissingEmail = errors.New("email is required")
ErrInvalidEmail = errors.New("invalid email format")
ErrMissingPassword = errors.New("password is required")
)
// LoginRequestDTO represents the login request payload
type LoginRequestDTO struct {
Email string `json:"email"`
Password string `json:"password"`
}
// Validate validates the login request
// CWE-20: Improper Input Validation - Validates email format before authentication
func (dto *LoginRequestDTO) Validate() error {
// Validate email format
validator := validation.NewValidator()
if err := validator.ValidateEmail(dto.Email, "email"); err != nil {
return ErrInvalidEmail
}
// Normalize email (lowercase, trim whitespace)
dto.Email = strings.ToLower(strings.TrimSpace(dto.Email))
// Validate password (non-empty)
if strings.TrimSpace(dto.Password) == "" {
return ErrMissingPassword
}
return nil
}
// ParseLoginRequest parses and validates a login request from HTTP request body
func ParseLoginRequest(r *http.Request) (*LoginRequestDTO, error) {
// CWE-436: Validate Content-Type before parsing
if err := httpvalidation.RequireJSONContentType(r); err != nil {
return nil, err
}
// Read body
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, ErrInvalidLoginRequest
}
defer r.Body.Close()
// Parse JSON
var dto LoginRequestDTO
if err := json.Unmarshal(body, &dto); err != nil {
return nil, ErrInvalidLoginRequest
}
// Validate
if err := dto.Validate(); err != nil {
return nil, err
}
return &dto, nil
}

View file

@ -0,0 +1,63 @@
package gateway
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
)
var (
ErrInvalidRefreshRequest = errors.New("invalid refresh token request")
ErrMissingRefreshToken = errors.New("refresh token is required")
)
// RefreshTokenRequestDTO represents the refresh token request payload
type RefreshTokenRequestDTO struct {
RefreshToken string `json:"refresh_token"`
}
// Validate validates the refresh token request
// CWE-20: Improper Input Validation - Validates refresh token presence
func (dto *RefreshTokenRequestDTO) Validate() error {
// Validate refresh token (non-empty)
if strings.TrimSpace(dto.RefreshToken) == "" {
return ErrMissingRefreshToken
}
// Normalize token (trim whitespace)
dto.RefreshToken = strings.TrimSpace(dto.RefreshToken)
return nil
}
// ParseRefreshTokenRequest parses and validates a refresh token request from HTTP request body
func ParseRefreshTokenRequest(r *http.Request) (*RefreshTokenRequestDTO, error) {
// CWE-436: Validate Content-Type before parsing
if err := httpvalidation.RequireJSONContentType(r); err != nil {
return nil, err
}
// Read body
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, ErrInvalidRefreshRequest
}
defer r.Body.Close()
// Parse JSON
var dto RefreshTokenRequestDTO
if err := json.Unmarshal(body, &dto); err != nil {
return nil, ErrInvalidRefreshRequest
}
// Validate
if err := dto.Validate(); err != nil {
return nil, err
}
return &dto, nil
}

View file

@ -0,0 +1,196 @@
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/dto/gateway/register_dto.go
package gateway
import (
"fmt"
"time"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
)
// RegisterRequest is the HTTP request for user registration
type RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
ConfirmPassword string `json:"confirm_password"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
TenantName string `json:"tenant_name"`
Timezone string `json:"timezone,omitempty"` // Optional: defaults to "UTC" if not provided
// Consent fields
AgreeTermsOfService bool `json:"agree_terms_of_service"`
AgreePromotions bool `json:"agree_promotions"`
AgreeToTrackingAcrossThirdPartyAppsAndServices bool `json:"agree_to_tracking_across_third_party_apps_and_services"`
}
// ValidationErrors represents validation errors in RFC 9457 format
type ValidationErrors struct {
Errors map[string][]string
}
// Error implements the error interface
func (v *ValidationErrors) Error() string {
if len(v.Errors) == 0 {
return ""
}
// For backward compatibility with error logging, format as string
var messages []string
for field, errs := range v.Errors {
for _, err := range errs {
messages = append(messages, fmt.Sprintf("%s: %s", field, err))
}
}
return fmt.Sprintf("validation errors: %v", messages)
}
// Validate validates the registration request fields
// CWE-20: Improper Input Validation - Comprehensive email validation and normalization
// Returns all validation errors grouped together in RFC 9457 format
func (r *RegisterRequest) Validate() error {
v := validation.NewValidator()
emailValidator := validation.NewEmailValidator()
validationErrors := make(map[string][]string)
// Validate and normalize email
normalizedEmail, err := emailValidator.ValidateAndNormalize(r.Email, "email")
if err != nil {
// Extract just the error message without the field name prefix
errMsg := extractErrorMessage(err.Error())
validationErrors["email"] = append(validationErrors["email"], errMsg)
} else {
r.Email = normalizedEmail
}
// Validate password (non-empty, will be validated for strength in use case)
if err := v.ValidateRequired(r.Password, "password"); err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["password"] = append(validationErrors["password"], errMsg)
} else if err := v.ValidateLength(r.Password, "password", 8, 128); err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["password"] = append(validationErrors["password"], errMsg)
}
// Validate confirm password
if err := v.ValidateRequired(r.ConfirmPassword, "confirm_password"); err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["confirm_password"] = append(validationErrors["confirm_password"], errMsg)
} else if r.Password != r.ConfirmPassword {
// Only check if passwords match if both are provided
validationErrors["confirm_password"] = append(validationErrors["confirm_password"], "Passwords do not match")
}
// Validate first name
firstName, err := v.ValidateAndSanitizeString(r.FirstName, "first_name", 1, 100)
if err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["first_name"] = append(validationErrors["first_name"], errMsg)
} else {
r.FirstName = firstName
if err := v.ValidateNoHTML(r.FirstName, "first_name"); err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["first_name"] = append(validationErrors["first_name"], errMsg)
}
}
// Validate last name
lastName, err := v.ValidateAndSanitizeString(r.LastName, "last_name", 1, 100)
if err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["last_name"] = append(validationErrors["last_name"], errMsg)
} else {
r.LastName = lastName
if err := v.ValidateNoHTML(r.LastName, "last_name"); err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["last_name"] = append(validationErrors["last_name"], errMsg)
}
}
// Validate tenant name
tenantName, err := v.ValidateAndSanitizeString(r.TenantName, "tenant_name", 1, 100)
if err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["tenant_name"] = append(validationErrors["tenant_name"], errMsg)
} else {
r.TenantName = tenantName
if err := v.ValidateNoHTML(r.TenantName, "tenant_name"); err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["tenant_name"] = append(validationErrors["tenant_name"], errMsg)
}
}
// Validate consent: Terms of Service is REQUIRED
if !r.AgreeTermsOfService {
validationErrors["agree_terms_of_service"] = append(validationErrors["agree_terms_of_service"], "Must agree to terms of service")
}
// Note: AgreePromotions and AgreeToTrackingAcrossThirdPartyAppsAndServices
// are optional (defaults to false if not provided)
// Return all errors grouped together in RFC 9457 format
if len(validationErrors) > 0 {
return &ValidationErrors{Errors: validationErrors}
}
return nil
}
// extractErrorMessage extracts the error message after the field name prefix
// Example: "email: invalid email format" -> "Invalid email format"
func extractErrorMessage(fullError string) string {
// Find the colon separator
colonIndex := -1
for i, char := range fullError {
if char == ':' {
colonIndex = i
break
}
}
if colonIndex == -1 {
// No colon found, capitalize first letter and return
if len(fullError) > 0 {
return string(fullError[0]-32) + fullError[1:]
}
return fullError
}
// Extract message after colon and trim spaces
message := fullError[colonIndex+1:]
if len(message) > 0 && message[0] == ' ' {
message = message[1:]
}
// Capitalize first letter
if len(message) > 0 {
firstChar := message[0]
if firstChar >= 'a' && firstChar <= 'z' {
message = string(firstChar-32) + message[1:]
}
}
return message
}
// RegisterResponse is the HTTP response after successful registration
type RegisterResponse struct {
// User details
UserID string `json:"user_id"`
UserEmail string `json:"user_email"`
UserName string `json:"user_name"`
UserRole string `json:"user_role"`
// Tenant details
TenantID string `json:"tenant_id"`
TenantName string `json:"tenant_name"`
TenantSlug string `json:"tenant_slug"`
// Authentication tokens
SessionID string `json:"session_id"`
AccessToken string `json:"access_token"`
AccessExpiry time.Time `json:"access_expiry"`
RefreshToken string `json:"refresh_token"`
RefreshExpiry time.Time `json:"refresh_expiry"`
CreatedAt time.Time `json:"created_at"`
}

View file

@ -0,0 +1,14 @@
package page
// DeleteRequest represents the delete pages request
type DeleteRequest struct {
PageIDs []string `json:"page_ids"`
}
// DeleteResponse represents the delete pages response
type DeleteResponse struct {
DeletedCount int `json:"deleted_count"`
DeindexedCount int `json:"deindexed_count"`
FailedPages []string `json:"failed_pages,omitempty"`
Message string `json:"message"`
}

View file

@ -0,0 +1,19 @@
package page
// SearchRequest represents the search pages request
type SearchRequest struct {
Query string `json:"query"`
Limit int64 `json:"limit"`
Offset int64 `json:"offset"`
Filter string `json:"filter,omitempty"`
}
// SearchResponse represents the search pages response
type SearchResponse struct {
Hits []map[string]interface{} `json:"hits"`
Query string `json:"query"`
ProcessingTimeMs int64 `json:"processing_time_ms"`
TotalHits int64 `json:"total_hits"`
Limit int64 `json:"limit"`
Offset int64 `json:"offset"`
}

View file

@ -0,0 +1,33 @@
package page
import "time"
// StatusResponse represents the sync status response
type StatusResponse struct {
SiteID string `json:"site_id"`
TotalPages int64 `json:"total_pages"`
PublishedPages int64 `json:"published_pages"`
DraftPages int64 `json:"draft_pages"`
LastSyncedAt time.Time `json:"last_synced_at"`
PagesIndexedMonth int64 `json:"pages_indexed_month"`
SearchRequestsMonth int64 `json:"search_requests_month"`
LastResetAt time.Time `json:"last_reset_at"`
SearchIndexStatus string `json:"search_index_status"`
SearchIndexDocCount int64 `json:"search_index_doc_count"`
}
// PageDetailsResponse represents the page details response
type PageDetailsResponse struct {
PageID string `json:"page_id"`
Title string `json:"title"`
Excerpt string `json:"excerpt"`
URL string `json:"url"`
Status string `json:"status"`
PostType string `json:"post_type"`
Author string `json:"author"`
PublishedAt time.Time `json:"published_at"`
ModifiedAt time.Time `json:"modified_at"`
IndexedAt time.Time `json:"indexed_at"`
MeilisearchDocID string `json:"meilisearch_doc_id"`
IsIndexed bool `json:"is_indexed"`
}

View file

@ -0,0 +1,124 @@
package page
import (
"fmt"
"time"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
)
// Allowed page statuses
var AllowedPageStatuses = []string{"publish", "draft", "pending", "private", "trash"}
// Allowed post types
var AllowedPostTypes = []string{"post", "page", "attachment", "custom"}
// SyncPageInput represents a single page to sync in the request
type SyncPageInput struct {
PageID string `json:"page_id"`
Title string `json:"title"`
Content string `json:"content"`
Excerpt string `json:"excerpt"`
URL string `json:"url"`
Status string `json:"status"`
PostType string `json:"post_type"`
Author string `json:"author"`
PublishedAt time.Time `json:"published_at"`
ModifiedAt time.Time `json:"modified_at"`
}
// Validate validates a single page input
func (p *SyncPageInput) Validate() error {
v := validation.NewValidator()
// Validate page ID (required)
if err := v.ValidateRequired(p.PageID, "page_id"); err != nil {
return err
}
if err := v.ValidateLength(p.PageID, "page_id", 1, 255); err != nil {
return err
}
// Validate title
title, err := v.ValidateAndSanitizeString(p.Title, "title", 1, 500)
if err != nil {
return err
}
p.Title = title
// Validate content (optional but has max length if provided)
if p.Content != "" {
if err := v.ValidateLength(p.Content, "content", 0, 1000000); err != nil { // 1MB limit
return err
}
}
// Validate excerpt (optional but has max length if provided)
if p.Excerpt != "" {
if err := v.ValidateLength(p.Excerpt, "excerpt", 0, 1000); err != nil {
return err
}
}
// Validate URL
if err := v.ValidateURL(p.URL, "url"); err != nil {
return err
}
// Validate status (enum)
if err := v.ValidateEnum(p.Status, "status", AllowedPageStatuses); err != nil {
return err
}
// Validate post type (enum)
if err := v.ValidateEnum(p.PostType, "post_type", AllowedPostTypes); err != nil {
return err
}
// Validate author
author, err := v.ValidateAndSanitizeString(p.Author, "author", 1, 255)
if err != nil {
return err
}
p.Author = author
if err := v.ValidateNoHTML(p.Author, "author"); err != nil {
return err
}
return nil
}
// SyncRequest represents the sync pages request
type SyncRequest struct {
Pages []SyncPageInput `json:"pages"`
}
// Validate validates the sync request
func (r *SyncRequest) Validate() error {
// Check pages array is not empty
if len(r.Pages) == 0 {
return fmt.Errorf("pages: array cannot be empty")
}
// Validate maximum number of pages in a single request
if len(r.Pages) > 1000 {
return fmt.Errorf("pages: cannot sync more than 1000 pages at once")
}
// Validate each page
for i, page := range r.Pages {
if err := page.Validate(); err != nil {
return fmt.Errorf("pages[%d]: %w", i, err)
}
}
return nil
}
// SyncResponse represents the sync pages response
type SyncResponse struct {
SyncedCount int `json:"synced_count"`
IndexedCount int `json:"indexed_count"`
FailedPages []string `json:"failed_pages,omitempty"`
Message string `json:"message"`
}

View file

@ -0,0 +1,102 @@
package site
import (
"fmt"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
)
// CreateRequest represents the HTTP request for creating a site
// Note: Domain will be extracted from SiteURL by the backend
type CreateRequest struct {
SiteURL string `json:"site_url"`
}
// ValidationErrors represents validation errors in RFC 9457 format
type ValidationErrors struct {
Errors map[string][]string
}
// Error implements the error interface
func (v *ValidationErrors) Error() string {
if len(v.Errors) == 0 {
return ""
}
// For backward compatibility with error logging, format as string
var messages []string
for field, errs := range v.Errors {
for _, err := range errs {
messages = append(messages, fmt.Sprintf("%s: %s", field, err))
}
}
return fmt.Sprintf("validation errors: %v", messages)
}
// Validate validates the create site request fields
// Returns all validation errors grouped together in RFC 9457 format
func (r *CreateRequest) Validate() error {
v := validation.NewValidator()
validationErrors := make(map[string][]string)
// Validate site URL (required)
if err := v.ValidateURL(r.SiteURL, "site_url"); err != nil {
errMsg := extractErrorMessage(err.Error())
validationErrors["site_url"] = append(validationErrors["site_url"], errMsg)
}
// Return all errors grouped together in RFC 9457 format
if len(validationErrors) > 0 {
return &ValidationErrors{Errors: validationErrors}
}
return nil
}
// extractErrorMessage extracts the error message after the field name prefix
// Example: "domain: invalid domain format" -> "Invalid domain format"
func extractErrorMessage(fullError string) string {
// Find the colon separator
colonIndex := -1
for i, char := range fullError {
if char == ':' {
colonIndex = i
break
}
}
if colonIndex == -1 {
// No colon found, capitalize first letter and return
if len(fullError) > 0 {
return string(fullError[0]-32) + fullError[1:]
}
return fullError
}
// Extract message after colon and trim spaces
message := fullError[colonIndex+1:]
if len(message) > 0 && message[0] == ' ' {
message = message[1:]
}
// Capitalize first letter
if len(message) > 0 {
firstChar := message[0]
if firstChar >= 'a' && firstChar <= 'z' {
message = string(firstChar-32) + message[1:]
}
}
return message
}
// CreateResponse represents the HTTP response after creating a site
type CreateResponse struct {
ID string `json:"id"`
Domain string `json:"domain"`
SiteURL string `json:"site_url"`
APIKey string `json:"api_key"` // Only returned once at creation
Status string `json:"status"`
VerificationToken string `json:"verification_token"`
SearchIndexName string `json:"search_index_name"`
VerificationInstructions string `json:"verification_instructions"` // DNS TXT record setup instructions
}

View file

@ -0,0 +1,28 @@
package site
import "time"
// GetResponse represents the HTTP response for getting a site
type GetResponse struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Domain string `json:"domain"`
SiteURL string `json:"site_url"`
APIKeyPrefix string `json:"api_key_prefix"`
APIKeyLastFour string `json:"api_key_last_four"`
Status string `json:"status"`
IsVerified bool `json:"is_verified"`
SearchIndexName string `json:"search_index_name"`
TotalPagesIndexed int64 `json:"total_pages_indexed"`
LastIndexedAt time.Time `json:"last_indexed_at,omitempty"`
PluginVersion string `json:"plugin_version,omitempty"`
StorageUsedBytes int64 `json:"storage_used_bytes"`
SearchRequestsCount int64 `json:"search_requests_count"`
MonthlyPagesIndexed int64 `json:"monthly_pages_indexed"`
LastResetAt time.Time `json:"last_reset_at"`
Language string `json:"language,omitempty"`
Timezone string `json:"timezone,omitempty"`
Notes string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View file

@ -0,0 +1,19 @@
package site
import "time"
// ListResponse represents the HTTP response for listing sites
type ListResponse struct {
Sites []SiteListItem `json:"sites"`
Total int `json:"total"`
}
// SiteListItem represents a site in the list
type SiteListItem struct {
ID string `json:"id"`
Domain string `json:"domain"`
Status string `json:"status"`
IsVerified bool `json:"is_verified"`
TotalPagesIndexed int64 `json:"total_pages_indexed"`
CreatedAt time.Time `json:"created_at"`
}

View file

@ -0,0 +1,10 @@
package site
import "time"
// RotateAPIKeyResponse represents the HTTP response after rotating an API key
type RotateAPIKeyResponse struct {
NewAPIKey string `json:"new_api_key"` // New API key (only returned once)
OldKeyLastFour string `json:"old_key_last_four"`
RotatedAt time.Time `json:"rotated_at"`
}

View file

@ -0,0 +1,53 @@
package tenant
import (
"time"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
)
// CreateRequest represents the HTTP request for creating a tenant
type CreateRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
// Validate validates the create tenant request
// CWE-20: Improper Input Validation
func (r *CreateRequest) Validate() error {
validator := validation.NewValidator()
// Validate name: 3-100 chars, printable, no HTML
if err := validator.ValidateRequired(r.Name, "name"); err != nil {
return err
}
if err := validator.ValidateLength(r.Name, "name", 3, 100); err != nil {
return err
}
if err := validator.ValidatePrintable(r.Name, "name"); err != nil {
return err
}
if err := validator.ValidateNoHTML(r.Name, "name"); err != nil {
return err
}
// Validate slug: uses existing slug validation (lowercase, hyphens, 3-63 chars)
if err := validator.ValidateSlug(r.Slug, "slug"); err != nil {
return err
}
// Sanitize inputs
r.Name = validator.SanitizeString(r.Name)
r.Slug = validator.SanitizeString(r.Slug)
return nil
}
// CreateResponse represents the HTTP response after creating a tenant
type CreateResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}

View file

@ -0,0 +1,13 @@
package tenant
import "time"
// GetResponse represents the HTTP response when retrieving a tenant
type GetResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View file

@ -0,0 +1,18 @@
package user
import "time"
// CreateRequest is the HTTP request for creating a user
type CreateRequest struct {
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
// CreateResponse is the HTTP response after creating a user
type CreateResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}

View file

@ -0,0 +1,12 @@
package user
import "time"
// GetResponse is the HTTP response for getting a user
type GetResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View file

@ -0,0 +1,130 @@
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/handler/admin/account_status_handler.go
package admin
import (
"net/http"
"time"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/ratelimit"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
)
// AccountStatusHandler handles HTTP requests for checking account lock status
type AccountStatusHandler struct {
loginRateLimiter ratelimit.LoginRateLimiter
logger *zap.Logger
}
// NewAccountStatusHandler creates a new account status handler
func NewAccountStatusHandler(
loginRateLimiter ratelimit.LoginRateLimiter,
logger *zap.Logger,
) *AccountStatusHandler {
return &AccountStatusHandler{
loginRateLimiter: loginRateLimiter,
logger: logger.Named("account-status-handler"),
}
}
// ProvideAccountStatusHandler creates a new AccountStatusHandler for dependency injection
func ProvideAccountStatusHandler(
loginRateLimiter ratelimit.LoginRateLimiter,
logger *zap.Logger,
) *AccountStatusHandler {
return NewAccountStatusHandler(loginRateLimiter, logger)
}
// AccountStatusResponse represents the account status response
type AccountStatusResponse struct {
Email string `json:"email"`
IsLocked bool `json:"is_locked"`
FailedAttempts int `json:"failed_attempts"`
RemainingTime string `json:"remaining_time,omitempty"`
RemainingSeconds int `json:"remaining_seconds,omitempty"`
}
// Handle processes GET /api/v1/admin/account-status?email=user@example.com requests
// This endpoint allows administrators to check if an account is locked and get details
func (h *AccountStatusHandler) Handle(w http.ResponseWriter, r *http.Request) {
h.logger.Debug("handling account status request")
// CWE-20: Validate email query parameter
email, err := validation.ValidateQueryEmail(r, "email")
if err != nil {
h.logger.Warn("invalid email query parameter", zap.Error(err))
httperror.ProblemBadRequest(w, err.Error())
return
}
// Check if account is locked
locked, remainingTime, err := h.loginRateLimiter.IsAccountLocked(r.Context(), email)
if err != nil {
h.logger.Error("failed to check account lock status",
logger.EmailHash(email),
zap.Error(err))
httperror.ProblemInternalServerError(w, "Failed to check account status")
return
}
// Get failed attempts count
failedAttempts, err := h.loginRateLimiter.GetFailedAttempts(r.Context(), email)
if err != nil {
h.logger.Error("failed to get failed attempts",
logger.EmailHash(email),
zap.Error(err))
// Continue with locked status even if we can't get attempt count
failedAttempts = 0
}
response := &AccountStatusResponse{
Email: email,
IsLocked: locked,
FailedAttempts: failedAttempts,
}
if locked {
response.RemainingTime = formatDuration(remainingTime)
response.RemainingSeconds = int(remainingTime.Seconds())
}
h.logger.Info("account status checked",
logger.EmailHash(email),
zap.Bool("is_locked", locked),
zap.Int("failed_attempts", failedAttempts))
httpresponse.OK(w, response)
}
// formatDuration formats a duration into a human-readable string
func formatDuration(d time.Duration) string {
if d < 0 {
return "0s"
}
hours := int(d.Hours())
minutes := int(d.Minutes()) % 60
seconds := int(d.Seconds()) % 60
if hours > 0 {
return formatWithUnit(hours, "hour") + " " + formatWithUnit(minutes, "minute")
}
if minutes > 0 {
return formatWithUnit(minutes, "minute") + " " + formatWithUnit(seconds, "second")
}
return formatWithUnit(seconds, "second")
}
func formatWithUnit(value int, unit string) string {
if value == 0 {
return ""
}
if value == 1 {
return "1 " + unit
}
return string(rune(value)) + " " + unit + "s"
}

View file

@ -0,0 +1,149 @@
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/handler/admin/unlock_account_handler.go
package admin
import (
"encoding/json"
"io"
"net/http"
"go.uber.org/zap"
securityeventservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/securityevent"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/ratelimit"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
)
// UnlockAccountHandler handles HTTP requests for unlocking locked accounts
type UnlockAccountHandler struct {
loginRateLimiter ratelimit.LoginRateLimiter
securityEventLogger securityeventservice.Logger
logger *zap.Logger
}
// NewUnlockAccountHandler creates a new unlock account handler
func NewUnlockAccountHandler(
loginRateLimiter ratelimit.LoginRateLimiter,
securityEventLogger securityeventservice.Logger,
logger *zap.Logger,
) *UnlockAccountHandler {
return &UnlockAccountHandler{
loginRateLimiter: loginRateLimiter,
securityEventLogger: securityEventLogger,
logger: logger.Named("unlock-account-handler"),
}
}
// ProvideUnlockAccountHandler creates a new UnlockAccountHandler for dependency injection
func ProvideUnlockAccountHandler(
loginRateLimiter ratelimit.LoginRateLimiter,
securityEventLogger securityeventservice.Logger,
logger *zap.Logger,
) *UnlockAccountHandler {
return NewUnlockAccountHandler(loginRateLimiter, securityEventLogger, logger)
}
// UnlockAccountRequest represents the unlock account request payload
type UnlockAccountRequest struct {
Email string `json:"email"`
}
// UnlockAccountResponse represents the unlock account response
type UnlockAccountResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Email string `json:"email"`
}
// Handle processes POST /api/v1/admin/unlock-account requests
// This endpoint allows administrators to manually unlock accounts that have been
// locked due to excessive failed login attempts
func (h *UnlockAccountHandler) Handle(w http.ResponseWriter, r *http.Request) {
h.logger.Debug("handling unlock account request")
// Parse request body
body, err := io.ReadAll(r.Body)
if err != nil {
h.logger.Warn("failed to read request body", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid request body")
return
}
defer r.Body.Close()
var req UnlockAccountRequest
if err := json.Unmarshal(body, &req); err != nil {
h.logger.Warn("failed to parse request body", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid JSON")
return
}
// CWE-20: Comprehensive email validation
emailValidator := validation.NewEmailValidator()
normalizedEmail, err := emailValidator.ValidateAndNormalize(req.Email, "email")
if err != nil {
h.logger.Warn("invalid email", zap.Error(err))
httperror.ProblemBadRequest(w, err.Error())
return
}
req.Email = normalizedEmail
// Check if account is currently locked
locked, remainingTime, err := h.loginRateLimiter.IsAccountLocked(r.Context(), req.Email)
if err != nil {
h.logger.Error("failed to check account lock status",
logger.EmailHash(req.Email),
zap.Error(err))
httperror.ProblemInternalServerError(w, "Failed to check account status")
return
}
if !locked {
h.logger.Info("account not locked - nothing to do",
logger.EmailHash(req.Email))
httpresponse.OK(w, &UnlockAccountResponse{
Success: true,
Message: "Account is not locked",
Email: req.Email,
})
return
}
// Unlock the account
if err := h.loginRateLimiter.UnlockAccount(r.Context(), req.Email); err != nil {
h.logger.Error("failed to unlock account",
logger.EmailHash(req.Email),
zap.Error(err))
httperror.ProblemInternalServerError(w, "Failed to unlock account")
return
}
// Get admin user ID from context (set by JWT middleware)
// TODO: Extract admin user ID from JWT claims when authentication is added
adminUserID := "admin" // Placeholder until JWT middleware is integrated
// Log security event
redactor := logger.NewSensitiveFieldRedactor()
if err := h.securityEventLogger.LogAccountUnlocked(
r.Context(),
redactor.HashForLogging(req.Email),
adminUserID,
); err != nil {
h.logger.Error("failed to log security event",
logger.EmailHash(req.Email),
zap.Error(err))
// Don't fail the request if logging fails
}
h.logger.Info("account unlocked successfully",
logger.EmailHash(req.Email),
logger.SafeEmail("email_redacted", req.Email),
zap.Duration("was_locked_for", remainingTime))
httpresponse.OK(w, &UnlockAccountResponse{
Success: true,
Message: "Account unlocked successfully",
Email: req.Email,
})
}

View file

@ -0,0 +1,122 @@
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/handler/gateway/hello_handler.go
package gateway
import (
"encoding/json"
"fmt"
"html"
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
)
// HelloHandler handles the hello endpoint for authenticated users
type HelloHandler struct {
logger *zap.Logger
}
// ProvideHelloHandler creates a new HelloHandler
func ProvideHelloHandler(logger *zap.Logger) *HelloHandler {
return &HelloHandler{
logger: logger,
}
}
// HelloRequest represents the request body for the hello endpoint
type HelloRequest struct {
Name string `json:"name"`
}
// HelloResponse represents the response for the hello endpoint
type HelloResponse struct {
Message string `json:"message"`
}
// Handle handles the HTTP request for the hello endpoint
// Security: CWE-20, CWE-79, CWE-117 - Comprehensive input validation and sanitization
func (h *HelloHandler) Handle(w http.ResponseWriter, r *http.Request) {
// M-2: Enforce strict Content-Type validation
// CWE-436: Validate Content-Type before parsing
if err := httpvalidation.ValidateJSONContentTypeStrict(r); err != nil {
h.logger.Warn("invalid content type", zap.String("content_type", r.Header.Get("Content-Type")))
httperror.ProblemBadRequest(w, err.Error())
return
}
// Parse request body
var req HelloRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Warn("invalid request body", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid request body")
return
}
// H-1: Comprehensive input validation
// CWE-20: Improper Input Validation
validator := validation.NewValidator()
// Validate required
if err := validator.ValidateRequired(req.Name, "name"); err != nil {
httperror.ProblemBadRequest(w, err.Error())
return
}
// Validate length (1-100 characters is reasonable for a name)
if err := validator.ValidateLength(req.Name, "name", 1, 100); err != nil {
httperror.ProblemBadRequest(w, err.Error())
return
}
// Validate printable characters only
if err := validator.ValidatePrintable(req.Name, "name"); err != nil {
httperror.ProblemBadRequest(w, err.Error())
return
}
// M-1: Validate no HTML tags (XSS prevention)
// CWE-79: Cross-site Scripting
if err := validator.ValidateNoHTML(req.Name, "name"); err != nil {
httperror.ProblemBadRequest(w, err.Error())
return
}
// Sanitize input
req.Name = validator.SanitizeString(req.Name)
// H-2: Fix log injection vulnerability
// CWE-117: Improper Output Neutralization for Logs
// Hash the name to prevent log injection and protect PII
nameHash := logger.HashString(req.Name)
// L-1: Extract user ID from context for correlation
// Get authenticated user info from JWT context
userID := "unknown"
if uid := r.Context().Value(constants.SessionUserID); uid != nil {
if userIDUint, ok := uid.(uint64); ok {
userID = fmt.Sprintf("%d", userIDUint)
}
}
h.logger.Info("hello endpoint accessed",
zap.String("user_id", userID),
zap.String("name_hash", nameHash))
// M-1: HTML-escape the name to prevent XSS in any context
// CWE-79: Cross-site Scripting
safeName := html.EscapeString(req.Name)
// Create response with sanitized output
response := HelloResponse{
Message: fmt.Sprintf("Hello, %s! Welcome to MaplePress Backend.", safeName),
}
// Write response
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,183 @@
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/handler/gateway/login_handler.go
package gateway
import (
"errors"
"fmt"
"net/http"
"go.uber.org/zap"
gatewaydto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/gateway"
gatewaysvc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/gateway"
securityeventservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/securityevent"
gatewayuc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/gateway"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/ratelimit"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/clientip"
)
// LoginHandler handles HTTP requests for user login
type LoginHandler struct {
loginService gatewaysvc.LoginService
loginRateLimiter ratelimit.LoginRateLimiter
securityEventLogger securityeventservice.Logger
ipExtractor *clientip.Extractor
logger *zap.Logger
}
// NewLoginHandler creates a new login handler
// CWE-307: Integrates rate limiting and account lockout protection
// CWE-778: Integrates security event logging for audit trails
func NewLoginHandler(
loginService gatewaysvc.LoginService,
loginRateLimiter ratelimit.LoginRateLimiter,
securityEventLogger securityeventservice.Logger,
ipExtractor *clientip.Extractor,
logger *zap.Logger,
) *LoginHandler {
return &LoginHandler{
loginService: loginService,
loginRateLimiter: loginRateLimiter,
securityEventLogger: securityEventLogger,
ipExtractor: ipExtractor,
logger: logger.Named("login-handler"),
}
}
// ProvideLoginHandler creates a new LoginHandler for dependency injection
func ProvideLoginHandler(
loginService gatewaysvc.LoginService,
loginRateLimiter ratelimit.LoginRateLimiter,
securityEventLogger securityeventservice.Logger,
ipExtractor *clientip.Extractor,
logger *zap.Logger,
) *LoginHandler {
return NewLoginHandler(loginService, loginRateLimiter, securityEventLogger, ipExtractor, logger)
}
// Handle processes POST /api/v1/login requests
// CWE-307: Implements rate limiting and account lockout protection against brute force attacks
func (h *LoginHandler) Handle(w http.ResponseWriter, r *http.Request) {
h.logger.Debug("handling login request")
// Parse and validate request
dto, err := gatewaydto.ParseLoginRequest(r)
if err != nil {
h.logger.Warn("invalid login request", zap.Error(err))
httperror.ProblemBadRequest(w, err.Error())
return
}
// CWE-348: Extract client IP securely with trusted proxy validation
clientIP := h.ipExtractor.Extract(r)
// CWE-307: Check rate limits and account lockout BEFORE attempting authentication
allowed, isLocked, remainingAttempts, err := h.loginRateLimiter.CheckAndRecordAttempt(
r.Context(),
dto.Email,
clientIP,
)
if err != nil {
// Log error but continue (fail open)
h.logger.Error("rate limiter error",
logger.EmailHash(dto.Email),
zap.String("ip", clientIP),
zap.Error(err))
}
// Account is locked - return error immediately
if isLocked {
h.logger.Warn("login attempt on locked account",
logger.EmailHash(dto.Email),
logger.SafeEmail("email_redacted", dto.Email),
zap.String("ip", clientIP))
// Add Retry-After header (30 minutes)
w.Header().Set("Retry-After", "1800")
httperror.ProblemTooManyRequests(w, "Account temporarily locked due to too many failed login attempts. Please try again later.")
return
}
// IP rate limit exceeded - return error immediately
if !allowed {
h.logger.Warn("login rate limit exceeded",
logger.EmailHash(dto.Email),
zap.String("ip", clientIP))
// CWE-778: Log security event for IP rate limit
h.securityEventLogger.LogIPRateLimitExceeded(r.Context(), clientIP)
// Add Retry-After header (15 minutes)
w.Header().Set("Retry-After", "900")
httperror.ProblemTooManyRequests(w, "Too many login attempts from this IP address. Please try again later.")
return
}
// Execute login
response, err := h.loginService.Login(r.Context(), &gatewaysvc.LoginInput{
Email: dto.Email,
Password: dto.Password,
})
if err != nil {
if errors.Is(err, gatewayuc.ErrInvalidCredentials) {
// CWE-307: Record failed login attempt for account lockout tracking
if err := h.loginRateLimiter.RecordFailedAttempt(r.Context(), dto.Email, clientIP); err != nil {
h.logger.Error("failed to record failed login attempt",
logger.EmailHash(dto.Email),
zap.String("ip", clientIP),
zap.Error(err))
}
// CWE-532: Log with redacted email (security event logging)
h.logger.Warn("login failed: invalid credentials",
logger.EmailHash(dto.Email),
logger.SafeEmail("email_redacted", dto.Email),
zap.String("ip", clientIP),
zap.Int("remaining_attempts", remainingAttempts-1))
// CWE-778: Log security event for failed login
redactor := logger.NewSensitiveFieldRedactor()
h.securityEventLogger.LogFailedLogin(r.Context(), redactor.HashForLogging(dto.Email), clientIP, remainingAttempts-1)
// Include remaining attempts in error message to help legitimate users
errorMsg := "Invalid email or password."
if remainingAttempts <= 3 {
errorMsg = fmt.Sprintf("Invalid email or password. %d attempts remaining before account lockout.", remainingAttempts-1)
}
httperror.ProblemUnauthorized(w, errorMsg)
return
}
h.logger.Error("login failed", zap.Error(err))
httperror.ProblemInternalServerError(w, "Failed to process login. Please try again later.")
return
}
// CWE-307: Record successful login (resets failed attempt counters)
if err := h.loginRateLimiter.RecordSuccessfulLogin(r.Context(), dto.Email, clientIP); err != nil {
// Log error but don't fail the login
h.logger.Error("failed to reset login counters after successful login",
logger.EmailHash(dto.Email),
zap.String("ip", clientIP),
zap.Error(err))
}
// CWE-532: Log with safe identifiers only (no PII)
h.logger.Info("login successful",
zap.String("user_id", response.UserID),
zap.String("tenant_id", response.TenantID),
logger.EmailHash(response.UserEmail),
zap.String("ip", clientIP))
// CWE-778: Log security event for successful login
redactor := logger.NewSensitiveFieldRedactor()
h.securityEventLogger.LogSuccessfulLogin(r.Context(), redactor.HashForLogging(dto.Email), clientIP)
// Return response with pretty JSON
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,68 @@
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/handler/gateway/me_handler.go
package gateway
import (
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
)
// MeHandler handles the /me endpoint for getting authenticated user profile
type MeHandler struct {
logger *zap.Logger
}
// ProvideMeHandler creates a new MeHandler
func ProvideMeHandler(logger *zap.Logger) *MeHandler {
return &MeHandler{
logger: logger,
}
}
// MeResponse represents the user profile response
type MeResponse struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
TenantID string `json:"tenant_id"`
}
// Handle handles the HTTP request for the /me endpoint
func (h *MeHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Extract user info from context (set by JWT middleware)
userUUID, ok := r.Context().Value(constants.SessionUserUUID).(string)
if !ok || userUUID == "" {
h.logger.Error("user UUID not found in context")
httperror.ProblemUnauthorized(w, "Authentication required")
return
}
userEmail, _ := r.Context().Value(constants.SessionUserEmail).(string)
userName, _ := r.Context().Value(constants.SessionUserName).(string)
userRole, _ := r.Context().Value(constants.SessionUserRole).(string)
tenantUUID, _ := r.Context().Value(constants.SessionTenantID).(string)
// CWE-532: Use redacted email for logging
h.logger.Info("/me endpoint accessed",
zap.String("user_id", userUUID),
logger.EmailHash(userEmail),
logger.SafeEmail("email_redacted", userEmail))
// Create response
response := MeResponse{
UserID: userUUID,
Email: userEmail,
Name: userName,
Role: userRole,
TenantID: tenantUUID,
}
// Write response with pretty JSON
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,80 @@
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/handler/gateway/refresh_handler.go
package gateway
import (
"net/http"
"go.uber.org/zap"
gatewaydto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/gateway"
gatewaysvc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/gateway"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// RefreshTokenHandler handles HTTP requests for token refresh
type RefreshTokenHandler struct {
refreshTokenService gatewaysvc.RefreshTokenService
logger *zap.Logger
}
// NewRefreshTokenHandler creates a new refresh token handler
func NewRefreshTokenHandler(
refreshTokenService gatewaysvc.RefreshTokenService,
logger *zap.Logger,
) *RefreshTokenHandler {
return &RefreshTokenHandler{
refreshTokenService: refreshTokenService,
logger: logger.Named("refresh-token-handler"),
}
}
// ProvideRefreshTokenHandler creates a new RefreshTokenHandler for dependency injection
func ProvideRefreshTokenHandler(
refreshTokenService gatewaysvc.RefreshTokenService,
logger *zap.Logger,
) *RefreshTokenHandler {
return NewRefreshTokenHandler(refreshTokenService, logger)
}
// Handle processes POST /api/v1/refresh requests
// CWE-613: Validates session still exists before issuing new tokens
func (h *RefreshTokenHandler) Handle(w http.ResponseWriter, r *http.Request) {
h.logger.Debug("handling token refresh request")
// Parse and validate request
dto, err := gatewaydto.ParseRefreshTokenRequest(r)
if err != nil {
h.logger.Warn("invalid refresh token request", zap.Error(err))
httperror.ProblemBadRequest(w, err.Error())
return
}
// Execute token refresh
response, err := h.refreshTokenService.RefreshToken(r.Context(), &gatewaysvc.RefreshTokenInput{
RefreshToken: dto.RefreshToken,
})
if err != nil {
h.logger.Warn("token refresh failed", zap.Error(err))
// Return appropriate error based on error message
switch err.Error() {
case "invalid or expired refresh token":
httperror.ProblemUnauthorized(w, "Invalid or expired refresh token. Please log in again.")
case "session not found or expired":
httperror.ProblemUnauthorized(w, "Session has expired or been invalidated. Please log in again.")
default:
httperror.ProblemInternalServerError(w, "Failed to refresh token. Please try again later.")
}
return
}
// CWE-532: Log with safe identifiers only (no PII)
h.logger.Info("token refresh successful",
zap.String("user_id", response.UserID),
zap.String("tenant_id", response.TenantID),
zap.String("session_id", response.SessionID))
// Return response with pretty JSON
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,185 @@
// File Path: monorepo/cloud/maplepress-backend/internal/interface/http/handler/gateway/register_handler.go
package gateway
import (
"encoding/json"
"net/http"
"strings"
"go.uber.org/zap"
gatewaydto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/gateway"
gatewaysvc "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/gateway"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/logger"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/security/clientip"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/validation"
)
// RegisterHandler handles user registration HTTP requests
type RegisterHandler struct {
service gatewaysvc.RegisterService
ipExtractor *clientip.Extractor
logger *zap.Logger
}
// ProvideRegisterHandler creates a new RegisterHandler
func ProvideRegisterHandler(
service gatewaysvc.RegisterService,
ipExtractor *clientip.Extractor,
logger *zap.Logger,
) *RegisterHandler {
return &RegisterHandler{
service: service,
ipExtractor: ipExtractor,
logger: logger,
}
}
// Handle handles the HTTP request for user registration
func (h *RegisterHandler) Handle(w http.ResponseWriter, r *http.Request) {
// CWE-436: Validate Content-Type before parsing to prevent interpretation conflicts
if err := httpvalidation.RequireJSONContentType(r); err != nil {
h.logger.Warn("invalid content type",
zap.String("content_type", r.Header.Get("Content-Type")))
httperror.ProblemBadRequest(w, err.Error())
return
}
// Parse request body
var req gatewaydto.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.logger.Warn("invalid request body", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid request body format. Please check your JSON syntax.")
return
}
// CWE-20: Comprehensive input validation
if err := req.Validate(); err != nil {
h.logger.Warn("registration request validation failed", zap.Error(err))
// Check if it's a structured validation error (RFC 9457 format)
if validationErr, ok := err.(*gatewaydto.ValidationErrors); ok {
httperror.ValidationError(w, validationErr.Errors, "One or more validation errors occurred")
return
}
// Fallback for non-structured errors
httperror.ProblemBadRequest(w, err.Error())
return
}
// CWE-348: Extract IP address securely with X-Forwarded-For validation
// Only trusts X-Forwarded-For if request comes from configured trusted proxies
ipAddress := h.ipExtractor.Extract(r)
// Default timezone to UTC if not provided
timezone := req.Timezone
if timezone == "" {
timezone = "UTC"
h.logger.Debug("timezone not provided, defaulting to UTC")
}
// Generate tenant slug from tenant name
validator := validation.NewValidator()
tenantSlug := validator.GenerateSlug(req.TenantName)
h.logger.Debug("generated tenant slug from name",
zap.String("tenant_name", req.TenantName),
zap.String("tenant_slug", tenantSlug))
// Map DTO to service input
input := &gatewaysvc.RegisterInput{
Email: req.Email,
Password: req.Password,
FirstName: req.FirstName,
LastName: req.LastName,
TenantName: req.TenantName,
TenantSlug: tenantSlug,
Timezone: timezone,
// Consent fields
AgreeTermsOfService: req.AgreeTermsOfService,
AgreePromotions: req.AgreePromotions,
AgreeToTrackingAcrossThirdPartyAppsAndServices: req.AgreeToTrackingAcrossThirdPartyAppsAndServices,
// IP address for audit trail
CreatedFromIPAddress: ipAddress,
}
// Call service
output, err := h.service.Register(r.Context(), input)
if err != nil {
// CWE-532: Log with redacted sensitive information
h.logger.Error("failed to register user",
zap.Error(err),
logger.EmailHash(req.Email),
logger.SafeEmail("email_redacted", req.Email),
logger.TenantSlugHash(tenantSlug),
logger.SafeTenantSlug("tenant_slug_redacted", tenantSlug))
// Check for specific errors
errMsg := err.Error()
switch {
case errMsg == "user already exists":
// CWE-203: Return generic message to prevent user enumeration
httperror.ProblemConflict(w, "Registration failed. The provided information is already in use.")
case errMsg == "tenant already exists":
// CWE-203: Return generic message to prevent tenant slug enumeration
// Prevents attackers from discovering valid tenant slugs for reconnaissance
httperror.ProblemConflict(w, "Registration failed. The provided information is already in use.")
case errMsg == "must agree to terms of service":
httperror.ProblemBadRequest(w, "You must agree to the terms of service to create an account.")
case errMsg == "password must be at least 8 characters":
httperror.ProblemBadRequest(w, "Password must be at least 8 characters long.")
// CWE-521: Password breach checking
case strings.Contains(errMsg, "data breaches"):
httperror.ProblemBadRequest(w, "This password has been found in data breaches and cannot be used. Please choose a different password.")
// CWE-521: Granular password strength errors for better user experience
case errMsg == "password must contain at least one uppercase letter (A-Z)":
httperror.ProblemBadRequest(w, "Password must contain at least one uppercase letter (A-Z).")
case errMsg == "password must contain at least one lowercase letter (a-z)":
httperror.ProblemBadRequest(w, "Password must contain at least one lowercase letter (a-z).")
case errMsg == "password must contain at least one number (0-9)":
httperror.ProblemBadRequest(w, "Password must contain at least one number (0-9).")
case errMsg == "password must contain at least one special character (!@#$%^&*()_+-=[]{}; etc.)":
httperror.ProblemBadRequest(w, "Password must contain at least one special character (!@#$%^&*()_+-=[]{}; etc.).")
case errMsg == "password must contain uppercase, lowercase, number, and special character":
httperror.ProblemBadRequest(w, "Password must contain uppercase, lowercase, number, and special character.")
case errMsg == "invalid email format":
httperror.ProblemBadRequest(w, "Invalid email format. Please provide a valid email address.")
case errMsg == "tenant slug must contain only lowercase letters, numbers, and hyphens":
httperror.ProblemBadRequest(w, "Tenant name must contain only lowercase letters, numbers, and hyphens.")
default:
httperror.ProblemInternalServerError(w, "Failed to register user. Please try again later.")
}
return
}
// CWE-532: Log with safe identifiers (no PII)
h.logger.Info("user registered successfully",
zap.String("user_id", output.UserID),
zap.String("tenant_id", output.TenantID),
logger.EmailHash(output.UserEmail))
// Map to response DTO
response := gatewaydto.RegisterResponse{
UserID: output.UserID,
UserEmail: output.UserEmail,
UserName: output.UserName,
UserRole: output.UserRole,
TenantID: output.TenantID,
TenantName: output.TenantName,
TenantSlug: output.TenantSlug,
SessionID: output.SessionID,
AccessToken: output.AccessToken,
AccessExpiry: output.AccessExpiry,
RefreshToken: output.RefreshToken,
RefreshExpiry: output.RefreshExpiry,
CreatedAt: output.CreatedAt,
}
// Write response
httpresponse.Created(w, response)
}

View file

@ -0,0 +1,24 @@
package healthcheck
import (
"net/http"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// Handler handles healthcheck requests
type Handler struct{}
// ProvideHealthCheckHandler creates a new health check handler
func ProvideHealthCheckHandler() *Handler {
return &Handler{}
}
// Handle handles the healthcheck request
func (h *Handler) Handle(w http.ResponseWriter, r *http.Request) {
response := map[string]string{
"status": "healthy",
}
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,196 @@
package plugin
import (
"encoding/json"
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
pagedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/page"
pageservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/page"
pageusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/page"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
)
// DeletePagesHandler handles page deletion from WordPress plugin
type DeletePagesHandler struct {
deleteService pageservice.DeletePagesService
logger *zap.Logger
}
// ProvideDeletePagesHandler creates a new DeletePagesHandler
func ProvideDeletePagesHandler(
deleteService pageservice.DeletePagesService,
logger *zap.Logger,
) *DeletePagesHandler {
return &DeletePagesHandler{
deleteService: deleteService,
logger: logger,
}
}
// Handle handles the HTTP request for deleting pages
// This endpoint is protected by API key middleware
func (h *DeletePagesHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get site information from context (populated by API key middleware)
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
if !ok || !isAuthenticated {
h.logger.Error("site not authenticated in context")
httperror.ProblemUnauthorized(w, "Invalid API key")
return
}
// Extract site ID and tenant ID from context
siteIDStr, _ := r.Context().Value(constants.SiteID).(string)
tenantIDStr, _ := r.Context().Value(constants.SiteTenantID).(string)
h.logger.Info("delete pages request",
zap.String("tenant_id", tenantIDStr),
zap.String("site_id", siteIDStr))
// Parse IDs
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid tenant ID")
return
}
siteID, err := gocql.ParseUUID(siteIDStr)
if err != nil {
h.logger.Error("invalid site ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid site ID")
return
}
// CWE-436: Validate Content-Type before parsing
if err := httpvalidation.ValidateJSONContentType(r); err != nil {
httperror.ProblemBadRequest(w, err.Error())
return
}
// Parse request body
var req pagedto.DeleteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.ProblemBadRequest(w, "invalid request body")
return
}
// Validate request
if len(req.PageIDs) == 0 {
httperror.ProblemBadRequest(w, "page_ids array is required")
return
}
// Convert DTO to use case input
input := &pageusecase.DeletePagesInput{
PageIDs: req.PageIDs,
}
// Call service
output, err := h.deleteService.DeletePages(r.Context(), tenantID, siteID, input)
if err != nil {
h.logger.Error("failed to delete pages",
zap.Error(err),
zap.String("site_id", siteIDStr))
// Check for specific errors
if err.Error() == "site not found" {
httperror.ProblemNotFound(w, "site not found")
return
}
if err.Error() == "site is not verified" {
httperror.ProblemForbidden(w, "site is not verified")
return
}
httperror.ProblemInternalServerError(w, "failed to delete pages")
return
}
// Map to response DTO
response := pagedto.DeleteResponse{
DeletedCount: output.DeletedCount,
DeindexedCount: output.DeindexedCount,
FailedPages: output.FailedPages,
Message: output.Message,
}
h.logger.Info("pages deleted successfully",
zap.String("site_id", siteIDStr),
zap.Int("deleted_count", output.DeletedCount))
httpresponse.OK(w, response)
}
// HandleDeleteAll handles the HTTP request for deleting all pages
func (h *DeletePagesHandler) HandleDeleteAll(w http.ResponseWriter, r *http.Request) {
// Get site information from context (populated by API key middleware)
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
if !ok || !isAuthenticated {
h.logger.Error("site not authenticated in context")
httperror.ProblemUnauthorized(w, "Invalid API key")
return
}
// Extract site ID and tenant ID from context
siteIDStr, _ := r.Context().Value(constants.SiteID).(string)
tenantIDStr, _ := r.Context().Value(constants.SiteTenantID).(string)
h.logger.Info("delete all pages request",
zap.String("tenant_id", tenantIDStr),
zap.String("site_id", siteIDStr))
// Parse IDs
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid tenant ID")
return
}
siteID, err := gocql.ParseUUID(siteIDStr)
if err != nil {
h.logger.Error("invalid site ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid site ID")
return
}
// Call service
output, err := h.deleteService.DeleteAllPages(r.Context(), tenantID, siteID)
if err != nil {
h.logger.Error("failed to delete all pages",
zap.Error(err),
zap.String("site_id", siteIDStr))
// Check for specific errors
if err.Error() == "site not found" {
httperror.ProblemNotFound(w, "site not found")
return
}
if err.Error() == "site is not verified" {
httperror.ProblemForbidden(w, "site is not verified")
return
}
httperror.ProblemInternalServerError(w, "failed to delete all pages")
return
}
// Map to response DTO
response := pagedto.DeleteResponse{
DeletedCount: output.DeletedCount,
DeindexedCount: output.DeindexedCount,
Message: output.Message,
}
h.logger.Info("all pages deleted successfully",
zap.String("site_id", siteIDStr),
zap.Int("deleted_count", output.DeletedCount))
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,135 @@
package plugin
import (
"encoding/json"
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
pagedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/page"
pageservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/page"
pageusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/page"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
)
// SearchPagesHandler handles page search from WordPress plugin
type SearchPagesHandler struct {
searchService pageservice.SearchPagesService
logger *zap.Logger
}
// ProvideSearchPagesHandler creates a new SearchPagesHandler
func ProvideSearchPagesHandler(
searchService pageservice.SearchPagesService,
logger *zap.Logger,
) *SearchPagesHandler {
return &SearchPagesHandler{
searchService: searchService,
logger: logger,
}
}
// Handle handles the HTTP request for searching pages
// This endpoint is protected by API key middleware
func (h *SearchPagesHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get site information from context (populated by API key middleware)
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
if !ok || !isAuthenticated {
h.logger.Error("site not authenticated in context")
httperror.ProblemUnauthorized(w, "Invalid API key")
return
}
// Extract site ID and tenant ID from context
siteIDStr, _ := r.Context().Value(constants.SiteID).(string)
tenantIDStr, _ := r.Context().Value(constants.SiteTenantID).(string)
h.logger.Info("search pages request",
zap.String("tenant_id", tenantIDStr),
zap.String("site_id", siteIDStr))
// Parse IDs
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid tenant ID")
return
}
siteID, err := gocql.ParseUUID(siteIDStr)
if err != nil {
h.logger.Error("invalid site ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid site ID")
return
}
// CWE-436: Validate Content-Type before parsing
if err := httpvalidation.ValidateJSONContentType(r); err != nil {
httperror.ProblemBadRequest(w, err.Error())
return
}
// Parse request body
var req pagedto.SearchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.ProblemBadRequest(w, "invalid request body")
return
}
// Validate request
if req.Query == "" {
httperror.ProblemBadRequest(w, "query is required")
return
}
// Convert DTO to use case input
input := &pageusecase.SearchPagesInput{
Query: req.Query,
Limit: req.Limit,
Offset: req.Offset,
Filter: req.Filter,
}
// Call service
output, err := h.searchService.SearchPages(r.Context(), tenantID, siteID, input)
if err != nil {
h.logger.Error("failed to search pages",
zap.Error(err),
zap.String("site_id", siteIDStr),
zap.String("query", req.Query))
// Check for specific errors
if err.Error() == "site not found" {
httperror.ProblemNotFound(w, "site not found")
return
}
if err.Error() == "site is not verified" {
httperror.ProblemForbidden(w, "site is not verified")
return
}
httperror.ProblemInternalServerError(w, "failed to search pages")
return
}
// Map to response DTO
response := pagedto.SearchResponse{
Hits: output.Hits.([]map[string]interface{}),
Query: output.Query,
ProcessingTimeMs: output.ProcessingTimeMs,
TotalHits: output.TotalHits,
Limit: output.Limit,
Offset: output.Offset,
}
h.logger.Info("pages searched successfully",
zap.String("site_id", siteIDStr),
zap.String("query", req.Query),
zap.Int64("total_hits", output.TotalHits))
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,170 @@
package plugin
import (
"fmt"
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
domainsite "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/domain/site"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// StatusHandler handles WordPress plugin status/verification requests
type StatusHandler struct {
getSiteService siteservice.GetSiteService
logger *zap.Logger
}
// ProvideStatusHandler creates a new StatusHandler
func ProvideStatusHandler(
getSiteService siteservice.GetSiteService,
logger *zap.Logger,
) *StatusHandler {
return &StatusHandler{
getSiteService: getSiteService,
logger: logger,
}
}
// StatusResponse represents the response for plugin status endpoint
type StatusResponse struct {
// Core Identity
SiteID string `json:"site_id"`
TenantID string `json:"tenant_id"`
Domain string `json:"domain"`
SiteURL string `json:"site_url"`
// Status & Verification
Status string `json:"status"`
IsVerified bool `json:"is_verified"`
VerificationStatus string `json:"verification_status"` // "pending" or "verified"
VerificationToken string `json:"verification_token,omitempty"` // Only if pending
VerificationInstructions string `json:"verification_instructions,omitempty"` // Only if pending
// Storage (usage tracking only - no quotas)
StorageUsedBytes int64 `json:"storage_used_bytes"`
// Usage tracking (monthly, resets for billing)
SearchRequestsCount int64 `json:"search_requests_count"`
MonthlyPagesIndexed int64 `json:"monthly_pages_indexed"`
TotalPagesIndexed int64 `json:"total_pages_indexed"` // All-time stat
// Search
SearchIndexName string `json:"search_index_name"`
// Additional Info
APIKeyPrefix string `json:"api_key_prefix"`
APIKeyLastFour string `json:"api_key_last_four"`
PluginVersion string `json:"plugin_version,omitempty"`
Language string `json:"language,omitempty"`
Timezone string `json:"timezone,omitempty"`
Message string `json:"message"`
}
// Handle handles the HTTP request for plugin status verification
// This endpoint is protected by API key middleware, so if we reach here, the API key is valid
func (h *StatusHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get site information from context (populated by API key middleware)
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
if !ok || !isAuthenticated {
h.logger.Error("site not authenticated in context")
httperror.ProblemUnauthorized(w, "Invalid API key")
return
}
// Extract site ID and tenant ID from context
siteIDStr, _ := r.Context().Value(constants.SiteID).(string)
tenantIDStr, _ := r.Context().Value(constants.SiteTenantID).(string)
h.logger.Info("plugin status check",
zap.String("site_id", siteIDStr))
// Parse UUIDs
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid tenant ID")
return
}
// Fetch full site details from database
siteOutput, err := h.getSiteService.GetSite(r.Context(), tenantID, &siteusecase.GetSiteInput{
ID: siteIDStr,
})
if err != nil {
h.logger.Error("failed to get site details", zap.Error(err))
httperror.ProblemInternalServerError(w, "failed to retrieve site details")
return
}
site := siteOutput.Site
// Build response with full site details
response := StatusResponse{
SiteID: site.ID.String(),
TenantID: site.TenantID.String(),
Domain: site.Domain,
SiteURL: site.SiteURL,
Status: site.Status,
IsVerified: site.IsVerified,
VerificationStatus: getVerificationStatus(site),
StorageUsedBytes: site.StorageUsedBytes,
SearchRequestsCount: site.SearchRequestsCount,
MonthlyPagesIndexed: site.MonthlyPagesIndexed,
TotalPagesIndexed: site.TotalPagesIndexed,
SearchIndexName: site.SearchIndexName,
APIKeyPrefix: site.APIKeyPrefix,
APIKeyLastFour: site.APIKeyLastFour,
PluginVersion: site.PluginVersion,
Language: site.Language,
Timezone: site.Timezone,
Message: "API key is valid",
}
// If site is not verified and requires verification, include instructions
if site.RequiresVerification() && !site.IsVerified {
response.VerificationToken = site.VerificationToken
response.VerificationInstructions = generateVerificationInstructions(site)
}
httpresponse.OK(w, response)
}
// getVerificationStatus returns the verification status string
func getVerificationStatus(site *domainsite.Site) string {
if site.IsVerified {
return "verified"
}
return "pending"
}
// generateVerificationInstructions generates DNS verification instructions
func generateVerificationInstructions(site *domainsite.Site) string {
return fmt.Sprintf(
"To verify ownership of %s, add this DNS TXT record:\n\n"+
"Host/Name: %s\n"+
"Type: TXT\n"+
"Value: maplepress-verify=%s\n\n"+
"Instructions:\n"+
"1. Log in to your domain registrar (GoDaddy, Namecheap, Cloudflare, etc.)\n"+
"2. Find DNS settings for your domain\n"+
"3. Add a new TXT record with the values above\n"+
"4. Wait 5-10 minutes for DNS propagation\n"+
"5. Click 'Verify Domain' in your WordPress plugin settings",
site.Domain,
site.Domain,
site.VerificationToken,
)
}

View file

@ -0,0 +1,146 @@
package plugin
import (
"encoding/json"
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
pagedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/page"
pageservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/page"
pageusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/page"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
)
// SyncPagesHandler handles page synchronization from WordPress plugin
type SyncPagesHandler struct {
syncService pageservice.SyncPagesService
logger *zap.Logger
}
// ProvideSyncPagesHandler creates a new SyncPagesHandler
func ProvideSyncPagesHandler(
syncService pageservice.SyncPagesService,
logger *zap.Logger,
) *SyncPagesHandler {
return &SyncPagesHandler{
syncService: syncService,
logger: logger,
}
}
// Handle handles the HTTP request for syncing pages
// This endpoint is protected by API key middleware
func (h *SyncPagesHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get site information from context (populated by API key middleware)
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
if !ok || !isAuthenticated {
h.logger.Error("site not authenticated in context")
httperror.ProblemUnauthorized(w, "Invalid API key")
return
}
// Extract site ID and tenant ID from context
siteIDStr, _ := r.Context().Value(constants.SiteID).(string)
tenantIDStr, _ := r.Context().Value(constants.SiteTenantID).(string)
h.logger.Info("sync pages request",
zap.String("tenant_id", tenantIDStr),
zap.String("site_id", siteIDStr))
// Parse IDs
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid tenant ID")
return
}
siteID, err := gocql.ParseUUID(siteIDStr)
if err != nil {
h.logger.Error("invalid site ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid site ID")
return
}
// CWE-436: Validate Content-Type before parsing
if err := httpvalidation.ValidateJSONContentType(r); err != nil {
httperror.ProblemBadRequest(w, err.Error())
return
}
// Parse request body
var req pagedto.SyncRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.ProblemBadRequest(w, "invalid request body")
return
}
// CWE-20: Comprehensive input validation
if err := req.Validate(); err != nil {
h.logger.Warn("sync pages request validation failed", zap.Error(err))
httperror.ProblemBadRequest(w, err.Error())
return
}
// Convert DTO to use case input
pages := make([]pageusecase.SyncPageInput, len(req.Pages))
for i, p := range req.Pages {
pages[i] = pageusecase.SyncPageInput{
PageID: p.PageID,
Title: p.Title,
Content: p.Content,
Excerpt: p.Excerpt,
URL: p.URL,
Status: p.Status,
PostType: p.PostType,
Author: p.Author,
PublishedAt: p.PublishedAt,
ModifiedAt: p.ModifiedAt,
}
}
input := &pageusecase.SyncPagesInput{
Pages: pages,
}
// Call service
output, err := h.syncService.SyncPages(r.Context(), tenantID, siteID, input)
if err != nil {
h.logger.Error("failed to sync pages",
zap.Error(err),
zap.String("site_id", siteIDStr))
// Check for specific errors
if err.Error() == "site not found" {
httperror.ProblemNotFound(w, "site not found")
return
}
if err.Error() == "site is not verified" {
httperror.ProblemForbidden(w, "site is not verified")
return
}
httperror.ProblemInternalServerError(w, "failed to sync pages")
return
}
// Map to response DTO
response := pagedto.SyncResponse{
SyncedCount: output.SyncedCount,
IndexedCount: output.IndexedCount,
FailedPages: output.FailedPages,
Message: output.Message,
}
h.logger.Info("pages synced successfully",
zap.String("site_id", siteIDStr),
zap.Int("synced_count", output.SyncedCount),
zap.Int("indexed_count", output.IndexedCount))
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,196 @@
package plugin
import (
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
pagedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/page"
pageservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/page"
pageusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/page"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// SyncStatusHandler handles sync status requests from WordPress plugin
type SyncStatusHandler struct {
statusService pageservice.SyncStatusService
logger *zap.Logger
}
// ProvideSyncStatusHandler creates a new SyncStatusHandler
func ProvideSyncStatusHandler(
statusService pageservice.SyncStatusService,
logger *zap.Logger,
) *SyncStatusHandler {
return &SyncStatusHandler{
statusService: statusService,
logger: logger,
}
}
// Handle handles the HTTP request for getting sync status
// This endpoint is protected by API key middleware
func (h *SyncStatusHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get site information from context (populated by API key middleware)
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
if !ok || !isAuthenticated {
h.logger.Error("site not authenticated in context")
httperror.ProblemUnauthorized(w, "Invalid API key")
return
}
// Extract site ID and tenant ID from context
siteIDStr, _ := r.Context().Value(constants.SiteID).(string)
tenantIDStr, _ := r.Context().Value(constants.SiteTenantID).(string)
h.logger.Info("sync status request",
zap.String("tenant_id", tenantIDStr),
zap.String("site_id", siteIDStr))
// Parse IDs
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid tenant ID")
return
}
siteID, err := gocql.ParseUUID(siteIDStr)
if err != nil {
h.logger.Error("invalid site ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid site ID")
return
}
// Call service
output, err := h.statusService.GetSyncStatus(r.Context(), tenantID, siteID)
if err != nil {
h.logger.Error("failed to get sync status",
zap.Error(err),
zap.String("site_id", siteIDStr))
// Check for specific errors
if err.Error() == "site not found" {
httperror.ProblemNotFound(w, "site not found")
return
}
if err.Error() == "site is not verified" {
httperror.ProblemForbidden(w, "site is not verified")
return
}
httperror.ProblemInternalServerError(w, "failed to get sync status")
return
}
// Map to response DTO
response := pagedto.StatusResponse{
SiteID: output.SiteID,
TotalPages: output.TotalPages,
PublishedPages: output.PublishedPages,
DraftPages: output.DraftPages,
LastSyncedAt: output.LastSyncedAt,
PagesIndexedMonth: output.PagesIndexedMonth,
SearchRequestsMonth: output.SearchRequestsMonth,
LastResetAt: output.LastResetAt,
SearchIndexStatus: output.SearchIndexStatus,
SearchIndexDocCount: output.SearchIndexDocCount,
}
h.logger.Info("sync status retrieved successfully",
zap.String("site_id", siteIDStr),
zap.Int64("total_pages", output.TotalPages))
httpresponse.OK(w, response)
}
// HandleGetPageDetails handles the HTTP request for getting page details
func (h *SyncStatusHandler) HandleGetPageDetails(w http.ResponseWriter, r *http.Request) {
// Get site information from context (populated by API key middleware)
isAuthenticated, ok := r.Context().Value(constants.SiteIsAuthenticated).(bool)
if !ok || !isAuthenticated {
h.logger.Error("site not authenticated in context")
httperror.ProblemUnauthorized(w, "Invalid API key")
return
}
// Extract site ID and tenant ID from context
siteIDStr, _ := r.Context().Value(constants.SiteID).(string)
tenantIDStr, _ := r.Context().Value(constants.SiteTenantID).(string)
// Get page ID from URL path parameter
pageID := r.PathValue("page_id")
h.logger.Info("get page details request",
zap.String("tenant_id", tenantIDStr),
zap.String("site_id", siteIDStr),
zap.String("page_id", pageID))
// Parse IDs
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid tenant ID")
return
}
siteID, err := gocql.ParseUUID(siteIDStr)
if err != nil {
h.logger.Error("invalid site ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid site ID")
return
}
// Validate page ID
if pageID == "" {
httperror.ProblemBadRequest(w, "page_id is required")
return
}
// Call service
input := &pageusecase.GetPageDetailsInput{
PageID: pageID,
}
output, err := h.statusService.GetPageDetails(r.Context(), tenantID, siteID, input)
if err != nil {
h.logger.Error("failed to get page details",
zap.Error(err),
zap.String("site_id", siteIDStr),
zap.String("page_id", pageID))
// Check for specific errors
if err.Error() == "page not found" {
httperror.ProblemNotFound(w, "page not found")
return
}
httperror.ProblemInternalServerError(w, "failed to get page details")
return
}
// Map to response DTO
response := pagedto.PageDetailsResponse{
PageID: output.PageID,
Title: output.Title,
Excerpt: output.Excerpt,
URL: output.URL,
Status: output.Status,
PostType: output.PostType,
Author: output.Author,
PublishedAt: output.PublishedAt,
ModifiedAt: output.ModifiedAt,
IndexedAt: output.IndexedAt,
MeilisearchDocID: output.MeilisearchDocID,
IsIndexed: output.IsIndexed,
}
h.logger.Info("page details retrieved successfully",
zap.String("site_id", siteIDStr),
zap.String("page_id", pageID))
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,116 @@
package plugin
import (
"net/http"
"strings"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// PluginVerifyHandler handles domain verification from WordPress plugin
type PluginVerifyHandler struct {
service siteservice.VerifySiteService
logger *zap.Logger
}
// ProvidePluginVerifyHandler creates a new PluginVerifyHandler
func ProvidePluginVerifyHandler(service siteservice.VerifySiteService, logger *zap.Logger) *PluginVerifyHandler {
return &PluginVerifyHandler{
service: service,
logger: logger,
}
}
// VerifyResponse represents the verification response
type VerifyResponse struct {
Success bool `json:"success"`
Status string `json:"status"`
Message string `json:"message"`
}
// Handle handles the HTTP request for verifying a site via plugin API
// Uses API key authentication (site context from middleware)
func (h *PluginVerifyHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get tenant ID and site ID from API key middleware context
tenantIDStr, ok := r.Context().Value(constants.SiteTenantID).(string)
if !ok {
h.logger.Error("tenant ID not found in context")
httperror.ProblemUnauthorized(w, "Authentication required")
return
}
siteIDStr, ok := r.Context().Value(constants.SiteID).(string)
if !ok {
h.logger.Error("site ID not found in context")
httperror.ProblemUnauthorized(w, "Site context required")
return
}
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid tenant ID")
return
}
siteID, err := gocql.ParseUUID(siteIDStr)
if err != nil {
h.logger.Error("invalid site ID", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid site ID")
return
}
h.logger.Info("plugin verify request",
zap.String("tenant_id", tenantID.String()),
zap.String("site_id", siteID.String()))
// Call verification service (reuses existing DNS verification logic)
input := &siteusecase.VerifySiteInput{}
output, err := h.service.VerifySite(r.Context(), tenantID, siteID, input)
if err != nil {
h.logger.Error("verification failed",
zap.Error(err),
zap.String("site_id", siteID.String()))
// Provide user-friendly error messages
errMsg := err.Error()
if strings.Contains(errMsg, "DNS TXT record not found") {
httperror.ProblemBadRequest(w, "DNS TXT record not found. Please ensure you've added the verification record to your domain's DNS settings and wait 5-10 minutes for propagation.")
return
}
if strings.Contains(errMsg, "DNS lookup timed out") || strings.Contains(errMsg, "timeout") {
httperror.ProblemBadRequest(w, "DNS lookup timed out. Please check that your domain's DNS is properly configured.")
return
}
if strings.Contains(errMsg, "domain not found") {
httperror.ProblemBadRequest(w, "Domain not found. Please check that your domain is properly registered and DNS is active.")
return
}
if strings.Contains(errMsg, "DNS verification failed") {
httperror.ProblemBadRequest(w, "DNS verification failed. Please check your DNS settings and try again.")
return
}
httperror.ProblemInternalServerError(w, "Failed to verify site. Please try again later.")
return
}
// Success response
response := VerifyResponse{
Success: output.Success,
Status: output.Status,
Message: output.Message,
}
h.logger.Info("site verified successfully via plugin",
zap.String("site_id", siteID.String()))
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,46 @@
package plugin
import (
"net/http"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// VersionHandler handles version requests from WordPress plugin
type VersionHandler struct {
logger *zap.Logger
}
// ProvideVersionHandler creates a new VersionHandler
func ProvideVersionHandler(logger *zap.Logger) *VersionHandler {
return &VersionHandler{
logger: logger,
}
}
// VersionResponse represents the response for the version endpoint
type VersionResponse struct {
Version string `json:"version"`
APIVersion string `json:"api_version"`
Environment string `json:"environment"`
Status string `json:"status"`
}
// Handle processes GET /api/v1/plugin/version requests
func (h *VersionHandler) Handle(w http.ResponseWriter, r *http.Request) {
h.logger.Debug("Version endpoint called",
zap.String("method", r.Method),
zap.String("remote_addr", r.RemoteAddr),
)
response := VersionResponse{
Version: "1.0.0",
APIVersion: "v1",
Environment: "production", // Could be made configurable via environment variable
Status: "operational",
}
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,157 @@
package site
import (
"encoding/json"
"net/http"
"net/url"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
sitedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/site"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/dns"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpvalidation"
)
// CreateHandler handles site creation HTTP requests
type CreateHandler struct {
service siteservice.CreateSiteService
config *config.Config
logger *zap.Logger
}
// ProvideCreateHandler creates a new CreateHandler
func ProvideCreateHandler(service siteservice.CreateSiteService, cfg *config.Config, logger *zap.Logger) *CreateHandler {
return &CreateHandler{
service: service,
config: cfg,
logger: logger,
}
}
// Handle handles the HTTP request for creating a site
// Requires JWT authentication and tenant context
func (h *CreateHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get tenant ID from context (populated by TenantMiddleware)
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
if !ok {
h.logger.Error("tenant ID not found in context")
httperror.ProblemUnauthorized(w, "tenant context required")
return
}
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "invalid tenant ID")
return
}
// CWE-436: Validate Content-Type before parsing
if err := httpvalidation.ValidateJSONContentType(r); err != nil {
httperror.ProblemBadRequest(w, err.Error())
return
}
// Parse request body
var req sitedto.CreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.ProblemBadRequest(w, "invalid request body")
return
}
// CWE-20: Comprehensive input validation
if err := req.Validate(); err != nil {
h.logger.Warn("site creation request validation failed", zap.Error(err))
// Check if it's a structured validation error (RFC 9457 format)
if validationErr, ok := err.(*sitedto.ValidationErrors); ok {
httperror.ValidationError(w, validationErr.Errors, "One or more validation errors occurred")
return
}
// Fallback for non-structured errors
httperror.ProblemBadRequest(w, err.Error())
return
}
// Extract domain from site URL
parsedURL, err := url.Parse(req.SiteURL)
if err != nil {
h.logger.Warn("failed to parse site URL", zap.Error(err), zap.String("site_url", req.SiteURL))
httperror.ValidationError(w, map[string][]string{
"site_url": {"Invalid URL format. Please provide a valid URL (e.g., https://example.com)."},
}, "One or more validation errors occurred")
return
}
domain := parsedURL.Hostname()
if domain == "" {
h.logger.Warn("could not extract domain from site URL", zap.String("site_url", req.SiteURL))
httperror.ValidationError(w, map[string][]string{
"site_url": {"Could not extract domain from URL. Please provide a valid URL with a hostname."},
}, "One or more validation errors occurred")
return
}
// Determine test mode based on environment
testMode := h.config.App.IsTestMode()
h.logger.Info("creating site",
zap.String("domain", domain),
zap.String("site_url", req.SiteURL),
zap.String("environment", h.config.App.Environment),
zap.Bool("test_mode", testMode))
// Map DTO to use case input
input := &siteusecase.CreateSiteInput{
Domain: domain,
SiteURL: req.SiteURL,
TestMode: testMode,
}
// Call service
output, err := h.service.CreateSite(r.Context(), tenantID, input)
if err != nil {
h.logger.Error("failed to create site",
zap.Error(err),
zap.String("domain", domain),
zap.String("site_url", req.SiteURL),
zap.String("tenant_id", tenantID.String()))
// Check for domain already exists error
if err.Error() == "domain already exists" {
httperror.ProblemConflict(w, "This domain is already registered. Each domain can only be registered once.")
return
}
httperror.ProblemInternalServerError(w, "Failed to create site. Please try again later.")
return
}
// Map to response DTO
response := sitedto.CreateResponse{
ID: output.ID,
Domain: output.Domain,
SiteURL: output.SiteURL,
APIKey: output.APIKey, // Only shown once!
Status: output.Status,
VerificationToken: output.VerificationToken,
SearchIndexName: output.SearchIndexName,
VerificationInstructions: dns.GetVerificationInstructions(output.Domain, output.VerificationToken),
}
h.logger.Info("site created successfully",
zap.String("site_id", output.ID),
zap.String("domain", output.Domain),
zap.String("tenant_id", tenantID.String()))
// Write response with pretty JSON
httpresponse.Created(w, response)
}

View file

@ -0,0 +1,82 @@
package site
import (
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// DeleteHandler handles site deletion HTTP requests
type DeleteHandler struct {
service siteservice.DeleteSiteService
logger *zap.Logger
}
// ProvideDeleteHandler creates a new DeleteHandler
func ProvideDeleteHandler(service siteservice.DeleteSiteService, logger *zap.Logger) *DeleteHandler {
return &DeleteHandler{
service: service,
logger: logger,
}
}
// Handle handles the HTTP request for deleting a site
// Requires JWT authentication and tenant context
func (h *DeleteHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get tenant ID from context
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
if !ok {
h.logger.Error("tenant ID not found in context")
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
return
}
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
return
}
// Get site ID from path parameter
siteIDStr := r.PathValue("id")
if siteIDStr == "" {
httperror.ProblemBadRequest(w, "Site ID is required in the request path.")
return
}
// Validate UUID format
if _, err := gocql.ParseUUID(siteIDStr); err != nil {
httperror.ProblemBadRequest(w, "Invalid site ID format. Please provide a valid site ID.")
return
}
// Call service
input := &siteusecase.DeleteSiteInput{SiteID: siteIDStr}
_, err = h.service.DeleteSite(r.Context(), tenantID, input)
if err != nil {
h.logger.Error("failed to delete site",
zap.Error(err),
zap.String("site_id", siteIDStr),
zap.String("tenant_id", tenantID.String()))
httperror.ProblemNotFound(w, "The requested site could not be found. It may have been deleted or you may not have access to it.")
return
}
h.logger.Info("site deleted successfully",
zap.String("site_id", siteIDStr),
zap.String("tenant_id", tenantID.String()))
// Write response
httpresponse.OK(w, map[string]string{
"message": "site deleted successfully",
"site_id": siteIDStr,
})
}

View file

@ -0,0 +1,101 @@
package site
import (
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
sitedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/site"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// GetHandler handles getting a site by ID
type GetHandler struct {
service siteservice.GetSiteService
logger *zap.Logger
}
// ProvideGetHandler creates a new GetHandler
func ProvideGetHandler(service siteservice.GetSiteService, logger *zap.Logger) *GetHandler {
return &GetHandler{
service: service,
logger: logger,
}
}
// Handle handles the HTTP request for getting a site by ID
// Requires JWT authentication and tenant context
func (h *GetHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get tenant ID from context
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
if !ok {
h.logger.Error("tenant ID not found in context")
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
return
}
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
return
}
// Get site ID from path parameter
siteIDStr := r.PathValue("id")
if siteIDStr == "" {
httperror.ProblemBadRequest(w, "Site ID is required in the request path.")
return
}
// Validate UUID format
if _, err := gocql.ParseUUID(siteIDStr); err != nil {
httperror.ProblemBadRequest(w, "Invalid site ID format. Please provide a valid site ID.")
return
}
// Call service
input := &siteusecase.GetSiteInput{ID: siteIDStr}
output, err := h.service.GetSite(r.Context(), tenantID, input)
if err != nil {
h.logger.Error("failed to get site",
zap.Error(err),
zap.String("site_id", siteIDStr),
zap.String("tenant_id", tenantID.String()))
httperror.ProblemNotFound(w, "The requested site could not be found. It may have been deleted or you may not have access to it.")
return
}
// Map to response DTO
response := sitedto.GetResponse{
ID: output.Site.ID.String(),
TenantID: output.Site.TenantID.String(),
Domain: output.Site.Domain,
SiteURL: output.Site.SiteURL,
APIKeyPrefix: output.Site.APIKeyPrefix,
APIKeyLastFour: output.Site.APIKeyLastFour,
Status: output.Site.Status,
IsVerified: output.Site.IsVerified,
SearchIndexName: output.Site.SearchIndexName,
TotalPagesIndexed: output.Site.TotalPagesIndexed,
LastIndexedAt: output.Site.LastIndexedAt,
PluginVersion: output.Site.PluginVersion,
StorageUsedBytes: output.Site.StorageUsedBytes,
SearchRequestsCount: output.Site.SearchRequestsCount,
MonthlyPagesIndexed: output.Site.MonthlyPagesIndexed,
LastResetAt: output.Site.LastResetAt,
Language: output.Site.Language,
Timezone: output.Site.Timezone,
Notes: output.Site.Notes,
CreatedAt: output.Site.CreatedAt,
UpdatedAt: output.Site.UpdatedAt,
}
// Write response with pretty JSON
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,80 @@
package site
import (
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
sitedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/site"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// ListHandler handles listing sites for a tenant
type ListHandler struct {
service siteservice.ListSitesService
logger *zap.Logger
}
// ProvideListHandler creates a new ListHandler
func ProvideListHandler(service siteservice.ListSitesService, logger *zap.Logger) *ListHandler {
return &ListHandler{
service: service,
logger: logger,
}
}
// Handle handles the HTTP request for listing sites
// Requires JWT authentication and tenant context
func (h *ListHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get tenant ID from context
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
if !ok {
h.logger.Error("tenant ID not found in context")
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
return
}
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
return
}
// Call service
input := &siteusecase.ListSitesInput{}
output, err := h.service.ListSites(r.Context(), tenantID, input)
if err != nil {
h.logger.Error("failed to list sites",
zap.Error(err),
zap.String("tenant_id", tenantID.String()))
httperror.ProblemInternalServerError(w, "Failed to retrieve your sites. Please try again later.")
return
}
// Map to response DTO
items := make([]sitedto.SiteListItem, len(output.Sites))
for i, s := range output.Sites {
items[i] = sitedto.SiteListItem{
ID: s.ID.String(),
Domain: s.Domain,
Status: s.Status,
IsVerified: s.IsVerified,
TotalPagesIndexed: s.TotalPagesIndexed,
CreatedAt: s.CreatedAt,
}
}
response := sitedto.ListResponse{
Sites: items,
Total: len(items),
}
// Write response with pretty JSON
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,87 @@
package site
import (
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
sitedto "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/interface/http/dto/site"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// RotateAPIKeyHandler handles API key rotation HTTP requests
type RotateAPIKeyHandler struct {
service siteservice.RotateAPIKeyService
logger *zap.Logger
}
// ProvideRotateAPIKeyHandler creates a new RotateAPIKeyHandler
func ProvideRotateAPIKeyHandler(service siteservice.RotateAPIKeyService, logger *zap.Logger) *RotateAPIKeyHandler {
return &RotateAPIKeyHandler{
service: service,
logger: logger,
}
}
// Handle handles the HTTP request for rotating a site's API key
// Requires JWT authentication and tenant context
func (h *RotateAPIKeyHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get tenant ID from context
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
if !ok {
h.logger.Error("tenant ID not found in context")
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
return
}
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
return
}
// Get site ID from path parameter
siteIDStr := r.PathValue("id")
if siteIDStr == "" {
httperror.ProblemBadRequest(w, "Site ID is required in the request path.")
return
}
// Validate UUID format
if _, err := gocql.ParseUUID(siteIDStr); err != nil {
httperror.ProblemBadRequest(w, "Invalid site ID format. Please provide a valid site ID.")
return
}
// Call service
input := &siteusecase.RotateAPIKeyInput{SiteID: siteIDStr}
output, err := h.service.RotateAPIKey(r.Context(), tenantID, input)
if err != nil {
h.logger.Error("failed to rotate API key",
zap.Error(err),
zap.String("site_id", siteIDStr),
zap.String("tenant_id", tenantID.String()))
httperror.ProblemNotFound(w, "The requested site could not be found. It may have been deleted or you may not have access to it.")
return
}
// Map to response DTO
response := sitedto.RotateAPIKeyResponse{
NewAPIKey: output.NewAPIKey, // Only shown once!
OldKeyLastFour: output.OldKeyLastFour,
RotatedAt: output.RotatedAt,
}
h.logger.Info("API key rotated successfully",
zap.String("site_id", siteIDStr),
zap.String("tenant_id", tenantID.String()))
// Write response
httpresponse.OK(w, response)
}

View file

@ -0,0 +1,139 @@
package site
import (
"net/http"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config/constants"
siteservice "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/service/site"
siteusecase "codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/internal/usecase/site"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/pkg/httpresponse"
)
// VerifySiteHandler handles site verification HTTP requests
type VerifySiteHandler struct {
service siteservice.VerifySiteService
logger *zap.Logger
}
// ProvideVerifySiteHandler creates a new VerifySiteHandler
func ProvideVerifySiteHandler(service siteservice.VerifySiteService, logger *zap.Logger) *VerifySiteHandler {
return &VerifySiteHandler{
service: service,
logger: logger,
}
}
// VerifyResponse represents the verification response
// No request body needed - verification is done via DNS TXT record
type VerifyResponse struct {
Success bool `json:"success"`
Status string `json:"status"`
Message string `json:"message"`
}
// contains checks if a string contains a substring (helper for error checking)
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// Handle handles the HTTP request for verifying a site
// Requires JWT authentication and tenant context
func (h *VerifySiteHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Get tenant ID from context
tenantIDStr, ok := r.Context().Value(constants.ContextKeyTenantID).(string)
if !ok {
h.logger.Error("tenant ID not found in context")
httperror.ProblemUnauthorized(w, "Tenant context is required to access this resource.")
return
}
tenantID, err := gocql.ParseUUID(tenantIDStr)
if err != nil {
h.logger.Error("invalid tenant ID format", zap.Error(err))
httperror.ProblemBadRequest(w, "Invalid tenant ID format. Please ensure you have a valid session.")
return
}
// Get site ID from path parameter
siteIDStr := r.PathValue("id")
if siteIDStr == "" {
httperror.ProblemBadRequest(w, "Site ID is required in the request path.")
return
}
// Validate UUID format
siteID, err := gocql.ParseUUID(siteIDStr)
if err != nil {
httperror.ProblemBadRequest(w, "Invalid site ID format. Please provide a valid site ID.")
return
}
// No request body needed - DNS verification uses the token stored in the site entity
// Call service with empty input
input := &siteusecase.VerifySiteInput{}
output, err := h.service.VerifySite(r.Context(), tenantID, siteID, input)
if err != nil {
h.logger.Error("failed to verify site",
zap.Error(err),
zap.String("site_id", siteIDStr),
zap.String("tenant_id", tenantID.String()))
// Check for specific error types
errMsg := err.Error()
if errMsg == "site not found" {
httperror.ProblemNotFound(w, "The requested site could not be found. It may have been deleted or you may not have access to it.")
return
}
// DNS-related errors
if contains(errMsg, "DNS TXT record not found") {
httperror.ProblemBadRequest(w, "DNS TXT record not found. Please add the verification record to your domain's DNS settings and wait 5-10 minutes for propagation.")
return
}
if contains(errMsg, "DNS lookup timed out") {
httperror.ProblemBadRequest(w, "DNS lookup timed out. Please check that your domain's DNS is properly configured.")
return
}
if contains(errMsg, "domain not found") {
httperror.ProblemBadRequest(w, "Domain not found. Please check that your domain is properly registered and DNS is active.")
return
}
if contains(errMsg, "DNS verification failed") {
httperror.ProblemBadRequest(w, "DNS verification failed. Please check your DNS settings and try again.")
return
}
httperror.ProblemInternalServerError(w, "Failed to verify site. Please try again later.")
return
}
// Map to response
response := VerifyResponse{
Success: output.Success,
Status: output.Status,
Message: output.Message,
}
h.logger.Info("site verified successfully",
zap.String("site_id", siteIDStr),
zap.String("tenant_id", tenantID.String()))
// Write response
httpresponse.OK(w, response)
}

Some files were not shown because too many files have changed in this diff Show more