diff --git a/docs/guides/outbox.md b/docs/guides/outbox.md new file mode 100644 index 0000000..6290e02 --- /dev/null +++ b/docs/guides/outbox.md @@ -0,0 +1,131 @@ +# Using Outbox + +[Outbox](https://www.w3.org/wiki/ActivityPub/Primer/Outbox) is **required** by the specification, but most implementations recognize an Actor even if it doesn't have an Outbox. + +This guide explains how to implement a simple Outbox. + +## Define Empty Outbox + +First, you must define the Outbox in order to use it: + +```python +app.outbox("/users/{identifier}/outbox") +``` + +The Outbox is handled by subscribing to the special `Outbox` class from `apkit.types` using the `app.on` decorator: + +```python +from apkit.models import OrderedCollection, Person +from apkit.server.types import Context +from apkit.server.responses import ActivityResponse + +... + +person = Person( + ... + outbox="https://example.com/users/1/outbox" +) + +@app.on(Outbox) +async def listen_outbox(ctx: Context): + identifier = ctx.request.path_params.get("identifier") + col = OrderedCollection( + id=f"https://example.com/users/{identifier}/outbox", + total_items=0, + ordered_items=[] + ) + return ActivityResponse(col) +``` + +## Returning real data +In most cases, you don't actually need to return the contents. (However, some implementations use the contents of the outbox to count the number of posts.) + +However, this time let us retrieve and return actual data. + +```python +from datetime import datetime + +from apkit.models import Announce, Create, Delete, Note, Tombstone, Person, OrderedCollection, OrderedCollectionPage +from fastapi.responses import JSONResponse + +person = Person(id="https://example.com/users/alice") + +PAGE_SIZE = 20 +posts = [ + Announce( + id="https://example.com/users/alice/activities/4", + actor=person.id, + published=datetime(2026, 1, 3), + to=["https://www.w3.org/ns/activitystreams#Public"], + object="https://example.net/users/bob/notes/2", + ), + Delete( + id="https://example.com/users/alice/activities/3", + actor=person.id, + published=datetime(2026, 1, 2), + to=["https://www.w3.org/ns/activitystreams#Public"], + object=Tombstone( + id="https://example.com/users/alice/notes/2", + ), + ), + Create( + id="https://example.com/users/alice/activities/1", + actor=person.id, + published=datetime(2026, 1, 1), + to=["https://www.w3.org/ns/activitystreams#Public"], + object=Note( + id="https://example.com/users/alice/notes/1", + attributedTo="https://example.com/users/alice", + content="

Hello World!

", + published=datetime(2026, 1, 1), + to=["https://www.w3.org/ns/activitystreams#Public"] + ), + ) +] + +@app.on(Outbox) +async def listen_outbox(ctx: Context): + identifier = ctx.request.path_params.get("identifier") + if identifier != "alice": + return JSONResponse({"message": "Not Found"}, status_code=404) + outbox_url = f"https://example.com/users/{identifier}/outbox" + + is_page = ctx.request.query_params.get("page") == "true" + max_id = ctx.request.query_params.get("max_id") + + if not is_page: + col = OrderedCollection( + id=outbox_url, + total_items=len(posts), + first=f"{outbox_url}?page=true", + last=f"{outbox_url}?page=true&min_id={posts[-1].id}" if posts else None + ) + return ActivityResponse(col) + + start_index = 0 + if max_id: + for i, p in enumerate(posts): + if p.id == max_id: + start_index = i + 1 + break + + page_items = posts[start_index : start_index + PAGE_SIZE] + + next_url = None + if start_index + PAGE_SIZE < len(posts): + last_item_id = page_items[-1].id + next_url = f"{outbox_url}?page=true&max_id={last_item_id}" + + page = OrderedCollectionPage( + id=f"{outbox_url}?page=true" + (f"&max_id={max_id}" if max_id else ""), + part_of=outbox_url, + ordered_items=page_items, + next=next_url + ) + return ActivityResponse(page) + +``` + +!!! tips "What is `Tombstone`?" + + The `Tombstone` type indicates content that existed in the past but has now been deleted. By returning this object instead of completely removing the item from the Outbox, you can explicitly communicate to the remote server that "this post has been deleted." \ No newline at end of file diff --git a/examples/send_message.py b/examples/send_message.py index 03380af..5f55d1d 100644 --- a/examples/send_message.py +++ b/examples/send_message.py @@ -5,6 +5,7 @@ import uuid from datetime import UTC, datetime +from apmodel.vocab.mention import Mention from cryptography.hazmat.primitives import serialization as crypto_serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -106,13 +107,19 @@ async def send_note(recepient: str) -> None: logger.info(f"Found actor's inbox: {inbox_url}") # Create note + t = f'

