feat: multi-actor ActivityPub support

Each registered user now has their own ActivityPub actor:
- Generate RSA keys per user on registration
- Webfinger resolves any registered user (@user@domain)
- Actor endpoints work for any registered user
- Each user has their own outbox (filtered activities)
- Activities signed with the publishing user's keys
- Objects attributed to the asset owner

Removed:
- ARTDAG_USER config (no longer single-actor)
- L1_SERVER config (comes with each request)

Added:
- /ui/users page listing all registered users
- user_exists() helper function

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-07 19:54:11 +00:00
parent 58a125de1a
commit 4155427f03
4 changed files with 121 additions and 57 deletions

View File

@@ -3,10 +3,9 @@
# Domain for this ActivityPub server # Domain for this ActivityPub server
ARTDAG_DOMAIN=artdag.rose-ash.com ARTDAG_DOMAIN=artdag.rose-ash.com
# Default username (for actor endpoints)
ARTDAG_USER=giles
# JWT secret for token signing (generate with: openssl rand -hex 32) # JWT secret for token signing (generate with: openssl rand -hex 32)
JWT_SECRET=your-secret-here-generate-with-openssl-rand-hex-32 JWT_SECRET=your-secret-here-generate-with-openssl-rand-hex-32
# Note: ARTDAG_L1 is no longer needed - L1 server URL is sent with each request # Notes:
# - ARTDAG_USER removed - now multi-actor, each registered user is their own actor
# - ARTDAG_L1 removed - L1 server URL is sent with each request

View File

