Ver código fonte

Big reorg to package this better

Colin Powell 2 anos atrás
pai
commit
d69b6a43f1
65 arquivos alterados com 957 adições e 1 exclusões
  1. 0 0
      emus/apps/activitypub/__init__.py
  2. 17 0
      emus/apps/activitypub/exceptions.py
  3. 656 0
      emus/apps/activitypub/models.py
  4. 128 0
      emus/apps/activitypub/signatures.py
  5. 0 0
      emus/apps/activitypub/utils.py
  6. 125 0
      emus/apps/activitypub/views.py
  7. 0 0
      emus/apps/games/__init__.py
  8. 0 0
      emus/apps/games/admin.py
  9. 0 0
      emus/apps/games/api/__init__.py
  10. 0 0
      emus/apps/games/api/serializers.py
  11. 0 0
      emus/apps/games/api/views.py
  12. 0 0
      emus/apps/games/apps.py
  13. 0 0
      emus/apps/games/constants.py
  14. 0 0
      emus/apps/games/context_processors.py
  15. 1 0
      emus/apps/games/management/__init__.py
  16. 1 0
      emus/apps/games/management/commands/__init__.py
  17. 0 0
      emus/apps/games/management/commands/export_collections.py
  18. 0 0
      emus/apps/games/management/commands/export_gamelist_xml_file.py
  19. 0 0
      emus/apps/games/management/commands/import_gamelist_xml_file.py
  20. 0 0
      emus/apps/games/management/commands/scrape_roms.py
  21. 0 0
      emus/apps/games/management/commands/update_roms.py
  22. 0 0
      emus/apps/games/migrations/0001_initial.py
  23. 0 0
      emus/apps/games/migrations/0002_alter_game_players.py
  24. 0 0
      emus/apps/games/migrations/0003_alter_game_developer_alter_game_publisher.py
  25. 0 0
      emus/apps/games/migrations/0004_game_english_patched_game_english_patched_version_and_more.py
  26. 0 0
      emus/apps/games/migrations/0005_game_region_game_undub.py
  27. 0 0
      emus/apps/games/migrations/0006_alter_game_region.py
  28. 0 0
      emus/apps/games/migrations/0007_alter_game_marquee_alter_game_region_and_more.py
  29. 0 0
      emus/apps/games/migrations/0008_game_featured.py
  30. 0 0
      emus/apps/games/migrations/0009_developer_created_developer_modified_game_created_and_more.py
  31. 0 0
      emus/apps/games/migrations/0010_alter_game_release_date.py
  32. 0 0
      emus/apps/games/migrations/0011_alter_game_region.py
  33. 0 0
      emus/apps/games/migrations/0012_alter_game_marquee_alter_game_rom_file_and_more.py
  34. 0 0
      emus/apps/games/migrations/0013_alter_game_screenshot.py
  35. 0 0
      emus/apps/games/migrations/0014_alter_developer_options_alter_game_options_and_more.py
  36. 0 0
      emus/apps/games/migrations/0015_gamecollection.py
  37. 0 0
      emus/apps/games/migrations/0016_game_source_game_tags_and_more.py
  38. 0 0
      emus/apps/games/migrations/0017_game_featured_on.py
  39. 0 0
      emus/apps/games/migrations/0018_remove_game_featured_alter_game_tags.py
  40. 0 0
      emus/apps/games/migrations/0019_game_finished_on_game_started_on.py
  41. 0 0
      emus/apps/games/migrations/0020_developer_uuid_game_uuid_gamecollection_uuid_and_more.py
  42. 0 0
      emus/apps/games/migrations/0021_remove_game_finished_on_remove_game_started_on.py
  43. 0 0
      emus/apps/games/migrations/__init__.py
  44. 0 0
      emus/apps/games/models.py
  45. 0 0
      emus/apps/games/tasks.py
  46. 0 0
      emus/apps/games/tests.py
  47. 0 0
      emus/apps/games/urls.py
  48. 0 0
      emus/apps/games/utils.py
  49. 0 0
      emus/apps/games/views.py
  50. 0 0
      emus/apps/profiles/__init__.py
  51. 0 0
      emus/apps/profiles/admin.py
  52. 0 0
      emus/apps/profiles/apps.py
  53. 0 0
      emus/apps/profiles/migrations/0001_initial.py
  54. 0 0
      emus/apps/profiles/migrations/0002_usergameprogress_finished_ts_and_more.py
  55. 0 0
      emus/apps/profiles/migrations/0003_alter_usergameprogress_user.py
  56. 0 0
      emus/apps/profiles/migrations/0004_alter_userprofile_user_historicalusergameprogress.py
  57. 0 0
      emus/apps/profiles/migrations/0005_historicalusergameplaythrough_usergameplaythrough_and_more.py
  58. 0 0
      emus/apps/profiles/migrations/__init__.py
  59. 0 0
      emus/apps/profiles/models.py
  60. 12 0
      emus/apps/scraper/main.py
  61. 0 0
      emus/apps/search/__init__.py
  62. 0 0
      emus/apps/search/urls.py
  63. 0 0
      emus/apps/search/views.py
  64. 16 1
      poetry.lock
  65. 1 0
      pyproject.toml

+ 0 - 0
games/management/__init__.py → emus/apps/activitypub/__init__.py


+ 17 - 0
emus/apps/activitypub/exceptions.py

@@ -0,0 +1,17 @@
+class BlockedUserAgentException(Exception):
+    pass
+
+
+class DeletedActorException(Exception):
+    pass
+
+
+class BannedActorException(Exception):
+    pass
+
+
+class BlockedActorException(Exception):
+    pass
+
+class BannedOrDeletedActorException(Exception):
+    pass

+ 656 - 0
emus/apps/activitypub/models.py

