diff --git a/.env.dev-exemple b/.env.dev-exemple index e1f76665cd..3ebafe52ac 100644 --- a/.env.dev-exemple +++ b/.env.dev-exemple @@ -10,3 +10,6 @@ REDIS_TAG=redis:alpine3.16 ### In case of value changing, you have to rebuild and restart your container. ### All yours datas will be kept. DOCKER_ENV=light +## PEERTUBE SECRETS +POSTGRES_PASSWORD= +PEERTUBE_SECRET= diff --git a/.gitignore b/.gitignore index 9f02003adc..c8b4a13319 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ pod/main/static/custom/img !pod/custom/settings_local.py.example settings_local.py transcription/* +docker-volume # Unit test utilities # ####################### @@ -73,6 +74,7 @@ compile-model *.crt *.key *.pem +*.pub # NPM stuffs # ################ diff --git a/Makefile b/Makefile index e29ae0170c..008fbc4481 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 afad72da65..a2360c44c5 100755 --- a/docker-compose-full-dev-with-volumes.yml +++ b/docker-compose-full-dev-with-volumes.yml @@ -56,6 +56,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 @@ -79,6 +91,37 @@ services: ports: - 6379:6379 + peertube: + container_name: peertube + hostname: peertube.localhost + image: chocobozzz/peertube:develop-bookworm + ports: + - 9000:9000 + - 3000:3000 + env_file: + - ./dockerfile-dev-with-volumes/peertube/peertube.env + - ./.env.dev + depends_on: + - postgres + - redis + - postfix + command: sh -c "yarn install && npm run dev" + restart: "always" + + postgres: + image: postgres:13-alpine + env_file: + - ./dockerfile-dev-with-volumes/peertube/peertube.env + - ./.env.dev + restart: "always" + + postfix: + image: mwader/postfix-relay + env_file: + - ./dockerfile-dev-with-volumes/peertube/peertube.env + - ./.env.dev + restart: "always" + # redis-commander: # container_name: redis-commander # hostname: redis-commander.localhost diff --git a/dockerfile-dev-with-volumes/peertube/peertube.env b/dockerfile-dev-with-volumes/peertube/peertube.env new file mode 100644 index 0000000000..8da049c3d2 --- /dev/null +++ b/dockerfile-dev-with-volumes/peertube/peertube.env @@ -0,0 +1,60 @@ +NODE_VERSION=21.7.1 +NODE_ENV=dev +NODE_DB_LOG=false + +# Database / Postgres service configuration +POSTGRES_USER=postgres +# 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"] + +# 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/dockerfile-dev-with-volumes/pod-activitypub/Dockerfile b/dockerfile-dev-with-volumes/pod-activitypub/Dockerfile new file mode 100755 index 0000000000..9abd6cdf97 --- /dev/null +++ b/dockerfile-dev-with-volumes/pod-activitypub/Dockerfile @@ -0,0 +1,35 @@ +#------------------------------------------------------------------------------------------------------------------------------ +# (\___/) +# (='.'=) 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 \ + && pip3 install elasticsearch==7.17.9 + +# 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/pod/activitypub/README.md b/pod/activitypub/README.md new file mode 100644 index 0000000000..1d4b68ba83 --- /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:8000/accounts/peertube" | jq +``` + +### Unit tests + +```shell +python manage.py test --settings=pod.main.test_settings pod.activitypub.tests +``` diff --git a/pod/activitypub/__init__.py b/pod/activitypub/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pod/activitypub/admin.py b/pod/activitypub/admin.py new file mode 100644 index 0000000000..119259ea0d --- /dev/null +++ b/pod/activitypub/admin.py @@ -0,0 +1,71 @@ +from django.conf import settings +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from .models import Follower, Following, ExternalVideo +from .tasks import task_follow, task_index_external_videos + +USE_ACTIVITYPUB = getattr(settings, "USE_ACTIVITYPUB", False) + + +@admin.register(Follower) +class FollowerAdmin(admin.ModelAdmin): + list_display = ("actor",) + + def has_module_permission(self, request): + return USE_ACTIVITYPUB + + +@admin.action(description=_("Send the federation request")) +def send_federation_request(modeladmin, request, queryset): + """Send a federation request to selected instances from admin.""" + 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_external_videos(modeladmin, request, queryset): + """Reindex all videos from selected instances from admin.""" + for following in queryset: + task_index_external_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_external_videos] + list_display = ( + "object", + "status", + ) + + def has_module_permission(self, request): + return USE_ACTIVITYPUB + + +@admin.register(ExternalVideo) +class ExternalVideoAdmin(admin.ModelAdmin): + list_display = ( + "id", + "title", + "source_instance", + "ap_id", + "date_added", + "viewcount", + "duration_in_time", + "get_thumbnail_admin", + ) + list_display_links = ("id", "title") + list_filter = ("date_added",) + + search_fields = ( + "id", + "title", + "video", + "source_instance__object", + ) + list_per_page = 20 + + def has_module_permission(self, request): + return USE_ACTIVITYPUB diff --git a/pod/activitypub/apps.py b/pod/activitypub/apps.py new file mode 100644 index 0000000000..7e68a5207d --- /dev/null +++ b/pod/activitypub/apps.py @@ -0,0 +1,21 @@ +from django.apps import AppConfig +from django.db.models.signals import post_delete, pre_save, post_save +from django.conf import settings + +USE_ACTIVITYPUB = getattr(settings, "USE_ACTIVITYPUB", False) + + +class ActivitypubConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "pod.activitypub" + + def ready(self): + """Set signals on videos for activitypub broadcasting.""" + from pod.video.models import Video + + from .signals import on_video_delete, on_video_save, on_video_pre_save + + if USE_ACTIVITYPUB: + pre_save.connect(on_video_pre_save, sender=Video) + 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/context_processors.py b/pod/activitypub/context_processors.py new file mode 100644 index 0000000000..bd836cdc79 --- /dev/null +++ b/pod/activitypub/context_processors.py @@ -0,0 +1,12 @@ +from pod.activitypub.models import ExternalVideo + + +def get_available_external_videos_filter(request=None): + """Return the base filter to get the available external videos of the site.""" + + return ExternalVideo.objects.filter() + + +def get_available_external_videos(request=None): + """Get all external videos available.""" + return get_available_external_videos_filter(request).distinct() diff --git a/pod/activitypub/deserialization/__init__.py b/pod/activitypub/deserialization/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pod/activitypub/deserialization/video.py b/pod/activitypub/deserialization/video.py new file mode 100644 index 0000000000..2dd1b455b8 --- /dev/null +++ b/pod/activitypub/deserialization/video.py @@ -0,0 +1,106 @@ +import logging + +from dateutil.parser import isoparse + +from pod.activitypub.models import ExternalVideo +from pod.video.models import LANG_CHOICES + +logger = logging.getLogger(__name__) + + +def format_ap_video_data(payload, source_instance): + """Create an ExternalVideo object from an AP Video payload.""" + + video_source_links = [ + { + "type": link["mediaType"], + "src": link["href"], + "size": link["size"], + "width": link["width"], + "height": link["height"], + } + for link in payload["url"] + if "mediaType" in link and link["mediaType"] == "video/mp4" + ] + if not video_source_links: + tags = [] + for link in payload["url"]: + if "tag" in link: + tags.extend(link["tag"]) + video_source_links = [ + { + "type": link["mediaType"], + "src": link["href"], + "size": link["size"], + "width": link["width"], + "height": link["height"], + } + for link in tags + if "mediaType" in link and link["mediaType"] == "video/mp4" + ] + + external_video_attributes = { + "ap_id": payload["id"], + "videos": video_source_links, + "title": payload["name"], + "date_added": isoparse(payload["published"]), + "thumbnail": [icon for icon in payload["icon"] if "thumbnails" in icon["url"]][0][ + "url" + ], + "duration": int(payload["duration"].lstrip("PT").rstrip("S")), + "viewcount": payload["views"], + "source_instance": source_instance, + } + + if ( + "language" in payload + and "identifier" in payload["language"] + and (identifier := payload["language"]["identifier"]) + and identifier in LANG_CHOICES + ): + external_video_attributes["main_lang"] = identifier + + if "content" in payload and (content := payload["content"]): + external_video_attributes["description"] = content + + return external_video_attributes + + +def update_or_create_external_video(payload, source_instance): + """Create or update external video for activitypub reindexation purposes.""" + external_video_attributes = format_ap_video_data(payload=payload, source_instance=source_instance) + external_video, created = ExternalVideo.objects.update_or_create( + ap_id=external_video_attributes["ap_id"], + defaults=external_video_attributes, + ) + + if created: + logger.info( + "ActivityPub external video %s created from %s instance", + external_video, + source_instance, + ) + else: + logger.info( + "ActivityPub external video %s updated from %s instance", + external_video, + source_instance, + ) + + return external_video + + +def create_external_video(payload, source_instance): + """Create an external video from an activitypub event.""" + external_video_attributes = format_ap_video_data(payload=payload, source_instance=source_instance) + external_video = ExternalVideo.objects.create(**external_video_attributes) + return external_video + + +def update_external_video(external_video, payload, source_instance): + """Update an external video from an activitypub event.""" + external_video_attributes = format_ap_video_data(payload=payload, source_instance=source_instance) + for attribute, value in external_video_attributes.items(): + setattr(external_video, attribute, value) + external_video.save() + return external_video 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..b3f725da5a --- /dev/null +++ b/pod/activitypub/models.py @@ -0,0 +1,188 @@ +import json +import logging + +from django.db import models +from django.template.defaultfilters import slugify +from django.urls import reverse +from django.utils.html import format_html +from django.utils.translation import ugettext_lazy as _ + +from pod.main.models import get_nextautoincrement +from pod.video.models import BaseVideo +from pod.video.models import __LANG_CHOICES_DICT__ + +logger = logging.getLogger(__name__) + + +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, + ) + + def __str__(self) -> str: + return self.object + + +class ExternalVideo(BaseVideo): + 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"), + unique=True, + ) + thumbnail = models.CharField( + _("Thumbnails"), + max_length=255, + blank=True, + null=True, + ) + viewcount = models.IntegerField(_("Number of view"), default=0) + videos = models.JSONField( + verbose_name=_("Mp4 resolutions list"), + ) + + def __init__(self, *args, **kwargs): + super(ExternalVideo, self).__init__(is_external=True, *args, **kwargs) + + def save(self, *args, **kwargs) -> None: + """Store an external video object in db.""" + newid = -1 + + if not self.id: + try: + newid = get_nextautoincrement(ExternalVideo) + except Exception: + try: + newid = ExternalVideo.objects.latest("id").id + newid += 1 + except Exception: + newid = 1 + else: + newid = self.id + newid = "%04d" % newid + self.slug = "%s-%s" % (newid, slugify(self.title)) + super(ExternalVideo, self).save(*args, **kwargs) + + @property + def get_thumbnail_admin(self): + """Return thumbnail image card of current external video for admin.""" + return format_html( + '%s' + % ( + self.thumbnail, + self.title, + ) + ) + + def get_thumbnail_card(self) -> str: + """Return thumbnail image card of current external video.""" + return ( + '%s' + % (self.thumbnail, self.title) + ) + + get_thumbnail_admin.fget.short_description = _("Thumbnails") + + def get_thumbnail_url(self, scheme=False, is_activity_pub=False) -> str: + """Get a thumbnail url for the video.""" + return self.thumbnail + + def get_absolute_url(self) -> str: + """Get the external video absolute URL.""" + return reverse("activitypub:external_video", args=[str(self.slug)]) + + def get_marker_time_for_user(video, user): + return 0 + + def get_video_mp4_json(self) -> list: + """Get the JSON representation of the MP4 video.""" + videos = [ + { + "type": video["type"], + "src": video["src"], + "size": video["size"], + "width": video["width"], + "height": video["height"], + "extension": f".{video['src'].split('.')[-1]}", + "label": f"{video['height']}p", + } + for video in sorted(self.videos, key=lambda v: v["width"]) + ] + videos[-1]["selected"] = True + return json.dumps(videos) + + def get_json_to_index(self): + """Get json attributes for elasticsearch indexation.""" + try: + data_to_dump = { + "id": f"{self.id}_external", + "title": "%s" % self.title, + "owner": "%s" % self.source_instance.object, + "owner_full_name": "%s" % self.source_instance.object, + "date_added": ( + "%s" % self.date_added.strftime("%Y-%m-%dT%H:%M:%S") + if self.date_added + else None + ), + "date_evt": ( + "%s" % self.date_evt.strftime("%Y-%m-%dT%H:%M:%S") + if self.date_evt + else None + ), + "description": "%s" % self.description, + "thumbnail": "%s" % self.get_thumbnail_url(), + "duration": "%s" % self.duration, + "tags": [], + "type": {}, + "disciplines": [], + "channels": [], + "themes": [], + "contributors": [], + "chapters": [], + "overlays": [], + "full_url": self.get_absolute_url(), + "is_restricted": False, + "password": False, + "duration_in_time": self.duration_in_time, + "mediatype": "video" if self.is_video else "audio", + "cursus": "", + "main_lang": "%s" % __LANG_CHOICES_DICT__[self.main_lang], + "is_external": self.is_external, + } + return json.dumps(data_to_dump) + except ExternalVideo.DoesNotExist as e: + logger.error( + "An error occured during get_json_to_index" + " for external video %s: %s" % (self.id, e) + ) + return json.dumps({}) diff --git a/pod/activitypub/network.py b/pod/activitypub/network.py new file mode 100644 index 0000000000..67cde73207 --- /dev/null +++ b/pod/activitypub/network.py @@ -0,0 +1,301 @@ +"""Long-standing operations""" + +import logging +from urllib.parse import urlparse + +import requests +from django.urls import reverse +from django.core.exceptions import PermissionDenied + +from pod.activitypub.constants import (AP_DEFAULT_CONTEXT, AP_PT_VIDEO_CONTEXT, + BASE_HEADERS) +from pod.activitypub.deserialization.video import ( + create_external_video, update_external_video, + update_or_create_external_video) +from pod.activitypub.models import ExternalVideo, Follower, Following +from pod.activitypub.serialization.video import video_to_ap_video +from pod.activitypub.utils import ap_object, ap_post, ap_url +from pod.video.models import Video +from pod.video_search.utils import delete_es, index_es + +logger = logging.getLogger(__name__) + + +def index_external_videos(following: Following): + """Process activitypub video pages.""" + ap_actor = get_instance_application_account_metadata(following.object) + ap_outbox = ap_object(ap_actor["outbox"]) + if "first" in ap_outbox: + index_external_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.""" + 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): + """Get activitypub actor data from domain.""" + account_url = get_instance_application_account_url(domain) + ap_actor = ap_object(account_url) + return ap_actor + + +def handle_incoming_follow(ap_follow): + """Process activitypub follow event.""" + 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): + """Remove follower.""" + Follower.objects.filter(actor=ap_follow["actor"]).delete() + + +def send_follow_request(following: Following): + """Send follow request to instance.""" + 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_external_videos_page(following: Following, page_url, indexed_external_videos=[]): + """Parse a AP Video page payload, and handle each video.""" + ap_page = ap_object(page_url) + for item in ap_page["orderedItems"]: + indexed_external_videos.append(index_external_video(following, item["object"])) + + if "next" in ap_page: + index_external_videos_page(following, ap_page["next"], indexed_external_videos) + ExternalVideo.objects.filter(source_instance=following).exclude(ap_id__in=indexed_external_videos).delete() + + +def index_external_video(following: Following, video_url): + """Read a video payload and create an ExternalVideo object.""" + ap_video = ap_object(video_url) + external_video = update_or_create_external_video(payload=ap_video, source_instance=following) + index_es(media=external_video) + return external_video.ap_id + + +def external_video_added_by_actor(ap_video, ap_actor): + """Process video creation from actor event.""" + logger.info("ActivityPub task call ExternalVideo %s creation from actor %s", ap_video, ap_actor) + try: + plausible_following = get_related_following(ap_actor=ap_actor["id"]) + existing_e_video = get_external_video_with_related_following(ap_video_id=ap_video["id"], plausible_following=plausible_following) + logger.warning("Received an ActivityPub create event from actor %s on an already existing ExternalVideo %s", ap_actor["id"], existing_e_video.id) + except Following.DoesNotExist: + logger.warning("Received an ActivityPub create event from unknown actor %s", ap_actor["id"]) + except PermissionDenied: + logger.warning("Actor %s cannot execute ActivityPub actions", plausible_following.object) + except ExternalVideo.DoesNotExist: + external_video = create_external_video(payload=ap_video, source_instance=plausible_following) + index_es(media=external_video) + + +def external_video_added_by_channel(ap_video, ap_channel): + """Process video creation from channel event.""" + logger.info("ActivityPub task call ExternalVideo %s creation from channel %s", ap_video, ap_channel) + try: + plausible_following = get_related_following(ap_actor=ap_channel["id"]) + existing_e_video = get_external_video_with_related_following(ap_video_id=ap_video["id"], plausible_following=plausible_following) + logger.warning("Received an ActivityPub create event from channel %s on an already existing ExternalVideo %s", ap_channel["id"], existing_e_video.id) + except Following.DoesNotExist: + logger.warning("Received an ActivityPub create event from unknown channel %s", ap_channel["id"]) + except PermissionDenied: + logger.warning("Actor %s cannot execute ActivityPub actions", plausible_following.object) + except ExternalVideo.DoesNotExist: + external_video = create_external_video(payload=ap_video, source_instance=plausible_following) + index_es(media=external_video) + + +def external_video_update(ap_video, ap_actor): + """Process video update event.""" + logger.info("ActivityPub task call ExternalVideo %s update", ap_video["id"]) + try: + plausible_following = get_related_following(ap_actor=ap_actor) + e_video_to_update = get_external_video_with_related_following(ap_video_id=ap_video["id"], plausible_following=plausible_following) + external_video = update_external_video(external_video=e_video_to_update, payload=ap_video, source_instance=plausible_following) + index_es(media=external_video) + except Following.DoesNotExist: + logger.warning("Received an ActivityPub update event from unknown actor %s", ap_actor) + except PermissionDenied: + logger.warning("Actor %s cannot execute ActivityPub actions", plausible_following.object) + except ExternalVideo.DoesNotExist: + logger.warning("Received an ActivityPub update event on a nonexistent ExternalVideo %s", ap_video["id"]) + + +def external_video_deletion(ap_video_id, ap_actor): + """Process video delete event.""" + logger.info("ActivityPub task call ExternalVideo %s delete", ap_video_id) + try: + plausible_following = get_related_following(ap_actor=ap_actor) + external_video_to_delete = get_external_video_with_related_following(ap_video_id=ap_video_id, plausible_following=plausible_following) + delete_es(media=external_video_to_delete) + external_video_to_delete.delete() + except Following.DoesNotExist: + logger.warning("Received an ActivityPub delete event from unknown actor %s", ap_actor) + except PermissionDenied: + logger.warning("Actor %s cannot execute ActivityPub actions", plausible_following.object) + except ExternalVideo.DoesNotExist: + logger.warning("Received an ActivityPub delete event on a nonexistent ExternalVideo %s from actor %s", ap_video_id, plausible_following.object) + + +def get_related_following(ap_actor): + """Check actor is indeed followed.""" + actor_domain = "{uri.scheme}://{uri.netloc}".format(uri=urlparse(ap_actor)) + actor = Following.objects.get(object__startswith=actor_domain) + return actor + + +def get_external_video_with_related_following(ap_video_id, plausible_following): + """Check actor has accepted follow and is owner of external video.""" + if not plausible_following.status == Following.Status.ACCEPTED: + raise PermissionDenied + external_video = ExternalVideo.objects.get(ap_id=ap_video_id, source_instance=plausible_following) + return external_video + + +def send_video_announce_object(video: Video, follower: Follower): + """Broadcast video announce.""" + 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): + """Broadcast video update.""" + 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): + """Broadcast video delete.""" + 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): + """Process follow request acceptation.""" + 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): + """Process follow request rejection.""" + 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..843223be2a --- /dev/null +++ b/pod/activitypub/serialization/account.py @@ -0,0 +1,153 @@ +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]): + """Serialize account to activitypub actor.""" + 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..9ce3ad49ea --- /dev/null +++ b/pod/activitypub/serialization/channel.py @@ -0,0 +1,141 @@ +from django.conf import settings +from django.urls import reverse + +from pod.activitypub.utils import ap_url + + +def channel_to_ap_group(channel): + """Serialize channel to activitypub group.""" + 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..f87e1ca47a --- /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.utils import ap_url, make_magnet_url, stable_uuid + +import logging + +logger = logging.getLogger(__name__) + + +def video_to_ap_video(video): + """Serialize video to activitypub 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): + """The 'published' and 'updated' attributes are needed by peertube.""" + 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), + "href": ap_url( + reverse( + "video:video_mp4", + kwargs={"id": video.id, "mp4_id": mp4.id}, + ) + ), + "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): + """ + Attribute '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 + """ + + return { + "icon": [ + { + "type": "Image", + "url": video.get_thumbnail_url(scheme=True, is_activity_pub=True), + "width": video.thumbnail.file.width if video.thumbnail else 640, + "height": video.thumbnail.file.height if video.thumbnail else 360, + # TODO: use the real media type when peertub supports JPEG + "mediaType": "image/jpeg", + }, + ] + } diff --git a/pod/activitypub/signals.py b/pod/activitypub/signals.py new file mode 100644 index 0000000000..c9a16cbe19 --- /dev/null +++ b/pod/activitypub/signals.py @@ -0,0 +1,74 @@ +"""Signal callbacks.""" + +import logging +from django.db import transaction +from .tasks import ( + task_broadcast_local_video_creation, + task_broadcast_local_video_deletion, + task_broadcast_local_video_update, +) + +logger = logging.getLogger(__name__) + + +def on_video_pre_save(instance, sender, **kwargs): + """Create temporary attribute to compare previous state after video save.""" + instance._was_activity_pub_broadcasted = instance.is_activity_pub_broadcasted + instance.is_activity_pub_broadcasted = instance.is_visible() + + +def on_video_save(instance, sender, **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 ( + not instance._was_activity_pub_broadcasted + and instance.is_activity_pub_broadcasted + ): + logger.info( + "Save publicly visible %s and broadcast a creation ActivityPub task", + instance, + ) + task_broadcast_local_video_creation.delay(instance.id) + + elif ( + instance._was_activity_pub_broadcasted + and not instance.is_activity_pub_broadcasted + ): + logger.info( + "Save publicly invisible %s and broadcast a deletion ActivityPub task", + instance, + ) + task_broadcast_local_video_deletion.delay( + video_id=instance.id, owner_username=instance.owner.username + ) + elif ( + instance._was_activity_pub_broadcasted + and instance.is_activity_pub_broadcasted + ): + logger.info( + "Save publicly visible %s and broadcast an update ActivityPub task", + instance, + ) + task_broadcast_local_video_update.delay(instance.id) + + del instance._was_activity_pub_broadcasted + + 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/signature.py b/pod/activitypub/signature.py new file mode 100644 index 0000000000..bdcdea16cb --- /dev/null +++ b/pod/activitypub/signature.py @@ -0,0 +1,167 @@ +import base64 +import datetime +import email.utils +import json +from typing import Dict +from urllib.parse import urlparse + +from Crypto.Hash import SHA256 +from Crypto.PublicKey.RSA import RsaKey +from Crypto.Signature import pkcs1_15 +from pyld import jsonld + + +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 payload_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) + return sig + + +def payload_check(public_key, payload, signature) -> bool: + to_be_checked_str_bytes = bytes(payload, "utf-8") + to_be_checked_str_hash = SHA256.new(bytes(to_be_checked_str_bytes)) + verifier = pkcs1_15.new(public_key) + + try: + verifier.verify(to_be_checked_str_hash, signature) + return True + + except ValueError: + return False + + +def payload_normalize(payload): + return jsonld.normalize( + payload, {"algorithm": "URDNA2015", "format": "application/n-quads"} + ) + + +def build_signature_headers( + private_key: RsaKey, public_key_url: str, payload: Dict[str, str], url: str +) -> Dict[str, str]: + """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) + + payload_hash_raw = payload_hash(payload) + payload_hash_b64 = base64.b64encode(payload_hash_raw).decode() + + to_sign = ( + f"(request-target): post {url.path}\n" + f"host: {url.netloc}\n" + f"date: {date}\n" + f"digest: SHA-256={payload_hash_b64}" + ) + sig_raw = payload_signature(private_key, to_sign) + sig_b64 = base64.b64encode(sig_raw).decode() + + signature_header = f'keyId="{public_key_url}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="{sig_b64}"' + request_headers = { + "host": url.netloc, + "date": date, + "digest": f"SHA-256={payload_hash_b64}", + "content-type": "application/activity+json", + "signature": signature_header, + } + return request_headers + + +def check_signature_headers( + public_key: RsaKey, payload: Dict[str, str], headers: Dict[str, str], url: str +) -> bool: + """Sign JSON-LD payload according to the 'Signing HTTP Messages' RFC draft.""" + + url = urlparse(url) + date_header = headers["date"] + + payload_hash_raw = payload_hash(payload) + payload_hash_b64 = base64.b64encode(payload_hash_raw).decode() + + to_check = ( + f"(request-target): post {url.path}\n" + f"host: {url.netloc}\n" + f"date: {date_header}\n" + f"digest: SHA-256={payload_hash_b64}" + ) + + signature_header = headers["signature"] + signature_header_dict = dict( + (item.split("=", maxsplit=1) for item in signature_header.split(",")) + ) + sig_b64 = signature_header_dict["signature"] + sig_raw = base64.b64decode(sig_b64) + + is_valid = payload_check(public_key, to_check, sig_raw) + return is_valid + + +def signature_payload_raw_data(payload, signature): + """Build the raw data to be signed or checked according to the 'Linked Data Signatures 1.0' RFC draft.""" + + 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) + + return options_hash.hex() + document_hash.hex() + + +def build_signature_payload( + private_key: RsaKey, payload: Dict[str, str] +) -> Dict[str, str]: + """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 + """ + + signature = { + "type": "RsaSignature2017", + "creator": payload["actor"], + "created": datetime.datetime.now(tz=datetime.timezone.utc).isoformat(), + } + to_sign = signature_payload_raw_data(payload, signature) + sig_raw = payload_signature(private_key, to_sign) + sig_b64 = base64.b64encode(sig_raw).decode() + signature["signatureValue"] = sig_b64 + + return signature + + +def check_signature_payload(public_key: RsaKey, payload: Dict[str, str]) -> bool: + """Check JSON-LD payload according to the 'Linked Data Signatures 1.0' RFC draft.""" + + payload = dict(payload) + signature_metadata = payload.pop("signature") + to_check = signature_payload_raw_data(payload, signature_metadata) + + sig_b64 = signature_metadata["signatureValue"] + sig_raw = base64.b64decode(sig_b64) + + is_valid = payload_check(public_key, to_check, sig_raw) + return is_valid diff --git a/pod/activitypub/tasks.py b/pod/activitypub/tasks.py new file mode 100644 index 0000000000..d1f6e71757 --- /dev/null +++ b/pod/activitypub/tasks.py @@ -0,0 +1,174 @@ +"""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): + """Send celery activitypub follow request.""" + 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_external_videos(following_id): + """Get celery activitypub videos indexation request.""" + from .models import Following + from .network import index_external_videos + + following = Following.objects.get(id=following_id) + return index_external_videos(following) + + +@activitypub_app.task() +def task_handle_inbox_follow(username, data): + """Get celery activitypub follow request.""" + from .network import handle_incoming_follow + + return handle_incoming_follow(ap_follow=data) + + +@activitypub_app.task() +def task_handle_inbox_accept(username, data): + """Get celery activitypub accept follow request.""" + 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): + """Get celery activitypub reject follow request.""" + 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): + """Get celery activitypub video announce request.""" + 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): + """Get celery activitypub video update request.""" + 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, ap_actor=data["actor"]) + + logger.debug("Ignoring inbox 'Update' action for '%s' object", obj["type"]) + + +@activitypub_app.task() +def task_handle_inbox_delete(username, data): + """Get celery activitypub video delete request.""" + from .network import external_video_deletion + + if data["type"] == "Delete": + return external_video_deletion(ap_video_id=data["object"], ap_actor=data["actor"]) + + logger.debug("Ignoring inbox 'Delete' action for '%s' object", data["type"]) + + +@activitypub_app.task() +def task_handle_inbox_undo(username, data): + """Get celery activitypub undo request.""" + 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): + """Send celery activitypub video announce request.""" + from pod.video.models import Video + + from .models import Follower + from .network import send_video_announce_object + + video = Video.objects.get(id=video_id) + for follower in Follower.objects.all(): + send_video_announce_object(video, follower) + + +@activitypub_app.task() +def task_broadcast_local_video_update(video_id): + """Send celery activitypub video update request.""" + from pod.video.models import Video + + from .models import Follower + from .network import send_video_update_object + + video = Video.objects.get(id=video_id) + 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): + """Send celery activitypub video delete request.""" + from .models import Follower + from .network import send_video_delete_object + + 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..d6e10cbd52 --- /dev/null +++ b/pod/activitypub/tests/__init__.py @@ -0,0 +1,141 @@ +import json +import os +import httmock + +from datetime import datetime +from zoneinfo import ZoneInfo +from django.template.defaultfilters import slugify +from django.test import TestCase +from django.conf import settings + +from pod.authentication.models import User +from pod.video.models import VIDEOS_DIR +from pod.video.models import Video +from pod.video.models import Type +from pod.video_encode_transcript.models import VideoRendition +from pod.video_encode_transcript.models import EncodingVideo +from pod.activitypub.models import Following + +TIME_ZONE = getattr(settings, "TIME_ZONE", "Europe/Paris") + + +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", + ) + video_type = Type.objects.create(title="autre") + filename = "test.mp4" + fname, dot, extension = filename.rpartition(".") + self.vr = VideoRendition.objects.get(resolution="640x360") + self.draft_video = Video.objects.create( + type=video_type, + title="Draft video", + password=None, + date_added=datetime.now(ZoneInfo(TIME_ZONE)), + encoding_in_progress=False, + owner=self.admin_user, + date_evt=datetime.now(ZoneInfo(TIME_ZONE)), + video=os.path.join( + VIDEOS_DIR, + self.admin_user.owner.hashkey, + "%s.%s" % (slugify(fname), extension), + ), + description="description", + is_draft=True, + duration=3, + ) + self.ev_draft = EncodingVideo.objects.create( + video=self.draft_video, + rendition=self.vr, + ) + self.visible_video = Video.objects.create( + type=video_type, + title="Visible video", + password=None, + date_added=datetime.now(ZoneInfo(TIME_ZONE)), + encoding_in_progress=False, + owner=self.admin_user, + date_evt=datetime.now(ZoneInfo(TIME_ZONE)), + video=os.path.join( + VIDEOS_DIR, + self.admin_user.owner.hashkey, + "%s.%s" % (slugify(fname), extension), + ), + description="description", + is_draft=False, + duration=3, + ) + self.ev_visible = EncodingVideo.objects.create( + video=self.visible_video, + rendition=self.vr, + ) + self.peertube_test_following = Following.objects.create( + object="http://peertube.test", status=Following.Status.ACCEPTED + ) + self.other_peertube_test_following = Following.objects.create( + object="http://other_peertube.test", status=Following.Status.ACCEPTED + ) + + def tearDown(self): + del self.admin_user + del self.draft_video + del self.visible_video + del self.vr + del self.ev_draft + del self.ev_visible + + @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..f758e4e0e7 --- /dev/null +++ b/pod/activitypub/tests/fixtures/peertube_video.json @@ -0,0 +1,561 @@ +{ + "@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": "6708fca89e6086255e17f288f859b36649309a8d" + }, + { + "type": "Infohash", + "name": "e3c81555b41e482c1e127a3885f90ceb781a1cb0" + }, + { + "type": "Infohash", + "name": "97d2901e9fb0a664ea248b56bff462062c889166" + }, + { + "type": "Infohash", + "name": "4a8ef632739f72a1d7ad9efab152332651df23d4" + }, + { + "type": "Infohash", + "name": "299f35b448ed91f5c5d61c33c6b16c47db8811a9" + }, + { + "type": "Infohash", + "name": "9310e3f6557d18e2d7b949bd074103f3b940f19c" + }, + { + "type": "Infohash", + "name": "d6b2a3fcf496ba13e2068545d6ae63aa09865937" + }, + { + "type": "Infohash", + "name": "ffd6eaa6ede0def83085e9a303d88d3460cf2300" + }, + { + "type": "Link", + "name": "sha256", + "mediaType": "application/json", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/29f2ddd9-5315-4089-8e5c-fb8f27c9ba9b-segments-sha256.json" + }, + { + "type": "Link", + "mediaType": "video/mp4", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/1a14b408-e512-4790-bb56-2cb38506c739-2160-fragmented.mp4", + "height": 2160, + "width": 3840, + "size": 5648268, + "fps": 50 + }, + { + "type": "Link", + "rel": ["metadata", "video/mp4"], + "mediaType": "application/json", + "href": "http://peertube.test/api/v1/videos/717c9d87-c912-4943-a392-49fadf2f235d/metadata/6", + "height": 2160, + "width": 3840, + "fps": 50 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent", + "href": "http://peertube.test/lazy-static/torrents/172408e4-1cab-4af1-b581-3528f22243ec-2160-hls.torrent", + "height": 2160, + "width": 3840, + "fps": 50 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", + "href": "magnet:?xs=http%3A%2F%2Fpeertube.localhost%3A9000%2Flazy-static%2Ftorrents%2F172408e4-1cab-4af1-b581-3528f22243ec-2160-hls.torrent&xt=urn:btih:76f857d72d01837daf51affa2e02281744b9e5b0&dn=Quality+2+from+peertube&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%2F1a14b408-e512-4790-bb56-2cb38506c739-2160-fragmented.mp4", + "height": 2160, + "width": 3840, + "fps": 50 + }, + { + "type": "Link", + "mediaType": "video/mp4", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/602a2d2e-8f9c-449e-b998-c08bceefe954-1440-fragmented.mp4", + "height": 1440, + "width": 2560, + "size": 3517621, + "fps": 50 + }, + { + "type": "Link", + "rel": ["metadata", "video/mp4"], + "mediaType": "application/json", + "href": "http://peertube.test/api/v1/videos/717c9d87-c912-4943-a392-49fadf2f235d/metadata/13", + "height": 1440, + "width": 2560, + "fps": 50 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent", + "href": "http://peertube.test/lazy-static/torrents/766a7aae-1bb8-4e0a-beae-761e1383a0d9-1440-hls.torrent", + "height": 1440, + "width": 2560, + "fps": 50 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", + "href": "magnet:?xs=http%3A%2F%2Fpeertube.localhost%3A9000%2Flazy-static%2Ftorrents%2F766a7aae-1bb8-4e0a-beae-761e1383a0d9-1440-hls.torrent&xt=urn:btih:bd674919b0ce4c16a3085560f401450307a5015d&dn=Quality+2+from+peertube&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%2F602a2d2e-8f9c-449e-b998-c08bceefe954-1440-fragmented.mp4", + "height": 1440, + "width": 2560, + "fps": 50 + }, + { + "type": "Link", + "mediaType": "video/mp4", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/418fcf68-a6a1-4f0c-a68d-285eeb979229-1080-fragmented.mp4", + "height": 1080, + "width": 1920, + "size": 2074477, + "fps": 50 + }, + { + "type": "Link", + "rel": ["metadata", "video/mp4"], + "mediaType": "application/json", + "href": "http://peertube.test/api/v1/videos/717c9d87-c912-4943-a392-49fadf2f235d/metadata/12", + "height": 1080, + "width": 1920, + "fps": 50 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent", + "href": "http://peertube.test/lazy-static/torrents/4d70558d-987d-4eab-bfea-dfc70f4d0af4-1080-hls.torrent", + "height": 1080, + "width": 1920, + "fps": 50 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", + "href": "magnet:?xs=http%3A%2F%2Fpeertube.localhost%3A9000%2Flazy-static%2Ftorrents%2F4d70558d-987d-4eab-bfea-dfc70f4d0af4-1080-hls.torrent&xt=urn:btih:86b34386d6fa99aae7d6a7efcab5f424cce72d00&dn=Quality+2+from+peertube&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%2F418fcf68-a6a1-4f0c-a68d-285eeb979229-1080-fragmented.mp4", + "height": 1080, + "width": 1920, + "fps": 50 + }, + { + "type": "Link", + "mediaType": "video/mp4", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/4e4812d4-c2a3-4c48-b17c-3dfa99e814f0-720-fragmented.mp4", + "height": 720, + "width": 1280, + "size": 943692, + "fps": 50 + }, + { + "type": "Link", + "rel": ["metadata", "video/mp4"], + "mediaType": "application/json", + "href": "http://peertube.test/api/v1/videos/717c9d87-c912-4943-a392-49fadf2f235d/metadata/9", + "height": 720, + "width": 1280, + "fps": 50 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent", + "href": "http://peertube.test/lazy-static/torrents/d995bca0-629e-4352-9f04-a12de8532515-720-hls.torrent", + "height": 720, + "width": 1280, + "fps": 50 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", + "href": "magnet:?xs=http%3A%2F%2Fpeertube.localhost%3A9000%2Flazy-static%2Ftorrents%2Fd995bca0-629e-4352-9f04-a12de8532515-720-hls.torrent&xt=urn:btih:f85498ed53cbda44772d33c7168f3a77bfa8f1f9&dn=Quality+2+from+peertube&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%2F4e4812d4-c2a3-4c48-b17c-3dfa99e814f0-720-fragmented.mp4", + "height": 720, + "width": 1280, + "fps": 50 + }, + { + "type": "Link", + "mediaType": "video/mp4", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/9d2604d7-6708-45f4-8d7d-02405c6989ad-480-fragmented.mp4", + "height": 480, + "width": 854, + "size": 460605, + "fps": 25 + }, + { + "type": "Link", + "rel": ["metadata", "video/mp4"], + "mediaType": "application/json", + "href": "http://peertube.test/api/v1/videos/717c9d87-c912-4943-a392-49fadf2f235d/metadata/7", + "height": 480, + "width": 854, + "fps": 25 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent", + "href": "http://peertube.test/lazy-static/torrents/556fb50b-08f9-4427-a7e5-fa496333ed69-480-hls.torrent", + "height": 480, + "width": 854, + "fps": 25 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", + "href": "magnet:?xs=http%3A%2F%2Fpeertube.localhost%3A9000%2Flazy-static%2Ftorrents%2F556fb50b-08f9-4427-a7e5-fa496333ed69-480-hls.torrent&xt=urn:btih:dff015a9da95218c442e162f5470f8e9cb89e9bd&dn=Quality+2+from+peertube&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%2F9d2604d7-6708-45f4-8d7d-02405c6989ad-480-fragmented.mp4", + "height": 480, + "width": 854, + "fps": 25 + }, + { + "type": "Link", + "mediaType": "video/mp4", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/c1e2f78a-403b-4d84-b784-bee6a52267f3-360-fragmented.mp4", + "height": 360, + "width": 640, + "size": 257495, + "fps": 25 + }, + { + "type": "Link", + "rel": ["metadata", "video/mp4"], + "mediaType": "application/json", + "href": "http://peertube.test/api/v1/videos/717c9d87-c912-4943-a392-49fadf2f235d/metadata/8", + "height": 360, + "width": 640, + "fps": 25 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent", + "href": "http://peertube.test/lazy-static/torrents/2aa53712-1b5b-4cdf-9cbd-ec9f60b17e99-360-hls.torrent", + "height": 360, + "width": 640, + "fps": 25 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", + "href": "magnet:?xs=http%3A%2F%2Fpeertube.localhost%3A9000%2Flazy-static%2Ftorrents%2F2aa53712-1b5b-4cdf-9cbd-ec9f60b17e99-360-hls.torrent&xt=urn:btih:05b29a67f2c1afda96e4ba01c7f339b37daa5871&dn=Quality+2+from+peertube&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%2Fc1e2f78a-403b-4d84-b784-bee6a52267f3-360-fragmented.mp4", + "height": 360, + "width": 640, + "fps": 25 + }, + { + "type": "Link", + "mediaType": "video/mp4", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/8726668b-782e-4b89-8a9e-01eee150da79-240-fragmented.mp4", + "height": 240, + "width": 426, + "size": 113028, + "fps": 25 + }, + { + "type": "Link", + "rel": ["metadata", "video/mp4"], + "mediaType": "application/json", + "href": "http://peertube.test/api/v1/videos/717c9d87-c912-4943-a392-49fadf2f235d/metadata/10", + "height": 240, + "width": 426, + "fps": 25 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent", + "href": "http://peertube.test/lazy-static/torrents/e65f1539-cda7-40d6-ac5b-f376d69283d2-240-hls.torrent", + "height": 240, + "width": 426, + "fps": 25 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", + "href": "magnet:?xs=http%3A%2F%2Fpeertube.localhost%3A9000%2Flazy-static%2Ftorrents%2Fe65f1539-cda7-40d6-ac5b-f376d69283d2-240-hls.torrent&xt=urn:btih:7bb03a16816dfab461e358c804fe75dbb72d2619&dn=Quality+2+from+peertube&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%2F8726668b-782e-4b89-8a9e-01eee150da79-240-fragmented.mp4", + "height": 240, + "width": 426, + "fps": 25 + }, + { + "type": "Link", + "mediaType": "video/mp4", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/d5a82104-4ae5-4a5b-9b76-6dc7edd99746-144-fragmented.mp4", + "height": 144, + "width": 256, + "size": 42818, + "fps": 25 + }, + { + "type": "Link", + "rel": ["metadata", "video/mp4"], + "mediaType": "application/json", + "href": "http://peertube.test/api/v1/videos/717c9d87-c912-4943-a392-49fadf2f235d/metadata/11", + "height": 144, + "width": 256, + "fps": 25 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent", + "href": "http://peertube.test/lazy-static/torrents/b434d6ea-f709-4730-aee7-1dba620ea995-144-hls.torrent", + "height": 144, + "width": 256, + "fps": 25 + }, + { + "type": "Link", + "mediaType": "application/x-bittorrent;x-scheme-handler/magnet", + "href": "magnet:?xs=http%3A%2F%2Fpeertube.localhost%3A9000%2Flazy-static%2Ftorrents%2Fb434d6ea-f709-4730-aee7-1dba620ea995-144-hls.torrent&xt=urn:btih:b14a72417bef79b92590f6b4f43703e9b7cfeb16&dn=Quality+2+from+peertube&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%2Fd5a82104-4ae5-4a5b-9b76-6dc7edd99746-144-fragmented.mp4", + "height": 144, + "width": 256, + "fps": 25 + } + ] + }, + { + "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/peertube_video_second.json b/pod/activitypub/tests/fixtures/peertube_video_second.json new file mode 100644 index 0000000000..c67f04dd05 --- /dev/null +++ b/pod/activitypub/tests/fixtures/peertube_video_second.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/0e04770d-2da5-4ec8-905c-6c8e56e51dbc", + "name": "Titre de la vidéo", + "duration": "PT31S", + "uuid": "0e04770d-2da5-4ec8-905c-6c8e56e51dbc", + "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/0e04770d-2da5-4ec8-905c-6c8e56e51dbc" + }, + { + "type": "Link", + "mediaType": "application/x-mpegURL", + "href": "http://peertube.test/static/streaming-playlists/hls/0e04770d-2da5-4ec8-905c-6c8e56e51dbc/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/0e04770d-2da5-4ec8-905c-6c8e56e51dbc/82701fa5-ef21-4788-b190-8391bc0a73e2-segments-sha256.json" + }, + { + "type": "Link", + "mediaType": "video/mp4", + "href": "http://peertube.test/static/streaming-playlists/hls/0e04770d-2da5-4ec8-905c-6c8e56e51dbc/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/0e04770d-2da5-4ec8-905c-6c8e56e51dbc/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%2F0e04770d-2da5-4ec8-905c-6c8e56e51dbc%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/0e04770d-2da5-4ec8-905c-6c8e56e51dbc/likes", + "dislikes": "http://peertube.test/videos/watch/0e04770d-2da5-4ec8-905c-6c8e56e51dbc/dislikes", + "shares": "http://peertube.test/videos/watch/0e04770d-2da5-4ec8-905c-6c8e56e51dbc/announces", + "comments": "http://peertube.test/videos/watch/0e04770d-2da5-4ec8-905c-6c8e56e51dbc/comments", + "hasParts": "http://peertube.test/videos/watch/0e04770d-2da5-4ec8-905c-6c8e56e51dbc/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..45662e6259 --- /dev/null +++ b/pod/activitypub/tests/fixtures/video_creation_account_announce.json @@ -0,0 +1,24 @@ +{ + "@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/717c9d87-c912-4943-a392-49fadf2f235d/announces/1", + "actor": "http://peertube.test/accounts/peertube", + "object": "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d", + "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..b1243dc08d --- /dev/null +++ b/pod/activitypub/tests/fixtures/video_creation_channel_announce.json @@ -0,0 +1,24 @@ +{ + "@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/717c9d87-c912-4943-a392-49fadf2f235d/announces/3", + "actor": "http://peertube.test/video-channels/root_channel", + "object": "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d", + "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..dd7420cd37 --- /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/717c9d87-c912-4943-a392-49fadf2f235d/delete", + "actor": "http://peertube.test/accounts/root", + "object": "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d", + "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_delete_not_owner.json b/pod/activitypub/tests/fixtures/video_delete_not_owner.json new file mode 100644 index 0000000000..f4f4977d4b --- /dev/null +++ b/pod/activitypub/tests/fixtures/video_delete_not_owner.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/717c9d87-c912-4943-a392-49fadf2f235d/delete", + "actor": "http://other_peertube.test/accounts/root", + "object": "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d", + "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..5b120c2b28 --- /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/717c9d87-c912-4943-a392-49fadf2f235d/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/717c9d87-c912-4943-a392-49fadf2f235d", + "name": "terre", + "duration": "PT31S", + "uuid": "717c9d87-c912-4943-a392-49fadf2f235d", + "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/717c9d87-c912-4943-a392-49fadf2f235d" + }, + { + "type": "Link", + "mediaType": "application/x-mpegURL", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/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/717c9d87-c912-4943-a392-49fadf2f235d/dc1221ee-1090-40b8-bdfa-5a5f4cd67c98-segments-sha256.json" + }, + { + "type": "Link", + "mediaType": "video/mp4", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/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/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/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%2F717c9d87-c912-4943-a392-49fadf2f235d%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/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 + }, + "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_update_not_owner.json b/pod/activitypub/tests/fixtures/video_update_not_owner.json new file mode 100644 index 0000000000..f3eabfc996 --- /dev/null +++ b/pod/activitypub/tests/fixtures/video_update_not_owner.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/717c9d87-c912-4943-a392-49fadf2f235d/updates/2024-05-15T12:40:45.399Z", + "actor": "http://other_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/717c9d87-c912-4943-a392-49fadf2f235d", + "name": "terre", + "duration": "PT31S", + "uuid": "717c9d87-c912-4943-a392-49fadf2f235d", + "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/717c9d87-c912-4943-a392-49fadf2f235d" + }, + { + "type": "Link", + "mediaType": "application/x-mpegURL", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/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/717c9d87-c912-4943-a392-49fadf2f235d/dc1221ee-1090-40b8-bdfa-5a5f4cd67c98-segments-sha256.json" + }, + { + "type": "Link", + "mediaType": "video/mp4", + "href": "http://peertube.test/static/streaming-playlists/hls/717c9d87-c912-4943-a392-49fadf2f235d/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/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/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%2F717c9d87-c912-4943-a392-49fadf2f235d%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/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 + }, + "signature": { + "type": "RsaSignature2017", + "creator": "http://other_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..86f1701412 --- /dev/null +++ b/pod/activitypub/tests/fixtures/video_view.json @@ -0,0 +1,33 @@ +{ + "@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-717c9d87-c912-4943-a392-49fadf2f235d", + "type": "View", + "actor": "http://peertube.test/accounts/peertube", + "object": "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d", + "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..dd04350c00 --- /dev/null +++ b/pod/activitypub/tests/test_admin.py @@ -0,0 +1,104 @@ +import json + +from . import ActivityPubTestCase +from unittest.mock import patch +import httmock +from pod.activitypub.models import Following +from pod.activitypub.models import ExternalVideo +from pod.activitypub.deserialization.video import create_external_video + + +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.""" + + 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(self.peertube_test_following.id), + ], + }, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + str(list(response.context["messages"])[0]), + "The federation requests have been sent", + ) + + self.peertube_test_following.refresh_from_db() + self.assertEqual(self.peertube_test_following.status, Following.Status.REQUESTED) + + def test_reindex_external_videos(self): + """Nominal case test for the admin 'reindex_external_videos' action.""" + + with httmock.HTTMock( + self.mock_nodeinfo, + self.mock_application_actor, + self.mock_outbox, + self.mock_get_video, + ), patch( + "pod.activitypub.network.index_external_videos" + ) as index_external_videos: + response = self.client.post( + "/admin/activitypub/following/", + { + "action": "reindex_external_videos", + "_selected_action": [ + str(self.peertube_test_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(index_external_videos.called) + + def test_check_missing_external_videos_are_deleted_on_reindexation(self): + """Reindexation should delete missing ExternalVideo on following instance.""" + + with open("pod/activitypub/tests/fixtures/peertube_video.json") as fd: + payload = json.load(fd) + video = create_external_video(payload, source_instance=self.peertube_test_following) + + with open("pod/activitypub/tests/fixtures/peertube_video_second.json") as fd: + video_to_delete_payload = json.load(fd) + video_to_delete = create_external_video(video_to_delete_payload, source_instance=self.peertube_test_following) + self.assertEqual(video_to_delete, ExternalVideo.objects.get(id=video_to_delete.id)) + + with httmock.HTTMock( + self.mock_nodeinfo, + self.mock_application_actor, + self.mock_outbox, + self.mock_get_video, + ): + response = self.client.post( + "/admin/activitypub/following/", + { + "action": "reindex_external_videos", + "_selected_action": [ + str(self.peertube_test_following.id), + ], + }, + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + str(list(response.context["messages"])[0]), + "The video indexations have started", + ) + self.assertEqual(video.ap_id, ExternalVideo.objects.get(id=video.id).ap_id) + self.assertRaises(ExternalVideo.DoesNotExist, ExternalVideo.objects.get, id=video_to_delete.id) 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_signature.py b/pod/activitypub/tests/test_signature.py new file mode 100644 index 0000000000..e9873ed1aa --- /dev/null +++ b/pod/activitypub/tests/test_signature.py @@ -0,0 +1,48 @@ +from Crypto.PublicKey import RSA + +from pod.activitypub.signature import ( + build_signature_headers, + build_signature_payload, + check_signature_headers, + check_signature_payload, +) + +from . import ActivityPubTestCase + + +class ActivityPubViewTest(ActivityPubTestCase): + def setUp(self): + super().setUp() + + self.ap_key_1 = RSA.generate(2048) + self.ap_key_2 = RSA.generate(2048) + self.pk1 = self.ap_key_1.export_key().decode() + self.pk2 = self.ap_key_2.export_key().decode() + self.pub1 = self.ap_key_1.publickey().export_key().decode() + self.pub2 = self.ap_key_2.publickey().export_key().decode() + + def test_signature_payload(self): + priv_key = RSA.import_key(self.pk1) + valid_pub_key = RSA.import_key(self.pub1) + invalid_pub_key = RSA.import_key(self.pub2) + + payload = {"foo": "bar", "actor": "baz"} + payload["signature"] = build_signature_payload(priv_key, payload) + + self.assertTrue(check_signature_payload(valid_pub_key, payload)) + self.assertFalse(check_signature_payload(invalid_pub_key, payload)) + + def test_signature_headers(self): + priv_key = RSA.import_key(self.pk1) + valid_pub_key = RSA.import_key(self.pub1) + invalid_pub_key = RSA.import_key(self.pub2) + + payload = {"foo": "bar", "actor": "baz"} + url = "https://ap.test" + public_key_url = "https://ap.test/yolo" + headers = build_signature_headers(priv_key, public_key_url, payload, url) + + self.assertTrue(check_signature_headers(valid_pub_key, payload, headers, url)) + self.assertFalse( + check_signature_headers(invalid_pub_key, payload, headers, url) + ) diff --git a/pod/activitypub/tests/test_video_broadcast.py b/pod/activitypub/tests/test_video_broadcast.py new file mode 100644 index 0000000000..c16db1257b --- /dev/null +++ b/pod/activitypub/tests/test_video_broadcast.py @@ -0,0 +1,65 @@ +from unittest.mock import patch + +from . import ActivityPubTestCase + + +class VideoBroadcastTest(ActivityPubTestCase): + @patch("pod.activitypub.tasks.task_broadcast_local_video_deletion.delay") + @patch("pod.activitypub.tasks.task_broadcast_local_video_update.delay") + @patch("pod.activitypub.tasks.task_broadcast_local_video_creation.delay") + def test_video_creation(self, create_task, update_task, delete_task): + """Create video and check broadcast through ActivityPub. + + Newly visible video should send a create task. + """ + self.draft_video.save() + assert not self.draft_video.is_visible() + assert not self.visible_video.is_activity_pub_broadcasted + self.draft_video.is_draft = False + with self.captureOnCommitCallbacks(execute=True): + self.draft_video.save() + assert self.draft_video.is_visible() + + create_task.assert_called_with(self.draft_video.id) + assert not update_task.called + assert not delete_task.called + + @patch("pod.activitypub.tasks.task_broadcast_local_video_deletion.delay") + @patch("pod.activitypub.tasks.task_broadcast_local_video_update.delay") + @patch("pod.activitypub.tasks.task_broadcast_local_video_creation.delay") + def test_video_update(self, create_task, update_task, delete_task): + """Update video and check broadcast through ActivityPub. + + Visible video update should send an update task. + """ + self.visible_video.save() + assert self.visible_video.is_visible() + assert self.visible_video.is_activity_pub_broadcasted + self.visible_video.title = "Still visible video" + with self.captureOnCommitCallbacks(execute=True): + self.visible_video.save() + + assert not create_task.called + update_task.assert_called_with(self.visible_video.id) + assert not delete_task.called + + @patch("pod.activitypub.tasks.task_broadcast_local_video_deletion.delay") + @patch("pod.activitypub.tasks.task_broadcast_local_video_update.delay") + @patch("pod.activitypub.tasks.task_broadcast_local_video_creation.delay") + def test_video_delete(self, create_task, update_task, delete_task): + """Hide video and check broadcast through ActivityPub. + + Newly invisible video should send a delete task. + """ + self.visible_video.save() + assert self.visible_video.is_visible() + assert self.visible_video.is_activity_pub_broadcasted + self.visible_video.is_draft = True + with self.captureOnCommitCallbacks(execute=True): + self.visible_video.save() + + assert not create_task.called + assert not update_task.called + delete_task.assert_called_with( + video_id=self.visible_video.id, owner_username=self.admin_user.username + ) diff --git a/pod/activitypub/tests/test_video_discovery.py b/pod/activitypub/tests/test_video_discovery.py new file mode 100644 index 0000000000..60db654ff3 --- /dev/null +++ b/pod/activitypub/tests/test_video_discovery.py @@ -0,0 +1,21 @@ +import json + + +from . import ActivityPubTestCase + +from pod.activitypub.deserialization.video import create_external_video + + +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 = create_external_video(payload, source_instance=self.peertube_test_following) + + assert ( + video.ap_id + == "http://peertube.test/videos/watch/717c9d87-c912-4943-a392-49fadf2f235d" + ) + assert len(video.videos) == 8 diff --git a/pod/activitypub/tests/test_video_update.py b/pod/activitypub/tests/test_video_update.py new file mode 100644 index 0000000000..3f6be85cbc --- /dev/null +++ b/pod/activitypub/tests/test_video_update.py @@ -0,0 +1,317 @@ +import json + +import httmock + +from . import ActivityPubTestCase +from pod.activitypub.models import ExternalVideo +from pod.activitypub.models import Following + + +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. + """ + + assert len(ExternalVideo.objects.all()) == 0 + + with open( + "pod/activitypub/tests/fixtures/video_creation_account_announce.json" + ) as fd: + account_announce_payload = json.load(fd) + + assert not len(ExternalVideo.objects.all()) + + 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) + + assert len(ExternalVideo.objects.all()) == 1 + + 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) + + assert len(ExternalVideo.objects.all()) == 1 + + def test_video_creation_fails_for_unfollowed_instance(self): + """Test video creation activities are ignored for unfollowed instances""" + + assert len(ExternalVideo.objects.all()) == 0 + + self.peertube_test_following.status = Following.Status.NONE + self.peertube_test_following.save() + + with open( + "pod/activitypub/tests/fixtures/video_creation_account_announce.json" + ) as fd: + account_announce_payload = json.load(fd) + + assert not len(ExternalVideo.objects.all()) + + 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) + + assert len(ExternalVideo.objects.all()) == 0 + + 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) + + assert len(ExternalVideo.objects.all()) == 0 + + 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_creation_account_announce.json" + ) as fd: + account_announce_payload = json.load(fd) + + assert not len(ExternalVideo.objects.all()) + + 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) + + assert ExternalVideo.objects.first().title == "Titre de la vidéo" + + 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) + + assert ExternalVideo.objects.first().title == "terre" + + def test_video_update_fails_for_unfollowed_instance(self): + """Test the video update forbidden for unfollowed instances""" + + with open( + "pod/activitypub/tests/fixtures/video_creation_account_announce.json" + ) as fd: + account_announce_payload = json.load(fd) + + assert not len(ExternalVideo.objects.all()) + + with httmock.HTTMock(self.mock_application_actor, self.mock_get_video): + self.client.post( + "/ap/inbox", + json.dumps(account_announce_payload), + content_type="application/json", + **self.headers, + ) + + assert ExternalVideo.objects.first().title == "Titre de la vidéo" + + self.peertube_test_following.status = Following.Status.NONE + self.peertube_test_following.save() + + with open("pod/activitypub/tests/fixtures/video_update.json") as fd: + payload = json.load(fd) + + self.client.post( + "/ap/inbox", + json.dumps(payload), + content_type="application/json", + **self.headers, + ) + + assert ExternalVideo.objects.first().title == "Titre de la vidéo" + + def test_video_update_fails_for_unrelated_instance(self): + """Test the video update forbidden for unrelated instances""" + + with open( + "pod/activitypub/tests/fixtures/video_creation_account_announce.json" + ) as fd: + account_announce_payload = json.load(fd) + + assert not len(ExternalVideo.objects.all()) + + with httmock.HTTMock(self.mock_application_actor, self.mock_get_video): + self.client.post( + "/ap/inbox", + json.dumps(account_announce_payload), + content_type="application/json", + **self.headers, + ) + + assert ExternalVideo.objects.first().title == "Titre de la vidéo" + + with open("pod/activitypub/tests/fixtures/video_update_not_owner.json") as fd: + payload = json.load(fd) + + self.client.post( + "/ap/inbox", + json.dumps(payload), + content_type="application/json", + **self.headers, + ) + + assert ExternalVideo.objects.first().title == "Titre de la vidéo" + + def test_video_delete(self): + """Test the video delete activity on the inbox""" + + with open( + "pod/activitypub/tests/fixtures/video_creation_account_announce.json" + ) as fd: + account_announce_payload = json.load(fd) + + assert not len(ExternalVideo.objects.all()) + + 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) + + assert len(ExternalVideo.objects.all()) == 1 + + 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) + + assert not len(ExternalVideo.objects.all()) + + def test_video_delete_fails_for_unfollowed_instance(self): + """Test video deletion forbidden for unfollowed instances""" + + self.other_peertube_test_following.delete() + + with open( + "pod/activitypub/tests/fixtures/video_creation_account_announce.json" + ) as fd: + account_announce_payload = json.load(fd) + + assert not len(ExternalVideo.objects.all()) + + with httmock.HTTMock(self.mock_application_actor, self.mock_get_video): + self.client.post( + "/ap/inbox", + json.dumps(account_announce_payload), + content_type="application/json", + **self.headers, + ) + + assert len(ExternalVideo.objects.all()) == 1 + + self.peertube_test_following.status = Following.Status.NONE + self.peertube_test_following.save() + + with open("pod/activitypub/tests/fixtures/video_delete.json") as fd: + payload = json.load(fd) + + with httmock.HTTMock(self.mock_get_video): + self.client.post( + "/ap/inbox", + json.dumps(payload), + content_type="application/json", + **self.headers, + ) + + assert len(ExternalVideo.objects.all()) == 1 + + def test_video_delete_fails_for_unrelated_instance(self): + """Test video deletion forbidden for unrelated instances""" + + with open( + "pod/activitypub/tests/fixtures/video_creation_account_announce.json" + ) as fd: + account_announce_payload = json.load(fd) + + assert not len(ExternalVideo.objects.all()) + + with httmock.HTTMock(self.mock_application_actor, self.mock_get_video): + self.client.post( + "/ap/inbox", + json.dumps(account_announce_payload), + content_type="application/json", + **self.headers, + ) + + assert len(ExternalVideo.objects.all()) == 1 + + with open("pod/activitypub/tests/fixtures/video_delete_not_owner.json") as fd: + payload = json.load(fd) + + with httmock.HTTMock(self.mock_get_video): + self.client.post( + "/ap/inbox", + json.dumps(payload), + content_type="application/json", + **self.headers, + ) + + assert len(ExternalVideo.objects.all()) == 1 diff --git a/pod/activitypub/urls.py b/pod/activitypub/urls.py new file mode 100644 index 0000000000..82246ae81e --- /dev/null +++ b/pod/activitypub/urls.py @@ -0,0 +1,37 @@ +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"), +] + +urlpatterns += [ + path("external_video/", views.external_video, name="external_video"), +] diff --git a/pod/activitypub/utils.py b/pod/activitypub/utils.py new file mode 100644 index 0000000000..0f105dea9b --- /dev/null +++ b/pod/activitypub/utils.py @@ -0,0 +1,162 @@ +import hashlib +import json +import logging +import random +import uuid +from collections import namedtuple +from urllib.parse import urlencode, urlunparse +from Crypto.PublicKey import RSA + +import requests +from django.conf import settings +from django.urls import reverse +from django.contrib.sites.models import Site + +from pod.video.models import Video + +from .constants import AP_REQUESTS_TIMEOUT, BASE_HEADERS +from .signature import ( + build_signature_payload, + build_signature_headers, + check_signature_payload, + check_signature_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=""): + """Format activitypub url.""" + 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 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) + ) + + private_key = RSA.import_key(settings.ACTIVITYPUB_PRIVATE_KEY) + public_key_url = ap_url(reverse("activitypub:account")) + "#main-key" + + payload["signature"] = build_signature_payload(private_key, payload) + signature_headers = build_signature_headers( + private_key, public_key_url, 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 + + +def check_signatures(request): + """Check the signatures from incoming requests.""" + + # Reading the incoming request public key may + # be slow due to the subsequent requests. + # Some kind of caching might be useful here. + + payload = json.loads(request.body.decode()) + ap_actor = ap_object(payload["actor"]) + ap_pubkey = ap_object(ap_actor["publicKey"]) + ap_pubkey_pem = ap_pubkey["publicKeyPem"] + public_key = RSA.import_key(ap_pubkey_pem) + + try: + valid_payload = check_signature_payload(public_key, payload) + valid_headers = check_signature_headers( + public_key, payload, request.headers, request.get_raw_uri() + ) + + # abort if any header is missing + except (KeyError, ValueError): + return False + + return valid_payload and valid_headers diff --git a/pod/activitypub/views.py b/pod/activitypub/views.py new file mode 100644 index 0000000000..62aeee78d7 --- /dev/null +++ b/pod/activitypub/views.py @@ -0,0 +1,439 @@ +"""Django ActivityPub endpoints""" + +import json +import logging + +from django.contrib.auth.models import User +from django.conf import settings +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 django.shortcuts import render +from django.core.exceptions import SuspiciousOperation + +from pod.video.models import Channel, Video +from pod.activitypub.models import ExternalVideo + +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, check_signatures + +logger = logging.getLogger(__name__) + + +AP_PAGE_SIZE = 25 + + +TYPE_TASK = { + "Follow": task_handle_inbox_follow, + "Accept": task_handle_inbox_accept, + "Reject": task_handle_inbox_reject, + "Announce": task_handle_inbox_announce, + "Update": task_handle_inbox_update, + "Delete": task_handle_inbox_delete, + "Undo": task_handle_inbox_undo, +} + + +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/ + """ + 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)) + + if ( + data["type"] in ("Announce", "Update", "Delete") + and not settings.TEST_SETTINGS + and not check_signatures(request) + ): + return HttpResponse("Signature could not be verified", status=403) + + if activitypub_task := TYPE_TASK.get(data["type"], None): + activitypub_task.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_activity_pub_broadcasted=True) + 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) + 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) + + +def render_external_video(request, id): + """Render external video.""" + external_video = get_object_or_404(ExternalVideo, id=id) + return render( + request, + "videos/video.html", + { + "channel": None, + "video": external_video, + "theme": None, + "listNotes": None, + "owner_filter": False, + "playlist": None, + }, + ) + + +def external_video(request, slug): + """Render a single external video.""" + try: + id = int(slug[: slug.find("-")]) + except ValueError: + raise SuspiciousOperation("Invalid external video id") + + get_object_or_404(ExternalVideo, id=id) + return render_external_video(request, id) diff --git a/pod/chapter/models.py b/pod/chapter/models.py index 6ec098e7a6..90b169da33 100644 --- a/pod/chapter/models.py +++ b/pod/chapter/models.py @@ -46,6 +46,19 @@ 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/custom/settings_local_docker_full_test.py b/pod/custom/settings_local_docker_full_test.py index 884d291c9e..4b6b66f4f7 100644 --- a/pod/custom/settings_local_docker_full_test.py +++ b/pod/custom/settings_local_docker_full_test.py @@ -79,6 +79,7 @@ } USE_XAPI_VIDEO = False +ACTIVITYPUB_CELERY_BROKER_URL = "redis://redis:6379/5" XAPI_CELERY_BROKER_URL = "redis://redis.localhost:6379/6" # for maximum console logging\n diff --git a/pod/enrichment/views.py b/pod/enrichment/views.py index 0c91e45a96..29409ea010 100644 --- a/pod/enrichment/views.py +++ b/pod/enrichment/views.py @@ -14,7 +14,7 @@ from pod.playlist.models import Playlist from pod.playlist.utils import get_video_list_for_playlist, playlist_can_be_displayed from pod.video.models import Video -from pod.video.utils import sort_videos_list +from pod.video.utils import sort_videos_queryset from pod.video.views import render_video from .models import Enrichment, EnrichmentGroup @@ -269,7 +269,7 @@ def video_enrichment( if request.GET.get("playlist"): playlist = get_object_or_404(Playlist, slug=request.GET.get("playlist")) if playlist_can_be_displayed(request, playlist): - videos = sort_videos_list(get_video_list_for_playlist(playlist), "rank") + videos = sort_videos_queryset(get_video_list_for_playlist(playlist), "rank") params = { "playlist_in_get": playlist, "videos": videos, diff --git a/pod/main/configuration.json b/pod/main/configuration.json index 9e795e3d2a..6103b1bacf 100644 --- a/pod/main/configuration.json +++ b/pod/main/configuration.json @@ -2255,6 +2255,28 @@ "fr": "Configuration de l'application quiz" } }, + "activitypub": { + "description": {}, + "settings": { + "USE_ACTIVITYPUB": { + "default_value": false, + "description": { + "en": [ + "" + ], + "fr": [ + "Activation du module ActivityPub." + ] + }, + "pod_version_end": "", + "pod_version_init": "3.9.0" + }, + }, + "title": { + "en": "", + "fr": "Configuration application activitypub" + } + }, "recorder": { "description": {}, "settings": { @@ -2658,6 +2680,20 @@ "pod_version_end": "", "pod_version_init": "3.1.0" }, + "DEFAULT_AP_THUMBNAIL": { + "default_value": "img/default_ap.png", + "description": { + "en": [ + "" + ], + "fr": [ + "Image par défaut envoyée comme vignette, utilisée pour communiquer via ActivityPub", + "Cette image doit être au format png et se situer dans le répertoire static." + ] + }, + "pod_version_end": "", + "pod_version_init": "3.8.2" + }, "DEFAULT_TYPE_ID": { "default_value": 1, "description": { diff --git a/pod/main/static/img/default.png b/pod/main/static/img/default.png new file mode 100644 index 0000000000..a364a1e2df Binary files /dev/null and b/pod/main/static/img/default.png differ diff --git a/pod/main/test_settings.py b/pod/main/test_settings.py index 61a0cdbb7b..cf1e143319 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 @@ -83,6 +84,7 @@ USE_MEETING = True USE_SPEAKER = True +USE_ACTIVITYPUB = True def get_shared_secret(): @@ -129,3 +131,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/main/views.py b/pod/main/views.py index 2d20faa11f..c3e7f89139 100644 --- a/pod/main/views.py +++ b/pod/main/views.py @@ -35,6 +35,7 @@ from wsgiref.util import FileWrapper from django.db.models import Q, Count from pod.video.models import Video, remove_accents +from pod.activitypub.models import ExternalVideo from pod.authentication.forms import FrontOwnerForm, SetNotificationForm from django.db.models import Sum import os @@ -154,7 +155,7 @@ def get_dest_email(owner, video, form_subject, request): # Soit le owner a été spécifié # Soit on le récupere via la video # v_owner = instance de User - v_owner = owner if (owner) else getattr(video, "owner", None) + v_owner = owner if (owner) else getattr(video, "owner", None) if video and not video.is_external else None # Si ni le owner ni la video a été renseigné if not v_owner: # Vérifier si l'utilisateur est authentifié @@ -193,28 +194,34 @@ def contact_us(request): else None ) - video = ( - Video.objects.get(id=request.GET.get("video"), sites=get_current_site(request)) - if ( - request.GET.get("video") - and request.GET.get("video").isdigit() - and Video.objects.filter( - id=request.GET.get("video"), sites=get_current_site(request) - ).first() - ) - else None - ) + media = None + if ( + request.GET.get("external_video") + and request.GET.get("external_video").isdigit() + and ExternalVideo.objects.filter( + id=request.GET.get("external_video") + ).first() + ): + media = ExternalVideo.objects.get(id=request.GET.get("external_video")) + elif ( + request.GET.get("video") + and request.GET.get("video").isdigit() + and Video.objects.filter( + id=request.GET.get("video"), sites=get_current_site(request) + ).first() + ): + media = Video.objects.get(id=request.GET.get("video"), sites=get_current_site(request)) description = ( "%s: %s\n%s: %s%s\n\n" % ( _("Title"), - video.title, + media.title, _("Link"), "https:" if request.is_secure() else "http:", - video.get_full_url(request), + media.get_full_url(request), ) - if video + if media else None ) @@ -273,7 +280,7 @@ def contact_us(request): ) text_content = bleach.clean(html_content, tags=[], strip=True) dest_email = [] - dest_email = get_dest_email(owner, video, form_subject, request) + dest_email = get_dest_email(owner, media, form_subject, request) msg = EmailMultiAlternatives( subject, text_content, DEFAULT_FROM_EMAIL, dest_email, reply_to=[email] diff --git a/pod/playlist/models.py b/pod/playlist/models.py index c86fa6863a..c6a563542e 100644 --- a/pod/playlist/models.py +++ b/pod/playlist/models.py @@ -12,7 +12,8 @@ from pod.main.models import get_nextautoincrement from pod.video.models import Video -from pod.video.utils import sort_videos_list +from pod.activitypub.models import ExternalVideo +from pod.video.utils import sort_videos_queryset SITE_ID = getattr(settings, "SITE_ID") @@ -163,10 +164,10 @@ def get_first_video(self, request=None) -> Video: from .utils import get_video_list_for_playlist, user_can_see_playlist_video if request is not None: - for video in sort_videos_list(get_video_list_for_playlist(self), "rank"): + for video in sort_videos_queryset(get_video_list_for_playlist(self), "rank"): if user_can_see_playlist_video(request, video, self): return video - return sort_videos_list(get_video_list_for_playlist(self), "rank").first() + return sort_videos_queryset(get_video_list_for_playlist(self), "rank").first() class PlaylistContent(models.Model): @@ -175,7 +176,16 @@ class PlaylistContent(models.Model): playlist = models.ForeignKey( Playlist, verbose_name=_("Playlist"), on_delete=models.CASCADE ) - video = models.ForeignKey(Video, verbose_name=_("Video"), on_delete=models.CASCADE) + video = models.ForeignKey( + Video, verbose_name=_("Video"), on_delete=models.CASCADE, blank=True, null=True + ) + external_video = models.ForeignKey( + ExternalVideo, + verbose_name=_("External video"), + on_delete=models.CASCADE, + blank=True, + null=True, + ) date_added = models.DateTimeField( verbose_name=_("Addition date"), default=timezone.now, editable=False ) @@ -204,6 +214,10 @@ def save(self, *args, **kwargs) -> None: self.rank = last_rank + 1 if last_rank is not None else 1 except Exception: ... + if not self.video and not self.external_video: + raise ValidationError( + _("PlaylistContent needs a Video or an ExternalVideo to be created.") + ) return super().save(*args, **kwargs) def __str__(self) -> str: diff --git a/pod/playlist/utils.py b/pod/playlist/utils.py index 17d4679c8f..4e81a2a1e4 100644 --- a/pod/playlist/utils.py +++ b/pod/playlist/utils.py @@ -1,5 +1,7 @@ """Esup-Pod playlist utilities.""" +from typing import Union + from django.contrib.auth.models import User from django.contrib.sites.models import Site from django.db.models.functions import Lower @@ -8,6 +10,7 @@ from django.core.handlers.wsgi import WSGIRequest from pod.video.models import Video +from pod.activitypub.models import ExternalVideo from django.conf import settings from .apps import FAVORITE_PLAYLIST_NAME @@ -16,7 +19,9 @@ import hashlib -def check_video_in_playlist(playlist: Playlist, video: Video) -> bool: +def check_video_in_playlist( + playlist: Playlist, video: Union[Video, ExternalVideo] +) -> bool: """ Verify if a video is present in a playlist. @@ -27,7 +32,12 @@ def check_video_in_playlist(playlist: Playlist, video: Video) -> bool: Returns: bool: True if the video is on the playlist, False otherwise """ - return PlaylistContent.objects.filter(playlist=playlist, video=video).exists() + if isinstance(video, Video): + return PlaylistContent.objects.filter(playlist=playlist, video=video).exists() + elif isinstance(video, ExternalVideo): + return PlaylistContent.objects.filter( + playlist=playlist, external_video=video + ).exists() def user_add_video_in_playlist(playlist: Playlist, video: Video) -> str: diff --git a/pod/playlist/views.py b/pod/playlist/views.py index 53f794fb50..a854b01e99 100644 --- a/pod/playlist/views.py +++ b/pod/playlist/views.py @@ -20,7 +20,7 @@ from pod.main.views import in_maintenance from pod.video.views import CURSUS_CODES, get_owners_has_instances from pod.video.models import Video -from pod.video.utils import sort_videos_list +from pod.video.utils import sort_videos_queryset from .models import Playlist, PlaylistContent from .forms import PlaylistForm, PlaylistPasswordForm, PlaylistRemoveForm @@ -241,7 +241,7 @@ def render_playlist( request: WSGIRequest, playlist: Playlist, sort_field: str, sort_direction: str ): """Render playlist page with the videos list of this.""" - videos_list = sort_videos_list( + videos_list = sort_videos_queryset( get_video_list_for_playlist(playlist), sort_field, sort_direction ) count_videos = len(videos_list) diff --git a/pod/settings.py b/pod/settings.py index 0ebeb19ea4..3c4a5b4c1a 100644 --- a/pod/settings.py +++ b/pod/settings.py @@ -72,6 +72,7 @@ "pod.ai_enhancement", "pod.speaker", "pod.custom", + "pod.activitypub", ] ## diff --git a/pod/urls.py b/pod/urls.py index 6a66b8afed..d445b3818e 100644 --- a/pod/urls.py +++ b/pod/urls.py @@ -39,6 +39,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", False) if USE_CAS: from cas import views as cas_views @@ -102,6 +103,12 @@ url(r"^webpush/", include("webpush.urls")), ] +# ACTIVITYPUB +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/admin.py b/pod/video/admin.py index 4ab2adf5d3..f784236efa 100644 --- a/pod/video/admin.py +++ b/pod/video/admin.py @@ -55,6 +55,8 @@ ACTIVE_VIDEO_COMMENT = getattr(settings, "ACTIVE_VIDEO_COMMENT", False) +USE_ACTIVITYPUB = getattr(settings, "USE_ACTIVITYPUB", False) + def url_to_edit_object(obj): url = reverse( @@ -162,7 +164,11 @@ class VideoAdmin(admin.ModelAdmin): "channel", "theme", ) - readonly_fields = ("duration", "encoding_in_progress", "get_encoding_step") + readonly_fields = ( + "duration", + "encoding_in_progress", + "get_encoding_step", + ) inlines = [] @@ -176,6 +182,16 @@ class VideoAdmin(admin.ModelAdmin): inlines += [ChapterInline] + def get_list_display(self, request): + if USE_ACTIVITYPUB: + return self.list_display + ("is_activity_pub_broadcasted",) + return self.list_display + + def get_readonly_fields(self, request, obj): + if USE_ACTIVITYPUB: + return self.readonly_fields + ("is_activity_pub_broadcasted",) + return self.readonly_fields + def get_owner_establishment(self, obj): owner = obj.owner return owner.owner.establishment diff --git a/pod/video/models.py b/pod/video/models.py index 7dd4f3fcab..0d99017720 100644 --- a/pod/video/models.py +++ b/pod/video/models.py @@ -47,6 +47,7 @@ from django.db.models.functions import Concat from os.path import splitext + USE_PODFILE = getattr(settings, "USE_PODFILE", False) if USE_PODFILE: from pod.podfile.models import CustomImageModel @@ -146,6 +147,7 @@ ), ) DEFAULT_THUMBNAIL = getattr(settings, "DEFAULT_THUMBNAIL", "img/default.svg") +DEFAULT_AP_THUMBNAIL = getattr(settings, "DEFAULT_AP_THUMBNAIL", "img/default.png") SECRET_KEY = getattr(settings, "SECRET_KEY", "") NOTES_STATUS = getattr( @@ -676,15 +678,9 @@ def default_site_discipline(sender, instance, **kwargs) -> None: instance.site = Site.objects.get_current() -class Video(models.Model): +class BaseVideo(models.Model): """Class describing video objects.""" - video = models.FileField( - _("Video"), - upload_to=get_storage_path_video, - max_length=255, - help_text=_("You can send an audio or video file."), - ) title = models.CharField( _("Title"), max_length=250, @@ -706,6 +702,82 @@ class Video(models.Model): editable=False, ) sites = models.ManyToManyField(Site) + description = RichTextField( + _("Description"), + config_name="complete", + blank=True, + help_text=_( + "Describe your content, add all needed related information, " + + "and format the result using the toolbar." + ), + ) + date_added = models.DateTimeField(_("Date added"), default=timezone.now) + date_evt = models.DateField( + _("Date of event"), default=date.today, blank=True, null=True + ) + main_lang = models.CharField( + _("Main language"), + max_length=2, + choices=LANG_CHOICES, + default=get_language(), + help_text=_("The main language used in the content."), + ) + tags = TagField( + help_text=_( + "Separate tags with spaces, " + "enclose the tags consist of several words in quotation marks." + ), + verbose_name=_("Tags"), + ) + licence = models.CharField( + _("Licence"), + max_length=8, + choices=LICENCE_CHOICES, + blank=True, + null=True, + help_text=_("Usage rights granted to your content."), + ) + duration = models.IntegerField(_("Duration"), default=0, editable=False, blank=True) + is_video = models.BooleanField(_("Is Video"), default=True, editable=False) + is_external = models.BooleanField(_("Is External Video"), default=False) + + class Meta: + abstract = True + + def __str__(self) -> str: + """Display a video object as string.""" + if self.id: + return "%s - %s" % ("%04d" % self.id, self.title) + else: + return "None" + + @property + def duration_in_time(self) -> str: + """Get the duration of a video.""" + return time.strftime("%H:%M:%S", time.gmtime(self.duration)) + + duration_in_time.fget.short_description = _("Duration") + + def get_absolute_url(self): + pass + + def get_full_url(self, request=None) -> str: + """Get the video full URL.""" + full_url = "".join( + ["//", get_current_site(request).domain, self.get_absolute_url()] + ) + return full_url + + +class Video(BaseVideo): + """Class describing video objects.""" + + video = models.FileField( + _("Video"), + upload_to=get_storage_path_video, + max_length=255, + help_text=_("You can send an audio or video file."), + ) type = models.ForeignKey( Type, verbose_name=_("Type"), @@ -723,19 +795,6 @@ class Video(models.Model): + "that they can’t delete this media." ), ) - description = RichTextField( - _("Description"), - config_name="complete", - blank=True, - help_text=_( - "Describe your content, add all needed related information, " - + "and format the result using the toolbar." - ), - ) - date_added = models.DateTimeField(_("Date added"), default=timezone.now) - date_evt = models.DateField( - _("Date of event"), default=date.today, blank=True, null=True - ) cursus = models.CharField( _("University course"), max_length=1, @@ -743,13 +802,6 @@ class Video(models.Model): default="0", help_text=_("Select an university course as audience target of the content."), ) - main_lang = models.CharField( - _("Main language"), - max_length=2, - choices=LANG_CHOICES, - default=get_language(), - help_text=_("The main language used in the content."), - ) transcript = models.CharField( _("Transcript"), max_length=2, @@ -757,27 +809,13 @@ class Video(models.Model): blank=True, help_text=_("Select an available language to transcribe the audio."), ) - tags = TagField( - help_text=_( - "Separate tags with spaces, " - "enclose the tags consist of several words in quotation marks." - ), - verbose_name=_("Tags"), - ) discipline = models.ManyToManyField( Discipline, blank=True, verbose_name=_("Disciplines"), help_text=_("The disciplines to which your content belongs."), ) - licence = models.CharField( - _("Licence"), - max_length=8, - choices=LICENCE_CHOICES, - blank=True, - null=True, - help_text=_("Usage rights granted to your content."), - ) + channel = models.ManyToManyField( Channel, verbose_name=_("Channels"), @@ -841,7 +879,6 @@ class Video(models.Model): verbose_name=_("Thumbnails"), related_name="videos", ) - duration = models.IntegerField(_("Duration"), default=0, editable=False, blank=True) overview = models.ImageField( _("Overview"), null=True, @@ -853,7 +890,6 @@ class Video(models.Model): encoding_in_progress = models.BooleanField( _("Encoding in progress"), default=False, editable=False ) - is_video = models.BooleanField(_("Is Video"), default=True, editable=False) date_delete = models.DateField( _("Date to delete"), @@ -867,6 +903,12 @@ class Video(models.Model): default=False, ) + is_activity_pub_broadcasted = models.BooleanField( + _("Broadcasted through ActivityPub"), + default=False, + editable=False, + ) + class Meta: """Metadata subclass for Video object.""" @@ -1001,22 +1043,30 @@ 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, is_activity_pub=False) -> str: """Get a thumbnail url for the video.""" request = None if self.thumbnail and self.thumbnail.file_exist(): # Do not serve thumbnail url directly, as it can lead to the video URL im = get_thumbnail(self.thumbnail.file, "x170", crop="center", quality=80) - return im.url + thumbnail_url = im.url else: - return "".join( + thumbnail_url = "".join( [ "//", get_current_site(request).domain, - static(DEFAULT_THUMBNAIL), + static( + DEFAULT_AP_THUMBNAIL if is_activity_pub else DEFAULT_THUMBNAIL + ), ] ) + if scheme: + scheme = "https" if getattr(settings, "SECURE_SSL_REDIRECT") else "http" + return f"{scheme}:{thumbnail_url}" + + return thumbnail_url + @property def get_thumbnail_admin(self): thumbnail_url = "" @@ -1056,13 +1106,6 @@ def get_thumbnail_card(self) -> str: % (thumbnail_url, self.title) ) - @property - def duration_in_time(self) -> str: - """Get the duration of a video.""" - return time.strftime("%H:%M:%S", time.gmtime(self.duration)) - - duration_in_time.fget.short_description = _("Duration") - @property def encoded(self) -> bool: """Get the encoded status of a video.""" @@ -1152,13 +1195,6 @@ def get_absolute_url(self) -> str: """Get the video absolute URL.""" return reverse("video:video", args=[str(self.slug)]) - def get_full_url(self, request=None) -> str: - """Get the video full URL.""" - full_url = "".join( - ["//", get_current_site(request).domain, self.get_absolute_url()] - ) - return full_url - def get_hashkey(self) -> str: return hashlib.sha256( ("%s-%s" % (SECRET_KEY, self.id)).encode("utf-8") @@ -1236,6 +1272,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, @@ -1294,6 +1331,7 @@ def get_json_to_index(self) -> str: "mediatype": "video" if self.is_video else "audio", "cursus": "%s" % __CURSUS_CODES_DICT__[self.cursus], "main_lang": "%s" % __LANG_CHOICES_DICT__[self.main_lang], + "is_external": self.is_external, } return json.dumps(data_to_dump) except ObjectDoesNotExist as e: @@ -1456,6 +1494,16 @@ def update_additional_owners_rights(self) -> None: videodir.owner = self.owner videodir.save() + def is_visible(self): + """Check if video is visible for activitypub broadcast.""" + return ( + not self.is_draft + and self.encoded + and not self.encoding_in_progress + and not self.is_restricted + and not self.password + ) + class UpdateOwner(models.Model): class Meta: diff --git a/pod/video/templates/videos/card.html b/pod/video/templates/videos/card.html index 085bc8225a..fc2b26a6b1 100644 --- a/pod/video/templates/videos/card.html +++ b/pod/video/templates/videos/card.html @@ -13,12 +13,14 @@
{{video.duration_in_time}} - {% is_quiz_exists video as is_quiz_exists %} - {% if is_quiz_exists %} - - - + {% if not video.is_external %} + {% is_quiz_exists video as is_quiz_exists %} + {% if is_quiz_exists %} + + + + {% endif %} {% endif %} {% if video.password %} {% endif %} + {% if video.is_external %} + + + + {% endif %} {% if video.is_video %} diff --git a/pod/video/templates/videos/link_video.html b/pod/video/templates/videos/link_video.html index 387509faa1..d20c4d415a 100644 --- a/pod/video/templates/videos/link_video.html +++ b/pod/video/templates/videos/link_video.html @@ -14,7 +14,7 @@ {% is_favorite user video as is_favorite %} {% get_favorite_playlist user as fav_playlist %} -{% if USE_PLAYLIST and USE_FAVORITES and video.is_draft is False and not hide_favorite_link is True %} +{% if USE_PLAYLIST and USE_FAVORITES and video.is_draft is False and not hide_favorite_link is True and not video.is_external %} {% if is_favorite %} @@ -26,10 +26,12 @@ {% endif %} {% endif %} -{% if video.owner == request.user or request.user.is_superuser or request.user in video.additional_owners.all or perms.video.change_video %} - - - +{% if not video.is_external %} + {% if video.owner == request.user or request.user.is_superuser or request.user in video.additional_owners.all or perms.video.change_video %} + + + + {% endif %} {% endif %} {% comment %} {% if request.resolver_match.namespace %} @@ -41,15 +43,17 @@ {% endwith %} {% endif %} {% endcomment %} - -{% include 'videos/link_video_dropdown_menu.html' %} +{% if not video.is_external %} + + {% include 'videos/link_video_dropdown_menu.html' %} +{% endif %} {% if "edit" in request.resolver_match.url_name %} {% with video.get_other_version as versions %} {% if versions|length > 0 %} diff --git a/pod/video/templates/videos/video-all-info.html b/pod/video/templates/videos/video-all-info.html index b5e406d266..d44cce0c72 100644 --- a/pod/video/templates/videos/video-all-info.html +++ b/pod/video/templates/videos/video-all-info.html @@ -7,7 +7,7 @@

