diff --git a/webapp/activities/__init__.py b/webapp/activities/__init__.py index 4a13231..906bd86 100644 --- a/webapp/activities/__init__.py +++ b/webapp/activities/__init__.py @@ -24,19 +24,19 @@ def wrapper(target, activity: ActivityObject, *args, **kwargs): action_object = Actor.objects.get(id=activity.object) except Actor.DoesNotExist: logger.error(f"Object not found: '{activity.object}'") - return JsonResponse({"error": "Actor f'{activity.object}' not found"}, status=404) + action_object = None action.send( sender=localactor, verb=activity.type, action_object=action_object, target=target ) # noqa: E501 - return f(*args, **kwargs) + return f(target, activity, *args, **kwargs) return wrapper @action_decorator -def create(self, target, message: dict) -> JsonResponse: +def create(target: Actor, activity: ActivityObject) -> JsonResponse: """ Create a new `:model:Note`. @@ -72,9 +72,12 @@ def create(self, target, message: dict) -> JsonResponse: } # noqa: E501 """ - logger.error(f"Create Object: {message}") + logger.error(f"Create Object: {activity.object}") + + note = activity.object + assert note is not None + assert isinstance(note, dict) - note = message.get("object") if note.type == "Note": from webapp.models import Note @@ -86,22 +89,33 @@ def create(self, target, message: dict) -> JsonResponse: return JsonResponse( { - "status": f"success: {message['actor']} {message['type']} {message['object']}" # noqa: E501 + "status": f"success: {activity.actor} {activity.type} {activity.object}" # noqa: E501 } # noqa: E501 ) # noqa: E501 @action_decorator -def accept(self, message: dict) -> JsonResponse: +def accept(target: Actor, activity: ActivityObject) -> JsonResponse: """ - Accept + Received an Accept. + + Remember the accept-id in the database. + So we can later delete the follow request. + + :param target: The target of the activity + :param activity: The :py:class:webapp.activity.Activityobject` """ + from webapp.models.activitypub.actor import Fllwng + + fllwng = Fllwng.objects.get(actor=activity.actor) + fllwng.accepted = activity.id # remember the accept-id + fllwng.save() return JsonResponse({"status": "accepted."}) @action_decorator -def delete(self, message: dict) -> JsonResponse: +def delete(target: Actor, activity: ActivityObject) -> JsonResponse: """ Delete an activity. """ @@ -189,9 +203,9 @@ def follow(target: Actor, activity: ActivityObject): ) # noqa: E501, BLK100 if settings.DEBUG: - acceptFollow(remoteActor.inbox, activity, action_id[0][1].id) + acceptFollow(remoteActor.get('inbox'), activity, action_id[0][1].id) else: - acceptFollow.delay(remoteActor.inbox, activity, action_id[0][1].id) + acceptFollow.delay(remoteActor.get('inbox'), activity, action_id[0][1].id) return JsonResponse( { diff --git a/webapp/activity.py b/webapp/activity.py index 072e24c..169898c 100644 --- a/webapp/activity.py +++ b/webapp/activity.py @@ -12,7 +12,6 @@ from decimal import Decimal from dataclasses import dataclass from dataclasses import field - # from dataclasses import asdict # from dataclasses import is_dataclass diff --git a/webapp/admin.py b/webapp/admin.py index d66bbf2..4d52b5b 100644 --- a/webapp/admin.py +++ b/webapp/admin.py @@ -23,6 +23,7 @@ from django.urls import path, reverse from django.utils.decorators import method_decorator from django.utils.html import escape + # from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_protect @@ -30,6 +31,7 @@ from .forms import UserChangeForm, UserCreationForm from .models import Action, Actor, Note, Profile, User, Like +from webapp.models.activitypub.actor import Follow from .tasks import generateProfileKeyPair try: @@ -262,6 +264,12 @@ class LikeAdmin(admin.ModelAdmin): admin.site.register(Like, LikeAdmin) +class FollowInline(admin.TabularInline): + model = Follow + fk_name = "actor" + list_display = ("object",) + + class ActorAdmin(admin.ModelAdmin): model = Actor list_display = ( @@ -269,6 +277,7 @@ class ActorAdmin(admin.ModelAdmin): "type", "profile", ) + inlines = [FollowInline] admin.site.register(Actor, ActorAdmin) @@ -286,7 +295,7 @@ class ActionAdmin(GenericAdminModelAdmin): "public", ) # list_editable = ("activity_type",) - list_filter = ("timestamp", ) # "activity_type") + list_filter = ("timestamp",) # "activity_type") # raw_id_fields = ( # "actor_content_type", # "target_content_type", diff --git a/webapp/fields.py b/webapp/fields.py index e6c3334..1196365 100644 --- a/webapp/fields.py +++ b/webapp/fields.py @@ -1,16 +1,108 @@ +""" +..todo:: + + [ ] Add a description for the module. + [ ] Add a description for the class. + [ ] Add a description for the method. + [ ] Add a description for the attribute. + [ ] Add a description for the parameter. + [ ] Add a description for the return value. + [ ] Add a description for the exception. + [ ] Basically everything. Do under no circumstances use this yet. + +The idea of this is to be able to add a "FedID" to any model, that will +automatically be generated and stored, so objects creation and serialization +for ActivityPub can be done without having to worry about the ID. + +""" from django.db import models +from django.forms import CharField, URLInput, ValidationError, validators +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from urllib.parse import urlsplit, urlunsplit +import warnings +from django.utils.deprecation import RemovedInDjango60Warning + +from webapp.validators import validate_iri + + +class URLField(CharField): + widget = URLInput + default_error_messages = { + "invalid": _("Enter a valid URL."), + } + default_validators = [validators.URLValidator()] + + def __init__(self, *, assume_scheme=None, **kwargs): + if assume_scheme is None: + if settings.FORMS_URLFIELD_ASSUME_HTTPS: + assume_scheme = "https" + else: + warnings.warn( + "The default scheme will be changed from 'http' to 'https' in " + "Django 6.0. Pass the forms.URLField.assume_scheme argument to " + "silence this warning, or set the FORMS_URLFIELD_ASSUME_HTTPS " + "transitional setting to True to opt into using 'https' as the new " + "default scheme.", + RemovedInDjango60Warning, + stacklevel=2, + ) + assume_scheme = "http" + # RemovedInDjango60Warning: When the deprecation ends, replace with: + # self.assume_scheme = assume_scheme or "https" + self.assume_scheme = assume_scheme + super().__init__(strip=True, **kwargs) + + def to_python(self, value): + def split_url(url): + """ + Return a list of url parts via urlsplit(), or raise + ValidationError for some malformed URLs. + """ + try: + return list(urlsplit(url)) + except ValueError: + # urlsplit can raise a ValueError with some + # misformatted URLs. + raise ValidationError(self.error_messages["invalid"], code="invalid") + value = super().to_python(value) + if value: + url_fields = split_url(value) + if not url_fields[0]: + # If no URL scheme given, add a scheme. + url_fields[0] = self.assume_scheme + if not url_fields[1]: + # Assume that if no domain is provided, that the path segment + # contains the domain. + url_fields[1] = url_fields[2] + url_fields[2] = "" + # Rebuild the url_fields list, since the domain segment may now + # contain the path too. + url_fields = split_url(urlunsplit(url_fields)) + value = urlunsplit(url_fields) + return value -class FedIDField(models.URLField): - """ - A Field that represents a FedID. +class IRIField(models.CharField): + default_validators = [validate_iri] + description = _("IRI") - .. seealso:: - Activity Pub _` + def __init__(self, verbose_name=None, name=None, **kwargs): + kwargs.setdefault("max_length", 200) + super().__init__(verbose_name, name, **kwargs) - **id**: - The object's unique global identifier (unless the object is transient, in which case the id MAY be omitted). + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + if kwargs.get("max_length") == 200: + del kwargs["max_length"] + return name, path, args, kwargs - """ - def __init__(self, *args, **kwargs): - super(ActorField, self).__init__(*args, **kwargs) + def formfield(self, **kwargs): + # As with CharField, this will cause URL validation to be performed + # twice. + return super().formfield( + **{ + "form_class": IRIField, + **kwargs, + } + ) diff --git a/webapp/migrations/0042_actor_flw.py b/webapp/migrations/0042_actor_flw.py deleted file mode 100644 index 5d1259e..0000000 --- a/webapp/migrations/0042_actor_flw.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.0.7 on 2024-08-09 08:46 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("webapp", "0041_alter_actor_id"), - ] - - operations = [ - migrations.AddField( - model_name="actor", - name="flw", - field=models.ManyToManyField( - blank=True, related_name="flwng", to="webapp.actor" - ), - ), - ] diff --git a/webapp/migrations/0042_fllwng_actor_flw.py b/webapp/migrations/0042_fllwng_actor_flw.py new file mode 100644 index 0000000..e4ad5ad --- /dev/null +++ b/webapp/migrations/0042_fllwng_actor_flw.py @@ -0,0 +1,64 @@ +# Generated by Django 5.0.7 on 2024-08-10 15:36 + +import django.db.models.deletion +import webapp.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("webapp", "0041_alter_actor_id"), + ] + + operations = [ + migrations.CreateModel( + name="Fllwng", + fields=[ + ( + "id", + models.CharField( + max_length=255, + primary_key=True, + serialize=False, + unique=True, + validators=[webapp.validators.validate_iri], + ), + ), + ( + "accepted", + models.URLField( + blank=True, + null=True, + validators=[webapp.validators.validate_iri], + ), + ), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="actor", + to="webapp.actor", + ), + ), + ( + "object", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="object", + to="webapp.actor", + ), + ), + ], + ), + migrations.AddField( + model_name="actor", + name="flw", + field=models.ManyToManyField( + blank=True, + related_name="flwng", + through="webapp.Fllwng", + to="webapp.actor", + ), + ), + ] diff --git a/webapp/migrations/0043_remove_actor_follows.py b/webapp/migrations/0043_remove_actor_follows.py new file mode 100644 index 0000000..15fb7e0 --- /dev/null +++ b/webapp/migrations/0043_remove_actor_follows.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.7 on 2024-08-11 15:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("webapp", "0042_fllwng_actor_flw"), + ] + + operations = [ + migrations.RemoveField( + model_name="actor", + name="follows", + ), + ] diff --git a/webapp/migrations/0044_remove_actor_flw_follow_actor_follows_delete_fllwng.py b/webapp/migrations/0044_remove_actor_flw_follow_actor_follows_delete_fllwng.py new file mode 100644 index 0000000..2c407ef --- /dev/null +++ b/webapp/migrations/0044_remove_actor_flw_follow_actor_follows_delete_fllwng.py @@ -0,0 +1,73 @@ +# Generated by Django 5.0.7 on 2024-08-11 15:14 + +import django.db.models.deletion +import uuid +import webapp.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("webapp", "0043_remove_actor_follows"), + ] + + operations = [ + migrations.RemoveField( + model_name="actor", + name="flw", + ), + migrations.CreateModel( + name="Follow", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "accepted", + models.URLField( + blank=True, + null=True, + validators=[webapp.validators.validate_iri], + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "actor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="actor", + to="webapp.actor", + ), + ), + ( + "object", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="object", + to="webapp.actor", + ), + ), + ], + ), + migrations.AddField( + model_name="actor", + name="follows", + field=models.ManyToManyField( + blank=True, + related_name="followed_by", + through="webapp.Follow", + to="webapp.actor", + ), + ), + migrations.DeleteModel( + name="Fllwng", + ), + ] diff --git a/webapp/models/activitypub/actor.py b/webapp/models/activitypub/actor.py index 4a91361..1169173 100644 --- a/webapp/models/activitypub/actor.py +++ b/webapp/models/activitypub/actor.py @@ -16,6 +16,7 @@ from functools import cached_property from webapp.exceptions import RemoteActorError from django.contrib.sites.models import Site +import uuid logger = logging.getLogger(__name__) @@ -97,6 +98,18 @@ class Actor(models.Model): 'https://example.com/actor/inbox' + The `Actor` object will provide required and some optional properties: + + .. testcode:: + actor.follows.all() + actor.followed_by.all() + + Will return the `actors` that are `followed` by this `actor` and the + `actors` that are `following` this `actor`: + + .. testoutput:: + , ]> + ]> """ profile = models.ForeignKey( @@ -122,12 +135,13 @@ class Actor(models.Model): "self", related_name="followed_by", symmetrical=False, - blank=True, # , through="Follow" + blank=True, + through="Follow", ) - flw = models.ManyToManyField( # this is to prep/test migration of the above. - "self", related_name="flwng", symmetrical=False, blank=True, through="Follow" - ) + # flw = models.ManyToManyField( # this is to prep/test migration of the above. + # "self", related_name="flwng", symmetrical=False, blank=True, through="Fllwng" + # ) class Meta: verbose_name = _("Actor (Activity Streams 2.0)") @@ -156,6 +170,7 @@ def actorID(self): """ # return self.ap_id + logger.error("The actorID property is deprecated. Use id instead.") view = reverse("actor-view", args=[str(self.profile.user)]) return f"{view}" @@ -292,20 +307,38 @@ def liked(self): ) -""" + class Follow(models.Model): - actor = models.ForeignKey(Actor, on_delete=models.CASCADE) - object = models.ForeignKey(Actor, on_delete=models.CASCADE) - id = models.CharField(max_length=255, primary_key=True, unique=True, blank=False, validators=[validate_iri]) + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + actor = models.ForeignKey(Actor, on_delete=models.CASCADE, related_name="actor") + object = models.ForeignKey(Actor, on_delete=models.CASCADE, related_name="object") accepted = models.URLField(blank=True, null=True, validators=[validate_iri]) -""" + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + + def getID(self): + from django.contrib.sites.models import Site + base = f"https://{Site.objects.get_current().domain}" + return f"{base}/{self.id}" + def __str__(self): + return f"{self.actor} follows {self.object}" + + +""" class Fllwng(models.Model): + # id = models.CharField(max_length=255, primary_key=True, unique=True, blank=False, validators=[validate_iri]) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - + actor = models.ForeignKey(Actor, on_delete=models.CASCADE, related_name="actor") + object = models.ForeignKey(Actor, on_delete=models.CASCADE, related_name="object") + id = models.CharField( + max_length=255, + primary_key=True, + unique=True, + blank=False, validators=[validate_iri], - actor = models.ForeignKey(Actor, on_delete=models.CASCADE) - object = models.ForeignKey(Actor, on_delete=models.CASCADE) - id = models.CharField(max_length=255, primary_key=True, unique=True, blank=False, validators=[validate_iri]) + ) accepted = models.URLField(blank=True, null=True, validators=[validate_iri]) +""" diff --git a/webapp/signals.py b/webapp/signals.py index cdc6fba..3c8371d 100644 --- a/webapp/signals.py +++ b/webapp/signals.py @@ -159,4 +159,4 @@ def createUserProfile(sender, instance, created, **kwargs): Actor.objects.create( profile=profile, type="Person", id=f"{base}/@{instance.username}" ) - logger.error("Created profile for %s", instance.username) + logger.debug("Created profile for %s", instance.username) diff --git a/webapp/tasks/activitypub.py b/webapp/tasks/activitypub.py index 98f4f3d..ff1dc58 100644 --- a/webapp/tasks/activitypub.py +++ b/webapp/tasks/activitypub.py @@ -140,19 +140,25 @@ def requestFollow(localID: str, remoteID: str) -> bool: args: id: str: The id of the remote actor """ - from webapp.models import Actor as ActorModel + from webapp.models import Actor from webapp.signature import signedRequest from webapp.signals import action - localActor = ActorModel.objects.get(id=localID) + localActor = Actor.objects.get(id=localID) remoteActor = getRemoteActor(remoteID) - remoteActorObject = ActorModel.objects.get(id=remoteID) + remoteActorObject = Actor.objects.get(id=remoteActor.get('id')) activity_id = action.send( sender=localActor, verb="Follow", target=remoteActorObject )[0][ 1 - ].get_activity_id() # noqa: E501, BLK100 + ].id # noqa: E501, BLK100 + + print("Actor: ", localActor) + print("Type: ", type(localActor)) + print("Actor Following: ", localActor.follows) + print("Type: ", type(localActor.follows)) + message = json.dumps( { @@ -163,9 +169,10 @@ def requestFollow(localID: str, remoteID: str) -> bool: "object": remoteID, } ) + localActor.follows.add(remoteActorObject) # remember we follow this actor signed = signedRequest( # noqa: F841,E501 - "POST", remoteActor.inbox, message, f"{localActor.id}#main-key" + "POST", remoteActor.get('inbox'), message, f"{localActor.id}#main-key" ) # noqa: F841,E501 return True diff --git a/webapp/templates/account/profile_detail.html b/webapp/templates/account/profile_detail.html index e03dc48..3e9c71e 100644 --- a/webapp/templates/account/profile_detail.html +++ b/webapp/templates/account/profile_detail.html @@ -5,13 +5,23 @@ {% block content %}
-

