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:
@@ -3,10 +3,9 @@
|
||||
# Domain for this ActivityPub server
|
||||
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=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
|
||||
|
||||
7
auth.py
7
auth.py
@@ -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:
|
||||
"""Create a new user."""
|
||||
"""Create a new user with ActivityPub keys."""
|
||||
from keys import generate_keypair
|
||||
|
||||
users = load_users(data_dir)
|
||||
|
||||
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()
|
||||
save_users(data_dir, users)
|
||||
|
||||
# Generate ActivityPub keys for this user
|
||||
generate_keypair(data_dir, username)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
- 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:
|
||||
- l2_data:/data/l2
|
||||
depends_on:
|
||||
|
||||
162
server.py
162
server.py
@@ -33,9 +33,8 @@ from auth import (
|
||||
|
||||
# Configuration
|
||||
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")))
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
def load_actor() -> dict:
|
||||
"""Load actor data with public key if available."""
|
||||
def load_actor(username: str) -> dict:
|
||||
"""Load actor data for a specific user with public key if available."""
|
||||
actor = {
|
||||
"id": f"https://{DOMAIN}/users/{USERNAME}",
|
||||
"id": f"https://{DOMAIN}/users/{username}",
|
||||
"type": "Person",
|
||||
"preferredUsername": USERNAME,
|
||||
"name": USERNAME,
|
||||
"inbox": f"https://{DOMAIN}/users/{USERNAME}/inbox",
|
||||
"outbox": f"https://{DOMAIN}/users/{USERNAME}/outbox",
|
||||
"followers": f"https://{DOMAIN}/users/{USERNAME}/followers",
|
||||
"following": f"https://{DOMAIN}/users/{USERNAME}/following",
|
||||
"preferredUsername": username,
|
||||
"name": username,
|
||||
"inbox": f"https://{DOMAIN}/users/{username}/inbox",
|
||||
"outbox": f"https://{DOMAIN}/users/{username}/outbox",
|
||||
"followers": f"https://{DOMAIN}/users/{username}/followers",
|
||||
"following": f"https://{DOMAIN}/users/{username}/following",
|
||||
}
|
||||
|
||||
# Add public key if available
|
||||
from keys import has_keys, load_public_key_pem
|
||||
if has_keys(DATA_DIR, USERNAME):
|
||||
if has_keys(DATA_DIR, username):
|
||||
actor["publicKey"] = {
|
||||
"id": f"https://{DOMAIN}/users/{USERNAME}#main-key",
|
||||
"owner": f"https://{DOMAIN}/users/{USERNAME}",
|
||||
"publicKeyPem": load_public_key_pem(DATA_DIR, USERNAME)
|
||||
"id": f"https://{DOMAIN}/users/{username}#main-key",
|
||||
"owner": f"https://{DOMAIN}/users/{username}",
|
||||
"publicKeyPem": load_public_key_pem(DATA_DIR, username)
|
||||
}
|
||||
|
||||
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:
|
||||
"""Load followers list."""
|
||||
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
|
||||
|
||||
|
||||
def sign_activity(activity: dict) -> dict:
|
||||
"""Sign an activity with RSA private key."""
|
||||
if not has_keys(DATA_DIR, USERNAME):
|
||||
def sign_activity(activity: dict, username: str) -> dict:
|
||||
"""Sign an activity with the user's RSA private key."""
|
||||
if not has_keys(DATA_DIR, username):
|
||||
# No keys - use placeholder (for testing)
|
||||
activity["signature"] = {
|
||||
"type": "RsaSignature2017",
|
||||
"creator": f"https://{DOMAIN}/users/{USERNAME}#main-key",
|
||||
"creator": f"https://{DOMAIN}/users/{username}#main-key",
|
||||
"created": datetime.now(timezone.utc).isoformat(),
|
||||
"signatureValue": "NO_KEYS_CONFIGURED"
|
||||
}
|
||||
else:
|
||||
activity["signature"] = create_signature(DATA_DIR, USERNAME, DOMAIN, activity)
|
||||
activity["signature"] = create_signature(DATA_DIR, username, DOMAIN, 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/registry">Registry</a>
|
||||
<a href="/ui/activities">Activities</a>
|
||||
<a href="{L1_SERVER}/ui" target="_blank">L1 Server</a>
|
||||
<a href="/ui/users">Users</a>
|
||||
</nav>
|
||||
<main>
|
||||
{content}
|
||||
@@ -677,6 +682,45 @@ async def ui_activities_page(request: Request):
|
||||
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 ============
|
||||
|
||||
@app.get("/")
|
||||
@@ -694,11 +738,9 @@ async def root(request: Request):
|
||||
"name": "Art DAG L2 Server",
|
||||
"version": "0.1.0",
|
||||
"domain": DOMAIN,
|
||||
"user": USERNAME,
|
||||
"assets_count": len(registry.get("assets", {})),
|
||||
"activities_count": len(activities),
|
||||
"users_count": len(users),
|
||||
"l1_server": L1_SERVER
|
||||
"users_count": len(users)
|
||||
}
|
||||
|
||||
|
||||
@@ -775,18 +817,30 @@ async def verify_auth(credentials: HTTPAuthorizationCredentials = Depends(securi
|
||||
@app.get("/.well-known/webfinger")
|
||||
async def webfinger(resource: str):
|
||||
"""WebFinger endpoint for actor discovery."""
|
||||
expected = f"acct:{USERNAME}@{DOMAIN}"
|
||||
if resource != expected:
|
||||
raise HTTPException(404, f"Unknown resource: {resource}")
|
||||
# Parse acct:username@domain
|
||||
if not resource.startswith("acct:"):
|
||||
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(
|
||||
content={
|
||||
"subject": expected,
|
||||
"subject": resource,
|
||||
"links": [
|
||||
{
|
||||
"rel": "self",
|
||||
"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}")
|
||||
async def get_actor(username: str, request: Request):
|
||||
"""Get actor profile."""
|
||||
if username != USERNAME:
|
||||
"""Get actor profile for any registered user."""
|
||||
if not user_exists(username):
|
||||
raise HTTPException(404, f"Unknown user: {username}")
|
||||
|
||||
actor = load_actor()
|
||||
actor = load_actor(username)
|
||||
|
||||
# Add ActivityPub context
|
||||
actor["@context"] = [
|
||||
@@ -816,20 +870,23 @@ async def get_actor(username: str, request: Request):
|
||||
|
||||
@app.get("/users/{username}/outbox")
|
||||
async def get_outbox(username: str, page: bool = False):
|
||||
"""Get actor's outbox (published activities)."""
|
||||
if username != USERNAME:
|
||||
"""Get actor's outbox (activities they created)."""
|
||||
if not user_exists(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:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"id": f"https://{DOMAIN}/users/{USERNAME}/outbox",
|
||||
"id": f"https://{DOMAIN}/users/{username}/outbox",
|
||||
"type": "OrderedCollection",
|
||||
"totalItems": len(activities),
|
||||
"first": f"https://{DOMAIN}/users/{USERNAME}/outbox?page=true"
|
||||
"totalItems": len(user_activities),
|
||||
"first": f"https://{DOMAIN}/users/{username}/outbox?page=true"
|
||||
},
|
||||
media_type="application/activity+json"
|
||||
)
|
||||
@@ -838,10 +895,10 @@ async def get_outbox(username: str, page: bool = False):
|
||||
return JSONResponse(
|
||||
content={
|
||||
"@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",
|
||||
"partOf": f"https://{DOMAIN}/users/{USERNAME}/outbox",
|
||||
"orderedItems": activities
|
||||
"partOf": f"https://{DOMAIN}/users/{username}/outbox",
|
||||
"orderedItems": user_activities
|
||||
},
|
||||
media_type="application/activity+json"
|
||||
)
|
||||
@@ -850,7 +907,7 @@ async def get_outbox(username: str, page: bool = False):
|
||||
@app.post("/users/{username}/inbox")
|
||||
async def post_inbox(username: str, request: Request):
|
||||
"""Receive activities from other servers."""
|
||||
if username != USERNAME:
|
||||
if not user_exists(username):
|
||||
raise HTTPException(404, f"Unknown user: {username}")
|
||||
|
||||
body = await request.json()
|
||||
@@ -859,6 +916,7 @@ async def post_inbox(username: str, request: Request):
|
||||
# Handle Follow requests
|
||||
if activity_type == "Follow":
|
||||
follower = body.get("actor")
|
||||
# TODO: Per-user followers - for now use global followers
|
||||
followers = load_followers()
|
||||
if follower not in followers:
|
||||
followers.append(follower)
|
||||
@@ -875,15 +933,16 @@ async def post_inbox(username: str, request: Request):
|
||||
@app.get("/users/{username}/followers")
|
||||
async def get_followers(username: str):
|
||||
"""Get actor's followers."""
|
||||
if username != USERNAME:
|
||||
if not user_exists(username):
|
||||
raise HTTPException(404, f"Unknown user: {username}")
|
||||
|
||||
# TODO: Per-user followers - for now use global followers
|
||||
followers = load_followers()
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"id": f"https://{DOMAIN}/users/{USERNAME}/followers",
|
||||
"id": f"https://{DOMAIN}/users/{username}/followers",
|
||||
"type": "OrderedCollection",
|
||||
"totalItems": len(followers),
|
||||
"orderedItems": followers
|
||||
@@ -958,8 +1017,8 @@ async def update_asset(name: str, req: UpdateAssetRequest, user: User = Depends(
|
||||
"published": asset["updated_at"]
|
||||
}
|
||||
|
||||
# Sign activity
|
||||
activity = sign_activity(activity)
|
||||
# Sign activity with the user's keys
|
||||
activity = sign_activity(activity, user.username)
|
||||
|
||||
# Save activity
|
||||
activities = load_activities()
|
||||
@@ -1015,8 +1074,8 @@ def _register_asset_impl(req: RegisterRequest, owner: str):
|
||||
"published": now
|
||||
}
|
||||
|
||||
# Sign activity
|
||||
activity = sign_activity(activity)
|
||||
# Sign activity with the owner's keys
|
||||
activity = sign_activity(activity, owner)
|
||||
|
||||
# Save activity
|
||||
activities = load_activities()
|
||||
@@ -1153,8 +1212,8 @@ async def publish_cache(req: PublishCacheRequest, user: User = Depends(get_requi
|
||||
"published": now
|
||||
}
|
||||
|
||||
# Sign activity
|
||||
activity = sign_activity(activity)
|
||||
# Sign activity with the user's keys
|
||||
activity = sign_activity(activity, user.username)
|
||||
|
||||
# Save activity
|
||||
activities = load_activities()
|
||||
@@ -1180,6 +1239,7 @@ async def get_object(content_hash: str):
|
||||
# Find asset by hash
|
||||
for name, asset in registry.get("assets", {}).items():
|
||||
if asset.get("content_hash") == content_hash:
|
||||
owner = asset.get("owner", "unknown")
|
||||
return JSONResponse(
|
||||
content={
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
@@ -1190,7 +1250,7 @@ async def get_object(content_hash: str):
|
||||
"algorithm": "sha3-256",
|
||||
"value": content_hash
|
||||
},
|
||||
"attributedTo": f"https://{DOMAIN}/users/{USERNAME}",
|
||||
"attributedTo": f"https://{DOMAIN}/users/{owner}",
|
||||
"published": asset.get("created_at")
|
||||
},
|
||||
media_type="application/activity+json"
|
||||
|
||||
Reference in New Issue
Block a user