docs: add per package README for implementation details (AI generated with human review)

This commit is contained in:
yusing
2026-01-08 23:39:19 +08:00
parent 13441286d1
commit e9d7edef12
54 changed files with 13431 additions and 1519 deletions

144
internal/net/README.md Normal file
View File

@@ -0,0 +1,144 @@
# Network Utilities
The net package provides network utility functions for GoDoxy, including TCP connection testing and network-related helpers.
## Overview
The net package implements network utility functions that are used throughout GoDoxy for connectivity testing, TCP operations, and network-related utilities.
### Key Features
- TCP connection testing (ping)
- Connection utilities
## Core Functions
### TCP Ping
```go
// PingTCP pings a TCP endpoint by attempting a connection.
func PingTCP(ctx context.Context, ip net.IP, port int) error
```
## Usage
### Basic Usage
```go
import "github.com/yusing/godoxy/internal/net"
func checkService(ctx context.Context, ip string, port int) error {
addr := net.ParseIP(ip)
if addr == nil {
return fmt.Errorf("invalid IP: %s", ip)
}
err := net.PingTCP(ctx, addr, port)
if err != nil {
return fmt.Errorf("service %s:%d unreachable: %w", ip, port, err)
}
fmt.Printf("Service %s:%d is reachable\n", ip, port)
return nil
}
```
### Timeout Usage
```go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ip := net.ParseIP("192.168.1.100")
err := net.PingTCP(ctx, ip, 8080)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Println("Connection timed out")
} else {
log.Printf("Connection failed: %v", err)
}
}
```
## Implementation
```go
func PingTCP(ctx context.Context, ip net.IP, port int) error {
var dialer net.Dialer
conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", ip, port))
if err != nil {
return err
}
conn.Close()
return nil
}
```
## Data Flow
```mermaid
sequenceDiagram
participant Caller
participant Dialer
participant TCPEndpoint
participant Connection
Caller->>Dialer: DialContext("tcp", "ip:port")
Dialer->>TCPEndpoint: SYN
TCPEndpoint-->>Dialer: SYN-ACK
Dialer->>Connection: Create connection
Connection-->>Dialer: Connection
Dialer-->>Caller: nil error
Note over Caller,Connection: Connection immediately closed
Connection->>TCPEndpoint: FIN
TCPEndpoint-->>Connection: FIN-ACK
```
## Use Cases
### Service Health Check
```go
func checkServices(ctx context.Context, services []Service) error {
for _, svc := range services {
ip := net.ParseIP(svc.IP)
if ip == nil {
return fmt.Errorf("invalid IP for %s: %s", svc.Name, svc.IP)
}
if err := net.PingTCP(ctx, ip, svc.Port); err != nil {
return fmt.Errorf("service %s (%s:%d) unreachable: %w",
svc.Name, svc.IP, svc.Port, err)
}
}
return nil
}
```
### Proxmox Container Reachability
```go
// Check if a Proxmox container is reachable on its proxy port
func checkContainerReachability(ctx context.Context, node *proxmox.Node, vmid int, port int) error {
ips, err := node.LXCGetIPs(ctx, vmid)
if err != nil {
return err
}
for _, ip := range ips {
if err := net.PingTCP(ctx, ip, port); err == nil {
return nil // Found reachable IP
}
}
return fmt.Errorf("no reachable IP found for container %d", vmid)
}
```
## Related Packages
- **Route**: Uses TCP ping for load balancing health checks
- **Proxmox**: Uses TCP ping to verify container reachability
- **Idlewatcher**: Uses TCP ping to check idle status

View File

