├── .github └── workflows │ └── python-app.yml ├── .gitignore ├── LICENSE ├── README.md ├── all_tests.sh ├── bovine ├── README.md ├── as.json ├── bovine │ ├── __init__.py │ ├── activitypub │ │ ├── __init__.py │ │ ├── activity_factory.py │ │ ├── actor.py │ │ ├── collection_helper.py │ │ ├── collection_iterator.py │ │ └── object_factory.py │ ├── activitystreams │ │ ├── __init__.py │ │ ├── activities │ │ │ ├── __init__.py │ │ │ ├── accept_builder.py │ │ │ ├── create_builder.py │ │ │ ├── delete_builder.py │ │ │ ├── follow_builder.py │ │ │ ├── like_builder.py │ │ │ ├── test_accept_builder.py │ │ │ ├── test_create_builder.py │ │ │ ├── test_delete_builder.py │ │ │ ├── test_follow_builder.py │ │ │ ├── test_like_builder.py │ │ │ └── undo_builder.py │ │ ├── actor_builder.py │ │ ├── common │ │ │ ├── __init__.py │ │ │ ├── context_builder.py │ │ │ └── test_context_builder.py │ │ ├── objects │ │ │ ├── __init__.py │ │ │ ├── note_builder.py │ │ │ ├── object_builder.py │ │ │ ├── test_note_builder.py │ │ │ ├── test_tombstone_builder.py │ │ │ └── tombstone_builder.py │ │ ├── ordered_collection_builder.py │ │ ├── ordered_collection_page_builder.py │ │ ├── test_actor_builder.py │ │ ├── test_ordered_collection_builder.py │ │ ├── test_ordered_collection_page_builder.py │ │ └── utils │ │ │ ├── __init__.py │ │ │ ├── print.py │ │ │ └── test_utils.py │ ├── clients │ │ ├── __init__.py │ │ ├── activity_pub.py │ │ ├── consts.py │ │ ├── event_source.py │ │ ├── lookup_account.py │ │ ├── nodeinfo.py │ │ ├── signed_http.py │ │ ├── test_activity_pub_get.py │ │ ├── test_lookup_account.py │ │ ├── test_nodeinfo.py │ │ └── test_signed_http.py │ ├── test_types.py │ ├── types.py │ ├── utils │ │ ├── __init__.py │ │ ├── crypto │ │ │ ├── __init__.py │ │ │ ├── did_key.py │ │ │ ├── test_crypto.py │ │ │ └── test_did_key.py │ │ ├── date.py │ │ ├── http_signature.py │ │ ├── parse.py │ │ ├── signature_checker.py │ │ ├── signature_parser.py │ │ ├── test.py │ │ ├── test_date.py │ │ ├── test_http_signature.py │ │ ├── test_parse.py │ │ └── test_signature_parser.py │ └── version.py ├── examples │ ├── introduction.ipynb │ ├── mastodon_bridge.py │ ├── oauth.py │ ├── repl.py │ ├── send_accept.py │ ├── send_delete_user.py │ ├── send_like.py │ ├── send_note.py │ ├── send_unfollow.py │ └── sse.py ├── poetry.lock ├── pyproject.toml ├── scripts │ └── generate_heykey.py ├── setup.cfg └── tests │ └── test_json_ld_format.py ├── bovine_config.toml.example ├── bovine_fedi ├── FEDERATION.md ├── README.md ├── bovine_config.toml ├── bovine_fedi │ ├── __init__.py │ ├── build_store.py │ ├── caches │ │ ├── __init__.py │ │ ├── public_key.py │ │ └── test_public_key.py │ ├── config.py │ ├── models.py │ ├── server │ │ ├── __init__.py │ │ ├── activitypub.py │ │ ├── authorization.py │ │ ├── endpoints.py │ │ ├── info.py │ │ └── wellknown.py │ ├── storage │ │ ├── __init__.py │ │ ├── storage.py │ │ └── test_storage.py │ ├── types │ │ ├── __init__.py │ │ ├── base_count_and_items.py │ │ ├── local_actor.py │ │ └── test_local_actor.py │ ├── utils │ │ ├── __init__.py │ │ ├── in_memory_store.py │ │ ├── peer_type.py │ │ ├── queue_manager.py │ │ ├── test │ │ │ ├── __init__.py │ │ │ └── in_memory_test_app.py │ │ ├── test_queue_manager.py │ │ └── test_utils.py │ └── version.py ├── context_cache.sqlite ├── migrations │ └── models │ │ ├── 0_20230118205707_init.py │ │ ├── 1_20230122202029_add_public_key_table.py │ │ ├── 2_20230122215619_make_public_key_url_unique.py │ │ ├── 3_20230123193928_add_outbox_fields.py │ │ ├── 4_20230123202446_move_fields_from_outbox_to_inbox.py │ │ ├── 5_20230128185546_add_storage_table.py │ │ ├── 6_20230131082947_add_content_id_to_outbox_inbox.py │ │ ├── 7_20230220194230_None.py │ │ ├── 8_20230312120943_private_key_nullable.py │ │ └── 9_20230321190820_privatekeypk_table.py ├── poetry.lock ├── pyproject.toml ├── schemas │ └── nodeinfo_2_0.schema.json ├── scripts │ ├── collect_peers.py │ ├── database_cleanup.py │ ├── ensure_follow.py │ ├── get_activity.py │ └── import_rapid_block.py ├── setup.cfg ├── static │ └── style.css └── tests │ ├── test_activitypub_actor.py │ ├── test_webfinger_to_activitypub.py │ ├── test_wellknown_nodeinfo.py │ └── test_wellknown_webfinger.py ├── bovine_process ├── README.md ├── bovine_process │ ├── __init__.py │ ├── add_to_queue.py │ ├── content │ │ ├── __init__.py │ │ ├── handle_update.py │ │ ├── incoming_delete.py │ │ ├── store_incoming.py │ │ ├── test_handle_update.py │ │ └── test_store_incoming.py │ ├── fetch │ │ ├── __init__.py │ │ ├── incoming_actor.py │ │ └── test_incoming_actor.py │ ├── follow │ │ ├── __init__.py │ │ ├── accept_follow.py │ │ └── follow_accept.py │ ├── send_item.py │ ├── test_undo.py │ ├── types │ │ ├── __init__.py │ │ ├── processing_item.py │ │ └── test_processing_item.py │ ├── undo.py │ └── utils │ │ ├── __init__.py │ │ ├── by_activity_type.py │ │ ├── processor_list.py │ │ ├── test_by_activity_type.py │ │ └── test_processor_list.py ├── poetry.lock ├── pyproject.toml └── setup.cfg ├── bovine_store ├── .gitignore ├── README.md ├── bovine_config.toml ├── bovine_store │ ├── __init__.py │ ├── blueprint.py │ ├── collection.py │ ├── config.py │ ├── jsonld.py │ ├── models.py │ ├── permissions.py │ ├── store │ │ ├── __init__.py │ │ ├── collection.py │ │ ├── local.py │ │ ├── retrieve_object.py │ │ ├── store.py │ │ ├── test_collection.py │ │ ├── test_retrieve_object.py │ │ ├── test_store.py │ │ ├── test_store_local.py │ │ └── test_store_remote.py │ ├── test_jsonld.py │ ├── test_permissions.py │ └── utils │ │ ├── __init__.py │ │ ├── ordered_collection.py │ │ ├── test.py │ │ └── test_summary.py ├── context_cache.sqlite ├── examples │ ├── basic_app.py │ ├── create_table.py │ └── templates ├── poetry.lock ├── pyproject.toml ├── setup.cfg ├── templates │ └── fallback.html └── tests │ ├── __init__.py │ ├── app_env.py │ └── test_retrieve.py ├── bovine_user ├── README.md ├── bovine_config.toml ├── bovine_user │ ├── __init__.py │ ├── config.py │ ├── hello_auth.py │ ├── manager.py │ ├── models.py │ ├── server.py │ ├── test_manager.py │ ├── types.py │ └── utils │ │ ├── __init__.py │ │ └── test.py ├── examples │ ├── basic_app.py │ └── create_table.py ├── poetry.lock ├── pyproject.toml ├── setup.cfg └── templates │ └── create.html ├── docs ├── .prettierignore ├── actor.md ├── client_to_server_activitypub.md ├── deployment.md ├── http_signatures.md ├── like_activity.md ├── make_specification.py ├── poetry.lock ├── pyproject.toml ├── specification.md └── specification_template.md ├── examples ├── cow_resisa │ ├── README.md │ ├── generate_keys.py │ ├── poetry.lock │ ├── pyproject.toml │ └── resisa.py ├── examples │ └── media_storage_app.py ├── local_test_blog │ ├── README.md │ ├── local_test_blog.py │ ├── poetry.lock │ ├── pyproject.toml │ └── static └── munching_cow │ ├── munching_cow.py │ ├── munching_cow_cleanup.py │ ├── poetry.lock │ └── pyproject.toml ├── mechanical_bull ├── README.md ├── mechanical_bull │ ├── __init__.py │ └── actions │ │ ├── __init__.py │ │ ├── handle_follow_request.py │ │ └── test_handle_follow_request.py ├── poetry.lock ├── pyproject.toml └── run.py ├── notebooks ├── Understanding json-ld libraries.ipynb ├── data ├── instances.ipynb ├── poetry.lock ├── pyproject.toml └── urls └── tests ├── README.md ├── bovine_config.toml ├── context_cache.sqlite ├── data ├── buffalo_create_note_1.json ├── mastodon_announce_1.json ├── mastodon_announce_1_undo.json ├── mastodon_delete_actor_1.json ├── mastodon_flow_1_create_note.json ├── mastodon_flow_1_update_note.json ├── mastodon_flow_2_create_note.json ├── mastodon_flow_2_delete_note.json ├── mastodon_follow_1.json ├── mastodon_like_1.json ├── mastodon_like_1_undo.json ├── mitra_follow.json ├── munching_cow_create_note_1.json ├── munching_cow_delete_1.json └── peertube_create_video_1.json ├── poetry.lock ├── pyproject.toml ├── setup.cfg ├── tests ├── __init__.py ├── inbox │ ├── test_announce_then_undo_announce.py │ ├── test_create_then_update_note.py │ ├── test_delete_actor.py │ ├── test_flow_2_create_then_delete.py │ ├── test_like_then_undo_like.py │ ├── test_lone_undo_announce.py │ └── test_server_sent_events.py ├── outbox │ ├── test_create_many_notes.py │ ├── test_create_note.py │ ├── test_create_note_and_send_note.py │ ├── test_create_then_delete.py │ ├── test_follow_then_accept_added_to_following.py │ ├── test_follow_then_accept_added_to_following_full_accept.py │ └── test_on_accept_is_added_to_followers.py ├── test_actor.py ├── test_nodeinfo.py └── test_webfinger.py └── utils ├── __init__.py └── blog_test_env.py /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python 3.10 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: "3.10" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install --upgrade poetry 30 | - name: bovine 31 | run: | 32 | cd bovine 33 | poetry install 34 | poetry run flake8 35 | poetry run pytest 36 | # Github does not support hypercorn... 37 | # - name: bovine_store 38 | # run: | 39 | # cd bovine_store 40 | # poetry install 41 | # poetry run flake8 42 | # poetry run pytest 43 | # - name: bovine_user 44 | # run: | 45 | # cd bovine_user 46 | # poetry install 47 | # poetry run flake8 48 | # poetry run pytest 49 | # - name: bovine_fedi 50 | # run: | 51 | # cd bovine_fedi 52 | # poetry install 53 | # poetry run flake8 54 | # poetry run pytest 55 | # - name: tests 56 | # run: | 57 | # cd tests 58 | # poetry install 59 | # poetry run flake8 60 | # poetry run pytest 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | buffalo/src/config.js 3 | node_modules 4 | build 5 | __pycache__ 6 | .files 7 | db.sqlite3* 8 | debug.log 9 | .coverage 10 | *.pem 11 | resisa.toml 12 | .ipynb_checkpoints 13 | helge.toml 14 | context_cache.sqlite 15 | /bovine_config.toml 16 | store.db* 17 | h.toml 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Helge 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /all_tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eux 4 | 5 | cd bovine 6 | #poetry lock 7 | #poetry install 8 | poetry run pytest 9 | poetry run isort . 10 | poetry run black . 11 | # poetry run flake8 . 12 | 13 | cd ../bovine_store 14 | #poetry lock 15 | #poetry install 16 | poetry run pytest 17 | # poetry run isort . 18 | poetry run black . 19 | poetry run flake8 . 20 | 21 | cd ../bovine_user 22 | #poetry lock 23 | #poetry install 24 | poetry run pytest 25 | poetry run isort . 26 | poetry run black . 27 | poetry run flake8 . 28 | 29 | cd ../bovine_process 30 | #poetry lock 31 | #poetry install 32 | poetry run pytest 33 | poetry run isort . 34 | poetry run black . 35 | poetry run flake8 . 36 | 37 | cd ../bovine_fedi 38 | #poetry lock 39 | #poetry install 40 | poetry run pytest 41 | poetry run isort . 42 | poetry run black . 43 | #poetry run flake8 . 44 | 45 | cd ../tests 46 | #poetry lock 47 | #poetry install 48 | poetry run pytest 49 | # poetry run isort . 50 | poetry run black . 51 | poetry run flake8 . 52 | 53 | cd ../docs 54 | python make_specification.py 55 | poetry run mdformat . 56 | 57 | cd .. 58 | 59 | ack '# FIXME' bovine* tests 60 | -------------------------------------------------------------------------------- /bovine/README.md: -------------------------------------------------------------------------------- 1 | # Bovine Core 2 | 3 | The core of bovine. This is intended to be sufficient to build 4 | an ActivityPub Client application. The only package of interest 5 | should be `bovine.activitypub` 6 | 7 | 8 | - `bovine.activitystreams` contains builders to create 9 | [ActivityStreams](https://www.w3.org/ns/activitystreams) objects. 10 | - `bovine.clients` contains routines to interact with 11 | ActivityPub servers. In particular, routines to perform 12 | POST and GET requests using HTTP Signatures are implemented. 13 | - `bovine.utils` contains the cryptographic stuff to 14 | handle HTTP Signatures. 15 | 16 | ## Examples and toml file format 17 | 18 | The two examples in the example folder currently rely on a configuration file 19 | called `helge.toml`. 20 | 21 | ```toml 22 | account_url = "https://mymath.rocks/activitypub/helge" 23 | public_key_url = "https://mymath.rocks/activitypub/helge#main-key" 24 | private_key = """-----BEGIN PRIVATE KEY----- 25 | ... 26 | -----END PRIVATE KEY----- 27 | """ 28 | ``` 29 | 30 | The two scripts are `examples/sse.py` which opens the event source for the inbox 31 | and displays new elements. The second script `examples/send_note.py` allows one 32 | to send a quick message. Example usage: 33 | 34 | ```shell 35 | poetry run python examples/send_note.py 'Hello World! via send_note.py and AP-C2S.' 36 | ``` 37 | 38 | Similary the script `examples/send_like.py` can be used to like a remote object 39 | 40 | ```shell 41 | poetry run python examples/like.py ID_OF_REMOTE_OBJECT 42 | ``` 43 | -------------------------------------------------------------------------------- /bovine/bovine/__init__.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import tomli 3 | 4 | from .activitypub.actor import ActivityPubActor 5 | 6 | 7 | class BovineActor(ActivityPubActor): 8 | def __init__(self, config_file): 9 | self.config_file = config_file 10 | 11 | with open(config_file, "rb") as fp: 12 | self.config = tomli.load(fp) 13 | 14 | super().__init__(self.config["account_url"]) 15 | 16 | async def init(self, session=None): 17 | self.session = session 18 | if session is None: 19 | self.session = aiohttp.ClientSession() 20 | self.with_http_signature( 21 | self.config["public_key_url"], 22 | self.config["private_key"], 23 | session=self.session, 24 | ) 25 | await self.load() 26 | 27 | async def __aenter__(self): 28 | await self.init() 29 | return self 30 | 31 | async def __aexit__(self, *args): 32 | await self.session.close() 33 | -------------------------------------------------------------------------------- /bovine/bovine/activitypub/__init__.py: -------------------------------------------------------------------------------- 1 | from .actor import ActivityPubActor 2 | 3 | 4 | def actor_from_file(filename, session): 5 | return ActivityPubActor.from_file(filename, session) 6 | -------------------------------------------------------------------------------- /bovine/bovine/activitypub/activity_factory.py: -------------------------------------------------------------------------------- 1 | from bovine.activitystreams.activities import build_create 2 | 3 | 4 | class ActivityFactory: 5 | def __init__(self, actor_information): 6 | self.information = actor_information 7 | 8 | def create(self, note): 9 | return build_create(note) 10 | -------------------------------------------------------------------------------- /bovine/bovine/activitypub/collection_iterator.py: -------------------------------------------------------------------------------- 1 | class CollectionIterator: 2 | def __init__(self, collection_helper, max_number): 3 | self.count = 0 4 | self.collection_helper = collection_helper 5 | self.max_number = max_number 6 | 7 | def __aiter__(self): 8 | return self 9 | 10 | async def __anext__(self): 11 | self.count += 1 12 | if self.count > self.max_number: 13 | raise StopAsyncIteration 14 | 15 | return await self.collection_helper.next_item() 16 | -------------------------------------------------------------------------------- /bovine/bovine/activitypub/object_factory.py: -------------------------------------------------------------------------------- 1 | from bovine.activitystreams.objects.note_builder import NoteBuilder 2 | from bovine.activitystreams.objects.object_builder import ObjectBuilder 3 | 4 | 5 | class ObjectFactory: 6 | def __init__(self, actor_information): 7 | self.information = actor_information 8 | 9 | def note(self, text): 10 | return NoteBuilder( 11 | self.information["id"], text, followers=self.information["followers"] 12 | ) 13 | 14 | def article(self): 15 | return ObjectBuilder( 16 | "Article", self.information["id"], followers=self.information["followers"] 17 | ) 18 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/__init__.py: -------------------------------------------------------------------------------- 1 | from .actor_builder import ActorBuilder 2 | from .ordered_collection_builder import OrderedCollectionBuilder 3 | from .ordered_collection_page_builder import OrderedCollectionPageBuilder 4 | 5 | 6 | def build_actor(actor_name: str, actor_type: str = "Person"): 7 | return ActorBuilder(actor_name, actor_type=actor_type) 8 | 9 | 10 | def build_ordered_collection(url: str): 11 | return OrderedCollectionBuilder(url) 12 | 13 | 14 | def build_ordered_collection_page(url: str, part_of: str): 15 | return OrderedCollectionPageBuilder(url, part_of) 16 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/activities/__init__.py: -------------------------------------------------------------------------------- 1 | from .accept_builder import AcceptBuilder 2 | from .create_builder import CreateBuilder 3 | from .delete_builder import DeleteBuilder 4 | from .follow_builder import FollowBuilder 5 | from .like_builder import LikeBuilder 6 | from .undo_builder import UndoBuilder 7 | 8 | 9 | def build_follow(domain: str, actor: str, tofollow: str) -> FollowBuilder: 10 | return FollowBuilder(domain, actor, tofollow) 11 | 12 | 13 | def build_accept(account: str, obj: dict) -> AcceptBuilder: 14 | return AcceptBuilder(account, obj) 15 | 16 | 17 | def build_create(*args) -> CreateBuilder: 18 | return CreateBuilder(*args) 19 | 20 | 21 | def build_delete(actor, object_id): 22 | return DeleteBuilder(actor, object_id) 23 | 24 | 25 | def build_like(actor, object_id): 26 | return LikeBuilder(actor, object_id) 27 | 28 | 29 | def build_undo(obj: dict) -> UndoBuilder: 30 | return UndoBuilder(obj) 31 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/activities/accept_builder.py: -------------------------------------------------------------------------------- 1 | class AcceptBuilder: 2 | def __init__(self, actor: str, obj: dict): 3 | self.actor = actor 4 | self.obj = obj 5 | 6 | def build(self) -> dict: 7 | return { 8 | "@context": "https://www.w3.org/ns/activitystreams", 9 | "id": self.actor + "#accepts/follows/", 10 | "type": "Accept", 11 | "actor": self.actor, 12 | "object": self.obj, 13 | "to": [self.determine_to()], 14 | } 15 | 16 | def determine_to(self) -> str: 17 | actor = self.obj.get("actor") 18 | 19 | if isinstance(actor, dict): 20 | actor = actor.get("id") 21 | 22 | return actor 23 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/activities/create_builder.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | 4 | class CreateBuilder: 5 | def __init__(self, obj: dict): 6 | self.obj = obj 7 | self.account = self.obj.get("attributedTo", None) 8 | self.url = self.obj.get("id", None) 9 | self.to = self.obj.get("to", []) 10 | self.cc = self.obj.get("cc", []) 11 | self.published = self.obj.get( 12 | "published", datetime.utcnow().replace(microsecond=0).isoformat() + "Z" 13 | ) 14 | 15 | def with_account(self, account: str): 16 | self.account = account 17 | return self 18 | 19 | def as_public(self): 20 | self.to.append("https://www.w3.org/ns/activitystreams#Public") 21 | self.cc.append(f"{self.account}/followers") 22 | return self 23 | 24 | def as_unlisted(self): 25 | self.to.append(f"{self.account}/followers") 26 | self.cc.append("https://www.w3.org/ns/activitystreams#Public") 27 | return self 28 | 29 | def build(self) -> dict: 30 | return { 31 | "@context": [ 32 | "https://www.w3.org/ns/activitystreams", 33 | { 34 | "inReplyToAtomUri": "ostatus:inReplyToAtomUri", 35 | "conversation": "ostatus:conversation", 36 | "ostatus": "http://ostatus.org#", 37 | }, 38 | ], 39 | "id": self.url, 40 | "type": "Create", 41 | "actor": self.account, 42 | "object": self.obj, 43 | "published": self.published, 44 | "to": self.to, 45 | "cc": self.cc, 46 | } 47 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/activities/delete_builder.py: -------------------------------------------------------------------------------- 1 | from bovine.activitystreams.common import as_public, build_context 2 | from bovine.activitystreams.objects import tombstone 3 | 4 | 5 | class DeleteBuilder: 6 | def __init__(self, actor, object_id): 7 | self.actor = actor 8 | self.object_id = object_id 9 | self.context_builder = build_context() 10 | 11 | def build(self): 12 | my_tombstone = tombstone(self.object_id) 13 | my_tombstone = self.context_builder.absorb(my_tombstone) 14 | 15 | return { 16 | "@context": self.context_builder.build(), 17 | "id": self.object_id + "/delete", 18 | "type": "Delete", 19 | "actor": self.actor, 20 | "to": [as_public], 21 | "cc": [self.actor + "/followers"], 22 | "object": my_tombstone, 23 | } 24 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/activities/follow_builder.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | 4 | class FollowBuilder: 5 | def __init__(self, domain: str, actor: str, tofollow: str): 6 | self.domain = domain 7 | self.actor = actor 8 | self.tofollow = tofollow 9 | 10 | def build(self) -> dict: 11 | uuid_string = str(uuid.uuid4()) 12 | 13 | return { 14 | "@context": "https://www.w3.org/ns/activitystreams", 15 | "id": f"https://{self.domain}/{uuid_string}", 16 | "type": "Follow", 17 | "actor": self.actor, 18 | "object": self.tofollow, 19 | } 20 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/activities/like_builder.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from bovine.activitystreams.common import build_context 4 | 5 | 6 | class LikeBuilder: 7 | def __init__(self, account: str, obj: dict): 8 | self.account = account 9 | self.obj = obj 10 | self.content = None 11 | self.to = set() 12 | self.cc = set() 13 | 14 | def add_to(self, recipient): 15 | self.to.add(recipient) 16 | return self 17 | 18 | def add_cc(self, recipient): 19 | self.cc.add(recipient) 20 | return self 21 | 22 | def with_content(self, content): 23 | self.content = content 24 | return self 25 | 26 | def build(self) -> dict: 27 | context = build_context().build() 28 | 29 | result = { 30 | "@context": context, 31 | "id": self.account + "#likes+" + str(uuid.uuid4()), 32 | "type": "Like", 33 | "actor": self.account, 34 | "object": self.obj, 35 | } 36 | 37 | if self.to: 38 | result["to"] = list(self.to) 39 | 40 | if self.cc: 41 | result["cc"] = list(self.cc) 42 | 43 | if self.content: 44 | result["content"] = self.content 45 | 46 | return result 47 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/activities/test_accept_builder.py: -------------------------------------------------------------------------------- 1 | from .accept_builder import AcceptBuilder 2 | 3 | 4 | def test_accept_builder(): 5 | result = AcceptBuilder("account", {"an": "object"}).build() 6 | 7 | assert result["@context"] == "https://www.w3.org/ns/activitystreams" 8 | assert result["id"] == "account#accepts/follows/" 9 | assert result["type"] == "Accept" 10 | assert result["actor"] == "account" 11 | assert result["object"]["an"] == "object" 12 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/activities/test_create_builder.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from bovine.activitystreams.objects import build_note 4 | 5 | from .create_builder import CreateBuilder 6 | 7 | 8 | def test_basic_build(): 9 | result = CreateBuilder({"a": "message"}).build() 10 | 11 | assert "https://www.w3.org/ns/activitystreams" in result["@context"] 12 | assert result["type"] == "Create" 13 | assert result["id"] is None 14 | assert result["actor"] is None 15 | assert re.match(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", result["published"]) 16 | 17 | 18 | def test_basic_build_as_unlisted(): 19 | result = ( 20 | CreateBuilder({"a": "message"}).with_account("account").as_unlisted().build() 21 | ) 22 | 23 | assert result["to"] == ["account/followers"] 24 | assert result["cc"] == ["https://www.w3.org/ns/activitystreams#Public"] 25 | 26 | 27 | def test_copies_from_note_if_available(): 28 | note = build_note("account", "url", "content").as_public().build() 29 | 30 | result = CreateBuilder(note).build() 31 | 32 | assert result["actor"] == "account" 33 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/activities/test_delete_builder.py: -------------------------------------------------------------------------------- 1 | from .delete_builder import DeleteBuilder 2 | 3 | 4 | def test_delete_builder(): 5 | result = DeleteBuilder("actor", "object_id").build() 6 | 7 | assert result["id"] == "object_id/delete" 8 | assert result["type"] == "Delete" 9 | assert "@context" not in result["object"] 10 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/activities/test_follow_builder.py: -------------------------------------------------------------------------------- 1 | from .follow_builder import FollowBuilder 2 | 3 | 4 | def test_follow_builder() -> None: 5 | data = FollowBuilder("domain", "actor", "tofollow").build() 6 | 7 | assert data["@context"] == "https://www.w3.org/ns/activitystreams" 8 | assert data["id"].startswith("https://domain/") 9 | assert data["type"] == "Follow" 10 | assert data["actor"] == "actor" 11 | assert data["object"] == "tofollow" 12 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/activities/test_like_builder.py: -------------------------------------------------------------------------------- 1 | from .like_builder import LikeBuilder 2 | 3 | 4 | def test_like_builder(): 5 | result = ( 6 | LikeBuilder("account", "obj") 7 | .add_to("target") 8 | .add_cc("other") 9 | .with_content("🐮") 10 | .build() 11 | ) 12 | 13 | assert result["@context"] == "https://www.w3.org/ns/activitystreams" 14 | 15 | assert result["to"] == ["target"] 16 | assert result["cc"] == ["other"] 17 | 18 | assert result["type"] == "Like" 19 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/activities/undo_builder.py: -------------------------------------------------------------------------------- 1 | class UndoBuilder: 2 | def __init__(self, obj: dict): 3 | self.obj = obj 4 | 5 | def build(self) -> dict: 6 | return { 7 | "@context": "https://www.w3.org/ns/activitystreams", 8 | "type": "Undo", 9 | "actor": self.obj["actor"], 10 | "object": self.obj, 11 | } 12 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/common/__init__.py: -------------------------------------------------------------------------------- 1 | from .context_builder import ContextBuilder 2 | 3 | as_public = "https://www.w3.org/ns/activitystreams#Public" 4 | 5 | 6 | def build_context(): 7 | return ContextBuilder() 8 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/common/context_builder.py: -------------------------------------------------------------------------------- 1 | class ContextBuilder: 2 | def __init__(self): 3 | self.context_list = ["https://www.w3.org/ns/activitystreams"] 4 | self.additional_contexts = {} 5 | 6 | def add(self, *args, **kwargs): 7 | self.additional_contexts = {**self.additional_contexts, **kwargs} 8 | self.context_list += args 9 | return self 10 | 11 | def absorb(self, obj): 12 | if not isinstance(obj["@context"], str): 13 | for item in obj["@context"]: 14 | if isinstance(item, dict): 15 | self.add(**item) 16 | elif item != "https://www.w3.org/ns/activitystreams": 17 | self.context_list.append(item) 18 | 19 | del obj["@context"] 20 | 21 | return obj 22 | 23 | def build(self): 24 | if len(self.additional_contexts) == 0: 25 | if len(self.context_list) == 1: 26 | return self.context_list[0] 27 | return self.context_list 28 | 29 | return self.context_list + [self.additional_contexts] 30 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/common/test_context_builder.py: -------------------------------------------------------------------------------- 1 | from .context_builder import ContextBuilder 2 | 3 | 4 | def test_basic_context(): 5 | builder = ContextBuilder() 6 | result = builder.build() 7 | 8 | assert result == "https://www.w3.org/ns/activitystreams" 9 | 10 | 11 | def test_with_extensions(): 12 | builder = ContextBuilder().add( 13 | ostatus="http://ostatus.org#", atomUri="ostatus:atomUri" 14 | ) 15 | result = builder.build() 16 | 17 | assert result == [ 18 | "https://www.w3.org/ns/activitystreams", 19 | {"ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri"}, 20 | ] 21 | 22 | 23 | def test_with_additional_list_item(): 24 | builder = ContextBuilder().add("https://w3id.org/security/v1") 25 | 26 | result = builder.build() 27 | 28 | assert result == [ 29 | "https://www.w3.org/ns/activitystreams", 30 | "https://w3id.org/security/v1", 31 | ] 32 | 33 | 34 | def test_absorb(): 35 | obj = { 36 | "@context": ContextBuilder() 37 | .add( 38 | "https://w3id.org/security/v1", 39 | ostatus="http://ostatus.org#", 40 | atomUri="ostatus:atomUri", 41 | ) 42 | .build(), 43 | "prop": "value", 44 | } 45 | 46 | builder = ContextBuilder() 47 | obj = builder.absorb(obj) 48 | 49 | assert "@context" not in obj 50 | 51 | result = builder.build() 52 | 53 | assert result == [ 54 | "https://www.w3.org/ns/activitystreams", 55 | "https://w3id.org/security/v1", 56 | {"ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri"}, 57 | ] 58 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/objects/__init__.py: -------------------------------------------------------------------------------- 1 | from .note_builder import NoteBuilder 2 | from .tombstone_builder import TombstoneBuilder 3 | 4 | 5 | def build_note(account: str, url: str, content: str) -> NoteBuilder: 6 | return NoteBuilder(account, content, followers=f"{account}/followers") 7 | 8 | 9 | def tombstone(object_id): 10 | return TombstoneBuilder(object_id).build() 11 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/objects/note_builder.py: -------------------------------------------------------------------------------- 1 | from .object_builder import ObjectBuilder 2 | 3 | 4 | class NoteBuilder(ObjectBuilder): 5 | def __init__(self, account, content, followers=None): 6 | super().__init__("Note", account, followers=followers) 7 | 8 | self.with_content(content) 9 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/objects/test_tombstone_builder.py: -------------------------------------------------------------------------------- 1 | from . import tombstone 2 | 3 | 4 | def test_tomstone(): 5 | result = tombstone("object_id") 6 | assert result["type"] == "Tombstone" 7 | assert result["id"] == "object_id" 8 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/objects/tombstone_builder.py: -------------------------------------------------------------------------------- 1 | from bovine.activitystreams.common import build_context 2 | 3 | 4 | class TombstoneBuilder: 5 | def __init__(self, object_id): 6 | self.object_id = object_id 7 | 8 | def build(self): 9 | context = build_context().build() 10 | 11 | return { 12 | "@context": context, 13 | "type": "Tombstone", 14 | "id": self.object_id, 15 | } 16 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/ordered_collection_builder.py: -------------------------------------------------------------------------------- 1 | from .common import build_context 2 | 3 | 4 | class OrderedCollectionBuilder: 5 | def __init__(self, url: str): 6 | self.context_builder = build_context() 7 | self.url = url 8 | self.items: list | None = None 9 | self.count = 0 10 | self.first = None 11 | self.last = None 12 | 13 | def with_items(self, items: list): 14 | self.items = items 15 | return self 16 | 17 | def with_count(self, count: int): 18 | self.count = count 19 | return self 20 | 21 | def with_first_and_last(self, first, last): 22 | self.first = first 23 | self.last = last 24 | return self 25 | 26 | def build(self) -> dict: 27 | result = { 28 | "@context": self.context_builder.build(), 29 | "id": self.url, 30 | "totalItems": self.count, 31 | "type": "OrderedCollection", 32 | } 33 | 34 | if self.items: 35 | result["orderedItems"] = self.items 36 | 37 | if self.first: 38 | result["first"] = self.first 39 | 40 | if self.last: 41 | result["last"] = self.last 42 | 43 | return result 44 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/ordered_collection_page_builder.py: -------------------------------------------------------------------------------- 1 | from .common import build_context 2 | 3 | 4 | class OrderedCollectionPageBuilder: 5 | def __init__(self, url: str, part_of: str): 6 | self.context_builder = build_context() 7 | self.url = url 8 | self.items: list = [] 9 | self.part_of = part_of 10 | self.next: str | None = None 11 | self.prev: str | None = None 12 | 13 | def with_items(self, items: list): 14 | self.items = items 15 | return self 16 | 17 | def with_next(self, url): 18 | self.next = url 19 | return self 20 | 21 | def with_prev(self, url): 22 | self.prev = url 23 | return self 24 | 25 | def build(self) -> dict: 26 | result = { 27 | "@context": self.context_builder.build(), 28 | "id": self.url, 29 | "partOf": self.part_of, 30 | "orderedItems": self.items, 31 | "type": "OrderedCollectionPage", 32 | } 33 | 34 | if self.next: 35 | result["next"] = self.next 36 | 37 | if self.prev: 38 | result["prev"] = self.prev 39 | 40 | return result 41 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/test_actor_builder.py: -------------------------------------------------------------------------------- 1 | from bovine.types import Visibility 2 | 3 | from .actor_builder import ActorBuilder 4 | 5 | 6 | def test_basic_build(): 7 | result = ActorBuilder("name").build() 8 | 9 | assert result["@context"] == "https://www.w3.org/ns/activitystreams" 10 | assert result["name"] == "name" 11 | assert result["type"] == "Person" 12 | 13 | 14 | def test_account_urls(): 15 | result = ActorBuilder("name").with_account_url("account_url").build() 16 | 17 | assert result["id"] == "account_url" 18 | assert result["inbox"] == "account_url/inbox" 19 | assert result["outbox"] == "account_url/outbox" 20 | 21 | 22 | def test_public_key(): 23 | result = ( 24 | ActorBuilder("name") 25 | .with_account_url("account_url") 26 | .with_public_key("---key---") 27 | .build() 28 | ) 29 | 30 | assert isinstance(result["@context"], list) 31 | assert "https://www.w3.org/ns/activitystreams" in result["@context"] 32 | assert "https://w3id.org/security/v1" in result["@context"] 33 | 34 | assert result["publicKey"] == { 35 | "id": "account_url#main-key", 36 | "owner": "account_url", 37 | "publicKeyPem": "---key---", 38 | } 39 | 40 | 41 | def test_visibility_web(): 42 | result = ( 43 | ActorBuilder("name") 44 | .with_account_url("account_url") 45 | .with_public_key("---key---") 46 | .build(visibility=Visibility.WEB) 47 | ) 48 | 49 | assert isinstance(result["@context"], list) 50 | assert "https://www.w3.org/ns/activitystreams" in result["@context"] 51 | assert "https://w3id.org/security/v1" in result["@context"] 52 | 53 | assert "inbox" not in result 54 | 55 | assert result["publicKey"] == { 56 | "id": "account_url#main-key", 57 | "owner": "account_url", 58 | "publicKeyPem": "---key---", 59 | } 60 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/test_ordered_collection_builder.py: -------------------------------------------------------------------------------- 1 | from .ordered_collection_builder import OrderedCollectionBuilder 2 | 3 | 4 | def test_ordered_collection_builder(): 5 | result = OrderedCollectionBuilder("url").build() 6 | 7 | assert result == { 8 | "@context": "https://www.w3.org/ns/activitystreams", 9 | "id": "url", 10 | "totalItems": 0, 11 | "type": "OrderedCollection", 12 | } 13 | 14 | 15 | def test_ordered_collection_builder_with_items(): 16 | result = ( 17 | OrderedCollectionBuilder("url") 18 | .with_count(1) 19 | .with_items([{"item": "1"}]) 20 | .build() 21 | ) 22 | 23 | assert result == { 24 | "@context": "https://www.w3.org/ns/activitystreams", 25 | "id": "url", 26 | "orderedItems": [{"item": "1"}], 27 | "totalItems": 1, 28 | "type": "OrderedCollection", 29 | } 30 | 31 | 32 | def test_ordered_collection_builder_with_fist_last(): 33 | result = ( 34 | OrderedCollectionBuilder("url") 35 | .with_count(1) 36 | .with_first_and_last("first", "last") 37 | .build() 38 | ) 39 | 40 | assert result == { 41 | "@context": "https://www.w3.org/ns/activitystreams", 42 | "id": "url", 43 | "first": "first", 44 | "last": "last", 45 | "totalItems": 1, 46 | "type": "OrderedCollection", 47 | } 48 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/test_ordered_collection_page_builder.py: -------------------------------------------------------------------------------- 1 | from .ordered_collection_page_builder import OrderedCollectionPageBuilder 2 | 3 | 4 | def test_ordered_collection_page_builder(): 5 | result = ( 6 | OrderedCollectionPageBuilder("url?page=1", "url").with_items(["id1"]).build() 7 | ) 8 | 9 | assert result == { 10 | "@context": "https://www.w3.org/ns/activitystreams", 11 | "id": "url?page=1", 12 | "partOf": "url", 13 | "orderedItems": ["id1"], 14 | "type": "OrderedCollectionPage", 15 | } 16 | 17 | result = ( 18 | OrderedCollectionPageBuilder("url?page=1", "url") 19 | .with_items(["id1"]) 20 | .with_next("next_url") 21 | .with_prev("prev_url") 22 | .build() 23 | ) 24 | 25 | assert result == { 26 | "@context": "https://www.w3.org/ns/activitystreams", 27 | "id": "url?page=1", 28 | "next": "next_url", 29 | "prev": "prev_url", 30 | "partOf": "url", 31 | "orderedItems": ["id1"], 32 | "type": "OrderedCollectionPage", 33 | } 34 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/utils/__init__.py: -------------------------------------------------------------------------------- 1 | def actor_for_object(data): 2 | if "attributedTo" in data: 3 | actor = data.get("attributedTo") 4 | else: 5 | actor = data.get("actor") 6 | actor = id_for_object(actor) 7 | 8 | if actor: 9 | return actor 10 | 11 | return "__NO__ACTOR__" 12 | 13 | 14 | def id_for_object(data): 15 | if data is None: 16 | return None 17 | if isinstance(data, str): 18 | return data 19 | return data.get("id", None) 20 | 21 | 22 | def property_for_key_as_set(data, key): 23 | if data is None: 24 | return set() 25 | result = data.get(key, []) 26 | if isinstance(result, str): 27 | return set([result]) 28 | return set(result) 29 | 30 | 31 | def recipients_for_object(data): 32 | return set.union( 33 | *[ 34 | property_for_key_as_set(data, key) 35 | for key in ["to", "cc", "bto", "bcc", "audience"] 36 | ] 37 | ) 38 | 39 | 40 | def remove_public(recipients): 41 | return { 42 | x 43 | for x in recipients 44 | if x 45 | not in ["Public", "as:Public", "https://www.w3.org/ns/activitystreams#Public"] 46 | } 47 | 48 | 49 | def is_public(data): 50 | recipients = recipients_for_object(data) 51 | 52 | return any( 53 | x in recipients 54 | for x in ["Public", "as:Public", "https://www.w3.org/ns/activitystreams#Public"] 55 | ) 56 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/utils/print.py: -------------------------------------------------------------------------------- 1 | import bleach 2 | 3 | 4 | def print_activity(activity): 5 | if "type" in activity: 6 | print(f"Type: {activity['type']:10}, Id: {activity.get('id')}") 7 | print() 8 | 9 | if "object" in activity and isinstance(activity["object"], dict): 10 | obj = activity["object"] 11 | 12 | print_object(obj) 13 | 14 | 15 | def print_object(obj): 16 | if "type" in obj: 17 | print(f"Type: {obj['type']}") 18 | print() 19 | 20 | if "name" in obj and obj["name"]: 21 | print(f"Name: {obj['name']}") 22 | print() 23 | if "summary" in obj and obj["summary"]: 24 | print("Summary") 25 | print(bleach.clean(obj["summary"], tags=[], strip=True)) 26 | print() 27 | 28 | if "content" in obj and obj["content"]: 29 | print("Content") 30 | print(bleach.clean(obj["content"], tags=[], strip=True)) 31 | print() 32 | -------------------------------------------------------------------------------- /bovine/bovine/activitystreams/utils/test_utils.py: -------------------------------------------------------------------------------- 1 | from bovine.activitystreams.objects import build_note 2 | 3 | from . import actor_for_object, is_public, recipients_for_object 4 | 5 | 6 | def test_get_recipients(): 7 | note = ( 8 | build_note("account", "url", "content") 9 | .as_public() 10 | .add_cc("cc") 11 | .add_to("to") 12 | .add_cc("same") 13 | .add_to("same") 14 | .build() 15 | ) 16 | 17 | recipients = recipients_for_object(note) 18 | 19 | assert recipients == { 20 | "account/followers", 21 | "https://www.w3.org/ns/activitystreams#Public", 22 | "cc", 23 | "to", 24 | "same", 25 | } 26 | 27 | 28 | def test_is_public(): 29 | note = build_note("account", "url", "content").as_public().build() 30 | assert is_public(note) 31 | 32 | note = build_note("account", "url", "content").build() 33 | assert not is_public(note) 34 | 35 | note = build_note("account", "url", "content").add_cc("someone").build() 36 | assert not is_public(note) 37 | 38 | note = build_note("account", "url", "content").as_unlisted().build() 39 | assert is_public(note) 40 | 41 | note = build_note("account", "url", "content").add_to("as:Public").build() 42 | note["to"] = "as:Public" 43 | assert is_public(note) 44 | 45 | 46 | def test_actor_for_object(): 47 | assert actor_for_object({}) == "__NO__ACTOR__" 48 | assert actor_for_object({"actor": "alice"}) == "alice" 49 | assert actor_for_object({"actor": {"id": "alice"}}) == "alice" 50 | assert actor_for_object({"actor": {}}) == "__NO__ACTOR__" 51 | assert actor_for_object({"attributedTo": "alice"}) == "alice" 52 | assert actor_for_object({"attributedTo": {"id": "alice"}}) == "alice" 53 | assert actor_for_object({"attributedTo": {}}) == "__NO__ACTOR__" 54 | -------------------------------------------------------------------------------- /bovine/bovine/clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelgeKrueger/bovine/4ba2a83d1b4104ebffaaca357fbc9c225ffb06bf/bovine/bovine/clients/__init__.py -------------------------------------------------------------------------------- /bovine/bovine/clients/consts.py: -------------------------------------------------------------------------------- 1 | import bovine.version 2 | 3 | BOVINE_CLIENT_NAME = f"bovine/{bovine.version.__version__}" 4 | -------------------------------------------------------------------------------- /bovine/bovine/clients/event_source.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | 3 | from bovine.types import ServerSentEvent 4 | 5 | 6 | class EventSource: 7 | def __init__(self, session, url, headers={}): 8 | self.session = session 9 | self.url = url 10 | self.headers = headers 11 | self.response = None 12 | 13 | async def create_response(self): 14 | timeout = aiohttp.ClientTimeout(total=None) 15 | 16 | self.response = await self.session.get( 17 | self.url, 18 | headers={"Accept": "text/event-stream", **self.headers}, 19 | timeout=timeout, 20 | ) 21 | 22 | def __aiter__(self): 23 | return self 24 | 25 | async def __anext__(self): 26 | if self.response is None: 27 | await self.create_response() 28 | 29 | to_parse = "" 30 | async for line_in_bytes in self.response.content: 31 | line = line_in_bytes.decode("utf-8") 32 | if line[0] == ":": 33 | continue 34 | if line == "\n": 35 | event = ServerSentEvent.parse_utf8(to_parse) 36 | return event 37 | else: 38 | to_parse = f"{to_parse}{line}" 39 | -------------------------------------------------------------------------------- /bovine/bovine/clients/lookup_account.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | import aiohttp 5 | 6 | from bovine.utils.parse import parse_fediverse_handle 7 | 8 | from .consts import BOVINE_CLIENT_NAME 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | async def lookup_account_with_webfinger( 14 | session: aiohttp.ClientSession, fediverse_handle: str 15 | ) -> str | None: 16 | username, domain = parse_fediverse_handle(fediverse_handle) 17 | 18 | webfinger_url = f"https://{domain}/.well-known/webfinger" 19 | 20 | params = {"resource": f"acct:{username}@{domain}"} 21 | 22 | async with session.get( 23 | webfinger_url, params=params, headers={"user-agent": BOVINE_CLIENT_NAME} 24 | ) as response: 25 | if response.status != 200: 26 | logger.warn(f"{fediverse_handle} not found using webfinger") 27 | return None 28 | text = await response.text() 29 | data = json.loads(text) 30 | 31 | if "links" not in data: 32 | return None 33 | 34 | links = data["links"] 35 | for entry in links: 36 | if "rel" in entry and entry["rel"] == "self": 37 | return entry["href"] 38 | 39 | return None 40 | -------------------------------------------------------------------------------- /bovine/bovine/clients/nodeinfo.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import traceback 4 | 5 | import aiohttp 6 | 7 | from .consts import BOVINE_CLIENT_NAME 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | async def fetch_nodeinfo(session: aiohttp.ClientSession, domain: str) -> dict | None: 13 | try: 14 | wellknown_nodeinfo_url = f"https://{domain}/.well-known/nodeinfo" 15 | 16 | async with session.get( 17 | wellknown_nodeinfo_url, 18 | headers={"user-agent": BOVINE_CLIENT_NAME}, 19 | timeout=60, 20 | ) as response: 21 | text = await response.text() 22 | data = json.loads(text) 23 | 24 | for link in data["links"]: 25 | if link["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.0": 26 | return await fetch_nodeinfo20(session, link["href"]) 27 | 28 | return None 29 | 30 | except Exception as e: 31 | logger.error(str(e)) 32 | for log_line in traceback.format_exc().splitlines(): 33 | logger.error(log_line) 34 | return None 35 | 36 | 37 | async def fetch_nodeinfo20(session: aiohttp.ClientSession, url: str) -> dict | None: 38 | try: 39 | async with session.get( 40 | url, headers={"user-agent": BOVINE_CLIENT_NAME} 41 | ) as response: 42 | text = await response.text() 43 | return json.loads(text) 44 | 45 | except Exception as e: 46 | logger.error(str(e)) 47 | for log_line in traceback.format_exc().splitlines(): 48 | logger.error(log_line) 49 | return None 50 | -------------------------------------------------------------------------------- /bovine/bovine/clients/test_lookup_account.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | 3 | from .lookup_account import lookup_account_with_webfinger 4 | 5 | 6 | async def test_lookup_account(): 7 | async with aiohttp.ClientSession() as session: 8 | result = await lookup_account_with_webfinger(session, "helge@mymath.rocks") 9 | 10 | assert result.startswith("https://mymath.rocks/") 11 | -------------------------------------------------------------------------------- /bovine/bovine/clients/test_nodeinfo.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | 3 | from .nodeinfo import fetch_nodeinfo 4 | 5 | 6 | async def test_fetch_nodeinfo(): 7 | async with aiohttp.ClientSession() as session: 8 | result = await fetch_nodeinfo(session, "mymath.rocks") 9 | 10 | assert result["software"]["name"] == "bovine" 11 | assert "version" in result["software"] 12 | -------------------------------------------------------------------------------- /bovine/bovine/clients/test_signed_http.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock, MagicMock 2 | 3 | import aiohttp 4 | 5 | from bovine.utils.signature_checker import SignatureChecker 6 | from bovine.utils.test import get_user_keys 7 | 8 | from .signed_http import signed_get 9 | 10 | 11 | async def test_signed_get(): 12 | url = "https://test_domain/test_path" 13 | public_key_url = "public_key_url" 14 | public_key, private_key = get_user_keys() 15 | session = AsyncMock(aiohttp.ClientSession) 16 | session.get = AsyncMock() 17 | session.get.return_value = "value" 18 | 19 | key_retriever = AsyncMock() 20 | key_retriever.return_value = public_key 21 | signature_checker = SignatureChecker(key_retriever) 22 | 23 | response = await signed_get(session, public_key_url, private_key, url) 24 | 25 | session.get.assert_awaited_once() 26 | 27 | assert response == "value" 28 | 29 | args = session.get.await_args 30 | 31 | assert args[0] == (url,) 32 | assert "headers" in args[1] 33 | headers = args[1]["headers"] 34 | 35 | request = MagicMock() 36 | request.headers = headers 37 | request.method = "get" 38 | request.url = url 39 | 40 | assert await signature_checker.validate_signature(request) 41 | 42 | key_retriever.assert_awaited_once() 43 | assert key_retriever.await_args[0] == (public_key_url,) 44 | -------------------------------------------------------------------------------- /bovine/bovine/test_types.py: -------------------------------------------------------------------------------- 1 | from .types import ServerSentEvent 2 | 3 | 4 | def test_server_sent_event(): 5 | normalized_string = """data: {"json":true} 6 | event: outbox 7 | 8 | """.encode( 9 | "utf-8" 10 | ) 11 | 12 | sse = ServerSentEvent.parse(normalized_string) 13 | 14 | result = sse.encode() 15 | 16 | assert result == normalized_string 17 | -------------------------------------------------------------------------------- /bovine/bovine/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .crypto import generate_public_private_key 4 | from .http_signature import HttpSignature 5 | 6 | 7 | def build_signature(host, method, target): 8 | return ( 9 | HttpSignature() 10 | .with_field("(request-target)", f"{method} {target}") 11 | .with_field("host", host) 12 | ) 13 | 14 | 15 | def get_public_private_key_from_files(public_key_path, private_key_path): 16 | public_key = None 17 | private_key = None 18 | 19 | if os.path.exists(public_key_path): 20 | with open(public_key_path) as f: 21 | public_key = f.read() 22 | if os.path.exists(private_key_path): 23 | with open(private_key_path) as f: 24 | private_key = f.read() 25 | 26 | if public_key and private_key: 27 | return public_key, private_key 28 | 29 | public_key_pem, private_key_pem = generate_public_private_key() 30 | 31 | if not os.path.exists(".files"): 32 | os.mkdir(".files") 33 | 34 | with open(public_key_path, "w") as f: 35 | f.write(public_key_pem) 36 | 37 | with open(private_key_path, "w") as f: 38 | f.write(private_key_pem) 39 | 40 | return public_key_pem, private_key_pem 41 | -------------------------------------------------------------------------------- /bovine/bovine/utils/crypto/did_key.py: -------------------------------------------------------------------------------- 1 | from textwrap import wrap 2 | 3 | from cryptography.hazmat.primitives import serialization 4 | from cryptography.hazmat.primitives.asymmetric import ed25519 5 | from multiformats import multibase, multicodec 6 | 7 | 8 | def generate_keys(): 9 | private_key = ed25519.Ed25519PrivateKey.generate() 10 | public_key = private_key.public_key() 11 | 12 | return private_key, public_key 13 | 14 | 15 | def encode_private_key(private_key): 16 | private_bytes = private_key.private_bytes( 17 | encoding=serialization.Encoding.Raw, 18 | format=serialization.PrivateFormat.Raw, 19 | encryption_algorithm=serialization.NoEncryption(), 20 | ) 21 | 22 | wrapped = multicodec.wrap("ed25519-priv", private_bytes) 23 | 24 | return multibase.encode(wrapped, "base58btc") 25 | 26 | 27 | def encode_public_key(public_key): 28 | public_bytes = public_key.public_bytes( 29 | encoding=serialization.Encoding.Raw, 30 | format=serialization.PublicFormat.Raw, 31 | ) 32 | 33 | wrapped = multicodec.wrap("ed25519-pub", public_bytes) 34 | encoded = multibase.encode(wrapped, "base58btc") 35 | 36 | return encoded 37 | 38 | 39 | def public_key_to_did_key(public_key): 40 | return "did:key:" + encode_public_key(public_key) 41 | 42 | 43 | def private_key_to_secret(private_key): 44 | return "secret_:" + encode_private_key(private_key) 45 | 46 | 47 | def public_key_to_hey_key(public_key): 48 | encoded = encode_public_key(public_key) 49 | return "hey:key:" + " ".join(wrap(encoded, 4)) 50 | 51 | 52 | def private_key_to_hey_secret(private_key): 53 | encoded = encode_private_key(private_key) 54 | return "hey:sec:" + " ".join(wrap(encoded, 4)) 55 | 56 | 57 | def did_key_to_public_key(did): 58 | assert did.startswith("did:key:") 59 | decoded = multibase.decode(did[8:]) 60 | codec, key_bytes = multicodec.unwrap(decoded) 61 | assert codec.name == "ed25519-pub" 62 | 63 | return ed25519.Ed25519PublicKey.from_public_bytes(key_bytes) 64 | -------------------------------------------------------------------------------- /bovine/bovine/utils/crypto/test_crypto.py: -------------------------------------------------------------------------------- 1 | from bovine.utils.test import get_user_keys 2 | 3 | from . import ( 4 | content_digest_sha256, 5 | generate_public_private_key, 6 | sign_message, 7 | verify_signature, 8 | ) 9 | 10 | 11 | def test_crypto_sign_verify(): 12 | message = "secret" 13 | 14 | public_key, private_key = get_user_keys() 15 | 16 | signature = sign_message(private_key, message) 17 | 18 | assert verify_signature(public_key, message, signature) 19 | 20 | 21 | def test_crypto_sign_verify_failure(): 22 | message = "secret" 23 | 24 | public_key, private_key = get_user_keys() 25 | 26 | assert not verify_signature(public_key, message, "") 27 | 28 | 29 | def test_content_digest_sha256(): 30 | digest = content_digest_sha256("content") 31 | 32 | assert digest.startswith("sha-256=") 33 | 34 | 35 | def test_generate_public_private_key(): 36 | public_key, private_key = generate_public_private_key() 37 | 38 | assert public_key.startswith("-----BEGIN PUBLIC KEY-----") 39 | assert public_key.endswith("-----END PUBLIC KEY-----\n") 40 | 41 | assert private_key.startswith("-----BEGIN PRIVATE KEY-----") 42 | assert private_key.endswith("-----END PRIVATE KEY-----\n") 43 | -------------------------------------------------------------------------------- /bovine/bovine/utils/crypto/test_did_key.py: -------------------------------------------------------------------------------- 1 | from cryptography.hazmat.primitives.asymmetric import ed25519 2 | 3 | from .did_key import did_key_to_public_key, generate_keys, public_key_to_did_key 4 | 5 | did_example = "did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp" 6 | 7 | 8 | def test_encode_private_key(): 9 | _, public_key = generate_keys() 10 | did = public_key_to_did_key(public_key) 11 | 12 | # See https://w3c-ccg.github.io/did-method-key/#ed25519-x25519 13 | # These DID always start with z6Mk. 14 | 15 | assert did.startswith("did:key:z6Mk") 16 | assert len(did) == len(did_example) 17 | 18 | 19 | def test_did_to_public_key(): 20 | public_key = did_key_to_public_key(did_example) 21 | 22 | assert isinstance(public_key, ed25519.Ed25519PublicKey) 23 | 24 | 25 | def test_encode_private_key_and_back(): 26 | private_key, public_key = generate_keys() 27 | did = public_key_to_did_key(public_key) 28 | transformed = did_key_to_public_key(did) 29 | 30 | message = b"Hello did-core!" 31 | 32 | signature = private_key.sign(message) 33 | 34 | # If verification fails an error is thrown 35 | 36 | public_key.verify(signature, message) 37 | transformed.verify(signature, message) 38 | -------------------------------------------------------------------------------- /bovine/bovine/utils/date.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | 3 | from dateutil.parser import parse 4 | 5 | 6 | def get_gmt_now() -> str: 7 | return datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT") 8 | 9 | 10 | def parse_gmt(date_string: str) -> datetime: 11 | return parse(date_string) 12 | 13 | 14 | def check_max_offset_now(dt: datetime, minutes: int = 5) -> bool: 15 | now = datetime.now(tz=timezone.utc) 16 | 17 | if dt > now + timedelta(minutes=minutes): 18 | return False 19 | 20 | if dt < now - timedelta(minutes=minutes): 21 | return False 22 | 23 | return True 24 | -------------------------------------------------------------------------------- /bovine/bovine/utils/http_signature.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from cryptography.hazmat.primitives.asymmetric import ed25519 4 | from multiformats import multibase, multicodec 5 | 6 | from .crypto import sign_message, verify_signature 7 | from .crypto.did_key import did_key_to_public_key 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class HttpSignature: 13 | def __init__(self): 14 | self.fields = [] 15 | 16 | def build_signature(self, key_id, private_key): 17 | message = self.build_message() 18 | 19 | signature_string = sign_message(private_key, message) 20 | headers = " ".join(name for name, _ in self.fields) 21 | 22 | signature_parts = [ 23 | f'keyId="{key_id}"', 24 | 'algorithm="rsa-sha256"', # FIXME: Should other algorithms be supported? 25 | f'headers="{headers}"', 26 | f'signature="{signature_string}"', 27 | ] 28 | 29 | return ",".join(signature_parts) 30 | 31 | def ed25519_sign(self, private_encoded): 32 | private_bytes = multicodec.unwrap(multibase.decode(private_encoded))[1] 33 | private_key = ed25519.Ed25519PrivateKey.from_private_bytes(private_bytes) 34 | 35 | message = self.build_message() 36 | 37 | return multibase.encode(private_key.sign(message.encode("utf-8")), "base58btc") 38 | 39 | def ed25519_verify(self, didkey, signature): 40 | public_key = did_key_to_public_key(didkey) 41 | 42 | signature = multibase.decode(signature) 43 | 44 | message = self.build_message().encode("utf-8") 45 | 46 | return public_key.verify(signature, message) 47 | 48 | def verify(self, public_key, signature): 49 | message = self.build_message() 50 | return verify_signature(public_key, message, signature) 51 | 52 | def build_message(self): 53 | return "\n".join(f"{name}: {value}" for name, value in self.fields) 54 | 55 | def with_field(self, field_name, field_value): 56 | self.fields.append((field_name, field_value)) 57 | return self 58 | -------------------------------------------------------------------------------- /bovine/bovine/utils/parse.py: -------------------------------------------------------------------------------- 1 | def parse_fediverse_handle(account): 2 | if account[0] == "@": 3 | account = account[1:] 4 | 5 | if "@" in account: 6 | return tuple(account.split("@", 1)) 7 | return account, None 8 | -------------------------------------------------------------------------------- /bovine/bovine/utils/signature_parser.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger(__name__) 4 | 5 | 6 | class Signature: 7 | def __init__(self, key_id, algorithm, headers, signature): 8 | self.key_id = key_id 9 | self.algorithm = algorithm 10 | self.headers = headers 11 | self.signature = signature 12 | 13 | if self.algorithm not in ["rsa-sha256", "hs2019"]: 14 | logger.error(f"Unsupported algorithm {self.algorithm}") 15 | logger.error(self.signature) 16 | logger.error(self.headers) 17 | logger.error(self.key_id) 18 | 19 | raise Exception(f"Unsupported algorithm {self.algorithm}") 20 | 21 | def fields(self): 22 | return self.headers.split(" ") 23 | 24 | @staticmethod 25 | def from_signature_header(header): 26 | try: 27 | headers = header.split(",") 28 | headers = [x.split('="', 1) for x in headers] 29 | parsed = {x[0]: x[1].replace('"', "") for x in headers} 30 | 31 | return Signature( 32 | parsed["keyId"], 33 | parsed.get("algorithm", "rsa-sha256"), 34 | parsed["headers"], 35 | parsed["signature"], 36 | ) 37 | except Exception: 38 | logger.error(f"failed to parse signature {header}") 39 | 40 | 41 | def parse_signature_header(header): 42 | return Signature.from_signature_header(header) 43 | -------------------------------------------------------------------------------- /bovine/bovine/utils/test.py: -------------------------------------------------------------------------------- 1 | from . import get_public_private_key_from_files 2 | 3 | 4 | def get_user_keys(): 5 | return get_public_private_key_from_files( 6 | ".files/public_key.pem", ".files/private_key.pem" 7 | ) 8 | -------------------------------------------------------------------------------- /bovine/bovine/utils/test_date.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | 3 | from .date import check_max_offset_now, get_gmt_now, parse_gmt 4 | 5 | 6 | def test_get_gmt_now(): 7 | date_string = get_gmt_now() 8 | 9 | assert "GMT" in date_string 10 | 11 | 12 | def test_parse_gmt(): 13 | date_string = get_gmt_now() 14 | 15 | parsed = parse_gmt(date_string) 16 | now = datetime.now(tz=timezone.utc) 17 | 18 | assert parsed <= now 19 | assert parsed >= now - timedelta(seconds=5) 20 | 21 | 22 | def test_check_max_offset_now(): 23 | now = datetime.now(tz=timezone.utc) 24 | 25 | assert check_max_offset_now(now - timedelta(minutes=4)) 26 | assert check_max_offset_now(now + timedelta(minutes=4)) 27 | assert not check_max_offset_now(now - timedelta(minutes=6)) 28 | assert not check_max_offset_now(now + timedelta(minutes=6)) 29 | -------------------------------------------------------------------------------- /bovine/bovine/utils/test_parse.py: -------------------------------------------------------------------------------- 1 | from .parse import parse_fediverse_handle 2 | 3 | 4 | def test_parse_fediverse_handle(): 5 | assert parse_fediverse_handle("account") == ("account", None) 6 | assert parse_fediverse_handle("account@domain") == ("account", "domain") 7 | 8 | assert parse_fediverse_handle("account@domain@@@") == ("account", "domain@@@") 9 | assert parse_fediverse_handle("@account@domain@@@") == ("account", "domain@@@") 10 | assert parse_fediverse_handle("@account") == ("account", None) 11 | -------------------------------------------------------------------------------- /bovine/bovine/utils/test_signature_parser.py: -------------------------------------------------------------------------------- 1 | from .signature_parser import Signature 2 | 3 | 4 | def test_signature_header_parsing(): 5 | header_string = 'keyId="https://host.user#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="h...Kg=="' # noqa E501 6 | 7 | result = Signature.from_signature_header(header_string) 8 | 9 | assert result.key_id == "https://host.user#main-key" 10 | assert result.algorithm == "rsa-sha256" 11 | assert result.headers == "(request-target) host date digest content-type" 12 | assert result.signature == "h...Kg==" 13 | assert result.fields() == [ 14 | "(request-target)", 15 | "host", 16 | "date", 17 | "digest", 18 | "content-type", 19 | ] 20 | 21 | 22 | def test_signature_header_without_algorithm(): 23 | header_string = 'keyId="https://host.user#main-key",headers="(request-target) host date",signature="stuff="' # noqa E501 24 | 25 | result = Signature.from_signature_header(header_string) 26 | 27 | assert result.key_id == "https://host.user#main-key" 28 | assert result.algorithm == "rsa-sha256" 29 | assert result.headers == "(request-target) host date" 30 | assert result.signature == "stuff=" 31 | assert result.fields() == [ 32 | "(request-target)", 33 | "host", 34 | "date", 35 | ] 36 | -------------------------------------------------------------------------------- /bovine/bovine/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.6" 2 | -------------------------------------------------------------------------------- /bovine/examples/mastodon_bridge.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiohttp 4 | 5 | from bovine.clients.activity_pub import ActivityPubClient 6 | 7 | 8 | async def sse(client): 9 | async with aiohttp.ClientSession() as session: 10 | client = client.set_session(session) 11 | event_source = client.server_sent_events() 12 | 13 | async for event in event_source: 14 | await client.post("http://mastodon.local/users/admin/inbox", event.data) 15 | 16 | 17 | with open("helge.toml", "rb") as f: 18 | client = ActivityPubClient.from_toml_file(f) 19 | 20 | asyncio.run(sse(client)) 21 | -------------------------------------------------------------------------------- /bovine/examples/oauth.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | from http.server import BaseHTTPRequestHandler, HTTPServer 4 | from urllib.parse import urlencode 5 | 6 | host_name = "127.0.0.1" 7 | server_port = 8080 8 | 9 | 10 | class MyServer(BaseHTTPRequestHandler): 11 | path = None 12 | 13 | def do_POST(self): 14 | self.send_response(200) 15 | self.send_header("Content-type", "text/html") 16 | self.end_headers() 17 | self.wfile.write(bytes("All Done", "utf-8")) 18 | 19 | content_len = int(self.headers.get("Content-Length")) 20 | post_body = self.rfile.read(content_len) 21 | 22 | MyServer.path = post_body 23 | 24 | 25 | if __name__ == "__main__": 26 | url_to_open = "https://wallet.hello.coop/authorize?" + urlencode( 27 | { 28 | "client_id": "XXX", 29 | "nonce": "xxx", 30 | "redirect_uri": f"http://{host_name}:{server_port}", 31 | "response_mode": "form_post", 32 | "response_type": "id_token", 33 | "scope": "openid+email", 34 | } 35 | ).replace("%2B", "+") 36 | 37 | print(url_to_open) 38 | 39 | web_server = HTTPServer((host_name, server_port), MyServer) 40 | try: 41 | web_server.handle_request() 42 | except KeyboardInterrupt: 43 | pass 44 | 45 | id_token = MyServer.path.decode("utf-8").split("=")[1] 46 | token = id_token.split(".")[1] 47 | result = base64.urlsafe_b64decode(token + "=" * divmod(len(token), 4)[1]).decode( 48 | "utf-8" 49 | ) 50 | 51 | print(json.dumps(json.loads(result), indent=2)) 52 | 53 | import IPython 54 | 55 | IPython.embed() 56 | 57 | web_server.server_close() 58 | print("Server stopped.") 59 | -------------------------------------------------------------------------------- /bovine/examples/repl.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from argparse import ArgumentParser 3 | 4 | from ptpython.repl import embed 5 | 6 | from bovine import BovineActor 7 | 8 | loop = asyncio.get_event_loop() 9 | 10 | 11 | def config(repl): 12 | repl.use_code_colorscheme("dracula") 13 | repl.enable_output_formatting = True 14 | 15 | 16 | async def repl(config_file): 17 | async with BovineActor(config_file) as actor: 18 | activity_factory, object_factory = actor.factories 19 | inbox = await actor.inbox() 20 | await embed( 21 | globals=globals(), 22 | locals=locals(), 23 | return_asyncio_coroutine=True, 24 | patch_stdout=True, 25 | configure=config, 26 | ) 27 | 28 | 29 | parser = ArgumentParser() 30 | parser.add_argument("--config_file", default="h.toml") 31 | 32 | args = parser.parse_args() 33 | 34 | asyncio.run(repl(args.config_file)) 35 | -------------------------------------------------------------------------------- /bovine/examples/send_accept.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from argparse import ArgumentParser 4 | 5 | import aiohttp 6 | 7 | from bovine.activitypub import actor_from_file 8 | from bovine.activitystreams.activities import build_accept 9 | 10 | 11 | async def send_accept(config_file, target): 12 | async with aiohttp.ClientSession() as session: 13 | actor = actor_from_file(config_file, session) 14 | 15 | await actor.load() 16 | response = await actor.proxy_element(target) 17 | response = json.loads(await response.text()) 18 | 19 | accept = build_accept(actor.actor_id, response).build() 20 | 21 | await actor.send_to_outbox(accept) 22 | 23 | 24 | parser = ArgumentParser() 25 | parser.add_argument("target") 26 | parser.add_argument("--config", default="h.toml") 27 | 28 | args = parser.parse_args() 29 | 30 | asyncio.run(send_accept(args.config, args.target)) 31 | -------------------------------------------------------------------------------- /bovine/examples/send_delete_user.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from argparse import ArgumentParser 4 | 5 | import aiohttp 6 | 7 | from bovine.clients.activity_pub import ActivityPubClient 8 | 9 | 10 | async def send_delete(client, target): 11 | async with aiohttp.ClientSession() as session: 12 | client = client.set_session(session) 13 | result = await client.get(f"https://{target}/actor") 14 | data = json.loads(await result.text()) 15 | 16 | shared_inbox = data["endpoints"]["sharedInbox"] 17 | 18 | account = "https://mymath.rocks/activitypub/test" 19 | 20 | delete = { 21 | "@context": "https://www.w3.org/ns/activitystreams", 22 | "id": f"{account}#delete", 23 | "type": "Delete", 24 | "actor": account, 25 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 26 | "object": account, 27 | } 28 | 29 | response = await client.post(shared_inbox, json.dumps(delete)) 30 | print(shared_inbox, response.status) 31 | 32 | 33 | parser = ArgumentParser() 34 | parser.add_argument("--config", default="munchingcow.toml") 35 | args = parser.parse_args() 36 | 37 | with open(args.config, "rb") as f: 38 | client = ActivityPubClient.from_toml_file(f) 39 | 40 | with open("server_list") as f: 41 | for line in f: 42 | try: 43 | asyncio.run(send_delete(client, line.strip())) 44 | except Exception as e: 45 | print(e) 46 | pass 47 | -------------------------------------------------------------------------------- /bovine/examples/send_like.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | from argparse import ArgumentParser 4 | 5 | import aiohttp 6 | 7 | from bovine.activitypub import actor_from_file 8 | from bovine.activitystreams.activities import build_like 9 | 10 | 11 | async def send_like(config_file, target): 12 | async with aiohttp.ClientSession() as session: 13 | actor = actor_from_file(config_file, session) 14 | response = await actor.get(target) 15 | author = response["attributedTo"] 16 | 17 | # 18 | # Most implementations just support a single like, so we are 19 | # sending a random cow flavored emoji instead of all of them 20 | # 21 | # If this makes you sad, you should lobby UI implementations 22 | # to display multiple emotes from the same person. 23 | # 24 | 25 | emote_to_send = random.choice("🐮🐄🦬🐂🌱🥩🥛🧀🍼") 26 | 27 | like = ( 28 | build_like(actor.actor_id, target) 29 | .with_content(emote_to_send) 30 | .add_to(author) 31 | .build() 32 | ) 33 | 34 | await actor.send_to_outbox(like) 35 | 36 | 37 | parser = ArgumentParser() 38 | parser.add_argument("target") 39 | 40 | args = parser.parse_args() 41 | 42 | asyncio.run(send_like("helge.toml", args.target)) 43 | -------------------------------------------------------------------------------- /bovine/examples/send_note.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from argparse import ArgumentParser 3 | 4 | from bovine import BovineActor 5 | 6 | 7 | async def send_note(config_file, text): 8 | async with BovineActor(config_file) as actor: 9 | note = actor.note(text).add_to("https://mas.to/users/themilkman").build() 10 | create = actor.activity_factory.create(note).build() 11 | 12 | await actor.send_to_outbox(create) 13 | 14 | 15 | parser = ArgumentParser() 16 | parser.add_argument("text") 17 | parser.add_argument("--config_file", default="h.toml") 18 | 19 | args = parser.parse_args() 20 | 21 | asyncio.run(send_note(args.config_file, args.text)) 22 | -------------------------------------------------------------------------------- /bovine/examples/send_unfollow.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from argparse import ArgumentParser 3 | 4 | import aiohttp 5 | 6 | from bovine.activitypub import actor_from_file 7 | from bovine.activitystreams.activities import build_follow, build_undo 8 | 9 | print("This does not seem to work") 10 | exit(1) 11 | 12 | 13 | async def send_unfollow(config_file, target): 14 | async with aiohttp.ClientSession() as session: 15 | actor = actor_from_file(config_file, session) 16 | 17 | await actor.load() 18 | 19 | follow = build_follow("", actor.information["id"], target).build() 20 | unfollow = build_undo(follow).build() 21 | # print(json.dumps(unfollow, indent=2)) 22 | 23 | await actor.send_to_outbox(unfollow) 24 | 25 | 26 | parser = ArgumentParser() 27 | parser.add_argument("target") 28 | parser.add_argument("--config", default="h.toml") 29 | 30 | args = parser.parse_args() 31 | 32 | asyncio.run(send_unfollow(args.config, args.target)) 33 | -------------------------------------------------------------------------------- /bovine/examples/sse.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from argparse import ArgumentParser 5 | from datetime import datetime 6 | 7 | import aiohttp 8 | import bleach 9 | 10 | from bovine.activitypub.actor import ActivityPubActor 11 | 12 | logging.basicConfig(level=logging.DEBUG) 13 | 14 | 15 | async def sse(config_file): 16 | async with aiohttp.ClientSession() as session: 17 | actor = ActivityPubActor.from_file(config_file, session) 18 | event_source = await actor.event_source() 19 | async for event in event_source: 20 | if event is None: 21 | return 22 | # print(event) 23 | if event and event.data: 24 | data = json.loads(event.data) 25 | 26 | if "object" in data and "content" in data["object"]: 27 | print( 28 | datetime.now().isoformat() 29 | + " " 30 | + data["object"]["attributedTo"] 31 | ) 32 | print(bleach.clean(data["object"]["content"], tags=[], strip=True)) 33 | print() 34 | else: 35 | print(json.dumps(data, indent=2)) 36 | 37 | 38 | parser = ArgumentParser() 39 | parser.add_argument("--config", default="h.toml") 40 | 41 | args = parser.parse_args() 42 | 43 | asyncio.run(sse(args.config)) 44 | -------------------------------------------------------------------------------- /bovine/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "bovine" 3 | version = "0.0.5" 4 | description = "Core functionality of bovine needed to build client to server applications" 5 | authors = ["Helge "] 6 | license = "../LICENSE" 7 | readme = "README.md" 8 | packages = [{include = "bovine"}] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.10" 12 | aiohttp = "^3.8.3" 13 | cryptography = "^39.0.0" 14 | python-dateutil = "^2.8.2" 15 | tomli = "^2.0.1" 16 | multiformats = "^0.2.1" 17 | ptpython = "^3.0.23" 18 | bleach = "^6.0.0" 19 | 20 | [tool.poetry.group.test.dependencies] 21 | black = "*" 22 | pytest = "*" 23 | pytest-asyncio = "*" 24 | flake8 = "*" 25 | flake8-black = "*" 26 | 27 | [tool.poetry.group.dev.dependencies] 28 | isort = "^5.12.0" 29 | pytest-cov = "^4.0.0" 30 | pytest-asyncio = "^0.20.3" 31 | mypy = "^0.991" 32 | types-python-dateutil = "^2.8.19.6" 33 | rdflib = "^6.2.0" 34 | pyld = "^2.0.3" 35 | requests = "^2.28.2" 36 | rich = "^13.3.1" 37 | jupyter = "^1.0.0" 38 | 39 | [build-system] 40 | requires = ["poetry-core"] 41 | build-backend = "poetry.core.masonry.api" 42 | 43 | [tool.pytest.ini_options] 44 | asyncio_mode="auto" 45 | log_cli= 1 46 | log_cli_level="debug" 47 | -------------------------------------------------------------------------------- /bovine/scripts/generate_heykey.py: -------------------------------------------------------------------------------- 1 | from bovine.utils.crypto.did_key import ( 2 | generate_keys, 3 | private_key_to_hey_secret, 4 | private_key_to_secret, 5 | public_key_to_did_key, 6 | public_key_to_hey_key, 7 | ) 8 | 9 | if __name__ == "__main__": 10 | print() 11 | print("Generating hey keys") 12 | 13 | private_key, public_key = generate_keys() 14 | 15 | print() 16 | print() 17 | print("Formats to copy and paste:") 18 | print("Did Format of Public Key: ", public_key_to_did_key(public_key)) 19 | print("Secret format of private key: ", private_key_to_secret(private_key)) 20 | 21 | print() 22 | print("Formats to write down (the hey secret is enough)") 23 | print() 24 | print("Hey Key: ", public_key_to_hey_key(public_key)) 25 | print("Hey Secret: ", private_key_to_hey_secret(private_key)) 26 | print() 27 | -------------------------------------------------------------------------------- /bovine/setup.cfg: -------------------------------------------------------------------------------- 1 | 2 | [flake8] 3 | max-line-length = 88 4 | extend-ignore = E203, 5 | -------------------------------------------------------------------------------- /bovine_config.toml.example: -------------------------------------------------------------------------------- 1 | [bovine] 2 | 3 | host = "http://localhost:5000" 4 | secret_key = "some non weak key" 5 | 6 | [bovine_user] 7 | 8 | hello_client_id = "your uuid" -------------------------------------------------------------------------------- /bovine_fedi/FEDERATION.md: -------------------------------------------------------------------------------- 1 | # Federation 2 | 3 | This document is meant to be a reference for all the ActivityPub 4 | federation-related behavior that bovine has. `bovine_blog` 5 | is the only part of this project that is currently meant to 6 | federate. 7 | 8 | - Bovine is meant to modular. This also means that it has no specific 9 | federation behavior, except trying to implement ActivityPub as best as 10 | possible. 11 | - There is one notable exception: HTTP Signatures. Bovine assumes all 12 | requests to be signed, except to get the `bovine` Actor object. The 13 | `bovine` Actor is used to fetch public keys. 14 | - `bovine_blog` actually contains an implementation of federation behavior 15 | in [processors.py](bovine_blog/processors.py). My goal is to make 16 | this file human readable. 17 | - Activities are passed to the client by default through Client to Server. 18 | -------------------------------------------------------------------------------- /bovine_fedi/README.md: -------------------------------------------------------------------------------- 1 | # bovine_fedi 2 | 3 | `bovine_fedi` is a `bovine` powered ActivityPub server, which interoperates with the rest of the FediVerse. 4 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_config.toml: -------------------------------------------------------------------------------- 1 | ../bovine_config.toml -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/build_store.py: -------------------------------------------------------------------------------- 1 | from bovine_user.types import EndpointType 2 | from quart import current_app 3 | 4 | from bovine_fedi.types import LocalActor 5 | from bovine_fedi.utils import get_bovine_user 6 | from bovine_fedi.utils.in_memory_store import InMemoryUserStore 7 | 8 | 9 | class Chain: 10 | def __init__(self, *coroutines): 11 | self.coroutines = coroutines 12 | 13 | async def execute(self, username): 14 | for coro in self.coroutines: 15 | result = await coro(username) 16 | if result is not None: 17 | return result 18 | 19 | return None 20 | 21 | 22 | async def get_user_from_bovine_user_manager(name): 23 | manager = current_app.config["bovine_user_manager"] 24 | 25 | user = await manager.get_user_for_name(name) 26 | 27 | if user is None: 28 | return 29 | 30 | endpoints = [x for x in user.endpoints if x.endpoint_type == EndpointType.ACTOR] 31 | keypair = user.keypairs[0] 32 | 33 | return LocalActor( 34 | user.handle_name, 35 | endpoints[0].name, 36 | keypair.public_key, 37 | keypair.private_key, 38 | "Tombstone", 39 | ) 40 | 41 | 42 | def build_get_user(domain: str): 43 | bovine_user = get_bovine_user(domain) 44 | 45 | bovine_store = InMemoryUserStore() 46 | bovine_store.add_user(bovine_user) 47 | 48 | return ( 49 | bovine_user, 50 | Chain( 51 | bovine_store.get_user, 52 | get_user_from_bovine_user_manager, 53 | ).execute, 54 | ) 55 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/caches/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | import aiohttp 5 | import bovine.clients.signed_http 6 | 7 | from bovine_fedi.types import LocalActor 8 | 9 | from .public_key import PublicKeyCache 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def build_public_key_fetcher(session: aiohttp.ClientSession, bovine_user: LocalActor): 15 | def get_public_key_wrapper(key_id): 16 | return get_public_key(session, bovine_user, key_id) 17 | 18 | cache = PublicKeyCache(get_public_key_wrapper) 19 | 20 | return cache.get_for_url 21 | 22 | 23 | async def get_public_key( 24 | session: aiohttp.ClientSession, local_actor: LocalActor, key_id: str 25 | ) -> str | None: 26 | logger.info(f"getting public key for {key_id}") 27 | 28 | response = await bovine.clients.signed_http.signed_get( 29 | session, local_actor.get_public_key_url(), local_actor.private_key, key_id 30 | ) 31 | text = await response.text() 32 | 33 | try: 34 | data = json.loads(text) 35 | except Exception: 36 | logger.warning(f"Failed to decode json from {text}") 37 | return None 38 | 39 | if "publicKey" not in data: 40 | logger.warning(f"Public key not found in data for {key_id}") 41 | return None 42 | 43 | key_data = data["publicKey"] 44 | 45 | if key_data["id"] != key_id: 46 | logger.warning(f"Public key id mismatches for {key_id}") 47 | return None 48 | 49 | return key_data["publicKeyPem"] 50 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/caches/public_key.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from bovine_fedi.models import PublicKeyPK as PublicKey 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class PublicKeyCache: 9 | def __init__(self, key_fetcher): 10 | self.key_fetcher = key_fetcher 11 | 12 | async def get_for_url(self, url: str) -> str | None: 13 | item = await PublicKey.get_or_none(url=url) 14 | 15 | if item: 16 | return item.public_key 17 | 18 | logger.info(f"Fetching public key for {url}") 19 | public_key = await self.key_fetcher(url) 20 | 21 | if public_key is None: 22 | logger.warning(f"Fetching public key for {url} failed") 23 | return None 24 | 25 | try: 26 | await PublicKey.create(url=url, public_key=public_key) 27 | except Exception as ex: 28 | logger.info( 29 | f"Tried to create public key for {url} but failed with {str(ex)}" 30 | ) 31 | 32 | return public_key 33 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/caches/test_public_key.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | from bovine_fedi.models import PublicKeyPK as PublicKey 4 | from bovine_fedi.utils.test import db_url # noqa: F401 5 | 6 | from .public_key import PublicKeyCache 7 | 8 | 9 | async def test_public_key(db_url): # noqa F811 10 | key_fetcher = AsyncMock() 11 | key_fetcher.return_value = "---key---" 12 | url = "proto://domain/path#id" 13 | 14 | public_key_cache = PublicKeyCache(key_fetcher) 15 | public_key = await public_key_cache.get_for_url(url) 16 | 17 | assert public_key == "---key---" 18 | 19 | key_fetcher.assert_awaited_once() 20 | 21 | item = await PublicKey.get(url=url) 22 | 23 | assert item.public_key == "---key---" 24 | 25 | 26 | async def test_public_key_handles_return_null(db_url): # noqa F811 27 | key_fetcher = AsyncMock() 28 | key_fetcher.return_value = None 29 | url = "proto://domain/path#id" 30 | 31 | public_key_cache = PublicKeyCache(key_fetcher) 32 | public_key = await public_key_cache.get_for_url(url) 33 | 34 | assert public_key is None 35 | key_fetcher.assert_awaited_once() 36 | 37 | assert await PublicKey.filter().count() == 0 38 | 39 | 40 | async def test_public_key_only_fetches_once(db_url): # noqa F811 41 | key_fetcher = AsyncMock() 42 | key_fetcher.return_value = "---key---" 43 | url = "proto://domain/path#id" 44 | 45 | public_key_cache = PublicKeyCache(key_fetcher) 46 | public_key = await public_key_cache.get_for_url(url) 47 | assert public_key == "---key---" 48 | 49 | public_key = await public_key_cache.get_for_url(url) 50 | assert public_key == "---key---" 51 | 52 | key_fetcher.assert_awaited_once() 53 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/config.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | from bovine.utils.signature_checker import SignatureChecker 3 | 4 | from .caches import build_public_key_fetcher 5 | from .utils.queue_manager import QueueManager 6 | 7 | TORTOISE_ORM = { 8 | "connections": {"default": "sqlite://db.sqlite3"}, 9 | "apps": { 10 | "models": { 11 | "models": [ 12 | "bovine_fedi.models", 13 | "bovine_store.models", 14 | "bovine_user.models", 15 | "aerich.models", 16 | ], 17 | "default_connection": "default", 18 | }, 19 | }, 20 | } 21 | 22 | 23 | async def configure_bovine_fedi(app, bovine_user): 24 | session = aiohttp.ClientSession() 25 | public_key_fetcher = build_public_key_fetcher(session, bovine_user) 26 | signature_checker = SignatureChecker(public_key_fetcher) 27 | 28 | app.config["session"] = session 29 | app.config["validate_signature"] = signature_checker.validate_signature 30 | 31 | app.config["queue_manager"] = QueueManager() 32 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/models.py: -------------------------------------------------------------------------------- 1 | from tortoise import fields 2 | from tortoise.models import Model 3 | 4 | from .utils.peer_type import PeerType 5 | 6 | 7 | class Peer(Model): 8 | id = fields.IntField(pk=True) 9 | 10 | domain = fields.CharField(max_length=255) 11 | peer_type = fields.CharEnumField(PeerType, default=PeerType.NEW) 12 | software = fields.CharField(max_length=255, null=True) 13 | version = fields.CharField(max_length=255, null=True) 14 | 15 | created = fields.DatetimeField(auto_now_add=True) 16 | updated = fields.DatetimeField(null=True) 17 | 18 | 19 | class PublicKey(Model): 20 | id = fields.IntField(pk=True) 21 | url = fields.CharField(max_length=255, unique=True) 22 | public_key = fields.TextField() 23 | 24 | 25 | class PublicKeyPK(Model): 26 | url = fields.CharField(max_length=255, pk=True) 27 | public_key = fields.TextField() 28 | 29 | 30 | class StoredObject(Model): 31 | id = fields.IntField(pk=True) 32 | name = fields.CharField(max_length=255, unique=True) 33 | data = fields.BinaryField() 34 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/server/__init__.py: -------------------------------------------------------------------------------- 1 | from quart import Blueprint 2 | 3 | from .activitypub import activitypub 4 | from .info import info 5 | from .wellknown import wellknown 6 | 7 | default_configuration = Blueprint("default_configuration", __name__) 8 | default_configuration.register_blueprint(wellknown) 9 | default_configuration.register_blueprint(info) 10 | default_configuration.register_blueprint(activitypub) 11 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/server/activitypub.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import werkzeug 4 | from bovine.activitystreams import build_actor, build_ordered_collection 5 | from bovine.types import Visibility 6 | from quart import Blueprint, current_app 7 | 8 | activitypub = Blueprint("activitypub", __name__, url_prefix="/activitypub") 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @activitypub.get("/") 14 | async def userinfo(account_name: str) -> tuple[dict, int] | werkzeug.Response: 15 | user_info = await current_app.config["get_user"](account_name) 16 | 17 | if not user_info and account_name != "bovine": 18 | return {"status": "not found"}, 404 19 | 20 | domain = current_app.config["DOMAIN"] 21 | activitypub_profile_url = f"https://{domain}/activitypub/{user_info.name}" 22 | 23 | visibility = Visibility.WEB 24 | 25 | return ( 26 | ( 27 | build_actor(account_name, actor_type=user_info.actor_type) 28 | .with_account_url(activitypub_profile_url) 29 | .with_public_key(user_info.public_key) 30 | .build(visibility=visibility) 31 | ), 32 | 200, 33 | {"content-type": "application/activity+json"}, 34 | ) 35 | 36 | 37 | @activitypub.post("//inbox") 38 | async def inbox(account_name: str) -> tuple[dict, int]: 39 | return {"status": "not implemented"}, 501 40 | 41 | 42 | @activitypub.get("//outbox") 43 | async def outbox(account_name: str) -> tuple[dict, int] | werkzeug.Response: 44 | local_actor = await current_app.config["get_user"](account_name) 45 | return build_ordered_collection(local_actor.get_outbox()).with_count(0).build(), 200 46 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/server/authorization.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | 4 | from bovine.utils.crypto import content_digest_sha256 5 | from quart import current_app, g, request 6 | from quart_auth import current_user 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def is_get(): 12 | return re.match(r"^get$", request.method, re.IGNORECASE) 13 | 14 | 15 | def is_post(): 16 | return re.match(r"^post$", request.method, re.IGNORECASE) 17 | 18 | 19 | async def compute_signature_result() -> str | None: 20 | if request.method.lower() == "get": 21 | return await current_app.config["validate_signature"](request, digest=None) 22 | 23 | if is_post(): 24 | raw_data = await request.get_data() 25 | digest = content_digest_sha256(raw_data) 26 | return await current_app.config["validate_signature"](request, digest=digest) 27 | 28 | return None 29 | 30 | 31 | async def add_authorization(): 32 | g.signature_result = await compute_signature_result() 33 | 34 | if g.signature_result: 35 | g.retriever = g.signature_result.split("#")[0] 36 | elif await current_user.is_authenticated: 37 | manager = current_app.config["bovine_user_manager"] 38 | _, actor = await manager.get_activity_pub(current_user.auth_id) 39 | if actor: 40 | g.retriever = actor.build()["id"] 41 | else: 42 | g.retriever = "NONE" 43 | logger.warning("Unknown auth id %s", current_user.auth_id) 44 | else: 45 | g.retriever = "NONE" 46 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/server/info.py: -------------------------------------------------------------------------------- 1 | from quart import Blueprint, current_app 2 | 3 | from bovine_fedi.version import __version__ 4 | 5 | info = Blueprint("info", __name__, url_prefix="/info") 6 | 7 | 8 | @info.get("/nodeinfo2_0") 9 | async def nodeinfo() -> dict: 10 | user_count = 0 11 | 12 | if "bovine_user_manager" in current_app.config: 13 | user_manager = current_app.config["bovine_user_manager"] 14 | if user_manager: 15 | user_count = await user_manager.user_count() 16 | 17 | user_stat = { 18 | "total": user_count, 19 | "activeMonth": user_count, 20 | "activeHalfyear": user_count, 21 | } 22 | 23 | return { 24 | "metadata": {}, 25 | "openRegistrations": False, 26 | "protocols": ["activitypub"], 27 | "services": {"inbound": [], "outbound": []}, 28 | "software": {"name": "bovine", "version": __version__}, 29 | "usage": {"users": user_stat}, 30 | "version": "2.0", 31 | } 32 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from quart import Blueprint, Response, current_app 2 | 3 | storage_blueprint = Blueprint("storage_blueprint", __name__, url_prefix="/storage") 4 | 5 | 6 | @storage_blueprint.get("/") 7 | async def get_from_storage(name): 8 | try: 9 | obj = await current_app.config["object_storage"].get_object(name) 10 | 11 | except Exception as e: 12 | print(e) 13 | 14 | if obj is None: 15 | return {"status": "not found"}, 404 16 | 17 | return Response(obj.data, mimetype="image/png") 18 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/storage/storage.py: -------------------------------------------------------------------------------- 1 | from bovine_fedi.models import StoredObject 2 | 3 | 4 | class Storage: 5 | def __init__(self): 6 | pass 7 | 8 | async def add_object(self, name, data): 9 | result = await StoredObject.create(name=name, data=data) 10 | 11 | if result.data != data: 12 | return False 13 | 14 | return True 15 | 16 | async def get_object(self, name): 17 | return await StoredObject.get_or_none(name=name) 18 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/storage/test_storage.py: -------------------------------------------------------------------------------- 1 | from bovine_fedi.utils.test import db_url # noqa: F401 2 | 3 | from .storage import Storage 4 | 5 | 6 | async def test_storage(db_url): # noqa F811 7 | storage = Storage() 8 | 9 | assert await storage.get_object("name") is None 10 | 11 | data = b"\x13\x00\x00\x00\x08\x00" 12 | 13 | assert await storage.add_object("name", data) 14 | 15 | result = await storage.get_object("name") 16 | 17 | assert result.data == data 18 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/types/__init__.py: -------------------------------------------------------------------------------- 1 | from .local_actor import LocalActor # noqa F401 2 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/types/base_count_and_items.py: -------------------------------------------------------------------------------- 1 | class BaseCountAndItems: 2 | async def item_count(self, local_user) -> int: 3 | return 0 4 | 5 | async def items(self, local_user, **kwargs) -> dict | None: 6 | return {"items": []} 7 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/types/test_local_actor.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from bovine.clients.activity_pub import ActivityPubClient 4 | 5 | from bovine_fedi.utils.test.in_memory_test_app import app 6 | 7 | from .local_actor import LocalActor 8 | 9 | 10 | def test_create_object_id(): 11 | local_actor = LocalActor("name", "url", "public_key", "private_key", "actor_type") 12 | 13 | new_id = local_actor.generate_uuid() 14 | 15 | assert re.match( 16 | r"url/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", new_id 17 | ) 18 | 19 | 20 | async def test_client(): 21 | async with app.app_context(): 22 | local_actor = LocalActor( 23 | "name", "url", "public_key", "private_key", "actor_type" 24 | ) 25 | client = local_actor.client() 26 | 27 | assert isinstance(client, ActivityPubClient) 28 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/utils/in_memory_store.py: -------------------------------------------------------------------------------- 1 | from bovine_fedi.types import LocalActor 2 | 3 | 4 | class InMemoryUserStore: 5 | def __init__(self): 6 | self.users = {} 7 | 8 | def add_user(self, local_actor: LocalActor): 9 | self.users[local_actor.name] = local_actor 10 | 11 | async def get_user(self, username: str) -> LocalActor | None: 12 | if username in self.users: 13 | return self.users[username] 14 | return None 15 | 16 | 17 | class InMemoryObjectStore: 18 | async def retrieve(self, retriever, object_id, include=[]): 19 | pass 20 | 21 | async def store(self, owner, item, as_public=False, visible_to=[]): 22 | pass 23 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/utils/peer_type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class PeerType(Enum): 5 | TRUSTED = "TRUSTED" 6 | BLOCKED = "BLOCKED" 7 | NEW = "NEW" 8 | OFFLINE = "OFFLINE" 9 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/utils/queue_manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import uuid 3 | from collections import defaultdict 4 | 5 | 6 | class QueueManager: 7 | def __init__(self): 8 | self.queues = defaultdict(dict) 9 | 10 | def get_queues_for_actor(self, actor_name): 11 | return self.queues[actor_name].values() 12 | 13 | def new_queue_for_actor(self, actor_name): 14 | queue_id = uuid.uuid4() 15 | queue = asyncio.Queue() 16 | 17 | self.queues[actor_name][queue_id] = queue 18 | 19 | return queue_id, queue 20 | 21 | def remove_queue_for_actor(self, actor_name, queue_id): 22 | del self.queues[actor_name][queue_id] 23 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/utils/test/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from tortoise import Tortoise 5 | 6 | from bovine_fedi.utils import get_public_private_key_from_files, init 7 | 8 | 9 | def remove_domain_from_url(url): 10 | assert url.startswith("https://my_domain") 11 | 12 | return url[17:] 13 | 14 | 15 | def get_user_keys(): 16 | return get_public_private_key_from_files( 17 | ".files/public_key.pem", ".files/private_key.pem" 18 | ) 19 | 20 | 21 | @pytest.fixture 22 | async def db_url() -> str: 23 | db_file = "test_db.sqlite3" 24 | db_url = f"sqlite://{db_file}" 25 | 26 | await init(db_url) 27 | 28 | yield db_url 29 | 30 | await Tortoise.close_connections() 31 | 32 | os.unlink(db_file) 33 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/utils/test_queue_manager.py: -------------------------------------------------------------------------------- 1 | from .queue_manager import QueueManager 2 | 3 | 4 | def test_get_queues_for_actor(): 5 | manager = QueueManager() 6 | 7 | queues = manager.get_queues_for_actor("actor_name") 8 | 9 | assert list(queues) == [] 10 | 11 | 12 | def test_get_queues_for_actor_with_queues(): 13 | manager = QueueManager() 14 | 15 | _, queue1 = manager.new_queue_for_actor("actor_name") 16 | _, queue2 = manager.new_queue_for_actor("actor_name") 17 | 18 | queues = manager.get_queues_for_actor("actor_name") 19 | 20 | assert set(queues) == {queue1, queue2} 21 | 22 | 23 | def test_removing_queues(): 24 | manager = QueueManager() 25 | 26 | id1, queue1 = manager.new_queue_for_actor("actor_name") 27 | id2, queue2 = manager.new_queue_for_actor("actor_name") 28 | 29 | manager.remove_queue_for_actor("actor_name", id1) 30 | 31 | queues = manager.get_queues_for_actor("actor_name") 32 | 33 | assert set(queues) == {queue2} 34 | 35 | manager.remove_queue_for_actor("actor_name", id2) 36 | 37 | queues = manager.get_queues_for_actor("actor_name") 38 | 39 | assert len(queues) == 0 40 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/utils/test_utils.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from unittest.mock import AsyncMock 3 | 4 | from . import determine_local_path_from_activity_id, update_id 5 | 6 | 7 | async def test_update_id(): 8 | async def id_generator(): 9 | return str(uuid.uuid4()) 10 | 11 | store = AsyncMock() 12 | store.id_generator = id_generator 13 | 14 | data = {"id": "id"} 15 | result = await update_id(data, "retriever", store) 16 | assert result["id"] != "id" 17 | 18 | data = {"object": "test"} 19 | result = await update_id(data, "retriever", store) 20 | assert result["id"] 21 | 22 | # if in store; id is not updated 23 | data = {"object": {"id": "id"}} 24 | result = await update_id(data, "retriever", store) 25 | assert result["object"]["id"] == "id" 26 | 27 | store.retrieve.return_value = None 28 | data = {"object": {"id": "id"}} 29 | result = await update_id(data, "retriever", store) 30 | assert result["object"]["id"] != "id" 31 | 32 | 33 | def test_determine_local_path(): 34 | activity_id = "https://domain/something/name/uuid/delete" 35 | 36 | local_path = determine_local_path_from_activity_id(activity_id) 37 | 38 | assert local_path == "/something/name/uuid/delete" 39 | -------------------------------------------------------------------------------- /bovine_fedi/bovine_fedi/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0-alpha" 2 | -------------------------------------------------------------------------------- /bovine_fedi/context_cache.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelgeKrueger/bovine/4ba2a83d1b4104ebffaaca357fbc9c225ffb06bf/bovine_fedi/context_cache.sqlite -------------------------------------------------------------------------------- /bovine_fedi/migrations/models/0_20230118205707_init.py: -------------------------------------------------------------------------------- 1 | from tortoise import BaseDBAsyncClient 2 | 3 | 4 | async def upgrade(db: BaseDBAsyncClient) -> str: 5 | return """ 6 | CREATE TABLE IF NOT EXISTS "actor" ( 7 | "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 8 | "account" VARCHAR(255) NOT NULL, 9 | "url" VARCHAR(255) NOT NULL, 10 | "actor_type" VARCHAR(255) NOT NULL, 11 | "private_key" TEXT NOT NULL, 12 | "public_key" TEXT NOT NULL 13 | ); 14 | CREATE TABLE IF NOT EXISTS "follower" ( 15 | "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 16 | "account" VARCHAR(255) NOT NULL, 17 | "followed_on" TIMESTAMP NOT NULL, 18 | "inbox" VARCHAR(255), 19 | "public_key" TEXT, 20 | "actor_id" INT NOT NULL REFERENCES "actor" ("id") ON DELETE CASCADE 21 | ); 22 | CREATE TABLE IF NOT EXISTS "following" ( 23 | "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 24 | "account" VARCHAR(255) NOT NULL, 25 | "followed_on" TIMESTAMP NOT NULL, 26 | "actor_id" INT NOT NULL REFERENCES "actor" ("id") ON DELETE CASCADE 27 | ); 28 | CREATE TABLE IF NOT EXISTS "inboxentry" ( 29 | "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 30 | "created" TIMESTAMP NOT NULL, 31 | "content" JSON NOT NULL, 32 | "actor_id" INT NOT NULL REFERENCES "actor" ("id") ON DELETE CASCADE 33 | ); 34 | CREATE TABLE IF NOT EXISTS "outboxentry" ( 35 | "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 36 | "local_path" VARCHAR(255) NOT NULL, 37 | "created" TIMESTAMP NOT NULL, 38 | "content" JSON NOT NULL, 39 | "actor_id" INT NOT NULL REFERENCES "actor" ("id") ON DELETE CASCADE 40 | ); 41 | CREATE TABLE IF NOT EXISTS "aerich" ( 42 | "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 43 | "version" VARCHAR(255) NOT NULL, 44 | "app" VARCHAR(100) NOT NULL, 45 | "content" JSON NOT NULL 46 | );""" 47 | 48 | 49 | async def downgrade(db: BaseDBAsyncClient) -> str: 50 | return """ 51 | """ 52 | -------------------------------------------------------------------------------- /bovine_fedi/migrations/models/1_20230122202029_add_public_key_table.py: -------------------------------------------------------------------------------- 1 | from tortoise import BaseDBAsyncClient 2 | 3 | 4 | async def upgrade(db: BaseDBAsyncClient) -> str: 5 | return """ 6 | CREATE TABLE IF NOT EXISTS "peer" ( 7 | "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 8 | "domain" VARCHAR(255) NOT NULL, 9 | "peer_type" VARCHAR(7) NOT NULL DEFAULT 'NEW' /* TRUSTED: TRUSTED\nBLOCKED: BLOCKED\nNEW: NEW */, 10 | "software" VARCHAR(255), 11 | "version" VARCHAR(255), 12 | "created" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 | "updated" TIMESTAMP 14 | );; 15 | CREATE TABLE IF NOT EXISTS "publickey" ( 16 | "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 17 | "url" VARCHAR(255) NOT NULL, 18 | "public_key" TEXT NOT NULL 19 | );;""" 20 | 21 | 22 | async def downgrade(db: BaseDBAsyncClient) -> str: 23 | return """ 24 | DROP TABLE IF EXISTS "peer"; 25 | DROP TABLE IF EXISTS "publickey";""" 26 | -------------------------------------------------------------------------------- /bovine_fedi/migrations/models/2_20230122215619_make_public_key_url_unique.py: -------------------------------------------------------------------------------- 1 | from tortoise import BaseDBAsyncClient 2 | 3 | 4 | async def upgrade(db: BaseDBAsyncClient) -> str: 5 | return """ 6 | CREATE UNIQUE INDEX "uid_publickey_url_43f8a9" ON "publickey" ("url");""" 7 | 8 | 9 | async def downgrade(db: BaseDBAsyncClient) -> str: 10 | return """ 11 | ALTER TABLE "publickey" DROP INDEX "idx_publickey_url_43f8a9";""" 12 | -------------------------------------------------------------------------------- /bovine_fedi/migrations/models/3_20230123193928_add_outbox_fields.py: -------------------------------------------------------------------------------- 1 | from tortoise import BaseDBAsyncClient 2 | 3 | 4 | async def upgrade(db: BaseDBAsyncClient) -> str: 5 | return """ 6 | ALTER TABLE "outboxentry" ADD "conversation" VARCHAR(255); 7 | ALTER TABLE "outboxentry" ADD "read" INT NOT NULL DEFAULT 1;""" 8 | 9 | 10 | async def downgrade(db: BaseDBAsyncClient) -> str: 11 | return """ 12 | ALTER TABLE "outboxentry" DROP COLUMN "conversation"; 13 | ALTER TABLE "outboxentry" DROP COLUMN "read";""" 14 | -------------------------------------------------------------------------------- /bovine_fedi/migrations/models/4_20230123202446_move_fields_from_outbox_to_inbox.py: -------------------------------------------------------------------------------- 1 | from tortoise import BaseDBAsyncClient 2 | 3 | 4 | async def upgrade(db: BaseDBAsyncClient) -> str: 5 | return """ 6 | ALTER TABLE "inboxentry" ADD "conversation" VARCHAR(255); 7 | ALTER TABLE "inboxentry" ADD "read" INT NOT NULL DEFAULT 1; 8 | ALTER TABLE "outboxentry" DROP COLUMN "conversation"; 9 | ALTER TABLE "outboxentry" DROP COLUMN "read";""" 10 | 11 | 12 | async def downgrade(db: BaseDBAsyncClient) -> str: 13 | return """ 14 | ALTER TABLE "inboxentry" DROP COLUMN "conversation"; 15 | ALTER TABLE "inboxentry" DROP COLUMN "read"; 16 | ALTER TABLE "outboxentry" ADD "conversation" VARCHAR(255); 17 | ALTER TABLE "outboxentry" ADD "read" INT NOT NULL DEFAULT 1;""" 18 | -------------------------------------------------------------------------------- /bovine_fedi/migrations/models/5_20230128185546_add_storage_table.py: -------------------------------------------------------------------------------- 1 | from tortoise import BaseDBAsyncClient 2 | 3 | 4 | async def upgrade(db: BaseDBAsyncClient) -> str: 5 | return """ 6 | CREATE TABLE IF NOT EXISTS "storedobject" ( 7 | "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 8 | "name" VARCHAR(255) NOT NULL UNIQUE, 9 | "data" BLOB NOT NULL 10 | );;""" 11 | 12 | 13 | async def downgrade(db: BaseDBAsyncClient) -> str: 14 | return """ 15 | DROP TABLE IF EXISTS "storedobject";""" 16 | -------------------------------------------------------------------------------- /bovine_fedi/migrations/models/6_20230131082947_add_content_id_to_outbox_inbox.py: -------------------------------------------------------------------------------- 1 | from tortoise import BaseDBAsyncClient 2 | 3 | 4 | async def upgrade(db: BaseDBAsyncClient) -> str: 5 | return """ 6 | ALTER TABLE "inboxentry" ADD "content_id" VARCHAR(255); 7 | ALTER TABLE "outboxentry" ADD "content_id" VARCHAR(255);""" 8 | 9 | 10 | async def downgrade(db: BaseDBAsyncClient) -> str: 11 | return """ 12 | ALTER TABLE "inboxentry" DROP COLUMN "content_id"; 13 | ALTER TABLE "outboxentry" DROP COLUMN "content_id";""" 14 | -------------------------------------------------------------------------------- /bovine_fedi/migrations/models/9_20230321190820_privatekeypk_table.py: -------------------------------------------------------------------------------- 1 | from tortoise import BaseDBAsyncClient 2 | 3 | 4 | async def upgrade(db: BaseDBAsyncClient) -> str: 5 | return """ 6 | CREATE TABLE IF NOT EXISTS "publickeypk" ( 7 | "url" VARCHAR(255) NOT NULL PRIMARY KEY, 8 | "public_key" TEXT NOT NULL 9 | );;""" 10 | 11 | 12 | async def downgrade(db: BaseDBAsyncClient) -> str: 13 | return """ 14 | DROP TABLE IF EXISTS "publickeypk";""" 15 | -------------------------------------------------------------------------------- /bovine_fedi/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "bovine-fedi" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Helge "] 6 | readme = "../README.md" 7 | packages = [{include = "bovine_fedi"}] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.10" 11 | aiohttp = "^3.8.3" 12 | markdown = "^3.4.1" 13 | python-markdown-math = "^0.8" 14 | aerich = "^0.7.1" 15 | bovine-store = {path = "../bovine_store", develop = true} 16 | bovine-user = {path = "../bovine_user", develop = true} 17 | bovine-process = {path = "../bovine_process", develop = true} 18 | asyncstdlib = "^3.10.5" 19 | 20 | [tool.aerich] 21 | tortoise_orm = "bovine_fedi.TORTOISE_ORM" 22 | location = "./migrations" 23 | src_folder = "./." 24 | 25 | [build-system] 26 | requires = ["poetry-core"] 27 | build-backend = "poetry.core.masonry.api" 28 | [tool.poetry.group.dev.dependencies] 29 | pytest = "^7.2.1" 30 | pytest-asyncio = "^0.20.3" 31 | flake8 = "^6.0.0" 32 | flake8-black = "^0.3.6" 33 | isort = "^5.12.0" 34 | black = "^23.1.0" 35 | rich = "^13.3.1" 36 | 37 | 38 | 39 | [tool.poetry.group.test.dependencies] 40 | jsonschema = "^4.17.3" 41 | 42 | [tool.pytest.ini_options] 43 | asyncio_mode="auto" 44 | log_cli= 1 45 | -------------------------------------------------------------------------------- /bovine_fedi/scripts/collect_peers.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from urllib.parse import urlparse 3 | 4 | import aiohttp 5 | from bovine.clients.nodeinfo import fetch_nodeinfo 6 | from bovine_tortoise.models import Peer, PublicKey 7 | from bovine_tortoise.utils import init 8 | from bovine_tortoise.utils.peer_type import PeerType 9 | from tortoise import Tortoise 10 | 11 | 12 | async def update_peers(): 13 | public_keys = await PublicKey.all() 14 | domains = {urlparse(x.url).netloc for x in public_keys} 15 | for domain in domains: 16 | await Peer.get_or_create(domain=domain) 17 | 18 | 19 | async def get_for_peer(session, peer): 20 | return (peer, await fetch_nodeinfo(session, peer.domain)) 21 | 22 | 23 | async def update_nodeinfo(): 24 | async with aiohttp.ClientSession() as session: 25 | peers = await Peer().filter(software__not_isnull=False).all() 26 | for peer in peers: 27 | try: 28 | nodeinfo = await fetch_nodeinfo(session, peer.domain) 29 | peer.software = nodeinfo["software"]["name"] 30 | peer.version = nodeinfo["software"]["version"] 31 | await peer.save() 32 | print(peer.domain) 33 | except Exception: 34 | peer.peer_type = PeerType.OFFLINE 35 | await peer.save() 36 | print(f"Nodeinfo failed for {peer.domain}") 37 | 38 | 39 | async def main(): 40 | await init() 41 | 42 | # await update_peers() 43 | 44 | await update_nodeinfo() 45 | 46 | await Tortoise.close_connections() 47 | 48 | 49 | if __name__ == "__main__": 50 | asyncio.run(main()) 51 | -------------------------------------------------------------------------------- /bovine_fedi/scripts/database_cleanup.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import datetime, timedelta 3 | 4 | from bovine_blog import TORTOISE_ORM 5 | from bovine_store.models import CollectionItem, StoredJsonObject 6 | from tortoise import Tortoise 7 | 8 | 9 | async def cleanup_db(): 10 | await Tortoise.init( 11 | db_url=TORTOISE_ORM["connections"]["default"], 12 | modules={"models": TORTOISE_ORM["apps"]["models"]["models"]}, 13 | ) 14 | today = datetime.now() 15 | delete_from_datetime = today - timedelta(days=3) 16 | 17 | result = ( 18 | await StoredJsonObject.filter(created__lt=delete_from_datetime) 19 | .limit(1000) 20 | .all() 21 | .prefetch_related("visible_to") 22 | ) 23 | 24 | for x in result: 25 | if not x.id.startswith("https://mymath.rocks"): 26 | for y in x.visible_to: 27 | await y.delete() 28 | items = await CollectionItem.filter(object_id=x.id).all() 29 | print(x.id, len(items)) 30 | for i in items: 31 | await i.delete() 32 | 33 | await x.delete() 34 | 35 | await Tortoise.close_connections() 36 | 37 | 38 | asyncio.run(cleanup_db()) 39 | -------------------------------------------------------------------------------- /bovine_fedi/scripts/ensure_follow.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from bovine import BovineActor 4 | from bovine_store.models import CollectionItem 5 | from tortoise import Tortoise 6 | 7 | 8 | async def fetch_followers(): 9 | async with BovineActor("helge.toml") as actor: 10 | await Tortoise.init( 11 | db_url="sqlite://db.sqlite3", 12 | modules={ 13 | "models": [ 14 | "bovine_fedi.models", 15 | "bovine_store.models", 16 | "bovine_user.models", 17 | "aerich.models", 18 | ] 19 | }, 20 | ) 21 | for key in ["followers", "following"]: 22 | followers = actor.information[key] 23 | 24 | print(followers) 25 | 26 | items = await CollectionItem.filter(part_of=followers).all() 27 | 28 | for x in items: 29 | print(await actor.proxy_element(x.object_id)) 30 | 31 | await Tortoise.close_connections() 32 | 33 | 34 | asyncio.run(fetch_followers()) 35 | -------------------------------------------------------------------------------- /bovine_fedi/scripts/get_activity.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | 4 | import aiohttp 5 | from rich import print 6 | 7 | 8 | async def get_activity(): 9 | url = "https://mymath.rocks/activitypub/helge/4963d54c-8320-444b-9559-db5e9a5b7068" 10 | 11 | async with aiohttp.ClientSession() as session: 12 | response = await session.get( 13 | url, 14 | headers={ 15 | "accept": "application/activity+json", 16 | }, 17 | ) # authorization missing 18 | 19 | print(response.status) 20 | 21 | text_content = await response.text() 22 | 23 | data = json.loads(text_content) 24 | assert data["id"] == url 25 | 26 | print(data) 27 | 28 | 29 | asyncio.run(get_activity()) 30 | -------------------------------------------------------------------------------- /bovine_fedi/scripts/import_rapid_block.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | 4 | import aiohttp 5 | from bovine_tortoise.models import Peer 6 | from bovine_tortoise.utils import init 7 | from bovine_tortoise.utils.peer_type import PeerType 8 | from tortoise import Tortoise 9 | 10 | 11 | async def fetch_blocklist(session): 12 | response = await session.get("https://rapidblock.org/blocklist.json", timeout=60) 13 | data = await response.text() 14 | 15 | parsed = json.loads(data) 16 | 17 | result = [ 18 | domain for domain, value in parsed["blocks"].items() if value["isBlocked"] 19 | ] 20 | 21 | return result 22 | 23 | 24 | async def update_peers(blocklist): 25 | await init() 26 | 27 | for domain in blocklist: 28 | peer, _ = await Peer.get_or_create(domain=domain) 29 | peer.peer_type = PeerType.BLOCKED 30 | await peer.save() 31 | 32 | await Tortoise.close_connections() 33 | 34 | 35 | async def main(): 36 | async with aiohttp.ClientSession(raise_for_status=True) as session: 37 | blocklist = await fetch_blocklist(session) 38 | await update_peers(blocklist) 39 | 40 | 41 | if __name__ == "__main__": 42 | asyncio.run(main()) 43 | -------------------------------------------------------------------------------- /bovine_fedi/setup.cfg: -------------------------------------------------------------------------------- 1 | 2 | [flake8] 3 | max-line-length = 200 4 | extend-ignore = E203, 5 | -------------------------------------------------------------------------------- /bovine_fedi/static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: lightgoldenrodyellow; 3 | } 4 | 5 | h1 { 6 | text-align: center; 7 | } 8 | 9 | article { 10 | display: block; 11 | padding: 30px; 12 | margin: 30px; 13 | background-color: white; 14 | border: 1px solid black; 15 | max-width: 800px; 16 | } 17 | 18 | article header { 19 | padding: 10px; 20 | background-color: #aaaaaa; 21 | } 22 | 23 | footer { 24 | text-align: right; 25 | } 26 | 27 | blockquote, code { 28 | display: block; 29 | background: #fff; 30 | padding: 15px 20px 15px 45px; 31 | margin: 0 0 20px; 32 | position: relative; 33 | 34 | line-height: 1.2; 35 | } 36 | 37 | blockquote { 38 | color: #666; 39 | text-align: justify; 40 | 41 | border-left: 15px solid #c76c0c; 42 | border-right: 2px solid #c76c0c; 43 | box-shadow: 2px 2px 15px #ccc; 44 | } 45 | 46 | code { 47 | white-space: pre; 48 | font-family: monospace; 49 | font-size: 14px; 50 | 51 | border-left: 15px solid #0cc722; 52 | border-right: 2px solid #0cc722; 53 | box-shadow: 2px 2px 15px #ccc; 54 | } 55 | 56 | blockquote::before { 57 | content: "\201C"; 58 | 59 | font-size: 50px; 60 | font-weight: bold; 61 | color: #999; 62 | 63 | position: absolute; 64 | left: 10px; 65 | top: 5px; 66 | } 67 | 68 | blockquote::after, code::after { 69 | content: ""; 70 | } 71 | 72 | code::before { 73 | content: ">"; 74 | 75 | font-size: 50px; 76 | font-weight: bold; 77 | color: #999; 78 | 79 | position: absolute; 80 | left: 10px; 81 | top: 0px; 82 | } -------------------------------------------------------------------------------- /bovine_fedi/tests/test_activitypub_actor.py: -------------------------------------------------------------------------------- 1 | from bovine_fedi.utils.test.in_memory_test_app import app 2 | 3 | 4 | async def test_activitypub_bovine_actor() -> None: 5 | client = app.test_client() 6 | 7 | response = await client.get( 8 | "/activitypub/bovine", 9 | headers={"Accept": "application/activity+json"}, 10 | ) 11 | 12 | assert response.status_code == 200 13 | 14 | data = await response.get_json() 15 | 16 | assert data["id"] == "https://my_domain/activitypub/bovine" 17 | assert "publicKey" in data 18 | 19 | key_data = data["publicKey"] 20 | 21 | assert key_data["publicKeyPem"].startswith("-----BEGIN PUBLIC KEY-----\n") 22 | assert key_data["publicKeyPem"].endswith("\n-----END PUBLIC KEY-----\n") 23 | 24 | 25 | async def test_activitypub_inbox_post_is_not_implemented() -> None: 26 | client = app.test_client() 27 | 28 | response = await client.post("/activitypub/user/inbox") 29 | 30 | assert response.status_code == 501 31 | -------------------------------------------------------------------------------- /bovine_fedi/tests/test_webfinger_to_activitypub.py: -------------------------------------------------------------------------------- 1 | from bovine_fedi.utils.test import remove_domain_from_url 2 | from bovine_fedi.utils.test.in_memory_test_app import ( 3 | test_client_with_authorization, 4 | ) # noqa F401 5 | 6 | 7 | async def test_get_user(test_client_with_authorization) -> None: # noqa F811 8 | response = await test_client_with_authorization.get( 9 | "/.well-known/webfinger?resource=acct:user" 10 | ) 11 | 12 | assert response.status_code == 200 13 | 14 | data = await response.get_json() 15 | 16 | assert data["subject"] == "acct:user@my_domain" 17 | assert "links" in data 18 | 19 | assert len(data["links"]) > 0 20 | 21 | self_element_list = [x for x in data["links"] if x["rel"] == "self"] 22 | 23 | assert len(self_element_list) == 1 24 | 25 | self_element = self_element_list[0] 26 | 27 | assert self_element["type"] == "application/activity+json" 28 | 29 | self_url = self_element["href"] 30 | 31 | response = await test_client_with_authorization.get( 32 | remove_domain_from_url(self_url), 33 | headers={"Accept": "application/activity+json"}, 34 | ) 35 | 36 | assert response.status_code == 200 37 | 38 | data = await response.get_json() 39 | 40 | assert isinstance(data, dict) 41 | -------------------------------------------------------------------------------- /bovine_fedi/tests/test_wellknown_nodeinfo.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import jsonschema 4 | import pytest 5 | 6 | from bovine_fedi.utils.test.in_memory_test_app import app 7 | 8 | 9 | def validate_nodeinfo(data): 10 | with open("schemas/nodeinfo_2_0.schema.json", "r", encoding="utf-8") as schema_file: 11 | schema = json.load(schema_file) 12 | 13 | jsonschema.validate(data, schema) 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_nodeinfo() -> None: 18 | client = app.test_client() 19 | 20 | response = await client.get("/.well-known/nodeinfo") 21 | data = await response.get_json() 22 | 23 | assert "links" in data 24 | assert data["links"][0]["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.0" 25 | 26 | nodeinfo_url = data["links"][0]["href"] 27 | assert nodeinfo_url == "https://my_domain/info/nodeinfo2_0" 28 | 29 | response = await client.get(nodeinfo_url) 30 | data = await response.get_json() 31 | 32 | assert isinstance(data, dict) 33 | 34 | validate_nodeinfo(data) 35 | -------------------------------------------------------------------------------- /bovine_fedi/tests/test_wellknown_webfinger.py: -------------------------------------------------------------------------------- 1 | from bovine_fedi.utils.test.in_memory_test_app import app 2 | 3 | 4 | async def test_no_argument_leads_to_bad_request() -> None: 5 | client = app.test_client() 6 | 7 | response = await client.get("/.well-known/webfinger") 8 | 9 | assert response.status_code == 400 10 | 11 | 12 | async def test_unknown_account() -> None: 13 | client = app.test_client() 14 | 15 | response = await client.get("/.well-known/webfinger?resource=acct:unknown") 16 | 17 | assert response.status_code == 404 18 | 19 | data = await response.get_json() 20 | 21 | assert data["status"] == "not found" 22 | 23 | 24 | async def test_success() -> None: 25 | client = app.test_client() 26 | 27 | response = await client.get("/.well-known/webfinger?resource=acct:user") 28 | 29 | assert response.status_code == 200 30 | # Label: webfinger-content-type 31 | assert response.headers["content-type"] == "application/jrd+json" 32 | 33 | data = await response.get_json() 34 | 35 | assert data["subject"] == "acct:user@my_domain" 36 | assert "links" in data 37 | 38 | 39 | async def test_success_with_domain() -> None: 40 | client = app.test_client() 41 | 42 | response = await client.get("/.well-known/webfinger?resource=acct:user@my_domain") 43 | 44 | assert response.status_code == 200 45 | 46 | data = await response.get_json() 47 | 48 | assert data["subject"] == "acct:user@my_domain" 49 | assert "links" in data 50 | 51 | 52 | async def test_success_with_other_domain() -> None: 53 | client = app.test_client() 54 | 55 | response = await client.get("/.well-known/webfinger?resource=acct:user@other") 56 | 57 | assert response.status_code == 404 58 | -------------------------------------------------------------------------------- /bovine_process/README.md: -------------------------------------------------------------------------------- 1 | # bovine_process 2 | 3 | Some things are unfortunately still missing like handling Add/Remove from collections. 4 | 5 | Generally this package should implement one method for each time an entry in Section 6 or Section 7 of the [ActivityPub Specification](https://www.w3.org/TR/activitypub/) ends in Activity. 6 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from .add_to_queue import add_to_queue 5 | from .content.handle_update import handle_update 6 | from .content.incoming_delete import incoming_delete 7 | from .content.store_incoming import ( 8 | add_incoming_to_inbox, 9 | add_incoming_to_outbox, 10 | store_incoming, 11 | store_outgoing, 12 | ) 13 | from .fetch.incoming_actor import incoming_actor 14 | from .follow.accept_follow import accept_follow 15 | from .follow.follow_accept import follow_accept 16 | from .send_item import send_outbox_item 17 | from .types.processing_item import ProcessingItem 18 | from .undo import undo 19 | from .utils.processor_list import ProcessorList 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | default_content_processors = { 24 | "Create": store_incoming, 25 | "Update": handle_update, 26 | "Delete": incoming_delete, 27 | "Undo": undo, 28 | } 29 | 30 | 31 | default_inbox_process = ( 32 | ProcessorList() 33 | .add_for_types(**default_content_processors, Accept=follow_accept) 34 | .add(store_incoming) 35 | .add(add_incoming_to_inbox) 36 | .add(incoming_actor) 37 | .add(add_to_queue) 38 | .apply 39 | ) 40 | 41 | 42 | default_outbox_process = ( 43 | ProcessorList() 44 | .add(store_outgoing) 45 | .add(add_incoming_to_outbox) 46 | .add_for_types(**default_content_processors) 47 | .apply 48 | ) 49 | 50 | 51 | default_async_outbox_process = ( 52 | ProcessorList().add_for_types(Accept=accept_follow).add(add_to_queue).apply 53 | ) 54 | 55 | 56 | async def process_inbox(request, activity_pub, actor): 57 | logger.info("Processing inbox request") 58 | data = await request.get_json() 59 | 60 | item = ProcessingItem(json.dumps(data)) 61 | 62 | await default_inbox_process(item, activity_pub, actor) 63 | 64 | logger.debug(json.dumps(data)) 65 | 66 | 67 | async def process_outbox_item(item, activity_pub, actor): 68 | await default_async_outbox_process(item, activity_pub, actor) 69 | await send_outbox_item(item, activity_pub, actor) 70 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/add_to_queue.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from bovine.types import ServerSentEvent 5 | from quart import current_app 6 | 7 | from .types.processing_item import ProcessingItem 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | async def add_to_queue( 13 | item: ProcessingItem, activity_pub, actor 14 | ) -> ProcessingItem | None: 15 | data = item.get_data() 16 | 17 | data_s = json.dumps(data) 18 | event = ServerSentEvent(data=data_s, event="inbox") 19 | 20 | if "database_id" in item.meta: 21 | event.id = item.meta["database_id"] 22 | 23 | # FIXME: Is there a better way to access the queue_manager ? 24 | # If I do this, I could also access the session in a similar way 25 | # This would simplify the processor interface 26 | 27 | event_source = actor["endpoints"]["eventSource"] 28 | 29 | queues = current_app.config["queue_manager"].get_queues_for_actor(event_source) 30 | 31 | logging.info(f"Adding items to {len(queues)} queues") 32 | 33 | for queue in queues: 34 | await queue.put(event) 35 | 36 | return item 37 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/content/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelgeKrueger/bovine/4ba2a83d1b4104ebffaaca357fbc9c225ffb06bf/bovine_process/bovine_process/content/__init__.py -------------------------------------------------------------------------------- /bovine_process/bovine_process/content/handle_update.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from bovine.activitystreams.utils import actor_for_object 4 | from bovine_store.store import ( 5 | retrieve_remote_object, 6 | store_remote_object, 7 | update_remote_object, 8 | ) 9 | 10 | from bovine_process.types.processing_item import ProcessingItem 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | async def handle_update(item: ProcessingItem, activity_pub, actor) -> ProcessingItem: 16 | data = item.get_data() 17 | 18 | owner = actor_for_object(data) 19 | 20 | await store_remote_object(owner, data, visible_to=[actor["id"]]) 21 | 22 | object_to_update = data.get("object") 23 | if object_to_update is None or object_to_update.get("id") is None: 24 | logger.warning("Update without object %s", item.body) 25 | return 26 | 27 | to_update_from_db = await retrieve_remote_object(owner, object_to_update["id"]) 28 | 29 | if to_update_from_db: 30 | await update_remote_object(owner, object_to_update) 31 | 32 | return item 33 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/content/incoming_delete.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from bovine.activitystreams.objects import tombstone 4 | from bovine.activitystreams.utils import actor_for_object 5 | from bovine_store.store import ( 6 | retrieve_remote_object, 7 | store_remote_object, 8 | update_remote_object, 9 | ) 10 | 11 | from bovine_process.types.processing_item import ProcessingItem 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | async def incoming_delete( 17 | item: ProcessingItem, activity_pub, local_actor 18 | ) -> ProcessingItem: 19 | data = item.get_data() 20 | 21 | owner = actor_for_object(data) 22 | 23 | await store_remote_object(owner, data, visible_to=[local_actor["id"]]) 24 | 25 | object_to_delete = data.get("object") 26 | 27 | if isinstance(object_to_delete, dict): 28 | object_to_delete = object_to_delete.get("id") 29 | 30 | if object_to_delete is None: 31 | logger.warning("Delete without object %s", item.body) 32 | return 33 | 34 | to_update_from_db = await retrieve_remote_object(owner, object_to_delete) 35 | 36 | if to_update_from_db: 37 | await update_remote_object(owner, tombstone(object_to_delete)) 38 | 39 | return item 40 | # return None 41 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/content/test_handle_update.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from bovine_store.utils.test import store # noqa F401 4 | 5 | from bovine_process.types.processing_item import ProcessingItem 6 | 7 | from .handle_update import handle_update 8 | from .store_incoming import store_incoming 9 | 10 | 11 | async def test_basic_update(store): # noqa F801 12 | remote_actor = "https://remote_domain/actor" 13 | first_id = "https://my_domain/first" 14 | second_id = "https://my_domain/second" 15 | third_id = "https://my_domain/third" 16 | create = { 17 | "@context": "https://www.w3.org/ns/activitystreams", 18 | "id": first_id, 19 | "type": "Create", 20 | "actor": remote_actor, 21 | "object": {"type": "Note", "id": second_id, "content": "new"}, 22 | } 23 | 24 | update = { 25 | "@context": "https://www.w3.org/ns/activitystreams", 26 | "id": third_id, 27 | "type": "Create", 28 | "actor": remote_actor, 29 | "object": {"type": "Note", "id": second_id, "content": "updated"}, 30 | } 31 | 32 | processing_item = ProcessingItem(json.dumps(create)) 33 | 34 | await store_incoming(processing_item, {}, {"id": "local_actor_url"}) 35 | 36 | stored = await store.retrieve("local_actor_url", second_id) 37 | assert stored["content"] == "new" 38 | 39 | processing_item = ProcessingItem(json.dumps(update)) 40 | await handle_update(processing_item, {}, {"id": "local_actor_url"}) 41 | 42 | stored = await store.retrieve("local_actor_url", second_id) 43 | assert stored["content"] == "updated" 44 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/content/test_store_incoming.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from bovine_store.utils.test import store # noqa F401 4 | 5 | from bovine_process.types.processing_item import ProcessingItem 6 | 7 | from .store_incoming import store_incoming 8 | 9 | 10 | async def test_store_incoming(store): # noqa F801 11 | first_id = "https://my_domain/first" 12 | second_id = "https://my_domain/second" 13 | item = { 14 | "@context": "https://www.w3.org/ns/activitystreams", 15 | "id": first_id, 16 | "type": "Create", 17 | "object": { 18 | "type": "Note", 19 | "id": second_id, 20 | }, 21 | } 22 | 23 | processing_item = ProcessingItem(json.dumps(item)) 24 | 25 | result = await store_incoming(processing_item, {}, {"id": "local_actor_url"}) 26 | 27 | assert result == processing_item 28 | 29 | first = await store.retrieve("local_actor_url", first_id) 30 | second = await store.retrieve("local_actor_url", second_id) 31 | 32 | assert first["id"] == first_id 33 | assert first["object"] == second_id 34 | assert second["id"] == second_id 35 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/fetch/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelgeKrueger/bovine/4ba2a83d1b4104ebffaaca357fbc9c225ffb06bf/bovine_process/bovine_process/fetch/__init__.py -------------------------------------------------------------------------------- /bovine_process/bovine_process/fetch/incoming_actor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import traceback 3 | 4 | from bovine.activitystreams.utils import actor_for_object 5 | from bovine_store.jsonld import combine_items 6 | from bovine_store.store import retrieve_remote_object, store_remote_object 7 | 8 | from bovine_process.types.processing_item import ProcessingItem 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | async def incoming_actor(item: ProcessingItem, activity_pub, actor) -> ProcessingItem: 14 | data = item.get_data() 15 | owner = actor_for_object(data) 16 | 17 | if owner is None or owner == "__NO__ACTOR__": 18 | logger.warning("Retrieved object without actor %s", item.body) 19 | return item 20 | 21 | if data["type"] == "Delete": 22 | return item 23 | 24 | try: 25 | remote_actor = await retrieve_remote_object(actor["id"], owner) 26 | if remote_actor: 27 | data = combine_items(data, [remote_actor]) 28 | item = item.set_data(data) 29 | return item 30 | 31 | remote_actor = await activity_pub.get(owner) 32 | await store_remote_object(owner, remote_actor, visible_to=[actor["id"]]) 33 | 34 | data = combine_items(data, [remote_actor]) 35 | 36 | item = item.set_data(data) 37 | except Exception as ex: 38 | logger.warning("Failed to retrieve remote actor %s due to %s", owner, ex) 39 | for log_line in traceback.format_exc().splitlines(): 40 | logger.warning(log_line) 41 | 42 | return item 43 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/follow/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelgeKrueger/bovine/4ba2a83d1b4104ebffaaca357fbc9c225ffb06bf/bovine_process/bovine_process/follow/__init__.py -------------------------------------------------------------------------------- /bovine_process/bovine_process/follow/accept_follow.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from bovine.activitystreams.utils import actor_for_object 4 | from bovine_store.store import retrieve_remote_object 5 | from bovine_store.store.collection import add_to_collection 6 | 7 | from bovine_process.types.processing_item import ProcessingItem 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | async def accept_follow(item: ProcessingItem, activity_pub, actor) -> ProcessingItem: 13 | data = item.get_data() 14 | 15 | if data["type"] != "Accept": 16 | return item 17 | 18 | obj = data["object"] 19 | if isinstance(obj, str): 20 | obj = await retrieve_remote_object(actor["id"], obj) 21 | 22 | if obj["type"] != "Follow": 23 | return item 24 | 25 | remote_actor = actor_for_object(obj) 26 | 27 | await add_to_collection(actor["followers"], remote_actor) 28 | 29 | logger.info("Added %s to followers %s", remote_actor, actor["followers"]) 30 | 31 | return item 32 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/follow/follow_accept.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from bovine.activitystreams.utils import actor_for_object 4 | from bovine_store.store import retrieve_remote_object 5 | from bovine_store.store.collection import add_to_collection 6 | 7 | from bovine_process.types.processing_item import ProcessingItem 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | async def follow_accept(item: ProcessingItem, activity_pub, actor) -> ProcessingItem: 13 | data = item.get_data() 14 | 15 | if data["type"] != "Accept": 16 | return item 17 | 18 | obj = data["object"] 19 | if isinstance(obj, str): 20 | logger.info("retrieving remote object %s for %s", obj, actor["id"]) 21 | obj = await retrieve_remote_object(actor["id"], obj) 22 | 23 | if obj["type"] != "Follow": 24 | return item 25 | 26 | if obj["actor"] != actor["id"]: 27 | logger.warning("Got following for incorrect actor %s", obj["actor"]) 28 | return item 29 | 30 | remote_actor = actor_for_object(data) 31 | 32 | await add_to_collection(actor["following"], remote_actor) 33 | 34 | logger.info("Added %s to following %s", remote_actor, actor["following"]) 35 | 36 | return item 37 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/test_undo.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from bovine_store.utils.test import store # noqa F401 4 | 5 | from .content.store_incoming import store_incoming 6 | from .types.processing_item import ProcessingItem 7 | from .undo import undo 8 | 9 | 10 | async def test_undo_bad_format(store): # noqa F801 11 | first_id = "https://my_domain/first" 12 | item = { 13 | "@context": "https://www.w3.org/ns/activitystreams", 14 | "id": first_id, 15 | "type": "Undo", 16 | } 17 | 18 | processing_item = ProcessingItem(json.dumps(item)) 19 | 20 | result = await undo(processing_item, {}, {}) 21 | 22 | assert result is None 23 | 24 | 25 | async def test_undo(store): # noqa F801a 26 | actor = "https://remote_actor" 27 | first_id = "https://my_domain/first" 28 | second_id = "https://my_domain/second" 29 | 30 | item = { 31 | "@context": "https://www.w3.org/ns/activitystreams", 32 | "type": "Like", 33 | "actor": actor, 34 | "id": second_id, 35 | } 36 | 37 | processing_item = ProcessingItem(json.dumps(item)) 38 | result = await store_incoming(processing_item, {}, {"id": "local_actor_url"}) 39 | 40 | undo_item = { 41 | "@context": "https://www.w3.org/ns/activitystreams", 42 | "id": first_id, 43 | "actor": actor, 44 | "type": "Undo", 45 | "object": item, 46 | } 47 | 48 | processing_item = ProcessingItem(json.dumps(undo_item)) 49 | result = await undo(processing_item, {}, {"id": "local_actor_url"}) 50 | 51 | assert result is None 52 | 53 | first = await store.retrieve("local_actor_url", first_id) 54 | second = await store.retrieve("local_actor_url", second_id) 55 | 56 | assert first is None 57 | assert second is None 58 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/types/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelgeKrueger/bovine/4ba2a83d1b4104ebffaaca357fbc9c225ffb06bf/bovine_process/bovine_process/types/__init__.py -------------------------------------------------------------------------------- /bovine_process/bovine_process/types/processing_item.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import uuid 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class ProcessingItem: 9 | def __init__(self, body, authorization={}): 10 | self.authorization = authorization 11 | self.body = body 12 | self.meta = {} 13 | 14 | self.data = None 15 | 16 | self.object_id() 17 | 18 | def get_data(self): 19 | if not self.data: 20 | try: 21 | self.data = json.loads(self.body) 22 | except Exception as ex: 23 | logger.error("Failed to parse with %s", str(ex)) 24 | self.data = {} 25 | 26 | return self.data 27 | 28 | def set_data(self, data): 29 | self.data = None 30 | self.body = json.dumps(data) 31 | self.object_id() 32 | return self 33 | 34 | def object_id(self): 35 | data = self.get_data() 36 | object_id = data.get("id") 37 | 38 | if object_id is None: 39 | object_id = f"remote://{str(uuid.uuid4())}" 40 | data["id"] = object_id 41 | self.data = data 42 | self.body = json.dumps(data) 43 | 44 | return object_id 45 | 46 | def get_body_id(self) -> str: 47 | try: 48 | parsed = json.loads(self.body.decode("utf-8")) 49 | return parsed["id"] 50 | except Exception as e: 51 | logger.info(e) 52 | return "failed fetching id" 53 | 54 | def dump(self): 55 | logger.info("###########################################################") 56 | logger.info("---AUTHORIZATION----") 57 | logger.info(json.dumps(self.authorization)) 58 | logger.info("---BODY----") 59 | if isinstance(self.body, str): 60 | logger.info(self.body) 61 | else: 62 | logger.info(self.body.decode("utf-8")) 63 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/types/test_processing_item.py: -------------------------------------------------------------------------------- 1 | from .processing_item import ProcessingItem 2 | 3 | 4 | def test_get_object_id(): 5 | def id_for_body(body): 6 | item = ProcessingItem(body) 7 | return item.object_id() 8 | 9 | assert id_for_body("{}").startswith("remote://") 10 | assert id_for_body('{"id": "abc"}') == "abc" 11 | 12 | item = ProcessingItem("{}") 13 | 14 | assert item.object_id() == item.object_id() 15 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/undo.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from bovine.activitystreams.utils import actor_for_object 4 | from bovine_store.store import remove_remote_object 5 | 6 | from .types.processing_item import ProcessingItem 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | async def undo(item: ProcessingItem, activity_pub, actor) -> ProcessingItem: 12 | data = item.get_data() 13 | owner = actor_for_object(data) 14 | 15 | object_to_undo = data.get("object") 16 | if isinstance(object_to_undo, dict): 17 | object_to_undo = object_to_undo.get("id") 18 | 19 | if object_to_undo is None: 20 | logger.warning("Undo without object %s", item.body) 21 | return 22 | 23 | logger.info("Removing object with id %s for %s", object_to_undo, owner) 24 | 25 | await remove_remote_object(owner, object_to_undo) 26 | 27 | return 28 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .by_activity_type import ByActivityType, do_nothing_for_all_activities_or_objects 2 | 3 | 4 | def build_do_for_types(actions: dict): 5 | by_activity_type = ByActivityType( 6 | {**do_nothing_for_all_activities_or_objects, **actions} 7 | ) 8 | return by_activity_type.act 9 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/utils/processor_list.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import traceback 3 | 4 | from . import build_do_for_types 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class ProcessorList: 10 | def __init__(self, on_object=False): 11 | self.processors = [] 12 | self.on_object = on_object 13 | 14 | def add(self, processor): 15 | self.processors.append(processor) 16 | return self 17 | 18 | def add_for_types(self, **kwargs): 19 | return self.add(build_do_for_types(kwargs)) 20 | 21 | async def apply(self, item, *arguments): 22 | if self.on_object: 23 | if isinstance(item, dict): 24 | working = item["object"] 25 | else: 26 | working = item.get_data()["object"] 27 | else: 28 | working = item 29 | 30 | try: 31 | for processor in self.processors: 32 | working = await processor(working, *arguments) 33 | if not working: 34 | return 35 | 36 | if self.on_object: 37 | return item 38 | else: 39 | return working 40 | except Exception as ex: 41 | logger.error(">>>>> SOMETHING WENT WRONG IN PROCESSING <<<<<<") 42 | logger.error(ex) 43 | traceback.print_exception(type(ex), ex, ex.__traceback__) 44 | 45 | if isinstance(item, dict): 46 | logger.info(item) 47 | else: 48 | item.dump() 49 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/utils/test_by_activity_type.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | from . import build_do_for_types 4 | from .by_activity_type import ByActivityType, do_nothing 5 | 6 | 7 | async def test_do_nothing(): 8 | item = "item" 9 | 10 | assert await do_nothing(item, "other arg") == item 11 | assert await do_nothing(item, "other arg", {"more": "arguments"}) == item 12 | 13 | 14 | async def test_by_activity_type(): 15 | item = {"type": "Test"} 16 | 17 | mock = AsyncMock() 18 | mock.return_value = "mock" 19 | 20 | by_activity_type = ByActivityType({"Test": mock}) 21 | 22 | result = await by_activity_type.act(item) 23 | 24 | assert result == "mock" 25 | mock.assert_awaited_once() 26 | 27 | 28 | async def test_build_do_for_types(): 29 | follow_item = {"type": "Follow"} 30 | create_item = {"type": "Create"} 31 | 32 | mock = AsyncMock() 33 | mock.return_value = "mock" 34 | 35 | processor = build_do_for_types({"Follow": mock}) 36 | 37 | assert await processor(follow_item) == "mock" 38 | assert await processor(create_item) == create_item 39 | 40 | mock.assert_awaited_once() 41 | -------------------------------------------------------------------------------- /bovine_process/bovine_process/utils/test_processor_list.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | from .processor_list import ProcessorList 4 | 5 | 6 | async def test_processor_list(): 7 | processor_list = ProcessorList() 8 | 9 | mock1 = AsyncMock() 10 | mock2 = AsyncMock() 11 | mock1.return_value = None 12 | 13 | processor_list.add(mock1).add(mock2) 14 | 15 | item = "item" 16 | 17 | result = await processor_list.apply(item) 18 | 19 | mock1.assert_awaited_once() 20 | mock2.assert_not_awaited() 21 | 22 | assert result is None 23 | -------------------------------------------------------------------------------- /bovine_process/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "bovine-process" 3 | version = "0.0.1" 4 | description = "Processing of Side Effects of ActivityPub Activities for an ActivityPub Server" 5 | authors = ["Helge "] 6 | readme = "README.md" 7 | packages = [{include = "bovine_process"}] 8 | license = "MIT" 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.10" 12 | bovine-store = {path = "../bovine_store", develop = true} 13 | bovine = {path = "../bovine", develop = true} 14 | 15 | 16 | [tool.poetry.group.test.dependencies] 17 | flake8 = "^6.0.0" 18 | black = "^23.1.0" 19 | pytest = "^7.2.2" 20 | flake8-black = "^0.3.6" 21 | pytest-asyncio = "^0.20.3" 22 | 23 | 24 | [tool.poetry.group.dev.dependencies] 25 | isort = "^5.12.0" 26 | 27 | [build-system] 28 | requires = ["poetry-core"] 29 | build-backend = "poetry.core.masonry.api" 30 | 31 | 32 | 33 | [tool.pytest.ini_options] 34 | asyncio_mode="auto" 35 | log_cli= 1 36 | -------------------------------------------------------------------------------- /bovine_process/setup.cfg: -------------------------------------------------------------------------------- 1 | 2 | [flake8] 3 | max-line-length = 88 4 | extend-ignore = E203, 5 | -------------------------------------------------------------------------------- /bovine_store/.gitignore: -------------------------------------------------------------------------------- 1 | store.db* 2 | 3 | -------------------------------------------------------------------------------- /bovine_store/bovine_config.toml: -------------------------------------------------------------------------------- 1 | ../bovine_config.toml -------------------------------------------------------------------------------- /bovine_store/bovine_store/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0-alpha" 2 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/collection.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from quart import request, g 4 | 5 | from .utils.ordered_collection import ordered_collection_responder 6 | from .store.collection import collection_count, collection_items 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | async def collection_response(endpoint_path): 12 | arguments = { 13 | name: request.args.get(name) 14 | for name in ["first", "last", "min_id", "max_id"] 15 | if request.args.get(name) is not None 16 | } 17 | 18 | logger.info("Retrieving %s for %s", endpoint_path, g.retriever) 19 | 20 | async def ccount(): 21 | return await collection_count(g.retriever, endpoint_path) 22 | 23 | async def citems(**kwargs): 24 | return await collection_items(g.retriever, endpoint_path, **kwargs) 25 | 26 | return await ordered_collection_responder( 27 | endpoint_path, 28 | ccount, 29 | citems, 30 | **arguments, 31 | ) 32 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/config.py: -------------------------------------------------------------------------------- 1 | import tomli 2 | import aiohttp 3 | import uuid 4 | 5 | from .store import ObjectStore 6 | 7 | 8 | async def configure_bovine_store(app, db_url="sqlite://store.db"): 9 | with open("bovine_config.toml", "rb") as fp: 10 | config_data = tomli.load(fp) 11 | 12 | if "session" not in app.config: 13 | app.config["session"] = aiohttp.ClientSession() 14 | 15 | host = config_data["bovine"]["host"] 16 | app.config["host"] = host 17 | 18 | async def id_generator(): 19 | return host + "/objects/" + str(uuid.uuid4()) 20 | 21 | app.config["bovine_store"] = ObjectStore(id_generator=id_generator, db_url=db_url) 22 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/jsonld.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | import requests_cache 5 | from pyld import jsonld 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | requests_cache.install_cache("context_cache") 10 | 11 | jsonld.set_document_loader(jsonld.requests_document_loader(timeout=60)) 12 | 13 | 14 | async def split_into_objects(input_data): 15 | if "@context" not in input_data: 16 | logger.warning("@context missing in %s", json.dumps(input_data)) 17 | return [] 18 | context = input_data["@context"] 19 | flattened = jsonld.flatten(input_data) 20 | compacted = jsonld.compact(flattened, context) 21 | 22 | if "@graph" not in compacted: 23 | return [compacted] 24 | 25 | local, remote = split_remote_local(compacted["@graph"]) 26 | 27 | return [frame_object(obj, local, context) for obj in remote] 28 | 29 | 30 | def frame_object(obj, local, context): 31 | to_frame = {"@context": context, "@graph": [obj] + local} 32 | frame = {"@context": context, "id": obj["id"]} 33 | return jsonld.frame(to_frame, frame) 34 | 35 | 36 | def split_remote_local(graph): 37 | local = [x for x in graph if x["id"].startswith("_")] 38 | remote = [x for x in graph if not x["id"].startswith("_")] 39 | 40 | return local, remote 41 | 42 | 43 | def combine_items(data, items): 44 | return frame_object(data, items, data["@context"]) 45 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/models.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from tortoise import fields 4 | from tortoise.models import Model 5 | 6 | 7 | class VisibilityTypes(Enum): 8 | PUBLIC = "PUBLIC" 9 | RESTRICTED = "RESTRICTED" 10 | 11 | 12 | class ObjectType(Enum): 13 | LOCAL = "LOCAL" 14 | REMOTE = "REMOTE" 15 | LOCAL_COLLECTION = "LOCAL_COLLECTION" 16 | 17 | 18 | class StoredJsonObject(Model): 19 | id = fields.CharField(max_length=255, pk=True) 20 | owner = fields.CharField(max_length=255) 21 | 22 | content = fields.JSONField() 23 | created = fields.DatetimeField(auto_now_add=True) 24 | updated = fields.DatetimeField(auto_now=True) 25 | 26 | visibility = fields.CharEnumField( 27 | VisibilityTypes, default=VisibilityTypes.RESTRICTED 28 | ) 29 | object_type = fields.CharEnumField(ObjectType, default=ObjectType.LOCAL) 30 | 31 | 32 | class VisibleTo(Model): 33 | id = fields.IntField(pk=True) 34 | main_object = fields.ForeignKeyField( 35 | "models.StoredJsonObject", related_name="visible_to" 36 | ) 37 | object_id = fields.CharField(max_length=255) 38 | 39 | 40 | class CollectionItem(Model): 41 | id = fields.IntField(pk=True) 42 | part_of = fields.CharField(max_length=255) 43 | object_id = fields.CharField( 44 | max_length=255, 45 | ) 46 | 47 | created = fields.DatetimeField(auto_now_add=True) 48 | updated = fields.DatetimeField(auto_now=True) 49 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/permissions.py: -------------------------------------------------------------------------------- 1 | from .models import CollectionItem, VisibilityTypes 2 | 3 | 4 | async def has_access(entry, retriever): 5 | if entry is None: 6 | return False 7 | 8 | if retriever == entry.owner: 9 | return True 10 | 11 | if entry.visibility == VisibilityTypes.PUBLIC: 12 | return True 13 | 14 | await entry.fetch_related("visible_to") 15 | 16 | if any(item.object_id == retriever for item in entry.visible_to): 17 | return True 18 | 19 | collections = await CollectionItem.filter(object_id=retriever).all() 20 | 21 | collection_ids = [collection.part_of for collection in collections] 22 | 23 | for item in entry.visible_to: 24 | if item.object_id in collection_ids: 25 | return True 26 | 27 | return False 28 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/store/retrieve_object.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from bovine_store.jsonld import combine_items 5 | from bovine_store.models import StoredJsonObject 6 | from bovine_store.permissions import has_access 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | async def retrieve_remote_object(retriever, object_id, include=[]): 12 | result = await StoredJsonObject.get_or_none(id=object_id) 13 | 14 | if not await has_access(result, retriever): 15 | return None 16 | 17 | data = result.content 18 | if len(include) == 0: 19 | return data 20 | 21 | items = await asyncio.gather( 22 | *[StoredJsonObject.get_or_none(id=data[key]) for key in include if key in data] 23 | ) 24 | items = [obj.content for obj in items if obj] 25 | 26 | logger.debug("Retrieved %d items", len(items)) 27 | 28 | return combine_items(data, items) 29 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/store/store.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from bovine_store.jsonld import split_into_objects 5 | from bovine_store.models import StoredJsonObject, VisibilityTypes, VisibleTo 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def should_store(obj): 12 | if "type" not in obj: 13 | return True 14 | 15 | if obj["type"] in [ 16 | "Collection", 17 | "OrderedCollection", 18 | "CollectionPage", 19 | "OrderedCollectionPage", 20 | ]: 21 | return False 22 | 23 | return True 24 | 25 | 26 | async def store_remote_object(owner, item, as_public=False, visible_to=[]): 27 | visibility_type = VisibilityTypes.RESTRICTED 28 | if as_public: 29 | visibility_type = VisibilityTypes.PUBLIC 30 | 31 | to_store = await split_into_objects(item) 32 | 33 | tasks = [ 34 | StoredJsonObject.get_or_create( 35 | id=obj["id"], 36 | defaults={ 37 | "content": obj, 38 | "owner": owner, 39 | "visibility": visibility_type, 40 | }, 41 | ) 42 | for obj in to_store 43 | if should_store(obj) 44 | ] 45 | 46 | id_to_store = {obj["id"]: obj for obj in to_store} 47 | 48 | items = await asyncio.gather(*tasks) 49 | 50 | if visibility_type == VisibilityTypes.PUBLIC: 51 | for x, c in items: 52 | x.visibility = VisibilityTypes.PUBLIC 53 | await x.save() 54 | 55 | return items 56 | 57 | for item, created in items: 58 | visible_tasks = [ 59 | VisibleTo.get_or_create( 60 | main_object=item, 61 | object_id=actor, 62 | ) 63 | for actor in visible_to 64 | ] 65 | await asyncio.gather(*visible_tasks) 66 | if not created: 67 | if item.owner == owner: 68 | item.content = id_to_store[item.id] 69 | await item.save() 70 | 71 | # FIXME visibility not changed; check timestamps? 72 | 73 | return items 74 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/store/test_retrieve_object.py: -------------------------------------------------------------------------------- 1 | from bovine_store.utils.test import store # noqa F401 2 | 3 | from . import store_remote_object 4 | from .retrieve_object import retrieve_remote_object 5 | 6 | 7 | async def test_store_retrieval(store): # noqa F811 8 | first_id = "https://my_domain/first" 9 | second_id = "https://my_domain/second" 10 | item = { 11 | "@context": "https://www.w3.org/ns/activitystreams", 12 | "id": first_id, 13 | "type": "Create", 14 | "object": { 15 | "type": "Note", 16 | "id": second_id, 17 | }, 18 | } 19 | 20 | await store_remote_object("owner", item) 21 | 22 | first = await retrieve_remote_object("owner", first_id, include=["object"]) 23 | 24 | assert first == item 25 | 26 | second = await retrieve_remote_object("owner", second_id, include=["object"]) 27 | 28 | assert set(second.keys()) == {"@context", "type", "id"} 29 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/store/test_store_local.py: -------------------------------------------------------------------------------- 1 | from bovine_store.utils.test import store # noqa F401 2 | 3 | from .local import store_local_object 4 | 5 | 6 | async def test_store_local_object(store): # noqa F811 7 | first_id = "https://my_domain/first" 8 | item = { 9 | "@context": "https://www.w3.org/ns/activitystreams", 10 | "id": first_id, 11 | "type": "Like", 12 | } 13 | 14 | result = await store_local_object("owner", item) 15 | 16 | first = await store.retrieve("owner", first_id) 17 | assert first is None 18 | 19 | new_id = result[0]["id"] 20 | 21 | second = await store.retrieve("owner", new_id) 22 | assert second["type"] == "Like" 23 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/store/test_store_remote.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from bovine_store.utils.test import store # noqa F401 4 | 5 | from .store import store_remote_object 6 | 7 | 8 | async def test_store_remote_object_like_stored(store): # noqa F811 9 | first_id = "https://my_domain/first" 10 | item = { 11 | "@context": "https://www.w3.org/ns/activitystreams", 12 | "id": first_id, 13 | "type": "Like", 14 | } 15 | 16 | await store_remote_object("owner", item) 17 | 18 | first = await store.retrieve("owner", first_id) 19 | assert first == item 20 | 21 | 22 | @pytest.mark.parametrize( 23 | "object_type", 24 | ["Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage"], 25 | ) 26 | async def test_store_remote_object_collections_are_not_stored( 27 | store, object_type # noqa F811 28 | ): 29 | first_id = "https://my_domain/first" 30 | item = { 31 | "@context": "https://www.w3.org/ns/activitystreams", 32 | "id": first_id, 33 | "type": object_type, 34 | } 35 | 36 | await store_remote_object("owner", item) 37 | 38 | first = await store.retrieve("owner", first_id) 39 | assert first is None 40 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/test_permissions.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from .models import CollectionItem, StoredJsonObject, VisibleTo 6 | from .permissions import has_access 7 | from .store import ObjectStore 8 | 9 | 10 | @pytest.fixture 11 | async def store(): 12 | db_file = "test_db.db" 13 | db_url = f"sqlite://{db_file}" 14 | store = ObjectStore(db_url) 15 | 16 | await store.init_connection() 17 | 18 | yield store 19 | await store.close_connection() 20 | os.unlink(db_file) 21 | 22 | 23 | async def test_has_access_for_owner(store): 24 | entry = await StoredJsonObject.create(id="first", owner="owner", content={}) 25 | 26 | assert await has_access(entry, "owner") 27 | assert not await has_access(entry, "other") 28 | 29 | 30 | async def test_has_access_for_other_in_list(store): 31 | entry = await StoredJsonObject.create(id="first", owner="owner", content={}) 32 | 33 | await VisibleTo.create(main_object=entry, object_id="list") 34 | await CollectionItem.create(part_of="list", object_id="other") 35 | 36 | assert await has_access(entry, "other") 37 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/utils/__init__.py: -------------------------------------------------------------------------------- 1 | def determine_summary(obj): 2 | for key in ["summary", "name", "content"]: 3 | if obj.get(key): 4 | return obj[key][:97] 5 | return 6 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/utils/ordered_collection.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | from bovine.activitystreams import ( 3 | build_ordered_collection, 4 | build_ordered_collection_page, 5 | ) 6 | 7 | 8 | async def ordered_collection_responder(url, count_coroutine, items_coroutine, **kwargs): 9 | if any( 10 | kwargs.get(name) is not None for name in ["first", "last", "min_id", "max_id"] 11 | ): 12 | return await ordered_collection_page( 13 | url, 14 | count_coroutine, 15 | items_coroutine, 16 | **kwargs, 17 | ) 18 | 19 | count = await count_coroutine() 20 | 21 | builder = build_ordered_collection(url).with_count(count) 22 | 23 | if count < 10: 24 | data = await items_coroutine() 25 | builder = builder.with_items(data["items"]) 26 | else: 27 | builder = builder.with_first_and_last(f"{url}?first=1", f"{url}?last=1") 28 | 29 | return builder.build(), 200, {"content-type": "application/activity+json"} 30 | 31 | 32 | async def ordered_collection_page(url, count_coroutine, items_coroutine, **kwargs): 33 | builder = build_ordered_collection_page(url + "?" + urlencode(kwargs), url) 34 | 35 | data = await items_coroutine(**kwargs) 36 | 37 | if "prev" in data: 38 | builder = builder.with_prev(f"{url}?{data['prev']}") 39 | 40 | if "next" in data: 41 | builder = builder.with_next(f"{url}?{data['next']}") 42 | 43 | builder = builder.with_items(data["items"]) 44 | 45 | return builder.build(), 200, {"content-type": "application/activity+json"} 46 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/utils/test.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from bovine_store.store import ObjectStore 6 | 7 | 8 | @pytest.fixture 9 | async def store(): 10 | db_file = "test_db.db" 11 | db_url = f"sqlite://{db_file}" 12 | store = ObjectStore(db_url) 13 | 14 | await store.init_connection() 15 | 16 | yield store 17 | await store.close_connection() 18 | os.unlink(db_file) 19 | -------------------------------------------------------------------------------- /bovine_store/bovine_store/utils/test_summary.py: -------------------------------------------------------------------------------- 1 | from . import determine_summary 2 | 3 | 4 | def test_determine_summary(): 5 | assert determine_summary({}) is None 6 | assert determine_summary({"name": "name"}) == "name" 7 | assert determine_summary({"content": "content"}) == "content" 8 | assert determine_summary({"summary": "summary"}) == "summary" 9 | -------------------------------------------------------------------------------- /bovine_store/context_cache.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelgeKrueger/bovine/4ba2a83d1b4104ebffaaca357fbc9c225ffb06bf/bovine_store/context_cache.sqlite -------------------------------------------------------------------------------- /bovine_store/examples/create_table.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from tortoise import Tortoise 4 | 5 | 6 | async def create_table(): 7 | db_file = "db.sqlite3" 8 | db_url = f"sqlite://{db_file}" 9 | 10 | await Tortoise.init( 11 | db_url=db_url, 12 | modules={"models": ["bovine_store.models"]}, 13 | ) 14 | await Tortoise.generate_schemas() 15 | await Tortoise.close_connections() 16 | 17 | 18 | asyncio.run(create_table()) 19 | -------------------------------------------------------------------------------- /bovine_store/examples/templates: -------------------------------------------------------------------------------- 1 | ../templates/ -------------------------------------------------------------------------------- /bovine_store/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "bovine-store" 3 | version = "0.1.0-alpha" 4 | description = "Store for ActivityPub activities and objects" 5 | authors = ["Helge "] 6 | license = "MIT" 7 | readme = "README.md" 8 | packages = [{include = "bovine_store"}] 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.10" 12 | tortoise-orm = "^0.19.3" 13 | pyld = {git = "https://github.com/HelgeKrueger/pyld.git"} 14 | aiohttp = "^3.8.3" 15 | requests = "^2.28.2" 16 | requests-cache = "^0.9.8" 17 | quart = "^0.18.3" 18 | bovine = {path = "../bovine", develop = true} 19 | quart-auth = "^0.8.0" 20 | 21 | 22 | [tool.poetry.group.dev.dependencies] 23 | pytest = "^7.2.1" 24 | flake8 = "^6.0.0" 25 | flake8-black = "^0.3.6" 26 | black = "^23.1.0" 27 | isort = "^5.12.0" 28 | pytest-asyncio = "^0.20.3" 29 | 30 | [build-system] 31 | requires = ["poetry-core"] 32 | build-backend = "poetry.core.masonry.api" 33 | 34 | [tool.pytest.ini_options] 35 | asyncio_mode="auto" 36 | log_cli= 1 37 | log_cli_level="info" 38 | -------------------------------------------------------------------------------- /bovine_store/setup.cfg: -------------------------------------------------------------------------------- 1 | 2 | [flake8] 3 | max-line-length = 88 4 | extend-ignore = E203, 5 | -------------------------------------------------------------------------------- /bovine_store/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelgeKrueger/bovine/4ba2a83d1b4104ebffaaca357fbc9c225ffb06bf/bovine_store/tests/__init__.py -------------------------------------------------------------------------------- /bovine_store/tests/app_env.py: -------------------------------------------------------------------------------- 1 | from quart import Quart 2 | 3 | from bovine_store.blueprint import bovine_store_blueprint 4 | 5 | app = Quart(__name__) 6 | 7 | app.register_blueprint(bovine_store_blueprint) 8 | -------------------------------------------------------------------------------- /bovine_store/tests/test_retrieve.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import uuid 3 | 4 | from quart import g 5 | 6 | from .app_env import app 7 | 8 | 9 | @pytest.mark.skip("FIXME") 10 | async def test_retrieve(): 11 | client = app.test_client() 12 | 13 | async with app.app_context(): 14 | g.signature_result = "test" 15 | 16 | response = await client.get("/" + str(uuid.uuid4())) 17 | 18 | assert response.status_code == 200 19 | -------------------------------------------------------------------------------- /bovine_user/bovine_config.toml: -------------------------------------------------------------------------------- 1 | ../bovine_config.toml -------------------------------------------------------------------------------- /bovine_user/bovine_user/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0-alpha" 2 | -------------------------------------------------------------------------------- /bovine_user/bovine_user/config.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from urllib.parse import urljoin 3 | 4 | import aiohttp 5 | import tomli 6 | 7 | from .manager import BovineUserManager 8 | 9 | 10 | async def configure_bovine_user(app): 11 | with open("bovine_config.toml", "rb") as fp: 12 | config_data = tomli.load(fp) 13 | 14 | if "session" not in app.config: 15 | app.config["session"] = aiohttp.ClientSession() 16 | 17 | app.secret_key = config_data["bovine"]["secret_key"] 18 | 19 | app.config["bovine_user_hello_client_id"] = config_data["bovine_user"][ 20 | "hello_client_id" 21 | ] 22 | 23 | app.config["host"] = config_data["bovine"]["host"] 24 | 25 | app.config["bovine_user_nonce"] = secrets.token_urlsafe(32) 26 | 27 | endpoints = urljoin(config_data["bovine"]["host"], "endpoints/template") 28 | app.config["bovine_user_manager"] = BovineUserManager(endpoints) 29 | -------------------------------------------------------------------------------- /bovine_user/bovine_user/hello_auth.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from urllib.parse import urlencode, urljoin 4 | 5 | from quart import Blueprint, current_app, redirect, request 6 | from quart_auth import AuthUser, login_user 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | hello_auth = Blueprint("hello_auth", __name__) 12 | 13 | 14 | @hello_auth.get("/login") 15 | async def hello_login(): 16 | client_id = current_app.config["bovine_user_hello_client_id"] 17 | nonce = current_app.config["bovine_user_nonce"] 18 | 19 | redirect_uri = urljoin(current_app.config["host"], request.path) 20 | 21 | url_to_open = "https://wallet.hello.coop/authorize?" + urlencode( 22 | { 23 | "client_id": client_id, 24 | "nonce": nonce, 25 | "redirect_uri": redirect_uri, 26 | "response_mode": "form_post", 27 | "response_type": "id_token", 28 | "scope": "openid", 29 | } 30 | ).replace("%2B", "+") 31 | 32 | return redirect(url_to_open) 33 | 34 | 35 | @hello_auth.post("/login") 36 | async def hello_id_token(): 37 | await request.get_data(parse_form_data=True) 38 | 39 | id_token = (await request.form)["id_token"] 40 | client_id = current_app.config["bovine_user_hello_client_id"] 41 | nonce = current_app.config["bovine_user_nonce"] 42 | 43 | session = current_app.config["session"] 44 | validation_result = await session.post( 45 | "https://wallet.hello.coop/oauth/introspect", 46 | data={"token": id_token, "client_id": client_id, "nonce": nonce}, 47 | ) 48 | 49 | validation_parsed = json.loads(await validation_result.text()) 50 | 51 | try: 52 | sub = validation_parsed["sub"] 53 | 54 | logger.info("Signed in with %s", sub) 55 | 56 | except Exception as ex: 57 | logger.warning(validation_parsed) 58 | logger.warning(ex) 59 | 60 | login_user(AuthUser(sub)) 61 | 62 | return redirect("/buffalo/") 63 | -------------------------------------------------------------------------------- /bovine_user/bovine_user/models.py: -------------------------------------------------------------------------------- 1 | from tortoise import fields 2 | from tortoise.models import Model 3 | 4 | from .types import EndpointType 5 | 6 | 7 | class BovineUser(Model): 8 | id = fields.IntField(pk=True) 9 | 10 | hello_sub = fields.CharField(max_length=255) 11 | handle_name = fields.CharField(max_length=255, unique=True) 12 | 13 | created = fields.DatetimeField(auto_now_add=True) 14 | last_sign_in = fields.DatetimeField(auto_now=True) 15 | 16 | 17 | class BovineUserEndpoint(Model): 18 | id = fields.IntField(pk=True) 19 | 20 | bovine_user = fields.ForeignKeyField("models.BovineUser", related_name="endpoints") 21 | 22 | endpoint_type = fields.CharEnumField(enum_type=EndpointType) 23 | stream_name = fields.CharField(max_length=255) 24 | name = fields.CharField(max_length=255) 25 | 26 | 27 | class BovineUserProperty(Model): 28 | id = fields.IntField(pk=True) 29 | 30 | bovine_user = fields.ForeignKeyField("models.BovineUser", related_name="properties") 31 | 32 | name = fields.CharField(max_length=255) 33 | value = fields.JSONField() 34 | 35 | 36 | class BovineUserKeyPair(Model): 37 | id = fields.IntField(pk=True) 38 | 39 | bovine_user = fields.ForeignKeyField("models.BovineUser", related_name="keypairs") 40 | 41 | name = fields.CharField(max_length=255) 42 | 43 | private_key = fields.TextField(null=True) 44 | public_key = fields.TextField() 45 | -------------------------------------------------------------------------------- /bovine_user/bovine_user/test_manager.py: -------------------------------------------------------------------------------- 1 | from bovine.activitypub.actor import ActivityPubActor 2 | from bovine.activitystreams.actor_builder import ActorBuilder 3 | 4 | from bovine_user.utils.test import db_url # noqa F401 5 | from examples.basic_app import app 6 | 7 | from .manager import BovineUserManager 8 | 9 | 10 | async def test_bovine_user_manager(db_url): # noqa F811 11 | uuid = "uuid" 12 | 13 | manager = BovineUserManager("https://my_domain") 14 | 15 | assert await manager.get(uuid) is None 16 | 17 | user = await manager.register(uuid, "handle") 18 | assert user.handle_name == "handle" 19 | 20 | assert await manager.get(uuid) == user 21 | assert await manager.get_user_for_name("handle") 22 | 23 | 24 | async def test_endpoint(db_url): # noqa F811 25 | uuid = "uuid" 26 | 27 | manager = BovineUserManager("https://my_domain") 28 | 29 | user = await manager.register(uuid, "handle") 30 | 31 | first_endpoint = user.endpoints[0].name 32 | 33 | info = await manager.resolve_endpoint(first_endpoint) 34 | 35 | assert info.bovine_user.handle_name == "handle" 36 | 37 | 38 | async def test_get_acitivity_pub(db_url): # noqa F811 39 | async with app.app_context(): 40 | app.config["session"] = "session" 41 | uuid = "uuid" 42 | 43 | manager = BovineUserManager("https://my_domain") 44 | 45 | await manager.register(uuid, "handle") 46 | 47 | activity_pub_actor, actor = await manager.get_activity_pub(uuid) 48 | 49 | assert isinstance(activity_pub_actor, ActivityPubActor) 50 | assert isinstance(actor, ActorBuilder) 51 | 52 | 53 | async def test_bovine_user_manager_user_count(db_url): # noqa F811 54 | manager = BovineUserManager("https://my_domain") 55 | assert await manager.user_count() == 0 56 | 57 | await manager.register("uuid", "handle") 58 | assert await manager.user_count() == 1 59 | -------------------------------------------------------------------------------- /bovine_user/bovine_user/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class EndpointType(Enum): 5 | ACTOR = "ACTOR" 6 | INBOX = "INBOX" 7 | OUTBOX = "OUTBOX" 8 | FOLLOWERS = "FOLLOWERS" 9 | FOLLOWING = "FOLLOWING" 10 | 11 | PROXY_URL = "PROXY_URL" 12 | EVENT_SOURCE = "EVENT_SOURCE" 13 | 14 | COLLECTION = "COLLECTION" 15 | -------------------------------------------------------------------------------- /bovine_user/bovine_user/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import tomli_w 2 | 3 | from bovine_user.types import EndpointType 4 | 5 | 6 | def create_toml_file(user): 7 | account_url = "" 8 | for endpoint in user.endpoints: 9 | if endpoint.endpoint_type == EndpointType.ACTOR: 10 | account_url = endpoint.name 11 | 12 | keypair = user.keypairs[0] 13 | 14 | data = { 15 | "account_url": account_url, 16 | "public_key_url": f"{account_url}#{keypair.name}", 17 | "private_key": keypair.private_key, 18 | } 19 | 20 | return tomli_w.dumps(data) + "\n" 21 | -------------------------------------------------------------------------------- /bovine_user/bovine_user/utils/test.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from tortoise import Tortoise 5 | 6 | 7 | @pytest.fixture 8 | async def db_url() -> str: 9 | db_file = "test_db.sqlite3" 10 | db_url = f"sqlite://{db_file}" 11 | 12 | await Tortoise.init( 13 | db_url=db_url, 14 | modules={"models": ["bovine_user.models"]}, 15 | ) 16 | await Tortoise.generate_schemas() 17 | 18 | yield db_url 19 | 20 | await Tortoise.close_connections() 21 | 22 | os.unlink(db_file) 23 | -------------------------------------------------------------------------------- /bovine_user/examples/basic_app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from quart import Quart 4 | from quart_auth import AuthManager 5 | from tortoise.contrib.quart import register_tortoise 6 | 7 | from bovine_user.config import configure_bovine_user 8 | from bovine_user.server import bovine_user_blueprint 9 | 10 | logging.basicConfig(level=logging.INFO) 11 | 12 | app = Quart(__name__) 13 | AuthManager(app) 14 | 15 | app.register_blueprint(bovine_user_blueprint) 16 | 17 | 18 | TORTOISE_ORM = { 19 | "connections": {"default": "sqlite://db.sqlite3"}, 20 | "apps": { 21 | "models": { 22 | "models": [ 23 | "bovine_user.models", 24 | ], 25 | "default_connection": "default", 26 | }, 27 | }, 28 | } 29 | 30 | register_tortoise( 31 | app, 32 | db_url=TORTOISE_ORM["connections"]["default"], 33 | modules={"models": TORTOISE_ORM["apps"]["models"]["models"]}, 34 | generate_schemas=False, 35 | ) 36 | 37 | 38 | @app.before_serving 39 | async def startup(): 40 | await configure_bovine_user(app) 41 | 42 | 43 | if __name__ == "__main__": 44 | app.run() 45 | -------------------------------------------------------------------------------- /bovine_user/examples/create_table.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from tortoise import Tortoise 4 | 5 | 6 | async def create_table(): 7 | db_file = "db.sqlite3" 8 | db_url = f"sqlite://{db_file}" 9 | 10 | await Tortoise.init( 11 | db_url=db_url, 12 | modules={"models": ["bovine_user.models"]}, 13 | ) 14 | await Tortoise.generate_schemas() 15 | await Tortoise.close_connections() 16 | 17 | 18 | asyncio.run(create_table()) 19 | -------------------------------------------------------------------------------- /bovine_user/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "bovine-user" 3 | version = "0.1.0-alpha" 4 | description = "User management for bovine" 5 | authors = ["Helge "] 6 | readme = "README.md" 7 | packages = [{include = "bovine_user"}] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.10" 11 | quart = "^0.18.3" 12 | aiohttp = "^3.8.4" 13 | tortoise-orm = "^0.19.3" 14 | quart-auth = "^0.8.0" 15 | bovine = {path = "../bovine", develop = true} 16 | tomli-w = "^1.0.0" 17 | quart-cors = "^0.6.0" 18 | 19 | 20 | [tool.poetry.group.env.dependencies] 21 | flake8 = "^6.0.0" 22 | black = "^23.1.0" 23 | flake8-black = "^0.3.6" 24 | 25 | 26 | [tool.poetry.group.dev.dependencies] 27 | pytest = "^7.2.1" 28 | pytest-asyncio = "^0.20.3" 29 | isort = "^5.12.0" 30 | 31 | [build-system] 32 | requires = ["poetry-core"] 33 | build-backend = "poetry.core.masonry.api" 34 | 35 | 36 | [tool.pytest.ini_options] 37 | asyncio_mode="auto" 38 | log_cli= 1 39 | -------------------------------------------------------------------------------- /bovine_user/setup.cfg: -------------------------------------------------------------------------------- 1 | 2 | [flake8] 3 | max-line-length = 88 4 | extend-ignore = E203, 5 | -------------------------------------------------------------------------------- /bovine_user/templates/create.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Welcome to Bovine

