diff --git a/.gitguardian.yml b/.gitguardian.yml index 663b27ba00..98c37e29de 100644 --- a/.gitguardian.yml +++ b/.gitguardian.yml @@ -1,2 +1,10 @@ -exclude_paths: +secret: + ignored_matches: + - match: 955d07d067d24f71e7110f9fcd2d22bd51bc06cfd301601a43f95a9b2251105c + name: Local Peertube high entropy secret - ./peertube.env + - match: 6e0d657eb1f0fbc40cf0b8f3c3873ef627cc9cb7c4108d1c07d979c04bc8a4bb + name: Local Peertube generic password - ./peertube.env + ignored_paths: - pod/enrichment/tests/test_views.py + - peertube.env +version: 2 diff --git a/.gitignore b/.gitignore index bb869c0a8f..ab992558c6 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ pod/main/static/custom/img settings_local.py scripts/bbb-pod-live/docker-compose.yml transcription/* +docker-volume # Unit test utilities # ####################### @@ -74,6 +75,7 @@ compile-model *.crt *.key *.pem +*.pub # NPM stuffs # ################ diff --git a/Makefile b/Makefile index f5eaae6da9..b9f215d48e 100755 --- a/Makefile +++ b/Makefile @@ -174,3 +174,9 @@ endif sudo rm -rf ./pod/db.sqlite3 sudo rm -rf ./pod/db_remote.sqlite3 sudo rm -rf ./pod/media + +# Ouvre un shell avec le contexte Django dans le conteneur pod +shell: + docker exec -it pod-activitypub-with-volumes pip install ipython + docker exec -it pod-activitypub-with-volumes env DJANGO_SETTINGS_MODULE=pod.settings ipython --ext=autoreload -c "%autoreload 2" -i + # then it is needed to call import django; django.setup() diff --git a/docker-compose-full-dev-with-volumes.yml b/docker-compose-full-dev-with-volumes.yml index 8e64940b46..988d4ec206 100755 --- a/docker-compose-full-dev-with-volumes.yml +++ b/docker-compose-full-dev-with-volumes.yml @@ -58,6 +58,18 @@ services: - ./.env.dev volumes: *pod-volumes + pod-activitypub: + container_name: pod-activitypub-with-volumes + build: + context: . + dockerfile: dockerfile-dev-with-volumes/pod-activitypub/Dockerfile + depends_on: + - pod-back + - redis + env_file: + - ./.env.dev + volumes: *pod-volumes + elasticsearch: container_name: elasticsearch-with-volumes hostname: elasticsearch.localhost @@ -81,6 +93,34 @@ services: ports: - 6379:6379 + peertube: + container_name: peertube + hostname: peertube.localhost + image: chocobozzz/peertube:develop-bookworm + ports: + - 9000:9000 + - 3000:3000 + env_file: + - peertube.env + depends_on: + - postgres + - redis + - postfix + command: sh -c "yarn install && npm run dev" + restart: "always" + + postgres: + image: postgres:13-alpine + env_file: + - peertube.env + restart: "always" + + postfix: + image: mwader/postfix-relay + env_file: + - peertube.env + restart: "always" + # redis-commander: # container_name: redis-commander # hostname: redis-commander.localhost diff --git a/dockerfile-dev-with-volumes/pod-activitypub/Dockerfile b/dockerfile-dev-with-volumes/pod-activitypub/Dockerfile new file mode 100755 index 0000000000..529b78961d --- /dev/null +++ b/dockerfile-dev-with-volumes/pod-activitypub/Dockerfile @@ -0,0 +1,34 @@ +#------------------------------------------------------------------------------------------------------------------------------ +# (\___/) +# (='.'=) Dockerfile multi-stages node & python +# (")_(") +#------------------------------------------------------------------------------------------------------------------------------ +# Conteneur node +ARG PYTHON_VERSION +# TODO +#FROM harbor.urba.univ-lille.fr/store/node:19 as source-build-js + +#------------------------------------------------------------------------------------------------------------------------------ +# Conteneur python +FROM $PYTHON_VERSION +WORKDIR /tmp/pod +COPY ./pod/ . +# TODO +#FROM harbor.urba.univ-lille.fr/store/python:3.7-buster + +RUN apt-get clean && apt-get update && apt-get install -y netcat + +WORKDIR /usr/src/app + +COPY ./requirements.txt . +COPY ./requirements-conteneur.txt . +COPY ./requirements-encode.txt . +COPY ./requirements-dev.txt . + +RUN pip3 install --no-cache-dir -r requirements-dev.txt + +# ENTRYPOINT : +COPY ./dockerfile-dev-with-volumes/pod-activitypub/my-entrypoint-activitypub.sh /tmp/my-entrypoint-activitypub.sh +RUN chmod 755 /tmp/my-entrypoint-activitypub.sh + +ENTRYPOINT ["bash", "/tmp/my-entrypoint-activitypub.sh"] diff --git a/dockerfile-dev-with-volumes/pod-activitypub/my-entrypoint-activitypub.sh b/dockerfile-dev-with-volumes/pod-activitypub/my-entrypoint-activitypub.sh new file mode 100644 index 0000000000..e340c1ebcf --- /dev/null +++ b/dockerfile-dev-with-volumes/pod-activitypub/my-entrypoint-activitypub.sh @@ -0,0 +1,8 @@ +#!/bin/sh +echo "Launching commands into pod-dev" +until nc -z pod-back 8000; do echo waiting for pod-back; sleep 10; done; +# Worker ActivityPub +env DJANGO_SETTINGS_MODULE=pod.settings \ + python -m watchdog.watchmedo auto-restart --directory pod --pattern '*.py' --recursive --\ + celery --app pod.activitypub.tasks worker --loglevel INFO --queues activitypub --concurrency 1 --hostname activitypub +sleep infinity diff --git a/peertube.env b/peertube.env new file mode 100644 index 0000000000..1657fa79de --- /dev/null +++ b/peertube.env @@ -0,0 +1,70 @@ +NODE_VERSION=21.7.1 +NODE_ENV=dev +NODE_DB_LOG=false + +# Database / Postgres service configuration +POSTGRES_USER=postgres +# ggignore-start +# gitguardian:ignore +POSTGRES_PASSWORD=postgres +# ggignore-end +# Postgres database name "peertube" +POSTGRES_DB=peertube_dev +# The database name used by PeerTube will be PEERTUBE_DB_NAME (only if set) *OR* 'peertube'+PEERTUBE_DB_SUFFIX +#PEERTUBE_DB_NAME= +#PEERTUBE_DB_SUFFIX=_prod +# Database username and password used by PeerTube must match Postgres', so they are copied: +PEERTUBE_DB_USERNAME=$POSTGRES_USER +PEERTUBE_DB_PASSWORD=$POSTGRES_PASSWORD +PEERTUBE_DB_SSL=false +# Default to Postgres service name "postgres" in docker-compose.yml +PEERTUBE_DB_HOSTNAME=postgres + +# PeerTube server configuration +# If you test PeerTube in local: use "peertube.localhost" and add this domain to your host file resolving on 127.0.0.1 +PEERTUBE_WEBSERVER_HOSTNAME=peertube.localhost +# If you just want to test PeerTube on local +PEERTUBE_WEBSERVER_PORT=9000 +PEERTUBE_WEBSERVER_HTTPS=false +# If you need more than one IP as trust_proxy +# pass them as a comma separated array: +PEERTUBE_TRUST_PROXY=["127.0.0.1", "loopback", "172.18.0.0/16"] + +# ggignore-start +# gitguardian:ignore +# Generate one using `openssl rand -hex 32` +PEERTUBE_SECRET=804061c0547350babbc79de045861dc90fe783b8cf9a5ae02b4d5a46fc60f78c +# ggignore-end + +# E-mail configuration +# If you use a Custom SMTP server +#PEERTUBE_SMTP_USERNAME= +#PEERTUBE_SMTP_PASSWORD= +# Default to Postfix service name "postfix" in docker-compose.yml +# May be the hostname of your Custom SMTP server +PEERTUBE_SMTP_HOSTNAME=postfix +PEERTUBE_SMTP_PORT=25 +PEERTUBE_SMTP_FROM=noreply@example.org +PEERTUBE_SMTP_TLS=false +PEERTUBE_SMTP_DISABLE_STARTTLS=false +PEERTUBE_ADMIN_EMAIL=admin@example.org + +# Postfix service configuration +POSTFIX_myhostname=example.org +# If you need to generate a list of sub/DOMAIN keys +# pass them as a whitespace separated string = +OPENDKIM_DOMAINS=example.org=peertube +# see https://github.com/wader/postfix-relay/pull/18 +OPENDKIM_RequireSafeKeys=no + +PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PUBLIC="public-read" +PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PRIVATE="private" + +#PEERTUBE_LOG_LEVEL=info + +# /!\ Prefer to use the PeerTube admin interface to set the following configurations /!\ +#PEERTUBE_SIGNUP_ENABLED=true +#PEERTUBE_TRANSCODING_ENABLED=true +#PEERTUBE_CONTACT_FORM_ENABLED=true + +PEERTUBE_REDIS_HOSTNAME=redis-with-volumes diff --git a/pod/activitypub/README.md b/pod/activitypub/README.md new file mode 100644 index 0000000000..2d08326bd1 --- /dev/null +++ b/pod/activitypub/README.md @@ -0,0 +1,141 @@ +# ActivityPub implementation + +Pod implements a minimal set of ActivityPub that allows video sharing between Pod instances. +The ActivityPub implementation is also compatible with Peertube. + +## Federation + +Here is what happens when two instances, say *Node A* and *Node B* (being Pod or Peertube) federate with each other, in a one way federation. + +### Federation + +- An administrator asks for Node A to federate with Node B +- Node A reaches the [NodeInfo](https://github.com/jhass/nodeinfo/blob/main/PROTOCOL.md) endpoint (`/.well-known/nodeinfo`) on Node B and discover the root application endpoint URL. +- Node A reaches the root application endpoint (for Pod this is `/ap/`) and get the `inbox` URL. +- Node A sends a `Create` activity for a `Follow` object on the Node B root application `inbox`. +- Node B reads the Node A root application endpoint URL in the `Follow` objects, reaches this endpoint and get the Node A root application `inbox` URL. +- Node B creates a `Follower` objects and stores it locally +- Node B sends a `Accept` activity for the `Follower` object on Node A root application enpdoint. +- Later, Node A can send to Node B a `Undo` activity for the `Follow` object to de-federate. + +### Video discovery + +- Node A reaches the Node B root application `outbox`. +- Node A browse the pages of the `outbox` and look for announces about `Videos` +- Node A reaches the `Video` endpoints and store locally the information about the videos. + +### Video creation and update sharing + +#### Creation + +- A user of Node B publishes a `Video` +- Node B sends a `Announce` activity on the `inbox` of all its `Followers`, including Node A with the ID of the new video. +- Node A reads the information about the new `Video` on Node B video endpoint. + +#### Edition + +- A user of Node B edits a `Video` +- Node B sends a `Update` activity on the `inbox` of all its `Followers`, including Node A with the ID of the new video, containing the details of the `Video`. + +#### Deletion + +- A user of Node B deletes a `Video` +- Node B sends a `Delete` activity on the `inbox` of all its `Followers`, including Node A with the ID of the new video. + +## Implementation + +The ActivityPub implementation tries to replicate the network messages of Peertube. +There may be things that could have been done differently while still following the ActivityPub specs, but changing the network exchanges would require checking if the Peertube compatibility is not broken. +This is due to Peertube having a few undocumented behaviors that are not exactly part of the AP specs. + +To achieve compatibility with Peertube, Pod implements two specifications to sign ActivityPub exchanges. + +- [Signing HTTP Messages, draft 12](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12). + This specification is replaced by [RFC9421](https://www.rfc-editor.org/rfc/rfc9421.html) but Peertube does not implement the finale spec, + and instead lurks on the writing of the [ActivityPub and HTTP Signatures](https://swicg.github.io/activitypub-http-signature/) spec, that is also still a draft. + See the [related discussion](https://framacolibri.org/t/rfc9421-replaces-the-signing-http-messages-draft/20911/2). + This spec describe how to sign ActivityPub payload with HTTP headers. +- [Linked Data Signatures 1.0](https://web.archive.org/web/20170717200644/https://w3c-dvcg.github.io/ld-signatures/) draft. + This specification is replaced by [Verifiable Credential Data Integrity](https://w3c.github.io/vc-data-integrity/) but Peertube does not implement the finale spec. + This spec describe how to sign ActivityPub payload by adding fields in the payload. + +The state of the specification support in Peertube is similar to [Mastodon](https://docs.joinmastodon.org/spec/security/), and is probably a mean to keep the two software compatible with each other. + +## Limitations + +- Peertube instance will only be able to index Pod videos if the video thumbnails are absent. +- Peertube instance will only be able to index Pod videos if the thumbnails are in JPEG format. + png thumbnails are not supported at the moment (but that may come in the future + [more details here](https://framacolibri.org/t/comments-and-suggestions-on-the-peertube-activitypub-implementation/21215)). + In the meantime, pod fakes the mime-type of all thumbnails to be JPEG, even when they actually are PNGs. + +## Configuration + +A RSA keypair is needed for ActivityPub to work, and passed as +`ACTIVITYPUB_PUBLIC_KEY` and `ACTIVITYPUB_PRIVATE_KEY` configuration settings. +They can be generated from a python console: + +```python +from Crypto.PublicKey import RSA + +activitypub_key = RSA.generate(2048) + +# Generate the private key +# Add the content of this command in 'pod/custom/settings_local.py' +# in a variable named ACTIVITYPUB_PRIVATE_KEY +with open("pod/activitypub/ap.key", "w") as fd: + fd.write(activitypub_key.export_key().decode()) + +# Generate the public key +# Add the content of this command in 'pod/custom/settings_local.py' +# in a variable named ACTIVITYPUB_PUBLIC_KEY +with open("pod/activitypub/ap.pub", "w") as fd: + fd.write(activitypub_key.publickey().export_key().decode()) +``` + +The federation also needs celery to be configured with `ACTIVITYPUB_CELERY_BROKER_URL`. + +Here is a sample working activitypub `pod/custom/settings_local.py`: + +```python +ACTIVITYPUB_CELERY_BROKER_URL = "redis://redis:6379/5" + +with open("pod/activitypub/ap.key") as fd: + ACTIVITYPUB_PRIVATE_KEY = fd.read() + +with open("pod/activitypub/ap.pub") as fd: + ACTIVITYPUB_PUBLIC_KEY = fd.read() +``` + +## Development + +The `DOCKER_ENV` environment var should be set to `full` so a peertube instance and a ActivityPub celery worker are launched. +Then peertube is available at http://peertube.localhost:9000. + +### Federate Peertube with Pod + +- Sign in with the `root` account +- Go to [Main menu > Administration > Federation](http://peertube.localhost:9000/admin/follows/following-list) > Follow +- Open the *Follow* modal and type `pod.localhost:8000` + +### Federate Pod with Peertube + +- Sign in with `admin` +- Go to the [Administration pannel > Followings](http://pod.localhost:8000/admin/activitypub/following/) > Add following +- Type `http://peertube.localhost:9000` in *Object* and save +- On the [Followings list](http://pod.localhost:8000/admin/activitypub/following/) select the new object, and select `Send the federation request` in the action list, refresh. +- If the status is *Following request accepted* then select the object again, and choose `Reindex instance videos` in the action list. + +## Shortcuts + +### Manual AP request + +```shell +curl -H "Accept: application/activity+json, application/ld+json" -s "http://pod.localhost:9000/accounts/peertube" | jq +``` + +### Unit tests + +```shell +python manage.py test --settings=pod.main.test_settings pod.activitypub.test_settings +``` diff --git a/pod/activitypub/__init__.py b/pod/activitypub/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/pod/activitypub/__init__.py @@ -0,0 +1 @@ + diff --git a/pod/activitypub/admin.py b/pod/activitypub/admin.py new file mode 100644 index 0000000000..122d395009 --- /dev/null +++ b/pod/activitypub/admin.py @@ -0,0 +1,30 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from .models import Follower, Following +from .tasks import task_follow, task_index_videos + + +@admin.register(Follower) +class FollowerAdmin(admin.ModelAdmin): + list_display = ("actor",) + + +@admin.action(description=_("Send the federation request")) +def send_federation_request(modeladmin, request, queryset): + for following in queryset: + task_follow.delay(following.id) + modeladmin.message_user(request, _("The federation requests have been sent")) + + +@admin.action(description=_("Reindex the instance videos")) +def reindex_videos(modeladmin, request, queryset): + for following in queryset: + task_index_videos.delay(following.id) + modeladmin.message_user(request, _("The video indexations have started")) + + +@admin.register(Following) +class FollowingAdmin(admin.ModelAdmin): + actions = [send_federation_request, reindex_videos] + list_display = ("object", "status") diff --git a/pod/activitypub/apps.py b/pod/activitypub/apps.py new file mode 100644 index 0000000000..5a2fb8314f --- /dev/null +++ b/pod/activitypub/apps.py @@ -0,0 +1,15 @@ +from django.apps import AppConfig +from django.db.models.signals import post_delete, post_save + + +class ActivitypubConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "pod.activitypub" + + def ready(self): + from pod.video.models import Video + + from .signals import on_video_delete, on_video_save + + post_save.connect(on_video_save, sender=Video) + post_delete.connect(on_video_delete, sender=Video) diff --git a/pod/activitypub/constants.py b/pod/activitypub/constants.py new file mode 100644 index 0000000000..a0d9d843af --- /dev/null +++ b/pod/activitypub/constants.py @@ -0,0 +1,73 @@ +AP_REQUESTS_TIMEOUT = 60 +AP_DEFAULT_CONTEXT = [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + {"RsaSignature2017": "https://w3id.org/security#RsaSignature2017"}, +] +AP_PT_VIDEO_CONTEXT = { + "pt": "https://joinpeertube.org/ns#", + "sc": "http://schema.org/", + "Hashtag": "as:Hashtag", + "uuid": "sc:identifier", + "category": "sc:category", + "licence": "sc:license", + "subtitleLanguage": "sc:subtitleLanguage", + "sensitive": "as:sensitive", + "language": "sc:inLanguage", + "identifier": "sc:identifier", + "isLiveBroadcast": "sc:isLiveBroadcast", + "liveSaveReplay": {"@type": "sc:Boolean", "@id": "pt:liveSaveReplay"}, + "permanentLive": {"@type": "sc:Boolean", "@id": "pt:permanentLive"}, + "latencyMode": {"@type": "sc:Number", "@id": "pt:latencyMode"}, + "Infohash": "pt:Infohash", + "tileWidth": {"@type": "sc:Number", "@id": "pt:tileWidth"}, + "tileHeight": {"@type": "sc:Number", "@id": "pt:tileHeight"}, + "tileDuration": {"@type": "sc:Number", "@id": "pt:tileDuration"}, + "originallyPublishedAt": "sc:datePublished", + "uploadDate": "sc:uploadDate", + "hasParts": "sc:hasParts", + "views": {"@type": "sc:Number", "@id": "pt:views"}, + "state": {"@type": "sc:Number", "@id": "pt:state"}, + "size": {"@type": "sc:Number", "@id": "pt:size"}, + "fps": {"@type": "sc:Number", "@id": "pt:fps"}, + "commentsEnabled": {"@type": "sc:Boolean", "@id": "pt:commentsEnabled"}, + "downloadEnabled": {"@type": "sc:Boolean", "@id": "pt:downloadEnabled"}, + "waitTranscoding": {"@type": "sc:Boolean", "@id": "pt:waitTranscoding"}, + "support": {"@type": "sc:Text", "@id": "pt:support"}, + "likes": {"@id": "as:likes", "@type": "@id"}, + "dislikes": {"@id": "as:dislikes", "@type": "@id"}, + "shares": {"@id": "as:shares", "@type": "@id"}, + "comments": {"@id": "as:comments", "@type": "@id"}, +} +AP_PT_CHANNEL_CONTEXT = ( + { + "pt": "https://joinpeertube.org/ns#", + "sc": "http://schema.org/", + "playlists": {"@id": "pt:playlists", "@type": "@id"}, + "support": {"@type": "sc:Text", "@id": "pt:support"}, + "icons": "as:icon", + }, +) +AP_PT_CHAPTERS_CONTEXT = { + "pt": "https://joinpeertube.org/ns#", + "sc": "http://schema.org/", + "name": "sc:name", + "hasPart": "sc:hasPart", + "endOffset": "sc:endOffset", + "startOffset": "sc:startOffset", +} + +INSTANCE_ACTOR_ID = "pod" +BASE_HEADERS = {"Accept": "application/activity+json, application/ld+json"} + + +# https://creativecommons.org/licenses/?lang=en +AP_LICENSE_MAPPING = { + 1: "by", + 2: "by-sa", + 3: "by-nd", + 4: "by-nc", + 5: "by-nc-sa", + 6: "by-nc-nd", + 7: "zero", +} diff --git a/pod/activitypub/migrations/__init__.py b/pod/activitypub/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pod/activitypub/models.py b/pod/activitypub/models.py new file mode 100644 index 0000000000..6dc333db9a --- /dev/null +++ b/pod/activitypub/models.py @@ -0,0 +1,46 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from pod.video.models import Video + + +class Follower(models.Model): + actor = models.CharField( + _("Actor"), + max_length=255, + help_text=_("Actor who initiated the Follow activity"), + ) + + +class Following(models.Model): + class Status(models.IntegerChoices): + NONE = 0, _("None") + REQUESTED = 1, _("Following request sent") + ACCEPTED = 2, _("Following request accepted") + REFUSED = 3, _("Following request refused") + + object = models.CharField( + _("Object"), + max_length=255, + help_text=_("URL of the instance to follow"), + ) + status = models.IntegerField( + _("Status"), + help_text=_("URL of the instance to follow"), + choices=Status.choices, + default=Status.NONE, + ) + + +class ExternalVideo(Video): + source_instance = models.ForeignKey( + Following, + on_delete=models.CASCADE, + verbose_name=_("Source instance"), + help_text=_("Video origin instance"), + ) + ap_id = models.CharField( + _("Video identifier"), + max_length=255, + help_text=_("Video identifier URL"), + ) diff --git a/pod/activitypub/network.py b/pod/activitypub/network.py new file mode 100644 index 0000000000..053d0995fb --- /dev/null +++ b/pod/activitypub/network.py @@ -0,0 +1,237 @@ +"""Long-standing operations""" + +import logging +from urllib.parse import urlparse + +import requests +from django.urls import reverse + +from pod.activitypub.utils import ap_object +from pod.video.models import Video + +from .constants import AP_DEFAULT_CONTEXT, AP_PT_VIDEO_CONTEXT, BASE_HEADERS +from .models import Follower, Following +from .serialization.video import ap_video_to_external_video, video_to_ap_video +from .utils import ap_post, ap_url + +logger = logging.getLogger(__name__) + + +def index_videos(following: Following): + ap_actor = get_instance_application_account_metadata(following.object) + ap_outbox = ap_object(ap_actor["outbox"]) + if "first" in ap_outbox: + index_videos_page(following, ap_outbox["first"]) + return True + + +def get_instance_application_account_url(url): + """Read the instance nodeinfo well-known URL to get the main account URL.""" + # TODO: handle exceptions + nodeinfo_url = f"{url}/.well-known/nodeinfo" + response = requests.get(nodeinfo_url, headers=BASE_HEADERS) + for link in response.json()["links"]: + if link["rel"] == "https://www.w3.org/ns/activitystreams#Application": + return link["href"] + + +def get_instance_application_account_metadata(domain): + account_url = get_instance_application_account_url(domain) + ap_actor = ap_object(account_url) + return ap_actor + + +def handle_incoming_follow(ap_follow): + # TODO: test double follows + actor_account = ap_object(ap_follow["actor"]) + inbox = actor_account["inbox"] + + follower, _ = Follower.objects.get_or_create(actor=ap_follow["actor"]) + payload = { + "@context": AP_DEFAULT_CONTEXT, + "id": ap_url(f"/accepts/follows/{follower.id}"), + "type": "Accept", + "actor": ap_follow["object"], + "object": { + "type": "Follow", + "id": ap_follow["id"], + "actor": ap_follow["actor"], + "object": ap_follow["object"], + }, + } + response = ap_post(inbox, payload) + return response.status_code == 204 + + +def handle_incoming_unfollow(ap_follow): + Follower.objects.filter(actor=ap_follow["actor"]).delete() + + +def send_follow_request(following: Following): + ap_actor = get_instance_application_account_metadata(following.object) + following_url = ap_url(reverse("activitypub:following")) + payload = { + "@context": AP_DEFAULT_CONTEXT, + "type": "Follow", + "id": f"{following_url}/{following.id}", + "actor": ap_url(reverse("activitypub:account")), + "object": ap_actor["id"], + } + response = ap_post(ap_actor["inbox"], payload) + following.status = Following.Status.REQUESTED + following.save() + + return response.status_code == 204 + + +def index_videos_page(following: Following, page_url): + """Parse a AP Video page payload, and handle each video.""" + ap_page = ap_object(page_url) + for item in ap_page["orderedItems"]: + index_video(following, item["object"]) + + if "next" in ap_page: + index_videos_page(following, ap_page["next"]) + + +def index_video(following: Following, video_url): + """Read a video payload and create an ExternalVideo object""" + ap_video = ap_object(video_url) + logger.warning(f"TODO: Deal with video indexation {ap_video}") + extvideo = ap_video_to_external_video(ap_video) + extvideo.source_instance = following + extvideo.save() + + +def external_video_added_by_actor(ap_video, ap_actor): + # Announce for a Video created by a user account + # TODO: create an ExternalVideo + logger.warning(f"TODO: Handle Video addition by {ap_actor['type']}") + + +def external_video_added_by_channel(ap_video, ap_channel): + # Announce for a Video added to a channel + # TODO: update the external video to add it to the matching channel + logger.warning("TODO: Handle Video additon on Channel") + + +def external_video_update(ap_video): + # TODO: update the external video details + logger.warning("TODO: Deal with Video updates") + + +def external_video_deletion(ap_video): + # TODO: Delete the ExternalVideo + logger.warning("TODO: Handle Video deletion") + + +def send_video_announce_object(video: Video, follower: Follower): + # TODO: save the inbox for better performance? + actor_account = ap_object(follower.actor) + inbox = actor_account["inbox"] + + video_ap_url = ap_url(reverse("activitypub:video", kwargs={"id": video.id})) + owner_ap_url = ap_url( + reverse("activitypub:account", kwargs={"username": video.owner.username}) + ) + + payload = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + {"RsaSignature2017": "https://w3id.org/security#RsaSignature2017"}, + ], + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + ap_url(reverse("activitypub:followers")), + ap_url( + reverse( + "activitypub:followers", kwargs={"username": video.owner.username} + ) + ), + ], + "cc": [], + "type": "Announce", + "id": f"{video_ap_url}/announces/1", + "actor": owner_ap_url, + "object": video_ap_url, + } + response = ap_post(inbox, payload) + return response.status_code == 204 + + +def send_video_update_object(video: Video, follower: Follower): + # TODO: save the inbox for better performance? + actor_account = ap_object(follower.actor) + inbox = actor_account["inbox"] + + video_ap_url = ap_url(reverse("activitypub:video", kwargs={"id": video.id})) + owner_ap_url = ap_url( + reverse("activitypub:account", kwargs={"username": video.owner.username}) + ) + + payload = { + "@context": AP_DEFAULT_CONTEXT + [AP_PT_VIDEO_CONTEXT], + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": [ + ap_url(reverse("activitypub:followers")), + ap_url( + reverse( + "activitypub:followers", kwargs={"username": video.owner.username} + ) + ), + ], + "type": "Update", + "id": video_ap_url, + "actor": owner_ap_url, + "object": { + **video_to_ap_video(video), + }, + } + response = ap_post(inbox, payload) + return response.status_code == 204 + + +def send_video_delete_object(video_id, owner_username, follower: Follower): + # TODO: save the inbox for better performance? + actor_account = ap_object(follower.actor) + inbox = actor_account["inbox"] + + video_ap_url = ap_url(reverse("activitypub:video", kwargs={"id": video_id})) + owner_ap_url = ap_url( + reverse("activitypub:account", kwargs={"username": owner_username}) + ) + payload = { + "@context": AP_DEFAULT_CONTEXT, + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + ap_url(reverse("activitypub:followers")), + ap_url( + reverse("activitypub:followers", kwargs={"username": owner_username}) + ), + ], + "cc": [], + "type": "Delete", + "id": f"{video_ap_url}/delete", + "actor": owner_ap_url, + "object": video_ap_url, + } + + response = ap_post(inbox, payload) + return response.status_code == 204 + + +def follow_request_accepted(ap_follow): + parsed = urlparse(ap_follow["object"]) + obj = f"{parsed.scheme}://{parsed.netloc}" + follower = Following.objects.get(object=obj) + follower.status = Following.Status.ACCEPTED + follower.save() + + +def follow_request_rejected(ap_follow): + parsed = urlparse(ap_follow["object"]) + obj = f"{parsed.scheme}://{parsed.netloc}" + follower = Following.objects.get(object=obj) + follower.status = Following.Status.REFUSED + follower.save() diff --git a/pod/activitypub/serialization/account.py b/pod/activitypub/serialization/account.py new file mode 100644 index 0000000000..2c407247ff --- /dev/null +++ b/pod/activitypub/serialization/account.py @@ -0,0 +1,152 @@ +from typing import Optional +from django.conf import settings +from django.urls import reverse +from django.contrib.auth.models import User + +from pod.activitypub.constants import INSTANCE_ACTOR_ID +from pod.activitypub.utils import ap_url + + +def account_to_ap_actor(user: Optional[User]): + url_args = {"username": user.username} if user else {} + response = { + "id": ap_url(reverse("activitypub:account", kwargs=url_args)), + **account_type(user), + **account_url(user), + **account_following(user), + **account_followers(user), + **account_inbox(user), + **account_outbox(user), + **account_endpoints(user), + **account_public_key(user), + **account_preferred_username(user), + **account_name(user), + **account_summary(user), + **account_icon(user), + } + + return response + + +def account_to_ap_group(user: Optional[User]): + """Default channel for users. + + This is needed by Peertube, since in Peertube every + Video must have at least one channel attached.""" + + url_args = {"username": user.username} if user else {} + + # peertube needs a different username for the user account and the user channel + # https://github.com/Chocobozzz/PeerTube/blob/5dfa07adb5ae8f1692a15b0f97ea0694894264c9/server/core/lib/activitypub/actors/shared/creator.ts#L89-L110 + preferred_username = ( + account_preferred_username(user)["preferredUsername"] + "_channel" + ) + name = account_name(user)["name"] + "_channel" + + return { + "type": "Group", + "id": ap_url(reverse("activitypub:account_channel", kwargs=url_args)), + "url": ap_url(reverse("activitypub:account_channel", kwargs=url_args)), + **account_following(user), + **account_followers(user), + **account_playlists(user), + **account_outbox(user), + **account_inbox(user), + **account_endpoints(user), + **account_public_key(user), + **account_icon(user), + **account_summary(user), + "preferredUsername": preferred_username, + "name": name, + "attributedTo": [ + { + "type": "Person", + "id": ap_url(reverse("activitypub:account", kwargs=url_args)), + } + ], + } + + +def account_type(user: Optional[User]): + return {"type": "Person" if user else "Application"} + + +def account_url(user: Optional[User]): + url_args = {"username": user.username} if user else {} + return {"url": ap_url(reverse("activitypub:account", kwargs=url_args))} + + +def account_following(user: Optional[User]): + url_args = {"username": user.username} if user else {} + return {"following": ap_url(reverse("activitypub:following", kwargs=url_args))} + + +def account_followers(user: Optional[User]): + url_args = {"username": user.username} if user else {} + return {"followers": ap_url(reverse("activitypub:followers", kwargs=url_args))} + + +def account_inbox(user: Optional[User]): + url_args = {"username": user.username} if user else {} + return {"inbox": ap_url(reverse("activitypub:inbox", kwargs=url_args))} + + +def account_outbox(user: Optional[User]): + url_args = {"username": user.username} if user else {} + return {"outbox": ap_url(reverse("activitypub:outbox", kwargs=url_args))} + + +def account_endpoints(user: Optional[User]): + """sharedInbox is needed by peertube to send video updates.""" + url_args = {"username": user.username} if user else {} + return { + "endpoints": { + "sharedInbox": ap_url(reverse("activitypub:inbox", kwargs=url_args)) + } + } + + +def account_playlists(user: Optional[User]): + return {} + + +def account_public_key(user: Optional[User]): + instance_actor_url = ap_url(reverse("activitypub:account")) + return { + "publicKey": { + "id": f"{instance_actor_url}#main-key", + "owner": instance_actor_url, + "publicKeyPem": settings.ACTIVITYPUB_PUBLIC_KEY, + }, + } + + +def account_preferred_username(user: Optional[User]): + return {"preferredUsername": user.username if user else INSTANCE_ACTOR_ID} + + +def account_name(user: Optional[User]): + return {"name": user.username if user else INSTANCE_ACTOR_ID} + + +def account_summary(user: Optional[User]): + if user: + return {"summary": user.owner.commentaire} + return {} + + +def account_icon(user: Optional[User]): + if user and user.owner.userpicture: + return { + "icon": [ + { + "type": "Image", + "url": ap_url(user.owner.userpicture.file.url), + "height": user.owner.userpicture.file.width, + "width": user.owner.userpicture.file.height, + "mediaType": user.owner.userpicture.file_type, + }, + ] + } + + return {} diff --git a/pod/activitypub/serialization/channel.py b/pod/activitypub/serialization/channel.py new file mode 100644 index 0000000000..3fbefd7c07 --- /dev/null +++ b/pod/activitypub/serialization/channel.py @@ -0,0 +1,140 @@ +from django.conf import settings +from django.urls import reverse + +from pod.activitypub.utils import ap_url + + +def channel_to_ap_group(channel): + return { + "type": "Group", + "id": ap_url(reverse("activitypub:channel", kwargs={"id": channel.id})), + **channel_url(channel), + **channel_following(channel), + **channel_followers(channel), + **channel_playlists(channel), + **channel_outbox(channel), + **channel_inbox(channel), + **channel_preferred_username(channel), + **channel_name(channel), + **channel_endpoints(channel), + **channel_public_key(channel), + **channel_published(channel), + **channel_icon(channel), + **channel_support(channel), + **channel_attributed_to(channel), + **channel_image(channel), + **channel_summary(channel), + } + + +def channel_url(channel): + # needed by peertube + return {"url": ap_url(reverse("activitypub:channel", kwargs={"id": channel.id}))} + + +def channel_following(channel): + return {} + + +def channel_followers(channel): + return {} + + +def channel_playlists(channel): + return {} + + +def channel_outbox(channel): + return {} + + +def channel_inbox(channel): + """Channel inbox definition is needed by peertube. + + This is a fake URL and is not intented to be reached.""" + + channel_url = ap_url(reverse("activitypub:channel", kwargs={"id": channel.id})) + inbox_url = f"{channel_url}/inbox" + return {"inbox": inbox_url} + + +def channel_preferred_username(channel): + """Channel preferred username is needed by peertube. + + it seems to not support spaces.""" + return {"preferredUsername": channel.slug} + + +def channel_name(channel): + return {"name": channel.title} + + +def channel_endpoints(channel): + channel_url = ap_url(reverse("activitypub:channel", kwargs={"id": channel.id})) + inbox_url = f"{channel_url}/inbox" + return {"endpoints": {"sharedInbox": inbox_url}} + + +def channel_public_key(channel): + """Channel public key is needed by peertube. + + At the moment Pod only uses one key for AP so let's re-use it.""" + + instance_actor_url = ap_url(reverse("activitypub:account")) + return { + "publicKey": { + "id": f"{instance_actor_url}#main-key", + "owner": instance_actor_url, + "publicKeyPem": settings.ACTIVITYPUB_PUBLIC_KEY, + } + } + + +def channel_published(channel): + """Pod does not have the information at moment.""" + return {} + + +def channel_icon(channel): + return {} + + +def channel_support(channel): + return {} + + +def channel_attributed_to(channel): + """Channel attributions are needed by peertube.""" + return { + "attributedTo": [ + { + "type": "Person", + "id": ap_url( + reverse("activitypub:account", kwargs={"username": owner.username}) + ), + } + for owner in channel.owners.all() + ], + } + + +def channel_image(channel): + if channel.headband: + return { + "image": { + "type": "Image", + "url": channel.headband.file.url, + "height": channel.headband.file.height, + "width": channel.headband.file.width, + "mediaType": channel.headband.file_type, + } + } + return {} + + +def channel_summary(channel): + if channel.description: + return { + "summary": channel.description, + } + return {} diff --git a/pod/activitypub/serialization/video.py b/pod/activitypub/serialization/video.py new file mode 100644 index 0000000000..095665bd0e --- /dev/null +++ b/pod/activitypub/serialization/video.py @@ -0,0 +1,388 @@ +from django.template.defaultfilters import slugify +from django.urls import reverse +from markdownify import markdownify + +from pod.activitypub.constants import AP_LICENSE_MAPPING +from pod.activitypub.models import ExternalVideo +from pod.activitypub.utils import ap_url, make_magnet_url, stable_uuid + + +def ap_video_to_external_video(payload): + """Create an ExternalVideo object from an AP Video payload.""" + return ExternalVideo.objects.create() + + +def video_to_ap_video(video): + return { + "id": ap_url(reverse("activitypub:video", kwargs={"id": video.id})), + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": [ + ap_url( + reverse( + "activitypub:followers", kwargs={"username": video.owner.username} + ) + ) + ], + "type": "Video", + **video_name(video), + **video_duration(video), + **video_uuid(video), + **video_views(video), + **video_transcoding(video), + **video_comments(video), + **video_download(video), + **video_dates(video), + **video_tags(video), + **video_urls(video), + **video_attributions(video), + **video_sensitivity(video), + **video_likes(video), + **video_shares(video), + **video_category(video), + **video_state(video), + **video_support(video), + **video_preview(video), + **video_live(video), + **video_subtitles(video), + **video_chapters(video), + **video_licences(video), + **video_language(video), + **video_description(video), + **video_icon(video), + } + + +def video_name(video): + return {"name": video.title} + + +def video_duration(video): + """duration must fit the xsd:duration format + https://www.w3.org/TR/xmlschema11-2/#duration + """ + return {"duration": f"PT{video.duration}S"} + + +def video_uuid(video): + """needed by peertube 6.1, uuids must be version 4 exactly + https://github.com/Chocobozzz/PeerTube/blob/b824480af7054a5a49ddb1788c26c769c89ccc8a/server/core/helpers/custom-validators/activitypub/videos.ts#L76 + """ + return {"uuid": str(stable_uuid(video.id, version=4))} + + +def video_views(video): + """needed by peertube""" + return {"views": video.viewcount} + + +def video_transcoding(video): + return {"waitTranscoding": video.encoding_in_progress} + + +def video_comments(video): + """the comments endpoint is needed by peertube""" + return { + "commentsEnabled": not video.disable_comment, + "comments": ap_url(reverse("activitypub:comments", kwargs={"id": video.id})), + } + + +def video_download(video): + return {"downloadEnabled": video.allow_downloading} + + +def video_dates(video): + """ + 'published' and 'updated' are needed by peertube + + TODO: implement "originallyPublishedAt" when federation is implemented on pod side + """ + return { + "published": video.date_added.isoformat(), + "updated": video.date_added.isoformat(), + } + + +def video_tags(video): + """tags (even empty) are needed by peertube + https://github.com/Chocobozzz/PeerTube/blob/b824480af7054a5a49ddb1788c26c769c89ccc8a/server/core/helpers/custom-validators/activitypub/videos.ts#L148-L157 + + they may become fully optional someday + https://framacolibri.org/t/comments-and-suggestions-on-the-peertube-activitypub-implementation/21215/2 + """ + return { + "tag": [ + {"type": "Hashtag", "name": slugify(tag)} for tag in video.tags.split(" ") + ], + } + + +def video_urls(video): + """ + peertube needs a matching magnet url for every mp4 url + https://github.com/Chocobozzz/PeerTube/blob/b824480af7054a5a49ddb1788c26c769c89ccc8a/server/core/lib/activitypub/videos/shared/object-to-model-attributes.ts#L61-L64 + + magnets may become fully optional someday + https://framacolibri.org/t/comments-and-suggestions-on-the-peertube-activitypub-implementation/21215/2 + """ + + return { + "url": ( + [ + # Webpage + { + "type": "Link", + "mediaType": "text/html", + "href": ap_url(reverse("video:video", args=(video.id,))), + }, + ] + + [ + # MP4 link + { + "type": "Link", + "mediaType": mp4.encoding_format, + "href": ap_url(mp4.source_file.url), + "height": mp4.height, + "width": mp4.width, + "size": mp4.source_file.size, + # TODO: get the fps + # "fps": 30, + } + for mp4 in video.get_video_mp4() + ] + + [ + # Magnet + { + "type": "Link", + "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", + "href": make_magnet_url(video, mp4), + "height": mp4.height, + "width": mp4.width, + # TODO: get the fps + # "fps": 30, + } + for mp4 in video.get_video_mp4() + ] + ) + } + + +def video_attributions(video): + """ + Group and Person attributions are needed by peertube + TODO: ask peertube to make this optional + https://github.com/Chocobozzz/PeerTube/blob/b824480af7054a5a49ddb1788c26c769c89ccc8a/server/core/lib/activitypub/videos/shared/abstract-builder.ts#L47-L52 + + This won't change soon + https://framacolibri.org/t/comments-and-suggestions-on-the-peertube-activitypub-implementation/21215/2 + """ + + return { + "attributedTo": [ + # needed by peertube + { + "type": "Person", + "id": ap_url( + reverse( + "activitypub:account", kwargs={"username": video.owner.username} + ) + ), + }, + # We should fake a default channel for every videos + { + "type": "Group", + "id": ap_url( + reverse( + "activitypub:account_channel", + kwargs={"username": video.owner.username}, + ) + ), + }, + ] + + [ + { + "type": "Group", + "id": ap_url(reverse("activitypub:channel", kwargs={"id": channel.id})), + } + for channel in video.channel.all() + ], + } + + +def video_sensitivity(video): + """needed by peertube + + This may become optional someday + https://framacolibri.org/t/comments-and-suggestions-on-the-peertube-activitypub-implementation/21215/2 + """ + return { + "sensitive": False, + } + + +def video_likes(video): + """like and dislikes urls are needed by peertube. + + They may become optional someday + https://framacolibri.org/t/comments-and-suggestions-on-the-peertube-activitypub-implementation/21215/2 + """ + + return { + "likes": ap_url(reverse("activitypub:likes", kwargs={"id": video.id})), + "dislikes": ap_url(reverse("activitypub:likes", kwargs={"id": video.id})), + } + + +def video_shares(video): + """shares url is needed by peertube. + + This may become optional someday + https://framacolibri.org/t/comments-and-suggestions-on-the-peertube-activitypub-implementation/21215/2 + """ + + return { + "shares": ap_url(reverse("activitypub:likes", kwargs={"id": video.id})), + } + + +def video_category(video): + """ + we could use video.type as AP 'category' + but peertube categories are fixed, and identifiers are expected to be integers + https://github.com/Chocobozzz/PeerTube/blob/b824480af7054a5a49ddb1788c26c769c89ccc8a/server/core/initializers/constants.ts#L544-L563 + example of expected content: + "category": {"identifier": "11", "name": "News & Politics & shit"} + """ + return {} + + +def video_state(video): + """ + 'state' is optional + peertube valid values are + https://github.com/Chocobozzz/PeerTube/blob/b824480af7054a5a49ddb1788c26c769c89ccc8a/packages/models/src/videos/video-state.enum.ts#L1-L12C36 + """ + + return {} + + +def video_support(video): + """'support' is not managed by pod""" + return {} + + +def video_preview(video): + """'preview' thumbnails are not supported by pod""" + return {} + + +def video_live(video): + """ + Pod don't support live at the moment, so the following optional fields are ignored + isLiveBroadcast, liveSaveReplay, permanentLive, latencyMode, peertubeLiveChat + """ + return {} + + +def video_subtitles(video): + has_tracks = video.track_set.all().count() > 0 + if not has_tracks: + return {} + + return { + "subtitleLanguage": [ + { + "identifier": track.lang, + "name": track.get_label_lang(), + "url": ap_url(track.src.file.url), + } + for track in video.track_set.all() + ] + } + + +def video_chapters(video): + has_chapters = video.chapter_set.all().count() > 0 + if not has_chapters: + return {} + + return { + "hasParts": ap_url(reverse("activitypub:chapters", kwargs={"id": video.id})) + } + + +def video_licences(video): + """ + peertube needs integers identifiers for licences + https://github.com/Chocobozzz/PeerTube/blob/b824480af7054a5a49ddb1788c26c769c89ccc8a/server/core/helpers/custom-validators/activitypub/videos.ts#L78 + + This may become spdy identifiers like 'by-nd' someday + https://framacolibri.org/t/comments-and-suggestions-on-the-peertube-activitypub-implementation/21215/2 + """ + + reverse_license_mapping = {v: k for k, v in AP_LICENSE_MAPPING.items()} + if not video.licence or video.licence not in reverse_license_mapping: + return {} + + return { + "licence": { + "identifier": str(reverse_license_mapping[video.licence]), + "name": video.get_licence(), + } + } + + +def video_language(video): + if not video.main_lang: + return {} + + return { + "language": { + "identifier": video.main_lang, + "name": video.get_main_lang(), + } + } + + +def video_description(video): + """ + peertube only supports one language + TODO ask for several descriptions in several languages + + peertube only supports markdown + https://github.com/Chocobozzz/PeerTube/blob/b824480af7054a5a49ddb1788c26c769c89ccc8a/server/core/helpers/custom-validators/activitypub/videos.ts#L182 + + text/html may be supported someday + https://framacolibri.org/t/comments-and-suggestions-on-the-peertube-activitypub-implementation/21215/2 + This would allow Pod to avoid using markdownify + """ + if not video.description: + return {} + + return { + "mediaType": "text/markdown", + "content": markdownify(video.description), + } + + +def video_icon(video): + """ + # only image/jpeg is supported on peertube + # https://github.com/Chocobozzz/PeerTube/blob/b824480af7054a5a49ddb1788c26c769c89ccc8a/server/core/helpers/custom-validators/activitypub/videos.ts#L192 + """ + if not video.thumbnail: + return {} + + return { + "icon": [ + { + "type": "Image", + "url": video.get_thumbnail_url(scheme=True), + "width": video.thumbnail.file.width, + "height": video.thumbnail.file.height, + # TODO: use the real media type when peertub supports JPEG + # "mediaType": video.thumbnail.file_type, + "mediaType": "image/jpeg", + }, + ] + } diff --git a/pod/activitypub/signals.py b/pod/activitypub/signals.py new file mode 100644 index 0000000000..1a0df1dde2 --- /dev/null +++ b/pod/activitypub/signals.py @@ -0,0 +1,38 @@ +"""Signal callbacks.""" + +from django.db import transaction +from .tasks import ( + task_broadcast_local_video_creation, + task_broadcast_local_video_deletion, + task_broadcast_local_video_update, +) + + +def on_video_save(instance, created, **kwargs): + """Celery tasks are triggered after commits and not just after .save() calls, + so we are sure the database is really up to date at the moment we send data accross the network, + and that any federated instance will be able to read the updated data. + + Without this, celery tasks could have been triggered BEFORE the data was actually written to the database, + leading to old data being broadcasted. + """ + + def trigger_save_task(): + if created: + task_broadcast_local_video_creation.delay(instance.id) + + else: + task_broadcast_local_video_update.delay(instance.id) + + transaction.on_commit(trigger_save_task) + + +def on_video_delete(instance, **kwargs): + """At the moment the celery task will be triggered, + the video MAY already have been deleted. + Thus, we avoid to pass a Video id to read in the database, + and we directly pass pertinent data.""" + + task_broadcast_local_video_deletion.delay( + video_id=instance.id, owner_username=instance.owner.username + ) diff --git a/pod/activitypub/tasks.py b/pod/activitypub/tasks.py new file mode 100644 index 0000000000..2e5cec3230 --- /dev/null +++ b/pod/activitypub/tasks.py @@ -0,0 +1,168 @@ +"""Celery tasks configuration.""" + +from django.conf import settings + +try: + from ..custom import settings_local +except ImportError: + from .. import settings as settings_local + +from celery import Celery +from celery.utils.log import get_task_logger + +ACTIVITYPUB_CELERY_BROKER_URL = getattr( + settings_local, "ACTIVITYPUB_CELERY_BROKER_URL", "" +) +CELERY_TASK_ALWAYS_EAGER = getattr(settings, "CELERY_TASK_ALWAYS_EAGER", False) + +activitypub_app = Celery("activitypub", broker=ACTIVITYPUB_CELERY_BROKER_URL) +activitypub_app.conf.task_routes = {"pod.activitypub.tasks.*": {"queue": "activitypub"}} +activitypub_app.conf.task_always_eager = CELERY_TASK_ALWAYS_EAGER +activitypub_app.conf.task_eager_propagates = CELERY_TASK_ALWAYS_EAGER + +logger = get_task_logger(__name__) + + +@activitypub_app.task() +def task_follow(following_id): + from .models import Following + from .network import send_follow_request + + following = Following.objects.get(id=following_id) + return send_follow_request(following) + + +@activitypub_app.task() +def task_index_videos(following_id): + from .models import Following + from .network import index_videos + + following = Following.objects.get(id=following_id) + return index_videos(following) + + +@activitypub_app.task() +def task_handle_inbox_follow(username, data): + from .network import handle_incoming_follow + + return handle_incoming_follow(ap_follow=data) + + +@activitypub_app.task() +def task_handle_inbox_accept(username, data): + from pod.activitypub.utils import ap_object + + from .network import follow_request_accepted + + obj = ap_object(data["object"]) + if obj["type"] == "Follow": + return follow_request_accepted(ap_follow=data["object"]) + + logger.debug("Ignoring inbox 'Accept' action for '%s' object", obj["type"]) + + +@activitypub_app.task() +def task_handle_inbox_reject(username, data): + from pod.activitypub.utils import ap_object + + from .network import follow_request_rejected + + obj = ap_object(data["object"]) + if obj["type"] == "Follow": + return follow_request_rejected(ap_follow=data["object"]) + + logger.debug("Ignoring inbox 'Reject' action for '%s' object", obj["type"]) + + +@activitypub_app.task() +def task_handle_inbox_announce(username, data): + from pod.activitypub.utils import ap_object + + from .network import external_video_added_by_actor, external_video_added_by_channel + + obj = ap_object(data["object"]) + actor = ap_object(data["actor"]) + + if obj["type"] == "Video": + if actor["type"] in ("Application", "Person"): + return external_video_added_by_actor(ap_video=obj, ap_actor=actor) + + elif actor["type"] in ("Group",): + return external_video_added_by_channel(ap_video=obj, ap_channel=actor) + + logger.debug("Ignoring inbox 'Announce' action for '%s' object", obj["type"]) + + +@activitypub_app.task() +def task_handle_inbox_update(username, data): + from pod.activitypub.utils import ap_object + + from .network import external_video_update + + obj = ap_object(data["object"]) + if obj["type"] == "Video": + return external_video_update(ap_video=obj) + + logger.debug("Ignoring inbox 'Update' action for '%s' object", obj["type"]) + + +@activitypub_app.task() +def task_handle_inbox_delete(username, data): + from pod.activitypub.utils import ap_object + + from .network import external_video_deletion + + obj = ap_object(data["object"]) + if obj["type"] == "Video": + return external_video_deletion(ap_video=obj) + + logger.debug("Ignoring inbox 'Delete' action for '%s' object", obj["type"]) + + +@activitypub_app.task() +def task_handle_inbox_undo(username, data): + from pod.activitypub.utils import ap_object + + from .network import handle_incoming_unfollow + + obj = ap_object(data["object"]) + if obj["type"] == "Follow": + return handle_incoming_unfollow(ap_follow=obj) + + logger.debug("Ignoring inbox 'Undo' action for '%s' object", obj["type"]) + + +@activitypub_app.task() +def task_broadcast_local_video_creation(video_id): + from pod.video.models import Video + + from .models import Follower + from .network import send_video_announce_object + + video = Video.objects.get(id=video_id) + # TODO: maybe delegate in subtasks for better performance? + for follower in Follower.objects.all(): + send_video_announce_object(video, follower) + + +@activitypub_app.task() +def task_broadcast_local_video_update(video_id): + from pod.video.models import Video + + from .models import Follower + from .network import send_video_update_object + + video = Video.objects.get(id=video_id) + # TODO: maybe delegate in subtasks for better performance? + for follower in Follower.objects.all(): + send_video_update_object(video, follower) + + +@activitypub_app.task() +def task_broadcast_local_video_deletion(video_id, owner_username): + from .models import Follower + from .network import send_video_delete_object + + # TODO: maybe delegate in subtasks for better performance? + for follower in Follower.objects.all(): + send_video_delete_object(video_id, owner_username, follower) diff --git a/pod/activitypub/tests/__init__.py b/pod/activitypub/tests/__init__.py new file mode 100644 index 0000000000..88d5913425 --- /dev/null +++ b/pod/activitypub/tests/__init__.py @@ -0,0 +1,70 @@ +import json + +import httmock +from django.test import TestCase +from pod.authentication.models import User + + +class ActivityPubTestCase(TestCase): + """ActivityPub test case.""" + + maxDiff = None + fixtures = ["initial_data.json"] + headers = { + "HTTP_ACCEPT": "application/activity+json, application/ld+json", + } + + def setUp(self): + self.admin_user = User.objects.create_superuser( + username="admin", + first_name="Super", + last_name="User", + password="SuperPassword1234", + ) + + def tearDown(self): + del self.admin_user + + @httmock.urlmatch(path=r"^/.well-known/nodeinfo$") + def mock_nodeinfo(self, url, request): + with open("pod/activitypub/tests/fixtures/nodeinfo.json") as fd: + payload = json.load(fd) + + return httmock.response(200, payload) + + @httmock.urlmatch(path=r"^/accounts/peertube$") + def mock_application_actor(self, url, request): + with open("pod/activitypub/tests/fixtures/application_actor.json") as fd: + payload = json.load(fd) + + return httmock.response(200, payload) + + @httmock.urlmatch(path=r"^/accounts/peertube/inbox$") + def mock_inbox(self, url, request): + return httmock.response(204, "") + + @httmock.urlmatch(path=r"^/accounts/peertube/outbox$") + def mock_outbox(self, url, request): + if url.query == "page=1": + fixture = "pod/activitypub/tests/fixtures/outbox-page-1.json" + else: + fixture = "pod/activitypub/tests/fixtures/outbox.json" + + with open(fixture) as fd: + payload = json.load(fd) + + return httmock.response(200, payload) + + @httmock.urlmatch(path=r"^/videos/watch.*") + def mock_get_video(self, url, request): + with open("pod/activitypub/tests/fixtures/peertube_video.json") as fd: + payload = json.load(fd) + + return httmock.response(200, payload) + + @httmock.urlmatch(path=r"^/video-channels/.*") + def mock_get_channel(self, url, request): + with open("pod/activitypub/tests/fixtures/channel.json") as fd: + payload = json.load(fd) + + return httmock.response(200, payload) diff --git a/pod/activitypub/tests/fixtures/accept.json b/pod/activitypub/tests/fixtures/accept.json new file mode 100644 index 0000000000..1973bee56b --- /dev/null +++ b/pod/activitypub/tests/fixtures/accept.json @@ -0,0 +1,24 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" + } + ], + "type": "Accept", + "id": "http://peertube.test/accepts/follows/1", + "actor": "http://peertube.test/accounts/peertube", + "object": { + "type": "Follow", + "id": "http://pod.localhost:8000/ap/following/1", + "actor": "http://pod.localhost:8000/ap", + "object": "http://peertube.test/accounts/peertube" + }, + "signature": { + "type": "RsaSignature2017", + "creator": "http://peertube.test/accounts/peertube", + "created": "2024-06-04T14:36:41.773Z", + "signatureValue": "k21ZCAXIyDVH6w+5hoy6sfSOEB4cQAfZ84XJyue2E8SoLfBV7Vf4IZWtlMa1mFuE3VWX98KsjkJUbhZGTOqk87nS+zidAozOK1LhXEQkglVBanDYOnoYyJbKXZIfQoan6elYuQ1U3EO0wizts42iapFAv7s3lXYC4M2JophY73o=" + } +} diff --git a/pod/activitypub/tests/fixtures/application_actor.json b/pod/activitypub/tests/fixtures/application_actor.json new file mode 100644 index 0000000000..1deaecf4d3 --- /dev/null +++ b/pod/activitypub/tests/fixtures/application_actor.json @@ -0,0 +1,44 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" + }, + { + "pt": "https://joinpeertube.org/ns#", + "sc": "http://schema.org/", + "playlists": { + "@id": "pt:playlists", + "@type": "@id" + }, + "support": { + "@type": "sc:Text", + "@id": "pt:support" + }, + "lemmy": "https://join-lemmy.org/ns#", + "postingRestrictedToMods": "lemmy:postingRestrictedToMods", + "icons": "as:icon" + } + ], + "type": "Application", + "id": "http://peertube.test/accounts/peertube", + "following": "http://peertube.test/accounts/peertube/following", + "followers": "http://peertube.test/accounts/peertube/followers", + "playlists": "http://peertube.test/accounts/peertube/playlists", + "inbox": "http://peertube.test/accounts/peertube/inbox", + "outbox": "http://peertube.test/accounts/peertube/outbox", + "preferredUsername": "peertube", + "url": "http://peertube.test/accounts/peertube", + "name": "peertube", + "endpoints": { + "sharedInbox": "http://peertube.test/inbox" + }, + "publicKey": { + "id": "http://peertube.test/accounts/peertube#main-key", + "owner": "http://peertube.test/accounts/peertube", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIKnSziKKXcEtUwKeK8bA/0HtS\ngv2fuvaiMtGKggW6Mhmmq85nLFRo7+B5/HkEb95PJv8q7HkojpuAlJNuNYcr7FaJ\nob35GcaWA9p/h5XbLlNk7fHHj4SNvJyxK8zU8O+tysKswNtoaAifT1NAsnNJEuAJ\nsPXp5bwUyNsgyBBfSwIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "published": "2024-05-15T11:09:12.862Z", + "summary": null +} diff --git a/pod/activitypub/tests/fixtures/channel.json b/pod/activitypub/tests/fixtures/channel.json new file mode 100644 index 0000000000..3e06037615 --- /dev/null +++ b/pod/activitypub/tests/fixtures/channel.json @@ -0,0 +1,52 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" + }, + { + "pt": "https://joinpeertube.org/ns#", + "sc": "http://schema.org/", + "playlists": { + "@id": "pt:playlists", + "@type": "@id" + }, + "support": { + "@type": "sc:Text", + "@id": "pt:support" + }, + "lemmy": "https://join-lemmy.org/ns#", + "postingRestrictedToMods": "lemmy:postingRestrictedToMods", + "icons": "as:icon" + } + ], + "type": "Group", + "id": "http://peertube.test/video-channels/root_channel", + "following": "http://peertube.test/video-channels/root_channel/following", + "followers": "http://peertube.test/video-channels/root_channel/followers", + "playlists": "http://peertube.test/video-channels/root_channel/playlists", + "inbox": "http://peertube.test/video-channels/root_channel/inbox", + "outbox": "http://peertube.test/video-channels/root_channel/outbox", + "preferredUsername": "root_channel", + "url": "http://peertube.test/video-channels/root_channel", + "name": "Main root channel", + "endpoints": { + "sharedInbox": "http://peertube.test/inbox" + }, + "publicKey": { + "id": "http://peertube.test/video-channels/root_channel#main-key", + "owner": "http://peertube.test/video-channels/root_channel", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDRVjuMdfndpsBj3azB0xp7Gr97\nEstojfwqRsushQ1iKD1CACe+0+PomdDmNdGBSbpDud3s8QGs/eV5/LmFvhgRhqHu\n9gnVvt3NPP1nFgqQtfF8UZrofaN975fGa7qbr2Z0PAYkltbv0NdtF4VxHPF/j/Ae\nyQGL2Yp1w2GLIFwxgwIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "published": "2024-05-15T11:09:12.978Z", + "summary": null, + "support": null, + "postingRestrictedToMods": true, + "attributedTo": [ + { + "type": "Person", + "id": "http://peertube.test/accounts/root" + } + ] +} diff --git a/pod/activitypub/tests/fixtures/nodeinfo.json b/pod/activitypub/tests/fixtures/nodeinfo.json new file mode 100644 index 0000000000..8af195620c --- /dev/null +++ b/pod/activitypub/tests/fixtures/nodeinfo.json @@ -0,0 +1,12 @@ +{ + "links": [ + { + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", + "href": "http://peertube.test/nodeinfo/2.0.json" + }, + { + "rel": "https://www.w3.org/ns/activitystreams#Application", + "href": "https://peertube.test/accounts/peertube" + } + ] +} diff --git a/pod/activitypub/tests/fixtures/outbox-page-1.json b/pod/activitypub/tests/fixtures/outbox-page-1.json new file mode 100644 index 0000000000..c355df73d6 --- /dev/null +++ b/pod/activitypub/tests/fixtures/outbox-page-1.json @@ -0,0 +1,27 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" + } + ], + "id": "http://peertube.test/accounts/peertube/outbox?page=1", + "type": "OrderedCollectionPage", + "partOf": "http://peertube.test/accounts/peertube/outbox", + "orderedItems": [ + { + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://peertube.test/accounts/root/followers" + ], + "type": "Announce", + "id": "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d/announces/1", + "actor": "http://peertube.test/accounts/peertube", + "object": "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d" + } + ], + "totalItems": 1 +} diff --git a/pod/activitypub/tests/fixtures/outbox.json b/pod/activitypub/tests/fixtures/outbox.json new file mode 100644 index 0000000000..375b1b6279 --- /dev/null +++ b/pod/activitypub/tests/fixtures/outbox.json @@ -0,0 +1,13 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" + } + ], + "id": "http://peertube.test/accounts/peertube/outbox", + "type": "OrderedCollection", + "totalItems": 1, + "first": "http://peertube.test/accounts/peertube/outbox?page=1" +} diff --git a/pod/activitypub/tests/fixtures/peertube_video.json b/pod/activitypub/tests/fixtures/peertube_video.json new file mode 100644 index 0000000000..d00b70b337 --- /dev/null +++ b/pod/activitypub/tests/fixtures/peertube_video.json @@ -0,0 +1,298 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" + }, + { + "pt": "https://joinpeertube.org/ns#", + "sc": "http://schema.org/", + "Hashtag": "as:Hashtag", + "category": "sc:category", + "licence": "sc:license", + "subtitleLanguage": "sc:subtitleLanguage", + "sensitive": "as:sensitive", + "language": "sc:inLanguage", + "identifier": "sc:identifier", + "isLiveBroadcast": "sc:isLiveBroadcast", + "liveSaveReplay": { + "@type": "sc:Boolean", + "@id": "pt:liveSaveReplay" + }, + "permanentLive": { + "@type": "sc:Boolean", + "@id": "pt:permanentLive" + }, + "latencyMode": { + "@type": "sc:Number", + "@id": "pt:latencyMode" + }, + "Infohash": "pt:Infohash", + "tileWidth": { + "@type": "sc:Number", + "@id": "pt:tileWidth" + }, + "tileHeight": { + "@type": "sc:Number", + "@id": "pt:tileHeight" + }, + "tileDuration": { + "@type": "sc:Number", + "@id": "pt:tileDuration" + }, + "aspectRatio": { + "@type": "sc:Float", + "@id": "pt:aspectRatio" + }, + "uuid": { + "@type": "sc:identifier", + "@id": "pt:uuid" + }, + "originallyPublishedAt": "sc:datePublished", + "uploadDate": "sc:uploadDate", + "hasParts": "sc:hasParts", + "views": { + "@type": "sc:Number", + "@id": "pt:views" + }, + "state": { + "@type": "sc:Number", + "@id": "pt:state" + }, + "size": { + "@type": "sc:Number", + "@id": "pt:size" + }, + "fps": { + "@type": "sc:Number", + "@id": "pt:fps" + }, + "commentsEnabled": { + "@type": "sc:Boolean", + "@id": "pt:commentsEnabled" + }, + "canReply": "pt:canReply", + "commentsPolicy": { + "@type": "sc:Number", + "@id": "pt:commentsPolicy" + }, + "downloadEnabled": { + "@type": "sc:Boolean", + "@id": "pt:downloadEnabled" + }, + "waitTranscoding": { + "@type": "sc:Boolean", + "@id": "pt:waitTranscoding" + }, + "support": { + "@type": "sc:Text", + "@id": "pt:support" + }, + "likes": { + "@id": "as:likes", + "@type": "@id" + }, + "dislikes": { + "@id": "as:dislikes", + "@type": "@id" + }, + "shares": { + "@id": "as:shares", + "@type": "@id" + }, + "comments": { + "@id": "as:comments", + "@type": "@id" + } + } + ], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://peertube.test/accounts/root/followers" + ], + "type": "Video", + "id": "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d", + "name": "Titre de la vidéo", + "duration": "PT31S", + "uuid": "717c9d87-c912-4943-a392-49fadf2f235d", + "category": { + "identifier": "11", + "name": "News & Politics" + }, + "licence": { + "identifier": "1", + "name": "Attribution" + }, + "language": { + "identifier": "fr", + "name": "French" + }, + "views": 0, + "sensitive": true, + "waitTranscoding": true, + "state": 1, + "commentsEnabled": true, + "canReply": null, + "commentsPolicy": 1, + "downloadEnabled": true, + "published": "2024-06-06T13:06:05.060Z", + "originallyPublishedAt": "2024-06-01T13:13:21.000Z", + "updated": "2024-06-06T13:13:27.957Z", + "tag": [ + { + "type": "Hashtag", + "name": "etiquette1" + }, + { + "type": "Hashtag", + "name": "etiquette2" + } + ], + "mediaType": "text/markdown", + "content": "Ceci est une *description* au format markdown", + "support": "Soutenez la chaîne", + "subtitleLanguage": [ + { + "identifier": "ab", + "name": "Abkhazian", + "url": "http://peertube.test/lazy-static/video-captions/a2beedfa-5334-4bb9-98c3-04b701ea1e71-ab.vtt" + } + ], + "icon": [ + { + "type": "Image", + "url": "http://peertube.test/lazy-static/thumbnails/ae29b949-2ed5-424b-82c6-c4e0e2d75c5b.jpg", + "mediaType": "image/jpeg", + "width": 280, + "height": 157 + }, + { + "type": "Image", + "url": "http://peertube.test/lazy-static/previews/cdcb5319-eb33-4fa8-bc01-9c41553632dd.jpg", + "mediaType": "image/jpeg", + "width": 850, + "height": 480 + } + ], + "preview": [ + { + "type": "Image", + "rel": [ + "storyboard" + ], + "url": [ + { + "mediaType": "image/jpeg", + "href": "http://peertube.test/lazy-static/storyboards/9a4a2044-4a25-4e5b-bdde-f0b11f4d7569.jpg", + "width": 1920, + "height": 324, + "tileWidth": 192, + "tileHeight": 108, + "tileDuration": "PT1S" + } + ] + } + ], + "aspectRatio": 1.7778, + "url": [ + { + "type": "Link", + "mediaType": "text/html", + "href": "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d" + }, + { + "type": "Link", + "mediaType": "application/x-mpegURL", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/9bad4094-4d4d-4041-8e73-6b6f1e4dc643-master.m3u8", + "tag": [ + { + "type": "Infohash", + "name": "f900dbc0ae328d0ba28ff45785c6211e24588c99" + }, + { + "type": "Link", + "name": "sha256", + "mediaType": "application/json", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/82701fa5-ef21-4788-b190-8391bc0a73e2-segments-sha256.json" + }, + { + "type": "Link", + "mediaType": "video/mp4", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/f2e8d1b2-750a-4f30-a92d-7a4ae0125591-270-fragmented.mp4", + "height": 270, + "width": 480, + "size": 653252, + "fps": 30 + }, + { + "type": "Link", + "rel": [ + "metadata", + "video/mp4" + ], + "mediaType": "application/json", + "href": "http://peertube.test/api/v1/videos/717c9d87-c912-4943-a392-49fadf2f235d/metadata/3", + "height": 270, + "width": 480, + "fps": 30 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent", + "href": "http://peertube.test/lazy-static/torrents/69dfcf14-7a26-4452-bdc1-6e7c0a860f54-270-hls.torrent", + "height": 270, + "width": 480, + "fps": 30 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", + "href": "magnet:?xs=http%3A%2F%2Fpeertube.localhost%3A9000%2Flazy-static%2Ftorrents%2F69dfcf14-7a26-4452-bdc1-6e7c0a860f54-270-hls.torrent&xt=urn:btih:791b98a3a433cf0f032794f33390efb350ca78fc&dn=Titre+de+la+vid%C3%A9o&tr=http%3A%2F%2Fpeertube.localhost%3A9000%2Ftracker%2Fannounce&tr=ws%3A%2F%2Fpeertube.localhost%3A9000%2Ftracker%2Fsocket&ws=http%3A%2F%2Fpeertube.localhost%3A9000%2Fstatic%2Fstreaming-playlists%2Fhls%2F717c9d87-c912-4943-a392-49fadf2f235d%2Ff2e8d1b2-750a-4f30-a92d-7a4ae0125591-270-fragmented.mp4", + "height": 270, + "width": 480, + "fps": 30 + } + ] + }, + { + "type": "Link", + "name": "tracker-http", + "rel": [ + "tracker", + "http" + ], + "href": "http://peertube.test/tracker/announce" + }, + { + "type": "Link", + "name": "tracker-websocket", + "rel": [ + "tracker", + "websocket" + ], + "href": "ws://peertube.test/tracker/socket" + } + ], + "likes": "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d/likes", + "dislikes": "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d/dislikes", + "shares": "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d/announces", + "comments": "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d/comments", + "hasParts": "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d/chapters", + "attributedTo": [ + { + "type": "Person", + "id": "http://peertube.test/accounts/root" + }, + { + "type": "Group", + "id": "http://peertube.test/video-channels/root_channel" + } + ], + "isLiveBroadcast": false, + "liveSaveReplay": null, + "permanentLive": null, + "latencyMode": null +} diff --git a/pod/activitypub/tests/fixtures/undo.json b/pod/activitypub/tests/fixtures/undo.json new file mode 100644 index 0000000000..986983505b --- /dev/null +++ b/pod/activitypub/tests/fixtures/undo.json @@ -0,0 +1,30 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" + } + ], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://peertube.test/accounts/peertube/followers" + ], + "type": "Undo", + "id": "http://peertube.test/accounts/peertube/follows/4/undo", + "actor": "http://peertube.test/accounts/peertube", + "object": { + "type": "Follow", + "id": "http://peertube.test/accounts/peertube/follows/4", + "actor": "http://peertube.test/accounts/peertube", + "object": "http://pod.localhost:8000/ap" + }, + "signature": { + "type": "RsaSignature2017", + "creator": "http://peertube.test/accounts/peertube", + "created": "2024-05-16T14:15:38.440Z", + "signatureValue": "TcJcmP/7B3Fbhu8afH2Y8BxCgrInZbzc07evxkx7kN7wcvmnZhR/uEqLu5rfgomw6zwNEDKEdsOMDBPpojHvmNleF43Ba3ofzLJ1pLIQe4aTawNAhC3nhkbZzh35GIRMVTln7rUX7Q/S5gL6P03XVRc3xBcHTvnwx+Oxb0ejIUI=" + } +} diff --git a/pod/activitypub/tests/fixtures/video_creation_account_announce.json b/pod/activitypub/tests/fixtures/video_creation_account_announce.json new file mode 100644 index 0000000000..10281ddecf --- /dev/null +++ b/pod/activitypub/tests/fixtures/video_creation_account_announce.json @@ -0,0 +1,25 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + {"RsaSignature2017": "https://w3id.org/security#RsaSignature2017"} + ], + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "http://peertube.test/video-channels/root_channel/followers", + "http://peertube.test/accounts/peertube/followers", + "http://peertube.test/accounts/root/followers" + ], + "cc": [], + "type": "Announce", + "id": "http://peertube.test/videos/watch/a425fb67-0d67-405f-a305-9cfc62ec00fd/announces/1", + "actor": "http://peertube.test/accounts/peertube", + "object": "http://peertube.test/videos/watch/a425fb67-0d67-405f-a305-9cfc62ec00fd", + "signature": { + "type": "RsaSignature2017", + "creator": "http://peertube.test/accounts/peertube", + "created": "2024-05-15T11:15:55.283Z", + "signatureValue": "I0hzAFwpnlBthbAVas/eN+wAFC2G7mUBPi4ZefgvYKAxm990D0AopzQDUoW+P7Cc8CW7iUv2pX6GwM/m6Ez8LvKaDERmjNjPmAPIAC2Hm/V4/FTuCZ4wiQoIVvRNEsV/4O40Rr+ZyU+A9pJGhFh3RwJ3ezMsCdI36LVx2UHvgBM=" + } +} + diff --git a/pod/activitypub/tests/fixtures/video_creation_channel_announce.json b/pod/activitypub/tests/fixtures/video_creation_channel_announce.json new file mode 100644 index 0000000000..36b0752f6a --- /dev/null +++ b/pod/activitypub/tests/fixtures/video_creation_channel_announce.json @@ -0,0 +1,25 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + {"RsaSignature2017": "https://w3id.org/security#RsaSignature2017"} + ], + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "http://peertube.test/video-channels/root_channel/followers", + "http://peertube.test/accounts/peertube/followers", + "http://peertube.test/accounts/root/followers" + ], + "cc": [], + "type": "Announce", + "id": "http://peertube.test/videos/watch/a425fb67-0d67-405f-a305-9cfc62ec00fd/announces/3", + "actor": "http://peertube.test/video-channels/root_channel", + "object": "http://peertube.test/videos/watch/a425fb67-0d67-405f-a305-9cfc62ec00fd", + "signature": { + "type": "RsaSignature2017", + "creator": "http://peertube.test/video-channels/root_channel", + "created": "2024-05-15T11:15:54.089Z", + "signatureValue": "H6dBSuPhq0jqJVqHYq8+9SgyQNKwdH3wfTqvTyXGPAfM7xcLWU1o7nSViVkRoIMhvTMzFQYAGrBr5HCdTpfv+q6iyuxf3udTdIzaO7ilqmmihI9gu104nRg61tNPGkbWwhQ2IWLFlVGVlKsTzNdU2VteOQT98LpA1GpFa9ExDdk=" + } +} + diff --git a/pod/activitypub/tests/fixtures/video_delete.json b/pod/activitypub/tests/fixtures/video_delete.json new file mode 100644 index 0000000000..7724eff481 --- /dev/null +++ b/pod/activitypub/tests/fixtures/video_delete.json @@ -0,0 +1,26 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" + } + ], + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "http://peertube.test/video-channels/root_channel/followers", + "http://peertube.test/accounts/peertube/followers", + "http://peertube.test/accounts/root/followers" + ], + "cc": [], + "type": "Delete", + "id": "http://peertube.test/videos/watch/74b35c2c-5b41-47ee-b331-86ec80f317d8/delete", + "actor": "http://peertube.test/accounts/root", + "object": "http://peertube.test/videos/watch/74b35c2c-5b41-47ee-b331-86ec80f317d8", + "signature": { + "type": "RsaSignature2017", + "creator": "http://peertube.test/accounts/root", + "created": "2024-05-15T13:03:58.608Z", + "signatureValue": "sFcc/OwZVTjo1+iKZ9GSEcS/5KPfY5z1ul7q4nCi59B54fqM1P+WO6fupqD1SB1xYsLtPKDRpnoi3n0+vlPd6AsYN5dL2gHDZ79S0OygzyLFw18GlOSGdr6IFXNdQ7Gr0gTCDu7TXL42HzEes4OXz1LaV0Q+CagvsB96bkV5ZBM=" + } +} diff --git a/pod/activitypub/tests/fixtures/video_update.json b/pod/activitypub/tests/fixtures/video_update.json new file mode 100644 index 0000000000..7e533a2995 --- /dev/null +++ b/pod/activitypub/tests/fixtures/video_update.json @@ -0,0 +1,281 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" + }, + { + "pt": "https://joinpeertube.org/ns#", + "sc": "http://schema.org/", + "Hashtag": "as:Hashtag", + "category": "sc:category", + "licence": "sc:license", + "subtitleLanguage": "sc:subtitleLanguage", + "sensitive": "as:sensitive", + "language": "sc:inLanguage", + "identifier": "sc:identifier", + "isLiveBroadcast": "sc:isLiveBroadcast", + "liveSaveReplay": { + "@type": "sc:Boolean", + "@id": "pt:liveSaveReplay" + }, + "permanentLive": { + "@type": "sc:Boolean", + "@id": "pt:permanentLive" + }, + "latencyMode": { + "@type": "sc:Number", + "@id": "pt:latencyMode" + }, + "Infohash": "pt:Infohash", + "tileWidth": { + "@type": "sc:Number", + "@id": "pt:tileWidth" + }, + "tileHeight": { + "@type": "sc:Number", + "@id": "pt:tileHeight" + }, + "tileDuration": { + "@type": "sc:Number", + "@id": "pt:tileDuration" + }, + "aspectRatio": { + "@type": "sc:Float", + "@id": "pt:aspectRatio" + }, + "uuid": { + "@type": "sc:identifier", + "@id": "pt:uuid" + }, + "originallyPublishedAt": "sc:datePublished", + "uploadDate": "sc:uploadDate", + "hasParts": "sc:hasParts", + "views": { + "@type": "sc:Number", + "@id": "pt:views" + }, + "state": { + "@type": "sc:Number", + "@id": "pt:state" + }, + "size": { + "@type": "sc:Number", + "@id": "pt:size" + }, + "fps": { + "@type": "sc:Number", + "@id": "pt:fps" + }, + "commentsEnabled": { + "@type": "sc:Boolean", + "@id": "pt:commentsEnabled" + }, + "downloadEnabled": { + "@type": "sc:Boolean", + "@id": "pt:downloadEnabled" + }, + "waitTranscoding": { + "@type": "sc:Boolean", + "@id": "pt:waitTranscoding" + }, + "support": { + "@type": "sc:Text", + "@id": "pt:support" + }, + "likes": { + "@id": "as:likes", + "@type": "@id" + }, + "dislikes": { + "@id": "as:dislikes", + "@type": "@id" + }, + "shares": { + "@id": "as:shares", + "@type": "@id" + }, + "comments": { + "@id": "as:comments", + "@type": "@id" + } + } + ], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://peertube.test/accounts/root/followers" + ], + "type": "Update", + "id": "http://peertube.test/videos/watch/74b35c2c-5b41-47ee-b331-86ec80f317d8/updates/2024-05-15T12:40:45.399Z", + "actor": "http://peertube.test/accounts/root", + "object": { + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "http://peertube.test/accounts/root/followers" + ], + "type": "Video", + "id": "http://peertube.test/videos/watch/74b35c2c-5b41-47ee-b331-86ec80f317d8", + "name": "terre", + "duration": "PT31S", + "uuid": "74b35c2c-5b41-47ee-b331-86ec80f317d8", + "views": 0, + "sensitive": false, + "waitTranscoding": true, + "state": 1, + "commentsEnabled": true, + "downloadEnabled": true, + "published": "2024-05-15T12:39:43.713Z", + "originallyPublishedAt": null, + "updated": "2024-05-15T12:40:45.399Z", + "tag": [], + "mediaType": "text/markdown", + "content": "YOLO", + "support": null, + "subtitleLanguage": [], + "icon": [ + { + "type": "Image", + "url": "http://peertube.test/lazy-static/thumbnails/17544b3a-610a-4ca8-890a-7ad33da1003a.jpg", + "mediaType": "image/jpeg", + "width": 280, + "height": 157 + }, + { + "type": "Image", + "url": "http://peertube.test/lazy-static/previews/e5433ef5-92eb-433b-99d7-a0868ea831d9.jpg", + "mediaType": "image/jpeg", + "width": 850, + "height": 480 + } + ], + "preview": [ + { + "type": "Image", + "rel": [ + "storyboard" + ], + "url": [ + { + "mediaType": "image/jpeg", + "href": "http://peertube.test/lazy-static/storyboards/ac8e9031-cd22-4b55-b13a-6ea7303d84e8.jpg", + "width": 1920, + "height": 324, + "tileWidth": 192, + "tileHeight": 108, + "tileDuration": "PT1S" + } + ] + } + ], + "aspectRatio": 1.7778, + "url": [ + { + "type": "Link", + "mediaType": "text/html", + "href": "http://peertube.test/videos/watch/74b35c2c-5b41-47ee-b331-86ec80f317d8" + }, + { + "type": "Link", + "mediaType": "application/x-mpegURL", + "href": "http://peertube.test/static/streaming-playlists/hls/74b35c2c-5b41-47ee-b331-86ec80f317d8/af301f93-0d7d-4940-8e5e-aba13049ac26-master.m3u8", + "tag": [ + { + "type": "Infohash", + "name": "b2a1f81a855cd472c54d08bf5a8efb7bc1b7f9a8" + }, + { + "type": "Link", + "name": "sha256", + "mediaType": "application/json", + "href": "http://peertube.test/static/streaming-playlists/hls/74b35c2c-5b41-47ee-b331-86ec80f317d8/dc1221ee-1090-40b8-bdfa-5a5f4cd67c98-segments-sha256.json" + }, + { + "type": "Link", + "mediaType": "video/mp4", + "href": "http://peertube.test/static/streaming-playlists/hls/74b35c2c-5b41-47ee-b331-86ec80f317d8/519179ac-f8f9-4c50-a90c-2d6bf554e508-270-fragmented.mp4", + "height": 270, + "width": 480, + "size": 653210, + "fps": 30 + }, + { + "type": "Link", + "rel": [ + "metadata", + "video/mp4" + ], + "mediaType": "application/json", + "href": "http://peertube.test/api/v1/videos/74b35c2c-5b41-47ee-b331-86ec80f317d8/metadata/3", + "height": 270, + "width": 480, + "fps": 30 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent", + "href": "http://peertube.test/lazy-static/torrents/b0e90759-ec93-47b1-a54b-124eecc7e920-270-hls.torrent", + "height": 270, + "width": 480, + "fps": 30 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", + "href": "magnet:?xs=http%3A%2F%2Fpeertube.localhost%3A9000%2Flazy-static%2Ftorrents%2Fb0e90759-ec93-47b1-a54b-124eecc7e920-270-hls.torrent&xt=urn:btih:5072ce02cf06578a6ea1c3aa95f80f25afc33949&dn=terre&tr=http%3A%2F%2Fpeertube.localhost%3A9000%2Ftracker%2Fannounce&tr=ws%3A%2F%2Fpeertube.localhost%3A9000%2Ftracker%2Fsocket&ws=http%3A%2F%2Fpeertube.localhost%3A9000%2Fstatic%2Fstreaming-playlists%2Fhls%2F74b35c2c-5b41-47ee-b331-86ec80f317d8%2F519179ac-f8f9-4c50-a90c-2d6bf554e508-270-fragmented.mp4", + "height": 270, + "width": 480, + "fps": 30 + } + ] + }, + { + "type": "Link", + "name": "tracker-http", + "rel": [ + "tracker", + "http" + ], + "href": "http://peertube.test/tracker/announce" + }, + { + "type": "Link", + "name": "tracker-websocket", + "rel": [ + "tracker", + "websocket" + ], + "href": "ws://peertube.test/tracker/socket" + } + ], + "likes": "http://peertube.test/videos/watch/74b35c2c-5b41-47ee-b331-86ec80f317d8/likes", + "dislikes": "http://peertube.test/videos/watch/74b35c2c-5b41-47ee-b331-86ec80f317d8/dislikes", + "shares": "http://peertube.test/videos/watch/74b35c2c-5b41-47ee-b331-86ec80f317d8/announces", + "comments": "http://peertube.test/videos/watch/74b35c2c-5b41-47ee-b331-86ec80f317d8/comments", + "hasParts": "http://peertube.test/videos/watch/74b35c2c-5b41-47ee-b331-86ec80f317d8/chapters", + "attributedTo": [ + { + "type": "Person", + "id": "http://peertube.test/accounts/root" + }, + { + "type": "Group", + "id": "http://peertube.test/video-channels/root_channel" + } + ], + "isLiveBroadcast": false, + "liveSaveReplay": null, + "permanentLive": null, + "latencyMode": null + }, + "signature": { + "type": "RsaSignature2017", + "creator": "http://peertube.test/accounts/root", + "created": "2024-05-15T12:40:45.468Z", + "signatureValue": "YYYLp2CBIqHwF3LbP9cRgRyButsY32qVAtrpNPRS93TkwOfst6maqpS7xRdRsTyj+i6qs4EnfW0wWEd5tEbhMNr4gBZdvQ+Ohe7pOX15QNhmDVuKSaf5hnR4q44hEjUU6QPerF1YIWNNiOoJW1u//q4JWuYSdGWJnleRANYEiuQ=" + } +} diff --git a/pod/activitypub/tests/fixtures/video_view.json b/pod/activitypub/tests/fixtures/video_view.json new file mode 100644 index 0000000000..31ac51f085 --- /dev/null +++ b/pod/activitypub/tests/fixtures/video_view.json @@ -0,0 +1,34 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + {"RsaSignature2017": "https://w3id.org/security#RsaSignature2017"}, + { + "pt": "https://joinpeertube.org/ns#", + "sc": "http://schema.org/", + "WatchAction": "sc:WatchAction", + "InteractionCounter": "sc:InteractionCounter", + "interactionType": "sc:interactionType", + "userInteractionCount": "sc:userInteractionCount" + } + ], + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "http://peertube.test/video-channels/root_channel/followers", + "http://peertube.test/accounts/peertube/followers", + "http://peertube.test/accounts/root/followers" + ], + "cc": [], + "id": "http://peertube.test/accounts/peertube/views/videos/1/CBmE6B8qR3wmPvfXxlnzYLZj2aGxHbuP-a425fb67-0d67-405f-a305-9cfc62ec00fd", + "type": "View", + "actor": "http://peertube.test/accounts/peertube", + "object": "http://peertube.test/videos/watch/a425fb67-0d67-405f-a305-9cfc62ec00fd", + "expires": "2024-05-15T11:17:56.017Z", + "signature": { + "type": "RsaSignature2017", + "creator": "http://peertube.test/accounts/peertube", + "created": "2024-05-15T11:15:56.023Z", + "signatureValue": "xCmw4sWlGwss1ksoYSWAZ3g2+ZCy9ILiicz2YYgfeyCssstKDV405l6NfP+g7T0i4icQnjhyaOAYjveQlLSEvth1lhrwStBpY6WboAKbpct6aFV37a5FTAHHZD/zlGHRGTf2PJrPem4MMB0zI9Hr4K0gqylHGMDazyPElJl4S4E=" + } +} + diff --git a/pod/activitypub/tests/test_admin.py b/pod/activitypub/tests/test_admin.py new file mode 100644 index 0000000000..60a7401eb2 --- /dev/null +++ b/pod/activitypub/tests/test_admin.py @@ -0,0 +1,72 @@ +from . import ActivityPubTestCase +from unittest.mock import patch +import httmock +from pod.activitypub.models import Following + + +class AdminActivityPubTestCase(ActivityPubTestCase): + def setUp(self): + super().setUp() + self.client.force_login(self.admin_user) + + def test_send_federation_request(self): + """Nominal case test for the admin 'send_federation_request' action.""" + + following = Following.objects.create( + object="http://peertube.test", status=Following.Status.NONE + ) + + with httmock.HTTMock( + self.mock_nodeinfo, self.mock_application_actor, self.mock_inbox + ): + response = self.client.post( + "/admin/activitypub/following/", + { + "action": "send_federation_request", + "_selected_action": [ + str(following.id), + ], + }, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + str(list(response.context["messages"])[0]), + "The federation requests have been sent", + ) + + following.refresh_from_db() + self.assertEqual(following.status, Following.Status.REQUESTED) + + def test_reindex_videos(self): + """Nominal case test for the admin 'reindex_videos' action.""" + + following = Following.objects.create( + object="http://peertube.test", status=Following.Status.NONE + ) + + with httmock.HTTMock( + self.mock_nodeinfo, + self.mock_application_actor, + self.mock_outbox, + self.mock_get_video, + ), patch( + "pod.activitypub.network.ap_video_to_external_video" + ) as ap_video_to_external_video: + response = self.client.post( + "/admin/activitypub/following/", + { + "action": "reindex_videos", + "_selected_action": [ + str(following.id), + ], + }, + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + str(list(response.context["messages"])[0]), + "The video indexations have started", + ) + self.assertTrue(ap_video_to_external_video.called) diff --git a/pod/activitypub/tests/test_federation.py b/pod/activitypub/tests/test_federation.py new file mode 100644 index 0000000000..eb2c9aec78 --- /dev/null +++ b/pod/activitypub/tests/test_federation.py @@ -0,0 +1,158 @@ +import json +from unittest import mock + +import httmock + +from pod.activitypub.models import Follower + +from . import ActivityPubTestCase + + +class ActivityPubViewTest(ActivityPubTestCase): + def test_webfinger_view(self): + """Test for webfinger view.""" + account = "acct:instance@instance_domain" + response = self.client.get("/.well-known/webfinger", {"resource": account}) + self.assertEqual(response.status_code, 200) + + expected = { + "subject": account, + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": "http://pod.localhost:8000/ap", + }, + ], + } + self.assertJSONEqual(response.content, expected) + + def test_instance_account_view(self): + """Test for instance_account view.""" + response = self.client.get("/ap", **self.headers) + self.assertEqual(response.status_code, 200) + expected = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + {"RsaSignature2017": "https://w3id.org/security#RsaSignature2017"}, + ], + "type": "Application", + "id": "http://pod.localhost:8000/ap", + "following": "http://pod.localhost:8000/ap/following", + "followers": "http://pod.localhost:8000/ap/followers", + "inbox": "http://pod.localhost:8000/ap/inbox", + "outbox": "http://pod.localhost:8000/ap/outbox", + "url": "http://pod.localhost:8000/ap", + "name": "pod", + "preferredUsername": "pod", + "endpoints": {"sharedInbox": "http://pod.localhost:8000/ap/inbox"}, + "publicKey": { + "id": "http://pod.localhost:8000/ap#main-key", + "owner": "http://pod.localhost:8000/ap", + "publicKeyPem": mock.ANY, + }, + } + self.assertJSONEqual(response.content, expected) + + def test_follow_accept(self): + """Test that a Follow request returns a 204, and post an Accept response in the follower's inbox.""" + self.assertEqual(Follower.objects.all().count(), 0) + + @httmock.urlmatch(path=r"^/accounts/peertube$") + def follower_ap_url(url, request): + return httmock.response( + 200, + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "RsaSignature2017": "https://w3id.org/security#RsaSignature2017" + }, + ], + "type": "Application", + "id": "http://peertube.test/accounts/peertube", + "following": "http://peertube.test/accounts/peertube/following", + "followers": "http://peertube.test/accounts/peertube/followers", + "inbox": "http://peertube.test/accounts/peertube/inbox", + "outbox": "http://peertube.test/accounts/peertube/outbox", + "url": "http://peertube.test/accounts/peertube", + "name": "pod", + "preferredUsername": "pod", + "publicKey": { + "id": "http://peertube.test/accounts/peertube#main-key", + "owner": "http://peertube.test/accounts/peertube", + "publicKeyPem": "foobar", + }, + }, + ) + + payload = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + {"RsaSignature2017": "https://w3id.org/security#RsaSignature2017"}, + ], + "type": "Follow", + "id": "http://peertube.test/accounts/peertube/follows/4", + "actor": "http://peertube.test/accounts/peertube", + "object": "http://pod.localhost:8000/.well-known/peertube", + "signature": { + "type": "RsaSignature2017", + "creator": "http://peertube.test/accounts/peertube", + "created": "2024-02-15T09:54:14.188Z", + "signatureValue": "Cnh40KpjP7p0o1MBiTHkEHY4vXQnBOTVEkONurdlpGAvV8OAQgOCACQD8cHPE9E5W00+X7SrbzP76PTUpwCbRbxFXHiDq+9Y1dTQs5rLkDS2XSgu75XW++V95glIUUP1jxp7MfqMllxwPYjlVcM6x8jFYNVst2/QTm+Jj0IocSs=", + }, + } + + with httmock.HTTMock(follower_ap_url), mock.patch("requests.post") as post: + response = self.client.post( + "/ap/inbox", + json.dumps(payload), + content_type="application/json", + **self.headers, + ) + self.assertEqual(response.status_code, 204) + + expected = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + {"RsaSignature2017": "https://w3id.org/security#RsaSignature2017"}, + ], + "id": "http://pod.localhost:8000/accepts/follows/1", + "type": "Accept", + "actor": "http://pod.localhost:8000/.well-known/peertube", + "object": { + "type": "Follow", + "id": "http://peertube.test/accounts/peertube/follows/4", + "actor": "http://peertube.test/accounts/peertube", + "object": "http://pod.localhost:8000/.well-known/peertube", + }, + "signature": mock.ANY, + } + inbox_url = "http://peertube.test/accounts/peertube/inbox" + post.assert_called_with( + inbox_url, json=expected, headers=mock.ANY, timeout=mock.ANY + ) + + follower = Follower.objects.get() + assert follower.actor == "http://peertube.test/accounts/peertube" + + def test_unfollow(self): + """Test that a Undo Follow request returns a 204, and remove the follower from the database""" + follower = Follower(actor="http://peertube.test/accounts/peertube") + follower.save() + + with open("pod/activitypub/tests/fixtures/undo.json") as fd: + payload = json.load(fd) + + response = self.client.post( + "/ap/inbox", + json.dumps(payload), + content_type="application/json", + **self.headers, + ) + self.assertEqual(response.status_code, 204) + self.assertEqual(Follower.objects.all().count(), 0) diff --git a/pod/activitypub/tests/test_video_discovery.py b/pod/activitypub/tests/test_video_discovery.py new file mode 100644 index 0000000000..dd243f258f --- /dev/null +++ b/pod/activitypub/tests/test_video_discovery.py @@ -0,0 +1,20 @@ +import json + + +from . import ActivityPubTestCase + + +class VideoDiscoveryTest(ActivityPubTestCase): + def test_video_deserialization(self): + """Test ExternalVideo creation from a AP Video.""" + with open("pod/activitypub/tests/fixtures/peertube_video.json") as fd: + payload = json.load(fd) + + # video = ap_video_to_external_video(payload) + + # assert ( + # video.ap_id + # == "http://peertube.test/videos/watch/dc6d7e53-9acc-45ca-ac3e-adac05c4bb77" + # ) + + # TODO: implement the rest of the test diff --git a/pod/activitypub/tests/test_video_update.py b/pod/activitypub/tests/test_video_update.py new file mode 100644 index 0000000000..fb349d56da --- /dev/null +++ b/pod/activitypub/tests/test_video_update.py @@ -0,0 +1,96 @@ +import json + +import httmock + +from . import ActivityPubTestCase + + +class VideoUpdateTest(ActivityPubTestCase): + def test_video_creation(self): + """Test video creation activities on the inbox. + + When a Video is created on peertube, it sends two announces: + - one for the video creation on the user profile; + - one for the video addition on the user channel. + + This tests the situation where the account announce is received first. + TODO: Check what happens if the messages are received the other way + """ + + with open( + "pod/activitypub/tests/fixtures/video_creation_account_announce.json" + ) as fd: + account_announce_payload = json.load(fd) + + with httmock.HTTMock(self.mock_application_actor, self.mock_get_video): + response = self.client.post( + "/ap/inbox", + json.dumps(account_announce_payload), + content_type="application/json", + **self.headers, + ) + self.assertEqual(response.status_code, 204) + + # TODO: assert ExternalVideo is created + + with open( + "pod/activitypub/tests/fixtures/video_creation_channel_announce.json" + ) as fd: + channel_announce_payload = json.load(fd) + + with httmock.HTTMock(self.mock_get_channel, self.mock_get_video): + response = self.client.post( + "/ap/inbox", + json.dumps(channel_announce_payload), + content_type="application/json", + **self.headers, + ) + self.assertEqual(response.status_code, 204) + + # TODO: assert ExternalVideo is added to the channel + + def test_video_view(self): + """Test that View activities are ignored""" + + with open("pod/activitypub/tests/fixtures/video_view.json") as fd: + payload = json.load(fd) + + self.client.post( + "/ap/inbox", + json.dumps(payload), + content_type="application/json", + **self.headers, + ) + + def test_video_update(self): + """Test the video update activity on the inbox""" + + with open("pod/activitypub/tests/fixtures/video_update.json") as fd: + payload = json.load(fd) + + response = self.client.post( + "/ap/inbox", + json.dumps(payload), + content_type="application/json", + **self.headers, + ) + self.assertEqual(response.status_code, 204) + + # TODO: assert ExternalVideo is updated + + def test_video_delete(self): + """Test the video update activity on the inbox""" + + with open("pod/activitypub/tests/fixtures/video_delete.json") as fd: + payload = json.load(fd) + + with httmock.HTTMock(self.mock_get_video): + response = self.client.post( + "/ap/inbox", + json.dumps(payload), + content_type="application/json", + **self.headers, + ) + self.assertEqual(response.status_code, 204) + + # TODO: assert ExternalVideo is deleted diff --git a/pod/activitypub/urls.py b/pod/activitypub/urls.py new file mode 100644 index 0000000000..35e57fbfc0 --- /dev/null +++ b/pod/activitypub/urls.py @@ -0,0 +1,33 @@ +from django.urls import path + +from . import views + +app_name = "activitypub" + + +urlpatterns = [ + path(".well-known/nodeinfo", views.nodeinfo, name="nodeinfo"), + path(".well-known/webfinger", views.webfinger, name="webfinger"), + path("ap", views.account, name="account"), + path("ap/account/", views.account, name="account"), + path("ap/inbox", views.inbox, name="inbox"), + path("ap/account//inbox", views.inbox, name="inbox"), + path( + "ap/account//channel", + views.account_channel, + name="account_channel", + ), + path("ap/outbox", views.outbox, name="outbox"), + path("ap/account//outbox", views.outbox, name="outbox"), + path("ap/following", views.following, name="following"), + path("ap/account//following", views.following, name="following"), + path("ap/followers", views.followers, name="followers"), + path("ap/account//followers", views.followers, name="followers"), + path("ap/video/", views.video, name="video"), + path("ap/video//likes", views.likes, name="likes"), + path("ap/video//dislikes", views.dislikes, name="dislikes"), + path("ap/video//shares", views.shares, name="comments"), + path("ap/video//comments", views.comments, name="shares"), + path("ap/video//chapters", views.chapters, name="chapters"), + path("ap/channel/", views.channel, name="channel"), +] diff --git a/pod/activitypub/utils.py b/pod/activitypub/utils.py new file mode 100644 index 0000000000..ff589872f1 --- /dev/null +++ b/pod/activitypub/utils.py @@ -0,0 +1,221 @@ +import base64 +from pyld import jsonld +import datetime +import email.utils +import hashlib +import json +import logging +import random +import uuid +from collections import namedtuple +from urllib.parse import urlencode, urlparse, urlunparse + +import requests +from Crypto.Hash import SHA256 +from Crypto.PublicKey import RSA +from Crypto.Signature import pkcs1_15 +from django.conf import settings +from django.contrib.sites.models import Site +from django.urls import reverse + +from pod.video.models import Video + +from .constants import AP_REQUESTS_TIMEOUT, BASE_HEADERS + +logger = logging.getLogger(__name__) +URLComponents = namedtuple( + typename="URLComponents", + field_names=["scheme", "netloc", "url", "path", "query", "fragment"], +) + + +def make_url(scheme=None, netloc=None, params=None, path="", url="", fragment=""): + if scheme is None: + scheme = "https" if getattr(settings, "SECURE_SSL_REDIRECT") else "http" + + if netloc is None: + current_site = Site.objects.get_current() + netloc = current_site.domain + + if params is not None: + tuples = [(key, value) for key, values in params.items() for value in values] + params = urlencode(tuples, safe=":") + + return urlunparse( + URLComponents( + scheme=scheme, + netloc=netloc, + query=params or {}, + url=url, + path=path, + fragment=fragment, + ) + ) + + +def ap_url(suffix=""): + """Returns a full URL to be used in activitypub context.""" + return make_url(url=suffix) + + +def payload_hash(payload): + payload_json = json.dumps(payload) if isinstance(payload, dict) else payload + payload_hash = SHA256.new(payload_json.encode("utf-8")) + digest = payload_hash.digest() + return digest + + +def base64_signature(private_key, string): + to_be_signed_str_bytes = bytes(string, "utf-8") + to_be_signed_str_hash = SHA256.new(bytes(to_be_signed_str_bytes)) + sig = pkcs1_15.new(private_key).sign(to_be_signed_str_hash) + sig_base64 = base64.b64encode(sig).decode() + return sig_base64 + + +def payload_normalize(payload): + return jsonld.normalize( + payload, {"algorithm": "URDNA2015", "format": "application/n-quads"} + ) + + +def signed_payload_headers(payload, url): + """Sign JSON-LD payload according to the 'Signing HTTP Messages' RFC draft. + This brings compatibility with peertube (and mastodon for instance). + + More information here: + - https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12 + - https://framacolibri.org/t/rfc9421-replaces-the-signing-http-messages-draft/20911/2 + """ + date = email.utils.formatdate(usegmt=True) + url = urlparse(url) + + private_key = RSA.import_key(settings.ACTIVITYPUB_PRIVATE_KEY) + payload_hash_raw = payload_hash(payload) + payload_hash_base64 = base64.b64encode(payload_hash_raw).decode() + + to_be_signed_str = ( + f"(request-target): post {url.path}\n" + f"host: {url.netloc}\n" + f"date: {date}\n" + f"digest: SHA-256={payload_hash_base64}" + ) + sig_base64 = base64_signature(private_key, to_be_signed_str) + + public_key_url = ap_url(reverse("activitypub:account")) + "#main-key" + signature_header = f'keyId="{public_key_url}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="{sig_base64}"' + request_headers = { + "host": url.netloc, + "date": date, + "digest": f"SHA-256={payload_hash_base64}", + "content-type": "application/activity+json", + "signature": signature_header, + } + return request_headers + + +def signature_payload(payload, url): + """Sign JSON-LD payload according to the 'Linked Data Signatures 1.0' RFC draft. + This brings compatibility with peertube (and mastodon for instance). + + More information here: + - https://web.archive.org/web/20170717200644/https://w3c-dvcg.github.io/ld-signatures/ + - https://docs.joinmastodon.org/spec/security/#ld + """ + private_key = RSA.import_key(settings.ACTIVITYPUB_PRIVATE_KEY) + + signature = { + "type": "RsaSignature2017", + "creator": payload["actor"], + "created": datetime.datetime.now(tz=datetime.timezone.utc).isoformat(), + } + options_payload = { + "@context": [ + "https://w3id.org/security/v1", + {"RsaSignature2017": "https://w3id.org/security#RsaSignature2017"}, + ], + "creator": signature["creator"], + "created": signature["created"], + } + options_normalized = payload_normalize(options_payload) + options_hash = payload_hash(options_normalized) + + document_normalized = payload_normalize(payload) + document_hash = payload_hash(document_normalized) + + to_sign = options_hash.hex() + document_hash.hex() + signature["signatureValue"] = base64_signature(private_key, to_sign) + + return signature + + +def stable_uuid(seed, version=None): + """Always returns the same UUID given the same input string.""" + + full_seed = str(seed) + settings.SECRET_KEY + m = hashlib.md5() + m.update(full_seed.encode("utf-8")) + return uuid.UUID(m.hexdigest(), version=version) + + +def make_magnet_url(video: Video, mp4): + """Build a fake - but valid - magnet URL for compatibility with peertube < 6.2""" + + uuid = stable_uuid(video.id, version=4) + fake_hash = "".join( + random.choice("0123456789abcdefghijklmnopqrstuvwxyz") for _ in range(40) + ) + payload = { + "dn": [video.slug], + "tr": [ + make_url(url="/tracker/announce"), + make_url(scheme="ws", url="/tracker/announce"), + ], + "ws": [ + make_url( + url=f"/static/streaming-playlists/hls/{uuid}-{mp4.height}-fragmented.mp4" + ) + ], + "xs": [make_url(url=f"/lazy-static/torrents/{uuid}-{mp4.height}-hls.torrent")], + "xt": [f"urn:btih:{fake_hash}"], + } + return make_url( + scheme="magnet", + netloc="", + params=payload, + ) + + +def ap_object(obj): + """If obj is actually a link to a distant object, perform the request to get the object.""" + + if isinstance(obj, str): + result = requests.get( + obj, headers=BASE_HEADERS, timeout=AP_REQUESTS_TIMEOUT + ).json() + logger.debug( + "Read from AP endpoint: %s\n%s", obj, json.dumps(result, indent=True) + ) + return result + return obj + + +def ap_post(url, payload, **kwargs): + """Sign and post an AP payload at a given URL.""" + + logger.warning( + "Posting to AP endpoint: %s\n%s", url, json.dumps(payload, indent=True) + ) + + payload["signature"] = signature_payload(payload, url) + signature_headers = signed_payload_headers(payload, url) + headers = kwargs.pop("headers", {}) + timeout = kwargs.pop("timeout", AP_REQUESTS_TIMEOUT) + response = requests.post( + url, + json=payload, + headers={**BASE_HEADERS, **signature_headers, **headers}, + timeout=timeout, + **kwargs, + ) + return response diff --git a/pod/activitypub/views.py b/pod/activitypub/views.py new file mode 100644 index 0000000000..aeadb02391 --- /dev/null +++ b/pod/activitypub/views.py @@ -0,0 +1,418 @@ +"""Django ActivityPub endpoints""" + +import json +import logging + +from django.contrib.auth.models import User +from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt + +from pod.video.models import Channel, Video + +from .constants import ( + AP_DEFAULT_CONTEXT, + AP_PT_CHANNEL_CONTEXT, + AP_PT_CHAPTERS_CONTEXT, + AP_PT_VIDEO_CONTEXT, +) +from .serialization.account import account_to_ap_actor +from .serialization.account import account_to_ap_group +from .serialization.channel import channel_to_ap_group +from .serialization.video import video_to_ap_video +from .tasks import ( + task_handle_inbox_accept, + task_handle_inbox_announce, + task_handle_inbox_delete, + task_handle_inbox_follow, + task_handle_inbox_reject, + task_handle_inbox_update, + task_handle_inbox_undo, +) +from .utils import ap_url + +logger = logging.getLogger(__name__) + + +AP_PAGE_SIZE = 25 + + +def nodeinfo(request): + """ + Nodeinfo endpoint. This is the entrypoint for ActivityPub federation. + + https://github.com/jhass/nodeinfo/blob/main/PROTOCOL.md + https://nodeinfo.diaspora.software/ + """ + + response = { + "links": [ + { + "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0", + # This URL is not implemented yet as it does not seem mandatory + # for Peertube pairing. + "href": ap_url("/nodeinfo/2.0.json"), + }, + { + "rel": "https://www.w3.org/ns/activitystreams#Application", + "href": ap_url(reverse("activitypub:account")), + }, + ] + } + logger.debug("nodeinfo response: %s", json.dumps(response, indent=True)) + return JsonResponse(response, status=200) + + +@csrf_exempt +def webfinger(request): + """webfinger endpoint as described in RFC7033. + + Deal with account information request and return account endpoints. + + https://www.rfc-editor.org/rfc/rfc7033.html + https://docs.joinmastodon.org/spec/webfinger/ + """ + + # TODO: check that this is even needed + # TODO: reject accounts that are not peertube@THISDOMAIN + + resource = request.GET.get("resource", "") + if resource: + response = { + "subject": resource, + "links": [ + { + "rel": "self", + "type": "application/activity+json", + "href": ap_url(reverse("activitypub:account")), + } + ], + } + logger.debug("webfinger response: %s", json.dumps(response, indent=True)) + return JsonResponse(response, status=200) + + +@csrf_exempt +def account(request, username=None): + """ + 'Person' or 'Application' description as defined by ActivityStreams. + + https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person + https://www.w3.org/TR/activitystreams-vocabulary/#dfn-application + """ + user = get_object_or_404(User, username=username) if username else None + + context = ( + AP_DEFAULT_CONTEXT + [AP_PT_CHANNEL_CONTEXT] if user else AP_DEFAULT_CONTEXT + ) + response = { + "@context": context, + **account_to_ap_actor(user), + } + logger.debug("account response: %s", json.dumps(response, indent=True)) + return JsonResponse(response, status=200) + + +@csrf_exempt +def inbox(request, username=None): + """ + Inbox as defined in ActivityPub. + https://www.w3.org/TR/activitypub/#inbox + """ + + data = json.loads(request.body.decode()) if request.body else None + logger.warning("inbox query: %s", json.dumps(data, indent=True)) + # TODO: test HTTP signature + + if not username and data["type"] == "Follow": + task_handle_inbox_follow.delay(username, data) + + elif not username and data["type"] == "Accept": + task_handle_inbox_accept.delay(username, data) + + elif not username and data["type"] == "Reject": + task_handle_inbox_reject.delay(username, data) + + elif not username and data["type"] == "Announce": + task_handle_inbox_announce.delay(username, data) + + elif not username and data["type"] == "Update": + task_handle_inbox_update.delay(username, data) + + elif not username and data["type"] == "Delete": + task_handle_inbox_delete.delay(username, data) + + elif not username and data["type"] == "Undo": + task_handle_inbox_undo.delay(username, data) + + else: + logger.debug("Ignoring inbox action: %s", data["type"]) + + return HttpResponse(status=204) + + +@csrf_exempt +def outbox(request, username=None): + """ + Outbox as defined in ActivityPub. + https://www.w3.org/TR/activitypub/#outbox + + Lists videos 'Announce' objects. + """ + + url_args = {"username": username} if username else {} + page = int(request.GET.get("page", 0)) + user = get_object_or_404(User, username=username) if username else None + video_query = Video.objects.filter(is_restricted=False) + if user: + video_query = video_query.filter(owner=user) + nb_videos = video_query.count() + + if page: + first_index = (page - 1) * AP_PAGE_SIZE + last_index = min(nb_videos, first_index + AP_PAGE_SIZE) + items = video_query[first_index:last_index].all() + next_page = page + 1 if (page + 1) * AP_PAGE_SIZE < nb_videos else None + response = { + "@context": AP_DEFAULT_CONTEXT, + "id": ap_url(reverse("activitypub:outbox", kwargs=url_args)), + "type": "OrderedCollection", + "totalItems": nb_videos, + "orderedItems": [ + { + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": [ap_url(reverse("activitypub:followers", kwargs=url_args))], + "type": "Announce", + "id": ap_url(reverse("activitypub:video", kwargs={"id": item.id})) + + "/announces/1", + "actor": ap_url(reverse("activitypub:account", kwargs=url_args)), + "object": ap_url( + reverse("activitypub:video", kwargs={"id": item.id}) + ), + } + for item in items + ], + } + if next_page: + response["next"] = ( + ap_url(reverse("activitypub:outbox", kwargs=url_args)) + + "?page=" + + next_page + ) + + elif nb_videos: + response = { + "@context": AP_DEFAULT_CONTEXT, + "id": ap_url(reverse("activitypub:outbox", kwargs=url_args)), + "type": "OrderedCollection", + "totalItems": nb_videos, + "first": ap_url(reverse("activitypub:outbox", kwargs=url_args)) + "?page=1", + } + + else: + response = { + "@context": AP_DEFAULT_CONTEXT, + "id": ap_url(reverse("activitypub:outbox", kwargs=url_args)), + "type": "OrderedCollection", + "totalItems": 0, + } + + logger.debug("outbox response: %s", json.dumps(response, indent=True)) + return JsonResponse(response, status=200) + + +@csrf_exempt +def following(request, username=None): + """ + 'Following' objects collection as defined in ActivityPub. + + https://www.w3.org/TR/activitypub/#following + """ + + url_args = {"username": username} if username else {} + response = { + "@context": AP_DEFAULT_CONTEXT, + "id": ap_url(reverse("activitypub:following", kwargs=url_args)), + "type": "OrderedCollection", + "totalItems": 0, + } + logger.debug("following response: %s", json.dumps(response, indent=True)) + return JsonResponse(response, status=200) + + +@csrf_exempt +def followers(request, username=None): + """ + 'Followers' objects collection as defined ActivityPub. + + https://www.w3.org/TR/activitypub/#followers + """ + + url_args = {"username": username} if username else {} + response = { + "@context": AP_DEFAULT_CONTEXT, + "id": ap_url(reverse("activitypub:followers", kwargs=url_args)), + "type": "OrderedCollection", + "totalItems": 0, + } + logger.debug("followers response: %s", json.dumps(response, indent=True)) + return JsonResponse(response, status=200) + + +@csrf_exempt +def video(request, id): + """ + 'Video' object as defined on ActivityStreams, with additions from the Peertube NS. + + Note: videos cannot be identified by slugs, because Peertube 6.1 expects video AP URLs to be stable, + and a change in the video name may result in a change in the video slug. + https://framacolibri.org/t/comments-and-suggestions-on-the-peertube-activitypub-implementation/21215/10?u=eloi + + https://www.w3.org/TR/activitystreams-vocabulary/#dfn-video + https://docs.joinpeertube.org/api/activitypub#video + """ + + video = get_object_or_404(Video, id=id) + response = { + "@context": AP_DEFAULT_CONTEXT + [AP_PT_VIDEO_CONTEXT], + **video_to_ap_video(video), + } + logger.debug("video response: %s", json.dumps(response, indent=True)) + return JsonResponse(response, status=200) + + +@csrf_exempt +def channel(request, id): + """ + 'Group' object as defined by ActivityStreams, with additions from the Peertube NS. + + https://www.w3.org/TR/activitystreams-vocabulary/#dfn-group + https://docs.joinpeertube.org/api/activitypub + """ + channel = get_object_or_404(Channel, id=id) + + response = { + "@context": AP_DEFAULT_CONTEXT + [AP_PT_CHANNEL_CONTEXT], + **channel_to_ap_group(channel), + } + + logger.debug("video response: %s", json.dumps(response, indent=True)) + return JsonResponse(response, status=200) + + +@csrf_exempt +def account_channel(request, username=None): + """ + 'Person' or 'Application' fake channel for Peertube compatibility. + """ + user = get_object_or_404(User, username=username) if username else None + + context = ( + AP_DEFAULT_CONTEXT + [AP_PT_CHANNEL_CONTEXT] if user else AP_DEFAULT_CONTEXT + ) + response = { + "@context": context, + **account_to_ap_group(user), + } + logger.debug("account_channel response: %s", json.dumps(response, indent=True)) + return JsonResponse(response, status=200) + + +@csrf_exempt +def likes(request, id): + """ + 'Like' objects collection as defined by ActivityStreams and ActivityPub. + + https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like + https://www.w3.org/TR/activitypub/#liked + """ + + video = get_object_or_404(Video, id=id) + response = { + "@context": AP_DEFAULT_CONTEXT, + "id": ap_url(reverse("activitypub:likes", kwargs={"id": video.id})), + "type": "OrderedCollection", + "totalItems": 0, + } + return JsonResponse(response, status=200) + + +@csrf_exempt +def dislikes(request, id): + """ + 'Dislike' objects collection as defined by ActivityStreams and ActivityPub. + + https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like + https://www.w3.org/TR/activitypub/#liked + """ + + video = get_object_or_404(Video, id=id) + response = { + "@context": AP_DEFAULT_CONTEXT, + "id": ap_url(reverse("activitypub:dislikes", kwargs={"id": video.id})), + "type": "OrderedCollection", + "totalItems": 0, + } + return JsonResponse(response, status=200) + + +@csrf_exempt +def shares(request, id): + """ + 'Share' objects collection as defined by ActivityPub. + + https://www.w3.org/TR/activitypub/#video_shares + """ + + video = get_object_or_404(Video, id=id) + response = { + "@context": AP_DEFAULT_CONTEXT, + "id": ap_url(reverse("activitypub:shares", kwargs={"id": video.id})), + "type": "OrderedCollection", + "totalItems": 0, + } + return JsonResponse(response, status=200) + + +@csrf_exempt +def comments(request, id): + """ + 'Note' objects collection as defined by ActivityStreams. + + https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note + """ + + video = get_object_or_404(Video, id=id) + # TODO: video.notecomments + response = { + "@context": AP_DEFAULT_CONTEXT, + "id": ap_url(reverse("activitypub:comments", kwargs={"id": video.id})), + "type": "OrderedCollection", + "totalItems": 0, + } + return JsonResponse(response, status=200) + + +@csrf_exempt +def chapters(request, id): + """ + Video chapters description as defined by Peertube. + + https://joinpeertube.org/ns + """ + + video = get_object_or_404(Video, id=id) + response = { + "@context": AP_DEFAULT_CONTEXT + [AP_PT_CHAPTERS_CONTEXT], + "id": ap_url(reverse("activitypub:comments", kwargs={"id": video.id})), + "hasPart": [ + { + "name": chapter.title, + "startOffset": chapter.time_start, + "endOffset": chapter.time_stop, + } + for chapter in video.chapter_set.all() + ], + } + return JsonResponse(response, status=200) diff --git a/pod/chapter/models.py b/pod/chapter/models.py index 6ec098e7a6..3b98ec24e7 100644 --- a/pod/chapter/models.py +++ b/pod/chapter/models.py @@ -46,6 +46,15 @@ class Meta: "video", ) + @property + def next(self): + """Return the following chapter in the video if existing""" + return Chapter.objects.filter(video=self.video, time_start__gt=self.time_start).order_by("time_start").first() + + @property + def time_stop(self): + return self.next.time_start if self.next else self.video.duration + def clean(self) -> None: """Check chapter fields validity.""" msg = list() diff --git a/pod/main/configuration.json b/pod/main/configuration.json index fec95eaead..5d53cf2845 100644 --- a/pod/main/configuration.json +++ b/pod/main/configuration.json @@ -2234,6 +2234,28 @@ "fr": "Configuration de l'application quiz" } }, + "activitypub": { + "description": {}, + "settings": { + "USE_ACTIVITYPUB": { + "default_value": true, + "description": { + "en": [ + "" + ], + "fr": [ + "Activation du module ActivityPub." + ] + }, + "pod_version_end": "", + "pod_version_init": "XXX" + }, + }, + "title": { + "en": "", + "fr": "Configuration application activitypub" + } + }, "recorder": { "description": {}, "settings": { diff --git a/pod/main/settings.py b/pod/main/settings.py index a48467d8a4..0e492ede0b 100644 --- a/pod/main/settings.py +++ b/pod/main/settings.py @@ -48,7 +48,7 @@ # that this Django site is allowed to serve. # # https://docs.djangoproject.com/en/3.2/ref/settings/#allowed-hosts -ALLOWED_HOSTS = ["pod.localhost"] +ALLOWED_HOSTS = ["pod.localhost", "pod.localhost"] ## # Session settings diff --git a/pod/main/test_settings.py b/pod/main/test_settings.py index ab5e42acae..9ff80707d0 100644 --- a/pod/main/test_settings.py +++ b/pod/main/test_settings.py @@ -10,6 +10,7 @@ from ..settings import INSTALLED_APPS, MIDDLEWARE, AUTHENTICATION_BACKENDS import os from bs4 import BeautifulSoup +from Crypto.PublicKey import RSA import requests USE_OPENCAST_STUDIO = True @@ -132,3 +133,12 @@ def get_shared_secret(): # DEBUG USE_DEBUG_TOOLBAR = False + + +# Generate a temporary keypair for test purpose +activitypub_key = RSA.generate(2048) +ACTIVITYPUB_PRIVATE_KEY = activitypub_key.export_key().decode() +ACTIVITYPUB_PUBLIC_KEY = activitypub_key.publickey().export_key().decode() + +# Directly execute celery tasks instead of delegating them to workers +CELERY_TASK_ALWAYS_EAGER = True diff --git a/pod/settings.py b/pod/settings.py index a979ad7d3e..232dd2a176 100644 --- a/pod/settings.py +++ b/pod/settings.py @@ -73,6 +73,7 @@ "pod.ai_enhancement", "pod.speaker", "pod.custom", + "pod.activitypub", ] ## diff --git a/pod/urls.py b/pod/urls.py index a63ca7be84..d76ace5fe7 100644 --- a/pod/urls.py +++ b/pod/urls.py @@ -40,6 +40,7 @@ USE_IMPORT_VIDEO = getattr(settings, "USE_IMPORT_VIDEO", True) USE_QUIZ = getattr(settings, "USE_QUIZ", True) USE_AI_ENHANCEMENT = getattr(settings, "USE_AI_ENHANCEMENT", False) +USE_ACTIVITYPUB = getattr(settings, "USE_ACTIVITYPUB", True) if USE_CAS: from cas import views as cas_views @@ -103,6 +104,12 @@ url(r"^webpush/", include("webpush.urls")), ] +# WEBPUSH +if USE_ACTIVITYPUB: + urlpatterns += [ + url("", include("pod.activitypub.urls")), + ] + # CAS if USE_CAS: # urlpatterns += [url(r'^sso-cas/', include('cas.urls')), ] diff --git a/pod/video/models.py b/pod/video/models.py index 0c9930da89..cf6ffe7a93 100644 --- a/pod/video/models.py +++ b/pod/video/models.py @@ -986,7 +986,7 @@ def get_player_height(self) -> int: """ return 360 if self.is_video else 244 - def get_thumbnail_url(self) -> str: + def get_thumbnail_url(self, scheme=False) -> str: """Get a thumbnail url for the video.""" request = None if self.thumbnail and self.thumbnail.file_exist(): @@ -1005,6 +1005,11 @@ def get_thumbnail_url(self) -> str: static(DEFAULT_THUMBNAIL), ] ) + + if scheme: + scheme = "https" if getattr(settings, "SECURE_SSL_REDIRECT") else "http" + return f"{scheme}:{thumbnail_url}" + return thumbnail_url @property @@ -1226,6 +1231,7 @@ def get_media_json(extension_list, list_video) -> dict: "id": media.id, "type": media.encoding_format, "src": media.source_file.url, + "size": media.source_file.size, "height": media_height, "extension": file_extension, "label": media.name, diff --git a/requirements-dev.txt b/requirements-dev.txt index 44ed783a57..6448d50602 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,3 +7,4 @@ httmock beautifulsoup4 django-debug-toolbar django-extensions +watchdog diff --git a/requirements.txt b/requirements.txt index b67ad8056b..f9402a88e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,3 +37,6 @@ djangorestframework-simplejwt==5.3.0 django-pwa==1.1.0 django-webpush==0.3.5 defusedxml==0.7.1 +pycryptodome==3.20.0 +markdownify==0.12.1 +pyld==2.0.4