Compare commits

..

40 Commits

Author SHA1 Message Date
giles
40b010b8d9 Disable CI — moved to coop/art-dag monorepo 2026-02-24 23:24:39 +00:00
giles
79caa24e21 Add coop internal URL env vars to L2 docker-compose
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m23s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:04:26 +00:00
giles
e3c8b85812 Add coop fragment middleware and env vars
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Fetch nav-tree, auth-menu, cart-mini from coop apps for unified
header. Add INTERNAL_URL env vars for Docker networking. Update
base.html to render fragment blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:03:43 +00:00
giles
2448dabb83 Restyle base.html to use coop header theme
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Match coop light theme: sky header, stone sub-nav pills. Removes
dark-theme nav classes. L2-specific tabs in sub-nav bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:00:26 +00:00
giles
eed5aff238 Add healthcheck and start-first update for l2-server
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m7s
Zero-downtime deploys: new container starts and passes health
check before the old one is stopped. Caddy always has a healthy
backend to proxy to.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 22:20:00 +00:00
giles
859ff0b835 Eliminate ${VAR} substitutions from docker-compose.yml
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m41s
Move DATABASE_URL and POSTGRES_PASSWORD to .env via env_file.
docker stack deploy no longer needs env vars sourced, and
repeat deploys won't trigger spurious restarts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 22:06:40 +00:00
giles
5e45f24fba Source .env before docker stack deploy in CI
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m27s
docker stack deploy does not read .env files automatically
(unlike docker compose), so ${VAR} substitutions resolve to
empty strings. Source .env to export vars before deploying.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 21:44:38 +00:00
gilesb
fbf188afdc Remove hardcoded secrets from public repo
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
- Remove default password fallback from POSTGRES_PASSWORD in docker-compose.yml
- Remove default password fallback from db.py and migrate.py
- Update .env.example with required POSTGRES_PASSWORD
- Update README to mark DATABASE_URL as required

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 19:29:24 +00:00
giles
8f1ba74c53 Add Gitea Actions CI/CD and use private registry
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 12s
Add CI workflow mirroring celery pipeline: SSH to deploy server,
git pull, build and push to registry, deploy docker stack.
Update docker-compose to pull l2-server from registry.rose-ash.com:5000.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 19:23:22 +00:00
gilesb
655f533439 Add /auth/verify endpoint for L1 token verification
L1 servers call this endpoint to verify tokens during auth callback.
Returns user info if token is valid, 401 if invalid or revoked.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 15:49:39 +00:00
gilesb
8c4a30d18f Simplify renderers to use env-configured L1 servers
- L1 servers now come from L1_SERVERS env var instead of per-user attachment
- Added renderers/list.html template showing available servers
- Health check shows if servers are online
- Elegant error handling for invalid requests (no more raw JSON errors)
- Connect button passes auth token to L1 for seamless login

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 15:36:38 +00:00
giles
dcb487e6f4 Fix renderer list and enable markdown tables
- Fix get_user_renderers usage (returns strings not dicts)
- Enable tables and fenced_code markdown extensions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:20:47 +00:00
giles
0a15b2532e Add missing list templates for activities, anchors, storage
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:19:07 +00:00
giles
f8f44945ab Fix db function calls and add missing functions
- Fix get_activities to use get_activities_paginated
- Add get_user_assets, delete_asset, count_users, count_user_activities
- Add get_user_activities, get_renderer, update_anchor, delete_anchor
- Add record_run and get_run functions
- Fix create_asset calls to use dict parameter
- Fix update_asset call signature

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:13:40 +00:00
giles
65994ac107 Update artdag-common to 889ea98 with prose fix 2026-01-11 13:10:44 +00:00
giles
c3d131644a Fix anchors router to use get_anchors_paginated
Anchors are global, not user-specific. Added paginated db function.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:08:37 +00:00
giles
65169f49f9 Pin artdag-common to commit 2163cbd
Forces pip to fetch latest version with typography plugin.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:02:24 +00:00
giles
ff7ce1a61e Add cache busting to force pip to re-fetch dependencies
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:44:44 +00:00
giles
39870a499c Fix get_activities call to use get_activities_paginated
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:29:00 +00:00
giles
bfd38559b3 Update base.html to extend _base.html
Matches renamed template in artdag-common package.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:28:28 +00:00
giles
358fbba7b2 Add git to Dockerfile and httpx dependency
- Install git in Docker image for pip to clone git dependencies
- Add httpx package required by auth_service

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 12:11:51 +00:00
giles
0a31e1acfa Add artdag-common dependency 2026-01-11 11:55:41 +00:00
giles
d49e759d5a Refactor to modular app factory architecture
- Replace 3765-line monolithic server.py with 26-line entry point
- All routes now in app/routers/ using Jinja2 templates
- Backup old server as server_legacy.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 11:48:24 +00:00
giles
dd3d5927f5 Add /help routes to display README documentation
Provides /help index and /help/{doc_name} routes to view
L1 server and Common library READMEs in the web UI.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 11:29:49 +00:00
giles
c9c4a340fd Remove redundant documentation UI routes
/docs now correctly points to FastAPI's Swagger API docs.
README files can be viewed directly in the git repository.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 11:21:16 +00:00
giles
5730cd0f22 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>
2026-01-11 09:58:36 +00:00
giles
d1e9287829 Add modular app structure for L2 server
- Create app factory with routers and templates
- Auth, assets, activities, anchors, storage, users, renderers routers
- Federation router for WebFinger and nodeinfo
- Jinja2 templates for L2 pages
- Config and dependency injection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 07:46:26 +00:00
gilesb
a5619208cf Support activity_id (hash) in /activities/{ref} URL
- Accept both numeric index and activity_id hash
- Look up activity by ID from database when hash provided
- Refactor ui_activity_detail to support both lookup methods

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:37:53 +00:00
gilesb
e4cbbb1fbc Create activity for existing assets in record_run
When asset already exists, check if activity exists too.
If no activity, create one for the existing asset.
This fixes the case where an asset was registered but the
activity creation failed or was skipped.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 15:42:06 +00:00
gilesb
678d0e0ea3 Fix NoneType subscript error in record_run
- Add get_asset_by_name_tx for transaction-aware asset lookup
- Use transaction connection instead of separate connection
- Prevents race condition where asset might not be visible

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 15:07:42 +00:00
gilesb
59484936fd Fix db.get_asset_by_name -> db.get_asset
The function is named get_asset(), not get_asset_by_name().

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 12:27:05 +00:00
gilesb
292f7bf316 Add migration to add description column to user_storage table
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 02:48:09 +00:00
gilesb
c1cbf0b4ad Phase 2: Multiple storage configs per type with new UI structure
- Database: Add description field, remove unique constraint to allow
  multiple configs of same provider type
- UI: Main page shows provider types as cards with counts
- UI: Per-type page (/storage/type/{type}) for managing configs
- API: Add get_user_storage_by_type() for filtered queries
- Form: Add description field for distinguishing configs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 00:53:28 +00:00
gilesb
de7ca82862 Add boto3 for Filebase and Storj S3-compatible storage
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 00:29:05 +00:00
gilesb
2326658518 Fix storage test and delete endpoints to support cookie auth
Both /storage/{id}/test and DELETE /storage/{id} were using Bearer
token auth only. Now they also check cookie auth for browser sessions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 00:27:05 +00:00
gilesb
770c36479f Add support for more decentralized storage providers
Added 4 new storage providers:
- NFT.Storage (free for NFT data)
- Infura IPFS (5GB free)
- Filebase (5GB free, S3-compatible IPFS)
- Storj (25GB free, decentralized cloud)

Updated UI with 7 total storage options in a 4-column grid,
each with distinct colored borders for visibility.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 00:25:09 +00:00
gilesb
8bef9afb1f Add form-based storage endpoint for browser submissions
The POST /storage endpoint required Bearer token auth and JSON body,
which didn't work with browser form submissions using cookies. Added
new /storage/add endpoint that accepts form data and cookie auth.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 00:19:36 +00:00
gilesb
fb5c46330d Fix storage page authentication to support cookie-based sessions
The /storage route was only checking Bearer token authentication,
causing logged-in browser users to be redirected to login. Now also
checks cookie authentication like other HTML pages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 00:10:23 +00:00
gilesb
70cde17fef Fix storage provider buttons visibility with colored borders
The storage option buttons were nearly invisible due to low contrast
between bg-dark-600 buttons and bg-dark-700 background. Added distinct
colored borders (blue/green/purple) and darker backgrounds to make
the Pinata, web3.storage, and Local Storage options clearly visible.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 00:04:56 +00:00
gilesb
cf94600d63 Add borders to storage provider buttons for visibility
Buttons had same background color as container, making them nearly
invisible. Added border-dark-500 and hover:border-blue-500.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 23:44:17 +00:00
38 changed files with 7221 additions and 3615 deletions

View File

@@ -1,5 +1,8 @@
# L2 Server Configuration
# PostgreSQL password (REQUIRED - no default)
POSTGRES_PASSWORD=changeme-generate-with-openssl-rand-hex-16
# Domain for this ActivityPub server
ARTDAG_DOMAIN=artdag.rose-ash.com

View File

@@ -2,8 +2,12 @@ FROM python:3.11-slim
WORKDIR /app
# Install git for pip to clone dependencies
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
# Install dependencies
COPY requirements.txt .
ARG CACHEBUST=1
RUN pip install --no-cache-dir -r requirements.txt
# Copy application

490
README.md
View File

@@ -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 DATABASE_URL=postgresql://artdag:$POSTGRES_PASSWORD@localhost:5432/artdag
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` | **(required)** | 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
```

116
app/__init__.py Normal file
View File