@@ -0,0 +1,146 @@
# gphttp
HTTP utilities package providing transport configuration, default HTTP client, and a wrapper around `http.ServeMux` with panic recovery.
## Overview
This package provides shared HTTP utilities used throughout GoDoxy:
- **Default HTTP Client**: Pre-configured `http.Client` with secure settings
- **Transport Factory**: Functions to create optimized `http.Transport` configurations
- **ServeMux Wrapper**: Extended `http.ServeMux` with panic recovery for handler registration
## Architecture
```mermaid
graph TD
A[HTTP Request] --> B[gphttp.Client]
B --> C[Transport]
C --> D[Network Connection]
E[Server Setup] --> F[gphttp.ServeMux]
F --> G[http.ServeMux]
G --> H[HTTP Handlers]
```
## Core Components
### HTTP Client
The package exports a pre-configured `http.Client` with secure defaults:
```go
var (
httpClient = &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DisableKeepAlives: true,
ForceAttemptHTTP2: false,
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 60 * time.Second,
}).DialContext,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
Get = httpClient.Get
Post = httpClient.Post
Head = httpClient.Head
Do = httpClient.Do
)
```
### Transport Factory
Functions for creating optimized HTTP transports:
```go
// NewTransport creates an http.Transport with proxy support and optimized settings
func NewTransport() *http.Transport
// NewTransportWithTLSConfig creates an http.Transport with custom TLS configuration
func NewTransportWithTLSConfig(tlsConfig *tls.Config) *http.Transport
```
Default transport settings:
- `MaxIdleConnsPerHost`: 1000
- `IdleConnTimeout`: 90 seconds
- `TLSHandshakeTimeout`: 10 seconds
- `ResponseHeaderTimeout`: 60 seconds
- `WriteBufferSize` / `ReadBufferSize`: 16KB
### ServeMux Wrapper
Extended `http.ServeMux` with panic recovery:
```go
type ServeMux struct {
*http.ServeMux
}
func NewServeMux() ServeMux
func (mux ServeMux) Handle(pattern string, handler http.Handler) (err error)
func (mux ServeMux) HandleFunc(pattern string, handler http.HandlerFunc) (err error)
```
The `Handle` and `HandleFunc` methods recover from panics and return them as errors, preventing one bad handler from crashing the entire server.
## Usage Examples
### Using the Default Client
```go
import "github.com/yusing/godoxy/internal/net/gphttp"
// Simple GET request
resp, err := gphttp.Get("https://example.com")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
// POST request
resp, err := gphttp.Post("https://example.com", "application/json", body)
```
### Creating Custom Transports
```go
import (
"crypto/tls"
"net/http"
"github.com/yusing/godoxy/internal/net/gphttp"
)
// Default transport with environment proxy
transport := gphttp.NewTransport()
// Custom TLS configuration
tlsConfig := &tls.Config{
ServerName: "example.com",
}
transport := gphttp.NewTransportWithTLSConfig(tlsConfig)
```
### Using ServeMux with Panic Recovery
```go
mux := gphttp.NewServeMux()
// Register handlers - panics are converted to errors
if err := mux.HandleFunc("/api", apiHandler); err != nil {
log.Printf("handler registration failed: %v", err)
}
```
## Integration Points
- Used by `internal/net/gphttp/middleware` for HTTP request/response processing
- Used by `internal/net/gphttp/loadbalancer` for backend connections
- Used throughout the route handling system
## Configuration
The default client disables HTTP/2 (`ForceAttemptHTTP2: false`) and keep-alives (`DisableKeepAlives: true`) for security and compatibility reasons. The transport uses environment proxy settings via `http.ProxyFromEnvironment`.

View File