4 | 5 | Please note 6 |
    7 |
  • 8 | This server is in active development. All data might be deleted because 9 | an incorrectly implemented cleanup procedure. Inbox data will be deleted 10 | after a few days (like 3). 11 |
  • 12 |
  • Bovine Code
  • 13 |
  • Buffalo Code
  • 14 |
15 | 16 | Please choose a handle name. This handle will be used for your account in 17 | the form of "handle_name@domain". 18 | 19 |
20 | 21 | 22 |
23 | 24 |

Rules

25 |
    26 |
  1. Don't cause trouble
  2. 27 |
  3. Don't do anything illegal
  4. 28 |
  5. Generally be a nice person
  6. 29 |
  7. Help development
  8. 30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /docs/.prettierignore: -------------------------------------------------------------------------------- 1 | ** 2 | 3 | -------------------------------------------------------------------------------- /docs/actor.md: -------------------------------------------------------------------------------- 1 | # Actor 2 | 3 | The endpoint describing an actor, e.g. `https://mymath.rocks/activitypub/bovine` returns 4 | 5 | ``` 6 | { 7 | "@context": [ 8 | "https://www.w3.org/ns/activitystreams", 9 | "https://w3id.org/security/v1" 10 | ], 11 | "id": "https://mymath.rocks/activitypub/bovine", 12 | "inbox": "https://mymath.rocks/activitypub/bovine/inbox", 13 | "name": "bovine", 14 | "outbox": "https://mymath.rocks/activitypub/bovine/outbox", 15 | "preferredUsername": "bovine", 16 | "publicKey": { 17 | "id": "https://mymath.rocks/activitypub/bovine#main-key", 18 | "owner": "https://mymath.rocks/activitypub/bovine", 19 | "publicKeyPem": "-----BEGIN PUBLIC KEY-----....-----END PUBLIC KEY-----\n" 20 | }, 21 | "type": "Application" 22 | } 23 | ``` 24 | 25 | Two comments on this: 26 | 27 | - `publicKey` is required see [HTTP Signatures](http_signatures.md) 28 | - `preferredUsername` is required to interact with Mastodon. The reason is explained below 29 | 30 | ## PreferredUsername and Mastodon 31 | 32 | Mastodon doesn't seem to identify actors with their url, e.g. `https://mymath.rocks/activitypub/bovine`, 33 | instead it identifies them with their "username", e.g. `bovine@mymath.rocks`. This means that 34 | when looking up `bovine@mymath.rocks` on Mastodon the following requests happen: 35 | 36 | ``` 37 | GET /.well-known/webfinger?resource=acct:bovine@mymath.rocks 38 | GET /activitypub/bovine 39 | GET /.well-known/webfinger?resource=acct:$PREFERRED_USERNAME@mymath.rocks 40 | ... 41 | ``` 42 | 43 | This means that in order to interact with Mastodon, you need both the webfinger endpoint 44 | and specify the `preferredUsername` property in the actor object. Neither is 45 | required by ActivityPub as far as I can tell. 46 | -------------------------------------------------------------------------------- /docs/like_activity.md: -------------------------------------------------------------------------------- 1 | # Like Activity 2 | 3 | Here, I discuss the implementation choices, I made when designing a like activity. First an example: 4 | 5 | ``` 6 | { 7 | "@context": "https://www.w3.org/ns/activitystreams", 8 | "type": "Like", 9 | "id": "https://mymath.rocks/activitypub/helge/like-1234", 10 | "actor": "https://mymath.rocks/activitypub/helge", 11 | "content": "🐮", 12 | "object": "https://domain/users/name/activity", 13 | "to": ["https://domain/users/name"], 14 | "cc": ["https://www.w3.org/ns/activitystreams#Public"], 15 | "published": "2023-01-29T16:37:18.996Z" 16 | } 17 | 18 | ``` 19 | 20 | All properties exist in the [ActivityStreams Specification](https://www.w3.org/ns/activitystreams). 21 | Some of the background behind these choices can be found at [SocialHub](https://socialhub.activitypub.rocks/t/like-activity/2925). 22 | 23 | ## Display 24 | 25 | Display is handled by the Like component in `buffalo/src/components/timeline/Like.js`. 26 | 27 | ## Properties 28 | 29 | ### Content 30 | 31 | Following the choice made by MissKey / CalcKey, I use the content property to contain the emote. As bovine is bovine themed, the used emote is a cow face by default. 32 | 33 | **FIXME**: Citation needed 34 | 35 | ### To property 36 | 37 | As the activity is generated in the JavaScript code of `buffalo` and then send to the outbox, it is necessary to specify the `to` property of the like. 38 | 39 | ### cc as public 40 | 41 | This is mainly an indication that I am ok with my Like being displayed to the world, e.g. as an increased like count on the object. 42 | -------------------------------------------------------------------------------- /docs/make_specification.py: -------------------------------------------------------------------------------- 1 | from glob import glob 2 | 3 | 4 | def make_link(filename, line_number): 5 | short = filename.split("/")[-1] 6 | return f"- [{short}]({filename}#L{line_number})\n" 7 | 8 | 9 | all_tests = glob("../tests/**/*.py", recursive=True) 10 | 11 | tests = {} 12 | for name in all_tests: 13 | with open(name, "r") as f: 14 | tests[name] = f.readlines() 15 | 16 | with open("specification_template.md", "r") as f: 17 | with open("specification.md", "w") as fw: 18 | for line in f: 19 | fw.write(line) 20 | if line.startswith("####"): 21 | key = line[5:-1] 22 | for name, content in tests.items(): 23 | for line_number, line in enumerate(content, start=1): 24 | if key in line: 25 | fw.write(make_link(name, line_number)) 26 | -------------------------------------------------------------------------------- /docs/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "docs" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Helge "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.10" 10 | 11 | 12 | [tool.poetry.group.dev.dependencies] 13 | mdformat-gfm = "^0.3.5" 14 | 15 | [build-system] 16 | requires = ["poetry-core"] 17 | build-backend = "poetry.core.masonry.api" 18 | -------------------------------------------------------------------------------- /examples/cow_resisa/README.md: -------------------------------------------------------------------------------- 1 | # Bovine powered RSS to ActivityPub 2 | 3 | Sample project to create a [bovine](https://github.com/HelgeKrueger/bovine) that 4 | takes an RSS feed and converts it to ActivityPub. 5 | 6 | _Note_: Resisa needs Python 3.11 as `asyncio.TaskGroup` is being used. 7 | 8 | ## Generating keys and creating a user in bovine_blog 9 | 10 | For other platforms that allow authentication through HTTP Signatures, follow their 11 | instruction to obtain a user and their private key. 12 | 13 | Generate public and private keys 14 | 15 | ``` 16 | python generate_keys.py 17 | ``` 18 | 19 | This creates the files `private_key.pem` and `public_key.pem`. Uploads these to 20 | your bovine or similar installed activitypub server and use these to create 21 | a new user. For `bovine_blog` the following works 22 | 23 | ``` 24 | DOMAIN=$MY_DOMAIN poetry run python bovine_blog/scripts/add_user.py wordoftheday_merriamwebster --public_key $PUBLIC_KEY --private_key $PRIVATE_KEY 25 | ``` 26 | 27 | ## Configuration 28 | 29 | Configuration is done via a config file `resisa.toml`. It's format is: 30 | 31 | ``` 32 | [merriam_webster_word_of_the_day] 33 | 34 | feed_url = "https://www.merriam-webster.com/wotd/feed/rss2" 35 | account_url = "https://mymath.rocks/activitypub/wordoftheday_merriamwebster" 36 | outbox_url = "https://mymath.rocks/activitypub/wordoftheday_merriamwebster/outbox" 37 | public_key_url = "https://mymath.rocks/activitypub/wordoftheday_merriamwebster#main-key" 38 | private_key = """ 39 | -----BEGIN PRIVATE KEY----- 40 | .... 41 | -----END PRIVATE KEY----- 42 | """ 43 | ``` 44 | 45 | **Note**: Multiple entries are possible. Those are processed sequentially. 46 | 47 | ## Running 48 | 49 | Once it is done `resisa` can be run via 50 | 51 | ``` 52 | poetry run python resisa.py 53 | ``` 54 | 55 | Resisa ensures that the entries in the activitypub outbox match those in the RSS feed. 56 | -------------------------------------------------------------------------------- /examples/cow_resisa/generate_keys.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from bovine.utils.crypto import generate_public_private_key 4 | 5 | if os.path.exists("public_key.pem") or os.path.exists("private_key.pem"): 6 | print("Public or Private key file already exists.") 7 | exit(1) 8 | 9 | public_key, private_key = generate_public_private_key() 10 | 11 | with open("public_key.pem", "w") as f: 12 | f.write(public_key) 13 | with open("private_key.pem", "w") as f: 14 | f.write(private_key) 15 | -------------------------------------------------------------------------------- /examples/cow_resisa/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "cow-resisa" 3 | version = "0.1.0" 4 | description = "A RSS to ActivityPub bridge" 5 | authors = ["Helge "] 6 | license = "MIT" 7 | readme = "README.md" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.11" 11 | bovine_core = {path = "../../bovine_core", develop=true} 12 | feedparser = "^6.0.10" 13 | tomli = "^2.0.1" 14 | 15 | 16 | [tool.poetry.group.dev.dependencies] 17 | isort = "^5.12.0" 18 | black = "^23.1.0" 19 | flake8 = "^6.0.0" 20 | flake8-black = "^0.3.6" 21 | 22 | [build-system] 23 | requires = ["poetry-core"] 24 | build-backend = "poetry.core.masonry.api" 25 | -------------------------------------------------------------------------------- /examples/examples/media_storage_app.py: -------------------------------------------------------------------------------- 1 | from quart import Quart, request 2 | from quart_cors import cors, route_cors 3 | from tortoise.contrib.quart import register_tortoise 4 | 5 | from bovine_blog import TORTOISE_ORM 6 | from bovine_tortoise.storage import storage_blueprint 7 | from bovine_tortoise.storage.storage import Storage 8 | 9 | app = Quart(__name__) 10 | app.config["object_storage"] = Storage() 11 | app.register_blueprint(storage_blueprint) 12 | app = cors(app) 13 | 14 | 15 | @route_cors(allow_origin="*") 16 | @app.post("/add") 17 | async def my_post(): 18 | await request.get_data(parse_form_data=True) 19 | files = await request.files 20 | form = await request.form 21 | 22 | print(files.keys()) 23 | print(form["activity"]) 24 | 25 | for key in files.keys(): 26 | await app.config["object_storage"].add_object(key, files[key].read()) 27 | 28 | return "success", 200 29 | 30 | 31 | register_tortoise( 32 | app, 33 | db_url=TORTOISE_ORM["connections"]["default"], 34 | modules={"models": TORTOISE_ORM["apps"]["models"]["models"]}, 35 | generate_schemas=False, 36 | ) 37 | 38 | 39 | if __name__ == "__main__": 40 | app.run() 41 | -------------------------------------------------------------------------------- /examples/local_test_blog/README.md: -------------------------------------------------------------------------------- 1 | # local test blog 2 | 3 | This example illustrates `bovine_blog`. It can be used to adjust CSS styles in `static/styles.css`, which is a symlink. 4 | 5 | ## Usage 6 | 7 | The blog can be run by 8 | ``` 9 | poetry install 10 | poetry run python local_test_blog.py 11 | ``` 12 | -------------------------------------------------------------------------------- /examples/local_test_blog/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "local-test-blog" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Helge "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.10" 10 | bovine-blog = {path = "../../bovine_blog", develop=true} 11 | 12 | 13 | [build-system] 14 | requires = ["poetry-core"] 15 | build-backend = "poetry.core.masonry.api" 16 | -------------------------------------------------------------------------------- /examples/local_test_blog/static: -------------------------------------------------------------------------------- 1 | ../../bovine_blog/static -------------------------------------------------------------------------------- /examples/munching_cow/munching_cow_cleanup.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import aiohttp 4 | 5 | from dateutil.parser import parse 6 | from datetime import datetime, timedelta, timezone 7 | 8 | from bovine_core.clients.signed_http import signed_get, signed_post 9 | from bovine_core.activitystreams.activities import build_delete 10 | 11 | 12 | account_url = "https://mymath.rocks/activitypub/munchingcow" 13 | public_key_url = f"{account_url}#main-key" 14 | outbox_url = f"{account_url}/outbox" 15 | 16 | 17 | async def cleanup_outbox(public_key_url, private_key, outbox_url): 18 | cut_off = datetime.now(tz=timezone.utc) - timedelta(hours=12) 19 | async with aiohttp.ClientSession() as session: 20 | response = await signed_get(session, public_key_url, private_key, outbox_url) 21 | data = json.loads(await response.text()) 22 | 23 | for item in data["orderedItems"]: 24 | if parse(item["published"]) < cut_off: 25 | object_id = item["object"]["id"] 26 | 27 | delete = build_delete(account_url, object_id).build() 28 | 29 | response = await signed_post( 30 | session, public_key_url, private_key, outbox_url, json.dumps(delete) 31 | ) 32 | 33 | 34 | with open("../../.files/cow_private.pem", "r") as f: 35 | private_key = f.read() 36 | 37 | asyncio.run(cleanup_outbox(public_key_url, private_key, outbox_url)) 38 | -------------------------------------------------------------------------------- /examples/munching_cow/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "munching-cow" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Helge "] 6 | readme = "README.md" 7 | packages = [{include = "munching_cow"}] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.10" 11 | bovine-core = {path = "../../bovine_core"} 12 | cowsay = "^5.0" 13 | markdown = "^3.4.1" 14 | 15 | 16 | [build-system] 17 | requires = ["poetry-core"] 18 | build-backend = "poetry.core.masonry.api" 19 | -------------------------------------------------------------------------------- /mechanical_bull/README.md: -------------------------------------------------------------------------------- 1 | # Mechanical Bull 2 | 3 | Mechanical Bull is an ActivityPub Client application that is distributed as part of bovine that takes over automating certain tasks. 4 | 5 | ## Supported automations 6 | 7 | - Accept Follow requests 8 | -------------------------------------------------------------------------------- /mechanical_bull/mechanical_bull/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | from datetime import datetime 4 | 5 | import aiohttp 6 | 7 | from bovine_core.activitypub import actor_from_file 8 | from .actions.handle_follow_request import handle_follow_request 9 | 10 | 11 | async def mechanical_bull(config_files): 12 | async with aiohttp.ClientSession() as session: 13 | for filename in config_files: 14 | actor = actor_from_file(filename, session) 15 | event_source = await actor.event_source() 16 | 17 | async for event in event_source: 18 | data = json.loads(event.data) 19 | await handle_follow_request(actor, data) 20 | -------------------------------------------------------------------------------- /mechanical_bull/mechanical_bull/actions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelgeKrueger/bovine/4ba2a83d1b4104ebffaaca357fbc9c225ffb06bf/mechanical_bull/mechanical_bull/actions/__init__.py -------------------------------------------------------------------------------- /mechanical_bull/mechanical_bull/actions/handle_follow_request.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | 4 | from bovine_core.activitystreams.activities import build_accept 5 | from bovine_core.activitypub.actor import ActivityPubActor 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | async def handle_follow_request(actor: ActivityPubActor, data: dict): 11 | if data["type"] != "Follow": 12 | return 13 | 14 | logger.info("Accepting follow request") 15 | 16 | accept = build_accept(actor.actor_id, data).build() 17 | 18 | await actor.send_to_outbox(accept) 19 | -------------------------------------------------------------------------------- /mechanical_bull/mechanical_bull/actions/test_handle_follow_request.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | from bovine_core.activitystreams.activities import build_follow 4 | 5 | from .handle_follow_request import handle_follow_request 6 | 7 | 8 | async def test_does_nothing_on_random_activity(): 9 | data = {"type": "Note"} 10 | 11 | client = AsyncMock() 12 | 13 | await handle_follow_request(client, data) 14 | 15 | client.send_to_outbox.assert_not_awaited() 16 | 17 | 18 | async def test_replies_to_follow_with_accept(): 19 | data = build_follow("domain", "actor", "tofollow").build() 20 | client = AsyncMock() 21 | 22 | await handle_follow_request(client, data) 23 | 24 | client.send_to_outbox.assert_awaited_once() 25 | -------------------------------------------------------------------------------- /mechanical_bull/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "mechanical-bull" 3 | version = "0.0.1" 4 | description = "" 5 | authors = ["Helge "] 6 | readme = "README.md" 7 | packages = [{include = "mechanical_bull"}] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.11" 11 | bovine-core = {path = "../bovine_core", develop = true} 12 | tomli = "^2.0.1" 13 | 14 | 15 | [tool.poetry.group.dev.dependencies] 16 | pytest = "^7.2.1" 17 | pytest-asyncio = "^0.20.3" 18 | 19 | [build-system] 20 | requires = ["poetry-core"] 21 | build-backend = "poetry.core.masonry.api" 22 | 23 | 24 | [tool.pytest.ini_options] 25 | asyncio_mode="auto" 26 | log_cli= 1 27 | log_cli_level="info" -------------------------------------------------------------------------------- /mechanical_bull/run.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from mechanical_bull import mechanical_bull 5 | 6 | log_format = "[%(asctime)s] %(levelname)-8s %(name)-12s %(message)s" 7 | logging.basicConfig( 8 | level=logging.INFO, 9 | format=log_format, 10 | filename=("mechanical_bull.log"), 11 | ) 12 | 13 | asyncio.run(mechanical_bull(["h.toml"])) 14 | -------------------------------------------------------------------------------- /notebooks/data: -------------------------------------------------------------------------------- 1 | ../tests/data -------------------------------------------------------------------------------- /notebooks/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "notebooks" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Helge "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.10" 10 | jupyter = "^1.0.0" 11 | rdflib = "^6.2.0" 12 | requests = "^2.28.2" 13 | aiohttp = "^3.8.3" 14 | bovine = {path = "../bovine", develop = true} 15 | pyld = {git = "https://github.com/HelgeKrueger/pyld.git"} 16 | 17 | 18 | [build-system] 19 | requires = ["poetry-core"] 20 | build-backend = "poetry.core.masonry.api" 21 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Tests 2 | 3 | This folder contains the structure to run more complicated 4 | tests. The goal is to provide sufficient coverage that if 5 | these tests pass, it is safe to deploy to production. 6 | 7 | Tests can be run via 8 | 9 | ```bash 10 | poetry run pytest 11 | ``` 12 | 13 | ## Test data 14 | 15 | The folder `data` contains json files containing sample activities 16 | collected from traffic. They are used to provide realistic results 17 | when running the tests. 18 | -------------------------------------------------------------------------------- /tests/bovine_config.toml: -------------------------------------------------------------------------------- 1 | ../bovine_config.toml -------------------------------------------------------------------------------- /tests/context_cache.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelgeKrueger/bovine/4ba2a83d1b4104ebffaaca357fbc9c225ffb06bf/tests/context_cache.sqlite -------------------------------------------------------------------------------- /tests/data/buffalo_create_note_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://www.w3.org/ns/activitystreams", 4 | { 5 | "atomUri": "ostatus:atomUri", 6 | "inReplyToAtomUri": "ostatus:inReplyToAtomUri", 7 | "conversation": "ostatus:conversation", 8 | "ostatus": "http://ostatus.org#" 9 | } 10 | ], 11 | "actor": "https://my_domain/activitypub/name", 12 | "cc": ["https://my_domain/activitypub/name/followers"], 13 | "id": "https://my_domain/activitypub/name/3c0281b7-bede-460a-a49b-3b6d7d4eb32f/activity", 14 | "object": { 15 | "atomUri": "https://my_domain/activitypub/name/3c0281b7-bede-460a-a49b-3b6d7d4eb32f", 16 | "attachment": [], 17 | "attributedTo": "https://my_domain/activitypub/name", 18 | "cc": ["https://my_domain/activitypub/name/followers"], 19 | "content": "