@@ -0,0 +1,656 @@
+from base64 import b64encode
+from collections import namedtuple
+from functools import reduce
+import json
+import operator
+import logging
+from uuid import uuid4
+import requests
+from requests.exceptions import RequestException
+
+from Crypto.PublicKey import RSA
+from Crypto.Signature import pkcs1_15
+from Crypto.Hash import SHA256
+from django.apps import apps
+from django.core.paginator import Paginator
+from django.db.models import Q
+from django.utils.http import http_date
+
+from urllib.parse import urlparse
+
+from django.apps import apps
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+from bookwyrm import activitypub
+from bookwyrm.settings import USER_AGENT, PAGE_LENGTH
+from bookwyrm.signatures import make_signature, make_digest
+from bookwyrm.tasks import app, MEDIUM
+from bookwyrm.models.fields import ImageField, ManyToManyField
+
+logger = logging.getLogger(__name__)
+
+PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
+
+# pylint: disable=invalid-name
+def set_activity_from_property_field(activity, obj, field):
+    """assign a model property value to the activity json"""
+    activity[field[1]] = getattr(obj, field[0])
+
+
+class ActivityPubMixin:
+    """A model mixin which allows serialization for the ActivityPub standard
+
+    Largely derived from code from Bookwyrm"""
+
+    activity_serializer = lambda: {}
+    reverse_unfurl = False
+
+    def __init__(self, *args, **kwargs):
+        """collect some info on model fields for later use"""
+        self.image_fields = []
+        self.many_to_many_fields = []
+        self.simple_fields = []  # "simple"
+        # sort model fields by type
+        for field in self._meta.get_fields():
+            if not hasattr(field, "field_to_activity"):
+                continue
+
+            if isinstance(field, ImageField):
+                self.image_fields.append(field)
+            elif isinstance(field, ManyToManyField):
+                self.many_to_many_fields.append(field)
+            else:
+                self.simple_fields.append(field)
+
+        # a list of allll the serializable fields
+        self.activity_fields = (
+            self.image_fields + self.many_to_many_fields + self.simple_fields
+        )
+        if hasattr(self, "property_fields"):
+            self.activity_fields += [
+                # pylint: disable=cell-var-from-loop
+                PropertyField(
+                    lambda a, o: set_activity_from_property_field(a, o, f)
+                )
+                for f in self.property_fields
+            ]
+
+        # these are separate to avoid infinite recursion issues
+        self.deserialize_reverse_fields = (
+            self.deserialize_reverse_fields
+            if hasattr(self, "deserialize_reverse_fields")
+            else []
+        )
+        self.serialize_reverse_fields = (
+            self.serialize_reverse_fields
+            if hasattr(self, "serialize_reverse_fields")
+            else []
+        )
+
+        super().__init__(*args, **kwargs)
+
+    @classmethod
+    def find_existing_by_remote_id(cls, remote_id):
+        """look up a remote id in the db"""
+        return cls.find_existing({"id": remote_id})
+
+    @classmethod
+    def find_existing(cls, remote_id=None, data):
+        """Looks for existing activities"""
+
+        """compare data to fields that can be used for deduplation.
+        This always includes remote_id, but can also be unique identifiers
+        like an isbn for an edition"""
+        filters = []
+        # grabs all the data from the model to create django queryset filters
+        for field in cls._meta.get_fields():
+            if (
+                not hasattr(field, "deduplication_field")
+                or not field.deduplication_field
+            ):
+                continue
+
+            value = data.get(field.get_activitypub_field())
+            if not value:
+                continue
+            filters.append({field.name: value})
+
+        if hasattr(cls, "origin_id") and "id" in data:
+            # kinda janky, but this handles special case for books
+            filters.append({"origin_id": data["id"]})
+
+        if not filters:
+            # if there are no deduplication fields, it will match the first
+            # item no matter what. this shouldn't happen but just in case.
+            return None
+
+        objects = cls.objects
+        if hasattr(objects, "select_subclasses"):
+            objects = objects.select_subclasses()
+
+        # an OR operation on all the match fields, sorry for the dense syntax
+        match = objects.filter(reduce(operator.or_, (Q(**f) for f in filters)))
+        # there OUGHT to be only one match
+        return match.first()
+
+    def broadcast(self, activity, sender, software=None, queue=MEDIUM):
+        """Broadast an activity via an asyncronous task"""
+        broadcast_task.apply_async(
+            args=(
+                sender.id,
+                json.dumps(activity, cls=activitypub.ActivityEncoder),
+                self.get_recipients(software=software),
+            ),
+            queue=queue,
+        )
+
+    def get_recipients(self, software=None):
+        """figure out which inbox urls to post to"""
+        # first we have to figure out who should receive this activity
+        privacy = self.privacy if hasattr(self, "privacy") else "public"
+        # is this activity owned by a user (statuses, lists, shelves), or is it
+        # general to the instance (like books)
+        user = self.user if hasattr(self, "user") else None
+        user_model = apps.get_model("bookwyrm.User", require_ready=True)
+        if not user and isinstance(self, user_model):
+            # or maybe the thing itself is a user
+            user = self
+        # find anyone who's tagged in a status, for example
+        mentions = self.recipients if hasattr(self, "recipients") else []
+
+        # we always send activities to explicitly mentioned users' inboxes
+        recipients = [u.inbox for u in mentions or [] if not u.local]
+
+        # unless it's a dm, all the followers should receive the activity
+        if privacy != "direct":
+            # we will send this out to a subset of all remote users
+            queryset = (
+                user_model.viewer_aware_objects(user)
+                .filter(
+                    local=False,
+                )
+                .distinct()
+            )
+            # filter users first by whether they're using the desired software
+            # this lets us send book updates only to other bw servers
+            if software:
+                queryset = queryset.filter(
+                    bookwyrm_user=(software == "bookwyrm")
+                )
+            # if there's a user, we only want to send to the user's followers
+            if user:
+                queryset = queryset.filter(following=user)
+
+            # ideally, we will send to shared inboxes for efficiency
+            shared_inboxes = (
+                queryset.filter(shared_inbox__isnull=False)
+                .values_list("shared_inbox", flat=True)
+                .distinct()
+            )
+            # but not everyone has a shared inbox
+            inboxes = queryset.filter(shared_inbox__isnull=True).values_list(
+                "inbox", flat=True
+            )
+            recipients += list(shared_inboxes) + list(inboxes)
+        return list(set(recipients))
+
+    def to_activity_dataclass(self):
+        """convert from a model to an activity"""
+        activity = generate_activity(self)
+        return self.activity_serializer(**activity)
+
+    def to_activity(self, **kwargs):  # pylint: disable=unused-argument
+        """convert from a model to a json activity"""
+        return self.to_activity_dataclass().serialize()
+
+
+class ObjectMixin(ActivitypubMixin):
+    """add this mixin for object models that are AP serializable"""
+
+    def save(
+        self, *args, created=None, software=None, priority=MEDIUM, **kwargs
+    ):
+        """broadcast created/updated/deleted objects as appropriate"""
+        broadcast = kwargs.get("broadcast", True)
+        # this bonus kwarg would cause an error in the base save method
+        if "broadcast" in kwargs:
+            del kwargs["broadcast"]
+
+        created = created or not bool(self.id)
+        # first off, we want to save normally no matter what
+        super().save(*args, **kwargs)
+        if not broadcast or (
+            hasattr(self, "status_type") and self.status_type == "Announce"
+        ):
+            return
+
+        # this will work for objects owned by a user (lists, shelves)
+        user = self.user if hasattr(self, "user") else None
+
+        if created:
+            # broadcast Create activities for objects owned by a local user
+            if not user or not user.local:
+                return
+
+            try:
+                # do we have a "pure" activitypub version of this for mastodon?
+                if software != "bookwyrm" and hasattr(self, "pure_content"):
+                    pure_activity = self.to_create_activity(user, pure=True)
+                    self.broadcast(
+                        pure_activity, user, software="other", queue=priority
+                    )
+                    # set bookwyrm so that that type is also sent
+                    software = "bookwyrm"
+                # sends to BW only if we just did a pure version for masto
+                activity = self.to_create_activity(user)
+                self.broadcast(
+                    activity, user, software=software, queue=priority
+                )
+            except AttributeError:
+                # janky as heck, this catches the mutliple inheritence chain
+                # for boosts and ignores this auxilliary broadcast
+                return
+            return
+
+        # --- updating an existing object
+        if not user:
+            # users don't have associated users, they ARE users
+            user_model = apps.get_model("bookwyrm.User", require_ready=True)
+            if isinstance(self, user_model):
+                user = self
+            # book data tracks last editor
+            user = user or getattr(self, "last_edited_by", None)
+        # again, if we don't know the user or they're remote, don't bother
+        if not user or not user.local:
+            return
+
+        # is this a deletion?
+        if hasattr(self, "deleted") and self.deleted:
+            activity = self.to_delete_activity(user)
+        else:
+            activity = self.to_update_activity(user)
+        self.broadcast(activity, user, queue=priority)
+
+    def to_create_activity(self, user, **kwargs):
+        """returns the object wrapped in a Create activity"""
+        activity_object = self.to_activity_dataclass(**kwargs)
+
+        signature = None
+        create_id = self.remote_id + "/activity"
+        if hasattr(activity_object, "content") and activity_object.content:
+            signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
+            content = activity_object.content
+            signed_message = signer.sign(SHA256.new(content.encode("utf8")))
+
+            signature = activitypub.Signature(
+                creator=f"{user.remote_id}#main-key",
+                created=activity_object.published,
+                signatureValue=b64encode(signed_message).decode("utf8"),
+            )
+
+        return activitypub.Create(
+            id=create_id,
+            actor=user.remote_id,
+            to=activity_object.to,
+            cc=activity_object.cc,
+            object=activity_object,
+            signature=signature,
+        ).serialize()
+
+    def to_delete_activity(self, user):
+        """notice of deletion"""
+        return activitypub.Delete(
+            id=self.remote_id + "/activity",
+            actor=user.remote_id,
+            to=[f"{user.remote_id}/followers"],
+            cc=["https://www.w3.org/ns/activitystreams#Public"],
+            object=self,
+        ).serialize()
+
+    def to_update_activity(self, user):
+        """wrapper for Updates to an activity"""
+        uuid = uuid4()
+        return activitypub.Update(
+            id=f"{self.remote_id}#update/{uuid}",
+            actor=user.remote_id,
+            to=["https://www.w3.org/ns/activitystreams#Public"],
+            object=self,
+        ).serialize()
+
+
+class OrderedCollectionPageMixin(ObjectMixin):
+    """just the paginator utilities, so you don't HAVE to
+    override ActivitypubMixin's to_activity (ie, for outbox)"""
+
+    @property
+    def collection_remote_id(self):
+        """this can be overriden if there's a special remote id, ie outbox"""
+        return self.remote_id
+
+    def to_ordered_collection(
+        self,
+        queryset,
+        remote_id=None,
+        page=False,
+        collection_only=False,
+        **kwargs,
+    ):
+        """an ordered collection of whatevers"""
+        if not queryset.ordered:
+            raise RuntimeError("queryset must be ordered")
+
+        remote_id = remote_id or self.remote_id
+        if page:
+            if isinstance(page, list) and len(page) > 0:
+                page = page[0]
+            return to_ordered_collection_page(
+                queryset, remote_id, page=page, **kwargs
+            )
+
+        if collection_only or not hasattr(self, "activity_serializer"):
+            serializer = activitypub.OrderedCollection
+            activity = {}
+        else:
+            serializer = self.activity_serializer
+            # a dict from the model fields
+            activity = generate_activity(self)
+
+        if remote_id:
+            activity["id"] = remote_id
+
+        paginated = Paginator(queryset, PAGE_LENGTH)
+        # add computed fields specific to orderd collections
+        activity["totalItems"] = paginated.count
+        activity["first"] = f"{remote_id}?page=1"
+        activity["last"] = f"{remote_id}?page={paginated.num_pages}"
+
+        return serializer(**activity)
+
+
+class OrderedCollectionMixin(OrderedCollectionPageMixin):
+    """extends activitypub models to work as ordered collections"""
+
+    @property
+    def collection_queryset(self):
+        """usually an ordered collection model aggregates a different model"""
+        raise NotImplementedError("Model must define collection_queryset")
+
+    activity_serializer = activitypub.OrderedCollection
+
+    def to_activity_dataclass(self, **kwargs):
+        return self.to_ordered_collection(self.collection_queryset, **kwargs)
+
+    def to_activity(self, **kwargs):
+        """an ordered collection of the specified model queryset"""
+        return self.to_ordered_collection(
+            self.collection_queryset, **kwargs
+        ).serialize()
+
+    def delete(self, *args, broadcast=True, **kwargs):
+        """Delete the object"""
+        activity = self.to_delete_activity(self.user)
+        super().delete(*args, **kwargs)
+        if self.user.local and broadcast:
+            self.broadcast(activity, self.user)
+
+
+class CollectionItemMixin(ActivitypubMixin):
+    """for items that are part of an (Ordered)Collection"""
+
+    activity_serializer = activitypub.CollectionItem
+
+    def broadcast(self, activity, sender, software="bookwyrm", queue=MEDIUM):
+        """only send book collection updates to other bookwyrm instances"""
+        super().broadcast(activity, sender, software=software, queue=queue)
+
+    @property
+    def privacy(self):
+        """inherit the privacy of the list, or direct if pending"""
+        collection_field = getattr(self, self.collection_field)
+        if self.approved:
+            return collection_field.privacy
+        return "direct"
+
+    @property
+    def recipients(self):
+        """the owner of the list is a direct recipient"""
+        collection_field = getattr(self, self.collection_field)
+        if collection_field.user.local:
+            # don't broadcast to yourself
+            return []
+        return [collection_field.user]
+
+    def save(self, *args, broadcast=True, priority=MEDIUM, **kwargs):
+        """broadcast updated"""
+        # first off, we want to save normally no matter what
+        super().save(*args, **kwargs)
+
+        # list items can be updateda, normally you would only broadcast on created
+        if not broadcast or not self.user.local:
+            return
+
+        # adding an obj to the collection
+        activity = self.to_add_activity(self.user)
+        self.broadcast(activity, self.user, queue=priority)
+
+    def delete(self, *args, broadcast=True, **kwargs):
+        """broadcast a remove activity"""
+        activity = self.to_remove_activity(self.user)
+        super().delete(*args, **kwargs)
+        if self.user.local and broadcast:
+            self.broadcast(activity, self.user)
+
+    def to_add_activity(self, user):
+        """AP for shelving a book"""
+        collection_field = getattr(self, self.collection_field)
+        return activitypub.Add(
+            id=f"{collection_field.remote_id}#add",
+            actor=user.remote_id,
+            object=self.to_activity_dataclass(),
+            target=collection_field.remote_id,
+        ).serialize()
+
+    def to_remove_activity(self, user):
+        """AP for un-shelving a book"""
+        collection_field = getattr(self, self.collection_field)
+        return activitypub.Remove(
+            id=f"{collection_field.remote_id}#remove",
+            actor=user.remote_id,
+            object=self.to_activity_dataclass(),
+            target=collection_field.remote_id,
+        ).serialize()
+
+
+class ActivityMixin(ActivitypubMixin):
+    """add this mixin for models that are AP serializable"""
+
+    def save(self, *args, broadcast=True, priority=MEDIUM, **kwargs):
+        """broadcast activity"""
+        super().save(*args, **kwargs)
+        user = self.user if hasattr(self, "user") else self.user_subject
+        if broadcast and user.local:
+            self.broadcast(self.to_activity(), user, queue=priority)
+
+    def delete(self, *args, broadcast=True, **kwargs):
+        """nevermind, undo that activity"""
+        user = self.user if hasattr(self, "user") else self.user_subject
+        if broadcast and user.local:
+            self.broadcast(self.to_undo_activity(), user)
+        super().delete(*args, **kwargs)
+
+    def to_undo_activity(self):
+        """undo an action"""
+        user = self.user if hasattr(self, "user") else self.user_subject
+        return activitypub.Undo(
+            id=f"{self.remote_id}#undo",
+            actor=user.remote_id,
+            object=self,
+        ).serialize()
+
+
+def generate_activity(obj):
+    """go through the fields on an object"""
+    activity = {}
+    for field in obj.activity_fields:
+        field.set_activity_from_field(activity, obj)
+
+    if hasattr(obj, "serialize_reverse_fields"):
+        # for example, editions of a work
+        for (
+            model_field_name,
+            activity_field_name,
+            sort_field,
+        ) in obj.serialize_reverse_fields:
+            related_field = getattr(obj, model_field_name)
+            activity[activity_field_name] = unfurl_related_field(
+                related_field, sort_field=sort_field
+            )
+
+    if not activity.get("id"):
+        activity["id"] = obj.get_remote_id()
+    return activity
+
+
+def unfurl_related_field(related_field, sort_field=None):
+    """load reverse lookups (like public key owner or Status attachment"""
+    if sort_field and hasattr(related_field, "all"):
+        return [
+            unfurl_related_field(i)
+            for i in related_field.order_by(sort_field).all()
+        ]
+    if related_field.reverse_unfurl:
+        # if it's a one-to-one (key pair)
+        if hasattr(related_field, "field_to_activity"):
+            return related_field.field_to_activity()
+        # if it's one-to-many (attachments)
+        return related_field.to_activity()
+    return related_field.remote_id
+
+
+@app.task(queue=MEDIUM)
+def broadcast_task(sender_id, activity, recipients):
+    """the celery task for broadcast"""
+    user_model = apps.get_model("bookwyrm.User", require_ready=True)
+    sender = user_model.objects.get(id=sender_id)
+    for recipient in recipients:
+        try:
+            sign_and_send(sender, activity, recipient)
+        except RequestException:
+            pass
+
+
+def sign_and_send(sender, data, destination):
+    """crpyto whatever and http junk"""
+    now = http_date()
+
+    if not sender.key_pair.private_key:
+        # this shouldn't happen. it would be bad if it happened.
+        raise ValueError("No private key found for sender")
+
+    digest = make_digest(data)
+
+    response = requests.post(
+        destination,
+        data=data,
+        headers={
+            "Date": now,
+            "Digest": digest,
+            "Signature": make_signature(sender, destination, now, digest),
+            "Content-Type": "application/activity+json; charset=utf-8",
+            "User-Agent": USER_AGENT,
+        },
+    )
+    if not response.ok:
+        response.raise_for_status()
+    return response
+
+
+# pylint: disable=unused-argument
+def to_ordered_collection_page(
+    queryset, remote_id, id_only=False, page=1, pure=False, **kwargs
+):
+    """serialize and pagiante a queryset"""
+    paginated = Paginator(queryset, PAGE_LENGTH)
+
+    activity_page = paginated.get_page(page)
+    if id_only:
+        items = [s.remote_id for s in activity_page.object_list]
+    else:
+        items = [s.to_activity(pure=pure) for s in activity_page.object_list]
+
+    prev_page = next_page = None
+    if activity_page.has_next():
+        next_page = f"{remote_id}?page={activity_page.next_page_number()}"
+    if activity_page.has_previous():
+        prev_page = (
+            f"{remote_id}?page=%d{activity_page.previous_page_number()}"
+        )
+    return activitypub.OrderedCollectionPage(
+        id=f"{remote_id}?page={page}",
+        partOf=remote_id,
+        orderedItems=items,
+        next=next_page,
+        prev=prev_page,
+    )
+
+
+FederationStatus = [
+    ("federated", _("Federated")),
+    ("blocked", _("Blocked")),
+]
+
+
+class FederatedServer(BookWyrmModel):
+    """store which servers we federate with"""
+
+    server_name = models.CharField(max_length=255, unique=True)
+    status = models.CharField(
+        max_length=255, default="federated", choices=FederationStatus
+    )
+    # is it mastodon, bookwyrm, etc
+    application_type = models.CharField(max_length=255, null=True, blank=True)
+    application_version = models.CharField(max_length=255, null=True, blank=True)
+    notes = models.TextField(null=True, blank=True)
+
+    def block(self):
+        """block a server"""
+        self.status = "blocked"
+        self.save(update_fields=["status"])
+
+        # deactivate all associated users
+        self.user_set.filter(is_active=True).update(
+            is_active=False, deactivation_reason="domain_block"
+        )
+
+        # check for related connectors
+        if self.application_type == "bookwyrm":
+            connector_model = apps.get_model("bookwyrm.Connector", require_ready=True)
+            connector_model.objects.filter(
+                identifier=self.server_name, active=True
+            ).update(active=False, deactivation_reason="domain_block")
+
+    def unblock(self):
+        """unblock a server"""
+        self.status = "federated"
+        self.save(update_fields=["status"])
+
+        self.user_set.filter(deactivation_reason="domain_block").update(
+            is_active=True, deactivation_reason=None
+        )
+
+        # check for related connectors
+        if self.application_type == "bookwyrm":
+            connector_model = apps.get_model("bookwyrm.Connector", require_ready=True)
+            connector_model.objects.filter(
+                identifier=self.server_name,
+                active=False,
+                deactivation_reason="domain_block",
+            ).update(active=True, deactivation_reason=None)
+
+    @classmethod
+    def is_blocked(cls, url):
+        """look up if a domain is blocked"""
+        url = urlparse(url)
+        domain = url.netloc
+        return cls.objects.filter(server_name=domain, status="blocked").exists()