@@ -0,0 +1,304 @@
# Load Balancer
Load balancing package providing multiple distribution algorithms, sticky sessions, and server health management.
## Overview
This package implements a flexible load balancer for distributing HTTP requests across multiple backend servers. It supports multiple balancing algorithms and integrates with GoDoxy's task management and health monitoring systems.
## Architecture
```mermaid
graph TD
A[HTTP Request] --> B[LoadBalancer]
B --> C{Algorithm}
C -->|Round Robin| D[RoundRobin]
C -->|Least Connections| E[LeastConn]
C -->|IP Hash| F[IPHash]
D --> G[Available Servers]
E --> G
F --> G
G --> H[Server Selection]
H --> I{Sticky Session?}
I -->|Yes| J[Set Cookie]
I -->|No| K[Continue]
J --> L[ServeHTTP]
K --> L
```
## Algorithms
### Round Robin
Distributes requests evenly across all available servers in sequence.
```mermaid
sequenceDiagram
participant C as Client
participant LB as LoadBalancer
participant S1 as Server 1
participant S2 as Server 2
participant S3 as Server 3
C->>LB: Request 1
LB->>S1: Route to Server 1
C->>LB: Request 2
LB->>S2: Route to Server 2
C->>LB: Request 3
LB->>S3: Route to Server 3
C->>LB: Request 4
LB->>S1: Route to Server 1
```
### Least Connections
Routes requests to the server with the fewest active connections.
```mermaid
flowchart LR
subgraph LB["Load Balancer"]
direction TB
A["Server A<br/>3 connections"]
B["Server B<br/>1 connection"]
C["Server C<br/>5 connections"]
end
New["New Request"] --> B
```
### IP Hash
Consistently routes requests from the same client IP to the same server using hash-based distribution.
```mermaid
graph LR
Client1["Client IP: 192.168.1.10"] -->|Hash| ServerA
Client2["Client IP: 192.168.1.20"] -->|Hash| ServerB
Client3["Client IP: 192.168.1.30"] -->|Hash| ServerA
```
## Core Components
### LoadBalancer
```go
type LoadBalancer struct {
*types.LoadBalancerConfig
task *task.Task
pool pool.Pool[types.LoadBalancerServer]
poolMu sync.Mutex
sumWeight int
startTime time.Time
}
```
**Key Methods:**
```go
// Create a new load balancer from configuration
func New(cfg *types.LoadBalancerConfig) *LoadBalancer
// Start the load balancer as a background task
func (lb *LoadBalancer) Start(parent task.Parent) gperr.Error
// Update configuration dynamically
func (lb *LoadBalancer) UpdateConfigIfNeeded(cfg *types.LoadBalancerConfig)
// Add a backend server
func (lb *LoadBalancer) AddServer(srv types.LoadBalancerServer)
// Remove a backend server
func (lb *LoadBalancer) RemoveServer(srv types.LoadBalancerServer)
// ServeHTTP implements http.Handler
func (lb *LoadBalancer) ServeHTTP(rw http.ResponseWriter, r *http.Request)
```
### Server
```go
type server struct {
name string
url *nettypes.URL
weight int
http.Handler
types.HealthMonitor
}
// Create a new backend server
func NewServer(name string, url *nettypes.URL, weight int, handler http.Handler, healthMon types.HealthMonitor) types.LoadBalancerServer
```
**Server Interface:**
```go
type LoadBalancerServer interface {
Name() string
URL() *nettypes.URL
Key() string
Weight() int
SetWeight(weight int)
Status() types.HealthStatus
Latency() time.Duration
ServeHTTP(rw http.ResponseWriter, r *http.Request)
TryWake() error
}
```
### Sticky Sessions
The load balancer supports sticky sessions via cookies:
```mermaid
flowchart TD
A[Client Request] --> B{Cookie exists?}
B -->|No| C[Select Server]
B -->|Yes| D[Extract Server Hash]
D --> E[Find Matching Server]
C --> F[Set Cookie<br/>godoxy_lb_sticky]
E --> G[Route to Server]
F --> G
```
```go
// Cookie settings
Name: "godoxy_lb_sticky"
MaxAge: Configurable (default: 24 hours)
HttpOnly: true
SameSite: Lax
Secure: Based on TLS/Forwarded-Proto
```
## Balancing Modes
```go
const (
LoadbalanceModeUnset = ""
LoadbalanceModeRoundRobin = "round_robin"
LoadbalanceModeLeastConn = "least_conn"
LoadbalanceModeIPHash = "ip_hash"
)
```
## Configuration
```go
type LoadBalancerConfig struct {
Link string // Link name
Mode LoadbalanceMode // Balancing algorithm
Sticky bool // Enable sticky sessions
StickyMaxAge time.Duration // Cookie max age
Options map[string]any // Algorithm-specific options
}
```
## Usage Examples
### Basic Round Robin Load Balancer
```go
config := &types.LoadBalancerConfig{
Link: "my-service",
Mode: types.LoadbalanceModeRoundRobin,
}
lb := loadbalancer.New(config)
lb.Start(parentTask)
// Add backend servers
lb.AddServer(loadbalancer.NewServer("backend-1", url1, 10, handler1, health1))
lb.AddServer(loadbalancer.NewServer("backend-2", url2, 10, handler2, health2))
// Use as HTTP handler
http.Handle("/", lb)
```
### Least Connections with Sticky Sessions
```go
config := &types.LoadBalancerConfig{
Link: "api-service",
Mode: types.LoadbalanceModeLeastConn,
Sticky: true,
StickyMaxAge: 1 * time.Hour,
}
lb := loadbalancer.New(config)
lb.Start(parentTask)
for _, srv := range backends {
lb.AddServer(srv)
}
```
### IP Hash Load Balancer with Real IP
```go
config := &types.LoadBalancerConfig{
Link: "user-service",
Mode: types.LoadbalanceModeIPHash,
Options: map[string]any{
"header": "X-Real-IP",
"from": []string{"10.0.0.0/8", "172.16.0.0/12"},
"recursive": true,
},
}
lb := loadbalancer.New(config)
```
### Server Weight Management
```go
// Servers are balanced based on weight (max total: 100)
lb.AddServer(NewServer("server1", url1, 30, handler, health))
lb.AddServer(NewServer("server2", url2, 50, handler, health))
lb.AddServer(NewServer("server3", url3, 20, handler, health))
// Weights are auto-rebalanced if total != 100
```
## Idlewatcher Integration
The load balancer integrates with the idlewatcher system:
- Wake events path (`/api/wake`): Wakes all idle servers
- Favicon and loading page paths: Bypassed for sticky session handling
- Server wake support via `TryWake()` interface
## Health Monitoring
The load balancer implements `types.HealthMonitor`:
```go
func (lb *LoadBalancer) Status() types.HealthStatus
func (lb *LoadBalancer) Detail() string
func (lb *LoadBalancer) Uptime() time.Duration
func (lb *LoadBalancer) Latency() time.Duration
```
Health JSON representation:
```json
{
"name": "my-service",
"status": "healthy",
"detail": "3/3 servers are healthy",
"started": "2024-01-01T00:00:00Z",
"uptime": "1h2m3s",
"latency": "10ms",
"extra": {
"config": {...},
"pool": {...}
}
}
```
## Thread Safety
- Server pool operations are protected by `poolMu` mutex
- Algorithm-specific state uses atomic operations or dedicated synchronization
- Least connections uses `xsync.Map` for thread-safe connection counting