{{ object }}

+

{{ actor }}

- Following: {{ object.actor.following.count }} + Following: {{ follows.count }} +
    + {% for following in follows %} +
  • {{ following }}
  • + {% endfor %} +
- Followers: {{ object.actor.followers.count }} + Followers: {{ followers.count }} +
    + {% for following in followers %} +
  • {{ following }}
  • + {% endfor %} +
diff --git a/webapp/templates/activitypub/follow_create.html b/webapp/templates/activitypub/follow_create.html new file mode 100644 index 0000000..efab7d3 --- /dev/null +++ b/webapp/templates/activitypub/follow_create.html @@ -0,0 +1,5 @@ +
+ {% csrf_token %} + {{ form }} + +
diff --git a/webapp/templates/activitypub/follow_delete.html b/webapp/templates/activitypub/follow_delete.html new file mode 100644 index 0000000..3382466 --- /dev/null +++ b/webapp/templates/activitypub/follow_delete.html @@ -0,0 +1,6 @@ +
+ {% csrf_token %} +

Are you sure you want to delete "{{ object }}"?

+ {{ form }} + +
diff --git a/webapp/templates/activitypub/follow_detail.html b/webapp/templates/activitypub/follow_detail.html new file mode 100644 index 0000000..2262996 --- /dev/null +++ b/webapp/templates/activitypub/follow_detail.html @@ -0,0 +1,2 @@ +