{% if video.date_evt %}{{ video.date_evt }}{% endif %}
- + diff --git a/pod/video/templates/videos/video-info.html b/pod/video/templates/videos/video-info.html index 1e7934ffcc..d965d71756 100644 --- a/pod/video/templates/videos/video-info.html +++ b/pod/video/templates/videos/video-info.html @@ -21,7 +21,7 @@ {{ video.get_viewcount }} {% endif %} - {% if USE_PLAYLIST %} + {% if USE_PLAYLIST and not video.is_external %}
{% trans 'Addition in a playlist' %} {% if USE_STATS_VIEW and not video.encoding_in_progress %} @@ -31,7 +31,7 @@ {% endif %}
{% endif %} - {% if USE_PLAYLIST and USE_FAVORITES %} + {% if USE_PLAYLIST and not video.is_external and USE_FAVORITES %} {% endif %}
- {% if USE_PLAYLIST and user.is_authenticated and not video.is_draft %} + {% if USE_PLAYLIST and not video.is_external and user.is_authenticated and not video.is_draft %} @@ -50,7 +50,7 @@ {% is_favorite user video as is_favorite %} {% get_favorite_playlist user as fav_playlist %} - {% if USE_PLAYLIST and USE_FAVORITES and video.is_draft is False and not hide_favorite_link is True %} + {% if USE_PLAYLIST and not video.is_external and USE_FAVORITES and video.is_draft is False and not hide_favorite_link is True %} {% if is_favorite %} diff --git a/pod/video/templates/videos/video_aside.html b/pod/video/templates/videos/video_aside.html index dfa3913821..f508dd8c13 100644 --- a/pod/video/templates/videos/video_aside.html +++ b/pod/video/templates/videos/video_aside.html @@ -5,31 +5,34 @@ {% include 'playlist/playlist_player.html' %} {% endif %} -{% if video.owner == request.user or request.user.is_superuser or request.user in video.additional_owners.all or perms.video.change_video or perms.enrichment.edit_enrichment or perms.completion.add_contributor or perms.completion.add_track or perms.completion.add_document or perms.completion.add_overlay or perms.chapter.change_chapter or perms.video.delete_video %} -
-