View File

@@ -0,0 +1,336 @@
# Middleware
HTTP middleware framework providing request/response processing, middleware chaining, and composition from YAML files.
## Overview
This package implements a flexible HTTP middleware system for GoDoxy. Middleware can modify requests before they reach the backend and modify responses before they return to the client. The system supports:
- **Request Modifiers**: Process requests before forwarding
- **Response Modifiers**: Modify responses before returning to client
- **Middleware Chaining**: Compose multiple middleware in priority order
- **YAML Composition**: Define middleware chains in configuration files
- **Bypass Rules**: Skip middleware based on request properties
- **Dynamic Loading**: Load middleware definitions from files at runtime
## Architecture
```mermaid
graph TD
A[HTTP Request] --> B[Middleware Chain]
subgraph Chain [Middleware Pipeline]
direction LR
B1[RedirectHTTP] --> B2[RealIP]
B2 --> B3[RateLimit]
B3 --> B4[OIDC]
B4 --> B5[CustomErrorPage]
end
Chain --> C[Backend Handler]
C --> D[Response Modifier]
subgraph ResponseChain [Response Pipeline]
direction LR
D1[CustomErrorPage] --> D2[ModifyResponse]
D2 --> D3[ModifyHTML]
end
ResponseChain --> E[HTTP Response]
```
## Middleware Flow
```mermaid
sequenceDiagram
participant C as Client
participant M as Middleware Chain
participant B as Backend
participant R as Response Chain
participant C2 as Client
C->>M: HTTP Request
M->>M: before() - RequestModifier
M->>M: Check Bypass Rules
M->>M: Sort by Priority
par Request Modifiers
M->>M: Middleware 1 (before)
M->>M: Middleware 2 (before)
end
M->>B: Forward Request
B-->>M: HTTP Response
par Response Modifiers
M->>R: ResponseModifier 1
M->>R: ResponseModifier 2
end
R-->>C2: Modified Response
```
## Core Components
### Middleware
```go
type Middleware struct {
name string
construct ImplNewFunc
impl any
commonOptions
}
type commonOptions struct {
Priority int `json:"priority"` // Default: 10, 0 is highest
Bypass Bypass `json:"bypass"`
}
```
**Interfaces:**
```go
// RequestModifier - modify or filter requests
type RequestModifier interface {
before(w http.ResponseWriter, r *http.Request) (proceed bool)
}
// ResponseModifier - modify responses
type ResponseModifier interface {
modifyResponse(r *http.Response) error
}
// MiddlewareWithSetup - one-time setup after construction
type MiddlewareWithSetup interface {
setup()
}
// MiddlewareFinalizer - finalize after options applied
type MiddlewareFinalizer interface {
finalize()
}
// MiddlewareFinalizerWithError - finalize with error handling
type MiddlewareFinalizerWithError interface {
finalize() error
}
```
### Middleware Chain
```go
type middlewareChain struct {
beforess []RequestModifier
modResps []ResponseModifier
}
func NewMiddlewareChain(name string, chain []*Middleware) *Middleware
```
### Bypass Rules
```go
type Bypass []rules.RuleOn
// ShouldBypass checks if request should skip middleware
func (b Bypass) ShouldBypass(w http.ResponseWriter, r *http.Request) bool
```
## Available Middleware
| Name | Type | Description |
| ------------------------------- | -------- | ------------------------------------------ |
| `redirecthttp` | Request | Redirect HTTP to HTTPS |
| `oidc` | Request | OIDC authentication |
| `forwardauth` | Request | Forward authentication to external service |
| `modifyrequest` / `request` | Request | Modify request headers and path |
| `modifyresponse` / `response` | Response | Modify response headers |
| `setxforwarded` | Request | Set X-Forwarded headers |
| `hidexforwarded` | Request | Remove X-Forwarded headers |
| `modifyhtml` | Response | Inject HTML into responses |
| `themed` | Response | Apply theming to HTML |
| `errorpage` / `customerrorpage` | Response | Serve custom error pages |
| `realip` | Request | Extract real client IP from headers |
| `cloudflarerealip` | Request | Cloudflare-specific real IP extraction |
| `cidrwhitelist` | Request | Allow only specific IP ranges |
| `ratelimit` | Request | Rate limiting by IP |
| `hcaptcha` | Request | hCAPTCHA verification |
## Usage Examples
### Creating a Middleware
```go
import "github.com/yusing/godoxy/internal/net/gphttp/middleware"
type myMiddleware struct {
SomeOption string `json:"some_option"`
}
func (m *myMiddleware) before(w http.ResponseWriter, r *http.Request) bool {
// Process request
r.Header.Set("X-Custom", m.SomeOption)
return true // false would block the request
}
var MyMiddleware = middleware.NewMiddleware[myMiddleware]()
```
### Building Middleware from Map
```go
middlewaresMap := map[string]middleware.OptionsRaw{
"realip": {
"priority": 5,
"header": "X-Real-IP",
"from": []string{"10.0.0.0/8"},
},
"ratelimit": {
"priority": 10,
"average": 10,
"burst": 20,
},
}
mid, err := middleware.BuildMiddlewareFromMap("my-chain", middlewaresMap)
if err != nil {
log.Fatal(err)
}
```
### YAML Composition
```yaml
# config/middlewares/my-chain.yml
- use: realip
header: X-Real-IP
from:
- 10.0.0.0/8
- 172.16.0.0/12
bypass:
- path glob("/public/*")
- use: ratelimit
average: 100
burst: 200
- use: oidc
allowed_users:
- user@example.com
```
```go
// Load from file
eb := &gperr.Builder{}
middlewares := middleware.BuildMiddlewaresFromComposeFile(
"config/middlewares/my-chain.yml",
eb,
)
```
### Applying Middleware to Reverse Proxy
```go
import "github.com/yusing/goutils/http/reverseproxy"
rp := &reverseproxy.ReverseProxy{
Target: backendURL,
}
err := middleware.PatchReverseProxy(rp, middlewaresMap)
if err != nil {
log.Fatal(err)
}
```
### Bypass Rules
```go
bypassRules := middleware.Bypass{
{
Type: rules.RuleOnTypePathPrefix,
Value: "/public",
},
{
Type: rules.RuleOnTypePath,
Value: "/health",
},
}
mid, _ := middleware.RateLimiter.New(middleware.OptionsRaw{
"bypass": bypassRules,
"average": 10,
"burst": 20,
})
```
## Priority
Middleware are executed in priority order (lower number = higher priority):
```mermaid
graph LR
A[Priority 0] --> B[Priority 5]
B --> C[Priority 10]
C --> D[Priority 20]
style A fill:#14532d,stroke:#fff,color:#fff
style B fill:#14532d,stroke:#fff,color:#fff
style C fill:#44403c,stroke:#fff,color:#fff
style D fill:#44403c,stroke:#fff,color:#fff
```
## Request Processing
```mermaid
flowchart TD
A[Request] --> B{Has Bypass Rules?}
B -->|Yes| C{Match Bypass?}
B -->|No| D[Execute before#40;#41;]
C -->|Match| E[Skip Middleware<br/>Proceed to Next]
C -->|No Match| D
D --> F{before#40;#41; Returns?}
F -->|true| G[Continue to Next]
F -->|false| H[Stop Pipeline]
G --> I[Backend Handler]
I --> J[Response]
J --> K{Has Response Modifier?}
K -->|Yes| L[Execute modifyResponse]
K -->|No| M[Return Response]
L --> M
```
## Integration Points
- **Error Pages**: Uses `errorpage` package for custom error responses
- **Authentication**: Integrates with `internal/auth` for OIDC
- **Rate Limiting**: Uses `golang.org/x/time/rate`
- **IP Processing**: Uses `internal/net/types` for CIDR handling
## Error Handling
Errors during middleware construction are collected and reported:
```go
var errs gperr.Builder
for name, opts := range middlewaresMap {
m, err := middleware.Get(name)
if err != nil {
errs.Add(err)
continue
}
mid, err := m.New(opts)
if err != nil {
errs.AddSubjectf(err, "middlewares.%s", name)
continue
}
}
if errs.HasError() {
log.Error().Err(errs.Error()).Msg("middleware compilation failed")
}
```