[delete] {{ object }}

+

list

diff --git a/webapp/templates/activitypub/follow_list.html b/webapp/templates/activitypub/follow_list.html new file mode 100644 index 0000000..10e66ff --- /dev/null +++ b/webapp/templates/activitypub/follow_list.html @@ -0,0 +1,8 @@ +

Articles

+
    +{% for like in likes %} +
  • {{ like.actor }} - {{ like.object }} - {{ like.created_at}} - [delete]
  • +{% empty %} +
  • No likes yet.
  • +{% endfor %} +
diff --git a/webapp/templates/activitypub/like_delete.html b/webapp/templates/activitypub/like_delete.html index 3382466..f5bbe97 100644 --- a/webapp/templates/activitypub/like_delete.html +++ b/webapp/templates/activitypub/like_delete.html @@ -1,4 +1,4 @@ -
+ {% csrf_token %}

Are you sure you want to delete "{{ object }}"?

{{ form }} diff --git a/webapp/templates/activitypub/like_detail.html b/webapp/templates/activitypub/like_detail.html index 2262996..9fea26c 100644 --- a/webapp/templates/activitypub/like_detail.html +++ b/webapp/templates/activitypub/like_detail.html @@ -1,2 +1,2 @@

[delete] {{ object }}

-

list

+

