├── .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 |
23 |
24 | Rules
25 |
26 | - Don't cause trouble
27 | - Don't do anything illegal
28 | - Generally be a nice person
29 | - Help development
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 |
--------------------------------------------------------------------------------