@@ -103,7 +103,9 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
def create_user(data_dir: Path, username: str, password: str, email: Optional[str] = None) -> User: def create_user(data_dir: Path, username: str, password: str, email: Optional[str] = None) -> User:
"""Create a new user.""" """Create a new user with ActivityPub keys."""
from keys import generate_keypair
users = load_users(data_dir) users = load_users(data_dir)
if username in users: if username in users:
@@ -119,6 +121,9 @@ def create_user(data_dir: Path, username: str, password: str, email: Optional[st
users[username] = user.model_dump() users[username] = user.model_dump()
save_users(data_dir, users) save_users(data_dir, users)
# Generate ActivityPub keys for this user
generate_keypair(data_dir, username)
return user return user

View File

@@ -69,7 +69,7 @@ services:
- .env - .env
environment: environment:
- ARTDAG_DATA=/data/l2 - ARTDAG_DATA=/data/l2
# ARTDAG_DOMAIN, ARTDAG_USER, JWT_SECRET from .env file # ARTDAG_DOMAIN, JWT_SECRET from .env file (multi-actor, no ARTDAG_USER)
volumes: volumes:
- l2_data:/data/l2 - l2_data:/data/l2
depends_on: depends_on:

162
server.py
View File

@@ -33,9 +33,8 @@ from auth import (
# Configuration # Configuration
DOMAIN = os.environ.get("ARTDAG_DOMAIN", "artdag.rose-ash.com") DOMAIN = os.environ.get("ARTDAG_DOMAIN", "artdag.rose-ash.com")
USERNAME = os.environ.get("ARTDAG_USER", "giles")
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")))
L1_SERVER = os.environ.get("ARTDAG_L1", "http://localhost:8100") # Note: L1_SERVER is no longer needed - L1 URL comes with each request
# Ensure data directory exists # Ensure data directory exists
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
@@ -150,31 +149,37 @@ def save_activities(activities: list):
json.dump({"version": "1.0", "activities": activities}, f, indent=2) json.dump({"version": "1.0", "activities": activities}, f, indent=2)
def load_actor() -> dict: def load_actor(username: str) -> dict:
"""Load actor data with public key if available.""" """Load actor data for a specific user with public key if available."""
actor = { actor = {
"id": f"https://{DOMAIN}/users/{USERNAME}", "id": f"https://{DOMAIN}/users/{username}",
"type": "Person", "type": "Person",
"preferredUsername": USERNAME, "preferredUsername": username,
"name": USERNAME, "name": username,
"inbox": f"https://{DOMAIN}/users/{USERNAME}/inbox", "inbox": f"https://{DOMAIN}/users/{username}/inbox",
"outbox": f"https://{DOMAIN}/users/{USERNAME}/outbox", "outbox": f"https://{DOMAIN}/users/{username}/outbox",
"followers": f"https://{DOMAIN}/users/{USERNAME}/followers", "followers": f"https://{DOMAIN}/users/{username}/followers",
"following": f"https://{DOMAIN}/users/{USERNAME}/following", "following": f"https://{DOMAIN}/users/{username}/following",
} }
# Add public key if available # Add public key if available
from keys import has_keys, load_public_key_pem from keys import has_keys, load_public_key_pem
if has_keys(DATA_DIR, USERNAME): if has_keys(DATA_DIR, username):
actor["publicKey"] = { actor["publicKey"] = {
"id": f"https://{DOMAIN}/users/{USERNAME}#main-key", "id": f"https://{DOMAIN}/users/{username}#main-key",
"owner": f"https://{DOMAIN}/users/{USERNAME}", "owner": f"https://{DOMAIN}/users/{username}",
"publicKeyPem": load_public_key_pem(DATA_DIR, USERNAME) "publicKeyPem": load_public_key_pem(DATA_DIR, username)
} }
return actor return actor
def user_exists(username: str) -> bool:
"""Check if a user exists."""
users = load_users(DATA_DIR)
return username in users
def load_followers() -> list: def load_followers() -> list:
"""Load followers list.""" """Load followers list."""
path = DATA_DIR / "followers.json" path = DATA_DIR / "followers.json"
@@ -196,18 +201,18 @@ def save_followers(followers: list):
from keys import has_keys, load_public_key_pem, create_signature from keys import has_keys, load_public_key_pem, create_signature
def sign_activity(activity: dict) -> dict: def sign_activity(activity: dict, username: str) -> dict:
"""Sign an activity with RSA private key.""" """Sign an activity with the user's RSA private key."""
if not has_keys(DATA_DIR, USERNAME): if not has_keys(DATA_DIR, username):
# No keys - use placeholder (for testing) # No keys - use placeholder (for testing)
activity["signature"] = { activity["signature"] = {
"type": "RsaSignature2017", "type": "RsaSignature2017",
"creator": f"https://{DOMAIN}/users/{USERNAME}#main-key", "creator": f"https://{DOMAIN}/users/{username}#main-key",
"created": datetime.now(timezone.utc).isoformat(), "created": datetime.now(timezone.utc).isoformat(),
"signatureValue": "NO_KEYS_CONFIGURED" "signatureValue": "NO_KEYS_CONFIGURED"
} }
else: else:
activity["signature"] = create_signature(DATA_DIR, USERNAME, DOMAIN, activity) activity["signature"] = create_signature(DATA_DIR, username, DOMAIN, activity)
return activity return activity
@@ -402,7 +407,7 @@ def base_html(title: str, content: str, username: str = None) -> str:
<a href="/ui">Home</a> <a href="/ui">Home</a>
<a href="/ui/registry">Registry</a> <a href="/ui/registry">Registry</a>
<a href="/ui/activities">Activities</a> <a href="/ui/activities">Activities</a>
<a href="{L1_SERVER}/ui" target="_blank">L1 Server</a> <a href="/ui/users">Users</a>
</nav> </nav>
<main> <main>
{content} {content}
@@ -677,6 +682,45 @@ async def ui_activities_page(request: Request):
return HTMLResponse(base_html("Activities", content, username)) return HTMLResponse(base_html("Activities", content, username))
@app.get("/ui/users", response_class=HTMLResponse)
async def ui_users_page(request: Request):
"""Users page showing all registered users."""
current_user = get_user_from_cookie(request)
users = load_users(DATA_DIR)
if not users:
content = '<h2>Users</h2><p>No users registered yet.</p>'
else:
rows = ""
for username, user_data in sorted(users.items()):
actor_url = f"https://{DOMAIN}/users/{username}"
webfinger = f"@{username}@{DOMAIN}"
rows += f'''
<tr>
<td><a href="{actor_url}" target="_blank"><strong>{username}</strong></a></td>
<td><code>{webfinger}</code></td>
<td>{user_data.get("created_at", "")[:10]}</td>
</tr>
'''
content = f'''
<h2>Users ({len(users)} registered)</h2>
<p>Each user has their own ActivityPub actor that can be followed from Mastodon and other federated platforms.</p>
<table>
<thead>
<tr>
<th>Username</th>
<th>ActivityPub Handle</th>
<th>Registered</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
'''
return HTMLResponse(base_html("Users", content, current_user))
# ============ API Endpoints ============ # ============ API Endpoints ============
@app.get("/") @app.get("/")
@@ -694,11 +738,9 @@ async def root(request: Request):
"name": "Art DAG L2 Server", "name": "Art DAG L2 Server",
"version": "0.1.0", "version": "0.1.0",
"domain": DOMAIN, "domain": DOMAIN,
"user": USERNAME,
"assets_count": len(registry.get("assets", {})), "assets_count": len(registry.get("assets", {})),
"activities_count": len(activities), "activities_count": len(activities),
"users_count": len(users), "users_count": len(users)
"l1_server": L1_SERVER
} }
@@ -775,18 +817,30 @@ async def verify_auth(credentials: HTTPAuthorizationCredentials = Depends(securi
@app.get("/.well-known/webfinger") @app.get("/.well-known/webfinger")
async def webfinger(resource: str): async def webfinger(resource: str):
"""WebFinger endpoint for actor discovery.""" """WebFinger endpoint for actor discovery."""
expected = f"acct:{USERNAME}@{DOMAIN}" # Parse acct:username@domain
if resource != expected: if not resource.startswith("acct:"):
raise HTTPException(404, f"Unknown resource: {resource}") raise HTTPException(400, "Resource must be acct: URI")
acct = resource[5:] # Remove "acct:"
if "@" not in acct:
raise HTTPException(400, "Invalid acct format")
username, domain = acct.split("@", 1)
if domain != DOMAIN:
raise HTTPException(404, f"Unknown domain: {domain}")
if not user_exists(username):
raise HTTPException(404, f"Unknown user: {username}")
return JSONResponse( return JSONResponse(
content={ content={
"subject": expected, "subject": resource,
"links": [ "links": [
{ {
"rel": "self", "rel": "self",
"type": "application/activity+json", "type": "application/activity+json",
"href": f"https://{DOMAIN}/users/{USERNAME}" "href": f"https://{DOMAIN}/users/{username}"
} }
] ]
}, },
@@ -796,11 +850,11 @@ async def webfinger(resource: str):
@app.get("/users/{username}") @app.get("/users/{username}")
async def get_actor(username: str, request: Request): async def get_actor(username: str, request: Request):
"""Get actor profile.""" """Get actor profile for any registered user."""
if username != USERNAME: if not user_exists(username):
raise HTTPException(404, f"Unknown user: {username}") raise HTTPException(404, f"Unknown user: {username}")
actor = load_actor() actor = load_actor(username)
# Add ActivityPub context # Add ActivityPub context
actor["@context"] = [ actor["@context"] = [
@@ -816,20 +870,23 @@ async def get_actor(username: str, request: Request):
@app.get("/users/{username}/outbox") @app.get("/users/{username}/outbox")
async def get_outbox(username: str, page: bool = False): async def get_outbox(username: str, page: bool = False):
"""Get actor's outbox (published activities).""" """Get actor's outbox (activities they created)."""
if username != USERNAME: if not user_exists(username):
raise HTTPException(404, f"Unknown user: {username}") raise HTTPException(404, f"Unknown user: {username}")
activities = load_activities() # Filter activities by this user's actor_id
all_activities = load_activities()
actor_id = f"https://{DOMAIN}/users/{username}"
user_activities = [a for a in all_activities if a.get("actor_id") == actor_id]
if not page: if not page:
return JSONResponse( return JSONResponse(
content={ content={
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
"id": f"https://{DOMAIN}/users/{USERNAME}/outbox", "id": f"https://{DOMAIN}/users/{username}/outbox",
"type": "OrderedCollection", "type": "OrderedCollection",
"totalItems": len(activities), "totalItems": len(user_activities),
"first": f"https://{DOMAIN}/users/{USERNAME}/outbox?page=true" "first": f"https://{DOMAIN}/users/{username}/outbox?page=true"
}, },
media_type="application/activity+json" media_type="application/activity+json"
) )
@@ -838,10 +895,10 @@ async def get_outbox(username: str, page: bool = False):
return JSONResponse( return JSONResponse(
content={ content={
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
"id": f"https://{DOMAIN}/users/{USERNAME}/outbox?page=true", "id": f"https://{DOMAIN}/users/{username}/outbox?page=true",
"type": "OrderedCollectionPage", "type": "OrderedCollectionPage",
"partOf": f"https://{DOMAIN}/users/{USERNAME}/outbox", "partOf": f"https://{DOMAIN}/users/{username}/outbox",
"orderedItems": activities "orderedItems": user_activities
}, },
media_type="application/activity+json" media_type="application/activity+json"
) )
@@ -850,7 +907,7 @@ async def get_outbox(username: str, page: bool = False):
@app.post("/users/{username}/inbox") @app.post("/users/{username}/inbox")
async def post_inbox(username: str, request: Request): async def post_inbox(username: str, request: Request):
"""Receive activities from other servers.""" """Receive activities from other servers."""
if username != USERNAME: if not user_exists(username):
raise HTTPException(404, f"Unknown user: {username}") raise HTTPException(404, f"Unknown user: {username}")
body = await request.json() body = await request.json()
@@ -859,6 +916,7 @@ async def post_inbox(username: str, request: Request):
# Handle Follow requests # Handle Follow requests
if activity_type == "Follow": if activity_type == "Follow":
follower = body.get("actor") follower = body.get("actor")
# TODO: Per-user followers - for now use global followers
followers = load_followers() followers = load_followers()
if follower not in followers: if follower not in followers:
followers.append(follower) followers.append(follower)
@@ -875,15 +933,16 @@ async def post_inbox(username: str, request: Request):
@app.get("/users/{username}/followers") @app.get("/users/{username}/followers")
async def get_followers(username: str): async def get_followers(username: str):
"""Get actor's followers.""" """Get actor's followers."""
if username != USERNAME: if not user_exists(username):
raise HTTPException(404, f"Unknown user: {username}") raise HTTPException(404, f"Unknown user: {username}")
# TODO: Per-user followers - for now use global followers
followers = load_followers() followers = load_followers()
return JSONResponse( return JSONResponse(
content={ content={
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
"id": f"https://{DOMAIN}/users/{USERNAME}/followers", "id": f"https://{DOMAIN}/users/{username}/followers",
"type": "OrderedCollection", "type": "OrderedCollection",
"totalItems": len(followers), "totalItems": len(followers),
"orderedItems": followers "orderedItems": followers
@@ -958,8 +1017,8 @@ async def update_asset(name: str, req: UpdateAssetRequest, user: User = Depends(
"published": asset["updated_at"] "published": asset["updated_at"]
} }
# Sign activity # Sign activity with the user's keys
activity = sign_activity(activity) activity = sign_activity(activity, user.username)
# Save activity # Save activity
activities = load_activities() activities = load_activities()
@@ -1015,8 +1074,8 @@ def _register_asset_impl(req: RegisterRequest, owner: str):
"published": now "published": now
} }
# Sign activity # Sign activity with the owner's keys
activity = sign_activity(activity) activity = sign_activity(activity, owner)
# Save activity # Save activity
activities = load_activities() activities = load_activities()
@@ -1153,8 +1212,8 @@ async def publish_cache(req: PublishCacheRequest, user: User = Depends(get_requi
"published": now "published": now
} }
# Sign activity # Sign activity with the user's keys
activity = sign_activity(activity) activity = sign_activity(activity, user.username)
# Save activity # Save activity
activities = load_activities() activities = load_activities()
@@ -1180,6 +1239,7 @@ async def get_object(content_hash: str):
# Find asset by hash # Find asset by hash
for name, asset in registry.get("assets", {}).items(): for name, asset in registry.get("assets", {}).items():
if asset.get("content_hash") == content_hash: if asset.get("content_hash") == content_hash:
owner = asset.get("owner", "unknown")
return JSONResponse( return JSONResponse(
content={ content={
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
@@ -1190,7 +1250,7 @@ async def get_object(content_hash: str):
"algorithm": "sha3-256", "algorithm": "sha3-256",
"value": content_hash "value": content_hash
}, },
"attributedTo": f"https://{DOMAIN}/users/{USERNAME}", "attributedTo": f"https://{DOMAIN}/users/{owner}",
"published": asset.get("created_at") "published": asset.get("created_at")
}, },
media_type="application/activity+json" media_type="application/activity+json"