@@ -0,0 +1,116 @@
"""
Art-DAG L2 Server Application Factory.
Creates and configures the FastAPI application with all routers and middleware.
"""
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, HTMLResponse
from artdag_common import create_jinja_env
from artdag_common.middleware.auth import get_user_from_cookie
from .config import settings
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Manage database connection pool lifecycle."""
import db
await db.init_pool()
yield
await db.close_pool()
def create_app() -> FastAPI:
"""
Create and configure the L2 FastAPI application.
Returns:
Configured FastAPI instance
"""
app = FastAPI(
title="Art-DAG L2 Server",
description="ActivityPub server for Art-DAG ownership and federation",
version="1.0.0",
lifespan=lifespan,
)
# Coop fragment pre-fetch — inject nav-tree, auth-menu, cart-mini
_FRAG_SKIP = ("/auth/", "/.well-known/", "/health",
"/internal/", "/static/", "/inbox")
@app.middleware("http")
async def coop_fragments_middleware(request: Request, call_next):
path = request.url.path
if (
request.method != "GET"
or any(path.startswith(p) for p in _FRAG_SKIP)
or request.headers.get("hx-request")
):
request.state.nav_tree_html = ""
request.state.auth_menu_html = ""
request.state.cart_mini_html = ""
return await call_next(request)
from artdag_common.fragments import fetch_fragments as _fetch_frags
user = get_user_from_cookie(request)
auth_params = {"email": user.email} if user and user.email else {}
nav_params = {"app_name": "artdag", "path": path}
try:
nav_tree_html, auth_menu_html, cart_mini_html = await _fetch_frags([
("blog", "nav-tree", nav_params),
("account", "auth-menu", auth_params or None),
("cart", "cart-mini", None),
])
except Exception:
nav_tree_html = auth_menu_html = cart_mini_html = ""
request.state.nav_tree_html = nav_tree_html
request.state.auth_menu_html = auth_menu_html
request.state.cart_mini_html = cart_mini_html
return await call_next(request)
# Initialize Jinja2 templates
template_dir = Path(__file__).parent / "templates"
app.state.templates = create_jinja_env(template_dir)
# Custom 404 handler
@app.exception_handler(404)
async def not_found_handler(request: Request, exc):
from artdag_common.middleware import wants_html
if wants_html(request):
from artdag_common import render
return render(app.state.templates, "404.html", request,
user=None,
)
return JSONResponse({"detail": "Not found"}, status_code=404)
# Include routers
from .routers import auth, assets, activities, anchors, storage, users, renderers
# Root routes
app.include_router(auth.router, prefix="/auth", tags=["auth"])
app.include_router(users.router, tags=["users"])
# Feature routers
app.include_router(assets.router, prefix="/assets", tags=["assets"])
app.include_router(activities.router, prefix="/activities", tags=["activities"])
app.include_router(anchors.router, prefix="/anchors", tags=["anchors"])
app.include_router(storage.router, prefix="/storage", tags=["storage"])
app.include_router(renderers.router, prefix="/renderers", tags=["renderers"])
# WebFinger and ActivityPub discovery
from .routers import federation
app.include_router(federation.router, tags=["federation"])
return app
# Create the default app instance
app = create_app()

56
app/config.py Normal file
View File

@@ -0,0 +1,56 @@
"""
L2 Server Configuration.
Environment-based settings for the ActivityPub server.
"""
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass
class Settings:
"""L2 Server configuration."""
# Domain and URLs
domain: str = os.environ.get("ARTDAG_DOMAIN", "artdag.rose-ash.com")
l1_public_url: str = os.environ.get("L1_PUBLIC_URL", "https://celery-artdag.rose-ash.com")
effects_repo_url: str = os.environ.get("EFFECTS_REPO_URL", "https://git.rose-ash.com/art-dag/effects")
ipfs_gateway_url: str = os.environ.get("IPFS_GATEWAY_URL", "")
# L1 servers
l1_servers: list = None
# Cookie domain for cross-subdomain auth
cookie_domain: str = None
# Data directory
data_dir: Path = None
# JWT settings
jwt_secret: str = os.environ.get("JWT_SECRET", "")
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 30 # 30 days
def __post_init__(self):
# Parse L1 servers
l1_str = os.environ.get("L1_SERVERS", "https://celery-artdag.rose-ash.com")
self.l1_servers = [s.strip() for s in l1_str.split(",") if s.strip()]
# Cookie domain
env_cookie = os.environ.get("COOKIE_DOMAIN")
if env_cookie:
self.cookie_domain = env_cookie
else:
parts = self.domain.split(".")
if len(parts) >= 2:
self.cookie_domain = "." + ".".join(parts[-2:])
# Data directory
self.data_dir = Path(os.environ.get("ARTDAG_DATA", str(Path.home() / ".artdag" / "l2")))
self.data_dir.mkdir(parents=True, exist_ok=True)
(self.data_dir / "assets").mkdir(exist_ok=True)
settings = Settings()

80
app/dependencies.py Normal file
View File

@@ -0,0 +1,80 @@
"""
L2 Server Dependency Injection.
Provides common dependencies for routes.
"""
from typing import Optional
from fastapi import Request, HTTPException, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from .config import settings
security = HTTPBearer(auto_error=False)
def get_templates(request: Request):
"""Get Jinja2 templates from app state."""
return request.app.state.templates
async def get_current_user(request: Request) -> Optional[dict]:
"""
Get current user from cookie or header.
Returns user dict or None if not authenticated.
"""
from auth import verify_token, get_token_claims
# Try cookie first
token = request.cookies.get("auth_token")
# Try Authorization header
if not token:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if not token:
return None
# Verify token
username = verify_token(token)
if not username:
return None
# Get full claims
claims = get_token_claims(token)
if not claims:
return None
return {
"username": username,
"actor_id": f"https://{settings.domain}/users/{username}",
"token": token,
**claims,
}
async def require_auth(request: Request) -> dict:
"""
Require authentication.
Raises HTTPException 401 if not authenticated.
"""
user = await get_current_user(request)
if not user:
raise HTTPException(401, "Authentication required")
return user
def get_user_from_cookie(request: Request) -> Optional[str]:
"""Get username from cookie (for HTML pages)."""
from auth import verify_token
token = request.cookies.get("auth_token")
if not token:
return None
return verify_token(token)

25
app/routers/__init__.py Normal file
View File

@@ -0,0 +1,25 @@
"""
L2 Server Routers.
Each router handles a specific domain of functionality.
"""
from . import auth
from . import assets
from . import activities
from . import anchors
from . import storage
from . import users
from . import renderers
from . import federation
__all__ = [
"auth",
"assets",
"activities",
"anchors",
"storage",
"users",
"renderers",
"federation",
]

99
app/routers/activities.py Normal file
View File

@@ -0,0 +1,99 @@
"""
Activity routes for L2 server.
Handles ActivityPub activities and outbox.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import JSONResponse
from artdag_common import render
from artdag_common.middleware import wants_html, wants_json
from ..config import settings
from ..dependencies import get_templates, require_auth, get_user_from_cookie
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("")
async def list_activities(
request: Request,
offset: int = 0,
limit: int = 20,
):
"""List recent activities."""
import db
username = get_user_from_cookie(request)
activities, total = await db.get_activities_paginated(limit=limit, offset=offset)
has_more = offset + len(activities) < total
if wants_json(request):
return {"activities": activities, "offset": offset, "limit": limit}
templates = get_templates(request)
return render(templates, "activities/list.html", request,
activities=activities,
user={"username": username} if username else None,
offset=offset,
limit=limit,
has_more=has_more,
active_tab="activities",
)
@router.get("/{activity_id}")
async def get_activity(
activity_id: str,
request: Request,
):
"""Get activity details."""
import db
activity = await db.get_activity(activity_id)
if not activity:
raise HTTPException(404, "Activity not found")
# ActivityPub response
if "application/activity+json" in request.headers.get("accept", ""):
return JSONResponse(
content=activity.get("activity_json", activity),
media_type="application/activity+json",
)
if wants_json(request):
return activity
username = get_user_from_cookie(request)
templates = get_templates(request)
return render(templates, "activities/detail.html", request,
activity=activity,
user={"username": username} if username else None,
active_tab="activities",
)
@router.post("")
async def create_activity(
request: Request,
user: dict = Depends(require_auth),
):
"""Create a new activity (internal use)."""
import db
import json
body = await request.json()
activity_id = await db.create_activity(
actor=user["actor_id"],
activity_type=body.get("type", "Create"),
object_data=body.get("object"),
)
return {"activity_id": activity_id, "created": True}

203
app/routers/anchors.py Normal file
View File

@@ -0,0 +1,203 @@
"""
Anchor routes for L2 server.
Handles OpenTimestamps anchoring and verification.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse, FileResponse
from artdag_common import render
from artdag_common.middleware import wants_html, wants_json
from ..config import settings
from ..dependencies import get_templates, require_auth, get_user_from_cookie
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("")
async def list_anchors(
request: Request,
offset: int = 0,
limit: int = 20,
):
"""List user's anchors."""
import db
username = get_user_from_cookie(request)
if not username:
if wants_json(request):
raise HTTPException(401, "Authentication required")
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/login", status_code=302)
anchors = await db.get_anchors_paginated(offset=offset, limit=limit)
has_more = len(anchors) >= limit
if wants_json(request):
return {"anchors": anchors, "offset": offset, "limit": limit}
templates = get_templates(request)
return render(templates, "anchors/list.html", request,
anchors=anchors,
user={"username": username},
offset=offset,
limit=limit,
has_more=has_more,
active_tab="anchors",
)
@router.post("")
async def create_anchor(
request: Request,
user: dict = Depends(require_auth),
):
"""Create a new timestamp anchor."""
import db
import anchoring
body = await request.json()
content_hash = body.get("content_hash")
if not content_hash:
raise HTTPException(400, "content_hash required")
# Create OTS timestamp
try:
ots_data = await anchoring.create_timestamp(content_hash)
except Exception as e:
logger.error(f"Failed to create timestamp: {e}")
raise HTTPException(500, f"Timestamping failed: {e}")
# Save anchor
anchor_id = await db.create_anchor(
username=user["username"],
content_hash=content_hash,
ots_data=ots_data,
)
return {
"anchor_id": anchor_id,
"content_hash": content_hash,
"status": "pending",
"message": "Anchor created, pending Bitcoin confirmation",
}
@router.get("/{anchor_id}")
async def get_anchor(
anchor_id: str,
request: Request,
):
"""Get anchor details."""
import db
anchor = await db.get_anchor(anchor_id)
if not anchor:
raise HTTPException(404, "Anchor not found")
if wants_json(request):
return anchor
username = get_user_from_cookie(request)
templates = get_templates(request)
return render(templates, "anchors/detail.html", request,
anchor=anchor,
user={"username": username} if username else None,
active_tab="anchors",
)
@router.get("/{anchor_id}/ots")
async def download_ots(anchor_id: str):
"""Download OTS proof file."""
import db
anchor = await db.get_anchor(anchor_id)
if not anchor:
raise HTTPException(404, "Anchor not found")
ots_data = anchor.get("ots_data")
if not ots_data:
raise HTTPException(404, "OTS data not available")
# Return as file download
from fastapi.responses import Response
return Response(
content=ots_data,
media_type="application/octet-stream",
headers={
"Content-Disposition": f"attachment; filename={anchor['content_hash']}.ots"
},
)
@router.post("/{anchor_id}/verify")
async def verify_anchor(
anchor_id: str,
request: Request,
user: dict = Depends(require_auth),
):
"""Verify anchor status (check Bitcoin confirmation)."""
import db
import anchoring
anchor = await db.get_anchor(anchor_id)
if not anchor:
raise HTTPException(404, "Anchor not found")
try:
result = await anchoring.verify_timestamp(
anchor["content_hash"],
anchor["ots_data"],
)
# Update anchor status
if result.get("confirmed"):
await db.update_anchor(
anchor_id,
status="confirmed",
bitcoin_block=result.get("block_height"),
confirmed_at=result.get("confirmed_at"),
)
if wants_html(request):
if result.get("confirmed"):
return HTMLResponse(
f'<span class="text-green-400">Confirmed in block {result["block_height"]}</span>'
)
return HTMLResponse('<span class="text-yellow-400">Pending confirmation</span>')
return result
except Exception as e:
logger.error(f"Verification failed: {e}")
raise HTTPException(500, f"Verification failed: {e}")
@router.delete("/{anchor_id}")
async def delete_anchor(
anchor_id: str,
user: dict = Depends(require_auth),
):
"""Delete an anchor."""
import db
anchor = await db.get_anchor(anchor_id)
if not anchor:
raise HTTPException(404, "Anchor not found")
if anchor.get("username") != user["username"]:
raise HTTPException(403, "Not authorized")
success = await db.delete_anchor(anchor_id)
if not success:
raise HTTPException(400, "Failed to delete anchor")
return {"deleted": True}

244
app/routers/assets.py Normal file
View File