View File

@@ -0,0 +1,264 @@
# Captcha Middleware
CAPTCHA verification middleware package providing session-based captcha challenge and verification.
## Overview
This package implements CAPTCHA verification middleware that protects routes by requiring users to complete a CAPTCHA challenge before accessing the protected resource. It supports pluggable providers (currently hCAPTCHA) and uses encrypted sessions for verification state.
## Architecture
```mermaid
graph TD
A[Client Request] --> B{Captcha Session?}
B -->|Valid| C[Proceed to Backend]
B -->|Invalid| D[Show CAPTCHA Page]
D --> E{POST with Token?}
E -->|Valid| F[Create Session<br/>Set Cookie]
E -->|Invalid| G[Show Error]
F --> C
subgraph Captcha Provider
H[hCAPTCHA API]
D -->|Script/Form HTML| H
F -->|Verify Token| H
end
subgraph Session Store
I[CaptchaSessions<br/>jsonstore]
end
F --> I
I -.->|Session Check| B
```
## Captcha Flow
```mermaid
sequenceDiagram
participant C as Client
participant M as Middleware
participant P as Provider
participant S as Session Store
participant B as Backend
C->>M: Request (no session)
M->>M: Check cookie
M->>M: Session not found/expired
M->>C: Send CAPTCHA Page
C->>M: POST with captcha response
M->>P: Verify token
P-->>M: Verification result
alt Verification successful
M->>S: Store session
M->>C: Set session cookie<br/>Redirect to protected path
C->>M: Request (with session cookie)
M->>S: Validate session
M->>B: Forward request
else Verification failed
M->>C: Error: verification failed
end
```
## Core Components
### Provider Interface
```go
type Provider interface {
// CSP directives for the captcha provider
CSPDirectives() []string
// CSP sources for the captcha provider
CSPSources() []string
// Verify the captcha response from the request
Verify(r *http.Request) error
// Session expiry duration after successful verification
SessionExpiry() time.Duration
// Script HTML to include in the page
ScriptHTML() string
// Form HTML to render the captcha widget
FormHTML() string
}
```
### ProviderBase
```go
type ProviderBase struct {
Expiry time.Duration `json:"session_expiry"` // Default: 24 hours
}
func (p *ProviderBase) SessionExpiry() time.Duration
```
### hCAPTCHA Provider
```go
type HcaptchaProvider struct {
ProviderBase
SiteKey string `json:"site_key" validate:"required"`
Secret string `json:"secret" validate:"required"`
}
// CSP Directives: script-src, frame-src, style-src, connect-src
// CSP Sources: https://hcaptcha.com, https://*.hcaptcha.com
```
### Captcha Session
```go
type CaptchaSession struct {
ID string `json:"id"`
Expiry time.Time `json:"expiry"`
}
var CaptchaSessions = jsonstore.Store[*CaptchaSession]("captcha_sessions")
func newCaptchaSession(p Provider) *CaptchaSession
func (s *CaptchaSession) expired() bool
```
## Middleware Integration
```go
type hCaptcha struct {
captcha.HcaptchaProvider
}
func (h *hCaptcha) before(w http.ResponseWriter, r *http.Request) bool {
return captcha.PreRequest(h, w, r)
}
var HCaptcha = NewMiddleware[hCaptcha]()
```
### PreRequest Handler
```go
func PreRequest(p Provider, w http.ResponseWriter, r *http.Request) (proceed bool)
```
This function:
1. Checks for valid session cookie
1. Validates session expiry
1. Returns true if session is valid
1. For non-HTML requests, returns 403 Forbidden
1. For POST requests, verifies the captcha token
1. For GET requests, renders the CAPTCHA challenge page
## Configuration
### hCAPTCHA Configuration
```yaml
middleware:
my-captcha:
use: hcaptcha
site_key: "YOUR_SITE_KEY"
secret: "YOUR_SECRET"
session_expiry: 24h # optional, default 24h
```
### Route Configuration
```yaml
routes:
- host: example.com
path: /admin
middlewares:
- my-captcha
```
## Usage Examples
### Basic Setup
```go
import "github.com/yusing/godoxy/internal/net/gphttp/middleware"
hcaptchaMiddleware := middleware.HCaptcha.New(middleware.OptionsRaw{
"site_key": "your-site-key",
"secret": "your-secret",
})
```
### Using in Middleware Chain
```yaml
# config/middlewares/admin-protection.yml
- use: captcha
site_key: "${HCAPTCHA_SITE_KEY}"
secret: "${HCAPTCHA_SECRET}"
bypass:
- type: CIDR
value: 10.0.0.0/8
```
## Session Management
Sessions are stored in a JSON-based store with the following properties:
- **Session ID**: 32-byte CRNG (`crypto/rand.Read`) random hex string
- **Expiry**: Configurable duration (default 24 hours)
- **Cookie**: `godoxy_captcha_session` with HttpOnly flag
```mermaid
flowchart TD
A[Session Created] --> B[Cookie Set]
B --> C[Client Sends Cookie]
C --> D{Session Valid?}
D -->|Yes| E[Proceed]
D -->|No| F{HTML Request?}
F -->|Yes| G[Show CAPTCHA]
F -->|No| H[403 Forbidden]
```
## CSP Integration
The CAPTCHA provider supplies CSP directives that should be added to the response:
```go
// hCAPTCHA CSP Directives
CSPDirectives() []string
// Returns: ["script-src", "frame-src", "style-src", "connect-src"]
CSPSources() []string
// Returns: ["https://hcaptcha.com", "https://*.hcaptcha.com"]
```
## HTML Template
The package includes an embedded HTML template (`captcha.html`) that renders the CAPTCHA challenge page with:
- Provider script (`<script src="https://js.hcaptcha.com/1/api.js">`)
- Provider form (`<div class="h-captcha" data-sitekey="...">`)
- Auto-submit callback on successful verification
## Security Considerations
1. **Session Cookie**: Uses HttpOnly flag to prevent JavaScript access
1. **Token Verification**: Tokens are verified server-side with the CAPTCHA provider
1. **Remote IP**: Client IP is included in verification request to prevent token reuse
1. **Session Expiry**: Sessions expire after configurable duration
1. **Non-HTML Fallback**: Non-HTML requests receive 403 without challenge page
## Error Handling
```go
var ErrCaptchaVerificationFailed = gperr.New("captcha verification failed")
// Verification errors are logged with request details
log.Warn().Err(err).Str("url", r.URL.String()).Str("remote_addr", r.RemoteAddr).Msg("failed to verify captcha")
```
## Integration with GoDoxy
The captcha middleware integrates with GoDoxy's:
- **Authentication**: Sessions are managed via `auth.SetTokenCookie`
- **Session Store**: Uses `jsonstore` for persistent session storage
- **Middleware Framework**: Implements `RequestModifier` interface

View File

@@ -0,0 +1,301 @@
# Error Page Middleware
Custom error page serving middleware that replaces default HTTP error responses with styled custom pages.
## Overview
This package provides two components:
1. **errorpage package**: Manages error page file loading, caching, and hot-reloading from disk
1. **CustomErrorPage middleware**: Intercepts error responses and replaces them with custom error pages
## Architecture
```mermaid
graph TD
A[HTTP Error Response] --> B{CustomErrorPage Middleware}
B --> C{Status Code & Content Type}
C -->|HTML/Plain| D[Look Up Error Page]
C -->|Other| E[Pass Through]
D --> F{Page Found?}
F -->|Yes| G[Replace Body<br/>Set Content-Type]
F -->|No| H[Log Error<br/>Pass Through]
G --> I[Custom Error Page Response]
subgraph Error Page Management
J[Error Pages Directory]
K[File Watcher]
L[Content Cache]
M[HTTP Handler]
end
J -->|Read| L
J -->|Watch Changes| K
K -->|Notify| L
L --> M
```
## Error Page Lookup Flow
```mermaid
flowchart TD
A[Error Status: 503] --> B{Look for 503.html?}
B -->|Found| C[Return 503.html]
B -->|Not Found| D{Look for 404.html?}
D -->|Found| E[Return 404.html]
D -->|Not Found| F[Return Default Error]
```
## Core Components
### Error Page Package
```go
var (
setupOnce sync.Once
dirWatcher watcher.Watcher
fileContentMap = xsync.NewMap[string, []byte]()
)
func setup() {
t := task.RootTask("error_page", false)
dirWatcher = watcher.NewDirectoryWatcher(t, errPagesBasePath)
loadContent()
go watchDir()
}
// GetStaticFile retrieves an error page file by filename
func GetStaticFile(filename string) ([]byte, bool)
// GetErrorPageByStatus retrieves the error page for a given status code
func GetErrorPageByStatus(statusCode int) (content []byte, ok bool)
```
### File Watcher
The package watches the error pages directory for changes:
```go
func watchDir() {
eventCh, errCh := dirWatcher.Events(task.RootContext())
for {
select {
case event := <-eventCh:
filename := event.ActorName
switch event.Action {
case events.ActionFileWritten:
fileContentMap.Delete(filename)
loadContent()
case events.ActionFileDeleted:
fileContentMap.Delete(filename)
case events.ActionFileRenamed:
fileContentMap.Delete(filename)
loadContent()
}
case err := <-errCh:
gperr.LogError("error watching error page directory", err)
}
}
}
```
### Custom Error Page Middleware
```go
type customErrorPage struct{}
var CustomErrorPage = NewMiddleware[customErrorPage]()
const StaticFilePathPrefix = "/$gperrorpage/"
```
### Request Modifier
```go
func (customErrorPage) before(w http.ResponseWriter, r *http.Request) bool {
return !ServeStaticErrorPageFile(w, r)
}
```
### Response Modifier
```go
func (customErrorPage) modifyResponse(resp *http.Response) error {
// Only handles:
// - Non-success status codes (4xx, 5xx)
// - HTML or Plain Text content types
contentType := httputils.GetContentType(resp.Header)
if !httputils.IsSuccess(resp.StatusCode) && (contentType.IsHTML() || contentType.IsPlainText()) {
errorPage, ok := errorpage.GetErrorPageByStatus(resp.StatusCode)
if ok {
// Replace response body with error page
resp.Body = io.NopCloser(bytes.NewReader(errorPage))
resp.ContentLength = int64(len(errorPage))
resp.Header.Set(httpheaders.HeaderContentLength, strconv.Itoa(len(errorPage)))
resp.Header.Set(httpheaders.HeaderContentType, "text/html; charset=utf-8")
}
}
return nil
}
```
## Static File Serving
The middleware also serves static error page assets:
```go
func ServeStaticErrorPageFile(w http.ResponseWriter, r *http.Request) bool {
if strings.HasPrefix(path, StaticFilePathPrefix) {
filename := path[len(StaticFilePathPrefix):]
file, ok := errorpage.GetStaticFile(filename)
if ok {
// Set content type based on extension
switch ext := filepath.Ext(filename); ext {
case ".html":
w.Header().Set(httpheaders.HeaderContentType, "text/html; charset=utf-8")
case ".js":
w.Header().Set(httpheaders.HeaderContentType, "application/javascript; charset=utf-8")
case ".css":
w.Header().Set(httpheaders.HeaderContentType, "text/css; charset=utf-8")
}
w.Write(file)
return true
}
}
return false
}
```
## Configuration
### Error Pages Directory
Default path: `config/error_pages/`
### Supported Files
| File Pattern | Description |
| ------------------- | -------------------------------------- |
| `{statusCode}.html` | Specific error page (e.g., `503.html`) |
| `404.html` | Fallback for missing specific pages |
| `*.css` | Stylesheets |
| `*.js` | JavaScript files |
| `*.{png,jpg,svg}` | Images and assets |
### Example Structure
```
config/error_pages/
├── 403.html
├── 404.html
├── 500.html
├── 502.html
├── 503.html
├── style.css
└── logo.png
```
### Middleware Configuration
```yaml
# In route middleware configuration
- use: errorpage
# Optional: bypass rules
bypass:
- type: PathPrefix
value: /api
```
## Response Processing
```mermaid
flowchart TD
A[Backend Response] --> B{Status Code >= 400?}
B -->|No| C[Pass Through]
B -->|Yes| D{Content Type HTML/Plain?}
D -->|No| C
D -->|Yes| E{Look Up Error Page}
E -->|Found| F[Replace Body]
E -->|Not Found| G[Log Error]
G --> C
F --> H[Set Content-Type: text/html]
H --> I[Return Custom Error Page]
```
## Usage Examples
### Creating Custom Error Pages
**503.html**:
```html
<!DOCTYPE html>
<html>
<head>
<title>Service Unavailable</title>
<link rel="stylesheet" href="/$gperrorpage/style.css">
</head>
<body>
<div class="error-container">
<h1>503 - Service Unavailable</h1>
<p>The service is temporarily unavailable. Please try again later.</p>
</div>
</body>
</html>
```
### Using in Middleware Chain
```yaml
# config/middlewares/error-pages.yml
- use: errorpage
bypass:
- type: PathPrefix
value: /api/health
```
### Programmatic Usage
```go
import (
"github.com/yusing/godoxy/internal/net/gphttp/middleware"
"github.com/yusing/godoxy/internal/net/gphttp/middleware/errorpage"
)
// Check if error page exists
content, ok := errorpage.GetErrorPageByStatus(503)
if ok {
// Use error page content
}
// Serve static asset
ServeStaticErrorPageFile(w, r)
```
## Integration with GoDoxy
The error page middleware integrates with:
- **File Watching**: Uses `internal/watcher` for hot-reloading
- **Task Management**: Uses `internal/task` for lifetime management
- **Content Caching**: Uses `xsync.Map` for thread-safe caching
- **HTTP Headers**: Uses `goutils/http/httpheaders` for content type handling
## Performance Considerations
- Error page content is cached in memory after first load
- File watcher notifies on changes for cache invalidation
- Static files are served directly from cache
- Concurrent access protected by `xsync.Map`
## Error Handling
```go
// Logging on error page not found
log.Error().Msgf("unable to load error page for status %d", resp.StatusCode)
// Logging on static file not found
log.Error().Msg("unable to load resource " + filename)
```