diff --git a/Makefile b/Makefile index 40647a8..48fe6cd 100644 --- a/Makefile +++ b/Makefile @@ -52,11 +52,11 @@ envfile: isort: @echo "-> Apply isort changes to ensure proper imports ordering" - @${ACTIVATE} isort --profile black . + @${ACTIVATE} isort --profile black aboutcode/ fedcode/ federatedcode/ tests/ black: @echo "-> Apply black code formatter" - @${ACTIVATE} black ${BLACK_ARGS} . + @${ACTIVATE} black ${BLACK_ARGS} aboutcode/ fedcode/ federatedcode/ tests/ doc8: @echo "-> Run doc8 validation" @@ -92,7 +92,7 @@ migrate: test: @echo "-> Run the test suite" - @${ACTIVATE} pytest -vvs + @${ACTIVATE} pytest -vvs tests/ fedcode/ federatedcode/ aboutcode/ docs: rm -rf docs/_build/ diff --git a/fedcode/management/commands/federate.py b/fedcode/management/commands/federate.py index 0046af2..61b6ffe 100644 --- a/fedcode/management/commands/federate.py +++ b/fedcode/management/commands/federate.py @@ -9,6 +9,7 @@ from traceback import format_exc as traceback_format_exc +import requests from django.core.management.base import BaseCommand from fedcode.models import FederateRequest @@ -17,19 +18,17 @@ def send_fed_req_task(): - """ - send_fed_req_task is a task to send the http signed request to the target and save the status of the request - """ + """Send activity request to the target and save the status.""" + for rq in FederateRequest.objects.all().order_by("created_at"): if not rq.done: try: - HttpSignature.signed_request( - rq.target, rq.body, FEDERATEDCODE_PRIVATE_KEY, rq.key_id - ) + headers = {"Content-Type": "application/json"} + requests.post(rq.target, json=rq.body, headers=headers) rq.done = True rq.save() except Exception as e: - rq.error_message = e + rq.error_message = f"Failed to federate {rq!r} {e!r} \n {traceback_format_exc()}" finally: rq.save() diff --git a/fedcode/migrations/0003_remove_package_local_remove_person_local_and_more.py b/fedcode/migrations/0003_remove_package_local_remove_person_local_and_more.py new file mode 100644 index 0000000..306acac --- /dev/null +++ b/fedcode/migrations/0003_remove_package_local_remove_person_local_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.0.1 on 2024-12-10 10:01 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("fedcode", "0002_alter_package_options_alter_federaterequest_done_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name="package", + name="local", + ), + migrations.RemoveField( + model_name="person", + name="local", + ), + migrations.AlterField( + model_name="person", + name="user", + field=models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddConstraint( + model_name="person", + constraint=models.CheckConstraint( + check=models.Q( + models.Q(("remote_actor__isnull", True), ("user__isnull", False)), + models.Q(("remote_actor__isnull", False), ("user__isnull", True)), + _connector="OR", + ), + name="either_local_or_remote", + ), + ), + ] diff --git a/fedcode/models.py b/fedcode/models.py index 41fc4e4..79a9b05 100644 --- a/fedcode/models.py +++ b/fedcode/models.py @@ -64,10 +64,6 @@ class Actor(models.Model): blank=False, ) - local = models.BooleanField( - default=True, - ) - class Meta: abstract = True @@ -237,6 +233,7 @@ def to_ap(self): "type": "Note", "author": self.acct, "content": self.content, + "update_date": str(self.updated_at), } @@ -365,6 +362,7 @@ class Person(Actor): user = models.OneToOneField( User, null=True, + blank=True, on_delete=models.CASCADE, ) @@ -381,6 +379,21 @@ class Person(Actor): help_text="Notes created by this user", ) + class Meta: + constraints = [ + models.CheckConstraint( + check=( + models.Q(user__isnull=False, remote_actor__isnull=True) + | models.Q(user__isnull=True, remote_actor__isnull=False) + ), + name="either_local_or_remote", + ), + ] + + @property + def local(self): + return bool(self.user) + @property def avatar_absolute_url(self): return f'{"https://"}{FEDERATEDCODE_DOMAIN}{self.avatar.url}' @@ -410,6 +423,8 @@ def absolute_url_ap(self): @property def inbox_url(self): + if not self.local: + return self.remote_actor.url return full_reverse("user-inbox", self.user.username) @property @@ -477,7 +492,10 @@ class Meta: ordering = ["-updated_at"] def __str__(self): - return f"{self.person.user.username} - {self.package.purl}" + username = self.person.remote_actor.username + if self.person.local: + username = self.person.user.username + return f"{username} - {self.package.purl}" class Repository(models.Model): diff --git a/fedcode/templates/pkg_profile.html b/fedcode/templates/pkg_profile.html index 36759b6..eb09305 100644 --- a/fedcode/templates/pkg_profile.html +++ b/fedcode/templates/pkg_profile.html @@ -82,8 +82,12 @@
{{ follower.person.user.username }} profile image

- @{{ follower.person.user.username }} -

+ {% if follower.person.local %} + @{{ follower.person.user.username }} + {% else %} + @{{ follower.person.remote_actor.username }} + {% endif %} +

{% endfor %} diff --git a/fedcode/views.py b/fedcode/views.py index 254b38a..d470455 100644 --- a/fedcode/views.py +++ b/fedcode/views.py @@ -10,6 +10,7 @@ import json import logging import os.path +from urllib.parse import urlparse import requests from django.contrib import messages @@ -59,6 +60,7 @@ from fedcode.models import Note from fedcode.models import Package from fedcode.models import Person +from fedcode.models import RemoteActor from fedcode.models import Repository from fedcode.models import Reputation from fedcode.models import Review @@ -696,6 +698,24 @@ def post(self, request, *args, **kwargs): return HttpResponseBadRequest("Invalid message") +@method_decorator(has_valid_header, name="dispatch") +class RemoteUserSubscribe(View): + def get(self, request, *args, **kwargs): + """Endpoint to for existing remote user to subscribe to package.""" + purl = request.GET.get("purl").rstrip("/") + package = get_object_or_404(Package, purl=purl) + remote_actor = get_object_or_404(RemoteActor, username=kwargs["username"]) + host = request.get_host() + if urlparse(remote_actor.url).netloc == host: + _, created = Follow.objects.get_or_create(package=package, person=remote_actor.person) + message = f"Already subscribed package {purl}" + if created: + message = f"Successfully subscribed package {purl}" + + return JsonResponse({"status": "success", "message": message}) + return HttpResponseBadRequest() + + @method_decorator(has_valid_header, name="dispatch") class PackageInbox(View): def get(self, request, *args, **kwargs): @@ -736,7 +756,7 @@ def get(self, request, *args, **kwargs): (or at least the ones you're authorized to see). (client-to-server and/or server-to-server)""" - actor = Package.objects.get(purl=kwargs["purl_string"]) + actor = get_object_or_404(Package, purl=kwargs["purl_string"]) return JsonResponse( {"notes": ap_collection(actor.notes)}, content_type=AP_CONTENT_TYPE, diff --git a/federatedcode/urls.py b/federatedcode/urls.py index a059387..fb16091 100644 --- a/federatedcode/urls.py +++ b/federatedcode/urls.py @@ -6,8 +6,8 @@ # See https://github.com/nexB/federatedcode for support or download. # See https://aboutcode.org for more information about AboutCode.org OSS projects. # + from django.conf import settings -from django.conf.urls.static import static from django.contrib import admin from django.urls import include from django.urls import path @@ -28,6 +28,7 @@ from fedcode.views import PersonSignUp from fedcode.views import PersonUpdateView from fedcode.views import PersonView +from fedcode.views import RemoteUserSubscribe from fedcode.views import RepositoryListView from fedcode.views import ReviewListView from fedcode.views import ReviewView @@ -92,6 +93,11 @@ path("api/v0/users/@/inbox", UserInbox.as_view(), name="user-inbox"), path("api/v0/users/@/outbox", UserOutbox.as_view(), name="user-outbox"), path("api/v0/purls/@/inbox", PackageInbox.as_view(), name="purl-inbox"), + path( + "api/v0/users/@/subscribe", + RemoteUserSubscribe.as_view(), + name="purl-subscribe", + ), path( "api/v0/purls/@/outbox", PackageOutbox.as_view(), diff --git a/pyproject-aboutcode.federatedcode.toml b/pyproject-aboutcode.federatedcode.toml index f6455a6..29d212a 100644 --- a/pyproject-aboutcode.federatedcode.toml +++ b/pyproject-aboutcode.federatedcode.toml @@ -36,7 +36,7 @@ classifiers = [ dependencies = [ "packageurl_python >= 0.15.6", - "aboutcode.hashid>=0.1.0", + "aboutcode.hashid>=0.2.0", "python-dotenv>=1.0.1", "click>=8.1.7", "requests>=2.32.3", diff --git a/tests/test_ap_api.py b/tests/test_ap_api.py index 8705520..1f30a7a 100644 --- a/tests/test_ap_api.py +++ b/tests/test_ap_api.py @@ -209,12 +209,14 @@ def test_get_user_inbox(person, vulnerability, review, package): "type": "Note", "author": "pkg:maven/org.apache.logging@127.0.0.1:8000", "content": "yaml data1", + "update_date": str(note1.updated_at), }, { "id": f"https://127.0.0.1:8000/notes/{note2.id}", "type": "Note", "author": "pkg:maven/org.apache.logging@127.0.0.1:8000", "content": "yaml data2", + "update_date": str(note2.updated_at), }, ], }, @@ -282,6 +284,7 @@ def test_get_user_outbox(person, vulnerability, review, note): "content": note.content, "id": f"https://127.0.0.1:8000/notes/{note.id}", "type": "Note", + "update_date": str(note.updated_at), } ], }, @@ -383,6 +386,7 @@ def test_get_package_inbox(package, service): "[] fixing_vulnerabilities: []", "id": f"https://127.0.0.1:8000/notes/{note1.id}", "type": "Note", + "update_date": str(note1.updated_at), } ], "totalItems": 1, @@ -413,6 +417,7 @@ def test_get_package_outbox(service, package): "content": "yaml data1", "id": f"https://127.0.0.1:8000/notes/{note1.id}", "type": "Note", + "update_date": str(note1.updated_at), } ], "totalItems": 1, diff --git a/tests/test_vocabulary_toap.py b/tests/test_vocabulary_toap.py index 623653b..acac9d5 100644 --- a/tests/test_vocabulary_toap.py +++ b/tests/test_vocabulary_toap.py @@ -158,6 +158,7 @@ def test_objects_to_ap(repo, review, vulnerability, note, rep, mute_post_save_si "id": f"https://127.0.0.1:8000/notes/{note.id}", "author": note.acct, "content": note.content, + "update_date": str(note.updated_at), } assert rep.to_ap == { @@ -168,5 +169,6 @@ def test_objects_to_ap(repo, review, vulnerability, note, rep, mute_post_save_si "id": f"https://127.0.0.1:8000/notes/{note.id}", "author": note.acct, "content": note.content, + "update_date": str(note.updated_at), }, }