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
|
# 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
|
||||||
|
|||||||
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:
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
162
server.py
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user