+ 128 - 0
emus/apps/activitypub/signatures.py

@@ -0,0 +1,128 @@
+import hashlib
+from typing import Any
+from urllib.parse import urlparse
+import datetime
+from base64 import b64encode, b64decode
+
+from Crypto import Random
+from Crypto.PublicKey import RSA
+from Crypto.Signature import pkcs1_15  # pylint: disable=no-name-in-module
+from Crypto.Hash import SHA256
+
+MAX_SIGNATURE_AGE = 300
+
+
+def create_key_pair() -> tuple(str, str):
+    """Creates new key pair for a new user"""
+    random_generator = Random.new().read
+    key = RSA.generate(1024, random_generator)
+    private_key = key.export_key().decode("utf8")
+    public_key = key.publickey().export_key().decode("utf8")
+
+    return private_key, public_key
+
+
+def make_signature(sender, destination, date, digest):
+    """Sign outgoing message with a private key"""
+    inbox_parts = urlparse(destination)
+    signature_headers = [
+        f"(request-target): post {inbox_parts.path}",
+        f"host: {inbox_parts.netloc}",
+        f"date: {date}",
+        f"digest: {digest}",
+    ]
+    message_to_sign = "\n".join(signature_headers)
+    signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key))
+    signed_message = signer.sign(SHA256.new(message_to_sign.encode("utf8")))
+    signature = {
+        "keyId": f"{sender.remote_id}#main-key",
+        "algorithm": "rsa-sha256",
+        "headers": "(request-target) host date digest",
+        "signature": b64encode(signed_message).decode("utf8"),
+    }
+    return ",".join(f'{k}="{v}"' for (k, v) in signature.items())
+
+
+def make_digest(data):
+    """creates a message digest for signing"""
+    return "SHA-256=" + b64encode(
+        hashlib.sha256(data.encode("utf-8")).digest()
+    ).decode("utf-8")
+
+
+def verify_digest(request):
+    """checks if a digest is syntactically valid and matches the message"""
+    algorithm, digest = request.headers["digest"].split("=", 1)
+    if algorithm == "SHA-256":
+        hash_function = hashlib.sha256
+    elif algorithm == "SHA-512":
+        hash_function = hashlib.sha512
+    else:
+        raise ValueError(f"Unsupported hash function: {algorithm}")
+
+    expected = hash_function(request.body).digest()
+    if b64decode(digest) != expected:
+        raise ValueError("Invalid HTTP Digest header")
+
+
+class Signature:
+    """read and validate incoming signatures"""
+
+    def __init__(self, key_id, headers, signature):
+        self.key_id = key_id
+        self.headers = headers
+        self.signature = signature
+
+    # pylint: disable=invalid-name
+    @classmethod
+    def parse(cls, signature: str):
+        """Extract and parse signature from an HTTP request signature string"""
+        signature_dict = {}
+        for pair in signature.split(","):
+            k, v = pair.split("=", 1)
+            v = v.replace('"', "")
+            signature_dict[k] = v
+
+        try:
+            key_id = signature_dict["keyId"]
+            headers = signature_dict["headers"]
+            signature = b64decode(signature_dict["signature"])
+        except KeyError:
+            raise ValueError("Invalid auth header")
+
+        return cls(key_id, headers, signature)
+
+    def verify(self, public_key, date, request):
+        """Verify RSA signature using a public key"""
+        """verify rsa signature"""
+        if http_date_age(request.headers["date"]) > MAX_SIGNATURE_AGE:
+            raise ValueError(f"Request too old: {request.headers['date']}")
+        public_key = RSA.import_key(public_key)
+
+        comparison_string = []
+        for signed_header_name in self.headers.split(" "):
+            if signed_header_name == "(request-target)":
+                comparison_string.append(
+                    f"(request-target): post {request.path}"
+                )
+            else:
+                if signed_header_name == "digest":
+                    verify_digest(request)
+                comparison_string.append(
+                    f"{signed_header_name}: {request.headers[signed_header_name]}"
+                )
+        comparison_string = "\n".join(comparison_string)
+
+        signer = pkcs1_15.new(public_key)
+        digest = SHA256.new()
+        digest.update(comparison_string.encode())
+
+        # raises a ValueError if it fails
+        signer.verify(digest, self.signature)
+
+
+def http_date_age(datestr):
+    """age of a signature in seconds"""
+    parsed = datetime.datetime.strptime(datestr, "%a, %d %b %Y %H:%M:%S GMT")
+    delta = datetime.datetime.utcnow() - parsed
+    return delta.total_seconds()