list

diff --git a/webapp/templates/activitypub/like_list.html b/webapp/templates/activitypub/like_list.html index 10e66ff..415c076 100644 --- a/webapp/templates/activitypub/like_list.html +++ b/webapp/templates/activitypub/like_list.html @@ -1,7 +1,7 @@ -

Articles

+

{{ actor }} Likes

    {% for like in likes %} -
  • {{ like.actor }} - {{ like.object }} - {{ like.created_at}} - [delete]
  • +
  • {{ like.object }} - {{ like.created_at}} - [delete]
  • {% empty %}
  • No likes yet.
  • {% endfor %} diff --git a/webapp/tests/web/__init__.py b/webapp/tests/web/__init__.py index 28f5ce9..02050a5 100644 --- a/webapp/tests/web/__init__.py +++ b/webapp/tests/web/__init__.py @@ -1,5 +1,7 @@ from django.test import TestCase from django.contrib.auth import get_user_model +from django.test import Client +from django.urls import reverse from webapp.models import Like @@ -8,33 +10,42 @@ def setUp(self): User = get_user_model() self.client = Client() self.username = "andreas" - user = User.objects.create_user(username=self.username, password="password") + self.password = "password" + self.user = User.objects.create_user(username=self.username, password=self.password) # noqa def test_like_create_anonymous(self): - self.client.get(reverse("like-create") + response = self.client.get(reverse("like-create")) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, "/accounts/login/?next=/like/") self.assertEqual(Like.objects.count(), 0) - + def test_like_create_authenticated(self): self.client.login(username=self.username, password="password") response = self.client.get(reverse("like-create")) self.assertEqual(response.status_code, 200) - self.client.post(reverse("like-create"), data={"object": "http://example.com"}) + actor = self.user.profile_set.get().actor + response = self.client.post(reverse("like-create"), data={"actor": actor, "object": "http://example.com"}) + self.assertEqual(response.status_code, 302) self.assertEqual(Like.objects.count(), 1) + like = Like.objects.get() + self.assertRedirects(response, reverse("like-detail", kwargs={"pk": like.id})) def test_like_list(self): - response = self.client.get(reverse("like-list")) + self.client.login(username=self.username, password=self.password) + slug = self.user.profile_set.get().slug + response = self.client.get(reverse("like-list", kwargs={"slug": slug})) self.assertEqual(response.status_code, 200) def test_like_detail(self): - like = Like.objects.create(object="http://example.com") + self.client.login(username=self.username, password=self.password) + like = Like.objects.create(actor=self.user.profile_set.get().actor, object="http://example.com") response = self.client.get(reverse("like-detail", kwargs={"pk": like.pk})) self.assertEqual(response.status_code, 200) def test_like_delete(self): - like = Like.objects.create(object="http://example.com") - response = self.client.get(reverse("like-delete", kwargs={"pk": like.pk})) + self.client.login(username=self.username, password=self.password) + like = Like.objects.create(actor=self.user.profile_set.get().actor, object="http://example.com") + response = self.client.get(reverse("like-delete", kwargs={"pk": like.id})) self.assertEqual(response.status_code, 200) self.client.post(reverse("like-delete", kwargs={"pk": like.pk})) - self.assertEqual(Like.objects.count(), 0) \ No newline at end of file + self.assertEqual(Like.objects.count(), 0) diff --git a/webapp/urls/web.py b/webapp/urls/web.py index 5948721..38ef76c 100644 --- a/webapp/urls/web.py +++ b/webapp/urls/web.py @@ -5,17 +5,36 @@ LikeDetailView, LikeDeleteView, ) +from webapp.views.web import ( + FollowingListView, + FollowingCreateView, + FollowingDetailView, + FollowingDeleteView, +) from webapp.views.signature import SignatureView urlpatterns = [ path(r"like/", LikeCreateView.as_view(), name="like-create"), - path(r"like/list", LikeListView.as_view(), name="like-list"), + path(r"like//list", LikeListView.as_view(), name="like-list"), path(r"like/", LikeDetailView.as_view(), name="like-detail"), path( r"like//delete", LikeDeleteView.as_view(), name="like-delete" ), # noqa: E501 ] +urlpatterns += [ + path(r"follow/", FollowingCreateView.as_view(), name="follow-create"), + path(r"follow/list", FollowingListView.as_view(), name="follow-list"), + path( + r"follow/", FollowingDetailView.as_view(), name="follow-detail" + ), + path( + r"follow//delete", + FollowingDeleteView.as_view(), + name="follow-delete", + ), +] + urlpatterns += [ # Debug view to show the signature of a given object path(r"signature", SignatureView.as_view(), name="signature"), diff --git a/webapp/views/activitypub/actor.py b/webapp/views/activitypub/actor.py index fa5980d..844110e 100644 --- a/webapp/views/activitypub/actor.py +++ b/webapp/views/activitypub/actor.py @@ -73,7 +73,7 @@ def to_jsonld(self, *args, **kwargs): "owner": actorid, "publicKeyPem": publicKey, }, - "image": { + "image": { # background image "type": "Image", "mediaType": "image/jpeg", "url": actor.profile.imgurl, @@ -81,10 +81,11 @@ def to_jsonld(self, *args, **kwargs): "icon": { "type": "Image", "mediaType": "image/png", - "url": actor.profile.icon, + "url": actor.profile.imgurl, }, # noqa: E501 } - return jsonld + from webapp.activity import canonicalize + return canonicalize(jsonld) def get(self, request, *args, **kwargs): # pylint: disable=W0613 if ( diff --git a/webapp/views/activitypub/followers.py b/webapp/views/activitypub/followers.py index a7a9009..7f7aa95 100644 --- a/webapp/views/activitypub/followers.py +++ b/webapp/views/activitypub/followers.py @@ -1,12 +1,12 @@ from django.shortcuts import get_object_or_404 -from django.views.generic import ListView +from django.views.generic import DetailView from django.http import JsonResponse from webapp.models import Profile from django.contrib.sites.models import Site -class FollowersView(ListView): +class FollowersView(DetailView): """ Provide a list of followers for a given profile. @@ -22,16 +22,19 @@ class FollowersView(ListView): template_name = "activitypub/followers.html" model = Profile + def followers(self): + return self.get_object().actor.followed_by.all() + def to_jsonld(self, *args, **kwargs): actor = self.get_object().actor - followers = actor.followers.all() base = f"https://{Site.objects.get_current().domain}" + followers = self.followers().values_list("actor", flat=True).order_by("-created") wrap = { "@context": "https://www.w3.org/ns/activitystreams", "id": f"https://{base}{actor.followers_url}", "type": "OrderedCollection", - "totalItems": len(followers), - "items": [f"{item.get_actor_url}" for item in followers], + "totalItems": len(self.followers), + "items": [f"{item.id}" for item in followers], } return wrap diff --git a/webapp/views/activitypub/following.py b/webapp/views/activitypub/following.py index 8f24b09..fefc9d2 100644 --- a/webapp/views/activitypub/following.py +++ b/webapp/views/activitypub/following.py @@ -1,5 +1,4 @@ -# from django.views.generic import ListView -from django.views.generic.list import ListView +from django.views.generic import DetailView from django.http import HttpResponse from django.http import JsonResponse @@ -39,24 +38,21 @@ -class FollowingView(ListView): +class FollowingView(DetailView): template_name = "activitypub/following.html" paginate_by = 10 model = Profile - def get_queryset(self): - return Profile.objects.filter(slug=self.kwargs["slug"]) # noqa: E501 - def jsonld(self, request, *args, **kwargs): page = request.GET.get("page", None) - actor = self.get_queryset().get().actor + actor = self.get_object().actor totalItems = actor.follows.count() # noqa: F841 orderedCollection.update({"totalItems": totalItems}) if not page: - orderedCollection.update({"first": f"{actor.id}/following?page=1"}) + orderedCollection.update({"first": f"{actor.following}?page=1"}) if totalItems > 10: - orderedCollection.update({"next": f"{actor.id}/following?page=2"}) + orderedCollection.update({"next": f"{actor.following}?page=2"}) return JsonResponse( orderedCollection, diff --git a/webapp/views/activitypub/inbox.py b/webapp/views/activitypub/inbox.py index 58790b1..7b6a559 100644 --- a/webapp/views/activitypub/inbox.py +++ b/webapp/views/activitypub/inbox.py @@ -131,11 +131,11 @@ def post(self, request, *args, **kwargs): case "undo": result = undo(target=target.actor, activity=activity) case "create": - result = create(target=target.actor, message=activity) + result = create(target=target.actor, activity=activity) case "delete": - result = delete(target=target.actor, message=activity) + result = delete(target=target.actor, activity=activity) case "accept": - result = accept(target=target.actor, message=activity) + result = accept(target=target.actor, activity=activity) case _: error = f"InboxView: Unsupported activity: {activity.type}" logger.error(f"Actvity error: {error}") diff --git a/webapp/views/activitypub/liked.py b/webapp/views/activitypub/liked.py index c6c5ec5..e500708 100644 --- a/webapp/views/activitypub/liked.py +++ b/webapp/views/activitypub/liked.py @@ -1,3 +1,4 @@ +import json from django.views.generic.detail import DetailView from django.http import JsonResponse from webapp.models import Profile @@ -22,11 +23,16 @@ class LikedView(DetailView): :class:`django.http.HttpResponse` """ - models = Profile + model = Profile def liked(self): - return self.get_object().actor.liked.all() + result = self.get_object().actor.like_set.all() + print("Likes: ", result) + return result def get(self, request, *args, **kwargs): result = {"type": "Collection", "totalItems": 0, "items": []} - return JsonResponse(result, contenttype="application/activity+json") + likes = self.liked().values_list("object", flat=True).order_by("-created_at") + result.update({"totalItems": len(likes)}) + result.update({"items": json.dumps(list(likes))}) + return JsonResponse(result, content_type="application/activity+json") diff --git a/webapp/views/activitypub/webfinger.py b/webapp/views/activitypub/webfinger.py index 12414a8..431b88a 100644 --- a/webapp/views/activitypub/webfinger.py +++ b/webapp/views/activitypub/webfinger.py @@ -70,9 +70,9 @@ def get(self, request, *args, **kwargs): # pylint: disable=W0613 # The user's profile URL # subject is the user's profile identified # "subject": f"acct:{profile.user.username}@{request.get_host()}", - "subject": f"acct:{profile.actor.actorID}", + "subject": f"acct:{profile.actor.id}", "aliases": [ - f"https://{base}{profile.actor.actorID}", + f"{profile.actor.id}", f"https://{request.get_host()}{profile.get_absolute_url}", ], "links": [ @@ -84,7 +84,7 @@ def get(self, request, *args, **kwargs): # pylint: disable=W0613 { "rel": "self", "type": "application/activity+json", - "href": f"https://{base}{profile.actor.actorID}", # noqa: E501 + "href": f"{profile.actor.id}", # noqa: E501 }, { "rel": "http://ostatus.org/schema/1.0/subscribe", diff --git a/webapp/views/profile.py b/webapp/views/profile.py index 4e13742..79dedc3 100644 --- a/webapp/views/profile.py +++ b/webapp/views/profile.py @@ -175,6 +175,18 @@ def test_func(self): return False + def get_context_data(self, **kwargs): + """ + Get context for this request. + + Overriding super() implementation. + """ + context = super().get_context_data(**kwargs) + actor = self.get_object().actor + context["follows"] = actor.follows.all() + context["followers"] = actor.followed_by.all() + return context + class SearchView(ListView): """ Search: diff --git a/webapp/views/web/following.py b/webapp/views/web/following.py index f3af838..4ba4dfc 100644 --- a/webapp/views/web/following.py +++ b/webapp/views/web/following.py @@ -2,26 +2,30 @@ from django.views.generic import DetailView, ListView from django.views.generic.edit import CreateView, DeleteView # from webapp.models import Profile as Fllwng -from webapp.models import Actor +from webapp.models.activitypub.actor import Follow class FollowForm(forms.ModelForm): class Meta: - model = Actor + model = Follow fields = "__all__" widgets = { "object": forms.URLInput(attrs={"class": "form-control"}), } class FollowingCreateView(CreateView): - model = Actor + model = Follow form_class = FollowForm + template_name = "activitypub/follow_create.html" class FollowingDetailView(DetailView): - model = Actor + model = Follow + template_name = "activitypub/follow_detail.html" class FollowingListView(ListView): - model = Actor + model = Follow + template_name = "activitypub/follow_list.html" class FollowingDeleteView(DeleteView): - model = Actor + model = Follow success_url = "/following/" + template_name = "activitypub/follow_delete.html" diff --git a/webapp/views/web/likes.py b/webapp/views/web/likes.py index f5e0603..bacb935 100644 --- a/webapp/views/web/likes.py +++ b/webapp/views/web/likes.py @@ -8,6 +8,7 @@ import logging from webapp.tasks.activitypub import sendLike from webapp.signals import action +from webapp.models import Profile logger = logging.getLogger(__name__) @@ -64,6 +65,11 @@ class LikeDetailView(LoginRequiredMixin, DetailView): model = Like template_name = "activitypub/like_detail.html" + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["slug"] = self.request.user.profile_set.get().slug + return context + class LikeDeleteView(LoginRequiredMixin, DeleteView): model = Like @@ -83,7 +89,6 @@ def form_valid(self, form): .. seealso:: `ActivityPub Undo `_ # noqa: E501 """ - self.object = form.save() action.send( sender=self.request.user.profile_set.get().actor, verb="undo", @@ -99,8 +104,13 @@ class LikeListView(LoginRequiredMixin, ListView): paginate_by = 10 def get_queryset(self): - from webapp.models import Profile slug = self.kwargs.get("slug") actor = Profile.objects.get(slug=slug).actor return Like.objects.all().order_by("-created_at").filter(actor=actor) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + slug = self.kwargs.get("slug") + context["actor"] = Profile.objects.get(slug=slug).actor + return context