Merge branch 'feature/issue-89-multi-tenant'

This commit is contained in:
michael 2025-08-29 20:06:48 +02:00
commit dfeaca55ae
9 changed files with 781 additions and 135 deletions

1
.gitignore vendored
View file

@ -56,3 +56,4 @@ debug.log
config.local.lua
config.production.lua
config/furt.conf

View file

@ -4,3 +4,5 @@
7e41647c,00b8a18,main,2025-08-19T19:36:33Z,michael,git,lua-api
7e41647c,62ddc17,main,2025-08-20T04:08:04Z,michael,git,lua-api
7e41647c,95dcdba,main,2025-08-28T15:34:36Z,michael,git,lua-api
7ca7e6d6,8ec4019,feature/issue-89-multi-tenant,2025-08-28T17:53:42Z,michael,git,lua-api
25a29c32,5c17c86,feature/issue-89-multi-tenant,2025-08-29T18:01:55Z,michael,git,lua-api

102
config/furt.conf.example Normal file
View file

@ -0,0 +1,102 @@
# furt.conf - Multi-Tenant Configuration Example
# Dragons@Work Digital Sovereignty Project
# Server configuration
[server]
host = 127.0.0.1
port = 7811
log_level = info
log_requests = true
client_timeout = 10
# CORS configuration
cors_allowed_origins = http://localhost:1313,http://127.0.0.1:1313,https://dragons-at-work.de,https://www.dragons-at-work.de
# Security settings
[security]
rate_limit_api_key_max = 60
rate_limit_ip_max = 100
rate_limit_window = 3600
enable_test_endpoint = false
# Default SMTP settings (used when API keys don't have custom SMTP)
[smtp_default]
host = mail.dragons-at-work.de
port = 465
user = noreply@dragons-at-work.de
password = your-smtp-password-here
use_ssl = true
# Dragons@Work Website
[api_key "daw-frontend-key"]
name = "Dragons@Work Website"
permissions = mail:send
allowed_ips = 127.0.0.1, 10.0.0.0/8, 192.168.0.0/16
mail_to = admin@dragons-at-work.de
mail_from = noreply@dragons-at-work.de
mail_subject_prefix = "[DAW Contact] "
# Biocodie Website (same SMTP, different recipient)
[api_key "bio-frontend-key"]
name = "Biocodie Website"
permissions = mail:send
allowed_ips = 127.0.0.1, 10.0.0.0/8
mail_to = contact@biocodie.de
mail_from = noreply@biocodie.de
mail_subject_prefix = "[Biocodie] "
# Verlag Website
[api_key "verlag-frontend-key"]
name = "Verlag Dragons@Work"
permissions = mail:send
allowed_ips = 127.0.0.1, 10.0.0.0/8
mail_to = verlag@dragons-at-work.de
mail_from = noreply@verlag.dragons-at-work.de
mail_subject_prefix = "[Verlag] "
# Customer with custom SMTP
[api_key "kunde-x-frontend-key"]
name = "Kunde X Website"
permissions = mail:send
allowed_ips = 1.2.3.4/32, 5.6.7.8/32
mail_to = info@kunde-x.de
mail_from = noreply@kunde-x.de
mail_subject_prefix = "[Kunde X] "
# Custom SMTP for this customer
mail_smtp_host = mail.kunde-x.de
mail_smtp_port = 587
mail_smtp_user = noreply@kunde-x.de
mail_smtp_pass = kunde-x-smtp-password
mail_smtp_ssl = true
# Customer with external provider (e.g., Gmail)
[api_key "kunde-y-frontend-key"]
name = "Kunde Y Website"
permissions = mail:send
allowed_ips = 9.10.11.12/32
mail_to = support@kunde-y.com
mail_from = website@kunde-y.com
mail_subject_prefix = "[Kunde Y Support] "
# Gmail SMTP example
mail_smtp_host = smtp.gmail.com
mail_smtp_port = 587
mail_smtp_user = website@kunde-y.com
mail_smtp_pass = gmail-app-password
mail_smtp_ssl = true
# Admin API key (full access for management)
[api_key "admin-management-key"]
name = "Admin Access"
permissions = *, mail:send, auth:status
allowed_ips = 127.0.0.1, 10.0.0.0/8
mail_to = admin@dragons-at-work.de
mail_from = furt-admin@dragons-at-work.de
mail_subject_prefix = "[Furt Admin] "
# Monitoring key (limited access)
[api_key "monitoring-health-key"]
name = "Monitoring Service"
permissions = health:check
allowed_ips = 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12
# No mail config needed for monitoring

