Compare commits
40 Commits
efd3a2dd16
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40b010b8d9 | ||
|
|
79caa24e21 | ||
|
|
e3c8b85812 | ||
|
|
2448dabb83 | ||
|
|
eed5aff238 | ||
|
|
859ff0b835 | ||
|
|
5e45f24fba | ||
|
|
fbf188afdc | ||
|
|
8f1ba74c53 | ||
|
|
655f533439 | ||
|
|
8c4a30d18f | ||
|
|
dcb487e6f4 | ||
|
|
0a15b2532e | ||
|
|
f8f44945ab | ||
|
|
65994ac107 | ||
|
|
c3d131644a | ||
|
|
65169f49f9 | ||
|
|
ff7ce1a61e | ||
|
|
39870a499c | ||
|
|
bfd38559b3 | ||
|
|
358fbba7b2 | ||
|
|
0a31e1acfa | ||
|
|
d49e759d5a | ||
|
|
dd3d5927f5 | ||
|
|
c9c4a340fd | ||
|
|
5730cd0f22 | ||
|
|
d1e9287829 | ||
|
|
a5619208cf | ||
|
|
e4cbbb1fbc | ||
|
|
678d0e0ea3 | ||
|
|
59484936fd | ||
|
|
292f7bf316 | ||
|
|
c1cbf0b4ad | ||
|
|
de7ca82862 | ||
|
|
2326658518 | ||
|
|
770c36479f | ||
|
|
8bef9afb1f | ||
|
|
fb5c46330d | ||
|
|
70cde17fef | ||
|
|
cf94600d63 |
@@ -1,5 +1,8 @@
|
|||||||
# L2 Server Configuration
|
# L2 Server Configuration
|
||||||
|
|
||||||
|
# PostgreSQL password (REQUIRED - no default)
|
||||||
|
POSTGRES_PASSWORD=changeme-generate-with-openssl-rand-hex-16
|
||||||
|
|
||||||
# Domain for this ActivityPub server
|
# Domain for this ActivityPub server
|
||||||
ARTDAG_DOMAIN=artdag.rose-ash.com
|
ARTDAG_DOMAIN=artdag.rose-ash.com
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ FROM python:3.11-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
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
|
# Install dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
ARG CACHEBUST=1
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application
|
# Copy application
|
||||||
|
|||||||
490
README.md
490
README.md
@@ -1,26 +1,34 @@
|
|||||||
# Art DAG L2 Server - ActivityPub
|
# 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
|
- **Asset Registry**: Content-addressed assets with provenance tracking
|
||||||
- **Activities**: Creates signed ownership claims (Create activities)
|
- **ActivityPub Federation**: Standard protocol for distributed social networking
|
||||||
- **Federation**: ActivityPub endpoints for follow/share
|
- **OpenTimestamps Anchoring**: Cryptographic proof of existence on Bitcoin blockchain
|
||||||
- **L1 Integration**: Records completed L1 runs as owned assets
|
- **L1 Integration**: Record and verify L1 rendering runs
|
||||||
- **Authentication**: User registration, login, JWT tokens
|
- **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
|
```bash
|
||||||
|
# Install dependencies
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Configure (optional - defaults shown)
|
# Configure
|
||||||
export ARTDAG_DOMAIN=artdag.rose-ash.com
|
export ARTDAG_DOMAIN=artdag.example.com
|
||||||
export ARTDAG_USER=giles
|
export ARTDAG_USER=giles
|
||||||
export ARTDAG_DATA=~/.artdag/l2
|
export DATABASE_URL=postgresql://artdag:$POSTGRES_PASSWORD@localhost:5432/artdag
|
||||||
export DATABASE_URL=postgresql://artdag:artdag@localhost:5432/artdag
|
export L1_SERVERS=https://celery-artdag.example.com
|
||||||
export L1_SERVERS=https://celery-artdag.rose-ash.com
|
|
||||||
|
|
||||||
# Generate signing keys (required for federation)
|
# Generate signing keys (required for federation)
|
||||||
python setup_keys.py
|
python setup_keys.py
|
||||||
@@ -29,142 +37,265 @@ python setup_keys.py
|
|||||||
python server.py
|
python server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## JWT Secret Configuration
|
## Docker Deployment
|
||||||
|
|
||||||
The JWT secret is used to sign authentication tokens. **Without a persistent secret, tokens are invalidated on server restart.**
|
|
||||||
|
|
||||||
### Generate a secret
|
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
```bash
|
||||||
export JWT_SECRET="your-generated-secret-here"
|
# Generate keys
|
||||||
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
|
|
||||||
python setup_keys.py
|
python setup_keys.py
|
||||||
|
|
||||||
# Or with custom paths
|
# Or with custom paths
|
||||||
python setup_keys.py --data-dir /data/l2 --user giles
|
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/`:
|
Keys stored in `$ARTDAG_DATA/keys/`:
|
||||||
- `{username}.pem` - Private key (chmod 600, NEVER share)
|
- `{username}.pem` - Private key (chmod 600)
|
||||||
- `{username}.pub` - Public key (included in actor profile)
|
- `{username}.pub` - Public key (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`.
|
|
||||||
|
|
||||||
## Web UI
|
## Web UI
|
||||||
|
|
||||||
The server provides a web interface:
|
|
||||||
|
|
||||||
| Path | Description |
|
| 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 |
|
| `/assets` | Browse registered assets |
|
||||||
| `/asset/{name}` | Asset detail page |
|
| `/asset/{name}` | Asset detail page |
|
||||||
| `/activities` | View published activities |
|
| `/activities` | Published activities |
|
||||||
| `/activity/{id}` | Activity detail page |
|
| `/activity/{id}` | Activity detail |
|
||||||
| `/users` | List registered users |
|
| `/users` | Registered users |
|
||||||
| `/renderers` | Manage L1 renderer connections |
|
| `/renderers` | L1 renderer connections |
|
||||||
| `/anchors/ui` | OpenTimestamps anchor management |
|
| `/anchors/ui` | OpenTimestamps management |
|
||||||
| `/login` | Login page |
|
| `/storage` | Storage provider config |
|
||||||
| `/register` | Registration page |
|
|
||||||
| `/logout` | Log out |
|
|
||||||
| `/download/client` | Download CLI client |
|
| `/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
|
```bash
|
||||||
curl -X POST http://localhost:8200/assets \
|
# Single L1 server
|
||||||
-H "Content-Type: application/json" \
|
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 "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"name": "my-video",
|
"name": "my-video",
|
||||||
"content_hash": "abc123...",
|
"content_hash": "abc123...",
|
||||||
@@ -173,14 +304,12 @@ curl -X POST http://localhost:8200/assets \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Upload Recipe
|
### Record L1 Run
|
||||||
|
|
||||||
Record an L1 run as an owned asset. This fetches the run details from the L1 server and registers the output:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8200/assets/record-run \
|
curl -X POST https://artdag.example.com/assets/record-run \
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer <token>" \
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"run_id": "uuid-from-l1",
|
"run_id": "uuid-from-l1",
|
||||||
"l1_server": "https://celery-artdag.rose-ash.com",
|
"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
|
```bash
|
||||||
| Method | Path | Description |
|
curl -X POST https://artdag.example.com/assets/publish-cache \
|
||||||
|--------|------|-------------|
|
-H "Authorization: Bearer <token>" \
|
||||||
| GET | `/` | Home page with stats |
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
### Assets
|
"content_hash": "abc123...",
|
||||||
| Method | Path | Description |
|
"l1_server": "https://celery-artdag.rose-ash.com",
|
||||||
|--------|------|-------------|
|
"name": "my-asset",
|
||||||
| GET | `/assets` | List all assets |
|
"asset_type": "video"
|
||||||
| 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).
|
|
||||||
|
|
||||||
## Architecture
|
## 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
116
app/__init__.py
Normal 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
56
app/config.py
Normal 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
80
app/dependencies.py
Normal 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
25
app/routers/__init__.py
Normal 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
99
app/routers/activities.py
Normal 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
203
app/routers/anchors.py
Normal 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
244
app/routers/assets.py
Normal 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
223
app/routers/auth.py
Normal 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
115
app/routers/federation.py
Normal 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
93
app/routers/renderers.py
Normal 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
254
app/routers/storage.py
Normal 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
161
app/routers/users.py
Normal 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
11
app/templates/404.html
Normal 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 %}
|
||||||
63
app/templates/activities/detail.html
Normal file
63
app/templates/activities/detail.html
Normal 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 %}
|
||||||
39
app/templates/activities/list.html
Normal file
39
app/templates/activities/list.html
Normal 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 %}
|
||||||
81
app/templates/anchors/detail.html
Normal file
81
app/templates/anchors/detail.html
Normal 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 %}
|
||||||
47
app/templates/anchors/list.html
Normal file
47
app/templates/anchors/list.html
Normal 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 %}
|
||||||
81
app/templates/assets/detail.html
Normal file
81
app/templates/assets/detail.html
Normal 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 %}
|
||||||
58
app/templates/assets/list.html
Normal file
58
app/templates/assets/list.html
Normal 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 %}
|
||||||
12
app/templates/auth/already_logged_in.html
Normal file
12
app/templates/auth/already_logged_in.html
Normal 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 %}
|
||||||
37
app/templates/auth/login.html
Normal file
37
app/templates/auth/login.html
Normal 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 %}
|
||||||
45
app/templates/auth/register.html
Normal file
45
app/templates/auth/register.html
Normal 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
47
app/templates/base.html
Normal 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
42
app/templates/home.html
Normal 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 %}
|
||||||
52
app/templates/renderers/list.html
Normal file
52
app/templates/renderers/list.html
Normal 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 %}
|
||||||
41
app/templates/storage/list.html
Normal file
41
app/templates/storage/list.html
Normal 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 %}
|
||||||
62
app/templates/users/profile.html
Normal file
62
app/templates/users/profile.html
Normal 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
221
db.py
@@ -32,10 +32,9 @@ def _parse_timestamp(ts) -> datetime:
|
|||||||
_pool: Optional[asyncpg.Pool] = None
|
_pool: Optional[asyncpg.Pool] = None
|
||||||
|
|
||||||
# Configuration from environment
|
# Configuration from environment
|
||||||
DATABASE_URL = os.environ.get(
|
DATABASE_URL = os.environ.get("DATABASE_URL")
|
||||||
"DATABASE_URL",
|
if not DATABASE_URL:
|
||||||
"postgresql://artdag:artdag@localhost:5432/artdag"
|
raise RuntimeError("DATABASE_URL environment variable is required")
|
||||||
)
|
|
||||||
|
|
||||||
# Schema for database initialization
|
# Schema for database initialization
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
@@ -118,17 +117,18 @@ CREATE TABLE IF NOT EXISTS revoked_tokens (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- User storage providers (IPFS pinning services, local storage, etc.)
|
-- 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 (
|
CREATE TABLE IF NOT EXISTS user_storage (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
username VARCHAR(255) NOT NULL REFERENCES users(username),
|
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
|
provider_name VARCHAR(255), -- User-friendly name
|
||||||
|
description TEXT, -- User description to distinguish configs
|
||||||
config JSONB NOT NULL DEFAULT '{}', -- API keys, endpoints, paths
|
config JSONB NOT NULL DEFAULT '{}', -- API keys, endpoints, paths
|
||||||
capacity_gb INTEGER NOT NULL, -- Total capacity user is contributing
|
capacity_gb INTEGER NOT NULL, -- Total capacity user is contributing
|
||||||
is_active BOOLEAN DEFAULT true,
|
is_active BOOLEAN DEFAULT true,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
UNIQUE(username, provider_type, provider_name)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Track what's stored where
|
-- Track what's stored where
|
||||||
@@ -169,6 +169,12 @@ DO $$ BEGIN
|
|||||||
ALTER TABLE assets ADD COLUMN source_type VARCHAR(50);
|
ALTER TABLE assets ADD COLUMN source_type VARCHAR(50);
|
||||||
EXCEPTION WHEN duplicate_column THEN NULL;
|
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
END $$;
|
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:
|
async def create_asset_tx(conn, asset: dict) -> dict:
|
||||||
"""Create a new asset within a transaction."""
|
"""Create a new asset within a transaction."""
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
@@ -739,6 +758,28 @@ async def get_all_anchors() -> list[dict]:
|
|||||||
return results
|
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:
|
async def update_anchor_confirmed(merkle_root: str, bitcoin_txid: str) -> bool:
|
||||||
"""Mark anchor as confirmed with Bitcoin txid."""
|
"""Mark anchor as confirmed with Bitcoin txid."""
|
||||||
async with get_connection() as conn:
|
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."""
|
"""Get all storage providers for a user."""
|
||||||
async with get_connection() as conn:
|
async with get_connection() as conn:
|
||||||
rows = await conn.fetch(
|
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
|
capacity_gb, is_active, created_at, updated_at
|
||||||
FROM user_storage WHERE username = $1
|
FROM user_storage WHERE username = $1
|
||||||
ORDER BY created_at""",
|
ORDER BY provider_type, created_at""",
|
||||||
username
|
username
|
||||||
)
|
)
|
||||||
return [dict(row) for row in rows]
|
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]:
|
async def get_storage_by_id(storage_id: int) -> Optional[dict]:
|
||||||
"""Get a storage provider by ID."""
|
"""Get a storage provider by ID."""
|
||||||
async with get_connection() as conn:
|
async with get_connection() as conn:
|
||||||
row = await conn.fetchrow(
|
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
|
capacity_gb, is_active, created_at, updated_at
|
||||||
FROM user_storage WHERE id = $1""",
|
FROM user_storage WHERE id = $1""",
|
||||||
storage_id
|
storage_id
|
||||||
@@ -844,16 +898,17 @@ async def add_user_storage(
|
|||||||
provider_type: str,
|
provider_type: str,
|
||||||
provider_name: str,
|
provider_name: str,
|
||||||
config: dict,
|
config: dict,
|
||||||
capacity_gb: int
|
capacity_gb: int,
|
||||||
|
description: Optional[str] = None
|
||||||
) -> Optional[int]:
|
) -> Optional[int]:
|
||||||
"""Add a storage provider for a user. Returns storage ID."""
|
"""Add a storage provider for a user. Returns storage ID."""
|
||||||
async with get_connection() as conn:
|
async with get_connection() as conn:
|
||||||
try:
|
try:
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""INSERT INTO user_storage (username, provider_type, provider_name, config, capacity_gb)
|
"""INSERT INTO user_storage (username, provider_type, provider_name, description, config, capacity_gb)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id""",
|
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
|
return row["id"] if row else None
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -862,6 +917,8 @@ async def add_user_storage(
|
|||||||
|
|
||||||
async def update_user_storage(
|
async def update_user_storage(
|
||||||
storage_id: int,
|
storage_id: int,
|
||||||
|
provider_name: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
config: Optional[dict] = None,
|
config: Optional[dict] = None,
|
||||||
capacity_gb: Optional[int] = None,
|
capacity_gb: Optional[int] = None,
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
@@ -871,6 +928,14 @@ async def update_user_storage(
|
|||||||
params = []
|
params = []
|
||||||
param_num = 1
|
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:
|
if config is not None:
|
||||||
updates.append(f"config = ${param_num}")
|
updates.append(f"config = ${param_num}")
|
||||||
params.append(json.dumps(config))
|
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)."""
|
"""Get all active storage providers (for distributed pinning)."""
|
||||||
async with get_connection() as conn:
|
async with get_connection() as conn:
|
||||||
rows = await conn.fetch(
|
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,
|
COALESCE(SUM(sp.size_bytes), 0) as used_bytes,
|
||||||
COUNT(sp.id) as pin_count
|
COUNT(sp.id) as pin_count
|
||||||
FROM user_storage us
|
FROM user_storage us
|
||||||
LEFT JOIN storage_pins sp ON us.id = sp.storage_id
|
LEFT JOIN storage_pins sp ON us.id = sp.storage_id
|
||||||
WHERE us.is_active = true
|
WHERE us.is_active = true
|
||||||
GROUP BY us.id
|
GROUP BY us.id
|
||||||
ORDER BY us.created_at"""
|
ORDER BY us.provider_type, us.created_at"""
|
||||||
)
|
)
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
@@ -1024,3 +1090,126 @@ async def cleanup_expired_revocations() -> int:
|
|||||||
return int(result.split()[-1])
|
return int(result.split()[-1])
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
return 0
|
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
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ echo "=== Pulling latest code ==="
|
|||||||
git pull
|
git pull
|
||||||
|
|
||||||
echo "=== Building Docker image ==="
|
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 ==="
|
echo "=== Redeploying activitypub stack ==="
|
||||||
docker stack deploy -c docker-compose.yml activitypub
|
docker stack deploy -c docker-compose.yml activitypub
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ version: "3.8"
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: artdag
|
POSTGRES_USER: artdag
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-artdag}
|
|
||||||
POSTGRES_DB: artdag
|
POSTGRES_DB: artdag
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
@@ -16,6 +17,10 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
deploy:
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.labels.gpu != true
|
||||||
|
|
||||||
ipfs:
|
ipfs:
|
||||||
image: ipfs/kubo:latest
|
image: ipfs/kubo:latest
|
||||||
@@ -31,17 +36,29 @@ services:
|
|||||||
replicas: 1
|
replicas: 1
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.labels.gpu != true
|
||||||
|
|
||||||
l2-server:
|
l2-server:
|
||||||
image: git.rose-ash.com/art-dag/l2-server:latest
|
image: registry.rose-ash.com:5000/l2-server:latest
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- ARTDAG_DATA=/data/l2
|
- ARTDAG_DATA=/data/l2
|
||||||
- DATABASE_URL=postgresql://artdag:${POSTGRES_PASSWORD:-artdag}@postgres:5432/artdag
|
|
||||||
- IPFS_API=/dns/ipfs/tcp/5001
|
- IPFS_API=/dns/ipfs/tcp/5001
|
||||||
- ANCHOR_BACKUP_DIR=/data/anchors
|
- 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:
|
volumes:
|
||||||
- l2_data:/data/l2 # Still needed for RSA keys
|
- l2_data:/data/l2 # Still needed for RSA keys
|
||||||
- anchor_backup:/data/anchors # Persistent anchor proofs (survives DB wipes)
|
- anchor_backup:/data/anchors # Persistent anchor proofs (survives DB wipes)
|
||||||
@@ -53,8 +70,13 @@ services:
|
|||||||
- ipfs
|
- ipfs
|
||||||
deploy:
|
deploy:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
|
update_config:
|
||||||
|
order: start-first
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.labels.gpu != true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
l2_data:
|
l2_data:
|
||||||
|
|||||||
@@ -27,10 +27,9 @@ import asyncpg
|
|||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
DATA_DIR = Path(os.environ.get("ARTDAG_DATA", str(Path.home() / ".artdag" / "l2")))
|
DATA_DIR = Path(os.environ.get("ARTDAG_DATA", str(Path.home() / ".artdag" / "l2")))
|
||||||
DATABASE_URL = os.environ.get(
|
DATABASE_URL = os.environ.get("DATABASE_URL")
|
||||||
"DATABASE_URL",
|
if not DATABASE_URL:
|
||||||
"postgresql://artdag:artdag@localhost:5432/artdag"
|
raise RuntimeError("DATABASE_URL environment variable is required")
|
||||||
)
|
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
-- Drop existing tables (careful in production!)
|
-- Drop existing tables (careful in production!)
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
fastapi>=0.109.0
|
fastapi>=0.109.0
|
||||||
uvicorn>=0.27.0
|
uvicorn>=0.27.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
httpx>=0.27.0
|
||||||
cryptography>=42.0.0
|
cryptography>=42.0.0
|
||||||
bcrypt>=4.0.0
|
bcrypt>=4.0.0
|
||||||
python-jose[cryptography]>=3.3.0
|
python-jose[cryptography]>=3.3.0
|
||||||
markdown>=3.5.0
|
markdown>=3.5.0
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
asyncpg>=0.29.0
|
asyncpg>=0.29.0
|
||||||
|
boto3>=1.34.0
|
||||||
|
# Shared components
|
||||||
|
git+https://git.rose-ash.com/art-dag/common.git@889ea98
|
||||||
|
|||||||
3765
server_legacy.py
Normal file
3765
server_legacy.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -370,6 +370,479 @@ class Web3StorageProvider(StorageProvider):
|
|||||||
return {"used_bytes": 0, "capacity_bytes": self.capacity_bytes, "pin_count": 0}
|
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):
|
class LocalStorageProvider(StorageProvider):
|
||||||
"""Local filesystem storage provider."""
|
"""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.
|
Factory function to create a storage provider from config.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provider_type: 'pinata', 'web3storage', or 'local'
|
provider_type: One of 'pinata', 'web3storage', 'nftstorage', 'infura', 'filebase', 'storj', 'local'
|
||||||
config: Provider-specific configuration dict
|
config: Provider-specific configuration dict
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -493,7 +966,32 @@ def create_provider(provider_type: str, config: dict) -> Optional[StorageProvide
|
|||||||
elif provider_type == "web3storage":
|
elif provider_type == "web3storage":
|
||||||
return Web3StorageProvider(
|
return Web3StorageProvider(
|
||||||
api_token=config["api_token"],
|
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":
|
elif provider_type == "local":
|
||||||
return LocalStorageProvider(
|
return LocalStorageProvider(
|
||||||
|
|||||||
Reference in New Issue
Block a user