diff --git a/.env.example b/.env.example index 6bf0379..b53dc65 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/auth.py b/auth.py index 592bbb8..fcbbeed 100644 --- a/auth.py +++ b/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 diff --git a/docker-stack.yml b/docker-stack.yml index a5a499e..3411aeb 100644 --- a/docker-stack.yml +++ b/docker-stack.yml @@ -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: diff --git a/server.py b/server.py index 9fa98b3..6b94228 100644 --- a/server.py +++ b/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: Home Registry Activities - L1 Server + Users
{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 = '

Users

No users registered yet.

' + else: + rows = "" + for username, user_data in sorted(users.items()): + actor_url = f"https://{DOMAIN}/users/{username}" + webfinger = f"@{username}@{DOMAIN}" + rows += f''' + + {username} + {webfinger} + {user_data.get("created_at", "")[:10]} + + ''' + content = f''' +

Users ({len(users)} registered)

+

Each user has their own ActivityPub actor that can be followed from Mastodon and other federated platforms.

+ + + + + + + + + + {rows} + +
UsernameActivityPub HandleRegistered
+ ''' + 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"