@@ -0,0 +1,244 @@
"""
Asset management routes for L2 server.
Handles asset registration, listing, and publishing.
"""
import logging
from typing import Optional, List
from fastapi import APIRouter, Request, Depends, HTTPException, Form
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from artdag_common import render
from artdag_common.middleware import wants_html, wants_json
from ..config import settings
from ..dependencies import get_templates, require_auth, get_user_from_cookie
router = APIRouter()
logger = logging.getLogger(__name__)
class AssetCreate(BaseModel):
name: str
content_hash: str
ipfs_cid: Optional[str] = None
asset_type: str # image, video, effect, recipe
tags: List[str] = []
metadata: dict = {}
provenance: Optional[dict] = None
class RecordRunRequest(BaseModel):
run_id: str
recipe: str
inputs: List[str]
output_hash: str
ipfs_cid: Optional[str] = None
provenance: Optional[dict] = None
@router.get("")
async def list_assets(
request: Request,
offset: int = 0,
limit: int = 20,
asset_type: Optional[str] = None,
):
"""List user's assets."""
import db
username = get_user_from_cookie(request)
if not username:
if wants_json(request):
raise HTTPException(401, "Authentication required")
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/login", status_code=302)
assets = await db.get_user_assets(username, offset=offset, limit=limit, asset_type=asset_type)
has_more = len(assets) >= limit
if wants_json(request):
return {"assets": assets, "offset": offset, "limit": limit, "has_more": has_more}
templates = get_templates(request)
return render(templates, "assets/list.html", request,
assets=assets,
user={"username": username},
offset=offset,
limit=limit,
has_more=has_more,
active_tab="assets",
)
@router.post("")
async def create_asset(
req: AssetCreate,
user: dict = Depends(require_auth),
):
"""Register a new asset."""
import db
asset = await db.create_asset({
"owner": user["username"],
"name": req.name,
"content_hash": req.content_hash,
"ipfs_cid": req.ipfs_cid,
"asset_type": req.asset_type,
"tags": req.tags or [],
"metadata": req.metadata or {},
"provenance": req.provenance,
})
if not asset:
raise HTTPException(400, "Failed to create asset")
return {"asset_id": asset.get("name"), "message": "Asset registered"}
@router.get("/{asset_id}")
async def get_asset(
asset_id: str,
request: Request,
):
"""Get asset details."""
import db
username = get_user_from_cookie(request)
asset = await db.get_asset(asset_id)
if not asset:
raise HTTPException(404, "Asset not found")
if wants_json(request):
return asset
templates = get_templates(request)
return render(templates, "assets/detail.html", request,
asset=asset,
user={"username": username} if username else None,
active_tab="assets",
)
@router.delete("/{asset_id}")
async def delete_asset(
asset_id: str,
user: dict = Depends(require_auth),
):
"""Delete an asset."""
import db
asset = await db.get_asset(asset_id)
if not asset:
raise HTTPException(404, "Asset not found")
if asset.get("owner") != user["username"]:
raise HTTPException(403, "Not authorized")
success = await db.delete_asset(asset_id)
if not success:
raise HTTPException(400, "Failed to delete asset")
return {"deleted": True}
@router.post("/record-run")
async def record_run(
req: RecordRunRequest,
user: dict = Depends(require_auth),
):
"""Record a run completion and register output as asset."""
import db
# Create asset for output
asset = await db.create_asset({
"owner": user["username"],
"name": f"{req.recipe}-{req.run_id[:8]}",
"content_hash": req.output_hash,
"ipfs_cid": req.ipfs_cid,
"asset_type": "render",
"metadata": {
"run_id": req.run_id,
"recipe": req.recipe,
"inputs": req.inputs,
},
"provenance": req.provenance,
})
asset_id = asset.get("name") if asset else None
# Record run
await db.record_run(
run_id=req.run_id,
username=user["username"],
recipe=req.recipe,
inputs=req.inputs or [],
output_hash=req.output_hash,
ipfs_cid=req.ipfs_cid,
asset_id=asset_id,
)
return {
"run_id": req.run_id,
"asset_id": asset_id,
"recorded": True,
}
@router.get("/by-run-id/{run_id}")
async def get_asset_by_run_id(run_id: str):
"""Get asset by run ID (for L1 cache lookup)."""
import db
run = await db.get_run(run_id)
if not run:
raise HTTPException(404, "Run not found")
return {
"run_id": run_id,
"output_hash": run.get("output_hash"),
"ipfs_cid": run.get("ipfs_cid"),
"provenance_cid": run.get("provenance_cid"),
}
@router.post("/{asset_id}/publish")
async def publish_asset(
asset_id: str,
request: Request,
user: dict = Depends(require_auth),
):
"""Publish asset to IPFS."""
import db
import ipfs_client
asset = await db.get_asset(asset_id)
if not asset:
raise HTTPException(404, "Asset not found")
if asset.get("owner") != user["username"]:
raise HTTPException(403, "Not authorized")
# Already published?
if asset.get("ipfs_cid"):
return {"ipfs_cid": asset["ipfs_cid"], "already_published": True}
# Get content from L1
content_hash = asset.get("content_hash")
for l1_url in settings.l1_servers:
try:
import requests
resp = requests.get(f"{l1_url}/cache/{content_hash}/raw", timeout=30)
if resp.status_code == 200:
# Pin to IPFS
cid = await ipfs_client.add_bytes(resp.content)
if cid:
await db.update_asset(asset_id, {"ipfs_cid": cid})
return {"ipfs_cid": cid, "published": True}
except Exception as e:
logger.warning(f"Failed to fetch from {l1_url}: {e}")
raise HTTPException(400, "Failed to publish - content not found on any L1")

223
app/routers/auth.py Normal file
View File

@@ -0,0 +1,223 @@
"""
Authentication routes for L2 server.
Handles login, registration, logout, and token verification.
"""
import hashlib
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form, HTTPException, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from artdag_common import render
from artdag_common.middleware import wants_html
from ..config import settings
from ..dependencies import get_templates, get_user_from_cookie
router = APIRouter()
security = HTTPBearer(auto_error=False)
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, return_to: str = None):
"""Login page."""
username = get_user_from_cookie(request)
if username:
templates = get_templates(request)
return render(templates, "auth/already_logged_in.html", request,
user={"username": username},
)
templates = get_templates(request)
return render(templates, "auth/login.html", request,
return_to=return_to,
)
@router.post("/login", response_class=HTMLResponse)
async def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
return_to: str = Form(None),
):
"""Handle login form submission."""
from auth import authenticate_user, create_access_token
if not username or not password:
return HTMLResponse(
'<div class="text-red-400">Username and password are required</div>'
)
user = await authenticate_user(settings.data_dir, username.strip(), password)
if not user:
return HTMLResponse(
'<div class="text-red-400">Invalid username or password</div>'
)
token = create_access_token(user.username, l2_server=f"https://{settings.domain}")
# Handle return_to redirect
if return_to and return_to.startswith("http"):
separator = "&" if "?" in return_to else "?"
redirect_url = f"{return_to}{separator}auth_token={token.access_token}"
response = HTMLResponse(f'''
<div class="text-green-400">Login successful! Redirecting...</div>
<script>window.location.href = "{redirect_url}";</script>
''')
else:
response = HTMLResponse('''
<div class="text-green-400">Login successful! Redirecting...</div>
<script>window.location.href = "/";</script>
''')
response.set_cookie(
key="auth_token",
value=token.access_token,
httponly=True,
max_age=60 * 60 * 24 * 30,
samesite="lax",
secure=True,
)
return response
@router.get("/register", response_class=HTMLResponse)
async def register_page(request: Request):
"""Registration page."""
username = get_user_from_cookie(request)
if username:
templates = get_templates(request)
return render(templates, "auth/already_logged_in.html", request,
user={"username": username},
)
templates = get_templates(request)
return render(templates, "auth/register.html", request)
@router.post("/register", response_class=HTMLResponse)
async def register_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
password2: str = Form(...),
email: str = Form(None),
):
"""Handle registration form submission."""
from auth import create_user, create_access_token
if not username or not password:
return HTMLResponse('<div class="text-red-400">Username and password are required</div>')
if password != password2:
return HTMLResponse('<div class="text-red-400">Passwords do not match</div>')
if len(password) < 6:
return HTMLResponse('<div class="text-red-400">Password must be at least 6 characters</div>')
try:
user = await create_user(settings.data_dir, username.strip(), password, email)
except ValueError as e:
return HTMLResponse(f'<div class="text-red-400">{str(e)}</div>')
token = create_access_token(user.username, l2_server=f"https://{settings.domain}")
response = HTMLResponse('''
<div class="text-green-400">Registration successful! Redirecting...</div>
<script>window.location.href = "/";</script>
''')
response.set_cookie(
key="auth_token",
value=token.access_token,
httponly=True,
max_age=60 * 60 * 24 * 30,
samesite="lax",
secure=True,
)
return response
@router.get("/logout")
async def logout(request: Request):
"""Handle logout."""
import db
import requests
from auth import get_token_claims
token = request.cookies.get("auth_token")
claims = get_token_claims(token) if token else None
username = claims.get("sub") if claims else None
if username and token and claims:
# Revoke token in database
token_hash = hashlib.sha256(token.encode()).hexdigest()
expires_at = datetime.fromtimestamp(claims.get("exp", 0), tz=timezone.utc)
await db.revoke_token(token_hash, username, expires_at)
# Revoke on attached L1 servers
attached = await db.get_user_renderers(username)
for l1_url in attached:
try:
requests.post(
f"{l1_url}/auth/revoke-user",
json={"username": username, "l2_server": f"https://{settings.domain}"},
timeout=5,
)
except Exception:
pass
response = RedirectResponse(url="/", status_code=302)
response.delete_cookie("auth_token")
return response
@router.get("/verify")
async def verify_token(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
):
"""
Verify a token is valid.
Called by L1 servers to verify tokens during auth callback.
Returns user info if valid, 401 if not.
"""
import db
from auth import verify_token as verify_jwt, get_token_claims
# Get token from Authorization header or query param
token = None
if credentials:
token = credentials.credentials
else:
# Try Authorization header manually (for clients that don't use Bearer format)
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if not token:
raise HTTPException(401, "No token provided")
# Verify JWT signature and expiry
username = verify_jwt(token)
if not username:
raise HTTPException(401, "Invalid or expired token")
# Check if token is revoked
claims = get_token_claims(token)
if claims:
token_hash = hashlib.sha256(token.encode()).hexdigest()
if await db.is_token_revoked(token_hash):
raise HTTPException(401, "Token has been revoked")
return {
"valid": True,
"username": username,
"claims": claims,
}

115
app/routers/federation.py Normal file
View File