+ 0 - 0
games/management/commands/__init__.py → emus/apps/activitypub/utils.py


+ 125 - 0
emus/apps/activitypub/views.py

@@ -0,0 +1,125 @@
+from django.http import Http404
+from activitypub.signatures import Signature
+import json
+import logging
+import re
+
+from django.core.exceptions import BadRequest, PermissionDenied
+from django.utils.decorators import method_decorator
+from django.views import View
+from django.views.decorators.csrf import csrf_exempt
+
+from activitypub.exceptions import (
+    BannedOrDeletedActorException,
+    BlockedActorException,
+    BlockedUserAgentException,
+)
+
+from activitypub.models import FederatedServer
+
+logger = logging.getLogger(__name__)
+
+
+@method_decorator(csrf_exempt, name="dispatch")
+# pylint: disable=no-self-use
+class InboxView(View):
+    """requests sent by outside servers"""
+
+    def post(self, request, username=None):
+        """InboxView handles requests sent from outside our instance"""
+        self.check_is_blocked_user_agent()
+
+        # make sure the user's inbox even exists
+        if username:
+            get_object_or_404(User, localname=username, is_active=True)
+
+        # is it valid json? does it at least vaguely resemble an activity?
+        try:
+            activity_json = json.loads(request.body)
+        except json.decoder.JSONDecodeError:
+            raise BadRequest()
+
+        # let's be extra sure we didn't block this domain
+        self.check_is_blocked_activity(activity_json)
+
+        if (
+            not "object" in activity_json
+            or not "type" in activity_json
+            or not activity_json["type"] in activitypub.activity_objects
+        ):
+            raise Http404()
+
+        # verify the signature
+        if not has_valid_signature(request, activity_json):
+            if activity_json["type"] == "Delete":
+                # Pretend that unauth'd deletes succeed. Auth may be failing
+                # because the resource or owner of the resource might have
+                # been deleted.
+                return HttpResponse()
+            return HttpResponse(status=401)
+
+        activity_task.delay(activity_json)
+        return HttpResponse()
+
+    def check_is_blocked_user_agent(self) -> None:
+        """Raise an exception if a request is from a blocked server based on user agent"""
+
+        user_agent = self.request.headers.get("User-Agent")
+        if not user_agent:
+            return
+        url = re.search(rf"https?://{regex.DOMAIN}/?", user_agent)
+        if not url:
+            return
+        url = url.group()
+        if FederatedServer.is_blocked(url):
+            logger.debug(
+                "%s is blocked, denying request based on user agent", url
+            )
+            raise BlockedUserAgentException
+
+    def check_is_blocked_activity(self, activity_json: dict) -> None:
+        """Raise an exception if actor of an activity is blocked"""
+        actor = activity_json.get("actor")
+
+        if not actor:
+            return
+
+        # TODO Remove User hard-code and add an AP Profile
+        existing = User.find_existing_by_remote_id(actor)
+        if existing:
+            if existing.deleted:
+                logger.debug("%s is banned/deleted", actor)
+                raise BannedOrDeletedActorException
+            if existing.banned:
+                logger.debug("%s is banned/deleted", actor)
+                raise BannedOrDeletedActorException
+
+        if FederatedServer.is_blocked(actor):
+            logger.debug("%s is blocked", actor)
+            raise BlockedActorException
+
+    def is_signature_valid(self, activity) -> bool:
+        try:
+            signature = Signature.parse(self.request.headers["Signature"])
+
+            key_actor = urldefrag(signature.key_id).url
+            if key_actor != activity.get("actor"):
+                raise ValueError("Wrong actor created signature.")
+
+            remote_user = resolve_remote_id(key_actor, model=User)
+            if not remote_user:
+                return False
+
+            try:
+                signature.verify(remote_user.key_pair.public_key, self.request)
+            except ValueError:
+                old_key = remote_user.key_pair.public_key
+                remote_user = resolve_remote_id(
+                    remote_user.remote_id, model=models.User, refresh=True
+                )
+                if remote_user.key_pair.public_key == old_key:
+                    raise  # Key unchanged.
+                signature.verify(remote_user.key_pair.public_key, self.request)
+        except (ValueError, requests.exceptions.HTTPError):
+            return False
+        return True

