Add documentation routes and update README
- Update README with comprehensive documentation covering ActivityPub, OpenTimestamps anchoring, L1 integration, and all API endpoints - Add /docs routes to serve markdown documentation as styled HTML - Include common library documentation in web interface 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
488
README.md
488
README.md
@@ -1,26 +1,34 @@
|
||||
# Art DAG L2 Server - ActivityPub
|
||||
|
||||
Ownership registry and ActivityPub federation for Art DAG.
|
||||
Ownership registry and ActivityPub federation for Art DAG. Manages asset provenance, cryptographic anchoring, and distributed identity.
|
||||
|
||||
## What it does
|
||||
## Features
|
||||
|
||||
- **Registry**: Maintains owned assets with content hashes
|
||||
- **Activities**: Creates signed ownership claims (Create activities)
|
||||
- **Federation**: ActivityPub endpoints for follow/share
|
||||
- **L1 Integration**: Records completed L1 runs as owned assets
|
||||
- **Authentication**: User registration, login, JWT tokens
|
||||
- **Asset Registry**: Content-addressed assets with provenance tracking
|
||||
- **ActivityPub Federation**: Standard protocol for distributed social networking
|
||||
- **OpenTimestamps Anchoring**: Cryptographic proof of existence on Bitcoin blockchain
|
||||
- **L1 Integration**: Record and verify L1 rendering runs
|
||||
- **Storage Providers**: S3, IPFS, and local storage backends
|
||||
- **Scoped Authentication**: Secure token-based auth for federated L1 servers
|
||||
|
||||
## Setup
|
||||
## Dependencies
|
||||
|
||||
- **PostgreSQL**: Primary data storage
|
||||
- **artdag-common**: Shared templates and middleware
|
||||
- **cryptography**: RSA key generation and signing
|
||||
- **httpx**: Async HTTP client for federation
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Configure (optional - defaults shown)
|
||||
export ARTDAG_DOMAIN=artdag.rose-ash.com
|
||||
# Configure
|
||||
export ARTDAG_DOMAIN=artdag.example.com
|
||||
export ARTDAG_USER=giles
|
||||
export ARTDAG_DATA=~/.artdag/l2
|
||||
export DATABASE_URL=postgresql://artdag:artdag@localhost:5432/artdag
|
||||
export L1_SERVERS=https://celery-artdag.rose-ash.com
|
||||
export L1_SERVERS=https://celery-artdag.example.com
|
||||
|
||||
# Generate signing keys (required for federation)
|
||||
python setup_keys.py
|
||||
@@ -29,142 +37,265 @@ python setup_keys.py
|
||||
python server.py
|
||||
```
|
||||
|
||||
## JWT Secret Configuration
|
||||
|
||||
The JWT secret is used to sign authentication tokens. **Without a persistent secret, tokens are invalidated on server restart.**
|
||||
|
||||
### Generate a secret
|
||||
## Docker Deployment
|
||||
|
||||
```bash
|
||||
# Generate a 64-character hex secret
|
||||
docker stack deploy -c docker-compose.yml artdag-l2
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ARTDAG_DOMAIN` | `artdag.rose-ash.com` | Domain for ActivityPub actors |
|
||||
| `ARTDAG_USER` | `giles` | Default username |
|
||||
| `ARTDAG_DATA` | `~/.artdag/l2` | Data directory |
|
||||
| `DATABASE_URL` | `postgresql://artdag:artdag@localhost:5432/artdag` | PostgreSQL connection |
|
||||
| `L1_SERVERS` | - | Comma-separated list of L1 server URLs |
|
||||
| `JWT_SECRET` | (generated) | JWT signing secret |
|
||||
| `HOST` | `0.0.0.0` | Server bind address |
|
||||
| `PORT` | `8200` | Server port |
|
||||
|
||||
### JWT Secret
|
||||
|
||||
The JWT secret signs authentication tokens. Without a persistent secret, tokens are invalidated on restart.
|
||||
|
||||
```bash
|
||||
# Generate a secret
|
||||
openssl rand -hex 32
|
||||
# Or with Python
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
|
||||
# Set in environment
|
||||
export JWT_SECRET="your-generated-secret"
|
||||
|
||||
# Or use Docker secrets (recommended for production)
|
||||
echo "your-secret" | docker secret create jwt_secret -
|
||||
```
|
||||
|
||||
### Local development
|
||||
### RSA Keys
|
||||
|
||||
ActivityPub requires RSA keys for signing activities:
|
||||
|
||||
```bash
|
||||
export JWT_SECRET="your-generated-secret-here"
|
||||
python server.py
|
||||
```
|
||||
|
||||
### Docker Swarm (recommended for production)
|
||||
|
||||
Create a Docker secret:
|
||||
```bash
|
||||
# From a generated value
|
||||
openssl rand -hex 32 | docker secret create jwt_secret -
|
||||
|
||||
# Or from a file
|
||||
echo "your-secret-here" > jwt_secret.txt
|
||||
docker secret create jwt_secret jwt_secret.txt
|
||||
rm jwt_secret.txt
|
||||
```
|
||||
|
||||
Reference in docker-compose.yml:
|
||||
```yaml
|
||||
services:
|
||||
l2-server:
|
||||
secrets:
|
||||
- jwt_secret
|
||||
|
||||
secrets:
|
||||
jwt_secret:
|
||||
external: true
|
||||
```
|
||||
|
||||
The server reads secrets from `/run/secrets/jwt_secret` automatically.
|
||||
|
||||
## Key Setup
|
||||
|
||||
ActivityPub requires RSA keys for signing activities. Generate them:
|
||||
|
||||
```bash
|
||||
# Local
|
||||
# Generate keys
|
||||
python setup_keys.py
|
||||
|
||||
# Or with custom paths
|
||||
python setup_keys.py --data-dir /data/l2 --user giles
|
||||
|
||||
# In Docker, exec into container or mount volume
|
||||
docker exec -it <container> python setup_keys.py
|
||||
```
|
||||
|
||||
Keys are stored in `$ARTDAG_DATA/keys/`:
|
||||
- `{username}.pem` - Private key (chmod 600, NEVER share)
|
||||
- `{username}.pub` - Public key (included in actor profile)
|
||||
|
||||
**Important**: Private keys are gitignored. Back them up securely. Losing them invalidates all your signatures.
|
||||
|
||||
## L1 Renderers Configuration
|
||||
|
||||
The `L1_SERVERS` environment variable defines which L1 rendering servers are available to users. Users can attach to these servers from the Renderers page to run effects and manage media.
|
||||
|
||||
```bash
|
||||
# Single server (default)
|
||||
export L1_SERVERS=https://celery-artdag.rose-ash.com
|
||||
|
||||
# Multiple servers (comma-separated)
|
||||
export L1_SERVERS=https://celery-artdag.rose-ash.com,https://renderer2.example.com,https://renderer3.example.com
|
||||
```
|
||||
|
||||
When a user attaches to an L1 server:
|
||||
1. L2 creates a **scoped token** that only works for that specific L1
|
||||
2. User is redirected to the L1's `/auth` endpoint with the scoped token
|
||||
3. L1 calls back to L2's `/auth/verify` endpoint to validate the token
|
||||
4. L2 verifies the token scope matches the requesting L1
|
||||
5. L1 sets its own local cookie, logging the user in
|
||||
6. The attachment is recorded in L2's `user_renderers` table
|
||||
|
||||
### Security Features
|
||||
|
||||
**No shared secrets**: L1 servers verify tokens by calling L2's `/auth/verify` endpoint. Any L1 provider can federate without the JWT secret.
|
||||
|
||||
**L1 authorization**: Only servers listed in `L1_SERVERS` can verify tokens. L1 must identify itself in verify requests.
|
||||
|
||||
**Scoped tokens**: Tokens issued for attachment contain an `l1_server` claim. A token scoped to L1-A cannot be used on L1-B, preventing malicious L1s from stealing tokens for use elsewhere.
|
||||
|
||||
**Federated logout**: When a user logs out of L2:
|
||||
1. L2 revokes the token in its database (`revoked_tokens` table)
|
||||
2. L2 calls `/auth/revoke` on all attached L1s to revoke their local copies
|
||||
3. All attachments are removed from `user_renderers`
|
||||
|
||||
Even if a malicious L1 ignores the revoke call, the token will fail verification at L2 because it's in the revocation table.
|
||||
|
||||
**Token revocation on L1**: L1 servers maintain their own Redis-based revocation list and check it on every authenticated request.
|
||||
|
||||
Users can manage attachments at `/renderers`.
|
||||
Keys stored in `$ARTDAG_DATA/keys/`:
|
||||
- `{username}.pem` - Private key (chmod 600)
|
||||
- `{username}.pub` - Public key (in actor profile)
|
||||
|
||||
## Web UI
|
||||
|
||||
The server provides a web interface:
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/` | Home page with stats and README |
|
||||
| `/` | Home page with stats |
|
||||
| `/login` | Login form |
|
||||
| `/register` | Registration form |
|
||||
| `/logout` | Log out |
|
||||
| `/assets` | Browse registered assets |
|
||||
| `/asset/{name}` | Asset detail page |
|
||||
| `/activities` | View published activities |
|
||||
| `/activity/{id}` | Activity detail page |
|
||||
| `/users` | List registered users |
|
||||
| `/renderers` | Manage L1 renderer connections |
|
||||
| `/anchors/ui` | OpenTimestamps anchor management |
|
||||
| `/login` | Login page |
|
||||
| `/register` | Registration page |
|
||||
| `/logout` | Log out |
|
||||
| `/activities` | Published activities |
|
||||
| `/activity/{id}` | Activity detail |
|
||||
| `/users` | Registered users |
|
||||
| `/renderers` | L1 renderer connections |
|
||||
| `/anchors/ui` | OpenTimestamps management |
|
||||
| `/storage` | Storage provider config |
|
||||
| `/download/client` | Download CLI client |
|
||||
|
||||
## Client Commands
|
||||
## API Reference
|
||||
|
||||
### Upload Media
|
||||
Interactive docs: http://localhost:8200/docs
|
||||
|
||||
Register a media asset (image, video, audio) with a content hash:
|
||||
### Authentication
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/auth/register` | Register new user |
|
||||
| POST | `/auth/login` | Login, get JWT token |
|
||||
| GET | `/auth/me` | Get current user info |
|
||||
| POST | `/auth/verify` | Verify token (for L1 servers) |
|
||||
|
||||
### Assets
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/assets` | List all assets |
|
||||
| GET | `/assets/{name}` | Get asset by name |
|
||||
| POST | `/assets` | Register new asset |
|
||||
| PATCH | `/assets/{name}` | Update asset metadata |
|
||||
| POST | `/assets/record-run` | Record L1 run as asset |
|
||||
| POST | `/assets/publish-cache` | Publish L1 cache item |
|
||||
| GET | `/assets/by-run-id/{run_id}` | Find asset by L1 run ID |
|
||||
|
||||
### ActivityPub
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/.well-known/webfinger` | Actor discovery |
|
||||
| GET | `/users/{username}` | Actor profile |
|
||||
| GET | `/users/{username}/outbox` | Published activities |
|
||||
| POST | `/users/{username}/inbox` | Receive activities |
|
||||
| GET | `/users/{username}/followers` | Followers list |
|
||||
| GET | `/objects/{hash}` | Get object by content hash |
|
||||
| GET | `/activities` | List activities (paginated) |
|
||||
| GET | `/activities/{ref}` | Get activity by reference |
|
||||
| GET | `/activity/{index}` | Get activity by index |
|
||||
|
||||
### OpenTimestamps Anchoring
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/anchors/create` | Create timestamp anchor |
|
||||
| GET | `/anchors` | List all anchors |
|
||||
| GET | `/anchors/{merkle_root}` | Get anchor details |
|
||||
| GET | `/anchors/{merkle_root}/tree` | Get merkle tree |
|
||||
| GET | `/anchors/verify/{activity_id}` | Verify activity timestamp |
|
||||
| POST | `/anchors/{merkle_root}/upgrade` | Upgrade pending timestamp |
|
||||
| GET | `/anchors/ui` | Anchor management UI |
|
||||
| POST | `/anchors/test-ots` | Test OTS functionality |
|
||||
|
||||
### Renderers (L1 Connections)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/renderers` | List attached L1 servers |
|
||||
| GET | `/renderers/attach` | Initiate L1 attachment |
|
||||
| POST | `/renderers/detach` | Detach from L1 server |
|
||||
|
||||
### Storage Providers
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/storage` | List storage providers |
|
||||
| POST | `/storage` | Add provider (form) |
|
||||
| POST | `/storage/add` | Add provider (JSON) |
|
||||
| GET | `/storage/{id}` | Get provider details |
|
||||
| PATCH | `/storage/{id}` | Update provider |
|
||||
| DELETE | `/storage/{id}` | Delete provider |
|
||||
| POST | `/storage/{id}/test` | Test connection |
|
||||
| GET | `/storage/type/{type}` | Get form for provider type |
|
||||
|
||||
## L1 Renderer Integration
|
||||
|
||||
L2 coordinates with L1 rendering servers for distributed processing.
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8200/assets \
|
||||
-H "Content-Type: application/json" \
|
||||
# Single L1 server
|
||||
export L1_SERVERS=https://celery-artdag.rose-ash.com
|
||||
|
||||
# Multiple L1 servers
|
||||
export L1_SERVERS=https://server1.example.com,https://server2.example.com
|
||||
```
|
||||
|
||||
### Attachment Flow
|
||||
|
||||
1. User visits `/renderers` and clicks "Attach"
|
||||
2. L2 creates a **scoped token** bound to the specific L1
|
||||
3. User redirected to L1's `/auth?auth_token=...`
|
||||
4. L1 calls L2's `/auth/verify` to validate
|
||||
5. L2 checks token scope matches requesting L1
|
||||
6. L1 sets local cookie, attachment recorded in `user_renderers`
|
||||
|
||||
### Security
|
||||
|
||||
- **Scoped tokens**: Tokens bound to specific L1; can't be used elsewhere
|
||||
- **No shared secrets**: L1 verifies via L2's `/auth/verify` endpoint
|
||||
- **Federated logout**: L2 revokes tokens on all attached L1s
|
||||
|
||||
## OpenTimestamps Anchoring
|
||||
|
||||
Cryptographic proof of existence using Bitcoin blockchain.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Activities are collected into merkle trees
|
||||
2. Merkle root submitted to Bitcoin via OpenTimestamps
|
||||
3. Pending proofs upgraded when Bitcoin confirms
|
||||
4. Final proof verifiable without trusted third parties
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
# Verify an activity's timestamp
|
||||
curl https://artdag.example.com/anchors/verify/123
|
||||
|
||||
# Returns:
|
||||
{
|
||||
"activity_id": 123,
|
||||
"merkle_root": "abc123...",
|
||||
"status": "confirmed",
|
||||
"bitcoin_block": 800000,
|
||||
"verified_at": "2026-01-01T..."
|
||||
}
|
||||
```
|
||||
|
||||
## Data Model
|
||||
|
||||
### PostgreSQL Tables
|
||||
|
||||
| Table | Description |
|
||||
|-------|-------------|
|
||||
| `users` | Registered users with hashed passwords |
|
||||
| `assets` | Asset registry with content hashes |
|
||||
| `activities` | Signed ActivityPub activities |
|
||||
| `followers` | Follower relationships |
|
||||
| `anchors` | OpenTimestamps anchor records |
|
||||
| `anchor_activities` | Activity-to-anchor mappings |
|
||||
| `user_renderers` | L1 attachment records |
|
||||
| `revoked_tokens` | Token revocation list |
|
||||
| `storage_providers` | Storage configurations |
|
||||
|
||||
### Asset Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-video",
|
||||
"content_hash": "sha3-256:abc123...",
|
||||
"asset_type": "video",
|
||||
"owner": "@giles@artdag.rose-ash.com",
|
||||
"created_at": "2026-01-01T...",
|
||||
"provenance": {
|
||||
"inputs": [...],
|
||||
"recipe": "beat-sync",
|
||||
"l1_server": "https://celery-artdag.rose-ash.com",
|
||||
"run_id": "..."
|
||||
},
|
||||
"tags": ["art", "generated"]
|
||||
}
|
||||
```
|
||||
|
||||
### Activity Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "Create",
|
||||
"actor": "https://artdag.rose-ash.com/users/giles",
|
||||
"object": {
|
||||
"type": "Document",
|
||||
"name": "my-video",
|
||||
"content": "sha3-256:abc123...",
|
||||
"attributedTo": "https://artdag.rose-ash.com/users/giles"
|
||||
},
|
||||
"published": "2026-01-01T..."
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### Register Asset
|
||||
|
||||
```bash
|
||||
curl -X POST https://artdag.example.com/assets \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "my-video",
|
||||
"content_hash": "abc123...",
|
||||
@@ -173,14 +304,12 @@ curl -X POST http://localhost:8200/assets \
|
||||
}'
|
||||
```
|
||||
|
||||
### Upload Recipe
|
||||
|
||||
Record an L1 run as an owned asset. This fetches the run details from the L1 server and registers the output:
|
||||
### Record L1 Run
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8200/assets/record-run \
|
||||
-H "Content-Type: application/json" \
|
||||
curl -X POST https://artdag.example.com/assets/record-run \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"run_id": "uuid-from-l1",
|
||||
"l1_server": "https://celery-artdag.rose-ash.com",
|
||||
@@ -188,62 +317,73 @@ curl -X POST http://localhost:8200/assets/record-run \
|
||||
}'
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
### Publish L1 Cache Item
|
||||
|
||||
### Server Info
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/` | Home page with stats |
|
||||
|
||||
### Assets
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/assets` | List all assets |
|
||||
| GET | `/assets/{name}` | Get asset by name |
|
||||
| POST | `/assets` | Upload media - register new asset |
|
||||
| POST | `/assets/record-run` | Upload recipe - record L1 run |
|
||||
|
||||
### ActivityPub
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/.well-known/webfinger?resource=acct:user@domain` | Actor discovery |
|
||||
| GET | `/users/{username}` | Actor profile |
|
||||
| GET | `/users/{username}/outbox` | Published activities |
|
||||
| POST | `/users/{username}/inbox` | Receive activities |
|
||||
| GET | `/users/{username}/followers` | Followers list |
|
||||
| GET | `/objects/{content_hash}` | Get object by hash |
|
||||
| GET | `/activities/{index}` | Get activity by index |
|
||||
|
||||
### Authentication
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/auth/register` | Register new user (API) |
|
||||
| POST | `/auth/login` | Login, get JWT token (API) |
|
||||
| GET | `/auth/me` | Get current user |
|
||||
| POST | `/auth/verify` | Verify token (for L1 servers) |
|
||||
|
||||
## Data Storage
|
||||
|
||||
Data stored in PostgreSQL:
|
||||
- `users` - Registered users
|
||||
- `assets` - Asset registry
|
||||
- `activities` - Signed activities
|
||||
- `followers` - Followers list
|
||||
|
||||
RSA keys stored in `$ARTDAG_DATA/keys/` (files, not database).
|
||||
```bash
|
||||
curl -X POST https://artdag.example.com/assets/publish-cache \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"content_hash": "abc123...",
|
||||
"l1_server": "https://celery-artdag.rose-ash.com",
|
||||
"name": "my-asset",
|
||||
"asset_type": "video"
|
||||
}'
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
L2 Server (port 8200)
|
||||
L2 Server (FastAPI)
|
||||
│
|
||||
├── POST /assets (upload media) → Register asset → Create activity → Sign
|
||||
├── Web UI (Jinja2 + HTMX + Tailwind)
|
||||
│
|
||||
├── POST /assets/record-run (upload recipe) → Fetch L1 run → Register output
|
||||
├── /assets → Asset Registry
|
||||
│ │
|
||||
│ └── GET L1_SERVER/runs/{id}
|
||||
│ └── PostgreSQL (assets table)
|
||||
│
|
||||
├── GET /users/{user}/outbox → Return signed activities
|
||||
├── /users/{user}/outbox → ActivityPub
|
||||
│ │
|
||||
│ ├── Sign activities (RSA)
|
||||
│ └── PostgreSQL (activities table)
|
||||
│
|
||||
└── POST /users/{user}/inbox → Receive Follow requests
|
||||
├── /anchors → OpenTimestamps
|
||||
│ │
|
||||
│ ├── Merkle tree construction
|
||||
│ └── Bitcoin anchoring
|
||||
│
|
||||
├── /auth/verify → L1 Token Verification
|
||||
│ │
|
||||
│ └── Scoped token validation
|
||||
│
|
||||
└── /storage → Storage Providers
|
||||
│
|
||||
├── S3 (boto3)
|
||||
├── IPFS (ipfs_client)
|
||||
└── Local filesystem
|
||||
```
|
||||
|
||||
## Federation
|
||||
|
||||
L2 implements ActivityPub for federated asset sharing.
|
||||
|
||||
### Discovery
|
||||
|
||||
```bash
|
||||
# Webfinger lookup
|
||||
curl "https://artdag.example.com/.well-known/webfinger?resource=acct:giles@artdag.example.com"
|
||||
```
|
||||
|
||||
### Actor Profile
|
||||
|
||||
```bash
|
||||
curl -H "Accept: application/activity+json" \
|
||||
https://artdag.example.com/users/giles
|
||||
```
|
||||
|
||||
### Outbox
|
||||
|
||||
```bash
|
||||
curl -H "Accept: application/activity+json" \
|
||||
https://artdag.example.com/users/giles/outbox
|
||||
```
|
||||
|
||||
184
server.py
184
server.py
@@ -3682,6 +3682,190 @@ async def download_client():
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Documentation Routes
|
||||
# ============================================================================
|
||||
|
||||
# Documentation paths
|
||||
L2_DOCS_DIR = Path(__file__).parent
|
||||
COMMON_DOCS_DIR = Path(__file__).parent.parent / "common"
|
||||
|
||||
L2_DOCS_MAP = {
|
||||
"l2": L2_DOCS_DIR / "README.md",
|
||||
"common": COMMON_DOCS_DIR / "README.md",
|
||||
}
|
||||
|
||||
|
||||
def render_markdown(content: str) -> str:
|
||||
"""Convert markdown to HTML with basic styling."""
|
||||
import re
|
||||
|
||||
# Escape HTML first
|
||||
content = content.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
# Code blocks (``` ... ```)
|
||||
def code_block_replace(match):
|
||||
lang = match.group(1) or ""
|
||||
code = match.group(2)
|
||||
return f'<pre class="bg-gray-800 p-4 rounded-lg overflow-x-auto text-sm"><code class="language-{lang}">{code}</code></pre>'
|
||||
content = re.sub(r'```(\w*)\n(.*?)```', code_block_replace, content, flags=re.DOTALL)
|
||||
|
||||
# Inline code
|
||||
content = re.sub(r'`([^`]+)`', r'<code class="bg-gray-700 px-1 rounded text-sm">\1</code>', content)
|
||||
|
||||
# Headers
|
||||
content = re.sub(r'^### (.+)$', r'<h3 class="text-lg font-semibold text-white mt-6 mb-2">\1</h3>', content, flags=re.MULTILINE)
|
||||
content = re.sub(r'^## (.+)$', r'<h2 class="text-xl font-bold text-white mt-8 mb-3 border-b border-gray-700 pb-2">\1</h2>', content, flags=re.MULTILINE)
|
||||
content = re.sub(r'^# (.+)$', r'<h1 class="text-2xl font-bold text-white mb-4">\1</h1>', content, flags=re.MULTILINE)
|
||||
|
||||
# Bold and italic
|
||||
content = re.sub(r'\*\*([^*]+)\*\*', r'<strong class="font-semibold">\1</strong>', content)
|
||||
content = re.sub(r'\*([^*]+)\*', r'<em>\1</em>', content)
|
||||
|
||||
# Links
|
||||
content = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2" class="text-blue-400 hover:underline">\1</a>', content)
|
||||
|
||||
# Tables
|
||||
def table_replace(match):
|
||||
lines = match.group(0).strip().split('\n')
|
||||
if len(lines) < 2:
|
||||
return match.group(0)
|
||||
|
||||
header = lines[0]
|
||||
rows = lines[2:] if len(lines) > 2 else []
|
||||
|
||||
header_cells = [cell.strip() for cell in header.split('|')[1:-1]]
|
||||
header_html = ''.join(f'<th class="px-4 py-2 text-left border-b border-gray-600">{cell}</th>' for cell in header_cells)
|
||||
|
||||
rows_html = ''
|
||||
for row in rows:
|
||||
cells = [cell.strip() for cell in row.split('|')[1:-1]]
|
||||
cells_html = ''.join(f'<td class="px-4 py-2 border-b border-gray-700">{cell}</td>' for cell in cells)
|
||||
rows_html += f'<tr class="hover:bg-gray-700">{cells_html}</tr>'
|
||||
|
||||
return f'<table class="w-full text-sm mb-4"><thead><tr class="bg-gray-700">{header_html}</tr></thead><tbody>{rows_html}</tbody></table>'
|
||||
|
||||
content = re.sub(r'(\|[^\n]+\|\n)+', table_replace, content)
|
||||
|
||||
# Bullet points
|
||||
content = re.sub(r'^- (.+)$', r'<li class="ml-4 list-disc">\1</li>', content, flags=re.MULTILINE)
|
||||
content = re.sub(r'(<li[^>]*>.*</li>\n?)+', r'<ul class="mb-4">\g<0></ul>', content)
|
||||
|
||||
# Paragraphs (lines not starting with < or whitespace)
|
||||
lines = content.split('\n')
|
||||
result = []
|
||||
in_paragraph = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
if in_paragraph:
|
||||
result.append('</p>')
|
||||
in_paragraph = False
|
||||
result.append('')
|
||||
elif stripped.startswith('<'):
|
||||
if in_paragraph:
|
||||
result.append('</p>')
|
||||
in_paragraph = False
|
||||
result.append(line)
|
||||
else:
|
||||
if not in_paragraph:
|
||||
result.append('<p class="mb-4 text-gray-300">')
|
||||
in_paragraph = True
|
||||
result.append(line)
|
||||
if in_paragraph:
|
||||
result.append('</p>')
|
||||
content = '\n'.join(result)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
@app.get("/docs", response_class=HTMLResponse)
|
||||
async def docs_index(request: Request):
|
||||
"""Documentation index page."""
|
||||
user = await get_optional_user(request)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html class="dark">
|
||||
<head>
|
||||
<title>Documentation - Art DAG L2</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>tailwind.config = {{ darkMode: 'class' }}</script>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 min-h-screen">
|
||||
<nav class="bg-gray-800 border-b border-gray-700 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" class="text-xl font-bold text-white">Art DAG L2</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/assets" class="text-gray-300 hover:text-white">Assets</a>
|
||||
<a href="/activities" class="text-gray-300 hover:text-white">Activities</a>
|
||||
<a href="/anchors/ui" class="text-gray-300 hover:text-white">Anchors</a>
|
||||
<a href="/docs" class="text-white font-semibold">Docs</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="max-w-4xl mx-auto p-8">
|
||||
<h1 class="text-3xl font-bold mb-8">Documentation</h1>
|
||||
<div class="grid gap-4">
|
||||
<a href="/docs/l2" class="block p-6 bg-gray-800 rounded-lg hover:bg-gray-700 transition">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">L2 Server (ActivityPub)</h2>
|
||||
<p class="text-gray-400">Ownership registry, ActivityPub federation, and OpenTimestamps anchoring.</p>
|
||||
</a>
|
||||
<a href="/docs/common" class="block p-6 bg-gray-800 rounded-lg hover:bg-gray-700 transition">
|
||||
<h2 class="text-xl font-semibold text-white mb-2">Common Library</h2>
|
||||
<p class="text-gray-400">Shared components: Jinja2 templates, middleware, content negotiation, and utilities.</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.get("/docs/{doc_name}", response_class=HTMLResponse)
|
||||
async def docs_page(doc_name: str, request: Request):
|
||||
"""Render a markdown documentation file as HTML."""
|
||||
if doc_name not in L2_DOCS_MAP:
|
||||
raise HTTPException(404, f"Documentation '{doc_name}' not found")
|
||||
|
||||
doc_path = L2_DOCS_MAP[doc_name]
|
||||
if not doc_path.exists():
|
||||
raise HTTPException(404, f"Documentation file not found: {doc_path}")
|
||||
|
||||
content = doc_path.read_text()
|
||||
html_content = render_markdown(content)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html class="dark">
|
||||
<head>
|
||||
<title>{doc_name.upper()} - Art DAG Documentation</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>tailwind.config = {{ darkMode: 'class' }}</script>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 min-h-screen">
|
||||
<nav class="bg-gray-800 border-b border-gray-700 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="/" class="text-xl font-bold text-white">Art DAG L2</a>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/assets" class="text-gray-300 hover:text-white">Assets</a>
|
||||
<a href="/activities" class="text-gray-300 hover:text-white">Activities</a>
|
||||
<a href="/anchors/ui" class="text-gray-300 hover:text-white">Anchors</a>
|
||||
<a href="/docs" class="text-white font-semibold">Docs</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="max-w-4xl mx-auto p-8">
|
||||
<div class="mb-4">
|
||||
<a href="/docs" class="text-blue-400 hover:underline">← Back to Documentation</a>
|
||||
</div>
|
||||
<article class="prose prose-invert max-w-none">
|
||||
{html_content}
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("server:app", host="0.0.0.0", port=8200, workers=4)
|
||||
|
||||
Reference in New Issue
Block a user