I'm literally creating test data.

\n", 20 | "contentMap": { "en": "

I'm literally creating test data.

\n" }, 21 | "id": "https://my_domain/activitypub/name/3c0281b7-bede-460a-a49b-3b6d7d4eb32f", 22 | "published": "2023-01-31T17:23:44.772Z", 23 | "tag": [], 24 | "replies": { "type": "Collection", "totalItems": 0, "items": [] }, 25 | "source": { 26 | "content": "I'm literally creating test data.", 27 | "mediaType": "text/markdown" 28 | }, 29 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 30 | "type": "Note", 31 | "url": "https://my_domain/name/3c0281b7-bede-460a-a49b-3b6d7d4eb32f" 32 | }, 33 | "published": "2023-01-31T17:23:44.772Z", 34 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 35 | "type": "Create" 36 | } 37 | -------------------------------------------------------------------------------- /tests/data/mastodon_announce_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://www.w3.org/ns/activitystreams", 3 | "actor": "https://first_domain/users/first", 4 | "cc": [ 5 | "https://second_domain/users/second", 6 | "https://first_domain/users/first/followers" 7 | ], 8 | "id": "https://first_domain/users/first/statuses/1097854/activity", 9 | "object": "https://second_domain/users/second/statuses/109724234853", 10 | "published": "2023-01-31T19:11:46Z", 11 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 12 | "type": "Announce" 13 | } 14 | -------------------------------------------------------------------------------- /tests/data/mastodon_announce_1_undo.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://www.w3.org/ns/activitystreams", 3 | "actor": "https://first_domain/users/first", 4 | "id": "https://first_domain/users/first/statuses/1097854/activity/undo", 5 | "object": { 6 | "actor": "https://first_domain/users/john", 7 | "cc": [ 8 | "https://first_domain/users/john/followers", 9 | "https://second_domain/users/second" 10 | ], 11 | "id": "https://first_domain/users/first/statuses/1097854/activity", 12 | "object": "https://second_domain/users/second/statuses/109724234853", 13 | "published": "2023-01-31T19:11:46Z", 14 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 15 | "type": "Announce" 16 | }, 17 | "signature": { 18 | "created": "2023-02-01T09:41:09Z", 19 | "creator": "https://first_domain/users/first#main-key", 20 | "signatureValue": "invalid==", 21 | "type": "RsaSignature2017" 22 | }, 23 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 24 | "type": "Undo" 25 | } 26 | -------------------------------------------------------------------------------- /tests/data/mastodon_delete_actor_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://www.w3.org/ns/activitystreams", 3 | "id": "https://mastodon/users/Alice#delete", 4 | "type": "Delete", 5 | "actor": "https://mastodon/users/Alice", 6 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 7 | "object": "https://mastodon/users/Alice", 8 | "signature": { 9 | "type": "RsaSignature2017", 10 | "creator": "https://mastodon/users/Alice#main-key", 11 | "created": "2023-01-29T12:46:36Z", 12 | "signatureValue": "unreadable==" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/data/mastodon_flow_2_delete_note.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://www.w3.org/ns/activitystreams", 4 | { "ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri" } 5 | ], 6 | "id": "https://mastodon/users/remote-user/statuses/109820990504#delete", 7 | "type": "Delete", 8 | "actor": "https://mastodon/users/remote-user", 9 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 10 | "object": { 11 | "id": "https://mastodon/users/remote-user/statuses/109820990504", 12 | "type": "Tombstone", 13 | "atomUri": "https://mastodon/users/remote-user/statuses/109820990504" 14 | }, 15 | "signature": { 16 | "type": "RsaSignature2017", 17 | "creator": "https://mastodon/users/remote-user#main-key", 18 | "created": "2023-02-07T11:37:28Z", 19 | "signatureValue": "meaningless==" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/data/mastodon_follow_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://www.w3.org/ns/activitystreams", 3 | "id": "https://example.com/mastodon_follow_1.json", 4 | "type": "Follow", 5 | "actor": "https://example.com/users/JohnMastodon", 6 | "object": "https://my_domain/activitypub/user" 7 | } 8 | -------------------------------------------------------------------------------- /tests/data/mastodon_like_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://www.w3.org/ns/activitystreams", 3 | "actor": "https://other_domain/users/phoenix", 4 | "id": "https://other_domain/users/phoenix#likes/557", 5 | "object": "https://my_domain/activitypub/helge/id567", 6 | "type": "Like" 7 | } 8 | -------------------------------------------------------------------------------- /tests/data/mastodon_like_1_undo.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://www.w3.org/ns/activitystreams", 3 | "actor": "https://other_domain/users/phoenix", 4 | "id": "https://other_domain/users/phoenix#likes/557/undo", 5 | "object": { 6 | "actor": "https://other_domain/users/phoenix", 7 | "id": "https://other_domain/users/phoenix#likes/557", 8 | "object": "https://my_domain/activitypub/helge/id567", 9 | "type": "Like" 10 | }, 11 | "type": "Undo" 12 | } 13 | -------------------------------------------------------------------------------- /tests/data/mitra_follow.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://www.w3.org/ns/activitystreams", 4 | "https://w3id.org/security/v1", 5 | "https://w3id.org/security/data-integrity/v1", 6 | { 7 | "MitraJcsRsaSignature2022": "mitra:MitraJcsRsaSignature2022", 8 | "mitra": "http://mitra.social#", 9 | "proofPurpose": "sec:proofPurpose", 10 | "proofValue": "sec:proofValue", 11 | "verificationMethod": "sec:verificationMethod" 12 | } 13 | ], 14 | "actor": "https://mitra.social/users/silverpill", 15 | "id": "https://mitra.social/objects/01867438-0990-95d8-82d9-a3597fa5a28c", 16 | "object": "https://mymath.rocks/activitypub/helge", 17 | "proof": { 18 | "created": "2023-02-21T13:44:39.093417671Z", 19 | "proofPurpose": "assertionMethod", 20 | "proofValue": "z2K6Y74KDNqueGSC75KqB5mgaWYxHMAMTtJ5gbiWtztJpsuJctw5LUKGpQD5QyTQ4BpTDYryS5S2xfGTHNh6gBkouMGq2fQAb7RaGwgBAjbGQC7L6dpEUPPJw1k6kJJvBPCAZdL61ND38FwZStT2LHoSgjVkMn15rzQJbT6o5J9n1CzhtL73NCguDDtr346EdcQXyLoHXEiTKWTDfjA2MF85WUptCnYfJSCkpKMdJQgvdZQtuTnn9TjzuhJM3gv6WoviAToQohKGzvcearrkpfLUbH9se44ry5p8UZwhfciB2mYy65igpoB2R9mC8ADaTrcc2yXKVuzxiXFAURPv7QLThSRz1Bw", 21 | "type": "MitraJcsRsaSignature2022", 22 | "verificationMethod": "https://mitra.social/users/silverpill#main-key" 23 | }, 24 | "to": ["https://mymath.rocks/activitypub/helge"], 25 | "type": "Follow" 26 | } 27 | -------------------------------------------------------------------------------- /tests/data/munching_cow_create_note_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://www.w3.org/ns/activitystreams", 3 | "actor": "https://my_domain/activitypub/munchingcow", 4 | "cc": ["https://my_domain/activitypub/munchingcow/followers"], 5 | "id": "https://my_domain/activitypub/munchingcow/9c1d3b44-c6f6-4310-9113-a0c3d3208cac/activity", 6 | "object": { 7 | "@context": "https://www.w3.org/ns/activitystreams", 8 | "attributedTo": "https://my_domain/activitypub/munchingcow", 9 | "cc": ["https://my_domain/activitypub/munchingcow/followers"], 10 | "content": "