@{target_actor.preferred_username}

' note = Note( id=f"https://{HOST}/notes/{uuid.uuid4()}", attributed_to=actor.id, - content="

Hello from apkit

", + content=f"

{t} Hello from apkit

", published=datetime.now(UTC).isoformat() + "Z", to=[target_actor.id], cc=["https://www.w3.org/ns/activitystreams#Public"], + tag=[ + Mention( + href=target_actor.url, name=f"@{target_actor.preferred_username}" + ) + ], ) # Create activity diff --git a/mkdocs.yml b/mkdocs.yml index 6f0d994..0776ca0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: apkit -site_url: https://fedi-libs.github.io/apkit +site_url: https://apkit.fedi-libs.org repo_url: https://github.com/fedi-libs/apkit edit_uri: edit/main/docs/ @@ -11,6 +11,7 @@ nav: - Configuration: guides/configuration.md - Client: guides/client.md - Server: guides/server.md + - Outbox: guides/outbox.md - Models: guides/models.md - KV Store: guides/kv_store.md - Nodeinfo: guides/nodeinfo.md diff --git a/pyproject.toml b/pyproject.toml index be40324..19a4193 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ dev = [ ] docs = [ "mkdocs>=1.6.1", - "mkdocs-material>=9.6.19", + "mkdocs-material==9.6.20", ] lint = [ "pyrefly>=0.46.0", diff --git a/uv.lock b/uv.lock index b092f0b..2f251ac 100644 --- a/uv.lock +++ b/uv.lock @@ -221,7 +221,7 @@ dev = [ ] docs = [ { name = "mkdocs", specifier = ">=1.6.1" }, - { name = "mkdocs-material", specifier = ">=9.6.19" }, + { name = "mkdocs-material", specifier = "==9.6.20" }, ] lint = [ { name = "pyrefly", specifier = ">=0.46.0" }, @@ -304,16 +304,16 @@ wheels = [ [[package]] name = "backrefs" -version = "6.1" +version = "5.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload-time = "2025-06-22T19:34:13.97Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" }, - { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" }, - { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" }, - { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" }, - { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, + { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload-time = "2025-06-22T19:34:05.252Z" }, + { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" }, + { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762, upload-time = "2025-06-22T19:34:11.037Z" }, + { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, ] [[package]] @@ -492,14 +492,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, ] [[package]] @@ -1167,11 +1167,12 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.7.1" +version = "9.6.20" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, { name = "backrefs" }, + { name = "click" }, { name = "colorama" }, { name = "jinja2" }, { name = "markdown" }, @@ -1182,9 +1183,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/ee/6ed7fc739bd7591485c8bec67d5984508d3f2733e708f32714c21593341a/mkdocs_material-9.6.20.tar.gz", hash = "sha256:e1f84d21ec5fb730673c4259b2e0d39f8d32a3fef613e3a8e7094b012d43e790", size = 4037822, upload-time = "2025-09-15T08:48:01.816Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/a31dd52e657bf12b20574706d07df8d767e1ab4340f9bfb9ce73950e5e59/mkdocs_material-9.6.20-py3-none-any.whl", hash = "sha256:b8d8c8b0444c7c06dd984b55ba456ce731f0035c5a1533cc86793618eb1e6c82", size = 9193367, upload-time = "2025-09-15T08:47:58.722Z" }, ] [[package]]