@@ -0,0 +1,115 @@
"""
Federation routes for L2 server.
Handles WebFinger, nodeinfo, and ActivityPub discovery.
"""
import logging
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
from ..config import settings
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/.well-known/webfinger")
async def webfinger(resource: str):
"""WebFinger endpoint for actor discovery."""
import db
# Parse resource (acct:username@domain)
if not resource.startswith("acct:"):
raise HTTPException(400, "Invalid resource format")
parts = resource[5:].split("@")
if len(parts) != 2:
raise HTTPException(400, "Invalid resource format")
username, domain = parts
if domain != settings.domain:
raise HTTPException(404, "User not on this server")
user = await db.get_user(username)
if not user:
raise HTTPException(404, "User not found")
return JSONResponse(
content={
"subject": resource,
"aliases": [f"https://{settings.domain}/users/{username}"],
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": f"https://{settings.domain}/users/{username}",
},
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": f"https://{settings.domain}/users/{username}",
},
],
},
media_type="application/jrd+json",
)
@router.get("/.well-known/nodeinfo")
async def nodeinfo_index():
"""NodeInfo index."""
return JSONResponse(
content={
"links": [
{
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
"href": f"https://{settings.domain}/nodeinfo/2.0",
}
]
},
media_type="application/json",
)
@router.get("/nodeinfo/2.0")
async def nodeinfo():
"""NodeInfo 2.0 endpoint."""
import db
user_count = await db.count_users()
activity_count = await db.count_activities()
return JSONResponse(
content={
"version": "2.0",
"software": {
"name": "artdag",
"version": "1.0.0",
},
"protocols": ["activitypub"],
"usage": {
"users": {"total": user_count, "activeMonth": user_count},
"localPosts": activity_count,
},
"openRegistrations": True,
"metadata": {
"nodeName": "Art-DAG",
"nodeDescription": "Content-addressable media processing with ActivityPub federation",
},
},
media_type="application/json",
)
@router.get("/.well-known/host-meta")
async def host_meta():
"""Host-meta endpoint."""
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" type="application/xrd+xml" template="https://{settings.domain}/.well-known/webfinger?resource={{uri}}"/>
</XRD>'''
from fastapi.responses import Response
return Response(content=xml, media_type="application/xrd+xml")

93
app/routers/renderers.py Normal file
View File

@@ -0,0 +1,93 @@
"""
Renderer (L1) management routes for L2 server.
L1 servers are configured via environment variable L1_SERVERS.
Users connect to renderers to create and run recipes.
"""
import logging
from typing import Optional
import requests
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from artdag_common import render
from artdag_common.middleware import wants_html, wants_json
from ..config import settings
from ..dependencies import get_templates, require_auth, get_user_from_cookie
router = APIRouter()
logger = logging.getLogger(__name__)
def check_renderer_health(url: str, timeout: float = 5.0) -> bool:
"""Check if a renderer is healthy."""
try:
resp = requests.get(f"{url}/", timeout=timeout)
return resp.status_code == 200
except Exception:
return False
@router.get("")
async def list_renderers(request: Request):
"""List configured L1 renderers."""
# Get user if logged in
username = get_user_from_cookie(request)
user = None
if username:
# Get token for connection links
token = request.cookies.get("auth_token", "")
user = {"username": username, "token": token}
# Build server list with health status
servers = []
for url in settings.l1_servers:
servers.append({
"url": url,
"healthy": check_renderer_health(url),
})
if wants_json(request):
return {"servers": servers}
templates = get_templates(request)
return render(templates, "renderers/list.html", request,
servers=servers,
user=user,
active_tab="renderers",
)
@router.get("/{path:path}")
async def renderer_catchall(path: str, request: Request):
"""Catch-all for invalid renderer URLs - redirect to list."""
if wants_json(request):
raise HTTPException(404, "Not found")
return RedirectResponse(url="/renderers", status_code=302)
@router.post("")
@router.post("/{path:path}")
async def renderer_post_catchall(request: Request, path: str = ""):
"""
Catch-all for POST requests.
The old API expected JSON POST to attach renderers.
Now renderers are env-configured, so redirect to the list.
"""
if wants_json(request):
return {
"error": "Renderers are now configured via environment. See /renderers for available servers.",
"servers": settings.l1_servers,
}
templates = get_templates(request)
return render(templates, "renderers/list.html", request,
servers=[{"url": url, "healthy": check_renderer_health(url)} for url in settings.l1_servers],
user=get_user_from_cookie(request),
error="Renderers are configured by the system administrator. Use the Connect button to access a renderer.",
active_tab="renderers",
)

254
app/routers/storage.py Normal file
View File

@@ -0,0 +1,254 @@
"""
Storage provider routes for L2 server.
Manages user storage backends.
"""
import logging
from typing import Optional, Dict, Any
from fastapi import APIRouter, Request, Depends, HTTPException, Form
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from artdag_common import render
from artdag_common.middleware import wants_html, wants_json
from ..config import settings
from ..dependencies import get_templates, require_auth, get_user_from_cookie
router = APIRouter()
logger = logging.getLogger(__name__)
STORAGE_PROVIDERS_INFO = {
"pinata": {"name": "Pinata", "desc": "1GB free, IPFS pinning", "color": "blue"},
"web3storage": {"name": "web3.storage", "desc": "IPFS + Filecoin", "color": "green"},
"nftstorage": {"name": "NFT.Storage", "desc": "Free for NFTs", "color": "pink"},
"infura": {"name": "Infura IPFS", "desc": "5GB free", "color": "orange"},
"filebase": {"name": "Filebase", "desc": "5GB free, S3+IPFS", "color": "cyan"},
"storj": {"name": "Storj", "desc": "25GB free", "color": "indigo"},
"local": {"name": "Local Storage", "desc": "Your own disk", "color": "purple"},
}
class AddStorageRequest(BaseModel):
provider_type: str
config: Dict[str, Any]
capacity_gb: int = 5
provider_name: Optional[str] = None
@router.get("")
async def list_storage(request: Request):
"""List user's storage providers."""
import db
username = get_user_from_cookie(request)
if not username:
if wants_json(request):
raise HTTPException(401, "Authentication required")
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/login", status_code=302)
storages = await db.get_user_storage(username)
if wants_json(request):
return {"storages": storages}
templates = get_templates(request)
return render(templates, "storage/list.html", request,
storages=storages,
user={"username": username},
providers_info=STORAGE_PROVIDERS_INFO,
active_tab="storage",
)
@router.post("")
async def add_storage(
req: AddStorageRequest,
user: dict = Depends(require_auth),
):
"""Add a storage provider."""
import db
import storage_providers
if req.provider_type not in STORAGE_PROVIDERS_INFO:
raise HTTPException(400, f"Invalid provider type: {req.provider_type}")
# Test connection
provider = storage_providers.create_provider(req.provider_type, {
**req.config,
"capacity_gb": req.capacity_gb,
})
if not provider:
raise HTTPException(400, "Failed to create provider")
success, message = await provider.test_connection()
if not success:
raise HTTPException(400, f"Connection failed: {message}")
# Save
storage_id = await db.add_user_storage(
username=user["username"],
provider_type=req.provider_type,
provider_name=req.provider_name,
config=req.config,
capacity_gb=req.capacity_gb,
)
return {"id": storage_id, "message": "Storage provider added"}
@router.post("/add", response_class=HTMLResponse)
async def add_storage_form(
request: Request,
provider_type: str = Form(...),
provider_name: Optional[str] = Form(None),
capacity_gb: int = Form(5),
api_key: Optional[str] = Form(None),
secret_key: Optional[str] = Form(None),
api_token: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
project_secret: Optional[str] = Form(None),
access_key: Optional[str] = Form(None),
bucket: Optional[str] = Form(None),
path: Optional[str] = Form(None),
):
"""Add storage via HTML form."""
import db
import storage_providers
username = get_user_from_cookie(request)
if not username:
return HTMLResponse('<div class="text-red-400">Not authenticated</div>', status_code=401)
# Build config
config = {}
if provider_type == "pinata":
if not api_key or not secret_key:
return HTMLResponse('<div class="text-red-400">Pinata requires API Key and Secret Key</div>')
config = {"api_key": api_key, "secret_key": secret_key}
elif provider_type in ["web3storage", "nftstorage"]:
if not api_token:
return HTMLResponse(f'<div class="text-red-400">{provider_type} requires API Token</div>')
config = {"api_token": api_token}
elif provider_type == "infura":
if not project_id or not project_secret:
return HTMLResponse('<div class="text-red-400">Infura requires Project ID and Secret</div>')
config = {"project_id": project_id, "project_secret": project_secret}
elif provider_type in ["filebase", "storj"]:
if not access_key or not secret_key or not bucket:
return HTMLResponse('<div class="text-red-400">Requires Access Key, Secret Key, and Bucket</div>')
config = {"access_key": access_key, "secret_key": secret_key, "bucket": bucket}
elif provider_type == "local":
if not path:
return HTMLResponse('<div class="text-red-400">Local storage requires a path</div>')
config = {"path": path}
else:
return HTMLResponse(f'<div class="text-red-400">Unknown provider: {provider_type}</div>')
# Test
provider = storage_providers.create_provider(provider_type, {**config, "capacity_gb": capacity_gb})
if provider:
success, message = await provider.test_connection()
if not success:
return HTMLResponse(f'<div class="text-red-400">Connection failed: {message}</div>')
# Save
storage_id = await db.add_user_storage(
username=username,
provider_type=provider_type,
provider_name=provider_name,
config=config,
capacity_gb=capacity_gb,
)
return HTMLResponse(f'''
<div class="text-green-400 mb-2">Storage provider added!</div>
<script>setTimeout(() => window.location.href = '/storage', 1500);</script>
''')
@router.get("/{storage_id}")
async def get_storage(
storage_id: int,
user: dict = Depends(require_auth),
):
"""Get storage details."""
import db
storage = await db.get_storage_by_id(storage_id)
if not storage:
raise HTTPException(404, "Storage not found")
if storage.get("username") != user["username"]:
raise HTTPException(403, "Not authorized")
return storage
@router.delete("/{storage_id}")
async def delete_storage(
storage_id: int,
request: Request,
user: dict = Depends(require_auth),
):
"""Delete a storage provider."""
import db
storage = await db.get_storage_by_id(storage_id)
if not storage:
raise HTTPException(404, "Storage not found")
if storage.get("username") != user["username"]:
raise HTTPException(403, "Not authorized")
success = await db.remove_user_storage(storage_id)
if wants_html(request):
return HTMLResponse("")
return {"deleted": True}
@router.post("/{storage_id}/test")
async def test_storage(
storage_id: int,
request: Request,
user: dict = Depends(require_auth),
):
"""Test storage connectivity."""
import db
import storage_providers
import json
storage = await db.get_storage_by_id(storage_id)
if not storage:
raise HTTPException(404, "Storage not found")
if storage.get("username") != user["username"]:
raise HTTPException(403, "Not authorized")
config = storage["config"]
if isinstance(config, str):
config = json.loads(config)
provider = storage_providers.create_provider(storage["provider_type"], {
**config,
"capacity_gb": storage.get("capacity_gb", 5),
})
if not provider:
if wants_html(request):
return HTMLResponse('<span class="text-red-400">Failed to create provider</span>')
return {"success": False, "message": "Failed to create provider"}
success, message = await provider.test_connection()
if wants_html(request):
color = "green" if success else "red"
return HTMLResponse(f'<span class="text-{color}-400">{message}</span>')
return {"success": success, "message": message}

161
app/routers/users.py Normal file
View File

@@ -0,0 +1,161 @@
"""
User profile routes for L2 server.
Handles ActivityPub actor profiles.
"""
import logging
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import JSONResponse
from artdag_common import render
from artdag_common.middleware import wants_html
from ..config import settings
from ..dependencies import get_templates, get_user_from_cookie
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/users/{username}")
async def get_user_profile(
username: str,
request: Request,
):
"""Get user profile (ActivityPub actor)."""
import db
user = await db.get_user(username)
if not user:
raise HTTPException(404, "User not found")
# ActivityPub response
accept = request.headers.get("accept", "")
if "application/activity+json" in accept or "application/ld+json" in accept:
actor = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
"type": "Person",
"id": f"https://{settings.domain}/users/{username}",
"name": user.get("display_name", username),
"preferredUsername": username,
"inbox": f"https://{settings.domain}/users/{username}/inbox",
"outbox": f"https://{settings.domain}/users/{username}/outbox",
"publicKey": {
"id": f"https://{settings.domain}/users/{username}#main-key",
"owner": f"https://{settings.domain}/users/{username}",
"publicKeyPem": user.get("public_key", ""),
},
}
return JSONResponse(content=actor, media_type="application/activity+json")
# HTML profile page
current_user = get_user_from_cookie(request)
assets = await db.get_user_assets(username, limit=12)
templates = get_templates(request)
return render(templates, "users/profile.html", request,
profile=user,
assets=assets,
user={"username": current_user} if current_user else None,
)
@router.get("/users/{username}/outbox")
async def get_outbox(
username: str,
request: Request,
page: bool = False,
):
"""Get user's outbox (ActivityPub)."""
import db
user = await db.get_user(username)
if not user:
raise HTTPException(404, "User not found")
actor_id = f"https://{settings.domain}/users/{username}"
if not page:
# Return collection summary
total = await db.count_user_activities(username)
return JSONResponse(
content={
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollection",
"id": f"{actor_id}/outbox",
"totalItems": total,
"first": f"{actor_id}/outbox?page=true",
},
media_type="application/activity+json",
)
# Return paginated activities
activities = await db.get_user_activities(username, limit=20)
items = [a.get("activity_json", a) for a in activities]
return JSONResponse(
content={
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollectionPage",
"id": f"{actor_id}/outbox?page=true",
"partOf": f"{actor_id}/outbox",
"orderedItems": items,
},
media_type="application/activity+json",
)
@router.post("/users/{username}/inbox")
async def receive_inbox(
username: str,
request: Request,
):
"""Receive ActivityPub inbox message."""
import db
user = await db.get_user(username)
if not user:
raise HTTPException(404, "User not found")
# TODO: Verify HTTP signature
# TODO: Process activity (Follow, Like, Announce, etc.)
body = await request.json()
logger.info(f"Received inbox activity for {username}: {body.get('type')}")
# For now, just acknowledge
return {"status": "accepted"}
@router.get("/")
async def home(request: Request):
"""Home page."""
import db
import markdown
username = get_user_from_cookie(request)
# Get recent activities
activities, _ = await db.get_activities_paginated(limit=10)
# Get README if exists
readme_html = ""
try:
from pathlib import Path
readme_path = Path(__file__).parent.parent.parent / "README.md"
if readme_path.exists():
readme_html = markdown.markdown(readme_path.read_text(), extensions=['tables', 'fenced_code'])
except Exception:
pass
templates = get_templates(request)
return render(templates, "home.html", request,
user={"username": username} if username else None,
activities=activities,
readme_html=readme_html,
)

11
app/templates/404.html Normal file
View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Not Found - Art-DAG{% endblock %}
{% block content %}
<div class="text-center py-16">
<h2 class="text-6xl font-bold text-gray-600 mb-4">404</h2>
<p class="text-xl text-gray-400 mb-8">Page not found</p>
<a href="/" class="text-blue-400 hover:text-blue-300">Go to home page</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Activity {{ activity.activity_id[:16] }}{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto">
<div class="mb-6">
<a href="/activities" class="inline-flex items-center text-blue-400 hover:text-blue-300">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Activities
</a>
</div>
<div class="bg-gray-800 rounded-lg p-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-white">{{ activity.activity_type }}</h1>
<span class="px-3 py-1 bg-blue-600 text-white text-sm rounded-full">
Activity
</span>
</div>
<div class="space-y-4">
<div>
<p class="text-sm text-gray-400 mb-1">Activity ID</p>
<p class="font-mono text-sm text-gray-200 break-all">{{ activity.activity_id }}</p>
</div>
<div>
<p class="text-sm text-gray-400 mb-1">Actor</p>
<p class="text-gray-200">{{ activity.actor_id }}</p>
</div>
<div>
<p class="text-sm text-gray-400 mb-1">Published</p>
<p class="text-gray-200">{{ activity.published }}</p>
</div>
{% if activity.anchor_root %}
<div>
<p class="text-sm text-gray-400 mb-1">Anchor Root</p>
<p class="font-mono text-sm text-gray-200 break-all">{{ activity.anchor_root }}</p>
</div>
{% endif %}
{% if activity.object_data %}
<div>
<p class="text-sm text-gray-400 mb-2">Object Data</p>
<pre class="bg-gray-900 rounded p-4 text-xs text-gray-300 overflow-x-auto">{{ activity.object_data | tojson(indent=2) }}</pre>
</div>
{% endif %}
{% if activity.signature %}
<div>
<p class="text-sm text-gray-400 mb-2">Signature</p>
<pre class="bg-gray-900 rounded p-4 text-xs text-gray-300 overflow-x-auto">{{ activity.signature | tojson(indent=2) }}</pre>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Activities - Art-DAG{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Activities</h1>
</div>
{% if activities %}
<div class="space-y-4">
{% for activity in activities %}
<a href="/activities/{{ activity.activity_id }}"
class="block bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-blue-500 transition-colors">
<div class="flex items-center justify-between mb-2">
<span class="text-blue-400 font-medium">{{ activity.activity_type }}</span>
<span class="text-gray-500 text-sm">{{ activity.published }}</span>
</div>
<div class="text-gray-300 text-sm truncate">
{{ activity.actor_id }}
</div>
</a>
{% endfor %}
</div>
{% if has_more %}
<div class="mt-6 text-center">
<a href="?offset={{ offset + limit }}&limit={{ limit }}"
class="text-blue-400 hover:text-blue-300">Load More</a>
</div>
{% endif %}
{% else %}
<div class="text-center py-12 text-gray-400">
<p>No activities yet.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block title %}Anchor {{ anchor.merkle_root[:16] }}{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto">
<div class="mb-6">
<a href="/anchors" class="inline-flex items-center text-blue-400 hover:text-blue-300">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Anchors
</a>
</div>
<div class="bg-gray-800 rounded-lg p-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-white">Bitcoin Anchor</h1>
<span class="px-3 py-1 text-sm rounded-full
{% if anchor.confirmed_at %}bg-green-600{% else %}bg-yellow-600{% endif %}">
{% if anchor.confirmed_at %}Confirmed{% else %}Pending{% endif %}
</span>
</div>
<div class="space-y-4">
<div>
<p class="text-sm text-gray-400 mb-1">Merkle Root</p>
<p class="font-mono text-sm text-gray-200 break-all">{{ anchor.merkle_root }}</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-400 mb-1">Activity Count</p>
<p class="text-xl font-semibold text-white">{{ anchor.activity_count }}</p>
</div>
<div>
<p class="text-sm text-gray-400 mb-1">Created</p>
<p class="text-gray-200">{{ anchor.created_at }}</p>
</div>
</div>
{% if anchor.bitcoin_txid %}
<div>
<p class="text-sm text-gray-400 mb-1">Bitcoin Transaction</p>
<a href="https://mempool.space/tx/{{ anchor.bitcoin_txid }}" target="_blank" rel="noopener"
class="font-mono text-sm text-blue-400 hover:text-blue-300 break-all">
{{ anchor.bitcoin_txid }}
</a>
</div>
{% endif %}
{% if anchor.confirmed_at %}
<div>
<p class="text-sm text-gray-400 mb-1">Confirmed At</p>
<p class="text-gray-200">{{ anchor.confirmed_at }}</p>
</div>
{% endif %}
{% if anchor.tree_ipfs_cid %}
<div>
<p class="text-sm text-gray-400 mb-1">Merkle Tree IPFS CID</p>
<a href="https://ipfs.io/ipfs/{{ anchor.tree_ipfs_cid }}" target="_blank" rel="noopener"
class="font-mono text-sm text-blue-400 hover:text-blue-300 break-all">
{{ anchor.tree_ipfs_cid }}
</a>
</div>
{% endif %}
{% if anchor.ots_proof_cid %}
<div>
<p class="text-sm text-gray-400 mb-1">OpenTimestamps Proof CID</p>
<a href="https://ipfs.io/ipfs/{{ anchor.ots_proof_cid }}" target="_blank" rel="noopener"
class="font-mono text-sm text-blue-400 hover:text-blue-300 break-all">
{{ anchor.ots_proof_cid }}
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Anchors - Art-DAG{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Bitcoin Anchors</h1>
</div>
{% if anchors %}
<div class="space-y-4">
{% for anchor in anchors %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="font-mono text-sm text-blue-400 truncate">{{ anchor.merkle_root[:16] }}...</span>
{% if anchor.confirmed_at %}
<span class="bg-green-600 text-xs px-2 py-1 rounded">Confirmed</span>
{% else %}
<span class="bg-yellow-600 text-xs px-2 py-1 rounded">Pending</span>
{% endif %}
</div>
<div class="text-gray-400 text-sm">
{{ anchor.activity_count or 0 }} activities | Created: {{ anchor.created_at }}
</div>
{% if anchor.bitcoin_txid %}
<div class="mt-2 text-xs text-gray-500 font-mono truncate">
TX: {{ anchor.bitcoin_txid }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% if has_more %}
<div class="mt-6 text-center">
<a href="?offset={{ offset + limit }}&limit={{ limit }}"
class="text-blue-400 hover:text-blue-300">Load More</a>
</div>
{% endif %}
{% else %}
<div class="text-center py-12 text-gray-400">
<p>No anchors yet.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block title %}{{ asset.name }} - Asset{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="mb-6">
<a href="/assets" class="inline-flex items-center text-blue-400 hover:text-blue-300">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Assets
</a>
</div>
<div class="bg-gray-800 rounded-lg overflow-hidden">
<!-- Asset Preview -->
<div class="aspect-video bg-gray-900 flex items-center justify-center">
{% if asset.asset_type == 'video' %}
<svg class="w-24 h-24 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% elif asset.asset_type == 'image' %}
<svg class="w-24 h-24 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{% else %}
<svg class="w-24 h-24 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
{% endif %}
</div>
<!-- Asset Info -->
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-white mb-1">{{ asset.name }}</h1>
<p class="text-gray-400">by {{ asset.owner }}</p>
</div>
<span class="px-3 py-1 bg-purple-600 text-white text-sm rounded-full">
{{ asset.asset_type }}
</span>
</div>
{% if asset.description %}
<p class="text-gray-300 mb-6">{{ asset.description }}</p>
{% endif %}
{% if asset.tags %}
<div class="flex flex-wrap gap-2 mb-6">
{% for tag in asset.tags %}
<span class="px-2 py-1 bg-gray-700 text-gray-300 text-sm rounded">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="bg-gray-900 rounded-lg p-4">
<p class="text-sm text-gray-400 mb-1">Content Hash</p>
<p class="font-mono text-xs text-gray-200 break-all">{{ asset.content_hash }}</p>
</div>
{% if asset.ipfs_cid %}
<div class="bg-gray-900 rounded-lg p-4">
<p class="text-sm text-gray-400 mb-1">IPFS CID</p>
<a href="https://ipfs.io/ipfs/{{ asset.ipfs_cid }}" target="_blank" rel="noopener"
class="font-mono text-xs text-blue-400 hover:text-blue-300 break-all">
{{ asset.ipfs_cid }}
</a>
</div>
{% endif %}
</div>
<div class="text-sm text-gray-500">
Created {{ asset.created_at }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Assets - Art-DAG{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Your Assets</h1>
{% if assets %}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4" id="assets-grid">
{% for asset in assets %}
<a href="/assets/{{ asset.id }}"
class="bg-gray-800 rounded-lg overflow-hidden hover:ring-2 hover:ring-blue-500 transition-all">
{% if asset.asset_type == 'image' %}
<img src="{{ asset.thumbnail_url or '/assets/' + asset.id + '/thumb' }}"
alt="{{ asset.name }}"
class="w-full h-40 object-cover">
{% elif asset.asset_type == 'video' %}
<div class="w-full h-40 bg-gray-900 flex items-center justify-center">
<svg class="w-12 h-12 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z"/>
</svg>
</div>
{% else %}
<div class="w-full h-40 bg-gray-900 flex items-center justify-center">
<span class="text-gray-600">{{ asset.asset_type }}</span>
</div>
{% endif %}
<div class="p-3">
<div class="font-medium text-white truncate">{{ asset.name }}</div>
<div class="text-xs text-gray-500">{{ asset.asset_type }}</div>
{% if asset.ipfs_cid %}
<div class="text-xs text-green-400 mt-1">Published</div>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% if has_more %}
<div hx-get="/assets?offset={{ offset + limit }}"
hx-trigger="revealed"
hx-swap="beforeend"
hx-target="#assets-grid"
class="h-20 flex items-center justify-center text-gray-500 mt-4">
Loading more...
</div>
{% endif %}
{% else %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
<p class="text-gray-500 mb-4">No assets yet</p>
<p class="text-gray-600 text-sm">Create content on an L1 renderer and publish it here.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Already Logged In - Art-DAG{% endblock %}
{% block content %}
<div class="max-w-md mx-auto text-center">
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">
You are already logged in as <strong>{{ user.username }}</strong>
</div>
<p><a href="/" class="text-blue-400 hover:text-blue-300">Go to home page</a></p>
</div>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Login - Art-DAG{% endblock %}
{% block content %}
<div class="max-w-md mx-auto">
<h2 class="text-xl font-semibold mb-6">Login</h2>
<div id="login-result"></div>
<form hx-post="/auth/login" hx-target="#login-result" hx-swap="innerHTML" class="space-y-4">
{% if return_to %}
<input type="hidden" name="return_to" value="{{ return_to }}">
{% endif %}
<div>
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">Username</label>
<input type="text" id="username" name="username" required
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">Password</label>
<input type="password" id="password" name="password" required
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
</div>
<button type="submit" class="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg">
Login
</button>
</form>
<p class="mt-6 text-gray-400">
Don't have an account? <a href="/auth/register" class="text-blue-400 hover:text-blue-300">Register</a>
</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Register - Art-DAG{% endblock %}
{% block content %}
<div class="max-w-md mx-auto">
<h2 class="text-xl font-semibold mb-6">Register</h2>
<div id="register-result"></div>
<form hx-post="/auth/register" hx-target="#register-result" hx-swap="innerHTML" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-300 mb-2">Username</label>
<input type="text" id="username" name="username" required pattern="[a-zA-Z0-9_-]+"
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-300 mb-2">Email (optional)</label>
<input type="email" id="email" name="email"
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-300 mb-2">Password</label>
<input type="password" id="password" name="password" required minlength="6"
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
</div>
<div>
<label for="password2" class="block text-sm font-medium text-gray-300 mb-2">Confirm Password</label>
<input type="password" id="password2" name="password2" required minlength="6"
class="w-full px-4 py-3 bg-gray-800 border border-gray-600 rounded-lg text-white focus:border-blue-500 focus:outline-none">
</div>
<button type="submit" class="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg">
Register
</button>
</form>
<p class="mt-6 text-gray-400">
Already have an account? <a href="/auth/login" class="text-blue-400 hover:text-blue-300">Login</a>
</p>
</div>
{% endblock %}

47
app/templates/base.html Normal file
View File

@@ -0,0 +1,47 @@
{% extends "_base.html" %}
{% block brand %}
<a href="https://blog.rose-ash.com/" class="no-underline text-stone-900">Rose Ash</a>
<span class="text-stone-400 mx-1">|</span>
<a href="/" class="no-underline text-stone-900">Art-DAG</a>
<span class="text-stone-400 mx-1">/</span>
<span class="text-stone-600 text-3xl">L2</span>
{% endblock %}
{% block cart_mini %}
{% if request and request.state.cart_mini_html %}
{{ request.state.cart_mini_html | safe }}
{% endif %}
{% endblock %}
{% block nav_tree %}
{% if request and request.state.nav_tree_html %}
{{ request.state.nav_tree_html | safe }}
{% endif %}
{% endblock %}
{% block auth_menu %}
{% if request and request.state.auth_menu_html %}
{{ request.state.auth_menu_html | safe }}
{% endif %}
{% endblock %}
{% block auth_menu_mobile %}
{% if request and request.state.auth_menu_html %}
{{ request.state.auth_menu_html | safe }}
{% endif %}
{% endblock %}
{% block sub_nav %}
<div class="bg-stone-200 border-b border-stone-300">
<div class="max-w-screen-2xl mx-auto px-4">
<nav class="flex items-center gap-4 py-2 text-sm overflow-x-auto no-scrollbar">
<a href="/assets" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'assets' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Assets</a>
<a href="/activities" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'activities' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Activities</a>
<a href="/anchors" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'anchors' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Anchors</a>
<a href="/storage" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'storage' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Storage</a>
<a href="/renderers" class="whitespace-nowrap px-3 py-1.5 rounded {% if active_tab == 'renderers' %}bg-stone-500 text-white{% else %}text-stone-700 hover:bg-stone-300{% endif %}">Renderers</a>
</nav>
</div>
</div>
{% endblock %}

42
app/templates/home.html Normal file
View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Art-DAG{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
{% if readme_html %}
<div class="prose prose-invert max-w-none mb-12">
{{ readme_html | safe }}
</div>
{% else %}
<div class="text-center py-12">
<h1 class="text-4xl font-bold mb-4">Art-DAG</h1>
<p class="text-xl text-gray-400 mb-8">Content-Addressable Media with ActivityPub Federation</p>
{% if not user %}
<div class="flex justify-center space-x-4">
<a href="/auth/login" class="bg-gray-700 hover:bg-gray-600 px-6 py-3 rounded-lg font-medium">Login</a>
<a href="/auth/register" class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-medium">Register</a>
</div>
{% endif %}
</div>
{% endif %}
{% if activities %}
<h2 class="text-2xl font-bold mb-4">Recent Activity</h2>
<div class="space-y-4">
{% for activity in activities %}
<div class="bg-gray-800 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-blue-400">{{ activity.actor }}</span>
<span class="text-gray-500 text-sm">{{ activity.created_at }}</span>
</div>
<div class="text-gray-300">
{{ activity.type }}: {{ activity.summary or activity.object_type }}
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-4xl mx-auto">
<h1 class="text-2xl font-bold mb-6">Renderers</h1>
<p class="text-gray-400 mb-6">
Renderers are L1 servers that process your media. Connect to a renderer to create and run recipes.
</p>
{% if error %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded mb-6">
{{ error }}
</div>
{% endif %}
{% if success %}
<div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded mb-6">
{{ success }}
</div>
{% endif %}
<div class="space-y-4">
{% for server in servers %}
<div class="bg-gray-800 rounded-lg p-4 flex items-center justify-between">
<div>
<a href="{{ server.url }}" target="_blank" class="text-blue-400 hover:text-blue-300 font-medium">
{{ server.url }}
</a>
{% if server.healthy %}
<span class="ml-2 text-green-400 text-sm">Online</span>
{% else %}
<span class="ml-2 text-red-400 text-sm">Offline</span>
{% endif %}
</div>
<div class="flex gap-2">
<a href="{{ server.url }}/auth?auth_token={{ user.token }}"
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 rounded text-sm">
Connect
</a>
</div>
</div>
{% else %}
<p class="text-gray-500">No renderers configured.</p>
{% endfor %}
</div>
<div class="mt-8 text-gray-500 text-sm">
<p>Renderers are configured by the system administrator.</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Storage - Art-DAG{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Storage Providers</h1>
<a href="/storage/add" class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg text-sm">
Add Storage
</a>
</div>
{% if storages %}
<div class="space-y-4">
{% for storage in storages %}
<div class="bg-gray-800 border border-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">{{ storage.name or storage.provider_type }}</span>
<span class="text-xs px-2 py-1 rounded {% if storage.is_active %}bg-green-600{% else %}bg-gray-600{% endif %}">
{{ storage.provider_type }}
</span>
</div>
<div class="text-gray-400 text-sm">
{% if storage.endpoint %}
{{ storage.endpoint }}
{% elif storage.bucket %}
Bucket: {{ storage.bucket }}
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-12 text-gray-400">
<p>No storage providers configured.</p>
<a href="/storage/add" class="text-blue-400 hover:text-blue-300 mt-2 inline-block">Add one now</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}{{ profile.username }} - Profile{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<!-- Profile Header -->
<div class="bg-gray-800 rounded-lg p-6 mb-6">
<div class="flex items-start gap-6">
<div class="w-24 h-24 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-3xl font-bold text-white">
{{ profile.username[0]|upper }}
</div>
<div class="flex-1">
<h1 class="text-2xl font-bold text-white mb-1">{{ profile.display_name or profile.username }}</h1>
<p class="text-gray-400 mb-3">@{{ profile.username }}</p>
{% if profile.bio %}
<p class="text-gray-300">{{ profile.bio }}</p>
{% endif %}
</div>
</div>
</div>
<!-- Assets -->
<div class="bg-gray-800 rounded-lg p-6">
<h2 class="text-xl font-semibold text-white mb-4">Assets</h2>
{% if assets %}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{% for asset in assets %}
<a href="/assets/{{ asset.name }}" class="group">
<div class="aspect-square bg-gray-900 rounded-lg overflow-hidden">
{% if asset.asset_type == 'image' %}
<div class="w-full h-full bg-gradient-to-br from-green-900/50 to-blue-900/50 flex items-center justify-center">
<svg class="w-12 h-12 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
{% elif asset.asset_type == 'video' %}
<div class="w-full h-full bg-gradient-to-br from-purple-900/50 to-pink-900/50 flex items-center justify-center">
<svg class="w-12 h-12 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
{% else %}
<div class="w-full h-full bg-gray-700 flex items-center justify-center">
<svg class="w-12 h-12 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
</div>
{% endif %}
</div>
<p class="mt-2 text-sm text-gray-300 truncate group-hover:text-white">{{ asset.name }}</p>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500 text-center py-8">No assets yet.</p>
{% endif %}
</div>
</div>
{% endblock %}

221
db.py
View File

@@ -32,10 +32,9 @@ def _parse_timestamp(ts) -> datetime:
_pool: Optional[asyncpg.Pool] = None
# Configuration from environment
DATABASE_URL = os.environ.get(
"DATABASE_URL",
"postgresql://artdag:artdag@localhost:5432/artdag"
)
DATABASE_URL = os.environ.get("DATABASE_URL")
if not DATABASE_URL:
raise RuntimeError("DATABASE_URL environment variable is required")
# Schema for database initialization
SCHEMA = """
@@ -118,17 +117,18 @@ CREATE TABLE IF NOT EXISTS revoked_tokens (
);
-- User storage providers (IPFS pinning services, local storage, etc.)
-- Users can have multiple configs of the same provider type
CREATE TABLE IF NOT EXISTS user_storage (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL REFERENCES users(username),
provider_type VARCHAR(50) NOT NULL, -- 'pinata', 'web3storage', 'filebase', 'local'
provider_type VARCHAR(50) NOT NULL, -- 'pinata', 'web3storage', 'nftstorage', 'infura', 'filebase', 'storj', 'local'
provider_name VARCHAR(255), -- User-friendly name
description TEXT, -- User description to distinguish configs
config JSONB NOT NULL DEFAULT '{}', -- API keys, endpoints, paths
capacity_gb INTEGER NOT NULL, -- Total capacity user is contributing
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(username, provider_type, provider_name)
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Track what's stored where
@@ -169,6 +169,12 @@ DO $$ BEGIN
ALTER TABLE assets ADD COLUMN source_type VARCHAR(50);
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
-- Add description column to user_storage if it doesn't exist
DO $$ BEGIN
ALTER TABLE user_storage ADD COLUMN description TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
"""
@@ -457,6 +463,19 @@ async def asset_exists_by_name_tx(conn, name: str) -> bool:
)
async def get_asset_by_name_tx(conn, name: str) -> Optional[dict]:
"""Get asset by name within a transaction."""
row = await conn.fetchrow(
"""SELECT name, content_hash, ipfs_cid, asset_type, tags, metadata, url,
provenance, description, origin, owner, created_at, updated_at
FROM assets WHERE name = $1""",
name
)
if row:
return _parse_asset_row(row)
return None
async def create_asset_tx(conn, asset: dict) -> dict:
"""Create a new asset within a transaction."""
row = await conn.fetchrow(
@@ -739,6 +758,28 @@ async def get_all_anchors() -> list[dict]:
return results
async def get_anchors_paginated(offset: int = 0, limit: int = 20) -> list[dict]:
"""Get anchors with pagination, newest first."""
async with get_connection() as conn:
rows = await conn.fetch(
"SELECT * FROM anchors ORDER BY created_at DESC LIMIT $1 OFFSET $2",
limit, offset
)
results = []
for row in rows:
result = dict(row)
if result.get("first_activity_id"):
result["first_activity_id"] = str(result["first_activity_id"])
if result.get("last_activity_id"):
result["last_activity_id"] = str(result["last_activity_id"])
if result.get("created_at"):
result["created_at"] = result["created_at"].isoformat()
if result.get("confirmed_at"):
result["confirmed_at"] = result["confirmed_at"].isoformat()
results.append(result)
return results
async def update_anchor_confirmed(merkle_root: str, bitcoin_txid: str) -> bool:
"""Mark anchor as confirmed with Bitcoin txid."""
async with get_connection() as conn:
@@ -818,20 +859,33 @@ async def get_user_storage(username: str) -> list[dict]:
"""Get all storage providers for a user."""
async with get_connection() as conn:
rows = await conn.fetch(
"""SELECT id, username, provider_type, provider_name, config,
"""SELECT id, username, provider_type, provider_name, description, config,
capacity_gb, is_active, created_at, updated_at
FROM user_storage WHERE username = $1
ORDER BY created_at""",
ORDER BY provider_type, created_at""",
username
)
return [dict(row) for row in rows]
async def get_user_storage_by_type(username: str, provider_type: str) -> list[dict]:
"""Get storage providers of a specific type for a user."""
async with get_connection() as conn:
rows = await conn.fetch(
"""SELECT id, username, provider_type, provider_name, description, config,
capacity_gb, is_active, created_at, updated_at
FROM user_storage WHERE username = $1 AND provider_type = $2
ORDER BY created_at""",
username, provider_type
)
return [dict(row) for row in rows]
async def get_storage_by_id(storage_id: int) -> Optional[dict]:
"""Get a storage provider by ID."""
async with get_connection() as conn:
row = await conn.fetchrow(
"""SELECT id, username, provider_type, provider_name, config,
"""SELECT id, username, provider_type, provider_name, description, config,
capacity_gb, is_active, created_at, updated_at
FROM user_storage WHERE id = $1""",
storage_id
@@ -844,16 +898,17 @@ async def add_user_storage(
provider_type: str,
provider_name: str,
config: dict,
capacity_gb: int
capacity_gb: int,
description: Optional[str] = None
) -> Optional[int]:
"""Add a storage provider for a user. Returns storage ID."""
async with get_connection() as conn:
try:
row = await conn.fetchrow(
"""INSERT INTO user_storage (username, provider_type, provider_name, config, capacity_gb)
VALUES ($1, $2, $3, $4, $5)
"""INSERT INTO user_storage (username, provider_type, provider_name, description, config, capacity_gb)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id""",
username, provider_type, provider_name, json.dumps(config), capacity_gb
username, provider_type, provider_name, description, json.dumps(config), capacity_gb
)
return row["id"] if row else None
except Exception:
@@ -862,6 +917,8 @@ async def add_user_storage(
async def update_user_storage(
storage_id: int,
provider_name: Optional[str] = None,
description: Optional[str] = None,
config: Optional[dict] = None,
capacity_gb: Optional[int] = None,
is_active: Optional[bool] = None
@@ -871,6 +928,14 @@ async def update_user_storage(
params = []
param_num = 1
if provider_name is not None:
updates.append(f"provider_name = ${param_num}")
params.append(provider_name)
param_num += 1
if description is not None:
updates.append(f"description = ${param_num}")
params.append(description)
param_num += 1
if config is not None:
updates.append(f"config = ${param_num}")
params.append(json.dumps(config))
@@ -974,14 +1039,15 @@ async def get_all_active_storage() -> list[dict]:
"""Get all active storage providers (for distributed pinning)."""
async with get_connection() as conn:
rows = await conn.fetch(
"""SELECT us.*,
"""SELECT us.id, us.username, us.provider_type, us.provider_name, us.description,
us.config, us.capacity_gb, us.is_active, us.created_at, us.updated_at,
COALESCE(SUM(sp.size_bytes), 0) as used_bytes,
COUNT(sp.id) as pin_count
FROM user_storage us
LEFT JOIN storage_pins sp ON us.id = sp.storage_id
WHERE us.is_active = true
GROUP BY us.id
ORDER BY us.created_at"""
ORDER BY us.provider_type, us.created_at"""
)
return [dict(row) for row in rows]
@@ -1024,3 +1090,126 @@ async def cleanup_expired_revocations() -> int:
return int(result.split()[-1])
except (ValueError, IndexError):
return 0
# ============ Additional helper functions ============
async def get_user_assets(username: str, offset: int = 0, limit: int = 20, asset_type: str = None) -> list[dict]:
"""Get assets owned by a user with pagination."""
async with get_connection() as conn:
if asset_type:
rows = await conn.fetch(
"""SELECT * FROM assets WHERE owner = $1 AND asset_type = $2
ORDER BY created_at DESC LIMIT $3 OFFSET $4""",
username, asset_type, limit, offset
)
else:
rows = await conn.fetch(
"""SELECT * FROM assets WHERE owner = $1
ORDER BY created_at DESC LIMIT $2 OFFSET $3""",
username, limit, offset
)
return [dict(row) for row in rows]
async def delete_asset(asset_id: str) -> bool:
"""Delete an asset by name/id."""
async with get_connection() as conn:
result = await conn.execute("DELETE FROM assets WHERE name = $1", asset_id)
return "DELETE 1" in result
async def count_users() -> int:
"""Count total users."""
async with get_connection() as conn:
return await conn.fetchval("SELECT COUNT(*) FROM users")
async def count_user_activities(username: str) -> int:
"""Count activities by a user."""
async with get_connection() as conn:
return await conn.fetchval(
"SELECT COUNT(*) FROM activities WHERE actor_id LIKE $1",
f"%{username}%"
)
async def get_user_activities(username: str, limit: int = 20, offset: int = 0) -> list[dict]:
"""Get activities by a user."""
async with get_connection() as conn:
rows = await conn.fetch(
"""SELECT activity_id, activity_type, actor_id, object_data, published, signature
FROM activities WHERE actor_id LIKE $1
ORDER BY published DESC LIMIT $2 OFFSET $3""",
f"%{username}%", limit, offset
)
return [_parse_activity_row(row) for row in rows]
async def get_renderer(renderer_id: str) -> Optional[dict]:
"""Get a renderer by ID/URL."""
async with get_connection() as conn:
row = await conn.fetchrow(
"SELECT * FROM user_renderers WHERE l1_url = $1",
renderer_id
)
return dict(row) if row else None
async def update_anchor(anchor_id: str, **updates) -> bool:
"""Update an anchor."""
async with get_connection() as conn:
if "bitcoin_txid" in updates:
result = await conn.execute(
"""UPDATE anchors SET bitcoin_txid = $1, confirmed_at = NOW()
WHERE merkle_root = $2""",
updates["bitcoin_txid"], anchor_id
)
return "UPDATE 1" in result
return False
async def delete_anchor(anchor_id: str) -> bool:
"""Delete an anchor."""
async with get_connection() as conn:
result = await conn.execute(
"DELETE FROM anchors WHERE merkle_root = $1", anchor_id
)
return "DELETE 1" in result
async def record_run(run_id: str, username: str, recipe: str, inputs: list,
output_hash: str, ipfs_cid: str = None, asset_id: str = None) -> dict:
"""Record a completed run."""
async with get_connection() as conn:
# Check if runs table exists, if not just return the data
try:
row = await conn.fetchrow(
"""INSERT INTO runs (run_id, username, recipe, inputs, output_hash, ipfs_cid, asset_id, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (run_id) DO UPDATE SET
output_hash = EXCLUDED.output_hash,
ipfs_cid = EXCLUDED.ipfs_cid,
asset_id = EXCLUDED.asset_id
RETURNING *""",
run_id, username, recipe, json.dumps(inputs), output_hash, ipfs_cid, asset_id
)
return dict(row) if row else None
except Exception:
# Table might not exist
return {"run_id": run_id, "username": username, "recipe": recipe}
async def get_run(run_id: str) -> Optional[dict]:
"""Get a run by ID."""
async with get_connection() as conn:
try:
row = await conn.fetchrow("SELECT * FROM runs WHERE run_id = $1", run_id)
if row:
result = dict(row)
if result.get("inputs") and isinstance(result["inputs"], str):
result["inputs"] = json.loads(result["inputs"])
return result
except Exception:
pass
return None

View File

@@ -7,7 +7,7 @@ echo "=== Pulling latest code ==="
git pull
echo "=== Building Docker image ==="
docker build -t git.rose-ash.com/art-dag/l2-server:latest .
docker build --build-arg CACHEBUST=$(date +%s) -t git.rose-ash.com/art-dag/l2-server:latest .
echo "=== Redeploying activitypub stack ==="
docker stack deploy -c docker-compose.yml activitypub

View File

@@ -3,9 +3,10 @@ version: "3.8"
services:
postgres:
image: postgres:16-alpine
env_file:
- .env
environment:
POSTGRES_USER: artdag
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-artdag}
POSTGRES_DB: artdag
volumes:
- postgres_data:/var/lib/postgresql/data
@@ -16,6 +17,10 @@ services:
interval: 5s
timeout: 5s
retries: 5
deploy:
placement:
constraints:
- node.labels.gpu != true
ipfs:
image: ipfs/kubo:latest
@@ -31,17 +36,29 @@ services:
replicas: 1
restart_policy:
condition: on-failure
placement:
constraints:
- node.labels.gpu != true
l2-server:
image: git.rose-ash.com/art-dag/l2-server:latest
image: registry.rose-ash.com:5000/l2-server:latest
env_file:
- .env
environment:
- ARTDAG_DATA=/data/l2
- DATABASE_URL=postgresql://artdag:${POSTGRES_PASSWORD:-artdag}@postgres:5432/artdag
- IPFS_API=/dns/ipfs/tcp/5001
- ANCHOR_BACKUP_DIR=/data/anchors
# ARTDAG_DOMAIN, ARTDAG_USER, JWT_SECRET from .env file
# Coop app internal URLs for fragment composition
- INTERNAL_URL_BLOG=http://blog:8000
- INTERNAL_URL_CART=http://cart:8000
- INTERNAL_URL_ACCOUNT=http://account:8000
# DATABASE_URL, ARTDAG_DOMAIN, ARTDAG_USER, JWT_SECRET from .env file
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8200/')"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
volumes:
- l2_data:/data/l2 # Still needed for RSA keys
- anchor_backup:/data/anchors # Persistent anchor proofs (survives DB wipes)
@@ -53,8 +70,13 @@ services:
- ipfs
deploy:
replicas: 1
update_config:
order: start-first
restart_policy:
condition: on-failure
placement:
constraints:
- node.labels.gpu != true
volumes:
l2_data:

View File

@@ -27,10 +27,9 @@ import asyncpg
# Configuration
DATA_DIR = Path(os.environ.get("ARTDAG_DATA", str(Path.home() / ".artdag" / "l2")))
DATABASE_URL = os.environ.get(
"DATABASE_URL",
"postgresql://artdag:artdag@localhost:5432/artdag"
)
DATABASE_URL = os.environ.get("DATABASE_URL")
if not DATABASE_URL:
raise RuntimeError("DATABASE_URL environment variable is required")
SCHEMA = """
-- Drop existing tables (careful in production!)

View File

@@ -1,9 +1,13 @@
fastapi>=0.109.0
uvicorn>=0.27.0
requests>=2.31.0
httpx>=0.27.0
cryptography>=42.0.0
bcrypt>=4.0.0
python-jose[cryptography]>=3.3.0
markdown>=3.5.0
python-multipart>=0.0.6
asyncpg>=0.29.0
boto3>=1.34.0
# Shared components
git+https://git.rose-ash.com/art-dag/common.git@889ea98

3421
server.py

File diff suppressed because it is too large Load Diff

3765
server_legacy.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -370,6 +370,479 @@ class Web3StorageProvider(StorageProvider):
return {"used_bytes": 0, "capacity_bytes": self.capacity_bytes, "pin_count": 0}
class NFTStorageProvider(StorageProvider):
"""NFT.Storage pinning service provider (free for NFT data)."""
provider_type = "nftstorage"
def __init__(self, api_token: str, capacity_gb: int = 5):
self.api_token = api_token
self.capacity_bytes = capacity_gb * 1024**3
self.base_url = "https://api.nft.storage"
def _headers(self) -> dict:
return {"Authorization": f"Bearer {self.api_token}"}
async def pin(self, content_hash: str, data: bytes, filename: Optional[str] = None) -> Optional[str]:
"""Pin content to NFT.Storage."""
try:
import asyncio
def do_pin():
response = requests.post(
f"{self.base_url}/upload",
data=data,
headers={**self._headers(), "Content-Type": "application/octet-stream"},
timeout=120
)
response.raise_for_status()
return response.json().get("value", {}).get("cid")
cid = await asyncio.to_thread(do_pin)
logger.info(f"NFT.Storage: Pinned {content_hash[:16]}... as {cid}")
return cid
except Exception as e:
logger.error(f"NFT.Storage pin failed: {e}")
return None
async def unpin(self, content_hash: str) -> bool:
"""NFT.Storage doesn't support unpinning - data is stored permanently."""
logger.warning("NFT.Storage: Unpinning not supported (permanent storage)")
return False
async def get(self, content_hash: str) -> Optional[bytes]:
"""Get content from NFT.Storage - would need CID mapping."""
return None
async def is_pinned(self, content_hash: str) -> bool:
"""Check if content is pinned - would need CID mapping."""
return False
async def test_connection(self) -> tuple[bool, str]:
"""Test NFT.Storage API connectivity."""
try:
import asyncio
def do_test():
response = requests.get(
f"{self.base_url}/",
headers=self._headers(),
timeout=10
)
response.raise_for_status()
return True, "Connected to NFT.Storage successfully"
return await asyncio.to_thread(do_test)
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
return False, "Invalid API token"
return False, f"HTTP error: {e}"
except Exception as e:
return False, f"Connection failed: {e}"
def get_usage(self) -> dict:
"""Get NFT.Storage usage stats."""
return {"used_bytes": 0, "capacity_bytes": self.capacity_bytes, "pin_count": 0}
class InfuraIPFSProvider(StorageProvider):
"""Infura IPFS pinning service provider."""
provider_type = "infura"
def __init__(self, project_id: str, project_secret: str, capacity_gb: int = 5):
self.project_id = project_id
self.project_secret = project_secret
self.capacity_bytes = capacity_gb * 1024**3
self.base_url = "https://ipfs.infura.io:5001/api/v0"
def _auth(self) -> tuple:
return (self.project_id, self.project_secret)
async def pin(self, content_hash: str, data: bytes, filename: Optional[str] = None) -> Optional[str]:
"""Pin content to Infura IPFS."""
try:
import asyncio
def do_pin():
files = {"file": (filename or f"{content_hash[:16]}.bin", data)}
response = requests.post(
f"{self.base_url}/add",
files=files,
auth=self._auth(),
timeout=120
)
response.raise_for_status()
return response.json().get("Hash")
cid = await asyncio.to_thread(do_pin)
logger.info(f"Infura IPFS: Pinned {content_hash[:16]}... as {cid}")
return cid
except Exception as e:
logger.error(f"Infura IPFS pin failed: {e}")
return None
async def unpin(self, content_hash: str) -> bool:
"""Unpin content from Infura IPFS."""
try:
import asyncio
def do_unpin():
response = requests.post(
f"{self.base_url}/pin/rm",
params={"arg": content_hash},
auth=self._auth(),
timeout=30
)
response.raise_for_status()
return True
return await asyncio.to_thread(do_unpin)
except Exception as e:
logger.error(f"Infura IPFS unpin failed: {e}")
return False
async def get(self, content_hash: str) -> Optional[bytes]:
"""Get content from Infura IPFS gateway."""
try:
import asyncio
def do_get():
response = requests.post(
f"{self.base_url}/cat",
params={"arg": content_hash},
auth=self._auth(),
timeout=120
)
response.raise_for_status()
return response.content
return await asyncio.to_thread(do_get)
except Exception as e:
logger.error(f"Infura IPFS get failed: {e}")
return None
async def is_pinned(self, content_hash: str) -> bool:
"""Check if content is pinned on Infura IPFS."""
try:
import asyncio
def do_check():
response = requests.post(
f"{self.base_url}/pin/ls",
params={"arg": content_hash},
auth=self._auth(),
timeout=30
)
return response.status_code == 200
return await asyncio.to_thread(do_check)
except Exception:
return False
async def test_connection(self) -> tuple[bool, str]:
"""Test Infura IPFS API connectivity."""
try:
import asyncio
def do_test():
response = requests.post(
f"{self.base_url}/id",
auth=self._auth(),
timeout=10
)
response.raise_for_status()
return True, "Connected to Infura IPFS successfully"
return await asyncio.to_thread(do_test)
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
return False, "Invalid project credentials"
return False, f"HTTP error: {e}"
except Exception as e:
return False, f"Connection failed: {e}"
def get_usage(self) -> dict:
"""Get Infura usage stats."""
return {"used_bytes": 0, "capacity_bytes": self.capacity_bytes, "pin_count": 0}
class FilebaseProvider(StorageProvider):
"""Filebase S3-compatible IPFS pinning service."""
provider_type = "filebase"
def __init__(self, access_key: str, secret_key: str, bucket: str, capacity_gb: int = 5):
self.access_key = access_key
self.secret_key = secret_key
self.bucket = bucket
self.capacity_bytes = capacity_gb * 1024**3
self.endpoint = "https://s3.filebase.com"
async def pin(self, content_hash: str, data: bytes, filename: Optional[str] = None) -> Optional[str]:
"""Pin content to Filebase."""
try:
import asyncio
import boto3
from botocore.config import Config
def do_pin():
s3 = boto3.client(
's3',
endpoint_url=self.endpoint,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
config=Config(signature_version='s3v4')
)
key = filename or f"{content_hash[:16]}.bin"
s3.put_object(Bucket=self.bucket, Key=key, Body=data)
# Get CID from response headers
head = s3.head_object(Bucket=self.bucket, Key=key)
return head.get('Metadata', {}).get('cid', content_hash)
cid = await asyncio.to_thread(do_pin)
logger.info(f"Filebase: Pinned {content_hash[:16]}... as {cid}")
return cid
except Exception as e:
logger.error(f"Filebase pin failed: {e}")
return None
async def unpin(self, content_hash: str) -> bool:
"""Remove content from Filebase."""
try:
import asyncio
import boto3
from botocore.config import Config
def do_unpin():
s3 = boto3.client(
's3',
endpoint_url=self.endpoint,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
config=Config(signature_version='s3v4')
)
s3.delete_object(Bucket=self.bucket, Key=content_hash)
return True
return await asyncio.to_thread(do_unpin)
except Exception as e:
logger.error(f"Filebase unpin failed: {e}")
return False
async def get(self, content_hash: str) -> Optional[bytes]:
"""Get content from Filebase."""
try:
import asyncio
import boto3
from botocore.config import Config
def do_get():
s3 = boto3.client(
's3',
endpoint_url=self.endpoint,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
config=Config(signature_version='s3v4')
)
response = s3.get_object(Bucket=self.bucket, Key=content_hash)
return response['Body'].read()
return await asyncio.to_thread(do_get)
except Exception as e:
logger.error(f"Filebase get failed: {e}")
return None
async def is_pinned(self, content_hash: str) -> bool:
"""Check if content exists in Filebase."""
try:
import asyncio
import boto3
from botocore.config import Config
def do_check():
s3 = boto3.client(
's3',
endpoint_url=self.endpoint,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
config=Config(signature_version='s3v4')
)
s3.head_object(Bucket=self.bucket, Key=content_hash)
return True
return await asyncio.to_thread(do_check)
except Exception:
return False
async def test_connection(self) -> tuple[bool, str]:
"""Test Filebase connectivity."""
try:
import asyncio
import boto3
from botocore.config import Config
def do_test():
s3 = boto3.client(
's3',
endpoint_url=self.endpoint,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
config=Config(signature_version='s3v4')
)
s3.head_bucket(Bucket=self.bucket)
return True, f"Connected to Filebase bucket '{self.bucket}'"
return await asyncio.to_thread(do_test)
except Exception as e:
if "404" in str(e):
return False, f"Bucket '{self.bucket}' not found"
if "403" in str(e):
return False, "Invalid credentials or no access to bucket"
return False, f"Connection failed: {e}"
def get_usage(self) -> dict:
"""Get Filebase usage stats."""
return {"used_bytes": 0, "capacity_bytes": self.capacity_bytes, "pin_count": 0}
class StorjProvider(StorageProvider):
"""Storj decentralized cloud storage (S3-compatible)."""
provider_type = "storj"
def __init__(self, access_key: str, secret_key: str, bucket: str, capacity_gb: int = 25):
self.access_key = access_key
self.secret_key = secret_key
self.bucket = bucket
self.capacity_bytes = capacity_gb * 1024**3
self.endpoint = "https://gateway.storjshare.io"
async def pin(self, content_hash: str, data: bytes, filename: Optional[str] = None) -> Optional[str]:
"""Store content on Storj."""
try:
import asyncio
import boto3
from botocore.config import Config
def do_pin():
s3 = boto3.client(
's3',
endpoint_url=self.endpoint,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
config=Config(signature_version='s3v4')
)
key = filename or content_hash
s3.put_object(Bucket=self.bucket, Key=key, Body=data)
return content_hash
result = await asyncio.to_thread(do_pin)
logger.info(f"Storj: Stored {content_hash[:16]}...")
return result
except Exception as e:
logger.error(f"Storj pin failed: {e}")
return None
async def unpin(self, content_hash: str) -> bool:
"""Remove content from Storj."""
try:
import asyncio
import boto3
from botocore.config import Config
def do_unpin():
s3 = boto3.client(
's3',
endpoint_url=self.endpoint,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
config=Config(signature_version='s3v4')
)
s3.delete_object(Bucket=self.bucket, Key=content_hash)
return True
return await asyncio.to_thread(do_unpin)
except Exception as e:
logger.error(f"Storj unpin failed: {e}")
return False
async def get(self, content_hash: str) -> Optional[bytes]:
"""Get content from Storj."""
try:
import asyncio
import boto3
from botocore.config import Config
def do_get():
s3 = boto3.client(
's3',
endpoint_url=self.endpoint,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
config=Config(signature_version='s3v4')
)
response = s3.get_object(Bucket=self.bucket, Key=content_hash)
return response['Body'].read()
return await asyncio.to_thread(do_get)
except Exception as e:
logger.error(f"Storj get failed: {e}")
return None
async def is_pinned(self, content_hash: str) -> bool:
"""Check if content exists on Storj."""
try:
import asyncio
import boto3
from botocore.config import Config
def do_check():
s3 = boto3.client(
's3',
endpoint_url=self.endpoint,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
config=Config(signature_version='s3v4')
)
s3.head_object(Bucket=self.bucket, Key=content_hash)
return True
return await asyncio.to_thread(do_check)
except Exception:
return False
async def test_connection(self) -> tuple[bool, str]:
"""Test Storj connectivity."""
try:
import asyncio
import boto3
from botocore.config import Config
def do_test():
s3 = boto3.client(
's3',
endpoint_url=self.endpoint,
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
config=Config(signature_version='s3v4')
)
s3.head_bucket(Bucket=self.bucket)
return True, f"Connected to Storj bucket '{self.bucket}'"
return await asyncio.to_thread(do_test)
except Exception as e:
if "404" in str(e):
return False, f"Bucket '{self.bucket}' not found"
if "403" in str(e):
return False, "Invalid credentials or no access to bucket"
return False, f"Connection failed: {e}"
def get_usage(self) -> dict:
"""Get Storj usage stats."""
return {"used_bytes": 0, "capacity_bytes": self.capacity_bytes, "pin_count": 0}
class LocalStorageProvider(StorageProvider):
"""Local filesystem storage provider."""
@@ -477,7 +950,7 @@ def create_provider(provider_type: str, config: dict) -> Optional[StorageProvide
Factory function to create a storage provider from config.
Args:
provider_type: 'pinata', 'web3storage', or 'local'
provider_type: One of 'pinata', 'web3storage', 'nftstorage', 'infura', 'filebase', 'storj', 'local'
config: Provider-specific configuration dict
Returns:
@@ -493,7 +966,32 @@ def create_provider(provider_type: str, config: dict) -> Optional[StorageProvide
elif provider_type == "web3storage":
return Web3StorageProvider(
api_token=config["api_token"],
capacity_gb=config.get("capacity_gb", 1)
capacity_gb=config.get("capacity_gb", 5)
)
elif provider_type == "nftstorage":
return NFTStorageProvider(
api_token=config["api_token"],
capacity_gb=config.get("capacity_gb", 5)
)
elif provider_type == "infura":
return InfuraIPFSProvider(
project_id=config["project_id"],
project_secret=config["project_secret"],
capacity_gb=config.get("capacity_gb", 5)
)
elif provider_type == "filebase":
return FilebaseProvider(
access_key=config["access_key"],
secret_key=config["secret_key"],
bucket=config["bucket"],
capacity_gb=config.get("capacity_gb", 5)
)
elif provider_type == "storj":
return StorjProvider(
access_key=config["access_key"],
secret_key=config["secret_key"],
bucket=config["bucket"],
capacity_gb=config.get("capacity_gb", 25)
)
elif provider_type == "local":
return LocalStorageProvider(