+ 0 - 0
games/__init__.py → emus/apps/games/__init__.py


+ 0 - 0
games/admin.py → emus/apps/games/admin.py


+ 0 - 0
games/api/__init__.py → emus/apps/games/api/__init__.py


+ 0 - 0
games/api/serializers.py → emus/apps/games/api/serializers.py


+ 0 - 0
games/api/views.py → emus/apps/games/api/views.py


+ 0 - 0
games/apps.py → emus/apps/games/apps.py


+ 0 - 0
games/constants.py → emus/apps/games/constants.py


+ 0 - 0
games/context_processors.py → emus/apps/games/context_processors.py


+ 1 - 0
emus/apps/games/management/__init__.py

@@ -0,0 +1 @@
+#!/usr/bin/env python3

+ 1 - 0
emus/apps/games/management/commands/__init__.py

@@ -0,0 +1 @@
+#!/usr/bin/env python3

+ 0 - 0
games/management/commands/export_collections.py → emus/apps/games/management/commands/export_collections.py


+ 0 - 0
games/management/commands/export_gamelist_xml_file.py → emus/apps/games/management/commands/export_gamelist_xml_file.py


+ 0 - 0
games/management/commands/import_gamelist_xml_file.py → emus/apps/games/management/commands/import_gamelist_xml_file.py