TEST

", 11 | "id": "https://my_domain/activitypub/munchingcow/9c1d3b44-c6f6-4310-9113-a0c3d3208cac", 12 | "inReplyTo": null, 13 | "published": "2023-01-25T16:00:39Z", 14 | "source": { 15 | "content": "TEST ", 16 | "mediaType": "text/markdown" 17 | }, 18 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 19 | "type": "Note" 20 | }, 21 | "published": "2023-01-25T16:00:39Z", 22 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 23 | "type": "Create" 24 | } 25 | -------------------------------------------------------------------------------- /tests/data/munching_cow_delete_1.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://www.w3.org/ns/activitystreams", 4 | { "ostatus": "http://ostatus.org#", "atomUri": "ostatus:atomUri" } 5 | ], 6 | "id": "https://my_domain/activitypub/munchingcow/9c1d3b44-c6f6-4310-9113-a0c3d3208cac/delete", 7 | "type": "Delete", 8 | "actor": "https://my_domain/activitypub/munchingcow", 9 | "to": ["https://www.w3.org/ns/activitystreams#Public"], 10 | "object": { 11 | "type": "Tombstone", 12 | "id": "https://my_domain/activitypub/munchingcow/9c1d3b44-c6f6-4310-9113-a0c3d3208cac", 13 | "atomUri": "https://my_domain/activitypub/munchingcow/9c1d3b44-c6f6-4310-9113-a0c3d3208cac" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "tests" 3 | version = "0.0.1" 4 | description = "" 5 | authors = ["Helge "] 6 | readme = "../README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.10" 10 | bovine = {path = "../bovine", develop = true} 11 | bovine-fedi = {path = "../bovine_fedi", develop = true} 12 | bovine-store = {path = "../bovine_store", develop = true} 13 | bovine-user = {path = "../bovine_user", develop = true} 14 | bovine-process = {path = "../bovine_process", develop = true} 15 | 16 | 17 | [build-system] 18 | requires = ["poetry-core"] 19 | build-backend = "poetry.core.masonry.api" 20 | 21 | [tool.poetry.group.dev.dependencies] 22 | pytest = "^7.2.1" 23 | pytest-asyncio = "^0.20.3" 24 | flake8 = "^6.0.0" 25 | flake8-black = "^0.3.6" 26 | isort = "^5.12.0" 27 | black = "^23.1.0" 28 | jsonschema = "^4.17.3" 29 | 30 | 31 | 32 | [tool.poetry.group.test.dependencies] 33 | pytest-cov = "^4.0.0" 34 | 35 | [tool.pytest.ini_options] 36 | asyncio_mode="auto" 37 | log_cli= 1 38 | log_cli_level="info" 39 | 40 | 41 | -------------------------------------------------------------------------------- /tests/setup.cfg: -------------------------------------------------------------------------------- 1 | 2 | [flake8] 3 | max-line-length = 88 4 | extend-ignore = E203, 5 | -------------------------------------------------------------------------------- /tests/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HelgeKrueger/bovine/4ba2a83d1b4104ebffaaca357fbc9c225ffb06bf/tests/tests/__init__.py -------------------------------------------------------------------------------- /tests/tests/inbox/test_announce_then_undo_announce.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import AsyncMock 3 | 4 | import pytest 5 | 6 | from utils import get_activity_from_json 7 | from utils.blog_test_env import ( # noqa: F401 8 | blog_test_env, 9 | wait_for_number_of_entries_in_inbox, 10 | ) 11 | 12 | 13 | @pytest.mark.skip("FIXME: Not sure if still correct behavior") 14 | async def test_mastodon_announce_then_undo(blog_test_env): # noqa F811 15 | announce = get_activity_from_json("data/mastodon_announce_1.json") 16 | undo_item = get_activity_from_json("data/mastodon_announce_1_undo.json") 17 | 18 | # announce_id = announce["id"] 19 | 20 | mock_response = AsyncMock() 21 | 22 | blog_test_env.mock_signed_get.return_value = mock_response 23 | 24 | mock_response.text.return_value = json.dumps( 25 | get_activity_from_json("data/buffalo_create_note_1.json") 26 | ) 27 | 28 | result = await blog_test_env.send_to_inbox(announce) 29 | 30 | await wait_for_number_of_entries_in_inbox(blog_test_env, 2) 31 | 32 | # FIXME: Should fetch actual object 33 | 34 | blog_test_env.mock_signed_get.assert_awaited() 35 | 36 | # FIXME: Also actor objects are fetched, account for this 37 | 38 | result = await blog_test_env.send_to_inbox(undo_item) 39 | 40 | assert result.status_code == 202 41 | 42 | await wait_for_number_of_entries_in_inbox(blog_test_env, 1) 43 | -------------------------------------------------------------------------------- /tests/tests/inbox/test_create_then_update_note.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from utils import get_activity_from_json 4 | from utils.blog_test_env import ( # noqa: F401 5 | blog_test_env, 6 | wait_for_number_of_entries_in_inbox, 7 | ) 8 | 9 | 10 | async def test_create_then_update_note(blog_test_env): # noqa F811 11 | create_item = get_activity_from_json("data/mastodon_flow_1_create_note.json") 12 | update_item = get_activity_from_json("data/mastodon_flow_1_update_note.json") 13 | 14 | result = await blog_test_env.send_to_inbox(create_item) 15 | 16 | assert result.status_code == 202 17 | await wait_for_number_of_entries_in_inbox(blog_test_env, 1) 18 | 19 | inbox_content = await blog_test_env.get_from_inbox() 20 | assert len(inbox_content["orderedItems"]) == 1 21 | inbox_item = inbox_content["orderedItems"][0] 22 | 23 | assert inbox_item == "https://mastodon/users/john/statuses/9876/activity" 24 | 25 | result = await blog_test_env.send_to_inbox(update_item) 26 | 27 | await asyncio.sleep(0.3) 28 | 29 | # LABEL: ap-s2s-update 30 | await wait_for_number_of_entries_in_inbox(blog_test_env, 2) 31 | 32 | inbox_content = await blog_test_env.get_from_inbox() 33 | assert len(inbox_content["orderedItems"]) == 2 34 | inbox_item = inbox_content["orderedItems"][0] 35 | 36 | assert inbox_item == update_item["id"] 37 | 38 | # FIXME: Check updated object content 39 | -------------------------------------------------------------------------------- /tests/tests/inbox/test_delete_actor.py: -------------------------------------------------------------------------------- 1 | from utils import get_activity_from_json 2 | from utils.blog_test_env import blog_test_env # noqa F401 3 | 4 | 5 | async def test_mastodon_delete_actor(blog_test_env): # noqa F811 6 | data = get_activity_from_json("data/mastodon_delete_actor_1.json") 7 | 8 | result = await blog_test_env.send_to_inbox(data) 9 | 10 | assert result.status_code == 202 11 | 12 | result_get = await blog_test_env.get_from_inbox() 13 | 14 | assert result_get["type"] == "OrderedCollection" 15 | assert result_get["totalItems"] == 0 16 | -------------------------------------------------------------------------------- /tests/tests/inbox/test_flow_2_create_then_delete.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from utils import get_activity_from_json 3 | from utils.blog_test_env import ( # noqa F401 4 | blog_test_env, 5 | wait_for_number_of_entries_in_inbox, 6 | ) 7 | 8 | 9 | async def test_flow_2_mastodon_create_then_delete(blog_test_env): # noqa F811 10 | create_activity = get_activity_from_json("data/mastodon_flow_2_create_note.json") 11 | delete_activity = get_activity_from_json("data/mastodon_flow_2_delete_note.json") 12 | 13 | result = await blog_test_env.send_to_inbox(create_activity) 14 | assert result.status_code == 202 15 | 16 | await wait_for_number_of_entries_in_inbox(blog_test_env, 1) 17 | 18 | # LABEL: ap-s2s-delete 19 | result = await blog_test_env.send_to_inbox(delete_activity) 20 | assert result.status_code == 202 21 | 22 | await asyncio.sleep(0.4) 23 | 24 | result_get = await blog_test_env.get_from_inbox() 25 | 26 | assert result_get["type"] == "OrderedCollection" 27 | assert result_get["totalItems"] == 2 28 | 29 | delete, create = result_get["orderedItems"] 30 | 31 | response = await blog_test_env.proxy(delete) 32 | assert response["type"] == "Delete" 33 | 34 | # FIXME: Why does this fail? 35 | object_to_delete = response["object"]["id"] 36 | # tombstone = await blog_test_env.proxy(object_to_delete) 37 | 38 | tombstone = response["object"] 39 | 40 | assert tombstone["id"] == object_to_delete 41 | assert tombstone["type"] == "Tombstone" 42 | assert set(tombstone.keys()) == {"atomUri", "id", "type"} 43 | # FIXME Check content 44 | # FIXME: why atomid 45 | -------------------------------------------------------------------------------- /tests/tests/inbox/test_like_then_undo_like.py: -------------------------------------------------------------------------------- 1 | from utils import get_activity_from_json 2 | from utils.blog_test_env import ( # noqa F401 3 | blog_test_env, 4 | wait_for_number_of_entries_in_inbox, 5 | ) 6 | 7 | 8 | async def test_mastodon_like_then_undo(blog_test_env): # noqa F811 9 | like_item = get_activity_from_json("data/mastodon_like_1.json") 10 | undo_item = get_activity_from_json("data/mastodon_like_1_undo.json") 11 | 12 | result = await blog_test_env.send_to_inbox(like_item) 13 | 14 | assert result.status_code == 202 15 | await wait_for_number_of_entries_in_inbox(blog_test_env, 1) 16 | 17 | inbox_content = await blog_test_env.get_from_inbox() 18 | assert len(inbox_content["orderedItems"]) == 1 19 | inbox_item = inbox_content["orderedItems"][0] 20 | 21 | assert inbox_item == like_item["id"] 22 | 23 | item_in_inbox = await blog_test_env.proxy(inbox_item) 24 | 25 | assert item_in_inbox == like_item 26 | 27 | result = await blog_test_env.send_to_inbox(undo_item) 28 | 29 | assert result.status_code == 202 30 | await wait_for_number_of_entries_in_inbox(blog_test_env, 0) 31 | -------------------------------------------------------------------------------- /tests/tests/inbox/test_lone_undo_announce.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from utils import get_activity_from_json 4 | from utils.blog_test_env import ( # noqa: F401 5 | blog_test_env, 6 | wait_for_number_of_entries_in_inbox, 7 | ) 8 | 9 | 10 | async def test_mastodon_lone_undo_announce(blog_test_env): # noqa F811 11 | undo_item = get_activity_from_json("data/mastodon_announce_1_undo.json") 12 | 13 | result = await blog_test_env.send_to_inbox(undo_item) 14 | 15 | assert result.status_code == 202 16 | 17 | await asyncio.sleep(0.3) 18 | 19 | await wait_for_number_of_entries_in_inbox(blog_test_env, 0) 20 | -------------------------------------------------------------------------------- /tests/tests/outbox/test_create_note_and_send_note.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import AsyncMock 2 | 3 | import asyncio 4 | import json 5 | 6 | from bovine.activitystreams.activities import build_create 7 | from bovine.activitystreams.objects import build_note 8 | 9 | from utils.blog_test_env import ( # noqa: F401 10 | blog_test_env, 11 | ) 12 | 13 | 14 | async def test_create_and_send_note(blog_test_env): # noqa F811 15 | actor = blog_test_env.actor 16 | note = ( 17 | build_note(actor["id"], None, "some text").add_to("http://remote_user").build() 18 | ) 19 | create = build_create(note).build() 20 | 21 | response = AsyncMock() 22 | response.raise_for_status = lambda: 1 23 | response.text.return_value = json.dumps({"inbox": "http://remote_user/inbox"}) 24 | 25 | blog_test_env.mock_signed_get.return_value = response 26 | blog_test_env.mock_signed_post.return_value = response 27 | 28 | result = await blog_test_env.send_to_outbox(create) 29 | 30 | assert result.status_code == 201 31 | 32 | await asyncio.sleep(0.05) 33 | 34 | result = await blog_test_env.get_from_outbox() 35 | assert result["id"] == blog_test_env.actor["outbox"] 36 | assert result["type"] == "OrderedCollection" 37 | assert result["totalItems"] == 1 38 | 39 | blog_test_env.mock_signed_post.assert_awaited_once() 40 | -------------------------------------------------------------------------------- /tests/tests/outbox/test_create_then_delete.py: -------------------------------------------------------------------------------- 1 | from bovine.activitystreams.activities import build_delete 2 | 3 | from utils import get_activity_from_json 4 | from utils.blog_test_env import blog_test_env # noqa: F401 5 | 6 | 7 | async def test_create_then_delete(blog_test_env): # noqa F811 8 | create = get_activity_from_json("data/munching_cow_create_note_1.json") 9 | # using a fixed delete doesn't work well with ids being assigned by the 10 | # server 11 | # delete = get_activity_from_json("data/munching_cow_delete_1.json") 12 | 13 | result = await blog_test_env.send_to_outbox(create) 14 | 15 | assert result.status_code == 201 16 | 17 | result = await blog_test_env.get_from_outbox() 18 | assert result["type"] == "OrderedCollection" 19 | assert result["totalItems"] == 1 20 | 21 | item_id = result["orderedItems"][0] 22 | item = await blog_test_env.get_activity(item_id) 23 | 24 | delete = build_delete(blog_test_env.actor["id"], item["object"]).build() 25 | 26 | # ap-c2s-delete-activity 27 | result = await blog_test_env.send_to_outbox(delete) 28 | assert result.status_code == 201 29 | 30 | result = await blog_test_env.get_from_outbox() 31 | assert result["type"] == "OrderedCollection" 32 | assert result["totalItems"] == 2 33 | 34 | first_item = await blog_test_env.get_activity(result["orderedItems"][0]) 35 | object_item = await blog_test_env.get_activity(first_item["object"]) 36 | 37 | assert object_item["type"] == "Tombstone" 38 | -------------------------------------------------------------------------------- /tests/tests/outbox/test_follow_then_accept_added_to_following.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | import asyncio 5 | from unittest.mock import AsyncMock 6 | 7 | from bovine.activitystreams.activities import build_follow, build_accept 8 | 9 | from utils.blog_test_env import ( # noqa: F401 10 | blog_test_env, 11 | wait_for_number_of_entries_in_inbox, 12 | ) 13 | 14 | 15 | async def test_follow_then_accept_is_added_to_following(blog_test_env): # noqa F811 16 | remote_actor = "https://remote/actor" 17 | 18 | mock_response = AsyncMock() 19 | blog_test_env.mock_signed_get.return_value = mock_response 20 | mock_response.text.return_value = json.dumps( 21 | { 22 | "@context": "https://www.w3.org/ns/activitystreams", 23 | "id": remote_actor, 24 | "type": "Person", 25 | "inbox": f"{remote_actor}/inbox", 26 | } 27 | ) 28 | mock_response.raise_for_status = lambda: 1 29 | 30 | actor_id = blog_test_env.actor["id"] 31 | 32 | follow = build_follow("remote", actor_id, remote_actor).build() 33 | 34 | # LABEL: ap-c2s-status-code 35 | 36 | result = await blog_test_env.send_to_outbox(follow) 37 | assert result.status_code == 201 38 | assert "location" in result.headers 39 | follow["id"] = result.headers["location"] 40 | 41 | follow_from_server = await blog_test_env.get(follow["id"]) 42 | follow_from_server = await follow_from_server.get_json() 43 | 44 | assert follow_from_server["type"] == "Follow" 45 | assert follow_from_server["object"] == remote_actor 46 | 47 | await asyncio.sleep(0.3) 48 | 49 | accept = build_accept(remote_actor, follow).build() 50 | accept["object"] = follow["id"] 51 | 52 | result = await blog_test_env.send_to_inbox(accept) 53 | 54 | assert result.status_code == 202 55 | 56 | await wait_for_number_of_entries_in_inbox(blog_test_env, 1) 57 | 58 | following = await blog_test_env.get_activity(blog_test_env.actor["following"]) 59 | 60 | assert following["totalItems"] == 1 61 | assert following["orderedItems"] == [remote_actor] 62 | -------------------------------------------------------------------------------- /tests/tests/test_actor.py: -------------------------------------------------------------------------------- 1 | from utils.blog_test_env import blog_test_env # noqa: F401 2 | 3 | 4 | async def test_actor(blog_test_env): # noqa: F811 5 | result = await blog_test_env.get_activity(blog_test_env.actor["id"]) 6 | 7 | assert result["type"] == "Person" 8 | assert "inbox" in result 9 | assert "outbox" in result 10 | assert "endpoints" in result 11 | 12 | # LABEL: ap-actor-preferredUsername 13 | assert isinstance(result["preferredUsername"], str) 14 | 15 | inbox_result = await blog_test_env.get_activity(result["inbox"]) 16 | # LABEL: ap-actor-inbox 17 | assert inbox_result["type"] == "OrderedCollection" 18 | 19 | outbox_result = await blog_test_env.get_activity(result["outbox"]) 20 | # LABEL: ap-actor-outbox 21 | assert outbox_result["type"] == "OrderedCollection" 22 | 23 | # LABEL: ap-actor-following 24 | following_result = await blog_test_env.get_activity(result["following"]) 25 | # LABEL: ap-collections-following 26 | assert following_result["type"] == "OrderedCollection" 27 | 28 | # LABEL: ap-actor-followers 29 | followers_result = await blog_test_env.get_activity(result["followers"]) 30 | # LABEL: ap-collections-followers 31 | assert followers_result["type"] == "OrderedCollection" 32 | 33 | # LABEL: ap-actor-endpoints 34 | assert "endpoints" in result 35 | 36 | # LABEL: ap-actor-endpoints-proxyUrl 37 | assert "proxyUrl" in result["endpoints"] 38 | 39 | 40 | async def test_actor_web_visibility(blog_test_env): # noqa: F811 41 | blog_test_env.app.config["validate_signature"].return_value = None 42 | 43 | response = await blog_test_env.client.get(blog_test_env.actor["id"]) 44 | assert response.status_code == 200 45 | 46 | result = await response.get_json() 47 | 48 | assert "outbox" not in result 49 | assert "endpoints" not in result 50 | -------------------------------------------------------------------------------- /tests/tests/test_nodeinfo.py: -------------------------------------------------------------------------------- 1 | from utils.blog_test_env import blog_test_env # noqa: F401 2 | 3 | 4 | async def test_nodeinfo(blog_test_env): # noqa: F811 5 | result = await blog_test_env.get("/.well-known/nodeinfo") 6 | data = await result.get_json() 7 | url = data["links"][0]["href"] 8 | 9 | result = await blog_test_env.get(url) 10 | data = await result.get_json() 11 | 12 | user_count = data["usage"]["users"]["total"] 13 | 14 | assert user_count == 1 15 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | def get_activity_from_json(json_file_name): 5 | with open(json_file_name, "r") as f: 6 | return json.load(f) 7 | 8 | 9 | fake_post_headers = { 10 | "Content-Type": "application/activity+json", 11 | "Signature": "signature", 12 | "date": "date", 13 | "host": "host", 14 | "digest": "XXXxx", 15 | } 16 | 17 | fake_get_headers = { 18 | "Accept": "application/activity+json", 19 | "date": "date", 20 | "host": "host", 21 | } 22 | --------------------------------------------------------------------------------