diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f0862c110..9037ba535 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -33,7 +33,7 @@ "version": "21", "jdkDistro": "open" }, - "./solr": {} + // "./solr": {} }, "overrideFeatureInstallOrder": [ "ghcr.io/devcontainers-extra/features/poetry", diff --git a/bases/renku_data_services/data_api/app.py b/bases/renku_data_services/data_api/app.py index d57923c32..e295901c0 100644 --- a/bases/renku_data_services/data_api/app.py +++ b/bases/renku_data_services/data_api/app.py @@ -4,6 +4,10 @@ from ulid import ULID from renku_data_services import errors +from renku_data_services.activitypub.blueprints import ActivityPubBP +from renku_data_services.activitypub.core import ActivityPubService +from renku_data_services.activitypub.db import ActivityPubRepository +from renku_data_services.activitypub.models import ActivityPubConfig from renku_data_services.app_config import Config from renku_data_services.base_api.error_handler import CustomErrorHandler from renku_data_services.base_api.misc import MiscBP @@ -225,6 +229,36 @@ def register_all_handlers(app: Sanic, config: Config) -> Sanic: data_connector_secret_repo=config.data_connector_secret_repo, authenticator=config.authenticator, ) + + # ActivityPub configuration + # Use the base_url and domain from the config + activitypub_config = ActivityPubConfig( + domain=config.domain, + base_url=f"{config.base_url}{url_prefix}", # Include the URL prefix + admin_email=config.admin_email, + ) + + # ActivityPub repository and service + activitypub_repo = ActivityPubRepository( + session_maker=config.db.async_session_maker, + project_repo=config.project_repo, + config=activitypub_config, + ) + + activitypub_service = ActivityPubService( + activitypub_repo=activitypub_repo, + project_repo=config.project_repo, + config=activitypub_config, + ) + + # ActivityPub blueprint + activitypub = ActivityPubBP( + name="activitypub", + url_prefix=url_prefix, + activitypub_service=activitypub_service, + authenticator=config.authenticator, + config=activitypub_config, + ) app.blueprint( [ resource_pools.blueprint(), @@ -252,6 +286,7 @@ def register_all_handlers(app: Sanic, config: Config) -> Sanic: message_queue.blueprint(), search.blueprint(), data_connectors.blueprint(), + activitypub.blueprint(), ] ) if builds is not None: diff --git a/components/renku_data_services/activitypub/__init__.py b/components/renku_data_services/activitypub/__init__.py new file mode 100644 index 000000000..fe05e2bf2 --- /dev/null +++ b/components/renku_data_services/activitypub/__init__.py @@ -0,0 +1 @@ +"""ActivityPub component for Renku.""" diff --git a/components/renku_data_services/activitypub/api.spec.yaml b/components/renku_data_services/activitypub/api.spec.yaml new file mode 100644 index 000000000..5758aaf8f --- /dev/null +++ b/components/renku_data_services/activitypub/api.spec.yaml @@ -0,0 +1,608 @@ +openapi: 3.0.3 +info: + title: Renku ActivityPub API + description: API for ActivityPub integration with Renku + version: 1.0.0 + contact: + name: Renku Team + url: https://renku.ch + email: renku@datascience.ch +servers: + - url: /api/data + description: Renku Data API +paths: + /ap/projects/{project_id}: + get: + summary: Get the ActivityPub representation of a project + description: Returns the ActivityPub actor representation of a project + operationId: getProjectActor + tags: + - ActivityPub + parameters: + - name: project_id + in: path + description: ID of the project + required: true + schema: + type: string + format: ulid + responses: + '200': + description: Project actor + content: + application/activity+json: + schema: + $ref: '#/components/schemas/Actor' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /ap/projects/{project_id}/followers: + get: + summary: Get the followers of a project + description: Returns the list of followers of a project + operationId: getProjectFollowers + tags: + - ActivityPub + parameters: + - name: project_id + in: path + description: ID of the project + required: true + schema: + type: string + format: ulid + responses: + '200': + description: Project followers + content: + application/json: + schema: + type: object + properties: + followers: + type: array + items: + type: string + format: uri + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /ap/projects/{project_id}/followers/{follower_uri}: + delete: + summary: Remove a follower from a project + description: Removes a follower from a project's followers list + operationId: removeProjectFollower + tags: + - ActivityPub + parameters: + - name: project_id + in: path + description: ID of the project + required: true + schema: + type: string + format: ulid + - name: follower_uri + in: path + description: URI of the follower to remove + required: true + schema: + type: string + format: uri + responses: + '204': + description: Follower removed successfully + '404': + description: Project or follower not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /ap/projects/{project_id}/inbox: + post: + summary: Receive an ActivityPub activity for a project + description: Endpoint for receiving ActivityPub activities directed at a project + operationId: projectInbox + tags: + - ActivityPub + parameters: + - name: project_id + in: path + description: ID of the project + required: true + schema: + type: string + format: ulid + requestBody: + description: ActivityPub activity + required: true + content: + application/activity+json: + schema: + $ref: '#/components/schemas/Activity' + responses: + '200': + description: Activity accepted + '400': + description: Invalid activity + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /ap/projects/{project_id}/outbox: + get: + summary: Get the outbox of a project + description: Returns the activities published by a project + operationId: getProjectOutbox + tags: + - ActivityPub + parameters: + - name: project_id + in: path + description: ID of the project + required: true + schema: + type: string + format: ulid + - name: page + in: query + description: Page number + required: false + schema: + type: integer + default: 1 + - name: per_page + in: query + description: Number of items per page + required: false + schema: + type: integer + default: 10 + responses: + '200': + description: Project outbox + content: + application/activity+json: + schema: + $ref: '#/components/schemas/OrderedCollection' + '404': + description: Project not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /ap/webfinger: + get: + summary: WebFinger endpoint + description: Endpoint for WebFinger protocol to discover ActivityPub actors + operationId: webfinger + tags: + - ActivityPub + parameters: + - name: resource + in: query + description: Resource to look up + required: true + schema: + type: string + responses: + '200': + description: WebFinger response + content: + application/jrd+json: + schema: + $ref: '#/components/schemas/WebFingerResponse' + '404': + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /ap/.well-known/host-meta: + get: + summary: Host metadata + description: Endpoint for host metadata + operationId: hostMeta + tags: + - ActivityPub + responses: + '200': + description: Host metadata + content: + application/xrd+xml: + schema: + type: string + /ap/.well-known/nodeinfo: + get: + summary: NodeInfo endpoint + description: Endpoint for NodeInfo protocol + operationId: nodeInfo + tags: + - ActivityPub + responses: + '200': + description: NodeInfo response + content: + application/json: + schema: + $ref: '#/components/schemas/NodeInfoLinks' + /ap/nodeinfo/2.0: + get: + summary: NodeInfo 2.0 endpoint + description: Endpoint for NodeInfo 2.0 protocol + operationId: nodeInfo20 + tags: + - ActivityPub + responses: + '200': + description: NodeInfo 2.0 response + content: + application/json: + schema: + $ref: '#/components/schemas/NodeInfo' +components: + schemas: + Error: + type: object + properties: + error: + type: string + message: + type: string + required: + - error + - message + Actor: + type: object + properties: + '@context': + oneOf: + - type: string + - type: array + items: + type: string + default: ['https://www.w3.org/ns/activitystreams'] + id: + type: string + format: uri + type: + type: string + enum: ['Person', 'Service', 'Group', 'Organization', 'Application', 'Project'] + name: + type: string + preferredUsername: + type: string + summary: + type: string + inbox: + type: string + format: uri + outbox: + type: string + format: uri + followers: + type: string + format: uri + following: + type: string + format: uri + publicKey: + type: object + properties: + id: + type: string + format: uri + owner: + type: string + format: uri + publicKeyPem: + type: string + required: + - id + - owner + - publicKeyPem + url: + type: string + format: uri + published: + type: string + format: date-time + updated: + type: string + format: date-time + keywords: + type: array + items: + type: string + repositories: + type: array + items: + type: string + visibility: + type: string + enum: ['public', 'private'] + documentation: + type: string + required: + - '@context' + - id + - type + - preferredUsername + - inbox + - outbox + Activity: + type: object + properties: + '@context': + oneOf: + - type: string + - type: array + items: + type: string + default: ['https://www.w3.org/ns/activitystreams'] + id: + type: string + format: uri + type: + type: string + enum: ['Create', 'Update', 'Delete', 'Follow', 'Accept', 'Reject', 'Announce', 'Like', 'Undo'] + actor: + oneOf: + - type: string + format: uri + - $ref: '#/components/schemas/Actor' + object: + oneOf: + - type: string + format: uri + - $ref: '#/components/schemas/Actor' + - $ref: '#/components/schemas/Activity' + - $ref: '#/components/schemas/Object' + to: + type: array + items: + type: string + format: uri + cc: + type: array + items: + type: string + format: uri + published: + type: string + format: date-time + required: + - '@context' + - id + - type + - actor + Object: + type: object + properties: + '@context': + oneOf: + - type: string + - type: array + items: + type: string + default: ['https://www.w3.org/ns/activitystreams'] + id: + type: string + format: uri + type: + type: string + enum: ['Note', 'Article', 'Collection', 'Document', 'Image', 'Video', 'Audio', 'Page', 'Event', 'Place', 'Profile', 'Tombstone'] + name: + type: string + content: + type: string + attributedTo: + oneOf: + - type: string + format: uri + - $ref: '#/components/schemas/Actor' + to: + type: array + items: + type: string + format: uri + cc: + type: array + items: + type: string + format: uri + published: + type: string + format: date-time + updated: + type: string + format: date-time + required: + - '@context' + - id + - type + OrderedCollection: + type: object + properties: + '@context': + oneOf: + - type: string + - type: array + items: + type: string + default: ['https://www.w3.org/ns/activitystreams'] + id: + type: string + format: uri + type: + type: string + enum: ['OrderedCollection'] + totalItems: + type: integer + first: + type: string + format: uri + last: + type: string + format: uri + required: + - '@context' + - id + - type + - totalItems + OrderedCollectionPage: + type: object + properties: + '@context': + oneOf: + - type: string + - type: array + items: + type: string + default: ['https://www.w3.org/ns/activitystreams'] + id: + type: string + format: uri + type: + type: string + enum: ['OrderedCollectionPage'] + totalItems: + type: integer + orderedItems: + type: array + items: + oneOf: + - $ref: '#/components/schemas/Activity' + - $ref: '#/components/schemas/Object' + next: + type: string + format: uri + prev: + type: string + format: uri + partOf: + type: string + format: uri + required: + - '@context' + - id + - type + - orderedItems + WebFingerResponse: + type: object + properties: + subject: + type: string + aliases: + type: array + items: + type: string + links: + type: array + items: + type: object + properties: + rel: + type: string + type: + type: string + href: + type: string + format: uri + template: + type: string + required: + - rel + required: + - subject + - links + NodeInfoLinks: + type: object + properties: + links: + type: array + items: + type: object + properties: + rel: + type: string + href: + type: string + format: uri + required: + - rel + - href + required: + - links + NodeInfo: + type: object + properties: + version: + type: string + enum: ['2.0'] + software: + type: object + properties: + name: + type: string + version: + type: string + required: + - name + - version + protocols: + type: array + items: + type: string + default: ['activitypub'] + services: + type: object + properties: + inbound: + type: array + items: + type: string + outbound: + type: array + items: + type: string + required: + - inbound + - outbound + usage: + type: object + properties: + users: + type: object + properties: + total: + type: integer + required: + - total + localPosts: + type: integer + required: + - users + openRegistrations: + type: boolean + metadata: + type: object + required: + - version + - software + - protocols + - services + - usage + - openRegistrations diff --git a/components/renku_data_services/activitypub/apispec.py b/components/renku_data_services/activitypub/apispec.py new file mode 100644 index 000000000..5a0e8024f --- /dev/null +++ b/components/renku_data_services/activitypub/apispec.py @@ -0,0 +1,244 @@ +"""API specification for ActivityPub.""" + +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional, Union + +from pydantic import BaseModel, Field, HttpUrl + + +class ActivityType(str, Enum): + """ActivityPub activity types.""" + + CREATE = "Create" + UPDATE = "Update" + DELETE = "Delete" + FOLLOW = "Follow" + ACCEPT = "Accept" + REJECT = "Reject" + ANNOUNCE = "Announce" + LIKE = "Like" + UNDO = "Undo" + + +class ActorType(str, Enum): + """ActivityPub actor types.""" + + PERSON = "Person" + SERVICE = "Service" + GROUP = "Group" + ORGANIZATION = "Organization" + APPLICATION = "Application" + PROJECT = "Project" + + +class ObjectType(str, Enum): + """ActivityPub object types.""" + + NOTE = "Note" + ARTICLE = "Article" + COLLECTION = "Collection" + DOCUMENT = "Document" + IMAGE = "Image" + VIDEO = "Video" + AUDIO = "Audio" + PAGE = "Page" + EVENT = "Event" + PLACE = "Place" + PROFILE = "Profile" + TOMBSTONE = "Tombstone" + + +class Link(BaseModel): + """ActivityPub Link object.""" + + href: HttpUrl + rel: Optional[str] = None + mediaType: Optional[str] = None + name: Optional[str] = None + hreflang: Optional[str] = None + height: Optional[int] = None + width: Optional[int] = None + preview: Optional[Dict[str, Any]] = None + + +class PublicKey(BaseModel): + """ActivityPub PublicKey object.""" + + id: HttpUrl + owner: HttpUrl + publicKeyPem: str + + +class BaseObject(BaseModel): + """Base ActivityPub Object.""" + + context: List[str] = Field(default_factory=lambda: ["https://www.w3.org/ns/activitystreams"], alias="@context") + id: HttpUrl + type: Union[ObjectType, ActorType, ActivityType, str] + name: Optional[str] = None + summary: Optional[str] = None + content: Optional[str] = None + url: Optional[Union[str, List[str], Link, List[Link]]] = None + published: Optional[datetime] = None + updated: Optional[datetime] = None + mediaType: Optional[str] = None + attributedTo: Optional[Union[str, Dict[str, Any]]] = None + to: Optional[List[str]] = None + cc: Optional[List[str]] = None + bto: Optional[List[str]] = None + bcc: Optional[List[str]] = None + + +class Actor(BaseObject): + """ActivityPub Actor.""" + + type: ActorType + preferredUsername: str + inbox: HttpUrl + outbox: HttpUrl + followers: Optional[HttpUrl] = None + following: Optional[HttpUrl] = None + liked: Optional[HttpUrl] = None + publicKey: Optional[PublicKey] = None + endpoints: Optional[Dict[str, Any]] = None + icon: Optional[Union[Dict[str, Any], Link]] = None + image: Optional[Union[Dict[str, Any], Link]] = None + + +class ProjectActor(Actor): + """ActivityPub representation of a Renku Project as an Actor.""" + + type: ActorType = ActorType.PROJECT + keywords: Optional[List[str]] = None + repositories: Optional[List[str]] = None + visibility: Optional[str] = None + created_by: Optional[str] = None + creation_date: Optional[datetime] = None + updated_at: Optional[datetime] = None + documentation: Optional[str] = None + + +class Object(BaseObject): + """ActivityPub Object.""" + + type: ObjectType + attachment: Optional[List[Union[Dict[str, Any], Link]]] = None + inReplyTo: Optional[Union[str, Dict[str, Any]]] = None + location: Optional[Union[str, Dict[str, Any]]] = None + tag: Optional[List[Union[Dict[str, Any], Link]]] = None + duration: Optional[str] = None + + +class Activity(BaseObject): + """ActivityPub Activity.""" + + type: ActivityType + actor: Union[str, Actor] + object: Optional[Union[str, Dict[str, Any], "Activity", BaseObject]] = None + target: Optional[Union[str, Dict[str, Any], "Activity", BaseObject]] = None + result: Optional[Union[str, Dict[str, Any], "Activity", BaseObject]] = None + origin: Optional[Union[str, Dict[str, Any], "Activity", BaseObject]] = None + instrument: Optional[Union[str, Dict[str, Any], "Activity", BaseObject]] = None + + +class OrderedCollection(BaseObject): + """ActivityPub OrderedCollection.""" + + type: str = "OrderedCollection" + totalItems: int + first: Optional[HttpUrl] = None + last: Optional[HttpUrl] = None + + +class OrderedCollectionPage(BaseObject): + """ActivityPub OrderedCollectionPage.""" + + type: str = "OrderedCollectionPage" + totalItems: int + orderedItems: List[Union[Activity, Object]] + next: Optional[HttpUrl] = None + prev: Optional[HttpUrl] = None + partOf: Optional[HttpUrl] = None + + +class WebFingerLink(BaseModel): + """WebFinger Link.""" + + rel: str + type: Optional[str] = None + href: Optional[HttpUrl] = None + template: Optional[str] = None + + +class WebFingerResponse(BaseModel): + """WebFinger Response.""" + + subject: str + aliases: Optional[List[str]] = None + links: List[WebFingerLink] + + +class NodeInfoLink(BaseModel): + """NodeInfo Link.""" + + rel: str + href: HttpUrl + + +class NodeInfoLinks(BaseModel): + """NodeInfo Links.""" + + links: List[NodeInfoLink] + + +class NodeInfoSoftware(BaseModel): + """NodeInfo Software.""" + + name: str + version: str + + +class NodeInfoUsers(BaseModel): + """NodeInfo Users.""" + + total: int + + +class NodeInfoUsage(BaseModel): + """NodeInfo Usage.""" + + users: NodeInfoUsers + localPosts: Optional[int] = None + + +class NodeInfoServices(BaseModel): + """NodeInfo Services.""" + + inbound: List[str] = Field(default_factory=list) + outbound: List[str] = Field(default_factory=list) + + +class NodeInfo(BaseModel): + """NodeInfo.""" + + version: str = "2.0" + software: NodeInfoSoftware + protocols: List[str] = Field(default_factory=lambda: ["activitypub"]) + services: NodeInfoServices = Field(default_factory=NodeInfoServices) + usage: NodeInfoUsage + openRegistrations: bool + metadata: Dict[str, Any] = Field(default_factory=dict) + + +class ProjectFollowers(BaseModel): + """Project followers.""" + + followers: List[str] + + +class Error(BaseModel): + """Error response.""" + + error: str + message: str diff --git a/components/renku_data_services/activitypub/blueprints.py b/components/renku_data_services/activitypub/blueprints.py new file mode 100644 index 000000000..20a351489 --- /dev/null +++ b/components/renku_data_services/activitypub/blueprints.py @@ -0,0 +1,376 @@ +"""ActivityPub blueprint.""" + +import json +import logging +import urllib.parse +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Union +from urllib.parse import urlparse + +from sanic import HTTPResponse, Request +from sanic.response import JSONResponse, text +from sanic_ext import validate +from ulid import ULID + +import renku_data_services.base_models as base_models +from renku_data_services.activitypub import apispec, core, models +from renku_data_services.base_api.auth import authenticate +from renku_data_services.base_api.blueprint import BlueprintFactoryResponse, CustomBlueprint +from renku_data_services.base_models.validation import validate_and_dump, validated_json +from renku_data_services.errors import errors + + +logger = logging.getLogger(__name__) + + +@dataclass(kw_only=True) +class ActivityPubBP(CustomBlueprint): + """Handlers for ActivityPub.""" + + activitypub_service: core.ActivityPubService + authenticator: base_models.Authenticator + config: models.ActivityPubConfig + + def get_project_actor(self) -> BlueprintFactoryResponse: + """Get the ActivityPub actor for a project.""" + + @authenticate(self.authenticator) + async def _get_project_actor(request: Request, user: base_models.APIUser, project_id: ULID) -> JSONResponse: + try: + actor = await self.activitypub_service.get_project_actor(user=user, project_id=project_id) + return JSONResponse( + self.activitypub_service._to_dict(actor), + status=200, + headers={"Content-Type": "application/activity+json"}, + ) + except errors.MissingResourceError as e: + return JSONResponse( + {"error": "not_found", "message": str(e)}, + status=404, + ) + + return "/ap/projects/", ["GET"], _get_project_actor + + def get_project_followers(self) -> BlueprintFactoryResponse: + """Get the followers of a project.""" + + @authenticate(self.authenticator) + async def _get_project_followers(request: Request, user: base_models.APIUser, project_id: ULID) -> JSONResponse: + try: + followers = await self.activitypub_service.get_project_followers(user=user, project_id=project_id) + return validated_json(apispec.ProjectFollowers, {"followers": followers}) + except errors.MissingResourceError as e: + return JSONResponse( + {"error": "not_found", "message": str(e)}, + status=404, + ) + + return "/ap/projects//followers", ["GET"], _get_project_followers + + def remove_project_follower(self) -> BlueprintFactoryResponse: + """Remove a follower from a project.""" + + @authenticate(self.authenticator) + async def _remove_project_follower( + request: Request, user: base_models.APIUser, project_id: ULID, follower_uri: str + ) -> JSONResponse: + try: + # URL-decode the follower_uri + follower_uri = urllib.parse.unquote(follower_uri) + + # Remove the follower + await self.activitypub_service.handle_unfollow(user=user, project_id=project_id, follower_actor_uri=follower_uri) + + # Return a 204 No Content response + return JSONResponse(None, status=204) + except errors.MissingResourceError as e: + return JSONResponse( + {"error": "not_found", "message": str(e)}, + status=404, + ) + + return "/ap/projects//followers/", ["DELETE"], _remove_project_follower + + def project_inbox(self) -> BlueprintFactoryResponse: + """Receive an ActivityPub activity for a project.""" + + @authenticate(self.authenticator) + async def _project_inbox(request: Request, user: base_models.APIUser, project_id: ULID) -> HTTPResponse: + try: + # Parse the activity + activity_json = request.json + if not activity_json: + return JSONResponse( + {"error": "invalid_request", "message": "Invalid activity: empty request body"}, + status=400, + ) + + # Check if the activity is a Follow activity + activity_type = activity_json.get("type") + if activity_type == models.ActivityType.FOLLOW: + # Get the actor URI + actor_uri = activity_json.get("actor") + if not actor_uri: + return JSONResponse( + {"error": "invalid_request", "message": "Invalid activity: missing actor"}, + status=400, + ) + + try: + # Handle the follow request + await self.activitypub_service.handle_follow( + user=user, project_id=project_id, follower_actor_uri=actor_uri + ) + return HTTPResponse(status=200) + except Exception as e: + logger.exception(f"Error handling follow activity: {e}") + return JSONResponse( + {"error": "internal_error", "message": f"Error handling follow: {str(e)}"}, + status=500, + ) + elif activity_type == models.ActivityType.UNDO: + # Check if the object is a Follow activity + object_json = activity_json.get("object", {}) + if isinstance(object_json, dict) and object_json.get("type") == models.ActivityType.FOLLOW: + # Get the actor URI + actor_uri = activity_json.get("actor") + if not actor_uri: + return JSONResponse( + {"error": "invalid_request", "message": "Invalid activity: missing actor"}, + status=400, + ) + + try: + # Handle the unfollow request + await self.activitypub_service.handle_unfollow( + user=user, project_id=project_id, follower_actor_uri=actor_uri + ) + return HTTPResponse(status=200) + except Exception as e: + logger.exception(f"Error handling unfollow activity: {e}") + return JSONResponse( + {"error": "internal_error", "message": f"Error handling unfollow: {str(e)}"}, + status=500, + ) + + # For other activity types, just acknowledge receipt + return HTTPResponse(status=200) + except errors.MissingResourceError as e: + return JSONResponse( + {"error": "not_found", "message": str(e)}, + status=404, + ) + except Exception as e: + logger.exception(f"Error handling activity: {e}") + return JSONResponse( + {"error": "internal_error", "message": f"An internal error occurred: {str(e)}"}, + status=500, + ) + + return "/ap/projects//inbox", ["POST"], _project_inbox + + def get_project_outbox(self) -> BlueprintFactoryResponse: + """Get the outbox of a project.""" + + @authenticate(self.authenticator) + async def _get_project_outbox(request: Request, user: base_models.APIUser, project_id: ULID) -> JSONResponse: + try: + # Get the project actor + actor = await self.activitypub_service.get_project_actor(user=user, project_id=project_id) + + # For now, return an empty collection + # In the future, this could be populated with activities from the project + collection = { + "@context": ["https://www.w3.org/ns/activitystreams"], + "id": f"{actor.id}/outbox", + "type": "OrderedCollection", + "totalItems": 0, + "first": f"{actor.id}/outbox?page=1", + "last": f"{actor.id}/outbox?page=1", + } + + return JSONResponse( + collection, + status=200, + headers={"Content-Type": "application/activity+json"}, + ) + except errors.MissingResourceError as e: + return JSONResponse( + {"error": "not_found", "message": str(e)}, + status=404, + ) + + return "/ap/projects//outbox", ["GET"], _get_project_outbox + + def webfinger(self) -> BlueprintFactoryResponse: + """WebFinger endpoint.""" + + async def _webfinger(request: Request) -> JSONResponse: + resource = request.args.get("resource") + if not resource: + return JSONResponse( + {"error": "invalid_request", "message": "Missing resource parameter"}, + status=400, + ) + + # Parse the resource + # Format: acct:username@domain or https://domain/ap/projects/project_id + if resource.startswith("acct:"): + # acct:username@domain + parts = resource[5:].split("@") + if len(parts) != 2 or parts[1] != self.config.domain: + return JSONResponse( + {"error": "not_found", "message": f"Resource {resource} not found"}, + status=404, + ) + + username = parts[0] + try: + # Get the actor by username + actor = await self.activitypub_service.get_project_actor_by_username(username=username) + + # Create the WebFinger response + response = { + "subject": resource, + "aliases": [actor.id], + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": actor.id, + } + ], + } + + return JSONResponse( + response, + status=200, + headers={"Content-Type": "application/jrd+json"}, + ) + except errors.MissingResourceError: + return JSONResponse( + {"error": "not_found", "message": f"Resource {resource} not found"}, + status=404, + ) + elif resource.startswith("https://") or resource.startswith("http://"): + # https://domain/ap/projects/project_id + parsed_url = urlparse(resource) + path_parts = parsed_url.path.strip("/").split("/") + + if len(path_parts) >= 3 and path_parts[0] == "ap" and path_parts[1] == "projects": + try: + project_id = ULID.from_str(path_parts[2]) + + # Get the actor + # Create a user with no authentication + # The project_repo.get_project method will check if the project is public + user = base_models.APIUser(id=None, is_admin=False) + actor = await self.activitypub_service.get_project_actor(user=user, project_id=project_id) + + # Create the WebFinger response + response = { + "subject": resource, + "aliases": [f"acct:{actor.preferredUsername}@{self.config.domain}"], + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": actor.id, + } + ], + } + + return JSONResponse( + response, + status=200, + headers={"Content-Type": "application/jrd+json"}, + ) + except (errors.MissingResourceError, ValueError): + return JSONResponse( + {"error": "not_found", "message": f"Resource {resource} not found"}, + status=404, + ) + + return JSONResponse( + {"error": "not_found", "message": f"Resource {resource} not found"}, + status=404, + ) + + return "/ap/webfinger", ["GET"], _webfinger + + def host_meta(self) -> BlueprintFactoryResponse: + """Host metadata endpoint.""" + + async def _host_meta_handler(request: Request) -> HTTPResponse: + # Create the XML response + template = self.config.base_url + "/ap/webfinger?resource={uri}" + xml_content = '\n' + xml_content += '\n' + xml_content += f' \n' + xml_content += '' + + # Return the response + return text( + xml_content, + status=200, + headers={"Content-Type": "application/xrd+xml"}, + ) + + return "/ap/.well-known/host-meta", ["GET"], _host_meta_handler + + def nodeinfo(self) -> BlueprintFactoryResponse: + """NodeInfo endpoint.""" + + async def _nodeinfo(request: Request) -> JSONResponse: + response = { + "links": [ + { + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", + "href": f"{self.config.base_url}/ap/nodeinfo/2.0", + } + ] + } + + return JSONResponse( + response, + status=200, + headers={"Content-Type": "application/json"}, + ) + + return "/ap/.well-known/nodeinfo", ["GET"], _nodeinfo + + def nodeinfo_2_0(self) -> BlueprintFactoryResponse: + """NodeInfo 2.0 endpoint.""" + + async def _nodeinfo_2_0(request: Request) -> JSONResponse: + response = { + "version": "2.0", + "software": { + "name": "renku", + "version": "1.0.0", + }, + "protocols": ["activitypub"], + "services": { + "inbound": [], + "outbound": [], + }, + "usage": { + "users": { + "total": 1, # Placeholder + }, + "localPosts": 0, # Placeholder + }, + "openRegistrations": False, + "metadata": { + "nodeName": "Renku", + "nodeDescription": "Renku ActivityPub Server", + }, + } + + return JSONResponse( + response, + status=200, + headers={"Content-Type": "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#"}, + ) + + return "/ap/nodeinfo/2.0", ["GET"], _nodeinfo_2_0 diff --git a/components/renku_data_services/activitypub/core.py b/components/renku_data_services/activitypub/core.py new file mode 100644 index 000000000..4513a0d9a --- /dev/null +++ b/components/renku_data_services/activitypub/core.py @@ -0,0 +1,549 @@ +"""Business logic for ActivityPub.""" + +import json +from datetime import UTC, datetime +from typing import Any, Dict, List, Optional, Union +import urllib.parse +import dataclasses + +import httpx +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from cryptography.hazmat.primitives import hashes, serialization +import base64 +from sanic.log import logger + +from ulid import ULID + +from renku_data_services import errors +from renku_data_services.activitypub import models +from renku_data_services.activitypub.db import ActivityPubRepository +from renku_data_services.base_models.core import APIUser +from renku_data_services.project.db import ProjectRepository +from renku_data_services.project.models import Project + + + +class ActivityPubService: + """Service for ActivityPub.""" + + def __init__( + self, + activitypub_repo: ActivityPubRepository, + project_repo: ProjectRepository, + config: models.ActivityPubConfig, + ) -> None: + self.activitypub_repo = activitypub_repo + self.project_repo = project_repo + self.config = config + + async def get_project_actor(self, user: APIUser, project_id: ULID) -> models.ProjectActor: + """Get the ActivityPub actor for a project.""" + # Get the project + project = await self.project_repo.get_project(user=user, project_id=project_id) + + # Get or create the actor + try: + # First try to get the existing actor + actor = await self.activitypub_repo.get_project_actor(project_id=project_id) + except errors.MissingResourceError: + # If it doesn't exist, create it + actor = await self.activitypub_repo.create_project_actor(user=user, project_id=project_id) + + if not actor or not actor.id: + raise errors.ProgrammingError(message="Failed to get or create actor for project") + + # Convert to ProjectActor + return self._create_project_actor(project, actor) + + async def get_project_actor_by_username(self, username: str) -> models.ProjectActor: + """Get the ActivityPub actor for a project by username.""" + # Get the actor + actor = await self.activitypub_repo.get_actor_by_username(username) + + if actor.project_id is None: + raise errors.MissingResourceError(message=f"Actor with username '{username}' is not a project actor.") + + # Create a user with no authentication + # The project_repo.get_project method will check if the project is public + user = APIUser(id=None, is_admin=False) + + # Get the project + project = await self.project_repo.get_project(user=user, project_id=actor.project_id) + + # Convert to ProjectActor + return self._create_project_actor(project, actor) + + def _create_project_actor(self, project: Project, actor: models.ActivityPubActor) -> models.ProjectActor: + """Create a ProjectActor from a Project and ActivityPubActor.""" + project_id = f"{self.config.base_url}/ap/projects/{project.id}" + + # Set the audience based on visibility + to = ["https://www.w3.org/ns/activitystreams#Public"] if project.visibility.value == "public" else [] + + # Set the attributedTo to the user who created the project + attributed_to = f"{self.config.base_url}/ap/users/{project.created_by}" + + # Create public key info + public_key = { + "id": f"{project_id}#main-key", + "owner": project_id, + "publicKeyPem": actor.public_key_pem, + } if actor.public_key_pem else None + + # Generate avatar image URL + # We use the project ID to generate a deterministic avatar + # This uses the Gravatar Identicon service to generate a unique avatar based on the project ID + avatar_url = f"https://www.gravatar.com/avatar/{str(project.id)}?d=identicon&s=256" + + # Create icon object for the avatar + icon = { + "type": "Image", + "mediaType": "image/png", + "url": avatar_url + } + + return models.ProjectActor( + id=project_id, + name=project.name, + preferredUsername=actor.username, + summary=project.description, + content=project.description, + documentation=project.documentation, + attributedTo=attributed_to, + to=to, + url=f"{self.config.base_url}/projects/{project.namespace.slug}/{project.slug}", + published=project.creation_date, + updated=project.updated_at, + inbox=f"{project_id}/inbox", + outbox=f"{project_id}/outbox", + followers=f"{project_id}/followers", + following=f"{project_id}/following", + publicKey=public_key, + keywords=project.keywords, + repositories=project.repositories, + visibility=project.visibility.value, + created_by=project.created_by, + creation_date=project.creation_date, + updated_at=project.updated_at, + type=models.ActorType.PROJECT, + icon=icon, + ) + + async def get_project_followers(self, user: APIUser, project_id: ULID) -> List[str]: + """Get the followers of a project.""" + # Get the actor + try: + # First try to get the existing actor + actor = await self.activitypub_repo.get_project_actor(project_id=project_id) + except errors.MissingResourceError: + # If it doesn't exist, create it + actor = await self.activitypub_repo.create_project_actor(user=user, project_id=project_id) + + if not actor or not actor.id: + raise errors.ProgrammingError(message="Failed to get or create actor for project") + + # Get the followers + followers = await self.activitypub_repo.get_followers(actor_id=actor.id) + + # Return only accepted followers + return [follower.follower_actor_uri for follower in followers if follower.accepted] + + async def handle_follow(self, user: APIUser, project_id: ULID, follower_actor_uri: str) -> models.Activity: + """Handle a follow request for a project.""" + # Get the actor + try: + # First try to get the existing actor + actor = await self.activitypub_repo.get_project_actor(project_id=project_id) + logger.debug(f"Found existing actor for project {project_id}: {actor.id}") + except errors.MissingResourceError: + # If it doesn't exist, create it + logger.debug(f"Creating new actor for project {project_id}") + actor = await self.activitypub_repo.create_project_actor(user=user, project_id=project_id) + logger.debug(f"Created new actor for project {project_id}: {actor.id}") + + if not actor: + raise errors.ProgrammingError(message="Failed to get or create actor for project") + + if not actor.id: + raise errors.ProgrammingError(message=f"Actor for project {project_id} has no ID") + + # This is logged at INFO level in db.py, so use DEBUG here + logger.debug(f"Adding follower {follower_actor_uri} to actor {actor.id}") + + # Add the follower + follower = models.UnsavedActivityPubFollower( + actor_id=actor.id, + follower_actor_uri=follower_actor_uri, + accepted=True, # Auto-accept follows + ) + + await self.activitypub_repo.add_follower(follower) + + # Create an Accept activity + project_actor_uri = f"{self.config.base_url}/ap/projects/{project_id}" + activity_id = f"{project_actor_uri}/activities/{ULID()}" + + follow_activity = { + "type": models.ActivityType.FOLLOW, + "actor": follower_actor_uri, + "object": project_actor_uri, + } + + accept_activity = models.Activity( + id=activity_id, + type=models.ActivityType.ACCEPT, + actor=project_actor_uri, + object=follow_activity, + to=[follower_actor_uri], + published=datetime.now(UTC), + ) + + # Discover the inbox URL using WebFinger + inbox_url = await self._discover_inbox_url(follower_actor_uri) + if not inbox_url: + logger.error(f"Failed to discover inbox URL for {follower_actor_uri}") + raise errors.ProgrammingError(message=f"Failed to discover inbox URL for {follower_actor_uri}") + + logger.info(f"Delivering activity to inbox URL: {inbox_url}") + await self._deliver_activity(actor, accept_activity, inbox_url) + + return accept_activity + + async def handle_unfollow(self, user: APIUser, project_id: ULID, follower_actor_uri: str) -> None: + """Handle an unfollow request for a project.""" + # Get the actor + try: + # First try to get the existing actor + actor = await self.activitypub_repo.get_project_actor(project_id=project_id) + except errors.MissingResourceError: + # If it doesn't exist, create it + actor = await self.activitypub_repo.create_project_actor(user=user, project_id=project_id) + + if not actor or not actor.id: + raise errors.ProgrammingError(message="Failed to get or create actor for project") + + # Remove the follower + await self.activitypub_repo.remove_follower(actor_id=actor.id, follower_actor_uri=follower_actor_uri) + + async def announce_project_update(self, user: APIUser, project_id: ULID) -> None: + """Announce a project update to followers.""" + # Get the actor + try: + # First try to get the existing actor + actor = await self.activitypub_repo.get_project_actor(project_id=project_id) + except errors.MissingResourceError: + # If it doesn't exist, create it + actor = await self.activitypub_repo.create_project_actor(user=user, project_id=project_id) + + if not actor or not actor.id: + raise errors.ProgrammingError(message="Failed to get or create actor for project") + + # Update the actor with the latest project info + actor = await self.activitypub_repo.update_project_actor(user=user, project_id=project_id) + + # Get the project + project = await self.project_repo.get_project(user=user, project_id=project_id) + + # Get the followers + followers = await self.activitypub_repo.get_followers(actor_id=actor.id) + accepted_followers = [follower.follower_actor_uri for follower in followers if follower.accepted] + + if not accepted_followers: + return # No followers to announce to + + # Create an Update activity + project_actor_uri = f"{self.config.base_url}/ap/projects/{project_id}" + activity_id = f"{project_actor_uri}/activities/{ULID()}" + + project_actor = self._create_project_actor(project, actor) + + update_activity = models.Activity( + id=activity_id, + type=models.ActivityType.UPDATE, + actor=project_actor_uri, + object=project_actor, + to=accepted_followers, + published=datetime.now(UTC), + ) + + # Send the Update activity to each follower's inbox + for follower_uri in accepted_followers: + try: + # Discover the inbox URL using WebFinger + inbox_url = await self._discover_inbox_url(follower_uri) + if not inbox_url: + logger.error(f"Failed to discover inbox URL for {follower_uri}") + continue + + logger.info(f"Delivering update activity to inbox URL: {inbox_url}") + await self._deliver_activity(actor, update_activity, inbox_url) + except Exception as e: + logger.error(f"Failed to deliver update activity to {follower_uri}: {e}") + + async def _deliver_activity( + self, actor: models.ActivityPubActor, activity: models.Activity, inbox_url: str + ) -> None: + """Deliver an activity to an inbox.""" + if not actor.private_key_pem: + raise errors.ProgrammingError(message="Actor does not have a private key") + + # Convert activity to dict + activity_dict = self._to_dict(activity) + + # Convert the activity dict to JSON + activity_json = json.dumps(activity_dict) + + # Calculate the digest + digest = hashes.Hash(hashes.SHA256()) + digest.update(activity_json.encode("utf-8")) + digest_value = digest.finalize() + digest_header = f"SHA-256={base64.b64encode(digest_value).decode('utf-8')}" + + # Parse the target URL + parsed_url = urllib.parse.urlparse(inbox_url) + host = parsed_url.netloc + path = parsed_url.path + + # Create the signature string + date = datetime.now(UTC).strftime("%a, %d %b %Y %H:%M:%S GMT") + signature_string = f"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest_header}" + + # Parse the private key + private_key = serialization.load_pem_private_key( + actor.private_key_pem.encode("utf-8"), + password=None, + ) + + if not isinstance(private_key, RSAPrivateKey): + raise errors.ProgrammingError(message="Actor's private key is not an RSA key") + + # Sign the signature string + signature = private_key.sign( + signature_string.encode("utf-8"), + padding.PKCS1v15(), + hashes.SHA256(), + ) + signature_b64 = base64.b64encode(signature).decode("utf-8") + + # Prepare the signature header + actor_id = f"{self.config.base_url}/ap/projects/{actor.project_id}" if actor.project_id else f"{self.config.base_url}/ap/users/{actor.user_id}" + key_id = f"{actor_id}#main-key" + signature_header = f'keyId="{key_id}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="{signature_b64}"' + + # Prepare the headers + headers = { + "Host": host, + "Date": date, + "Digest": digest_header, + "Signature": signature_header, + "Content-Type": "application/activity+json", + "Accept": "application/activity+json", + } + + # Log the request details for debugging + logger.info(f"Sending activity to {inbox_url}") + logger.debug(f"Headers: {headers}") + logger.debug(f"Body: {activity_json}") + + # Send the request + async with httpx.AsyncClient() as client: + response = await client.post( + inbox_url, + content=activity_json.encode("utf-8"), # Use content instead of json to ensure exact JSON string + headers=headers, + ) + + if response.status_code >= 400: + logger.error(f"Failed to deliver activity to {inbox_url}: {response.status_code} {response.text}") + raise errors.ProgrammingError( + message=f"Failed to deliver activity to {inbox_url}: {response.status_code}" + ) + + async def _build_signature_headers( + self, actor: models.ActivityPubActor, target_url: str, data: Dict[str, Any] + ) -> Dict[str, str]: + """Build HTTP Signature headers for an ActivityPub request.""" + if not actor.private_key_pem: + raise errors.ProgrammingError(message="Actor does not have a private key") + + # Parse the private key + private_key = serialization.load_pem_private_key( + actor.private_key_pem.encode("utf-8"), + password=None, + ) + + if not isinstance(private_key, RSAPrivateKey): + raise errors.ProgrammingError(message="Actor's private key is not an RSA key") + + # Prepare the signature + actor_id = f"{self.config.base_url}/ap/projects/{actor.project_id}" if actor.project_id else f"{self.config.base_url}/ap/users/{actor.user_id}" + key_id = f"{actor_id}#main-key" + + # Get the digest of the data + data_json = json.dumps(data) + digest = hashes.Hash(hashes.SHA256()) + digest.update(data_json.encode("utf-8")) + digest_value = digest.finalize() + digest_header = f"SHA-256={base64.b64encode(digest_value).decode('utf-8')}" + + # Parse the target URL + parsed_url = urllib.parse.urlparse(target_url) + host = parsed_url.netloc + path = parsed_url.path + + # Create the signature string + date = datetime.now(UTC).strftime("%a, %d %b %Y %H:%M:%S GMT") + signature_string = f"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest_header}" + + # Sign the signature string + signature = private_key.sign( + signature_string.encode("utf-8"), + padding.PKCS1v15(), + hashes.SHA256(), + ) + signature_b64 = base64.b64encode(signature).decode("utf-8") + + # Create the signature header + signature_header = f'keyId="{key_id}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="{signature_b64}"' + + # Return the headers + return { + "Host": host, + "Date": date, + "Digest": digest_header, + "Signature": signature_header, + "Content-Type": "application/activity+json", + "Accept": "application/activity+json", + } + + async def _discover_inbox_url(self, actor_uri: str) -> Optional[str]: + """Discover the inbox URL for an actor using WebFinger and ActivityPub. + + This method follows the ActivityPub discovery process: + 1. Parse the actor URI to extract the domain and username + 2. Perform a WebFinger lookup to get the actor's profile URL + 3. Fetch the actor's profile to get the inbox URL + """ + logger.info(f"Discovering inbox URL for actor: {actor_uri}") + + try: + # Parse the actor URI + parsed_uri = urllib.parse.urlparse(actor_uri) + domain = parsed_uri.netloc + + # Handle different URI formats + if parsed_uri.path.startswith("/@"): + # Mastodon-style URI: https://fosstodon.org/@username + username = parsed_uri.path[2:] # Remove the leading /@ + resource = f"acct:{username}@{domain}" + elif parsed_uri.path.startswith("/users/"): + # ActivityPub-style URI: https://domain.org/users/username + username = parsed_uri.path.split("/")[-1] + resource = f"acct:{username}@{domain}" + elif "@" in parsed_uri.path: + # Another Mastodon-style URI: https://domain.org/@username + username = parsed_uri.path.strip("/").replace("@", "") + resource = f"acct:{username}@{domain}" + else: + # Use the full URI as the resource + resource = actor_uri + + # Perform WebFinger lookup + webfinger_url = f"https://{domain}/.well-known/webfinger?resource={urllib.parse.quote(resource)}" + logger.info(f"WebFinger URL: {webfinger_url}") + + async with httpx.AsyncClient(follow_redirects=True) as client: + # Set a timeout for the request + response = await client.get(webfinger_url, timeout=10.0) + + if response.status_code != 200: + logger.error(f"WebFinger lookup failed: {response.status_code} {response.text}") + # Try a fallback approach for Mastodon instances + if "@" in parsed_uri.path: + username = parsed_uri.path.strip("/").replace("@", "") + return f"https://{domain}/users/{username}/inbox" + return None + + webfinger_data = response.json() + + # Find the self link with type application/activity+json + actor_url = None + for link in webfinger_data.get("links", []): + if link.get("rel") == "self" and link.get("type") == "application/activity+json": + actor_url = link.get("href") + break + + if not actor_url: + logger.error(f"No ActivityPub actor URL found in WebFinger response: {webfinger_data}") + # Try a fallback approach for Mastodon instances + if "@" in parsed_uri.path: + username = parsed_uri.path.strip("/").replace("@", "") + return f"https://{domain}/users/{username}/inbox" + return None + + # Fetch the actor's profile + logger.info(f"Fetching actor profile: {actor_url}") + response = await client.get( + actor_url, + headers={"Accept": "application/activity+json"}, + timeout=10.0 + ) + + if response.status_code != 200: + logger.error(f"Actor profile fetch failed: {response.status_code} {response.text}") + return None + + actor_data = response.json() + + # Get the inbox URL + inbox_url = actor_data.get("inbox") + if not inbox_url: + logger.error(f"No inbox URL found in actor profile: {actor_data}") + return None + + logger.info(f"Discovered inbox URL: {inbox_url}") + # Ensure we're returning a string, not Any + return str(inbox_url) if inbox_url else None + + except Exception as e: + logger.exception(f"Error discovering inbox URL: {e}") + # Try a fallback approach for Mastodon instances + try: + parsed_uri = urllib.parse.urlparse(actor_uri) + domain = parsed_uri.netloc + if "@" in parsed_uri.path: + username = parsed_uri.path.strip("/").replace("@", "") + return f"https://{domain}/users/{username}/inbox" + except Exception: + pass + return None + + def _to_dict(self, obj: Any) -> Any: + """Convert an object to a dictionary.""" + if isinstance(obj, dict): + return {k: self._to_dict(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [self._to_dict(item) for item in obj] + elif dataclasses.is_dataclass(obj) and not isinstance(obj, type): + # Convert dataclass instance to dict + result = {} + # First convert to dict using dataclasses.asdict + dc_dict = dataclasses.asdict(obj) + # Then recursively convert all values + for field_name, field_value in dc_dict.items(): + if field_value is not None: # Skip None values + if field_name == "context": + # Special case for @context + result["@context"] = self._to_dict(field_value) + else: + result[field_name] = self._to_dict(field_value) + return result + elif isinstance(obj, datetime): + return obj.isoformat() + elif isinstance(obj, ULID): + return str(obj) + elif isinstance(obj, (str, int, float, bool, type(None))): + return obj + else: + return str(obj) diff --git a/components/renku_data_services/activitypub/db.py b/components/renku_data_services/activitypub/db.py new file mode 100644 index 000000000..1f4dc8c6c --- /dev/null +++ b/components/renku_data_services/activitypub/db.py @@ -0,0 +1,350 @@ +"""Adapters for ActivityPub database classes.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import UTC, datetime +from typing import Optional + +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from sanic.log import logger +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession +from ulid import ULID + +from renku_data_services import errors +from renku_data_services.activitypub import models, orm +from renku_data_services.base_models.core import APIUser +from renku_data_services.project.db import ProjectRepository +from renku_data_services.utils.core import with_db_transaction + + +class ActivityPubRepository: + """Repository for ActivityPub.""" + + def __init__( + self, + session_maker: Callable[..., AsyncSession], + project_repo: ProjectRepository, + config: models.ActivityPubConfig, + ) -> None: + self.session_maker = session_maker + self.project_repo = project_repo + self.config = config + + async def get_actor(self, actor_id: ULID) -> models.ActivityPubActor: + """Get an actor by ID.""" + logger.info(f"Getting ActivityPub actor by ID {actor_id}") + + async with self.session_maker() as session: + result = await session.execute(select(orm.ActivityPubActorORM).where(orm.ActivityPubActorORM.id == actor_id)) + actor_orm = result.scalar_one_or_none() + + if actor_orm is None: + logger.warning(f"Actor with id '{actor_id}' not found") + raise errors.MissingResourceError(message=f"Actor with id '{actor_id}' does not exist.") + + logger.info(f"Found actor {actor_id} with username '{actor_orm.username}'") + return actor_orm.dump() + + async def get_actor_by_username(self, username: str) -> models.ActivityPubActor: + """Get an actor by username.""" + logger.info(f"Getting ActivityPub actor by username '{username}'") + + async with self.session_maker() as session: + result = await session.execute( + select(orm.ActivityPubActorORM).where(orm.ActivityPubActorORM.username == username) + ) + actor_orm = result.scalar_one_or_none() + + if actor_orm is None: + logger.warning(f"Actor with username '{username}' not found") + raise errors.MissingResourceError(message=f"Actor with username '{username}' does not exist.") + + logger.info(f"Found actor {actor_orm.id} with username '{username}'") + return actor_orm.dump() + + async def get_project_actor(self, project_id: ULID) -> models.ActivityPubActor: + """Get the actor for a project.""" + logger.info(f"Getting ActivityPub actor for project {project_id}") + + async with self.session_maker() as session: + result = await session.execute( + select(orm.ActivityPubActorORM).where(orm.ActivityPubActorORM.project_id == project_id) + ) + actor_orm = result.scalar_one_or_none() + + if actor_orm is None: + logger.warning(f"Actor for project '{project_id}' not found") + raise errors.MissingResourceError( + message=f"Actor for project with id '{project_id}' does not exist." + ) + + logger.info(f"Found actor {actor_orm.id} for project {project_id}") + return actor_orm.dump() + + @with_db_transaction + async def create_actor( + self, actor: models.UnsavedActivityPubActor, *, session: AsyncSession | None = None + ) -> models.ActivityPubActor: + """Create a new actor.""" + if not session: + raise errors.ProgrammingError(message="A database session is required") + + logger.info(f"Creating new ActivityPub actor with username '{actor.username}'") + + # Check if username is already taken + result = await session.execute( + select(orm.ActivityPubActorORM).where(orm.ActivityPubActorORM.username == actor.username) + ) + existing_actor = result.scalar_one_or_none() + if existing_actor is not None: + logger.warning(f"Cannot create actor: Username '{actor.username}' already exists") + raise errors.ConflictError(message=f"Actor with username '{actor.username}' already exists.") + + # Generate key pair for the actor + logger.info(f"Generating RSA key pair for actor '{actor.username}'") + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + public_key_pem = private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("utf-8") + + # Create the actor + actor_orm = orm.ActivityPubActorORM( + username=actor.username, + name=actor.name, + summary=actor.summary, + type=actor.type, + user_id=actor.user_id, + project_id=actor.project_id, + private_key_pem=private_key_pem, + public_key_pem=public_key_pem, + ) + + session.add(actor_orm) + await session.flush() + await session.refresh(actor_orm) + + logger.info(f"Successfully created ActivityPub actor with ID {actor_orm.id} and username '{actor.username}'") + return actor_orm.dump() + + @with_db_transaction + async def update_actor( + self, actor_id: ULID, name: Optional[str] = None, summary: Optional[str] = None, *, session: AsyncSession | None = None + ) -> models.ActivityPubActor: + """Update an actor.""" + if not session: + raise errors.ProgrammingError(message="A database session is required") + + logger.info(f"Updating ActivityPub actor {actor_id}") + + result = await session.execute(select(orm.ActivityPubActorORM).where(orm.ActivityPubActorORM.id == actor_id)) + actor_orm = result.scalar_one_or_none() + + if actor_orm is None: + logger.error(f"Cannot update actor: Actor with id '{actor_id}' does not exist") + raise errors.MissingResourceError(message=f"Actor with id '{actor_id}' does not exist.") + + # Update fields + changes = [] + if name is not None and name != actor_orm.name: + logger.info(f"Updating actor {actor_id} name from '{actor_orm.name}' to '{name}'") + actor_orm.name = name + changes.append("name") + if summary is not None and summary != actor_orm.summary: + logger.info(f"Updating actor {actor_id} summary") + actor_orm.summary = summary + changes.append("summary") + + if not changes: + logger.info(f"No changes to apply for actor {actor_id}") + return actor_orm.dump() + + actor_orm.updated_at = datetime.now(UTC).replace(microsecond=0) + + await session.flush() + await session.refresh(actor_orm) + + logger.info(f"Successfully updated actor {actor_id} ({', '.join(changes)})") + return actor_orm.dump() + + @with_db_transaction + async def delete_actor(self, actor_id: ULID, *, session: AsyncSession | None = None) -> None: + """Delete an actor.""" + if not session: + raise errors.ProgrammingError(message="A database session is required") + + logger.info(f"Deleting ActivityPub actor {actor_id}") + + result = await session.execute(select(orm.ActivityPubActorORM).where(orm.ActivityPubActorORM.id == actor_id)) + actor_orm = result.scalar_one_or_none() + + if actor_orm is None: + logger.info(f"Actor {actor_id} not found, nothing to delete") + return + + await session.delete(actor_orm) + logger.info(f"Successfully deleted actor {actor_id}") + + async def get_followers(self, actor_id: ULID) -> list[models.ActivityPubFollower]: + """Get all followers of an actor.""" + logger.info(f"Getting followers for actor {actor_id}") + + async with self.session_maker() as session: + result = await session.execute( + select(orm.ActivityPubFollowerORM).where(orm.ActivityPubFollowerORM.actor_id == actor_id) + ) + followers_orm = result.scalars().all() + + follower_count = len(followers_orm) + accepted_count = sum(1 for f in followers_orm if f.accepted) + + logger.info(f"Found {follower_count} followers for actor {actor_id} ({accepted_count} accepted)") + return [follower.dump() for follower in followers_orm] + + @with_db_transaction + async def add_follower( + self, follower: models.UnsavedActivityPubFollower, *, session: AsyncSession | None = None + ) -> models.ActivityPubFollower: + """Add a follower to an actor.""" + if not session: + raise errors.ProgrammingError(message="A database session is required") + + # Validate the actor_id + if not follower.actor_id: + logger.error("Cannot add follower: Actor ID is missing") + raise errors.ProgrammingError(message="Actor ID is required to add a follower") + + logger.info(f"Adding follower {follower.follower_actor_uri} to actor {follower.actor_id}") + + # Check if the actor exists + result = await session.execute( + select(orm.ActivityPubActorORM).where(orm.ActivityPubActorORM.id == follower.actor_id) + ) + actor_orm = result.scalar_one_or_none() + if actor_orm is None: + logger.error(f"Cannot add follower: Actor with id '{follower.actor_id}' does not exist") + raise errors.MissingResourceError(message=f"Actor with id '{follower.actor_id}' does not exist.") + + # Refresh the actor_orm to ensure it's fully loaded + await session.refresh(actor_orm) + + # Check if the follower already exists + result = await session.execute( + select(orm.ActivityPubFollowerORM) + .where(orm.ActivityPubFollowerORM.actor_id == follower.actor_id) + .where(orm.ActivityPubFollowerORM.follower_actor_uri == follower.follower_actor_uri) + ) + existing_follower = result.scalar_one_or_none() + if existing_follower is not None: + if existing_follower.accepted == follower.accepted: + logger.info(f"Follower {follower.follower_actor_uri} already exists with same acceptance status") + return existing_follower.dump() + + # Update the acceptance status + logger.info(f"Updating acceptance status for follower {follower.follower_actor_uri} to {follower.accepted}") + existing_follower.accepted = follower.accepted + existing_follower.updated_at = datetime.now(UTC).replace(microsecond=0) + await session.flush() + await session.refresh(existing_follower) + return existing_follower.dump() + + # Create the follower using the ORM + logger.info(f"Creating new follower record for {follower.follower_actor_uri}") + + follower_orm = orm.ActivityPubFollowerORM( + actor_id=follower.actor_id, + follower_actor_uri=follower.follower_actor_uri, + accepted=follower.accepted, + actor=actor_orm + ) + + session.add(follower_orm) + await session.flush() + await session.refresh(follower_orm) + + logger.info(f"Successfully added follower {follower.follower_actor_uri} to actor {follower.actor_id}") + return follower_orm.dump() + + @with_db_transaction + async def remove_follower( + self, actor_id: ULID, follower_actor_uri: str, *, session: AsyncSession | None = None + ) -> None: + """Remove a follower from an actor.""" + if not session: + raise errors.ProgrammingError(message="A database session is required") + + logger.info(f"Removing follower {follower_actor_uri} from actor {actor_id}") + + result = await session.execute( + select(orm.ActivityPubFollowerORM) + .where(orm.ActivityPubFollowerORM.actor_id == actor_id) + .where(orm.ActivityPubFollowerORM.follower_actor_uri == follower_actor_uri) + ) + follower_orm = result.scalar_one_or_none() + + if follower_orm is None: + logger.info(f"Follower {follower_actor_uri} not found for actor {actor_id}, nothing to remove") + return + + await session.delete(follower_orm) + logger.info(f"Successfully removed follower {follower_actor_uri} from actor {actor_id}") + + async def create_project_actor(self, user: APIUser, project_id: ULID) -> models.ActivityPubActor: + """Create an actor for a project.""" + # Get the project + project = await self.project_repo.get_project(user=user, project_id=project_id) + + # Create a username for the project + username = f"{project.namespace.slug}_{project.slug}" + + logger.info(f"Creating new ActivityPub actor for project {project_id} with username '{username}'") + + # Create the actor + actor = models.UnsavedActivityPubActor( + username=username, + name=project.name, + summary=project.description, + type=models.ActorType.PROJECT, + project_id=project_id, + ) + + created_actor = await self.create_actor(actor) + logger.info(f"Successfully created ActivityPub actor {created_actor.id} for project {project_id}") + return created_actor + + async def get_or_create_project_actor(self, user: APIUser, project_id: ULID) -> models.ActivityPubActor: + """Get or create an actor for a project.""" + logger.info(f"Getting or creating ActivityPub actor for project {project_id}") + try: + actor = await self.get_project_actor(project_id=project_id) + logger.info(f"Found existing ActivityPub actor {actor.id} for project {project_id}") + return actor + except errors.MissingResourceError: + logger.info(f"No existing actor found for project {project_id}, creating new one") + return await self.create_project_actor(user=user, project_id=project_id) + + async def update_project_actor(self, user: APIUser, project_id: ULID) -> models.ActivityPubActor: + """Update an actor for a project.""" + logger.info(f"Updating ActivityPub actor for project {project_id}") + + # Get the project + project = await self.project_repo.get_project(user=user, project_id=project_id) + + try: + actor = await self.get_project_actor(project_id=project_id) + logger.info(f"Found existing actor {actor.id} for project {project_id}, updating metadata") + updated_actor = await self.update_actor(actor_id=actor.id, name=project.name, summary=project.description) + logger.info(f"Successfully updated actor {actor.id} for project {project_id}") + return updated_actor + except errors.MissingResourceError: + logger.info(f"No existing actor found for project {project_id}, creating new one") + return await self.create_project_actor(user=user, project_id=project_id) diff --git a/components/renku_data_services/activitypub/models.py b/components/renku_data_services/activitypub/models.py new file mode 100644 index 000000000..a6223936e --- /dev/null +++ b/components/renku_data_services/activitypub/models.py @@ -0,0 +1,256 @@ +"""Models for ActivityPub.""" + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from enum import Enum +from typing import Any, Dict, List, Optional, Union + +from ulid import ULID + +from renku_data_services.base_models.core import APIUser +from renku_data_services.project.models import Project + + +class ActivityType(str, Enum): + """ActivityPub activity types.""" + + CREATE = "Create" + UPDATE = "Update" + DELETE = "Delete" + FOLLOW = "Follow" + ACCEPT = "Accept" + REJECT = "Reject" + ANNOUNCE = "Announce" + LIKE = "Like" + UNDO = "Undo" + + +class ActorType(str, Enum): + """ActivityPub actor types.""" + + PERSON = "Person" + SERVICE = "Service" + GROUP = "Group" + ORGANIZATION = "Organization" + APPLICATION = "Application" + PROJECT = "Project" # Custom type for Renku projects + + +class ObjectType(str, Enum): + """ActivityPub object types.""" + + NOTE = "Note" + ARTICLE = "Article" + COLLECTION = "Collection" + DOCUMENT = "Document" + IMAGE = "Image" + VIDEO = "Video" + AUDIO = "Audio" + PAGE = "Page" + EVENT = "Event" + PLACE = "Place" + PROFILE = "Profile" + TOMBSTONE = "Tombstone" + + +@dataclass +class Link: + """ActivityPub Link object.""" + + href: str + rel: Optional[str] = None + mediaType: Optional[str] = None + name: Optional[str] = None + hreflang: Optional[str] = None + height: Optional[int] = None + width: Optional[int] = None + preview: Optional[Dict[str, Any]] = None + + +@dataclass +class BaseObject: + """Base ActivityPub Object.""" + + id: str + type: Union[ObjectType, ActorType, ActivityType, str] + context: List[str] = field(default_factory=lambda: ["https://www.w3.org/ns/activitystreams"]) + name: Optional[str] = None + summary: Optional[str] = None + content: Optional[str] = None + url: Optional[Union[str, List[str], Link, List[Link]]] = None + published: Optional[datetime] = None + updated: Optional[datetime] = None + mediaType: Optional[str] = None + attributedTo: Optional[Union[str, Dict[str, Any]]] = None + to: Optional[List[str]] = None + cc: Optional[List[str]] = None + bto: Optional[List[str]] = None + bcc: Optional[List[str]] = None + + +@dataclass +class Actor(BaseObject): + """ActivityPub Actor.""" + + type: ActorType + inbox: Optional[str] = None + outbox: Optional[str] = None + preferredUsername: Optional[str] = None + followers: Optional[str] = None + following: Optional[str] = None + liked: Optional[str] = None + publicKey: Optional[Dict[str, Any]] = None + endpoints: Optional[Dict[str, Any]] = None + icon: Optional[Union[Dict[str, Any], Link]] = None + image: Optional[Union[Dict[str, Any], Link]] = None + + +@dataclass +class ProjectActor(Actor): + """ActivityPub representation of a Renku Project as an Actor.""" + + type: ActorType = ActorType.PROJECT + keywords: Optional[List[str]] = None + repositories: Optional[List[str]] = None + visibility: Optional[str] = None + created_by: Optional[str] = None + creation_date: Optional[datetime] = None + updated_at: Optional[datetime] = None + documentation: Optional[str] = None + + @classmethod + def from_project(cls, project: Project, base_url: str, domain: str) -> "ProjectActor": + """Create a ProjectActor from a Project.""" + project_id = f"{base_url}/ap/projects/{project.id}" + username = f"{project.namespace.slug}_{project.slug}" + + # Set the audience based on visibility + to = ["https://www.w3.org/ns/activitystreams#Public"] if project.visibility.value == "public" else [] + + # Set the attributedTo to the user who created the project + attributed_to = f"{base_url}/ap/users/{project.created_by}" + + # Create public key info + public_key = None # This would be populated with actual key data + + # Generate avatar image URL + # We use the project ID to generate a deterministic avatar + # This uses the Gravatar Identicon service to generate a unique avatar based on the project ID + avatar_url = f"https://www.gravatar.com/avatar/{str(project.id)}?d=identicon&s=256" + + # Create icon object for the avatar + icon = { + "type": "Image", + "mediaType": "image/png", + "url": avatar_url + } + + return cls( + id=project_id, + name=project.name, + preferredUsername=username, + summary=project.description, + content=project.description, + documentation=project.documentation, + attributedTo=attributed_to, + to=to, + url=f"{base_url}/projects/{project.namespace.slug}/{project.slug}", + published=project.creation_date, + updated=project.updated_at, + inbox=f"{project_id}/inbox", + outbox=f"{project_id}/outbox", + followers=f"{project_id}/followers", + following=f"{project_id}/following", + publicKey=public_key, + keywords=project.keywords, + repositories=project.repositories, + visibility=project.visibility.value, + created_by=project.created_by, + creation_date=project.creation_date, + updated_at=project.updated_at, + icon=icon, + ) + + +@dataclass +class Activity(BaseObject): + """ActivityPub Activity.""" + + type: ActivityType + actor: Optional[Union[str, Actor]] = None + object: Optional[Union[str, Dict[str, Any], "Activity", BaseObject]] = None + target: Optional[Union[str, Dict[str, Any], "Activity", BaseObject]] = None + result: Optional[Union[str, Dict[str, Any], "Activity", BaseObject]] = None + origin: Optional[Union[str, Dict[str, Any], "Activity", BaseObject]] = None + instrument: Optional[Union[str, Dict[str, Any], "Activity", BaseObject]] = None + + +@dataclass +class Object(BaseObject): + """ActivityPub Object.""" + + type: ObjectType + attachment: Optional[List[Union[Dict[str, Any], Link]]] = None + inReplyTo: Optional[Union[str, Dict[str, Any]]] = None + location: Optional[Union[str, Dict[str, Any]]] = None + tag: Optional[List[Union[Dict[str, Any], Link]]] = None + duration: Optional[str] = None + + +@dataclass +class UnsavedActivityPubActor: + """An ActivityPub actor that hasn't been stored in the database.""" + + username: str + name: Optional[str] = None + summary: Optional[str] = None + type: ActorType = ActorType.SERVICE + user_id: Optional[str] = None + project_id: Optional[ULID] = None + + +@dataclass +class ActivityPubActor: + """An ActivityPub actor that has been stored in the database.""" + + id: ULID + username: str + name: Optional[str] + summary: Optional[str] + type: ActorType + user_id: Optional[str] + project_id: Optional[ULID] + created_at: datetime = field(default_factory=lambda: datetime.now(UTC).replace(microsecond=0)) + updated_at: Optional[datetime] = None + private_key_pem: Optional[str] = None + public_key_pem: Optional[str] = None + + +@dataclass +class UnsavedActivityPubFollower: + """An ActivityPub follower that hasn't been stored in the database.""" + + actor_id: ULID + follower_actor_uri: str + accepted: bool = False + + +@dataclass +class ActivityPubFollower: + """An ActivityPub follower that has been stored in the database.""" + + id: ULID + actor_id: ULID + follower_actor_uri: str + accepted: bool + created_at: datetime = field(default_factory=lambda: datetime.now(UTC).replace(microsecond=0)) + updated_at: Optional[datetime] = None + + +@dataclass +class ActivityPubConfig: + """Configuration for ActivityPub.""" + + domain: str + base_url: str + admin_email: str diff --git a/components/renku_data_services/activitypub/orm.py b/components/renku_data_services/activitypub/orm.py new file mode 100644 index 000000000..0a682b711 --- /dev/null +++ b/components/renku_data_services/activitypub/orm.py @@ -0,0 +1,123 @@ +"""SQLAlchemy's schemas for the ActivityPub database.""" + +from datetime import datetime +from typing import Optional + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, MetaData, String, Text, func +from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column, relationship +from ulid import ULID + +from renku_data_services.base_orm.registry import COMMON_ORM_REGISTRY +from renku_data_services.activitypub import models +from renku_data_services.project.orm import ProjectORM +from renku_data_services.users.orm import UserORM +from renku_data_services.utils.sqlalchemy import ULIDType + + +class BaseORM(MappedAsDataclass, DeclarativeBase): + """Base class for all ORM classes.""" + + metadata = MetaData(schema="activitypub") + registry = COMMON_ORM_REGISTRY + + +class ActivityPubActorORM(BaseORM): + """ActivityPub actor.""" + + __tablename__ = "actors" + __table_args__ = ( + Index("ix_actors_username", "username", unique=True), + Index("ix_actors_user_id", "user_id"), + Index("ix_actors_project_id", "project_id"), + ) + + id: Mapped[ULID] = mapped_column( + "id", ULIDType, primary_key=True, default_factory=lambda: str(ULID()), init=False + ) + username: Mapped[str] = mapped_column("username", String(255), nullable=False) + name: Mapped[Optional[str]] = mapped_column("name", String(255), nullable=True) + summary: Mapped[Optional[str]] = mapped_column("summary", Text, nullable=True) + type: Mapped[models.ActorType] = mapped_column("type", String(50), nullable=False) + user_id: Mapped[Optional[str]] = mapped_column( + "user_id", ForeignKey(UserORM.keycloak_id, ondelete="CASCADE"), nullable=True + ) + project_id: Mapped[Optional[ULID]] = mapped_column( + "project_id", ForeignKey(ProjectORM.id, ondelete="CASCADE"), nullable=True + ) + private_key_pem: Mapped[Optional[str]] = mapped_column("private_key_pem", Text, nullable=True, default=None) + public_key_pem: Mapped[Optional[str]] = mapped_column("public_key_pem", Text, nullable=True, default=None) + created_at: Mapped[datetime] = mapped_column( + "created_at", DateTime(timezone=True), default=func.now(), nullable=False + ) + updated_at: Mapped[Optional[datetime]] = mapped_column( + "updated_at", DateTime(timezone=True), default=None, server_default=func.now(), onupdate=func.now() + ) + + # Relationships + followers: Mapped[list["ActivityPubFollowerORM"]] = relationship( + primaryjoin="ActivityPubActorORM.id == ActivityPubFollowerORM.actor_id", + back_populates="actor", + cascade="all, delete-orphan", + lazy="selectin", + default_factory=list, + ) + + def dump(self) -> models.ActivityPubActor: + """Create an ActivityPubActor model from the ORM.""" + return models.ActivityPubActor( + id=self.id, + username=self.username, + name=self.name, + summary=self.summary, + type=self.type, + user_id=self.user_id, + project_id=self.project_id, + created_at=self.created_at, + updated_at=self.updated_at, + private_key_pem=self.private_key_pem, + public_key_pem=self.public_key_pem, + ) + + +class ActivityPubFollowerORM(BaseORM): + """ActivityPub follower.""" + + __tablename__ = "followers" + __table_args__ = ( + Index("ix_followers_actor_id", "actor_id"), + Index("ix_followers_actor_id_follower_actor_uri", "actor_id", "follower_actor_uri", unique=True), + ) + + id: Mapped[ULID] = mapped_column( + "id", ULIDType, primary_key=True, default_factory=lambda: str(ULID()), init=False + ) + actor_id: Mapped[ULID] = mapped_column( + "actor_id", ULIDType, ForeignKey(ActivityPubActorORM.id, ondelete="CASCADE"), nullable=False + ) + follower_actor_uri: Mapped[str] = mapped_column("follower_actor_uri", String(2048), nullable=False) + accepted: Mapped[bool] = mapped_column("accepted", Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column( + "created_at", DateTime(timezone=True), default=func.now(), nullable=False + ) + updated_at: Mapped[Optional[datetime]] = mapped_column( + "updated_at", DateTime(timezone=True), default=None, server_default=func.now(), onupdate=func.now() + ) + + # Relationships + actor: Mapped[ActivityPubActorORM] = relationship( + primaryjoin="ActivityPubActorORM.id == ActivityPubFollowerORM.actor_id", + back_populates="followers", + lazy="selectin", + default=None, + ) + + def dump(self) -> models.ActivityPubFollower: + """Create an ActivityPubFollower model from the ORM.""" + return models.ActivityPubFollower( + id=self.id, + actor_id=self.actor_id, + follower_actor_uri=self.follower_actor_uri, + accepted=self.accepted, + created_at=self.created_at, + updated_at=self.updated_at, + ) diff --git a/components/renku_data_services/app_config/config.py b/components/renku_data_services/app_config/config.py index fa5f545c6..62af9f5f2 100644 --- a/components/renku_data_services/app_config/config.py +++ b/components/renku_data_services/app_config/config.py @@ -27,6 +27,7 @@ from yaml import safe_load import renku_data_services.base_models as base_models +import renku_data_services.activitypub import renku_data_services.connected_services import renku_data_services.crc import renku_data_services.data_connectors @@ -261,6 +262,9 @@ class Config: gitlab_url: str | None nb_config: NotebooksConfig builds_config: BuildsConfig + domain: str + base_url: str + admin_email: str secrets_service_public_key: rsa.RSAPublicKey """The public key of the secrets service, used to encrypt user secrets that only it can decrypt.""" @@ -325,6 +329,7 @@ def load_apispec() -> dict[str, Any]: renku_data_services.message_queue.__file__, renku_data_services.data_connectors.__file__, renku_data_services.search.__file__, + renku_data_services.activitypub.__file__, ] api_specs = [] @@ -684,6 +689,11 @@ def from_env(cls, prefix: str = "") -> "Config": nb_config = NotebooksConfig.from_env(db) builds_config = BuildsConfig.from_env(prefix, k8s_namespace) + # ActivityPub configuration + domain = os.environ.get(f"{prefix}DOMAIN", "localhost") + base_url = os.environ.get(f"{prefix}BASE_URL", "http://localhost:8080") + admin_email = os.environ.get(f"{prefix}ADMIN_EMAIL", "admin@example.com") + return cls( version=version, authenticator=authenticator, @@ -707,4 +717,7 @@ def from_env(cls, prefix: str = "") -> "Config": gitlab_url=gitlab_url, nb_config=nb_config, builds_config=builds_config, + domain=domain, + base_url=base_url, + admin_email=admin_email, ) diff --git a/components/renku_data_services/migrations/versions/20250303_01_add_activitypub_tables.py b/components/renku_data_services/migrations/versions/20250303_01_add_activitypub_tables.py new file mode 100644 index 000000000..6d4e24c98 --- /dev/null +++ b/components/renku_data_services/migrations/versions/20250303_01_add_activitypub_tables.py @@ -0,0 +1,72 @@ +"""Add ActivityPub tables. + +Revision ID: 20250303_01 +Revises: fd2117d2be29 +Create Date: 2025-03-03 13:52:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from renku_data_services.utils.sqlalchemy import ULIDType + + +# revision identifiers, used by Alembic. +revision = "20250303_01" +down_revision = "fd2117d2be29" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Upgrade database schema.""" + # Create activitypub schema + op.execute("CREATE SCHEMA IF NOT EXISTS activitypub") + + # Create actors table + op.create_table( + "actors", + sa.Column("id", ULIDType, primary_key=True), + sa.Column("username", sa.String(255), nullable=False), + sa.Column("name", sa.String(255), nullable=True), + sa.Column("summary", sa.Text, nullable=True), + sa.Column("type", sa.String(50), nullable=False), + sa.Column("user_id", sa.String, sa.ForeignKey("users.users.keycloak_id", ondelete="CASCADE"), nullable=True), + sa.Column("project_id", ULIDType, sa.ForeignKey("projects.projects.id", ondelete="CASCADE"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()), + sa.Column("private_key_pem", sa.Text, nullable=True), + sa.Column("public_key_pem", sa.Text, nullable=True), + schema="activitypub", + ) + op.create_index("ix_actors_username", "actors", ["username"], unique=True, schema="activitypub") + op.create_index("ix_actors_user_id", "actors", ["user_id"], schema="activitypub") + op.create_index("ix_actors_project_id", "actors", ["project_id"], schema="activitypub") + + # Create followers table + op.create_table( + "followers", + sa.Column("id", ULIDType, primary_key=True), + sa.Column("actor_id", ULIDType, sa.ForeignKey("activitypub.actors.id", ondelete="CASCADE"), nullable=False), + sa.Column("follower_actor_uri", sa.String(2048), nullable=False), + sa.Column("accepted", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now()), + schema="activitypub", + ) + op.create_index("ix_followers_actor_id", "followers", ["actor_id"], schema="activitypub") + op.create_index( + "ix_followers_actor_id_follower_actor_uri", + "followers", + ["actor_id", "follower_actor_uri"], + unique=True, + schema="activitypub", + ) + + +def downgrade() -> None: + """Downgrade database schema.""" + op.drop_table("followers", schema="activitypub") + op.drop_table("actors", schema="activitypub") + op.execute("DROP SCHEMA IF EXISTS activitypub") diff --git a/projects/background_jobs/pyproject.toml b/projects/background_jobs/pyproject.toml index 4a6ac6244..b5fb2be6b 100644 --- a/projects/background_jobs/pyproject.toml +++ b/projects/background_jobs/pyproject.toml @@ -42,6 +42,7 @@ packages = [ { include = "renku_data_services/notebooks", from = "../../components" }, { include = "renku_data_services/solr", from = "../../components" }, { include = "renku_data_services/search", from = "../../components" }, + { include = "renku_data_services/activitypub", from = "../../components" }, ] [tool.poetry.dependencies] diff --git a/projects/renku_data_service/pyproject.toml b/projects/renku_data_service/pyproject.toml index 102bb74e5..7481cc95e 100644 --- a/projects/renku_data_service/pyproject.toml +++ b/projects/renku_data_service/pyproject.toml @@ -40,6 +40,7 @@ packages = [ { include = "renku_data_services/migrations", from = "../../components" }, { include = "renku_data_services/solr", from = "../../components" }, { include = "renku_data_services/search", from = "../../components" }, + { include = "renku_data_services/activitypub", from = "../../components" }, ] [tool.poetry.dependencies] diff --git a/projects/secrets_storage/pyproject.toml b/projects/secrets_storage/pyproject.toml index bdc68ac25..57b989050 100644 --- a/projects/secrets_storage/pyproject.toml +++ b/projects/secrets_storage/pyproject.toml @@ -40,6 +40,7 @@ packages = [ { include = "renku_data_services/notebooks", from = "../../components" }, { include = "renku_data_services/solr", from = "../../components" }, { include = "renku_data_services/search", from = "../../components" }, + { include = "renku_data_services/activitypub", from = "../../components" }, ] [tool.poetry.dependencies] diff --git a/pyproject.toml b/pyproject.toml index af64e6f0b..8567bad8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ packages = [ { include = "renku_data_services/data_connectors", from = "components" }, { include = "renku_data_services/solr", from = "components" }, { include = "renku_data_services/search", from = "components" }, + { include = "renku_data_services/activitypub", from = "components" }, ] [tool.poetry.dependencies] diff --git a/test/components/renku_data_services/activitypub/conftest.py b/test/components/renku_data_services/activitypub/conftest.py new file mode 100644 index 000000000..b12d781a6 --- /dev/null +++ b/test/components/renku_data_services/activitypub/conftest.py @@ -0,0 +1,200 @@ +"""Common fixtures for ActivityPub tests.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession +from ulid import ULID + +from renku_data_services.activitypub import models, orm +from renku_data_services.activitypub.core import ActivityPubService +from renku_data_services.activitypub.db import ActivityPubRepository +from renku_data_services.base_models.core import APIUser, Authenticator +from renku_data_services.project.db import ProjectRepository +from renku_data_services.project.models import Project, Namespace, Visibility + + +@pytest.fixture +def mock_project(): + """Create a mock project.""" + return Project( + id=ULID(), + name="Test Project", + slug="test-project", + description="A test project for ActivityPub", + visibility=Visibility.PUBLIC, + namespace=Namespace( + id=ULID(), + slug="test-namespace", + name="Test Namespace", + kind="user", + created_by="user1", + underlying_resource_id="user1", + ), + created_by="user1", + creation_date="2025-03-03T12:00:00Z", + updated_at="2025-03-03T12:00:00Z", + documentation="Project documentation", + keywords=["test", "activitypub"], + repositories=["https://github.com/test/test-project"], + ) + + +@pytest.fixture +def mock_actor(): + """Create a mock ActivityPub actor.""" + return models.ActivityPubActor( + id=ULID(), + username="test-namespace_test-project", + name="Test Project", + summary="A test project for ActivityPub", + type=models.ActorType.PROJECT, + user_id="user1", + project_id=ULID(), + private_key_pem="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj\nMzEfYyjiWA4R4/M2bS1GB4t7NXp98C3SC6dVMvDuictGeurT8jNbvJZHtCSuYEvu\nNMoSfm76oqFvAp8Gy0iz5sxjZmSnXyCdPEovGhLa0VzMaQ8s+CLOyS56YyCFGeJZ\n-----END PRIVATE KEY-----", + public_key_pem="-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo\n4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u\n+qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWQ==\n-----END PUBLIC KEY-----", + created_at="2025-03-03T12:00:00Z", + updated_at="2025-03-03T12:00:00Z", + ) + + +@pytest.fixture +def mock_actor_orm(mock_actor): + """Create a mock ActivityPub actor ORM object.""" + actor_orm = orm.ActivityPubActorORM( + username=mock_actor.username, + name=mock_actor.name, + summary=mock_actor.summary, + type=mock_actor.type, + user_id=mock_actor.user_id, + project_id=mock_actor.project_id, + private_key_pem=mock_actor.private_key_pem, + public_key_pem=mock_actor.public_key_pem, + created_at=datetime.fromisoformat(mock_actor.created_at), + updated_at=datetime.fromisoformat(mock_actor.updated_at), + ) + # Set the id directly on the instance + actor_orm.id = mock_actor.id + return actor_orm + + +@pytest.fixture +def mock_follower(mock_actor): + """Create a mock ActivityPub follower.""" + return models.ActivityPubFollower( + id=ULID(), + actor_id=mock_actor.id, + follower_actor_uri="https://mastodon.social/users/test", + accepted=True, + created_at="2025-03-03T12:00:00Z", + updated_at="2025-03-03T12:00:00Z", + ) + + +@pytest.fixture +def mock_follower_orm(mock_actor): + """Create a mock ActivityPub follower ORM object.""" + follower_id = ULID() + follower_orm = orm.ActivityPubFollowerORM( + actor_id=mock_actor.id, + follower_actor_uri="https://mastodon.social/users/test", + accepted=True, + created_at=datetime.now(UTC).replace(microsecond=0), + updated_at=datetime.now(UTC).replace(microsecond=0), + ) + # Set the id directly on the instance + follower_orm.id = follower_id + return follower_orm + + +@pytest.fixture +def mock_session(): + """Create a mock SQLAlchemy session.""" + session = AsyncMock(spec=AsyncSession) + + # Configure the session to return results + session.execute.return_value.scalar_one_or_none.return_value = None + + return session + + +@pytest.fixture +def mock_session_maker(mock_session): + """Create a mock session maker.""" + session_maker = MagicMock() + session_maker.return_value.__aenter__.return_value = mock_session + return session_maker + + +@pytest.fixture +def mock_project_repo(mock_project): + """Create a mock project repository.""" + project_repo = AsyncMock(spec=ProjectRepository) + project_repo.get_project.return_value = mock_project + return project_repo + + +@pytest.fixture +def mock_config(): + """Create a mock ActivityPub config.""" + return models.ActivityPubConfig( + domain="example.com", + base_url="https://example.com", + admin_email="admin@example.com", + ) + + +@pytest.fixture +def mock_activity_service(mock_project_repo, mock_config): + """Create a mock ActivityPub service.""" + service = AsyncMock(spec=ActivityPubService) + + # Configure the service to return an Accept activity + activity = models.Activity( + id=f"https://example.com/ap/projects/{ULID()}/activities/{ULID()}", + type=models.ActivityType.ACCEPT, + actor=f"https://example.com/ap/projects/{ULID()}", + object={ + "type": models.ActivityType.FOLLOW, + "actor": "https://mastodon.social/users/test", + "object": f"https://example.com/ap/projects/{ULID()}", + }, + to=["https://mastodon.social/users/test"], + published="2025-03-03T12:00:00Z", + ) + service.handle_follow.return_value = activity + + # Configure the _to_dict method to return a dictionary + service._to_dict.return_value = { + "id": activity.id, + "type": activity.type, + "actor": activity.actor, + "object": activity.object, + "to": activity.to, + "published": activity.published, + } + + return service + + +@pytest.fixture +def mock_authenticator(): + """Create a mock authenticator.""" + authenticator = AsyncMock(spec=Authenticator) + + # Configure the authenticator to return a user + user = APIUser(id="user1", is_admin=False) + authenticator.authenticate.return_value = user + + return authenticator + + +@pytest.fixture +def mock_activitypub_repo(mock_session_maker, mock_project_repo, mock_config): + """Create a mock ActivityPub repository.""" + return ActivityPubRepository( + session_maker=mock_session_maker, + project_repo=mock_project_repo, + config=mock_config, + ) diff --git a/test/components/renku_data_services/activitypub/test_avatar.py b/test/components/renku_data_services/activitypub/test_avatar.py new file mode 100644 index 000000000..06dcc00fd --- /dev/null +++ b/test/components/renku_data_services/activitypub/test_avatar.py @@ -0,0 +1,78 @@ +"""Tests for ActivityPub project avatars.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest +from sanic import Sanic +from sanic.request import Request +from sanic.response import JSONResponse +from ulid import ULID + +import renku_data_services.errors as errors +from renku_data_services.activitypub import models +from renku_data_services.activitypub.blueprints import ActivityPubBP +from renku_data_services.base_models.core import APIUser + + +@pytest.mark.asyncio +async def test_project_actor_has_avatar(mock_activity_service, mock_authenticator, mock_config, mock_actor, mock_project): + """Test that the project actor has an avatar.""" + # Create the blueprint + blueprint = ActivityPubBP( + name="activitypub", + url_prefix="/api/data", + activitypub_service=mock_activity_service, + authenticator=mock_authenticator, + config=mock_config, + ) + + # Configure the mock service + project_actor = models.ProjectActor.from_project(mock_project, mock_config.base_url, mock_config.domain) + mock_activity_service.get_project_actor.return_value = project_actor + mock_activity_service._to_dict.return_value = { + "id": project_actor.id, + "type": project_actor.type, + "name": project_actor.name, + "preferredUsername": project_actor.preferredUsername, + "summary": project_actor.summary, + "content": project_actor.content, + "icon": { + "type": "Image", + "mediaType": "image/png", + "url": f"https://www.gravatar.com/avatar/{str(mock_project.id)}?d=identicon&s=256" + } + } + + # Get the route handler + _, _, handler = blueprint.get_project_actor() + + # Create a mock request with the necessary token field + request = MagicMock(spec=Request) + request.headers = {} + mock_authenticator.token_field = "Authorization" + + # Set up the user that the authenticator will return + user = APIUser(id="user1", is_admin=False) + mock_authenticator.authenticate.return_value = user + + project_id = mock_project.id + + # Call the handler + response = await handler(request, project_id) + + # Verify the response + assert response.status == 200 + assert response.headers["Content-Type"] == "application/activity+json" + + # Verify the response content + response_json = json.loads(response.body) + + # Check that the icon field exists and has the expected properties + assert "icon" in response_json + assert response_json["icon"]["type"] == "Image" + assert response_json["icon"]["mediaType"] == "image/png" + + # Check that the avatar URL is based on the project ID + expected_avatar_url = f"https://www.gravatar.com/avatar/{str(project_id)}?d=identicon&s=256" + assert response_json["icon"]["url"] == expected_avatar_url diff --git a/test/components/renku_data_services/activitypub/test_blueprints.py b/test/components/renku_data_services/activitypub/test_blueprints.py new file mode 100644 index 000000000..3633cde58 --- /dev/null +++ b/test/components/renku_data_services/activitypub/test_blueprints.py @@ -0,0 +1,197 @@ +"""Tests for ActivityPub blueprints.""" + +from unittest.mock import MagicMock, patch + +import pytest +from sanic import Sanic +from sanic.request import Request +from sanic.response import JSONResponse +from ulid import ULID + +import renku_data_services.errors as errors +from renku_data_services.activitypub import models +from renku_data_services.activitypub.blueprints import ActivityPubBP + + +@pytest.mark.asyncio +async def test_project_inbox_follow(mock_activity_service, mock_authenticator, mock_config): + """Test the project inbox endpoint with a Follow activity.""" + # Create the blueprint + blueprint = ActivityPubBP( + name="activitypub", + url_prefix="/api/data", + activitypub_service=mock_activity_service, + authenticator=mock_authenticator, + config=mock_config, + ) + + # Get the route handler + _, _, handler = blueprint.project_inbox() + + # Create a mock request + project_id = ULID() + request = MagicMock(spec=Request) + request.json = { + "type": "Follow", + "actor": "https://mastodon.social/users/test", + "object": f"https://example.com/ap/projects/{project_id}", + } + + # Call the handler + response = await handler(request, project_id) + + # Verify the response + assert response.status == 200 + + # Verify the service was called correctly + mock_activity_service.handle_follow.assert_called_once() + args, kwargs = mock_activity_service.handle_follow.call_args + assert kwargs["user"].is_admin is True # Should use an admin user + assert kwargs["project_id"] == project_id + assert kwargs["follower_actor_uri"] == "https://mastodon.social/users/test" + + +@pytest.mark.asyncio +async def test_project_inbox_undo_follow(mock_activity_service, mock_authenticator, mock_config): + """Test the project inbox endpoint with an Undo of a Follow activity.""" + # Create the blueprint + blueprint = ActivityPubBP( + name="activitypub", + url_prefix="/api/data", + activitypub_service=mock_activity_service, + authenticator=mock_authenticator, + config=mock_config, + ) + + # Get the route handler + _, _, handler = blueprint.project_inbox() + + # Create a mock request + project_id = ULID() + request = MagicMock(spec=Request) + request.json = { + "type": "Undo", + "actor": "https://mastodon.social/users/test", + "object": { + "type": "Follow", + "actor": "https://mastodon.social/users/test", + "object": f"https://example.com/ap/projects/{project_id}", + }, + } + + # Call the handler + response = await handler(request, project_id) + + # Verify the response + assert response.status == 200 + + # Verify the service was called correctly + mock_activity_service.handle_unfollow.assert_called_once() + args, kwargs = mock_activity_service.handle_unfollow.call_args + assert kwargs["user"].is_admin is True # Should use an admin user + assert kwargs["project_id"] == project_id + assert kwargs["follower_actor_uri"] == "https://mastodon.social/users/test" + + +@pytest.mark.asyncio +async def test_project_inbox_empty_request(mock_activity_service, mock_authenticator, mock_config): + """Test the project inbox endpoint with an empty request.""" + # Create the blueprint + blueprint = ActivityPubBP( + name="activitypub", + url_prefix="/api/data", + activitypub_service=mock_activity_service, + authenticator=mock_authenticator, + config=mock_config, + ) + + # Get the route handler + _, _, handler = blueprint.project_inbox() + + # Create a mock request + project_id = ULID() + request = MagicMock(spec=Request) + request.json = None + + # Call the handler + response = await handler(request, project_id) + + # Verify the response + assert response.status == 400 + assert response.json["error"] == "invalid_request" + + # Verify the service was not called + mock_activity_service.handle_follow.assert_not_called() + mock_activity_service.handle_unfollow.assert_not_called() + + +@pytest.mark.asyncio +async def test_project_inbox_missing_actor(mock_activity_service, mock_authenticator, mock_config): + """Test the project inbox endpoint with a Follow activity missing the actor.""" + # Create the blueprint + blueprint = ActivityPubBP( + name="activitypub", + url_prefix="/api/data", + activitypub_service=mock_activity_service, + authenticator=mock_authenticator, + config=mock_config, + ) + + # Get the route handler + _, _, handler = blueprint.project_inbox() + + # Create a mock request + project_id = ULID() + request = MagicMock(spec=Request) + request.json = { + "type": "Follow", + "object": f"https://example.com/ap/projects/{project_id}", + } + + # Call the handler + response = await handler(request, project_id) + + # Verify the response + assert response.status == 400 + assert response.json["error"] == "invalid_request" + + # Verify the service was not called + mock_activity_service.handle_follow.assert_not_called() + + +@pytest.mark.asyncio +async def test_project_inbox_missing_project(mock_activity_service, mock_authenticator, mock_config): + """Test the project inbox endpoint with a Follow activity for a missing project.""" + # Create the blueprint + blueprint = ActivityPubBP( + name="activitypub", + url_prefix="/api/data", + activitypub_service=mock_activity_service, + authenticator=mock_authenticator, + config=mock_config, + ) + + # Configure the service to raise an exception + mock_activity_service.handle_follow.side_effect = errors.MissingResourceError(message="Project not found") + + # Get the route handler + _, _, handler = blueprint.project_inbox() + + # Create a mock request + project_id = ULID() + request = MagicMock(spec=Request) + request.json = { + "type": "Follow", + "actor": "https://mastodon.social/users/test", + "object": f"https://example.com/ap/projects/{project_id}", + } + + # Call the handler + response = await handler(request, project_id) + + # Verify the response + assert response.status == 404 + assert response.json["error"] == "not_found" + + # Verify the service was called + mock_activity_service.handle_follow.assert_called_once() diff --git a/test/components/renku_data_services/activitypub/test_core.py b/test/components/renku_data_services/activitypub/test_core.py new file mode 100644 index 000000000..4e9eca803 --- /dev/null +++ b/test/components/renku_data_services/activitypub/test_core.py @@ -0,0 +1,153 @@ +"""Tests for ActivityPub core functionality.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from ulid import ULID + +import renku_data_services.errors as errors +from renku_data_services.activitypub import models +from renku_data_services.activitypub.core import ActivityPubService +from renku_data_services.activitypub.db import ActivityPubRepository +from renku_data_services.base_models.core import APIUser +from renku_data_services.project.db import ProjectRepository + + +@pytest.mark.asyncio +async def test_handle_follow(mock_project, mock_actor): + """Test handling a follow request.""" + # Create mocks + activitypub_repo = AsyncMock(spec=ActivityPubRepository) + project_repo = AsyncMock(spec=ProjectRepository) + config = models.ActivityPubConfig( + domain="example.com", + base_url="https://example.com", + admin_email="admin@example.com", + ) + + # Configure mocks + project_repo.get_project.return_value = mock_project + activitypub_repo.get_or_create_project_actor.return_value = mock_actor + activitypub_repo.add_follower.return_value = models.ActivityPubFollower( + id=ULID(), + actor_id=mock_actor.id, + follower_actor_uri="https://mastodon.social/users/test", + accepted=True, + created_at="2025-03-03T12:00:00Z", + updated_at="2025-03-03T12:00:00Z", + ) + + # Create service + service = ActivityPubService( + activitypub_repo=activitypub_repo, + project_repo=project_repo, + config=config, + ) + + # Mock the _deliver_activity method + service._deliver_activity = AsyncMock() + + # Create test data + user = APIUser(id="user1", is_admin=False) + project_id = ULID() + follower_actor_uri = "https://mastodon.social/users/test" + + # Call the method + result = await service.handle_follow(user=user, project_id=project_id, follower_actor_uri=follower_actor_uri) + + # Verify the result + assert result is not None + assert result.type == models.ActivityType.ACCEPT + assert result.actor == f"{config.base_url}/ap/projects/{project_id}" + assert result.to == [follower_actor_uri] + + # Verify the mocks were called correctly + activitypub_repo.get_or_create_project_actor.assert_called_once_with(user=user, project_id=project_id) + activitypub_repo.add_follower.assert_called_once() + follower = activitypub_repo.add_follower.call_args[0][0] + assert follower.actor_id == mock_actor.id + assert follower.follower_actor_uri == follower_actor_uri + assert follower.accepted is True + + # Verify the delivery was attempted + service._deliver_activity.assert_called_once() + actor_arg, activity_arg, inbox_url_arg = service._deliver_activity.call_args[0] + assert actor_arg == mock_actor + assert activity_arg.type == models.ActivityType.ACCEPT + assert inbox_url_arg == follower_actor_uri + "/inbox" + + +@pytest.mark.asyncio +async def test_handle_follow_missing_project(): + """Test handling a follow request for a missing project.""" + # Create mocks + activitypub_repo = AsyncMock(spec=ActivityPubRepository) + project_repo = AsyncMock(spec=ProjectRepository) + config = models.ActivityPubConfig( + domain="example.com", + base_url="https://example.com", + admin_email="admin@example.com", + ) + + # Configure mocks + activitypub_repo.get_or_create_project_actor.side_effect = errors.MissingResourceError(message="Project not found") + + # Create service + service = ActivityPubService( + activitypub_repo=activitypub_repo, + project_repo=project_repo, + config=config, + ) + + # Mock the _deliver_activity method to avoid the error + service._deliver_activity = AsyncMock() + + # Create test data + user = APIUser(id="user1", is_admin=False) + project_id = ULID() + follower_actor_uri = "https://mastodon.social/users/test" + + # Call the method and verify it raises the expected exception + with pytest.raises(errors.MissingResourceError, match="Project not found"): + await service.handle_follow(user=user, project_id=project_id, follower_actor_uri=follower_actor_uri) + + # Verify the mocks were called correctly + activitypub_repo.get_or_create_project_actor.assert_called_once_with(user=user, project_id=project_id) + activitypub_repo.add_follower.assert_not_called() + + +@pytest.mark.asyncio +async def test_handle_unfollow(mock_actor): + """Test handling an unfollow request.""" + # Create mocks + activitypub_repo = AsyncMock(spec=ActivityPubRepository) + project_repo = AsyncMock(spec=ProjectRepository) + config = models.ActivityPubConfig( + domain="example.com", + base_url="https://example.com", + admin_email="admin@example.com", + ) + + # Configure mocks + activitypub_repo.get_or_create_project_actor.return_value = mock_actor + + # Create service + service = ActivityPubService( + activitypub_repo=activitypub_repo, + project_repo=project_repo, + config=config, + ) + + # Create test data + user = APIUser(id="user1", is_admin=False) + project_id = ULID() + follower_actor_uri = "https://mastodon.social/users/test" + + # Call the method + await service.handle_unfollow(user=user, project_id=project_id, follower_actor_uri=follower_actor_uri) + + # Verify the mocks were called correctly + activitypub_repo.get_or_create_project_actor.assert_called_once_with(user=user, project_id=project_id) + activitypub_repo.remove_follower.assert_called_once_with( + actor_id=mock_actor.id, follower_actor_uri=follower_actor_uri + ) diff --git a/test/components/renku_data_services/activitypub/test_db.py b/test/components/renku_data_services/activitypub/test_db.py new file mode 100644 index 000000000..3a297ef4c --- /dev/null +++ b/test/components/renku_data_services/activitypub/test_db.py @@ -0,0 +1,187 @@ +"""Tests for ActivityPub database repository.""" + +from datetime import UTC, datetime + +import pytest +from ulid import ULID + +import renku_data_services.errors as errors +from renku_data_services.activitypub import models, orm + + +@pytest.mark.asyncio +async def test_add_follower(mock_session, mock_session_maker, mock_project_repo, mock_config, mock_actor, mock_actor_orm): + """Test adding a follower to an actor.""" + # Configure the session to return an actor + mock_session.execute.return_value.scalar_one_or_none.side_effect = [ + # First call: check if actor exists + mock_actor_orm, + # Second call: check if follower exists + None, + ] + + # Create a follower + follower = models.UnsavedActivityPubFollower( + actor_id=mock_actor.id, + follower_actor_uri="https://mastodon.social/users/test", + accepted=True, + ) + + # Add the follower + result = await mock_activitypub_repo.add_follower(follower, session=mock_session) + + # Verify the result + assert result is not None + assert result.actor_id == follower.actor_id + assert result.follower_actor_uri == follower.follower_actor_uri + assert result.accepted == follower.accepted + + # Verify the session was used correctly + mock_session.add.assert_called_once() + added_follower = mock_session.add.call_args[0][0] + assert isinstance(added_follower, orm.ActivityPubFollowerORM) + assert added_follower.actor_id == follower.actor_id + assert added_follower.follower_actor_uri == follower.follower_actor_uri + assert added_follower.accepted == follower.accepted + + mock_session.flush.assert_called_once() + mock_session.refresh.assert_called_once_with(added_follower) + + +@pytest.mark.asyncio +async def test_add_follower_actor_not_found(mock_session, mock_activitypub_repo): + """Test adding a follower to a non-existent actor.""" + # Configure the session to return no actor + mock_session.execute.return_value.scalar_one_or_none.return_value = None + + # Create a follower + actor_id = ULID() + follower = models.UnsavedActivityPubFollower( + actor_id=actor_id, + follower_actor_uri="https://mastodon.social/users/test", + accepted=True, + ) + + # Add the follower and verify it raises an exception + with pytest.raises(errors.MissingResourceError, match=f"Actor with id '{actor_id}' does not exist."): + await mock_activitypub_repo.add_follower(follower, session=mock_session) + + # Verify the session was used correctly + mock_session.add.assert_not_called() + mock_session.flush.assert_not_called() + mock_session.refresh.assert_not_called() + + +@pytest.mark.asyncio +async def test_add_follower_already_exists(mock_session, mock_activitypub_repo, mock_actor, mock_actor_orm, mock_follower_orm): + """Test adding a follower that already exists.""" + # Configure the session to return an actor and an existing follower + mock_session.execute.return_value.scalar_one_or_none.side_effect = [ + # First call: check if actor exists + mock_actor_orm, + # Second call: check if follower exists + mock_follower_orm, + ] + + # Create a follower + follower = models.UnsavedActivityPubFollower( + actor_id=mock_actor.id, + follower_actor_uri=mock_follower_orm.follower_actor_uri, + accepted=mock_follower_orm.accepted, + ) + + # Add the follower + result = await mock_activitypub_repo.add_follower(follower, session=mock_session) + + # Verify the result + assert result is not None + assert result.id == mock_follower_orm.id + assert result.actor_id == follower.actor_id + assert result.follower_actor_uri == follower.follower_actor_uri + assert result.accepted == follower.accepted + + # Verify the session was used correctly + mock_session.add.assert_not_called() + mock_session.flush.assert_not_called() + mock_session.refresh.assert_not_called() + + +@pytest.mark.asyncio +async def test_add_follower_update_acceptance(mock_session, mock_activitypub_repo, mock_actor, mock_actor_orm): + """Test updating a follower's acceptance status.""" + # Create a follower + follower_id = ULID() + follower_uri = "https://mastodon.social/users/test" + existing_follower = orm.ActivityPubFollowerORM( + id=follower_id, + actor_id=mock_actor.id, + follower_actor_uri=follower_uri, + accepted=False, # Initially not accepted + created_at=datetime.now(UTC).replace(microsecond=0), + updated_at=datetime.now(UTC).replace(microsecond=0), + ) + + # Configure the session to return an actor and an existing follower + mock_session.execute.return_value.scalar_one_or_none.side_effect = [ + # First call: check if actor exists + mock_actor_orm, + # Second call: check if follower exists + existing_follower, + ] + + # Create a follower with accepted=True + follower = models.UnsavedActivityPubFollower( + actor_id=mock_actor.id, + follower_actor_uri=follower_uri, + accepted=True, # Now accepted + ) + + # Add the follower + result = await mock_activitypub_repo.add_follower(follower, session=mock_session) + + # Verify the result + assert result is not None + assert result.id == follower_id + assert result.actor_id == follower.actor_id + assert result.follower_actor_uri == follower.follower_actor_uri + assert result.accepted == follower.accepted # Should be updated to True + + # Verify the existing follower was updated + assert existing_follower.accepted is True + + # Verify the session was used correctly + mock_session.add.assert_not_called() + mock_session.flush.assert_called_once() + mock_session.refresh.assert_called_once_with(existing_follower) + + +@pytest.mark.asyncio +async def test_remove_follower(mock_session, mock_activitypub_repo, mock_actor, mock_follower_orm): + """Test removing a follower.""" + # Configure the session to return an existing follower + mock_session.execute.return_value.scalar_one_or_none.return_value = mock_follower_orm + + # Remove the follower + await mock_activitypub_repo.remove_follower( + actor_id=mock_actor.id, + follower_actor_uri=mock_follower_orm.follower_actor_uri, + session=mock_session + ) + + # Verify the session was used correctly + mock_session.delete.assert_called_once_with(mock_follower_orm) + + +@pytest.mark.asyncio +async def test_remove_follower_not_found(mock_session, mock_activitypub_repo): + """Test removing a non-existent follower.""" + # Configure the session to return no follower + mock_session.execute.return_value.scalar_one_or_none.return_value = None + + # Remove the follower + actor_id = ULID() + follower_uri = "https://mastodon.social/users/test" + await mock_activitypub_repo.remove_follower(actor_id=actor_id, follower_actor_uri=follower_uri, session=mock_session) + + # Verify the session was used correctly + mock_session.delete.assert_not_called() diff --git a/test/components/renku_data_services/activitypub/test_integration.py b/test/components/renku_data_services/activitypub/test_integration.py new file mode 100644 index 000000000..2bba492fb --- /dev/null +++ b/test/components/renku_data_services/activitypub/test_integration.py @@ -0,0 +1,268 @@ +"""Integration tests for ActivityPub.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sanic import Sanic +from sanic.request import Request +from sanic.response import JSONResponse +from ulid import ULID + +import renku_data_services.errors as errors +from renku_data_services.activitypub import models +from renku_data_services.activitypub.blueprints import ActivityPubBP +from renku_data_services.activitypub.core import ActivityPubService +from renku_data_services.activitypub.db import ActivityPubRepository +from renku_data_services.base_models.core import APIUser + + +@pytest.mark.asyncio +async def test_follow_project_flow( + mock_session, mock_session_maker, mock_project_repo, mock_config, mock_actor, mock_actor_orm +): + """Test the full flow of following a project.""" + # Configure the session to return an actor + mock_session.execute.return_value.scalar_one_or_none.side_effect = [ + # First call: check if actor exists + mock_actor_orm, + # Second call: check if follower exists + None, + ] + + # Create the repository + repo = ActivityPubRepository( + session_maker=mock_session_maker, + project_repo=mock_project_repo, + config=mock_config, + ) + + # Create the service + service = ActivityPubService( + activitypub_repo=repo, + project_repo=mock_project_repo, + config=mock_config, + ) + + # Mock the _deliver_activity method + service._deliver_activity = AsyncMock() + + # Create the blueprint + blueprint = ActivityPubBP( + name="activitypub", + url_prefix="/api/data", + activitypub_service=service, + authenticator=MagicMock(), + config=mock_config, + ) + + # Get the route handler + _, _, handler = blueprint.project_inbox() + + # Create a mock request + project_id = ULID() + request = MagicMock(spec=Request) + request.json = { + "type": "Follow", + "actor": "https://mastodon.social/users/test", + "object": f"https://example.com/ap/projects/{project_id}", + } + + # Call the handler + response = await handler(request, project_id) + + # Verify the response + assert response.status == 200 + + # Verify the follower was added + mock_session.add.assert_called_once() + added_follower = mock_session.add.call_args[0][0] + assert added_follower.actor_id == mock_actor.id + assert added_follower.follower_actor_uri == "https://mastodon.social/users/test" + assert added_follower.accepted is True + + # Verify the delivery was attempted + service._deliver_activity.assert_called_once() + actor_arg, activity_arg, inbox_url_arg = service._deliver_activity.call_args[0] + assert actor_arg == mock_actor + assert activity_arg.type == models.ActivityType.ACCEPT + assert inbox_url_arg == "https://mastodon.social/users/test/inbox" + + +@pytest.mark.asyncio +async def test_unfollow_project_flow( + mock_session, mock_session_maker, mock_project_repo, mock_config, mock_actor, mock_actor_orm, mock_follower_orm +): + """Test the full flow of unfollowing a project.""" + # Configure the session to return an actor and a follower + mock_session.execute.return_value.scalar_one_or_none.side_effect = [ + # First call: get_or_create_project_actor + mock_actor_orm, + # Second call: remove_follower + mock_follower_orm, + ] + + # Create the repository + repo = ActivityPubRepository( + session_maker=mock_session_maker, + project_repo=mock_project_repo, + config=mock_config, + ) + + # Create the service + service = ActivityPubService( + activitypub_repo=repo, + project_repo=mock_project_repo, + config=mock_config, + ) + + # Create the blueprint + blueprint = ActivityPubBP( + name="activitypub", + url_prefix="/api/data", + activitypub_service=service, + authenticator=MagicMock(), + config=mock_config, + ) + + # Get the route handler + _, _, handler = blueprint.project_inbox() + + # Create a mock request + project_id = ULID() + request = MagicMock(spec=Request) + request.json = { + "type": "Undo", + "actor": "https://mastodon.social/users/test", + "object": { + "type": "Follow", + "actor": "https://mastodon.social/users/test", + "object": f"https://example.com/ap/projects/{project_id}", + }, + } + + # Call the handler + response = await handler(request, project_id) + + # Verify the response + assert response.status == 200 + + # Verify the follower was removed + mock_session.delete.assert_called_once_with(mock_follower_orm) + + +@pytest.mark.asyncio +async def test_get_project_actor( + mock_session, mock_session_maker, mock_project_repo, mock_config, mock_actor, mock_actor_orm, mock_project +): + """Test getting a project actor.""" + # Configure the session to return an actor + mock_session.execute.return_value.scalar_one_or_none.return_value = mock_actor_orm + + # Create the repository + repo = ActivityPubRepository( + session_maker=mock_session_maker, + project_repo=mock_project_repo, + config=mock_config, + ) + + # Create the service + service = ActivityPubService( + activitypub_repo=repo, + project_repo=mock_project_repo, + config=mock_config, + ) + + # Create the blueprint + blueprint = ActivityPubBP( + name="activitypub", + url_prefix="/api/data", + activitypub_service=service, + authenticator=MagicMock(), + config=mock_config, + ) + + # Get the route handler + _, _, handler = blueprint.get_project_actor() + + # Create a mock request + project_id = mock_project.id + request = MagicMock(spec=Request) + user = APIUser(id="user1", is_admin=False) + + # Call the handler + response = await handler(request, user, project_id) + + # Verify the response + assert response.status == 200 + assert response.headers["Content-Type"] == "application/activity+json" + + # Verify the response content + response_json = json.loads(response.body) + assert response_json["id"] == f"{mock_config.base_url}/ap/projects/{project_id}" + assert response_json["type"] == "Project" + assert response_json["name"] == mock_project.name + assert response_json["preferredUsername"] == mock_actor.username + assert response_json["summary"] == mock_project.description + assert response_json["inbox"] == f"{mock_config.base_url}/ap/projects/{project_id}/inbox" + assert response_json["outbox"] == f"{mock_config.base_url}/ap/projects/{project_id}/outbox" + assert response_json["followers"] == f"{mock_config.base_url}/ap/projects/{project_id}/followers" + assert response_json["following"] == f"{mock_config.base_url}/ap/projects/{project_id}/following" + assert response_json["publicKey"]["id"] == f"{mock_config.base_url}/ap/projects/{project_id}#main-key" + assert response_json["publicKey"]["owner"] == f"{mock_config.base_url}/ap/projects/{project_id}" + assert response_json["publicKey"]["publicKeyPem"] == mock_actor.public_key_pem + + +@pytest.mark.asyncio +async def test_get_project_followers( + mock_session, mock_session_maker, mock_project_repo, mock_config, mock_actor, mock_actor_orm, mock_follower +): + """Test getting project followers.""" + # Configure the session to return an actor and followers + mock_session.execute.return_value.scalar_one_or_none.return_value = mock_actor_orm + mock_session.execute.return_value.scalars.return_value.all.return_value = [ + mock_follower_orm for mock_follower_orm in [mock_follower] + ] + + # Create the repository + repo = ActivityPubRepository( + session_maker=mock_session_maker, + project_repo=mock_project_repo, + config=mock_config, + ) + + # Create the service + service = ActivityPubService( + activitypub_repo=repo, + project_repo=mock_project_repo, + config=mock_config, + ) + + # Create the blueprint + blueprint = ActivityPubBP( + name="activitypub", + url_prefix="/api/data", + activitypub_service=service, + authenticator=MagicMock(), + config=mock_config, + ) + + # Get the route handler + _, _, handler = blueprint.get_project_followers() + + # Create a mock request + project_id = ULID() + request = MagicMock(spec=Request) + user = APIUser(id="user1", is_admin=False) + + # Call the handler + response = await handler(request, user, project_id) + + # Verify the response + assert response.status == 200 + + # Verify the response content + response_json = json.loads(response.body) + assert "followers" in response_json + assert len(response_json["followers"]) == 1 + assert response_json["followers"][0] == mock_follower.follower_actor_uri diff --git a/test/components/renku_data_services/activitypub/test_serialization.py b/test/components/renku_data_services/activitypub/test_serialization.py new file mode 100644 index 000000000..2dbc8705d --- /dev/null +++ b/test/components/renku_data_services/activitypub/test_serialization.py @@ -0,0 +1,191 @@ +"""Tests for ActivityPub serialization functionality.""" + +from datetime import UTC, datetime +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +import pytest +from ulid import ULID + +from renku_data_services.activitypub.core import ActivityPubService +from renku_data_services.activitypub.db import ActivityPubRepository +from renku_data_services.activitypub import models + + +@dataclass +class TestDataclass: + """Test dataclass for serialization tests.""" + + name: str + value: int + nested: Optional[Dict[str, Any]] = None + context: Optional[str] = None + + +@pytest.fixture +def service(mock_project_repo, mock_config): + """Create an ActivityPub service for testing.""" + activitypub_repo = ActivityPubRepository( + session_maker=lambda: None, + project_repo=mock_project_repo, + config=mock_config, + ) + return ActivityPubService( + activitypub_repo=activitypub_repo, + project_repo=mock_project_repo, + config=mock_config, + ) + + +def test_to_dict_handles_basic_types(service): + """Test that _to_dict correctly handles basic Python types.""" + # Test with a string + assert service._to_dict("test") == "test" + + # Test with an integer + assert service._to_dict(42) == 42 + + # Test with a float + assert service._to_dict(3.14) == 3.14 + + # Test with a boolean + assert service._to_dict(True) is True + assert service._to_dict(False) is False + + # Test with None + assert service._to_dict(None) is None + + +def test_to_dict_handles_complex_types(service): + """Test that _to_dict correctly handles complex Python types.""" + # Test with a list + assert service._to_dict([1, 2, 3]) == [1, 2, 3] + + # Test with a dictionary + assert service._to_dict({"a": 1, "b": 2}) == {"a": 1, "b": 2} + + # Test with a nested structure + complex_obj = { + "name": "test", + "values": [1, 2, 3], + "nested": { + "a": True, + "b": None, + } + } + assert service._to_dict(complex_obj) == complex_obj + + +def test_to_dict_handles_datetime(service): + """Test that _to_dict correctly handles datetime objects.""" + # Create a datetime object + dt = datetime(2025, 3, 20, 12, 34, 56, tzinfo=UTC) + + # Convert to dict + result = service._to_dict(dt) + + # Verify the result is an ISO-formatted string + assert isinstance(result, str) + assert result == "2025-03-20T12:34:56+00:00" + + +def test_to_dict_handles_ulid(service): + """Test that _to_dict correctly handles ULID objects.""" + # Create a ULID object + ulid = ULID() + + # Convert to dict + result = service._to_dict(ulid) + + # Verify the result is a string representation of the ULID + assert isinstance(result, str) + assert result == str(ulid) + + +def test_to_dict_handles_dataclasses(service): + """Test that _to_dict correctly handles dataclass objects.""" + # Create a dataclass instance + obj = TestDataclass( + name="test", + value=42, + nested={"a": 1, "b": 2}, + ) + + # Convert to dict + result = service._to_dict(obj) + + # Verify the result + assert isinstance(result, dict) + assert result["name"] == "test" + assert result["value"] == 42 + assert result["nested"] == {"a": 1, "b": 2} + assert "context" not in result # None values should be skipped + + +def test_to_dict_handles_context_field(service): + """Test that _to_dict correctly handles the special 'context' field in dataclasses.""" + # Create a dataclass instance with a context field + obj = TestDataclass( + name="test", + value=42, + context="https://www.w3.org/ns/activitystreams", + ) + + # Convert to dict + result = service._to_dict(obj) + + # Verify the result + assert isinstance(result, dict) + assert result["name"] == "test" + assert result["value"] == 42 + assert "@context" in result # context should be converted to @context + assert result["@context"] == "https://www.w3.org/ns/activitystreams" + + +def test_to_dict_handles_activity_objects(service): + """Test that _to_dict correctly handles Activity objects.""" + # Create an Activity object + activity = models.Activity( + id="https://example.com/activities/1", + type=models.ActivityType.ACCEPT, + actor="https://example.com/users/1", + object={ + "type": models.ActivityType.FOLLOW, + "actor": "https://mastodon.social/users/test", + "object": "https://example.com/projects/1", + }, + to=["https://mastodon.social/users/test"], + published=datetime.now(UTC).isoformat(), + ) + + # Convert to dict + result = service._to_dict(activity) + + # Verify the result + assert isinstance(result, dict) + assert result["id"] == activity.id + assert result["type"] == activity.type + assert result["actor"] == activity.actor + assert isinstance(result["object"], dict) + assert result["object"]["type"] == models.ActivityType.FOLLOW + assert isinstance(result["to"], list) + assert result["to"][0] == "https://mastodon.social/users/test" + assert isinstance(result["published"], str) + + +def test_to_dict_handles_unknown_types(service): + """Test that _to_dict correctly handles unknown types by converting them to strings.""" + # Create a custom class + class CustomClass: + def __str__(self): + return "CustomClass" + + # Create an instance + obj = CustomClass() + + # Convert to dict + result = service._to_dict(obj) + + # Verify the result is a string + assert isinstance(result, str) + assert result == "CustomClass" diff --git a/test/components/renku_data_services/activitypub/test_url_prefix.py b/test/components/renku_data_services/activitypub/test_url_prefix.py new file mode 100644 index 000000000..a3df48241 --- /dev/null +++ b/test/components/renku_data_services/activitypub/test_url_prefix.py @@ -0,0 +1,289 @@ +"""Tests for URL prefix in ActivityPub.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sanic import Sanic +from sanic.request import Request +from sanic.response import JSONResponse +from ulid import ULID + +import renku_data_services.errors as errors +from renku_data_services.activitypub import models +from renku_data_services.activitypub.blueprints import ActivityPubBP +from renku_data_services.activitypub.core import ActivityPubService +from renku_data_services.activitypub.db import ActivityPubRepository +from renku_data_services.base_models.core import APIUser + + +@pytest.mark.asyncio +async def test_url_prefix_in_actor_urls( + mock_session, mock_session_maker, mock_project_repo, mock_actor, mock_actor_orm, mock_project +): + """Test that the URL prefix is correctly included in the actor URLs.""" + # Configure the session to return an actor + mock_session.execute.return_value.scalar_one_or_none.return_value = mock_actor_orm + + # Create a config with a URL prefix + config = models.ActivityPubConfig( + domain="example.com", + base_url="https://example.com/api/data", # Include the URL prefix + admin_email="admin@example.com", + ) + + # Create the repository + repo = ActivityPubRepository( + session_maker=mock_session_maker, + project_repo=mock_project_repo, + config=config, + ) + + # Create the service + service = ActivityPubService( + activitypub_repo=repo, + project_repo=mock_project_repo, + config=config, + ) + + # Create the blueprint + blueprint = ActivityPubBP( + name="activitypub", + url_prefix="/api/data", + activitypub_service=service, + authenticator=MagicMock(), + config=config, + ) + + # Get the route handler + _, _, handler = blueprint.get_project_actor() + + # Create a mock request + project_id = mock_project.id + request = MagicMock(spec=Request) + user = APIUser(id="user1", is_admin=False) + + # Call the handler + response = await handler(request, user, project_id) + + # Verify the response + assert response.status == 200 + assert response.headers["Content-Type"] == "application/activity+json" + + # Verify the response content + response_json = json.loads(response.body) + + # Check that all URLs include the URL prefix + assert response_json["id"] == f"https://example.com/api/data/ap/projects/{project_id}" + assert response_json["inbox"] == f"https://example.com/api/data/ap/projects/{project_id}/inbox" + assert response_json["outbox"] == f"https://example.com/api/data/ap/projects/{project_id}/outbox" + assert response_json["followers"] == f"https://example.com/api/data/ap/projects/{project_id}/followers" + assert response_json["following"] == f"https://example.com/api/data/ap/projects/{project_id}/following" + assert response_json["publicKey"]["id"] == f"https://example.com/api/data/ap/projects/{project_id}#main-key" + assert response_json["publicKey"]["owner"] == f"https://example.com/api/data/ap/projects/{project_id}" + + # Check that the URL to the project page includes the URL prefix + assert response_json["url"] == f"https://example.com/api/data/projects/{mock_project.namespace.slug}/{mock_project.slug}" + + +@pytest.mark.asyncio +async def test_url_prefix_in_webfinger_response( + mock_session, mock_session_maker, mock_project_repo, mock_actor, mock_actor_orm, mock_project +): + """Test that the URL prefix is correctly included in the WebFinger response.""" + # Configure the session to return an actor + mock_session.execute.return_value.scalar_one_or_none.return_value = mock_actor_orm + + # Create a config with a URL prefix + config = models.ActivityPubConfig( + domain="example.com", + base_url="https://example.com/api/data", # Include the URL prefix + admin_email="admin@example.com", + ) + + # Create the repository + repo = ActivityPubRepository( + session_maker=mock_session_maker, + project_repo=mock_project_repo, + config=config, + ) + + # Create the service + service = ActivityPubService( + activitypub_repo=repo, + project_repo=mock_project_repo, + config=config, + ) + + # Mock the get_project_actor_by_username method + service.get_project_actor_by_username = AsyncMock() + project_actor = models.ProjectActor( + id=f"https://example.com/api/data/ap/projects/{mock_project.id}", + name=mock_project.name, + preferredUsername=mock_actor.username, + summary=mock_project.description, + content=mock_project.description, + documentation=mock_project.documentation, + attributedTo=f"https://example.com/api/data/ap/users/{mock_project.created_by}", + to=["https://www.w3.org/ns/activitystreams#Public"], + url=f"https://example.com/api/data/projects/{mock_project.namespace.slug}/{mock_project.slug}", + published=mock_project.creation_date, + updated=mock_project.updated_at, + inbox=f"https://example.com/api/data/ap/projects/{mock_project.id}/inbox", + outbox=f"https://example.com/api/data/ap/projects/{mock_project.id}/outbox", + followers=f"https://example.com/api/data/ap/projects/{mock_project.id}/followers", + following=f"https://example.com/api/data/ap/projects/{mock_project.id}/following", + publicKey={ + "id": f"https://example.com/api/data/ap/projects/{mock_project.id}#main-key", + "owner": f"https://example.com/api/data/ap/projects/{mock_project.id}", + "publicKeyPem": mock_actor.public_key_pem, + }, + keywords=mock_project.keywords, + repositories=mock_project.repositories, + visibility=mock_project.visibility.value, + created_by=mock_project.created_by, + creation_date=mock_project.creation_date, + updated_at=mock_project.updated_at, + type=models.ActorType.PROJECT, + ) + service.get_project_actor_by_username.return_value = project_actor + + # Create the blueprint + blueprint = ActivityPubBP( + name="activitypub", + url_prefix="/api/data", + activitypub_service=service, + authenticator=MagicMock(), + config=config, + ) + + # Get the route handler + _, _, handler = blueprint.webfinger() + + # Create a mock request + request = MagicMock(spec=Request) + request.args = {"resource": f"acct:{mock_actor.username}@example.com"} + + # Call the handler + response = await handler(request) + + # Verify the response + assert response.status == 200 + assert response.headers["Content-Type"] == "application/jrd+json" + + # Verify the response content + response_json = json.loads(response.body) + + # Check that the URLs include the URL prefix + assert response_json["aliases"] == [f"https://example.com/api/data/ap/projects/{mock_project.id}"] + assert response_json["links"][0]["href"] == f"https://example.com/api/data/ap/projects/{mock_project.id}" + + +@pytest.mark.asyncio +async def test_url_prefix_in_host_meta_response( + mock_session, mock_session_maker, mock_project_repo, mock_actor, mock_actor_orm, mock_project +): + """Test that the URL prefix is correctly included in the host-meta response.""" + # Create a config with a URL prefix + config = models.ActivityPubConfig( + domain="example.com", + base_url="https://example.com/api/data", # Include the URL prefix + admin_email="admin@example.com", + ) + + # Create the repository + repo = ActivityPubRepository( + session_maker=mock_session_maker, + project_repo=mock_project_repo, + config=config, + ) + + # Create the service + service = ActivityPubService( + activitypub_repo=repo, + project_repo=mock_project_repo, + config=config, + ) + + # Create the blueprint + blueprint = ActivityPubBP( + name="activitypub", + url_prefix="/api/data", + activitypub_service=service, + authenticator=MagicMock(), + config=config, + ) + + # Get the route handler + _, _, handler = blueprint.host_meta() + + # Create a mock request + request = MagicMock(spec=Request) + + # Call the handler + response = await handler(request) + + # Verify the response + assert response.status == 200 + assert response.headers["Content-Type"] == "application/xrd+xml" + + # Verify the response content + response_text = response.body.decode("utf-8") + + # Check that the URL includes the URL prefix + assert f'template="https://example.com/api/data/ap/webfinger?resource={{uri}}"' in response_text + + +@pytest.mark.asyncio +async def test_url_prefix_in_nodeinfo_response( + mock_session, mock_session_maker, mock_project_repo, mock_actor, mock_actor_orm, mock_project +): + """Test that the URL prefix is correctly included in the nodeinfo response.""" + # Create a config with a URL prefix + config = models.ActivityPubConfig( + domain="example.com", + base_url="https://example.com/api/data", # Include the URL prefix + admin_email="admin@example.com", + ) + + # Create the repository + repo = ActivityPubRepository( + session_maker=mock_session_maker, + project_repo=mock_project_repo, + config=config, + ) + + # Create the service + service = ActivityPubService( + activitypub_repo=repo, + project_repo=mock_project_repo, + config=config, + ) + + # Create the blueprint + blueprint = ActivityPubBP( + name="activitypub", + url_prefix="/api/data", + activitypub_service=service, + authenticator=MagicMock(), + config=config, + ) + + # Get the route handler + _, _, handler = blueprint.nodeinfo() + + # Create a mock request + request = MagicMock(spec=Request) + + # Call the handler + response = await handler(request) + + # Verify the response + assert response.status == 200 + assert response.headers["Content-Type"] == "application/json" + + # Verify the response content + response_json = json.loads(response.body) + + # Check that the URL includes the URL prefix + assert response_json["links"][0]["href"] == "https://example.com/api/data/ap/nodeinfo/2.0" diff --git a/test/components/renku_data_services/activitypub/test_webfinger.py b/test/components/renku_data_services/activitypub/test_webfinger.py new file mode 100644 index 000000000..a9d8e0ea2 --- /dev/null +++ b/test/components/renku_data_services/activitypub/test_webfinger.py @@ -0,0 +1,126 @@ +"""Tests for ActivityPub WebFinger functionality.""" + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from ulid import ULID + +from renku_data_services.activitypub.core import ActivityPubService +from renku_data_services.activitypub.db import ActivityPubRepository +from renku_data_services.activitypub import models + + +@pytest.mark.asyncio +async def test_discover_inbox_url_returns_string_or_none(mock_project_repo, mock_config): + """Test that _discover_inbox_url returns a string or None.""" + # Create the service + activitypub_repo = AsyncMock(spec=ActivityPubRepository) + service = ActivityPubService( + activitypub_repo=activitypub_repo, + project_repo=mock_project_repo, + config=mock_config, + ) + + # Mock the httpx.AsyncClient + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": "https://mastodon.social/users/test", + } + ] + } + mock_client.__aenter__.return_value = mock_client + mock_client.get.return_value = mock_response + + # Mock the second response for the actor profile + mock_actor_response = MagicMock() + mock_actor_response.status_code = 200 + mock_actor_response.json.return_value = { + "inbox": "https://mastodon.social/users/test/inbox" + } + mock_client.get.side_effect = [mock_response, mock_actor_response] + + # Test with a valid actor URI + with patch("httpx.AsyncClient", return_value=mock_client): + result = await service._discover_inbox_url("https://mastodon.social/users/test") + assert isinstance(result, str) + assert result == "https://mastodon.social/users/test/inbox" + + # Test with an invalid actor URI + mock_client.get.side_effect = httpx.RequestError("Connection error") + with patch("httpx.AsyncClient", return_value=mock_client): + result = await service._discover_inbox_url("https://invalid.example.com/users/test") + assert result is None + + +@pytest.mark.asyncio +async def test_discover_inbox_url_handles_json_types(mock_project_repo, mock_config): + """Test that _discover_inbox_url correctly handles different JSON types for inbox URL.""" + # Create the service + activitypub_repo = AsyncMock(spec=ActivityPubRepository) + service = ActivityPubService( + activitypub_repo=activitypub_repo, + project_repo=mock_project_repo, + config=mock_config, + ) + + # Mock the httpx.AsyncClient + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": "https://mastodon.social/users/test", + } + ] + } + mock_client.__aenter__.return_value = mock_client + mock_client.get.return_value = mock_response + + # Test with a string inbox URL + mock_actor_response = MagicMock() + mock_actor_response.status_code = 200 + mock_actor_response.json.return_value = { + "inbox": "https://mastodon.social/users/test/inbox" + } + mock_client.get.side_effect = [mock_response, mock_actor_response] + + with patch("httpx.AsyncClient", return_value=mock_client): + result = await service._discover_inbox_url("https://mastodon.social/users/test") + assert isinstance(result, str) + assert result == "https://mastodon.social/users/test/inbox" + + # Test with a non-string inbox URL (e.g., a JSON object) + mock_actor_response = MagicMock() + mock_actor_response.status_code = 200 + mock_actor_response.json.return_value = { + "inbox": {"url": "https://mastodon.social/users/test/inbox"} + } + mock_client.get.side_effect = [mock_response, mock_actor_response] + + with patch("httpx.AsyncClient", return_value=mock_client): + result = await service._discover_inbox_url("https://mastodon.social/users/test") + assert isinstance(result, str) + assert "https://mastodon.social/users/test/inbox" in result + + # Test with a null inbox URL + mock_actor_response = MagicMock() + mock_actor_response.status_code = 200 + mock_actor_response.json.return_value = { + "inbox": None + } + mock_client.get.side_effect = [mock_response, mock_actor_response] + + with patch("httpx.AsyncClient", return_value=mock_client): + result = await service._discover_inbox_url("https://mastodon.social/users/test") + assert result is None