+ 0 - 0
games/management/commands/scrape_roms.py → emus/apps/games/management/commands/scrape_roms.py


+ 0 - 0
games/management/commands/update_roms.py → emus/apps/games/management/commands/update_roms.py


+ 0 - 0
games/migrations/0001_initial.py → emus/apps/games/migrations/0001_initial.py


+ 0 - 0
games/migrations/0002_alter_game_players.py → emus/apps/games/migrations/0002_alter_game_players.py


+ 0 - 0
games/migrations/0003_alter_game_developer_alter_game_publisher.py → emus/apps/games/migrations/0003_alter_game_developer_alter_game_publisher.py


+ 0 - 0
games/migrations/0004_game_english_patched_game_english_patched_version_and_more.py → emus/apps/games/migrations/0004_game_english_patched_game_english_patched_version_and_more.py


+ 0 - 0
games/migrations/0005_game_region_game_undub.py → emus/apps/games/migrations/0005_game_region_game_undub.py


+ 0 - 0
games/migrations/0006_alter_game_region.py → emus/apps/games/migrations/0006_alter_game_region.py


+ 0 - 0
games/migrations/0007_alter_game_marquee_alter_game_region_and_more.py → emus/apps/games/migrations/0007_alter_game_marquee_alter_game_region_and_more.py