-  {% trans "Manage video"%} -

-
- {% include "videos/link_video.html" with hide_favorite_link=True %} +{% if not video.is_external %} + {% if video.owner == request.user or request.user.is_superuser or request.user in video.additional_owners.all or perms.video.change_video or perms.enrichment.edit_enrichment or perms.completion.add_contributor or perms.completion.add_track or perms.completion.add_document or perms.completion.add_overlay or perms.chapter.change_chapter or perms.video.delete_video %} +
+

+  {% trans "Manage video"%} +

+
+ {% include "videos/link_video.html" with hide_favorite_link=True %} +
-
-{% endif %} + {% endif %} -{% if USE_QUIZ %} - {% is_quiz_accessible video as is_quiz_accessible %} - {% if is_quiz_accessible or video.owner == request.user or request.user.is_superuser or request.user in video.additional_owners.all %} -
- {% include "quiz/video_quiz_aside.html" %} + {% if USE_QUIZ %} + {% is_quiz_accessible video as is_quiz_accessible %} + {% if is_quiz_accessible or video.owner == request.user or request.user.is_superuser or request.user in video.additional_owners.all %} +
+ {% include "quiz/video_quiz_aside.html" %} +
+ {% endif %} + {% endif %} + + {# Do not display notes if video is restricted. #} + {% if not form %} +
+ {% include 'videos/video_notes.html' %}
{% endif %} {% endif %} -{# Do not display notes if video is restricted. #} -{% if not form %} -
- {% include 'videos/video_notes.html' %} -
-{% endif %} {% include 'aside.html' %} diff --git a/pod/video/templates/videos/video_opengraph.html b/pod/video/templates/videos/video_opengraph.html index 1e056aa8dd..371ca05e22 100644 --- a/pod/video/templates/videos/video_opengraph.html +++ b/pod/video/templates/videos/video_opengraph.html @@ -7,7 +7,6 @@ {% endif %} - @@ -18,23 +17,27 @@ - - - - {% if video.is_draft == True %} -{% endif %} \ No newline at end of file +{% endif %} + +{% if not video.is_external %} + + + + + +{% endif %} diff --git a/pod/video/templates/videos/video_page_content.html b/pod/video/templates/videos/video_page_content.html index 73a7a61023..b65b16206c 100644 --- a/pod/video/templates/videos/video_page_content.html +++ b/pod/video/templates/videos/video_page_content.html @@ -85,12 +85,14 @@

{% endif %} {% endif %} - {% enhancement_is_already_asked video as eiaa %} - {% if eiaa %} - + {% if not video.is_external %} + {% enhancement_is_already_asked video as eiaa %} + {% if eiaa %} + + {% endif %} {% endif %} {% if video.get_encoding_step == "" %}