View file

@ -1,88 +1,133 @@
-- config/server.lua
-- Server configuration for Furt Lua HTTP-Server
-- Multi-Tenant server configuration using nginx-style config parser
-- Dragons@Work Digital Sovereignty Project
return {
-- HTTP Server settings
host = "127.0.0.1",
port = 8080,
local ConfigParser = require("src.config_parser")
-- Timeouts (seconds)
client_timeout = 10,
-- Load configuration from furt.conf
local config = ConfigParser.load_config()
-- CORS Configuration
-- Configure rate limiting from config
local RateLimiter = require("src.rate_limiter")
local rate_limits = {
api_key_max = config.security and config.security.rate_limit_api_key_max or 60,
ip_max = config.security and config.security.rate_limit_ip_max or 100,
window = config.security and config.security.rate_limit_window or 3600
}
RateLimiter:configure(rate_limits)
-- Parse CORS origins from config or environment
local function get_cors_origins()
-- 1. Try config file first
if config.server.cors_allowed_origins then
local origins = {}
for origin in config.server.cors_allowed_origins:gmatch("([^,]+)") do
table.insert(origins, origin:match("^%s*(.-)%s*$"))
end
return origins
end
-- 2. Try environment variable
local env_origins = os.getenv("CORS_ALLOWED_ORIGINS")
if env_origins then
local origins = {}
for origin in env_origins:gmatch("([^,]+)") do
table.insert(origins, origin:match("^%s*(.-)%s*$"))
end
return origins
end
-- 3. Development defaults
return {
"http://localhost:1313", -- Hugo dev server
"http://127.0.0.1:1313", -- Hugo dev server alternative
"http://localhost:3000", -- Common dev port
"http://127.0.0.1:3000" -- Common dev port alternative
}
end
-- Add legacy compatibility and runtime enhancements
local server_config = {
-- HTTP Server settings (from [server] section)
host = config.server.host,
port = config.server.port,
-- Timeouts and limits
client_timeout = config.server.client_timeout or 10,
-- CORS Configuration (prioritize config file over environment)
cors = {
-- Default allowed origins for development
-- Override in production with CORS_ALLOWED_ORIGINS environment variable
allowed_origins = (function()
local env_origins = os.getenv("CORS_ALLOWED_ORIGINS")
if env_origins then
-- Parse comma-separated list from environment
local origins = {}
for origin in env_origins:gmatch("([^,]+)") do
table.insert(origins, origin:match("^%s*(.-)%s*$")) -- trim whitespace
end
return origins
else
-- Default development origins
return {
"http://localhost:1313", -- Hugo dev server
"http://127.0.0.1:1313", -- Hugo dev server alternative
"http://localhost:3000", -- Common dev port
"http://127.0.0.1:3000" -- Common dev port alternative
}
end
end)()
allowed_origins = get_cors_origins()
},
-- Logging
log_level = "info",
log_requests = true,
log_level = config.server.log_level or "info",
log_requests = config.server.log_requests or true,
-- API-Key-Authentifizierung (PRODUCTION READY)
api_keys = {
-- Hugo Frontend API-Key (für Website-Formulare)
[os.getenv("HUGO_API_KEY") or "hugo-dev-key-change-in-production"] = {
name = "Hugo Frontend",
permissions = {"mail:send"},
allowed_ips = {
"127.0.0.1", -- Localhost
"10.0.0.0/8", -- Private network
"192.168.0.0/16", -- Private network
"172.16.0.0/12" -- Private network
}
},
-- Admin API-Key (für Testing und Management)
[os.getenv("ADMIN_API_KEY") or "admin-dev-key-change-in-production"] = {
name = "Admin Access",
permissions = {"*"}, -- All permissions
allowed_ips = {
"127.0.0.1", -- Local only for admin
"10.0.0.0/8" -- Internal network
}
},
-- Optional: Monitoring API-Key (nur Health-Checks)
[os.getenv("MONITORING_API_KEY") or "monitoring-dev-key"] = {
name = "Monitoring Service",
permissions = {"monitoring:health"},
allowed_ips = {
"127.0.0.1",
"10.0.0.0/8",
"172.16.0.0/12"
}
}
-- Security settings
security = {
enable_test_endpoint = config.security and config.security.enable_test_endpoint or false,
rate_limits = rate_limits
},
-- Mail configuration (for SMTP integration)
mail = {
smtp_server = os.getenv("SMTP_HOST") or "mail.example.org",
smtp_port = tonumber(os.getenv("SMTP_PORT")) or 465,
use_ssl = true,
username = os.getenv("SMTP_USERNAME"),
password = os.getenv("SMTP_PASSWORD"),
from_address = os.getenv("SMTP_FROM") or "noreply@example.org",
to_address = os.getenv("SMTP_TO") or "admin@example.org"
}
-- API Keys (converted from nginx-style to old format for backward compatibility)
api_keys = config.api_keys,
-- Default SMTP config (for legacy compatibility)
mail = config.smtp_default,
-- Multi-tenant mail configuration function
get_mail_config_for_api_key = function(api_key)
return ConfigParser.get_mail_config_for_api_key(config, api_key)
end,
-- Raw config access (for advanced usage)
raw_config = config
}
-- Print configuration summary on load
print("Furt Multi-Tenant Configuration Loaded:")
print(" Server: " .. server_config.host .. ":" .. server_config.port)
print(" Log Level: " .. server_config.log_level)
-- Print CORS configuration
print(" CORS Origins:")
for i, origin in ipairs(server_config.cors.allowed_origins) do
print(" " .. i .. ": " .. origin)
end
-- Print security configuration
print(" Test Endpoint: " .. (server_config.security.enable_test_endpoint and "enabled" or "disabled"))
print(" Default SMTP: " .. (config.smtp_default.host or "not configured"))
-- Print API key information
local api_key_count = 0
for key_name, key_config in pairs(config.api_keys) do
api_key_count = api_key_count + 1
-- Check if this API key has mail permissions
local has_mail_permission = false
if key_config.permissions then
for _, perm in ipairs(key_config.permissions) do
if perm == "mail:send" or perm == "*" then
has_mail_permission = true
break
end
end
end
local smtp_info = ""
if key_config.mail_smtp_host then
smtp_info = " (custom SMTP: " .. key_config.mail_smtp_host .. ")"
end
if has_mail_permission then
print(" API Key: " .. key_config.name .. " -> " .. key_config.mail_to .. smtp_info)
else
print(" API Key: " .. key_config.name .. " (no mail)" .. smtp_info)
end
end
print(" Total API Keys: " .. api_key_count)
return server_config

176
docs/setup-guide.md Normal file
View file

@ -0,0 +1,176 @@
# Multi-Tenant furt Setup-Anleitung
## Installation
### 1. Dateien platzieren
```bash
# OpenBSD/FreeBSD
mkdir -p /usr/local/etc/furt
mkdir -p /usr/local/share/furt
# Oder Linux
mkdir -p /etc/furt
mkdir -p /usr/local/share/furt
# Source code
cp -r src/ /usr/local/share/furt/
cp -r config/ /usr/local/share/furt/
```
### 2. Konfiguration erstellen
```bash
# Beispiel-Config kopieren und anpassen
# OpenBSD/FreeBSD:
cp furt.conf.example /usr/local/etc/furt/furt.conf
# Linux:
cp furt.conf.example /etc/furt/furt.conf
# Config editieren
vi /usr/local/etc/furt/furt.conf # oder /etc/furt/furt.conf
```
### 3. Start-Script
```bash
#!/bin/sh
# /usr/local/bin/furt
cd /usr/local/share/furt
lua src/main.lua
```
## Multi-Tenant Konfiguration
### Beispiel für 3 Websites
```ini
[server]
host = 127.0.0.1
port = 8080
[smtp_default]
host = mail.dragons-at-work.de
port = 465
user = noreply@dragons-at-work.de
password = your-smtp-password
# Website 1: Dragons@Work
[api_key "daw-key-abc123"]
name = "Dragons@Work Website"
permissions = mail:send
allowed_ips = 1.2.3.4/32, 10.0.0.0/8
mail_to = admin@dragons-at-work.de
mail_from = noreply@dragons-at-work.de
mail_subject_prefix = "[DAW] "
# Website 2: Biocodie (gleiche SMTP, andere Empfänger)
[api_key "bio-key-def456"]
name = "Biocodie Website"
permissions = mail:send
allowed_ips = 5.6.7.8/32
mail_to = contact@biocodie.de
mail_from = noreply@biocodie.de
mail_subject_prefix = "[Biocodie] "
# Website 3: Kunde mit eigenem SMTP
[api_key "kunde-key-ghi789"]
name = "Kunde X Website"
permissions = mail:send
allowed_ips = 9.10.11.12/32
mail_to = info@kunde-x.de
mail_from = noreply@kunde-x.de
mail_smtp_host = mail.kunde-x.de
mail_smtp_user = noreply@kunde-x.de
mail_smtp_pass = kunde-smtp-password
```
## Admin-Workflow
### Neue Website hinzufügen
1. **Config editieren:**
```bash
vi /usr/local/etc/furt/furt.conf
```
2. **Neuen API-Key-Block hinzufügen:**
```ini
[api_key "neue-website-key"]
name = "Neue Website"
permissions = mail:send
allowed_ips = 12.34.56.78/32
mail_to = contact@neue-website.de
mail_from = noreply@neue-website.de
```
3. **furt neu starten:**
```bash
systemctl restart furt
# oder
pkill -f "lua.*main.lua" && /usr/local/bin/furt &
```
### Website testen
```bash
# Test mit curl
curl -X POST http://localhost:8080/v1/mail/send \
-H "X-API-Key: neue-website-key" \
-H "Content-Type: application/json" \
-d '{
"name": "Test User",
"email": "test@example.com",
"subject": "Test Message",
"message": "This is a test message"
}'
```
## Vorteile des Multi-Tenant-Systems
### ✅ Ein Server, viele Websites
- Alle Websites nutzen eine furt-Instanz
- Jede Website hat eigenen API-Key
- Verschiedene Empfänger-Adressen
- Verschiedene SMTP-Server möglich
### ✅ Admin-freundlich
- Nginx-style Config-Format
- Einfach neue Websites hinzufügen
- Klare Struktur pro Website
- Kommentare möglich
### ✅ Sicher
- IP-Restrictions pro Website
- Permissions pro API-Key
- Separate SMTP-Credentials möglich
- Rate-Limiting bleibt erhalten
### ✅ Flexibel
- Default SMTP + website-spezifische SMTP
- Subject-Prefix pro Website
- Verschiedene Mail-Adressen
- Beliebig viele Websites
## Backward Compatibility
Das neue System ist **vollständig kompatibel** mit der alten config/server.lua API. Bestehende Module (auth.lua, main.lua, etc.) funktionieren ohne Änderungen.
## Troubleshooting
### Config-Parsing-Fehler
```bash
# Config-Syntax prüfen
lua -e "require('src.config_parser').parse_file('/usr/local/etc/furt/furt.conf')"
```
### Mail-Routing testen
```bash
# Logs anschauen
tail -f /var/log/furt.log
# Debug-Mode
FURT_DEBUG=true lua src/main.lua
```

237
src/config_parser.lua Normal file
View file

@ -0,0 +1,237 @@
-- src/config_parser.lua
-- nginx-style configuration parser for Multi-Tenant setup
-- Dragons@Work Digital Sovereignty Project
-- Lua 5.1 compatible (no goto statements)
local ConfigParser = {}
-- Parse nginx-style config file
function ConfigParser.parse_file(config_path)
local file = io.open(config_path, "r")
if not file then
error("Could not open config file: " .. config_path)
end
local config = {
server = {},
api_keys = {},
smtp_default = {}
}
local current_section = nil
local current_api_key = nil
local line_number = 0
for line in file:lines() do
line_number = line_number + 1
-- Skip empty lines and comments
line = line:match("^%s*(.-)%s*$") -- trim whitespace
if not (line == "" or line:match("^#")) then
-- Section headers: [section] or [api_key "keyname"]
local section_match = line:match("^%[([^%]]+)%]$")
if section_match then
if section_match:match("^api_key") then
-- Extract API key from [api_key "keyname"]
local key_name = section_match:match('^api_key%s+"([^"]+)"$')
if not key_name then
error(string.format("Invalid api_key section at line %d: %s", line_number, line))
end
current_api_key = key_name
current_section = "api_key"
config.api_keys[key_name] = {}
else
current_section = section_match
current_api_key = nil
if not config[current_section] then
config[current_section] = {}
end
end
else
-- Key-value pairs: key = value
local key, value = line:match("^([^=]+)=(.+)$")
if key and value then
key = key:match("^%s*(.-)%s*$") -- trim
value = value:match("^%s*(.-)%s*$") -- trim
-- Remove quotes from value if present
value = value:match('^"(.*)"$') or value
if current_section == "api_key" and current_api_key then
ConfigParser.set_api_key_value(config.api_keys[current_api_key], key, value)
elseif current_section then
ConfigParser.set_config_value(config[current_section], key, value)
else
error(string.format("Key-value pair outside section at line %d: %s", line_number, line))
end
else
error(string.format("Invalid line format at line %d: %s", line_number, line))
end
end
end
end
file:close()
-- Validate required sections
ConfigParser.validate_config(config)
return config
end
-- Set configuration value with type conversion
function ConfigParser.set_config_value(section, key, value)
-- Convert numeric values
local num_value = tonumber(value)
if num_value then
section[key] = num_value
return
end
-- Convert boolean values
if value:lower() == "true" then
section[key] = true
return
elseif value:lower() == "false" then
section[key] = false
return
end
-- Keep as string
section[key] = value
end
-- Set API key configuration value
function ConfigParser.set_api_key_value(api_key_config, key, value)
-- Handle special multi-value fields
if key == "permissions" then
api_key_config.permissions = {}
for perm in value:gmatch("([^,]+)") do
table.insert(api_key_config.permissions, perm:match("^%s*(.-)%s*$"))
end
return
end
if key == "allowed_ips" then
api_key_config.allowed_ips = {}
for ip in value:gmatch("([^,]+)") do
table.insert(api_key_config.allowed_ips, ip:match("^%s*(.-)%s*$"))
end
return
end
-- Regular key-value assignment with type conversion
ConfigParser.set_config_value(api_key_config, key, value)
end
-- Validate required configuration
function ConfigParser.validate_config(config)
-- Check required server settings
if not config.server.port then
error("server.port is required")
end
if not config.server.host then
config.server.host = "127.0.0.1" -- default
end
-- Check that we have at least one API key
local key_count = 0
for _ in pairs(config.api_keys) do
key_count = key_count + 1
end
if key_count == 0 then
print("Warning: No API keys configured")
end
-- Validate each API key
for key_name, key_config in pairs(config.api_keys) do
if not key_config.name then
error("API key '" .. key_name .. "' missing name")
end
if not key_config.permissions then
key_config.permissions = {} -- empty permissions
end
if not key_config.allowed_ips then
key_config.allowed_ips = {} -- no IP restrictions
end
-- Validate mail configuration only if API key has mail:send permission
local has_mail_permission = false
if key_config.permissions then
for _, perm in ipairs(key_config.permissions) do
if perm == "mail:send" or perm == "*" then
has_mail_permission = true
break
end
end
end
if has_mail_permission then
if not key_config.mail_to then
error("API key '" .. key_name .. "' missing mail_to")
end
if not key_config.mail_from then
error("API key '" .. key_name .. "' missing mail_from")
end
end
end
-- Set SMTP defaults if not configured
if not config.smtp_default.host then
config.smtp_default.host = "localhost"
config.smtp_default.port = 25
print("Warning: No default SMTP configured, using localhost:25")
end
end
-- Get mail configuration for specific API key
function ConfigParser.get_mail_config_for_api_key(config, api_key)
local key_config = config.api_keys[api_key]
if not key_config then
return nil, "API key not found"
end
return {
-- Recipient and sender
to_address = key_config.mail_to,
from_address = key_config.mail_from,
subject_prefix = key_config.mail_subject_prefix or "",
-- SMTP settings: key-specific or default
smtp_server = key_config.mail_smtp_host or config.smtp_default.host,
smtp_port = key_config.mail_smtp_port or config.smtp_default.port,
username = key_config.mail_smtp_user or config.smtp_default.user,
password = key_config.mail_smtp_pass or config.smtp_default.password,
use_ssl = key_config.mail_smtp_ssl or config.smtp_default.use_ssl or true
}
end
-- Load configuration from file with fallback
function ConfigParser.load_config()
-- Try different locations based on OS
local config_paths = {
"/usr/local/etc/furt/furt.conf", -- OpenBSD/FreeBSD
"/etc/furt/furt.conf", -- Linux
"config/furt.conf", -- Development
"furt.conf" -- Current directory
}
for _, path in ipairs(config_paths) do
local file = io.open(path, "r")
if file then
file:close()
print("Loading config from: " .. path)
return ConfigParser.parse_file(path)
end
end
error("No configuration file found. Tried: " .. table.concat(config_paths, ", "))
end
return ConfigParser

View file

@ -1,4 +1,4 @@
-- furt-lua/src/main.lua
-- src/main.lua
-- Pure Lua HTTP-Server for Furt API-Gateway
-- Dragons@Work Digital Sovereignty Project
@ -256,8 +256,17 @@ function FurtServer:start()
print("Content-Hash: " .. (version_info.content_hash or "unknown"))
print("VCS: " .. (version_info.vcs_info and version_info.vcs_info.hash or "none"))
print("API-Key authentication: ENABLED")
print("Rate limiting: ENABLED (60 req/hour per API key, 100 req/hour per IP)")
print("CORS enabled for configured origins")
-- Show actual configured rate limits
local rate_limits = config.security and config.security.rate_limits
if rate_limits then
print(string.format("Rate limiting: ENABLED (%d req/hour per API key, %d req/hour per IP)",
rate_limits.api_key_max, rate_limits.ip_max))
else
print("Rate limiting: ENABLED (default values)")
end
print("CORS enabled for " .. (#config.cors.allowed_origins) .. " configured origins")
print("Press Ctrl+C to stop")
while true do
@ -285,23 +294,24 @@ server:add_route("GET", "/health", function(request, server)
timestamp = os.time(),
source = version_info.source,
features = {
smtp_configured = config.mail and config.mail.username ~= nil,
smtp_configured = config.smtp_default and config.smtp_default.host ~= nil,
auth_enabled = true,
rate_limiting = true,
rate_limits = config.security and config.security.rate_limits,
merkwerk_integrated = version_info.source == "merkwerk"
}
}
return server:create_response(200, response_data, nil, nil, request)
end)
-- Test endpoint for development (disable in production)
if os.getenv("ENABLE_TEST_ENDPOINT") == "true" then
-- Test endpoint for development (configurable via furt.conf)
if config.security and config.security.enable_test_endpoint then
server:add_route("POST", "/test", function(request, server)
local response_data = {
message = "Test endpoint working",
received_data = request.body,
headers_count = 0,
warning = "This is a development endpoint"
warning = "This is a development endpoint (enabled via config)"
}
-- Count headers
@ -311,7 +321,7 @@ if os.getenv("ENABLE_TEST_ENDPOINT") == "true" then
return server:create_response(200, response_data, nil, nil, request)
end)
print("[WARN] Test endpoint enabled (development mode)")
print("[WARN] Test endpoint enabled via configuration")
end
-- Protected routes (require authentication)

View file

@ -1,4 +1,4 @@
-- furt-lua/src/rate_limiter.lua
-- src/rate_limiter.lua
-- Rate limiting system for API requests
-- Dragons@Work Digital Sovereignty Project
@ -6,8 +6,8 @@ local RateLimiter = {
requests = {}, -- {api_key = {timestamps}, ip = {timestamps}}
cleanup_interval = 300, -- Cleanup every 5 minutes
last_cleanup = os.time(),
-- Default limits
-- Default limits (configurable)
default_limits = {
api_key_max = 60, -- 60 requests per hour per API key
ip_max = 100, -- 100 requests per hour per IP
@ -15,15 +15,35 @@ local RateLimiter = {
}
}
-- Configure rate limits from config
function RateLimiter:configure(limits)
if limits then
if limits.api_key_max then
self.default_limits.api_key_max = limits.api_key_max
end
if limits.ip_max then
self.default_limits.ip_max = limits.ip_max
end
if limits.window then
self.default_limits.window = limits.window
end
print("Rate limiting configured:")
print(" API Key limit: " .. self.default_limits.api_key_max .. " req/hour")
print(" IP limit: " .. self.default_limits.ip_max .. " req/hour")
print(" Window: " .. self.default_limits.window .. " seconds")
end
end
-- Cleanup old requests from memory
function RateLimiter:cleanup_old_requests()
local now = os.time()
if now - self.last_cleanup < self.cleanup_interval then
return
end
local cutoff = now - self.default_limits.window
for key, timestamps in pairs(self.requests) do
local filtered = {}
for _, timestamp in ipairs(timestamps) do
@ -33,21 +53,21 @@ function RateLimiter:cleanup_old_requests()
end
self.requests[key] = filtered
end
self.last_cleanup = now
end
-- Check if request is within rate limit
function RateLimiter:check_rate_limit(key, max_requests, window_seconds)
self:cleanup_old_requests()
local now = os.time()
local cutoff = now - (window_seconds or self.default_limits.window)
if not self.requests[key] then
self.requests[key] = {}
end
-- Count requests in time window
local count = 0
for _, timestamp in ipairs(self.requests[key]) do
@ -55,15 +75,15 @@ function RateLimiter:check_rate_limit(key, max_requests, window_seconds)
count = count + 1
end
end
-- Check if limit exceeded
if count >= max_requests then
return false, count, max_requests - count
end
-- Record this request
table.insert(self.requests[key], now)
return true, count + 1, max_requests - (count + 1)
end
@ -71,11 +91,11 @@ end
function RateLimiter:check_api_and_ip_limits(api_key, client_ip)
-- Check API key rate limit
local api_key_allowed, api_count, api_remaining = self:check_rate_limit(
"api_key:" .. api_key,
self.default_limits.api_key_max,
"api_key:" .. api_key,
self.default_limits.api_key_max,
self.default_limits.window
)
if not api_key_allowed then
return false, "API key rate limit exceeded", {
type = "api_key",
@ -84,14 +104,14 @@ function RateLimiter:check_api_and_ip_limits(api_key, client_ip)
remaining = api_remaining
}
end
-- Check IP rate limit
local ip_allowed, ip_count, ip_remaining = self:check_rate_limit(
"ip:" .. client_ip,
self.default_limits.ip_max,
"ip:" .. client_ip,
self.default_limits.ip_max,
self.default_limits.window
)
if not ip_allowed then
return false, "IP rate limit exceeded", {
type = "ip",
@ -100,7 +120,7 @@ function RateLimiter:check_api_and_ip_limits(api_key, client_ip)
remaining = ip_remaining
}
end
-- Both limits OK
return true, "OK", {
api_key = {
@ -121,7 +141,7 @@ function RateLimiter:get_rate_limit_headers(limit_info)
if not limit_info or not limit_info.api_key then
return {}
end
return {
["X-RateLimit-Remaining"] = tostring(limit_info.api_key.remaining or 0),
["X-RateLimit-Limit"] = tostring(self.default_limits.api_key_max),

View file

@ -1,5 +1,6 @@
-- furt-lua/src/routes/mail.lua
-- Mail service route handler
-- src/routes/mail.lua
-- Multi-Tenant Mail service route handler
-- API-Key determines mail configuration and recipient
-- Dragons@Work Digital Sovereignty Project
local cjson = require("cjson")
@ -19,25 +20,25 @@ local function validate_mail_data(data)
if not data.name or type(data.name) ~= "string" or data.name:match("^%s*$") then
return false, "Name is required and cannot be empty"
end
if not data.email or not validate_email(data.email) then
return false, "Valid email address is required"
end
if not data.message or type(data.message) ~= "string" or data.message:match("^%s*$") then
return false, "Message is required and cannot be empty"
end
-- Optional subject validation
if data.subject and (type(data.subject) ~= "string" or #data.subject > 200) then
return false, "Subject must be a string with max 200 characters"
end
-- Message length validation
if #data.message > 5000 then
return false, "Message too long (max 5000 characters)"
end
return true
end
@ -46,64 +47,116 @@ local function generate_request_id()
return os.time() .. "-" .. math.random(1000, 9999)
end
-- Mail service handler
-- Get tenant-specific mail configuration
local function get_tenant_mail_config(api_key)
local mail_config, error_msg = config.get_mail_config_for_api_key(api_key)
if not mail_config then
return nil, error_msg or "No mail configuration found for API key"
end
-- Validate essential mail configuration
if not mail_config.to_address then
return nil, "No recipient configured for this API key"
end
if not mail_config.from_address then
return nil, "No sender address configured for this API key"
end
return mail_config, nil
end
-- Multi-Tenant Mail service handler
function MailRoute.handle_mail_send(request, server)
print("Mail endpoint called - Method: " .. request.method .. ", Path: " .. request.path)
print("Authenticated as: " .. request.auth.key_name .. " (" .. request.auth.api_key .. ")")
-- Basic request validation
if not request.body or request.body == "" then
return {error = "No request body", code = "MISSING_BODY"}
end
-- Parse JSON
local success, data = pcall(cjson.decode, request.body)
if not success then
return {error = "Invalid JSON", body = request.body, code = "INVALID_JSON"}
end
-- Validate mail data
local valid, error_message = validate_mail_data(data)
if not valid then
return {error = error_message, code = "VALIDATION_ERROR"}
end
-- Get tenant-specific mail configuration
local tenant_mail_config, config_error = get_tenant_mail_config(request.auth.api_key)
if not tenant_mail_config then
print("Mail config error for API key " .. request.auth.api_key .. ": " .. config_error)
return server:create_response(500, {
error = "Mail configuration error: " .. config_error,
code = "CONFIG_ERROR"
}, nil, nil, request)
end
-- Generate request ID for tracking
local request_id = generate_request_id()
-- Prepare email content
-- Apply tenant-specific subject prefix
local subject = data.subject or "Contact Form Message"
if tenant_mail_config.subject_prefix and tenant_mail_config.subject_prefix ~= "" then
subject = tenant_mail_config.subject_prefix .. subject
end
-- Prepare email content with tenant information
local email_content = string.format(
"From: %s <%s>\nSubject: %s\n\n%s",
data.name, data.email, subject, data.message
"Website: %s (%s)\nFrom: %s <%s>\nSubject: %s\n\n%s\n\n---\nSent via Furt Gateway\nAPI Key: %s\nRequest ID: %s",
request.auth.key_name,
request.auth.api_key,
data.name,
data.email,
data.subject or "Contact Form Message",
data.message,
request.auth.key_name,
request_id
)
-- Send email via SMTP
-- Send email via SMTP using tenant-specific configuration
local SMTP = require("src.smtp")
local smtp_client = SMTP:new(config.mail)
local smtp_client = SMTP:new(tenant_mail_config)
print("Sending mail for tenant: " .. request.auth.key_name)
print(" To: " .. tenant_mail_config.to_address)
print(" From: " .. tenant_mail_config.from_address)
print(" SMTP: " .. tenant_mail_config.smtp_server .. ":" .. tenant_mail_config.smtp_port)
local smtp_success, smtp_result = smtp_client:send_email(
config.mail.to_address,
subject,
email_content,
tenant_mail_config.to_address,
subject,
email_content,
data.name
)
if smtp_success then
-- Success response
-- Success response with tenant information
return {
success = true,
message = "Mail sent successfully",
success = true,
message = "Mail sent successfully",
request_id = request_id,
api_key_name = request.auth.key_name
tenant = {
name = request.auth.key_name,
recipient = tenant_mail_config.to_address,
smtp_server = tenant_mail_config.smtp_server
}
}
else
-- SMTP error - log and return error
print("SMTP Error: " .. tostring(smtp_result))
print("SMTP Error for tenant " .. request.auth.key_name .. ": " .. tostring(smtp_result))
return server:create_response(500, {
success = false,
error = "Failed to send email: " .. tostring(smtp_result),
success = false,
error = "Failed to send email: " .. tostring(smtp_result),
request_id = request_id,
tenant = request.auth.key_name,
code = "SMTP_ERROR"
}, nil, nil, request)
end