+ 0 - 0
games/migrations/0008_game_featured.py → emus/apps/games/migrations/0008_game_featured.py


+ 0 - 0
games/migrations/0009_developer_created_developer_modified_game_created_and_more.py → emus/apps/games/migrations/0009_developer_created_developer_modified_game_created_and_more.py


+ 0 - 0
games/migrations/0010_alter_game_release_date.py → emus/apps/games/migrations/0010_alter_game_release_date.py


+ 0 - 0
games/migrations/0011_alter_game_region.py → emus/apps/games/migrations/0011_alter_game_region.py


+ 0 - 0
games/migrations/0012_alter_game_marquee_alter_game_rom_file_and_more.py → emus/apps/games/migrations/0012_alter_game_marquee_alter_game_rom_file_and_more.py


+ 0 - 0
games/migrations/0013_alter_game_screenshot.py → emus/apps/games/migrations/0013_alter_game_screenshot.py


+ 0 - 0
games/migrations/0014_alter_developer_options_alter_game_options_and_more.py → emus/apps/games/migrations/0014_alter_developer_options_alter_game_options_and_more.py


+ 0 - 0
games/migrations/0015_gamecollection.py → emus/apps/games/migrations/0015_gamecollection.py


+ 0 - 0
games/migrations/0016_game_source_game_tags_and_more.py → emus/apps/games/migrations/0016_game_source_game_tags_and_more.py


+ 0 - 0
games/migrations/0017_game_featured_on.py → emus/apps/games/migrations/0017_game_featured_on.py


+ 0 - 0
games/migrations/0018_remove_game_featured_alter_game_tags.py → emus/apps/games/migrations/0018_remove_game_featured_alter_game_tags.py


