refactor: clean repository structure for v0.1.0 open source release
- Remove Go artifacts (cmd/, internal/, pkg/, go.mod) - Move furt-lua/* content to repository root - Restructure as clean src/, config/, scripts/, tests/ layout - Rewrite README.md as practical tool documentation - Remove timeline references and marketing language - Clean .gitignore from Go-era artifacts - Update config/server.lua with example.org defaults - Add .env.production to .gitignore for security Repository now ready for open source distribution with minimal, focused structure and generic configuration templates. close issue DAW/furt#86
This commit is contained in:
parent
87c935379b
commit
be3b9614d0
38 changed files with 280 additions and 5892 deletions
139
src/auth.lua
Normal file
139
src/auth.lua
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
-- furt-lua/src/auth.lua
|
||||
-- API Key authentication system
|
||||
-- Dragons@Work Digital Sovereignty Project
|
||||
|
||||
local IpUtils = require("src.ip_utils")
|
||||
local RateLimiter = require("src.rate_limiter")
|
||||
|
||||
local Auth = {}
|
||||
|
||||
-- Load configuration
|
||||
local config = require("config.server")
|
||||
|
||||
-- Authenticate incoming request
|
||||
function Auth.authenticate_request(request)
|
||||
local api_key = request.headers["x-api-key"]
|
||||
|
||||
if not api_key then
|
||||
return false, "Missing X-API-Key header", 401
|
||||
end
|
||||
|
||||
-- Check if API key exists in config
|
||||
local key_config = config.api_keys and config.api_keys[api_key]
|
||||
if not key_config then
|
||||
return false, "Invalid API key", 401
|
||||
end
|
||||
|
||||
-- Get client IP
|
||||
local client_ip = IpUtils.get_client_ip(request)
|
||||
|
||||
-- Check IP restrictions
|
||||
if not IpUtils.is_ip_allowed(client_ip, key_config.allowed_ips) then
|
||||
return false, "IP address not allowed", 403
|
||||
end
|
||||
|
||||
-- Check rate limits
|
||||
local rate_ok, rate_message, rate_info = RateLimiter:check_api_and_ip_limits(api_key, client_ip)
|
||||
if not rate_ok then
|
||||
return false, rate_message, 429, rate_info
|
||||
end
|
||||
|
||||
-- Return auth context
|
||||
return true, {
|
||||
api_key = api_key,
|
||||
key_name = key_config.name,
|
||||
permissions = key_config.permissions or {},
|
||||
client_ip = client_ip,
|
||||
rate_info = rate_info
|
||||
}
|
||||
end
|
||||
|
||||
-- Check if user has specific permission
|
||||
function Auth.has_permission(auth_context, required_permission)
|
||||
if not auth_context or not auth_context.permissions then
|
||||
return false
|
||||
end
|
||||
|
||||
-- No permission required = always allow for authenticated users
|
||||
if not required_permission then
|
||||
return true
|
||||
end
|
||||
|
||||
-- Check for specific permission or wildcard
|
||||
for _, permission in ipairs(auth_context.permissions) do
|
||||
if permission == required_permission or permission == "*" then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
-- Create auth middleware wrapper for route handlers
|
||||
function Auth.create_protected_route(required_permission, handler)
|
||||
return function(request, server)
|
||||
-- Authenticate request
|
||||
local auth_success, auth_result, status_code, rate_info = Auth.authenticate_request(request)
|
||||
|
||||
if not auth_success then
|
||||
local error_response = {
|
||||
error = auth_result,
|
||||
timestamp = os.time()
|
||||
}
|
||||
|
||||
-- Add rate limit info to error if available
|
||||
if rate_info then
|
||||
error_response.rate_limit = rate_info
|
||||
end
|
||||
|
||||
return server:create_response(status_code or 401, error_response, nil, nil, request)
|
||||
end
|
||||
|
||||
-- Check permissions
|
||||
if required_permission and not Auth.has_permission(auth_result, required_permission) then
|
||||
return server:create_response(403, {
|
||||
error = "Insufficient permissions",
|
||||
required = required_permission,
|
||||
available = auth_result.permissions
|
||||
}, nil, nil, request)
|
||||
end
|
||||
|
||||
-- Add auth context to request
|
||||
request.auth = auth_result
|
||||
|
||||
-- Get rate limit headers
|
||||
local rate_headers = RateLimiter:get_rate_limit_headers(auth_result.rate_info)
|
||||
|
||||
-- Call original handler
|
||||
local result = handler(request, server)
|
||||
|
||||
-- If result is a string (already formatted response), return as-is
|
||||
if type(result) == "string" then
|
||||
return result
|
||||
end
|
||||
|
||||
-- If handler returned data, create response with rate limit headers
|
||||
return server:create_response(200, result, "application/json", rate_headers, request)
|
||||
end
|
||||
end
|
||||
|
||||
-- Get authentication status for debug/monitoring
|
||||
function Auth.get_auth_status(auth_context)
|
||||
if not auth_context then
|
||||
return {
|
||||
authenticated = false
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
authenticated = true,
|
||||
api_key_name = auth_context.key_name,
|
||||
permissions = auth_context.permissions,
|
||||
client_ip = auth_context.client_ip,
|
||||
rate_limit_remaining = auth_context.rate_info and auth_context.rate_info.api_key and auth_context.rate_info.api_key.remaining,
|
||||
ip_rate_limit_remaining = auth_context.rate_info and auth_context.rate_info.ip and auth_context.rate_info.ip.remaining
|
||||
}
|
||||
end
|
||||
|
||||
return Auth
|
||||
|
||||
117
src/ip_utils.lua
Normal file
117
src/ip_utils.lua
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
-- furt-lua/src/ip_utils.lua
|
||||
-- IP address and CIDR utilities
|
||||
-- Dragons@Work Digital Sovereignty Project
|
||||
|
||||
local IpUtils = {}
|
||||
|
||||
-- Simple bitwise AND for Lua 5.1 compatibility
|
||||
local function bitwise_and(a, b)
|
||||
local result = 0
|
||||
local bit = 1
|
||||
while a > 0 or b > 0 do
|
||||
if (a % 2 == 1) and (b % 2 == 1) then
|
||||
result = result + bit
|
||||
end
|
||||
a = math.floor(a / 2)
|
||||
b = math.floor(b / 2)
|
||||
bit = bit * 2
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
-- Create subnet mask for given CIDR bits
|
||||
local function create_mask(mask_bits)
|
||||
if mask_bits >= 32 then
|
||||
return 0xFFFFFFFF
|
||||
elseif mask_bits <= 0 then
|
||||
return 0
|
||||
else
|
||||
-- Create mask: 32-bit with 'mask_bits' ones from left
|
||||
local mask = 0
|
||||
for i = 0, mask_bits - 1 do
|
||||
mask = mask + math.pow(2, 31 - i)
|
||||
end
|
||||
return mask
|
||||
end
|
||||
end
|
||||
|
||||
-- CIDR IP matching function (Lua 5.1 compatible)
|
||||
function IpUtils.ip_matches_cidr(ip, cidr)
|
||||
if not cidr:find("/") then
|
||||
-- No subnet mask, direct comparison
|
||||
return ip == cidr
|
||||
end
|
||||
|
||||
local network, mask_bits = cidr:match("([^/]+)/(%d+)")
|
||||
if not network or not mask_bits then
|
||||
return false
|
||||
end
|
||||
|
||||
mask_bits = tonumber(mask_bits)
|
||||
|
||||
-- Simple IPv4 CIDR matching
|
||||
if ip:find("%.") and network:find("%.") then
|
||||
-- Convert IPv4 to number
|
||||
local function ip_to_num(ip_str)
|
||||
local parts = {}
|
||||
for part in ip_str:gmatch("(%d+)") do
|
||||
table.insert(parts, tonumber(part))
|
||||
end
|
||||
if #parts == 4 then
|
||||
return (parts[1] * 16777216) + (parts[2] * 65536) + (parts[3] * 256) + parts[4]
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
local ip_num = ip_to_num(ip)
|
||||
local network_num = ip_to_num(network)
|
||||
|
||||
-- Create subnet mask
|
||||
local mask = create_mask(mask_bits)
|
||||
|
||||
-- Apply mask to both IPs and compare
|
||||
return bitwise_and(ip_num, mask) == bitwise_and(network_num, mask)
|
||||
end
|
||||
|
||||
-- Fallback: if CIDR parsing fails, allow if IP matches network part
|
||||
return ip == network or ip:find("^" .. network:gsub("%.", "%%."))
|
||||
end
|
||||
|
||||
-- Check if IP is in allowed list
|
||||
function IpUtils.is_ip_allowed(client_ip, allowed_ips)
|
||||
if not allowed_ips or #allowed_ips == 0 then
|
||||
return true -- No restrictions
|
||||
end
|
||||
|
||||
for _, allowed_cidr in ipairs(allowed_ips) do
|
||||
if IpUtils.ip_matches_cidr(client_ip, allowed_cidr) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
-- Extract client IP (considering proxies)
|
||||
function IpUtils.get_client_ip(request)
|
||||
-- Check for forwarded IP headers first
|
||||
local forwarded_for = request.headers["x-forwarded-for"]
|
||||
if forwarded_for then
|
||||
-- Take first IP from comma-separated list
|
||||
local first_ip = forwarded_for:match("([^,]+)")
|
||||
if first_ip then
|
||||
return first_ip:match("^%s*(.-)%s*$") -- trim whitespace
|
||||
end
|
||||
end
|
||||
|
||||
local real_ip = request.headers["x-real-ip"]
|
||||
if real_ip then
|
||||
return real_ip
|
||||
end
|
||||
|
||||
-- Fallback to connection IP (would need socket info, defaulting to localhost for now)
|
||||
return "127.0.0.1"
|
||||
end
|
||||
|
||||
return IpUtils
|
||||
|
||||
288
src/main.lua
Normal file
288
src/main.lua
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
-- furt-lua/src/main.lua
|
||||
-- Pure Lua HTTP-Server for Furt API-Gateway
|
||||
-- Dragons@Work Digital Sovereignty Project
|
||||
|
||||
local socket = require("socket")
|
||||
local cjson = require("cjson")
|
||||
|
||||
-- Load modules
|
||||
local Auth = require("src.auth")
|
||||
local MailRoute = require("src.routes.mail")
|
||||
local AuthRoute = require("src.routes.auth")
|
||||
|
||||
-- Load configuration
|
||||
local config = require("config.server")
|
||||
|
||||
-- HTTP-Server Module
|
||||
local FurtServer = {}
|
||||
|
||||
function FurtServer:new()
|
||||
local instance = {
|
||||
server = nil,
|
||||
port = config.port or 8080,
|
||||
host = config.host or "127.0.0.1",
|
||||
routes = {}
|
||||
}
|
||||
setmetatable(instance, self)
|
||||
self.__index = self
|
||||
return instance
|
||||
end
|
||||
|
||||
-- Add route handler
|
||||
function FurtServer:add_route(method, path, handler)
|
||||
if not self.routes[method] then
|
||||
self.routes[method] = {}
|
||||
end
|
||||
self.routes[method][path] = handler
|
||||
end
|
||||
|
||||
-- Add protected route (requires authentication)
|
||||
function FurtServer:add_protected_route(method, path, required_permission, handler)
|
||||
self:add_route(method, path, Auth.create_protected_route(required_permission, handler))
|
||||
end
|
||||
|
||||
-- Parse HTTP request
|
||||
function FurtServer:parse_request(client)
|
||||
local request_line = client:receive()
|
||||
if not request_line then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Parse request line: "POST /v1/mail/send HTTP/1.1"
|
||||
local method, path, protocol = request_line:match("(%w+) (%S+) (%S+)")
|
||||
if not method then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Parse headers
|
||||
local headers = {}
|
||||
local content_length = 0
|
||||
|
||||
while true do
|
||||
local line = client:receive()
|
||||
if not line or line == "" then
|
||||
break
|
||||
end
|
||||
|
||||
local key, value = line:match("([^:]+): (.+)")
|
||||
if key and value then
|
||||
headers[key:lower()] = value
|
||||
if key:lower() == "content-length" then
|
||||
content_length = tonumber(value) or 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Parse body
|
||||
local body = ""
|
||||
if content_length > 0 then
|
||||
body = client:receive(content_length)
|
||||
end
|
||||
|
||||
return {
|
||||
method = method,
|
||||
path = path,
|
||||
protocol = protocol,
|
||||
headers = headers,
|
||||
body = body,
|
||||
content_length = content_length
|
||||
}
|
||||
end
|
||||
|
||||
-- Add CORS headers with configurable origins
|
||||
function FurtServer:add_cors_headers(request)
|
||||
local allowed_origins = config.cors and config.cors.allowed_origins or {
|
||||
"http://localhost:1313",
|
||||
"http://127.0.0.1:1313",
|
||||
"https://dragons-at-work.de",
|
||||
"https://www.dragons-at-work.de"
|
||||
}
|
||||
|
||||
-- Check if request has Origin header
|
||||
local origin = request and request.headers and request.headers.origin
|
||||
local cors_origin = "*" -- Default fallback
|
||||
|
||||
-- If origin is provided and in allowed list, use it
|
||||
if origin then
|
||||
for _, allowed in ipairs(allowed_origins) do
|
||||
if origin == allowed then
|
||||
cors_origin = origin
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
["Access-Control-Allow-Origin"] = cors_origin,
|
||||
["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS",
|
||||
["Access-Control-Allow-Headers"] = "Content-Type, X-API-Key, Authorization, Accept",
|
||||
["Access-Control-Max-Age"] = "86400",
|
||||
["Access-Control-Allow-Credentials"] = "false"
|
||||
}
|
||||
end
|
||||
|
||||
-- Create HTTP response
|
||||
function FurtServer:create_response(status, data, content_type, additional_headers, request)
|
||||
content_type = content_type or "application/json"
|
||||
local body = ""
|
||||
|
||||
if type(data) == "table" then
|
||||
body = cjson.encode(data)
|
||||
else
|
||||
body = tostring(data or "")
|
||||
end
|
||||
|
||||
-- Start with CORS headers
|
||||
local headers = self:add_cors_headers(request)
|
||||
|
||||
-- Add standard headers
|
||||
headers["Content-Type"] = content_type
|
||||
headers["Content-Length"] = tostring(#body)
|
||||
headers["Connection"] = "close"
|
||||
headers["Server"] = "Furt-Lua/1.1"
|
||||
|
||||
-- Add additional headers if provided
|
||||
if additional_headers then
|
||||
for key, value in pairs(additional_headers) do
|
||||
headers[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
-- Build response
|
||||
local response = string.format("HTTP/1.1 %d %s\r\n", status, self:get_status_text(status))
|
||||
|
||||
for key, value in pairs(headers) do
|
||||
response = response .. key .. ": " .. value .. "\r\n"
|
||||
end
|
||||
|
||||
response = response .. "\r\n" .. body
|
||||
|
||||
return response
|
||||
end
|
||||
|
||||
-- Get HTTP status text
|
||||
function FurtServer:get_status_text(status)
|
||||
local status_texts = {
|
||||
[200] = "OK",
|
||||
[204] = "No Content",
|
||||
[400] = "Bad Request",
|
||||
[401] = "Unauthorized",
|
||||
[403] = "Forbidden",
|
||||
[404] = "Not Found",
|
||||
[405] = "Method Not Allowed",
|
||||
[429] = "Too Many Requests",
|
||||
[500] = "Internal Server Error"
|
||||
}
|
||||
return status_texts[status] or "Unknown"
|
||||
end
|
||||
|
||||
-- Handle client request
|
||||
function FurtServer:handle_client(client)
|
||||
local request = self:parse_request(client)
|
||||
if not request then
|
||||
local response = self:create_response(400, {error = "Invalid request"}, nil, nil, nil)
|
||||
client:send(response)
|
||||
return
|
||||
end
|
||||
|
||||
print(string.format("[%s] %s %s", os.date("%Y-%m-%d %H:%M:%S"),
|
||||
request.method, request.path))
|
||||
|
||||
-- Handle OPTIONS preflight requests (CORS)
|
||||
if request.method == "OPTIONS" then
|
||||
local response = self:create_response(204, "", "text/plain", nil, request)
|
||||
client:send(response)
|
||||
return
|
||||
end
|
||||
|
||||
-- Route handling
|
||||
local handler = nil
|
||||
if self.routes[request.method] and self.routes[request.method][request.path] then
|
||||
handler = self.routes[request.method][request.path]
|
||||
end
|
||||
|
||||
if handler then
|
||||
local success, result = pcall(handler, request, self)
|
||||
if success then
|
||||
client:send(result)
|
||||
else
|
||||
print("Handler error: " .. tostring(result))
|
||||
local error_response = self:create_response(500, {error = "Internal server error"}, nil, nil, request)
|
||||
client:send(error_response)
|
||||
end
|
||||
else
|
||||
print("Route not found: " .. request.method .. " " .. request.path)
|
||||
local response = self:create_response(404, {error = "Route not found", method = request.method, path = request.path}, nil, nil, request)
|
||||
client:send(response)
|
||||
end
|
||||
end
|
||||
|
||||
-- Start HTTP server
|
||||
function FurtServer:start()
|
||||
self.server = socket.bind(self.host, self.port)
|
||||
if not self.server then
|
||||
error("Failed to bind to " .. self.host .. ":" .. self.port)
|
||||
end
|
||||
|
||||
print(string.format("Furt HTTP-Server started on %s:%d", self.host, self.port))
|
||||
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")
|
||||
print("Press Ctrl+C to stop")
|
||||
|
||||
while true do
|
||||
local client = self.server:accept()
|
||||
if client then
|
||||
client:settimeout(10) -- 10 second timeout
|
||||
self:handle_client(client)
|
||||
client:close()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Initialize server and register routes
|
||||
local server = FurtServer:new()
|
||||
|
||||
-- Public routes (no authentication required)
|
||||
server:add_route("GET", "/health", function(request, server)
|
||||
local response_data = {
|
||||
status = "healthy",
|
||||
service = "furt-lua",
|
||||
version = "1.1.0",
|
||||
timestamp = os.time(),
|
||||
features = {
|
||||
smtp_configured = config.mail and config.mail.username ~= nil,
|
||||
auth_enabled = true,
|
||||
rate_limiting = true
|
||||
}
|
||||
}
|
||||
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
|
||||
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"
|
||||
}
|
||||
|
||||
-- Count headers
|
||||
for _ in pairs(request.headers) do
|
||||
response_data.headers_count = response_data.headers_count + 1
|
||||
end
|
||||
|
||||
return server:create_response(200, response_data, nil, nil, request)
|
||||
end)
|
||||
print("⚠️ Test endpoint enabled (development mode)")
|
||||
end
|
||||
|
||||
-- Protected routes (require authentication)
|
||||
server:add_protected_route("POST", "/v1/mail/send", "mail:send", MailRoute.handle_mail_send)
|
||||
server:add_protected_route("GET", "/v1/auth/status", nil, AuthRoute.handle_auth_status)
|
||||
|
||||
-- Start server
|
||||
server:start()
|
||||
|
||||
133
src/rate_limiter.lua
Normal file
133
src/rate_limiter.lua
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
-- furt-lua/src/rate_limiter.lua
|
||||
-- Rate limiting system for API requests
|
||||
-- Dragons@Work Digital Sovereignty Project
|
||||
|
||||
local RateLimiter = {
|
||||
requests = {}, -- {api_key = {timestamps}, ip = {timestamps}}
|
||||
cleanup_interval = 300, -- Cleanup every 5 minutes
|
||||
last_cleanup = os.time(),
|
||||
|
||||
-- Default limits
|
||||
default_limits = {
|
||||
api_key_max = 60, -- 60 requests per hour per API key
|
||||
ip_max = 100, -- 100 requests per hour per IP
|
||||
window = 3600 -- 1 hour window
|
||||
}
|
||||
}
|
||||
|
||||
-- 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
|
||||
if timestamp > cutoff then
|
||||
table.insert(filtered, timestamp)
|
||||
end
|
||||
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
|
||||
if timestamp > cutoff then
|
||||
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
|
||||
|
||||
-- Check rate limits for API key and IP
|
||||
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,
|
||||
self.default_limits.window
|
||||
)
|
||||
|
||||
if not api_key_allowed then
|
||||
return false, "API key rate limit exceeded", {
|
||||
type = "api_key",
|
||||
current = api_count,
|
||||
limit = self.default_limits.api_key_max,
|
||||
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,
|
||||
self.default_limits.window
|
||||
)
|
||||
|
||||
if not ip_allowed then
|
||||
return false, "IP rate limit exceeded", {
|
||||
type = "ip",
|
||||
current = ip_count,
|
||||
limit = self.default_limits.ip_max,
|
||||
remaining = ip_remaining
|
||||
}
|
||||
end
|
||||
|
||||
-- Both limits OK
|
||||
return true, "OK", {
|
||||
api_key = {
|
||||
current = api_count,
|
||||
limit = self.default_limits.api_key_max,
|
||||
remaining = api_remaining
|
||||
},
|
||||
ip = {
|
||||
current = ip_count,
|
||||
limit = self.default_limits.ip_max,
|
||||
remaining = ip_remaining
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
-- Get rate limit headers for HTTP response
|
||||
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),
|
||||
["X-RateLimit-Window"] = tostring(self.default_limits.window)
|
||||
}
|
||||
end
|
||||
|
||||
return RateLimiter
|
||||
|
||||
16
src/routes/auth.lua
Normal file
16
src/routes/auth.lua
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- furt-lua/src/routes/auth.lua
|
||||
-- Authentication status route handler
|
||||
-- Dragons@Work Digital Sovereignty Project
|
||||
|
||||
local Auth = require("src.auth")
|
||||
|
||||
local AuthRoute = {}
|
||||
|
||||
-- Auth status endpoint handler
|
||||
function AuthRoute.handle_auth_status(request, server)
|
||||
-- Return authentication status
|
||||
return Auth.get_auth_status(request.auth)
|
||||
end
|
||||
|
||||
return AuthRoute
|
||||
|
||||
113
src/routes/mail.lua
Normal file
113
src/routes/mail.lua
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
-- furt-lua/src/routes/mail.lua
|
||||
-- Mail service route handler
|
||||
-- Dragons@Work Digital Sovereignty Project
|
||||
|
||||
local cjson = require("cjson")
|
||||
|
||||
local MailRoute = {}
|
||||
|
||||
-- Load configuration
|
||||
local config = require("config.server")
|
||||
|
||||
-- Validate email format
|
||||
local function validate_email(email)
|
||||
return email and email:match("^[^@]+@[^@]+%.[^@]+$") ~= nil
|
||||
end
|
||||
|
||||
-- Validate required fields
|
||||
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
|
||||
|
||||
-- Generate unique request ID
|
||||
local function generate_request_id()
|
||||
return os.time() .. "-" .. math.random(1000, 9999)
|
||||
end
|
||||
|
||||
-- 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
|
||||
|
||||
-- Generate request ID for tracking
|
||||
local request_id = generate_request_id()
|
||||
|
||||
-- Prepare email content
|
||||
local subject = data.subject or "Contact Form Message"
|
||||
local email_content = string.format(
|
||||
"From: %s <%s>\nSubject: %s\n\n%s",
|
||||
data.name, data.email, subject, data.message
|
||||
)
|
||||
|
||||
-- Send email via SMTP
|
||||
local SMTP = require("src.smtp")
|
||||
local smtp_client = SMTP:new(config.mail)
|
||||
|
||||
local smtp_success, smtp_result = smtp_client:send_email(
|
||||
config.mail.to_address,
|
||||
subject,
|
||||
email_content,
|
||||
data.name
|
||||
)
|
||||
|
||||
if smtp_success then
|
||||
-- Success response
|
||||
return {
|
||||
success = true,
|
||||
message = "Mail sent successfully",
|
||||
request_id = request_id,
|
||||
api_key_name = request.auth.key_name
|
||||
}
|
||||
else
|
||||
-- SMTP error - log and return error
|
||||
print("SMTP Error: " .. tostring(smtp_result))
|
||||
return server:create_response(500, {
|
||||
success = false,
|
||||
error = "Failed to send email: " .. tostring(smtp_result),
|
||||
request_id = request_id,
|
||||
code = "SMTP_ERROR"
|
||||
}, nil, nil, request)
|
||||
end
|
||||
end
|
||||
|
||||
return MailRoute
|
||||
|
||||
313
src/smtp.lua
Normal file
313
src/smtp.lua
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
-- furt-lua/src/smtp.lua
|
||||
-- Universal SMTP implementation with SSL compatibility
|
||||
-- Supports both luaossl (Arch/karl) and luasec (OpenBSD/walter)
|
||||
-- Dragons@Work Digital Sovereignty Project
|
||||
|
||||
local socket = require("socket")
|
||||
|
||||
local SMTP = {}
|
||||
|
||||
-- SSL Compatibility Layer - Auto-detect available SSL library
|
||||
local SSLCompat = {}
|
||||
|
||||
function SSLCompat:detect_ssl_library()
|
||||
-- Try luaossl first (more feature-complete)
|
||||
local success, ssl_lib = pcall(require, "ssl")
|
||||
if success and ssl_lib and ssl_lib.wrap then
|
||||
-- Check if it's luaossl (has more comprehensive API)
|
||||
if ssl_lib.newcontext or type(ssl_lib.wrap) == "function" then
|
||||
return "luaossl", ssl_lib
|
||||
end
|
||||
end
|
||||
|
||||
-- Try luasec
|
||||
local success, ssl_lib = pcall(require, "ssl")
|
||||
if success and ssl_lib then
|
||||
-- luasec typically has ssl.wrap function but different API
|
||||
if ssl_lib.wrap and not ssl_lib.newcontext then
|
||||
return "luasec", ssl_lib
|
||||
end
|
||||
end
|
||||
|
||||
return nil, "No compatible SSL library found (tried luaossl, luasec)"
|
||||
end
|
||||
|
||||
function SSLCompat:wrap_socket(sock, options)
|
||||
local ssl_type, ssl_lib = self:detect_ssl_library()
|
||||
|
||||
if not ssl_type then
|
||||
return nil, ssl_lib -- ssl_lib contains error message
|
||||
end
|
||||
|
||||
if ssl_type == "luaossl" then
|
||||
return self:wrap_luaossl(sock, options, ssl_lib)
|
||||
elseif ssl_type == "luasec" then
|
||||
return self:wrap_luasec(sock, options, ssl_lib)
|
||||
end
|
||||
|
||||
return nil, "Unknown SSL library type: " .. ssl_type
|
||||
end
|
||||
|
||||
function SSLCompat:wrap_luaossl(sock, options, ssl_lib)
|
||||
-- luaossl API
|
||||
local ssl_sock, err = ssl_lib.wrap(sock, {
|
||||
mode = "client",
|
||||
protocol = "tlsv1_2",
|
||||
verify = "none" -- For self-signed certs
|
||||
})
|
||||
|
||||
if not ssl_sock then
|
||||
return nil, "luaossl wrap failed: " .. (err or "unknown error")
|
||||
end
|
||||
|
||||
-- luaossl typically does handshake automatically, but explicit is safer
|
||||
local success, err = pcall(function() return ssl_sock:dohandshake() end)
|
||||
if not success then
|
||||
-- Some luaossl versions don't need explicit handshake
|
||||
-- Continue if dohandshake doesn't exist
|
||||
end
|
||||
|
||||
return ssl_sock, nil
|
||||
end
|
||||
|
||||
function SSLCompat:wrap_luasec(sock, options, ssl_lib)
|
||||
-- luasec API
|
||||
local ssl_sock, err = ssl_lib.wrap(sock, {
|
||||
protocol = "tlsv1_2",
|
||||
mode = "client",
|
||||
verify = "none",
|
||||
options = "all"
|
||||
})
|
||||
|
||||
if not ssl_sock then
|
||||
return nil, "luasec wrap failed: " .. (err or "unknown error")
|
||||
end
|
||||
|
||||
-- luasec requires explicit handshake
|
||||
local success, err = ssl_sock:dohandshake()
|
||||
if not success then
|
||||
return nil, "luasec handshake failed: " .. (err or "unknown error")
|
||||
end
|
||||
|
||||
return ssl_sock, nil
|
||||
end
|
||||
|
||||
-- Create SMTP instance
|
||||
function SMTP:new(config)
|
||||
local instance = {
|
||||
server = config.smtp_server or "mail.dragons-at-work.de",
|
||||
port = config.smtp_port or 465,
|
||||
username = config.username,
|
||||
password = config.password,
|
||||
from_address = config.from_address or "noreply@dragons-at-work.de",
|
||||
use_ssl = config.use_ssl or true,
|
||||
debug = config.debug or false,
|
||||
ssl_compat = SSLCompat
|
||||
}
|
||||
setmetatable(instance, self)
|
||||
self.__index = self
|
||||
return instance
|
||||
end
|
||||
|
||||
-- Base64 encoding for SMTP AUTH
|
||||
function SMTP:base64_encode(str)
|
||||
local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
return ((str:gsub('.', function(x)
|
||||
local r, b = '', x:byte()
|
||||
for i = 8, 1, -1 do
|
||||
r = r .. (b % 2^i - b % 2^(i-1) > 0 and '1' or '0')
|
||||
end
|
||||
return r;
|
||||
end) .. '0000'):gsub('%d%d%d?%d?%d?%d?', function(x)
|
||||
if (#x < 6) then return '' end
|
||||
local c = 0
|
||||
for i = 1, 6 do
|
||||
c = c + (x:sub(i,i) == '1' and 2^(6-i) or 0)
|
||||
end
|
||||
return b:sub(c+1,c+1)
|
||||
end) .. ({ '', '==', '=' })[#str % 3 + 1])
|
||||
end
|
||||
|
||||
-- Send SMTP command and read response
|
||||
function SMTP:send_command(sock, command, expected_code)
|
||||
if self.debug then
|
||||
print("SMTP CMD: " .. (command or ""):gsub("\r\n", "\\r\\n"))
|
||||
end
|
||||
|
||||
-- Only send if command is not nil (for server greeting, command is nil)
|
||||
if command then
|
||||
local success, err = sock:send(command .. "\r\n")
|
||||
if not success then
|
||||
return false, "Failed to send command: " .. (err or "unknown error")
|
||||
end
|
||||
end
|
||||
|
||||
local response, err = sock:receive()
|
||||
if not response then
|
||||
return false, "Failed to receive response: " .. (err or "unknown error")
|
||||
end
|
||||
|
||||
if self.debug then
|
||||
print("SMTP RSP: " .. response)
|
||||
end
|
||||
|
||||
-- Handle multi-line responses (like EHLO)
|
||||
local full_response = response
|
||||
while response:match("^%d%d%d%-") do
|
||||
response, err = sock:receive()
|
||||
if not response then
|
||||
return false, "Failed to receive multi-line response: " .. (err or "unknown error")
|
||||
end
|
||||
if self.debug then
|
||||
print("SMTP RSP: " .. response)
|
||||
end
|
||||
full_response = full_response .. "\n" .. response
|
||||
end
|
||||
|
||||
local code = response:match("^(%d+)")
|
||||
if expected_code and code ~= tostring(expected_code) then
|
||||
return false, "Unexpected response: " .. full_response
|
||||
end
|
||||
|
||||
return true, full_response
|
||||
end
|
||||
|
||||
-- Connect to SMTP server with universal SSL support
|
||||
function SMTP:connect()
|
||||
-- Create socket
|
||||
local sock, err = socket.tcp()
|
||||
if not sock then
|
||||
return false, "Failed to create socket: " .. (err or "unknown error")
|
||||
end
|
||||
|
||||
-- Set timeout
|
||||
sock:settimeout(30)
|
||||
|
||||
-- Connect to server
|
||||
local success, err = sock:connect(self.server, self.port)
|
||||
if not success then
|
||||
return false, "Failed to connect to " .. self.server .. ":" .. self.port .. " - " .. (err or "unknown error")
|
||||
end
|
||||
|
||||
-- Wrap with SSL for port 465 using compatibility layer
|
||||
if self.use_ssl and self.port == 465 then
|
||||
local ssl_sock, err = self.ssl_compat:wrap_socket(sock, {
|
||||
mode = "client",
|
||||
protocol = "tlsv1_2"
|
||||
})
|
||||
|
||||
if not ssl_sock then
|
||||
sock:close()
|
||||
return false, "Failed to establish SSL connection: " .. (err or "unknown error")
|
||||
end
|
||||
|
||||
sock = ssl_sock
|
||||
end
|
||||
|
||||
-- Read server greeting
|
||||
local success, response = self:send_command(sock, nil, 220)
|
||||
if not success then
|
||||
sock:close()
|
||||
return false, "SMTP server greeting failed: " .. response
|
||||
end
|
||||
|
||||
return sock, nil
|
||||
end
|
||||
|
||||
-- Send email
|
||||
function SMTP:send_email(to_address, subject, message, from_name)
|
||||
if not self.username or not self.password then
|
||||
return false, "SMTP username or password not configured"
|
||||
end
|
||||
|
||||
-- Connect to server
|
||||
local sock, err = self:connect()
|
||||
if not sock then
|
||||
return false, err
|
||||
end
|
||||
|
||||
local function cleanup_and_fail(error_msg)
|
||||
sock:close()
|
||||
return false, error_msg
|
||||
end
|
||||
|
||||
-- EHLO command
|
||||
local success, response = self:send_command(sock, "EHLO furt-lua", 250)
|
||||
if not success then
|
||||
return cleanup_and_fail("EHLO failed: " .. response)
|
||||
end
|
||||
|
||||
-- AUTH LOGIN
|
||||
local success, response = self:send_command(sock, "AUTH LOGIN", 334)
|
||||
if not success then
|
||||
return cleanup_and_fail("AUTH LOGIN failed: " .. response)
|
||||
end
|
||||
|
||||
-- Send username (base64 encoded)
|
||||
local username_b64 = self:base64_encode(self.username)
|
||||
local success, response = self:send_command(sock, username_b64, 334)
|
||||
if not success then
|
||||
return cleanup_and_fail("Username authentication failed: " .. response)
|
||||
end
|
||||
|
||||
-- Send password (base64 encoded)
|
||||
local password_b64 = self:base64_encode(self.password)
|
||||
local success, response = self:send_command(sock, password_b64, 235)
|
||||
if not success then
|
||||
return cleanup_and_fail("Password authentication failed: " .. response)
|
||||
end
|
||||
|
||||
-- MAIL FROM
|
||||
local mail_from = "MAIL FROM:<" .. self.from_address .. ">"
|
||||
local success, response = self:send_command(sock, mail_from, 250)
|
||||
if not success then
|
||||
return cleanup_and_fail("MAIL FROM failed: " .. response)
|
||||
end
|
||||
|
||||
-- RCPT TO
|
||||
local rcpt_to = "RCPT TO:<" .. to_address .. ">"
|
||||
local success, response = self:send_command(sock, rcpt_to, 250)
|
||||
if not success then
|
||||
return cleanup_and_fail("RCPT TO failed: " .. response)
|
||||
end
|
||||
|
||||
-- DATA command
|
||||
local success, response = self:send_command(sock, "DATA", 354)
|
||||
if not success then
|
||||
return cleanup_and_fail("DATA command failed: " .. response)
|
||||
end
|
||||
|
||||
-- Build email message
|
||||
local display_name = from_name or "Furt Contact Form"
|
||||
local email_content = string.format(
|
||||
"From: %s <%s>\r\n" ..
|
||||
"To: <%s>\r\n" ..
|
||||
"Subject: %s\r\n" ..
|
||||
"Date: %s\r\n" ..
|
||||
"Content-Type: text/plain; charset=UTF-8\r\n" ..
|
||||
"\r\n" ..
|
||||
"%s\r\n" ..
|
||||
".",
|
||||
display_name,
|
||||
self.from_address,
|
||||
to_address,
|
||||
subject,
|
||||
os.date("%a, %d %b %Y %H:%M:%S %z"),
|
||||
message
|
||||
)
|
||||
|
||||
-- Send email content
|
||||
local success, response = self:send_command(sock, email_content, 250)
|
||||
if not success then
|
||||
return cleanup_and_fail("Email sending failed: " .. response)
|
||||
end
|
||||
|
||||
-- QUIT
|
||||
self:send_command(sock, "QUIT", 221)
|
||||
sock:close()
|
||||
|
||||
return true, "Email sent successfully"
|
||||
end
|
||||
|
||||
return SMTP
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue