This commit is contained in:
2025-09-17 07:49:27 +02:00
parent 48607d710e
commit 59cf09f73a
10 changed files with 690 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
module heroserver
import net.http
import json
import veb
// Setup API routes
pub fn (mut server HeroServer) setup_api_routes() ! {
// Authentication endpoints
server.app.register_controller[AuthController, Context]('/auth', mut &AuthController{server: server})!
// API endpoints for each handler type
for handler_type, _ in server.handlers {
controller := &APIController{
server: server
handler_type: handler_type
}
server.app.register_controller[APIController, Context]('/api/${handler_type}', mut controller)!
}
}
// Authentication controller
pub struct AuthController {
mut:
server &HeroServer
}
@[post; '/register']
pub fn (mut controller AuthController) register(mut ctx Context) veb.Result {
// Parse JSON request
request := json.decode(RegisterRequest, ctx.req.data) or {
return ctx.request_error('Invalid JSON format')
}
// Register public key
controller.server.register(request.pubkey) or {
return ctx.request_error('Registration failed: ${err}')
}
return ctx.json({'status': 'success'})
}
@[post; '/authreq']
pub fn (mut controller AuthController) auth_request(mut ctx Context) veb.Result {
// Parse JSON request
request := json.decode(AuthRequest, ctx.req.data) or {
return ctx.request_error('Invalid JSON format')
}
// Generate challenge
response := controller.server.auth_request(request.pubkey) or {
return ctx.request_error('Auth request failed: ${err}')
}
return ctx.json(response)
}
@[post; '/auth']
pub fn (mut controller AuthController) auth_submit(mut ctx Context) veb.Result {
// Parse JSON request
request := json.decode(AuthSubmitRequest, ctx.req.data) or {
return ctx.request_error('Invalid JSON format')
}
// Verify and create session
response := controller.server.auth_submit(request.pubkey, request.signature) or {
return ctx.request_error('Authentication failed: ${err}')
}
return ctx.json(response)
}
// API controller for specific handler types
pub struct APIController {
mut:
server &HeroServer
handler_type string
}
@[post; '/:method']
pub fn (mut controller APIController) handle_api_call(mut ctx Context, method string) veb.Result {
// Extract session key from header
session_key := ctx.get_header(.authorization) or {
return ctx.request_error('Missing session key in Authorization header')
}.replace('Bearer ', '')
// Validate session
controller.server.validate_session(session_key) or {
return ctx.server_error('Invalid or expired session: ${err}')
}
// Get handler
handler := controller.server.handlers[controller.handler_type] or {
return ctx.server_error('Handler not found')
}
// Parse request parameters
params := if ctx.req.data.len > 0 {
json.decode(map[string]string, ctx.req.data) or { map[string]string{} }
} else {
map[string]string{}
}
// Call handler method
result := handler.call_method(method, params) or {
return ctx.server_error('Method call failed: ${err}')
}
return ctx.json(result)
}

107
lib/hero/heroserver/auth.v Normal file
View File

@@ -0,0 +1,107 @@
module heroserver
import crypto.md5
import crypto.rand
import time
import encoding.base64
// Active challenges storage
mut challenges := map[string]AuthChallenge{}
// Register a public key (currently just validates format)
pub fn (mut server HeroServer) register(pubkey string) ! {
// Validate public key format
if pubkey.len < 10 {
return error('Invalid public key format')
}
// For now, just return success
// In future versions, could store registered keys
}
// Request authentication challenge
pub fn (mut server HeroServer) auth_request(pubkey string) !AuthResponse {
// Generate random challenge data
random_bytes := rand.bytes(32)!
challenge_data := '${pubkey}:${random_bytes.hex()}:${time.now().unix}'
// Create MD5 hash of challenge
challenge := md5.hexhash(challenge_data)
// Store challenge with expiration
challenges[pubkey] = AuthChallenge{
pubkey: pubkey
challenge: challenge
created_at: time.now()
expires_at: time.now().add_seconds(300) // 5 minute expiry
}
return AuthResponse{
challenge: challenge
}
}
// Submit signed challenge for authentication
pub fn (mut server HeroServer) auth_submit(pubkey string, signature string) !AuthSubmitResponse {
// Get stored challenge
challenge_data := challenges[pubkey] or {
return error('No active challenge for this public key')
}
// Check if challenge expired
if time.now() > challenge_data.expires_at {
challenges.delete(pubkey)
return error('Challenge expired')
}
// Verify signature using HeroCrypt
// Note: We need the verification key, which should be derived from pubkey
// For now, assume pubkey is the verification key in correct format
is_valid := server.crypto_client.verify(pubkey, challenge_data.challenge, signature)!
if !is_valid {
return error('Invalid signature')
}
// Generate session key
session_data := '${pubkey}:${time.now().unix}:${rand.bytes(16)!.hex()}'
session_key := md5.hexhash(session_data)
// Create session
session := Session{
session_key: session_key
pubkey: pubkey
created_at: time.now()
last_activity: time.now()
expires_at: time.now().add_seconds(3600 * 24) // 24 hour session
}
// Store session
server.sessions[session_key] = session
// Clean up challenge
challenges.delete(pubkey)
return AuthSubmitResponse{
session_key: session_key
}
}
// Validate session key
pub fn (mut server HeroServer) validate_session(session_key string) !Session {
mut session := server.sessions[session_key] or {
return error('Invalid session key')
}
// Check if session expired
if time.now() > session.expires_at {
server.sessions.delete(session_key)
return error('Session expired')
}
// Update last activity
session.last_activity = time.now()
server.sessions[session_key] = session
return session
}

View File

@@ -0,0 +1,38 @@
module heroserver
import veb
import freeflowuniverse.herolib.schemas.openrpc
// Documentation controller
pub struct DocController {
mut:
server &HeroServer
handler_type string
}
@[get; '/']
pub fn (mut controller DocController) show_docs(mut ctx Context) veb.Result {
// Get handler
handler := controller.server.handlers[controller.handler_type] or {
return ctx.not_found()
}
// Get OpenRPC specification
spec := handler.get_openrpc_spec()
// Render template
html_content := $tmpl('templates/doc.html')
return ctx.html(html_content)
}
// Setup documentation routes
pub fn (mut server HeroServer) setup_doc_routes() ! {
for handler_type, _ in server.handlers {
controller := &DocController{
server: server
handler_type: handler_type
}
server.app.register_controller[DocController, Context]('/doc/${handler_type}', mut controller)!
}
}

View File

@@ -0,0 +1,59 @@
module heroserver
import freeflowuniverse.herolib.crypt.herocrypt
import veb
// Create a new HeroServer instance
pub fn new(config HeroServerConfig) !&HeroServer {
// Initialize crypto client
mut crypto_client := if c := config.crypto_client {
c
} else {
herocrypt.new_default()!
}
// Create VEB app
mut app := &veb.StaticHandler{}
mut server := &HeroServer{
port: config.port
host: config.host
crypto_client: crypto_client
sessions: map[string]Session{}
handlers: map[string]openrpc.OpenRPCHandler{}
app: app
}
return server
}
// Register an OpenRPC handler
pub fn (mut server HeroServer) register_handler(handler_type string, handler openrpc.OpenRPCHandler) ! {
server.handlers[handler_type] = handler
}
// Start the server
pub fn (mut server HeroServer) start() ! {
// Setup routes
server.setup_routes()!
// Start VEB server
veb.run_at[HeroServer, Context](mut server,
host: server.host,
port: server.port
)!
}
// Context struct for VEB
pub struct Context {
veb.Context
}
// Add to HeroServer struct
pub fn (mut server HeroServer) setup_routes() ! {
// Setup authentication routes
server.setup_api_routes()!
// Setup documentation routes
server.setup_doc_routes()!
}

View File

@@ -0,0 +1,85 @@
module heroserver
import freeflowuniverse.herolib.crypt.herocrypt
import freeflowuniverse.herolib.schemas.openrpc
import time
import net.http
// Main server configuration
@[params]
pub struct HeroServerConfig {
pub mut:
port int = 9977
host string = 'localhost'
// Optional crypto client, will create default if not provided
crypto_client ?&herocrypt.HeroCrypt
}
// Main server struct
pub struct HeroServer {
mut:
port int
host string
crypto_client &herocrypt.HeroCrypt
sessions map[string]Session // sessionkey -> Session
handlers map[string]openrpc.OpenRPCHandler // handlertype -> handler
challenges map[string]AuthChallenge
pub mut:
app &veb.StaticHandler
challenges map[string]AuthChallenge
}
// Authentication challenge data
pub struct AuthChallenge {
pub mut:
pubkey string
challenge string // unique hashed challenge
created_at time.Time
expires_at time.Time
}
// Active session data
pub struct Session {
pub mut:
session_key string
pubkey string
created_at time.Time
last_activity time.Time
expires_at time.Time
}
// Authentication request structures
pub struct RegisterRequest {
pub:
pubkey string
}
pub struct AuthRequest {
pub:
pubkey string
}
pub struct AuthResponse {
pub:
challenge string
}
pub struct AuthSubmitRequest {
pub:
pubkey string
signature string // signed challenge
}
pub struct AuthSubmitResponse {
pub:
session_key string
}
// API request wrapper
pub struct APIRequest {
pub:
session_key string
method string
params map[string]string
}

View File

@@ -0,0 +1,55 @@
# HeroServer
HeroServer is a secure web server built in V, designed for public key-based authentication and serving OpenRPC APIs and their documentation.
## Features
- **Public Key Authentication**: Secure access using cryptographic signatures.
- **OpenRPC Integration**: Serve APIs defined with the OpenRPC specification.
- **Automatic Documentation**: Generates HTML documentation from your OpenRPC schemas.
- **Session Management**: Manages authenticated user sessions.
- **Extensible**: Register multiple, independent handlers for different API groups.
## Usage
```v
import freeflowuniverse.herolib.hero.heroserver
import freeflowuniverse.herolib.schemas.openrpc
fn main() {
// 1. Create a new server instance
mut server := heroserver.new(port: 8080)!
// 2. Create and register your OpenRPC handlers
// These handlers must conform to the `openrpc.OpenRPCHandler` interface.
calendar_handler := create_calendar_handler() // Your implementation
server.register_handler('calendar', calendar_handler)!
task_handler := create_task_handler() // Your implementation
server.register_handler('tasks', task_handler)!
// 3. Start the server
server.start()! // This call blocks and starts serving requests
}
```
## API Endpoints
- **API Calls**: `POST /api/{handler_type}/{method_name}`
- **Documentation**: `GET /doc/{handler_type}/`
## Authentication Flow
1. **Register Public Key**: `POST /auth/register`
- Body: `{"pubkey": "your_public_key"}`
2. **Request Challenge**: `POST /auth/authreq`
- Body: `{"pubkey": "your_public_key"}`
- Returns a unique challenge string.
3. **Submit Signature**: `POST /auth/auth`
- Sign the challenge from step 2 with your private key.
- Body: `{"pubkey": "your_public_key", "signature": "your_signature"}`
- Returns a session key.
All subsequent API calls must include the session key in the `Authorization` header:
`Authorization: Bearer {session_key}`

View File

@@ -0,0 +1,13 @@
module heroserver
import time
// Active session data
pub struct Session {
pub mut:
session_key string
pubkey string
created_at time.Time
last_activity time.Time
expires_at time.Time
}

View File

@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@{spec.info.title} - API Documentation</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.method-card {
margin-bottom: 1.5rem;
}
.param-table {
font-size: 0.9rem;
}
.code-block {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 0.375rem;
padding: 1rem;
overflow-x: auto;
}
.object-section {
margin-bottom: 2rem;
}
</style>
</head>
<body>
<div class="container mt-4">
<!-- Header -->
<div class="row">
<div class="col-12">
<h1 class="display-4">@{spec.info.title}</h1>
<p class="lead">@{spec.info.description}</p>
<hr>
</div>
</div>
<!-- Authentication Info -->
<div class="row mb-4">
<div class="col-12">
<div class="alert alert-info">
<h5>Authentication Required</h5>
<p>All API endpoints require authentication using a session key obtained through the authentication flow:</p>
<ol>
<li>Register your public key: <code>POST /auth/register</code></li>
<li>Request authentication challenge: <code>POST /auth/authreq</code></li>
<li>Sign challenge and submit: <code>POST /auth/auth</code></li>
<li>Use returned session key in <code>Authorization: Bearer {session_key}</code> header</li>
</ol>
</div>
</div>
</div>
<!-- Group methods by root object -->
@for root_object, methods in spec.methods_by_object() {
<div class="object-section">
<h2 id="@{root_object.name_fix()}">@{root_object}</h2>
@if root_object_desc := spec.get_object_description(root_object) {
<p class="text-muted">@{root_object_desc}</p>
}
@for method in methods {
<div class="card method-card">
<div class="card-header">
<h4 class="mb-0">
<span class="badge bg-primary me-2">POST</span>
<code>/api/@{controller.handler_type}/@{method.name}</code>
</h4>
</div>
<div class="card-body">
<p>@{method.summary}</p>
@if method.description.len > 0 {
<div class="mb-3">
<h6>Description</h6>
<p>@{method.description}</p>
</div>
}
<!-- Parameters -->
@if method.params.len > 0 {
<div class="mb-3">
<h6>Parameters</h6>
<table class="table table-sm param-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
@for param in method.params {
<tr>
<td><code>@{param.name}</code></td>
<td>@{param.schema.type_}</td>
<td>@{param.required ? 'Yes' : 'No'}</td>
<td>@{param.description}</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- Return Type -->
@if method.result {
<div class="mb-3">
<h6>Returns</h6>
<p><strong>Type:</strong> @{method.result.schema.type_}</p>
@if method.result.description.len > 0 {
<p>@{method.result.description}</p>
}
</div>
}
<!-- Example -->
@{example_call, example_response := method.example()}
@if example_call.len > 0 {
<div class="mb-3">
<h6>Example Request</h6>
<div class="code-block">
<pre><code>curl -X POST \
'http://localhost:8080/api/@{controller.handler_type}/@{method.name}' \
-H 'Authorization: Bearer YOUR_SESSION_KEY' \
-H 'Content-Type: application/json' \
-d '@{example_call}'</code></pre>
</div>
</div>
}
@if example_response.len > 0 {
<div class="mb-3">
<h6>Example Response</h6>
<div class="code-block">
<pre><code>@{example_response}</code></pre>
</div>
</div>
}
</div>
</div>
}
</div>
}
<!-- Footer -->
<footer class="mt-5 py-4 bg-light">
<div class="container text-center">
<p class="mb-0">Generated from OpenRPC specification</p>
</div>
</footer>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,9 @@
module jsonschema
pub fn (schema Schema) type_() string {
return schema.typ.str()
}
pub fn (schema Schema) example_value() string {
return ''
}

View File

@@ -0,0 +1,51 @@
module openrpc
import json
// In the OpenRPC specification struct
pub fn (spec OpenRPC) methods_by_object() map[string][]Method {
mut grouped := map[string][]Method{}
for method in spec.methods {
// Extract root object from method name (e.g., "calendar.create" -> "calendar")
parts := method.name.split('.')
root_object := if parts.len > 1 { parts[0] } else { 'general' }
if root_object !in grouped {
grouped[root_object] = []Method{}
}
grouped[root_object] << method
}
return grouped
}
pub fn (spec OpenRPC) get_object_description(object_name string) string {
// Return description for object if available
// Implementation depends on how objects are defined in the spec
return ''
}
pub fn (spec OpenRPC) name_fix() string {
return spec.info.title.replace(' ', '_').to_lower()
}
// In Method struct
pub fn (method Method) example() (string, string) {
// Generate example call and response
// This should create realistic JSON examples based on the method schema
mut example_params := map[string]string{}
for param in method.params {
example_params[param.name] = param.schema.example_value()
}
example_call := json.encode(example_params)
example_response := if method.result {
method.result.schema.example_value()
} else {
'{"result": "success"}'
}
return example_call, example_response
}