+ 0 - 0
games/migrations/0019_game_finished_on_game_started_on.py → emus/apps/games/migrations/0019_game_finished_on_game_started_on.py


+ 0 - 0
games/migrations/0020_developer_uuid_game_uuid_gamecollection_uuid_and_more.py → emus/apps/games/migrations/0020_developer_uuid_game_uuid_gamecollection_uuid_and_more.py


+ 0 - 0
games/migrations/0021_remove_game_finished_on_remove_game_started_on.py → emus/apps/games/migrations/0021_remove_game_finished_on_remove_game_started_on.py


+ 0 - 0
games/migrations/__init__.py → emus/apps/games/migrations/__init__.py


+ 0 - 0
games/models.py → emus/apps/games/models.py


+ 0 - 0
games/tasks.py → emus/apps/games/tasks.py


+ 0 - 0
games/tests.py → emus/apps/games/tests.py


+ 0 - 0
games/urls.py → emus/apps/games/urls.py


+ 0 - 0
games/utils.py → emus/apps/games/utils.py


+ 0 - 0
games/views.py → emus/apps/games/views.py


+ 0 - 0
profiles/__init__.py → emus/apps/profiles/__init__.py


+ 0 - 0
profiles/admin.py → emus/apps/profiles/admin.py


+ 0 - 0
profiles/apps.py → emus/apps/profiles/apps.py


+ 0 - 0
profiles/migrations/0001_initial.py → emus/apps/profiles/migrations/0001_initial.py


+ 0 - 0
profiles/migrations/0002_usergameprogress_finished_ts_and_more.py → emus/apps/profiles/migrations/0002_usergameprogress_finished_ts_and_more.py


+ 0 - 0
profiles/migrations/0003_alter_usergameprogress_user.py → emus/apps/profiles/migrations/0003_alter_usergameprogress_user.py


+ 0 - 0
profiles/migrations/0004_alter_userprofile_user_historicalusergameprogress.py → emus/apps/profiles/migrations/0004_alter_userprofile_user_historicalusergameprogress.py


+ 0 - 0
profiles/migrations/0005_historicalusergameplaythrough_usergameplaythrough_and_more.py → emus/apps/profiles/migrations/0005_historicalusergameplaythrough_usergameplaythrough_and_more.py


+ 0 - 0
profiles/migrations/__init__.py → emus/apps/profiles/migrations/__init__.py


+ 0 - 0
profiles/models.py → emus/apps/profiles/models.py


+ 12 - 0
emus/apps/scraper/main.py

@@ -0,0 +1,12 @@
+import os
+from typing import Optional
+
+password = os.getenv('SCREENSCRAPER_DEV_PASSWORD', 'a88e9PGPlldlmaLRlkNH8naEl')
+user = os.getenv('SCREENSCRAPER_USERNAME', 'secstate')
+base_scraper_url = f"https://www.screenscraper.fr/api2/jeuInfos.php?softname=emus&ssid={user}&sspassword={password}&output=json&{0}"
+
+def query_screenscraper_fr(search_name: str, platform_id: Optional[int]):
+    scraper_url = base_scraper_url.format(search_name)
+    print(scraper_url)
+
+# + (platformId.isEmpty()?"":"&systemeid=" + platformId) + "&output=json&" + searchName;

+ 0 - 0
search/__init__.py → emus/apps/search/__init__.py


+ 0 - 0
search/urls.py → emus/apps/search/urls.py


+ 0 - 0
search/views.py → emus/apps/search/views.py


+ 16 - 1
poetry.lock

@@ -1361,6 +1361,17 @@ python-versions = ">=3.6"
 [package.extras]
 watchdog = ["watchdog"]
 
+[[package]]
+name = "whitenoise"
+version = "6.3.0"
+description = "Radically simplified static file serving for WSGI applications"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+brotli = ["Brotli"]
+
 [[package]]
 name = "wrapt"
 version = "1.14.1"
@@ -1395,7 +1406,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.8"
-content-hash = "d1a46b6435a93d8ccaffd853d3d96e3a317db0b2d19e435323d5100ad37ac1d5"
+content-hash = "6105971e3adba942edffa16bd54f5822cdcabcd1e55dfecfc67410cf486a1a71"
 
 [metadata.files]
 amqp = [
@@ -2144,6 +2155,10 @@ werkzeug = [
     {file = "Werkzeug-2.0.3-py3-none-any.whl", hash = "sha256:1421ebfc7648a39a5c58c601b154165d05cf47a3cd0ccb70857cbdacf6c8f2b8"},
     {file = "Werkzeug-2.0.3.tar.gz", hash = "sha256:b863f8ff057c522164b6067c9e28b041161b4be5ba4d0daceeaa50a163822d3c"},
 ]
+whitenoise = [
+    {file = "whitenoise-6.3.0-py3-none-any.whl", hash = "sha256:cf8ecf56d86ba1c734fdb5ef6127312e39e92ad5947fef9033dc9e43ba2777d9"},
+    {file = "whitenoise-6.3.0.tar.gz", hash = "sha256:fe0af31504ab08faa1ec7fc02845432096e40cc1b27e6a7747263d7b30fb51fa"},
+]
 wrapt = [
     {file = "wrapt-1.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1b376b3f4896e7930f1f772ac4b064ac12598d1c38d04907e696cc4d794b43d3"},
     {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:903500616422a40a98a5a3c4ff4ed9d0066f3b4c951fa286018ecdf0750194ef"},

+ 1 - 0
pyproject.toml

@@ -26,6 +26,7 @@ django-taggit = "^2.1.0"
 django-markdownify = "^0.9.1"
 gunicorn = "^20.1.0"
 django-simple-history = "^3.1.1"
+whitenoise = "^6.3.0"
 
 [tool.poetry.dev-dependencies]
 Werkzeug = "2.0.3"