Compare commits

...

55 Commits

Author SHA1 Message Date
de1d2f7ec7 fix(clippy): remove unused PasswordHash import
Some checks failed
lint / lint (push) Failing after 8m45s
test / unit (push) Successful in 16m15s
2026-05-28 03:00:54 +02:00
e8d42b01dc fix: look up remote parent by ap_id to thread remote-to-remote replies
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-28 02:59:45 +02:00
4038a6b554 ci: remove failing integration tests job
Some checks failed
lint / lint (push) Failing after 7m13s
test / unit (push) Has been cancelled
2026-05-28 02:48:21 +02:00
be4a37546c refactor: delegate mark_follower_accepted/rejected through k-ap service, remove federation_repo from ApFederationAdapter
Some checks failed
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (push) Has been cancelled
2026-05-28 02:45:59 +02:00
7a2d8308d9 fix: extract initiate_actor_move use case — remove event publish from handler 2026-05-28 02:41:42 +02:00
4a5d5df884 fix(tests): update federation_management tests for EventPublisher arg 2026-05-28 02:34:30 +02:00
421cb463e3 feat: split accept/reject into DB+event; broadcast_move via event in API 2026-05-28 02:32:50 +02:00
925f4f8bf3 feat(worker): add FederationManagementHandler and wire into event loop 2026-05-28 02:30:22 +02:00
e5c8380ba7 feat(application): add FederationManagementEventService 2026-05-28 02:28:15 +02:00
97bc918bbc fix(bootstrap,worker): pass shared federation_repo to ApFederationAdapter 2026-05-28 02:26:57 +02:00
805240aaf8 feat(activitypub): add federation_repo field and thin DB-only methods to ApFederationAdapter 2026-05-28 02:24:43 +02:00
cd6148eff9 feat(domain): add mark_follower_accepted/rejected thin port methods 2026-05-28 02:22:52 +02:00
6f1a0572df fix(event-payload): correct NATS subjects for federation events 2026-05-28 02:20:43 +02:00
0841554dbe feat(domain): add RemoteFollowAccepted, RemoteFollowRejected, ActorMoved events 2026-05-28 02:19:46 +02:00
c30243f1c8 feat: add alsoKnownAs field to federation settings
Some checks failed
lint / lint (push) Failing after 7m10s
test / unit (push) Successful in 16m28s
test / integration (push) Failing after 17m52s
2026-05-28 02:01:56 +02:00
1ad02e0806 feat: add PATCH /federation/me/also-known-as endpoint
Some checks failed
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (push) Has been cancelled
Adds alsoKnownAs column to users table (migration 013), reads it in
the AP actor JSON, and exposes PATCH /federation/me/also-known-as to
set or clear it. Required pre-condition for broadcast_move.
2026-05-28 01:59:35 +02:00
23a8444b5c fix: update user URL handling in ThoughtsObjectHandler to use user_id
Some checks failed
lint / lint (push) Failing after 7m19s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-28 01:51:55 +02:00
b20b60ad10 fix: add broadcast_move stub to TestStore
Some checks failed
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (push) Has been cancelled
2026-05-28 01:50:37 +02:00
54bd2b60d0 feat: add POST /federation/me/move endpoint 2026-05-28 01:47:29 +02:00
94193f2d2e feat: bump k-ap to v0.1.9 and implement migrate_follower_actor 2026-05-28 01:43:06 +02:00
f54fb543b2 feat: update k-ap dependency to v0.1.8 and enhance middleware for ActivityPub requests 2026-05-28 01:08:45 +02:00
a460428be1 feat: update dependencies to k-ap v0.1.7 and add profileHref utility for user links
Some checks failed
lint / lint (push) Failing after 7m11s
test / unit (push) Successful in 16m59s
test / integration (push) Failing after 18m3s
2026-05-27 23:38:14 +02:00
95dea06c55 feat: add /about/fediverse info page with glass accordion panels 2026-05-27 23:38:14 +02:00
c085067318 feat: add Fediverse nav link 2026-05-27 23:38:14 +02:00
d831784489 feat: add copy handle button and fediverse info link to profile 2026-05-27 23:38:14 +02:00
4c203bed1d fix: handle clipboard errors and cleanup timeout in CopyButton 2026-05-27 23:38:14 +02:00
21b8684608 feat: add CopyButton client component 2026-05-27 23:38:14 +02:00
74eeb9fcb9 docs: replace activitypub-base with k-ap in architecture overview
Some checks failed
lint / lint (push) Failing after 7m3s
test / unit (push) Successful in 16m26s
test / integration (push) Failing after 17m39s
Reflects the migration from the local activitypub-base crate to the
external k-ap library, with an accurate description of what it provides.
2026-05-25 00:57:29 +02:00
7ee22ae79f feat: store AP note extensions in JSONB and render movies-diary posts as rich cards
Some checks failed
lint / lint (push) Failing after 7m24s
test / unit (push) Successful in 17m17s
test / integration (push) Failing after 18m2s
2026-05-24 04:29:04 +02:00
3f26456d77 feat: custom CSS editor with CodeMirror, live preview, and /docs/css reference
Some checks failed
lint / lint (push) Failing after 7m36s
test / unit (push) Successful in 17m12s
test / integration (push) Failing after 18m34s
2026-05-24 03:26:34 +02:00
379f31e27d fix(federation): include header_url as AP banner (image) in actor JSON
Some checks failed
lint / lint (push) Failing after 7m3s
test / unit (push) Successful in 16m12s
test / integration (push) Failing after 17m24s
2026-05-24 02:18:41 +02:00
9c99f7a7a8 feat: add image upload for avatar and banner
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
2026-05-24 02:06:47 +02:00
636d3d453d fix: resolve thoughts compile errors after k-ap migration
Some checks failed
lint / lint (push) Failing after 5m0s
test / unit (push) Failing after 4m59s
test / integration (push) Failing after 5m2s
2026-05-17 23:02:49 +02:00
9172c82d54 chore: move ap_ports into activitypub adapter, delete activitypub-base 2026-05-17 22:48:22 +02:00
cd2eb48ddb chore: switch activitypub-base to k-ap git dep 2026-05-17 22:47:32 +02:00
c5d9833c8b refactor: replace long arg lists with input/config structs and builder
Some checks failed
lint / lint (push) Failing after 7m8s
test / unit (push) Successful in 17m2s
test / integration (push) Failing after 17m47s
- Thought::new_local → NewThought struct (7 args → 1)
- UserWriter::update_profile → UpdateProfileInput struct (6 args → 2)
- update_profile use case → UpdateProfileInput (8 args → 3)
- ActivityPubService::new → builder pattern (9 args → 5 required + 4 optional setters)
- accept_note → AcceptNoteInput struct (8 args → 1)
- ThoughtNote::new_public → ThoughtNoteInput struct (8 args → 1)

Remove all #[allow(clippy::too_many_arguments)] annotations.
2026-05-17 12:25:53 +02:00
f39c1a614d clean up
Some checks failed
lint / lint (push) Failing after 7m18s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-17 12:15:27 +02:00
30c8a17168 clean up
Some checks failed
test / unit (push) Has been cancelled
lint / lint (push) Has been cancelled
test / integration (push) Has been cancelled
2026-05-17 12:14:45 +02:00
6a8c8b1fb8 chore: add pre-commit fmt+clippy hooks, fix clippy warnings 2026-05-17 12:09:24 +02:00
4ec0725ff8 fmt
Some checks failed
lint / lint (push) Failing after 5m3s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-17 12:04:51 +02:00
31e0f2958c fix: make ThoughtNote sensitive field optional (default false) 2026-05-17 12:02:58 +02:00
555121ea75 fix: promote worker event logs from debug to info 2026-05-17 12:02:13 +02:00
9e795eefdc fix: make ThoughtNote url field optional for AP compat
Some checks failed
lint / lint (push) Failing after 5m1s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
2026-05-17 11:57:10 +02:00
18cf2c9f54 feat: implement verify() for all stub activity handlers
Undo: inner activity actor must match Undo actor
Announce/Like/Block: verify_domains_match(activity_id, actor_url)
Add: attributedTo must match actor (same as Create/Update)
2026-05-17 11:55:17 +02:00
b58c96b843 feat: implement federation post/connections backfill schedulers
Some checks failed
lint / lint (push) Failing after 5m12s
test / integration (push) Has been cancelled
test / unit (push) Has been cancelled
schedule_actor_posts_fetch now spawns backfill_outbox in background,
fetching all pages of a remote outbox and persisting via accept_note.
schedule_connections_fetch follows AP collection next-links, resolves
profiles, and caches them in the DB. Both were no-ops ("deferred").

Add connections_repo field to ActivityPubService; wire both factories.
2026-05-17 11:49:53 +02:00
8ea24461ba feat: load more pagination for user profile thoughts 2026-05-16 15:21:18 +02:00
e14a9f90c8 fix: route local users to /users/{username} in remote connection lists 2026-05-16 15:17:58 +02:00
28756ef4cd feat: load more pagination for remote user posts 2026-05-16 15:14:53 +02:00
7f27ae49c3 fix: overflow-y scroll on html to prevent layout shift on dropdown open 2026-05-16 15:12:41 +02:00
59f3423c00 fix: break-all on fediverse handle to prevent overflow 2026-05-16 15:07:30 +02:00
c48aa33592 fix: scrollbar-gutter stable to prevent bg flicker on dropdown open 2026-05-16 15:05:28 +02:00
8f3aa4b891 fix: wrap background image in fixed div so it stays put on scroll 2026-05-16 15:03:41 +02:00
32bfb00970 feat: Frutiger Aero redesign — glass panels, Aero shimmer, interaction moments
Some checks failed
lint / lint (push) Failing after 5m7s
test / unit (push) Successful in 16m24s
test / integration (push) Failing after 18m14s
2026-05-16 14:55:51 +02:00
7ce2901c2a docs: add Frutiger Aero redesign implementation plan 2026-05-16 13:53:44 +02:00
8bbc713093 docs: add Frutiger Aero redesign spec 2026-05-16 13:46:25 +02:00
160 changed files with 5743 additions and 6003 deletions

18
.claude/settings.json Normal file
View File

@@ -0,0 +1,18 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git commit*)",
"command": "cargo fmt --all 2>&1 && cargo clippy --workspace 2>&1 || echo '{\"continue\": false, \"stopReason\": \"cargo fmt or clippy failed — fix before committing\"}'",
"timeout": 120,
"statusMessage": "Running cargo fmt + clippy..."
}
]
}
]
}
}

View File

@@ -24,5 +24,21 @@ RUST_ENV=development # set to "production" to disable AP debug mode
# but events will not be delivered to the worker) # but events will not be delivered to the worker)
# NATS_URL=nats://localhost:4222 # NATS_URL=nats://localhost:4222
# Media storage — local filesystem (default) or S3/MinIO
STORAGE_BACKEND=local
STORAGE_PATH=./media # required when STORAGE_BACKEND=local
# STORAGE_PREFIX= # optional key prefix
# S3/MinIO (set STORAGE_BACKEND=s3 to use)
# S3_ENDPOINT=http://localhost:9000
# S3_ACCESS_KEY_ID=minioadmin
# S3_SECRET_ACCESS_KEY=minioadmin
# S3_BUCKET=thoughts
# S3_REGION=us-east-1
# Upload limits (optional, defaults shown)
# UPLOAD_MAX_BYTES=5242880
# UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp,image/avif
# Logging # Logging
RUST_LOG=info RUST_LOG=info

View File

@@ -21,32 +21,3 @@ jobs:
--exclude postgres-federation \ --exclude postgres-federation \
--exclude postgres-search --exclude postgres-search
# Integration tests — require a real PostgreSQL instance.
# These test that the SQL queries in the adapter crates are correct.
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: thoughts_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/thoughts_test
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: integration tests
run: |
cargo test \
-p postgres \
-p postgres-federation \
-p postgres-search

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
.env .env
/target /target
/docs/superpowers/
/media

237
Cargo.lock generated
View File

@@ -5,22 +5,6 @@ version = 4
[[package]] [[package]]
name = "activitypub" name = "activitypub"
version = "0.1.0" version = "0.1.0"
dependencies = [
"activitypub-base",
"anyhow",
"async-trait",
"chrono",
"domain",
"serde",
"serde_json",
"tracing",
"url",
"uuid",
]
[[package]]
name = "activitypub-base"
version = "0.1.0"
dependencies = [ dependencies = [
"activitypub_federation", "activitypub_federation",
"anyhow", "anyhow",
@@ -28,9 +12,9 @@ dependencies = [
"axum", "axum",
"chrono", "chrono",
"domain", "domain",
"enum_delegate",
"futures", "futures",
"reqwest", "k-ap",
"reqwest 0.13.3",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@@ -63,12 +47,12 @@ dependencies = [
"http-signature-normalization", "http-signature-normalization",
"http-signature-normalization-reqwest", "http-signature-normalization-reqwest",
"httpdate", "httpdate",
"itertools", "itertools 0.14.0",
"moka", "moka",
"pin-project-lite", "pin-project-lite",
"rand 0.8.6", "rand 0.8.6",
"regex", "regex",
"reqwest", "reqwest 0.13.3",
"reqwest-middleware", "reqwest-middleware",
"rsa", "rsa",
"serde", "serde",
@@ -281,6 +265,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"serde", "serde",
"serde_json",
"utoipa", "utoipa",
"uuid", "uuid",
] ]
@@ -289,10 +274,12 @@ dependencies = [
name = "application" name = "application"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub-base", "activitypub",
"async-trait", "async-trait",
"bytes",
"chrono", "chrono",
"domain", "domain",
"futures",
"hex", "hex",
"sha2", "sha2",
"thiserror 2.0.18", "thiserror 2.0.18",
@@ -485,6 +472,7 @@ dependencies = [
"matchit", "matchit",
"memchr", "memchr",
"mime", "mime",
"multer",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"serde_core", "serde_core",
@@ -596,7 +584,7 @@ name = "bootstrap"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub", "activitypub",
"activitypub-base", "application",
"async-nats", "async-nats",
"async-trait", "async-trait",
"auth", "auth",
@@ -605,12 +593,14 @@ dependencies = [
"dotenvy", "dotenvy",
"event-transport", "event-transport",
"http 1.4.0", "http 1.4.0",
"k-ap",
"nats", "nats",
"postgres", "postgres",
"postgres-federation", "postgres-federation",
"postgres-search", "postgres-search",
"presentation", "presentation",
"sqlx", "sqlx",
"storage",
"tokio", "tokio",
"tower-http", "tower-http",
"tower_governor", "tower_governor",
@@ -1056,10 +1046,12 @@ name = "domain"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"bytes",
"chrono", "chrono",
"futures", "futures",
"hex", "hex",
"serde", "serde",
"serde_json",
"sha2", "sha2",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
@@ -1639,7 +1631,7 @@ dependencies = [
"base64", "base64",
"http-signature-normalization", "http-signature-normalization",
"httpdate", "httpdate",
"reqwest", "reqwest 0.13.3",
"reqwest-middleware", "reqwest-middleware",
"sha2", "sha2",
"tokio", "tokio",
@@ -1657,6 +1649,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humantime"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.9.0" version = "1.9.0"
@@ -1689,6 +1687,7 @@ dependencies = [
"hyper", "hyper",
"hyper-util", "hyper-util",
"rustls", "rustls",
"rustls-native-certs",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
@@ -1904,6 +1903,15 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.14.0" version = "0.14.0"
@@ -2005,6 +2013,27 @@ dependencies = [
"simple_asn1", "simple_asn1",
] ]
[[package]]
name = "k-ap"
version = "0.1.10"
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.10#d80cfd0431205498161db8665fd884710866ca95"
dependencies = [
"activitypub_federation",
"anyhow",
"async-trait",
"axum",
"chrono",
"enum_delegate",
"futures",
"reqwest 0.13.3",
"serde",
"serde_json",
"tokio",
"tracing",
"url",
"uuid",
]
[[package]] [[package]]
name = "language-tags" name = "language-tags"
version = "0.3.2" version = "0.3.2"
@@ -2182,6 +2211,23 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http 1.4.0",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]] [[package]]
name = "nats" name = "nats"
version = "0.1.0" version = "0.1.0"
@@ -2306,6 +2352,36 @@ dependencies = [
"libm", "libm",
] ]
[[package]]
name = "object_store"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cfccb68961a56facde1163f9319e0d15743352344e7808a11795fb99698dcaf"
dependencies = [
"async-trait",
"base64",
"bytes",
"chrono",
"futures",
"humantime",
"hyper",
"itertools 0.13.0",
"md-5",
"parking_lot",
"percent-encoding",
"quick-xml",
"rand 0.8.6",
"reqwest 0.12.28",
"ring",
"serde",
"serde_json",
"snafu",
"tokio",
"tracing",
"url",
"walkdir",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -2452,7 +2528,7 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
name = "postgres" name = "postgres"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub-base", "activitypub",
"async-trait", "async-trait",
"chrono", "chrono",
"domain", "domain",
@@ -2470,10 +2546,10 @@ dependencies = [
name = "postgres-federation" name = "postgres-federation"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub-base",
"anyhow", "anyhow",
"async-trait", "async-trait",
"chrono", "chrono",
"k-ap",
"sqlx", "sqlx",
"tokio", "tokio",
"tracing", "tracing",
@@ -2489,6 +2565,7 @@ dependencies = [
"chrono", "chrono",
"domain", "domain",
"postgres", "postgres",
"serde_json",
"sqlx", "sqlx",
"tokio", "tokio",
"uuid", "uuid",
@@ -2522,13 +2599,14 @@ dependencies = [
name = "presentation" name = "presentation"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub-base", "activitypub",
"api-types", "api-types",
"application", "application",
"async-trait", "async-trait",
"axum", "axum",
"chrono", "chrono",
"domain", "domain",
"futures",
"http-body-util", "http-body-util",
"serde", "serde",
"serde_json", "serde_json",
@@ -2577,6 +2655,16 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "quick-xml"
version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
"serde",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -2792,6 +2880,48 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-core",
"futures-util",
"h2",
"http 1.4.0",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
]
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.3" version = "0.13.3"
@@ -2831,7 +2961,7 @@ dependencies = [
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams 0.5.0",
"web-sys", "web-sys",
] ]
@@ -2844,7 +2974,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"http 1.4.0", "http 1.4.0",
"reqwest", "reqwest 0.13.3",
"thiserror 2.0.18", "thiserror 2.0.18",
"tower-service", "tower-service",
] ]
@@ -3284,6 +3414,27 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "snafu"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2"
dependencies = [
"snafu-derive",
]
[[package]]
name = "snafu-derive"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.5.10" version = "0.5.10"
@@ -3536,6 +3687,19 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "storage"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"bytes",
"domain",
"futures",
"object_store",
"tokio",
]
[[package]] [[package]]
name = "stringprep" name = "stringprep"
version = "0.1.5" version = "0.1.5"
@@ -4285,6 +4449,19 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "wasm-streams" name = "wasm-streams"
version = "0.5.0" version = "0.5.0"
@@ -4389,7 +4566,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
@@ -4715,7 +4892,6 @@ name = "worker"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"activitypub", "activitypub",
"activitypub-base",
"application", "application",
"async-nats", "async-nats",
"domain", "domain",
@@ -4723,6 +4899,7 @@ dependencies = [
"event-payload", "event-payload",
"event-transport", "event-transport",
"futures", "futures",
"k-ap",
"nats", "nats",
"postgres", "postgres",
"postgres-federation", "postgres-federation",

View File

@@ -9,12 +9,12 @@ members = [
"crates/adapters/postgres", "crates/adapters/postgres",
"crates/adapters/postgres-search", "crates/adapters/postgres-search",
"crates/adapters/postgres-federation", "crates/adapters/postgres-federation",
"crates/adapters/activitypub-base",
"crates/adapters/activitypub", "crates/adapters/activitypub",
"crates/adapters/auth", "crates/adapters/auth",
"crates/adapters/nats", "crates/adapters/nats",
"crates/adapters/event-payload", "crates/adapters/event-payload",
"crates/adapters/event-transport", "crates/adapters/event-transport",
"crates/adapters/storage",
] ]
resolver = "2" resolver = "2"
@@ -30,9 +30,10 @@ async-trait = "0.1"
uuid = { version = "1.0", features = ["v4", "v5", "serde"] } uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] }
axum = { version = "0.8", features = ["macros"] } axum = { version = "0.8", features = ["macros", "multipart"] }
tower-http = { version = "0.6", features = ["cors", "trace"] } tower-http = { version = "0.6", features = ["cors", "trace"] }
futures = "0.3" futures = "0.3"
bytes = "1.0"
dotenvy = "0.15" dotenvy = "0.15"
async-nats = "0.48" async-nats = "0.48"
async-stream = "0.3" async-stream = "0.3"
@@ -46,9 +47,9 @@ api-types = { path = "crates/api-types" }
postgres = { path = "crates/adapters/postgres" } postgres = { path = "crates/adapters/postgres" }
postgres-search = { path = "crates/adapters/postgres-search" } postgres-search = { path = "crates/adapters/postgres-search" }
postgres-federation = { path = "crates/adapters/postgres-federation" } postgres-federation = { path = "crates/adapters/postgres-federation" }
activitypub-base = { path = "crates/adapters/activitypub-base" }
activitypub = { path = "crates/adapters/activitypub" } activitypub = { path = "crates/adapters/activitypub" }
auth = { path = "crates/adapters/auth" } auth = { path = "crates/adapters/auth" }
nats = { path = "crates/adapters/nats" } nats = { path = "crates/adapters/nats" }
event-payload = { path = "crates/adapters/event-payload" } event-payload = { path = "crates/adapters/event-payload" }
event-transport = { path = "crates/adapters/event-transport" } event-transport = { path = "crates/adapters/event-transport" }
storage = { path = "crates/adapters/storage" }

View File

@@ -6,8 +6,8 @@ WORKDIR /build
# Cache dependency compilation separately from source # Cache dependency compilation separately from source
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
COPY crates/adapters/activitypub-base/Cargo.toml crates/adapters/activitypub-base/Cargo.toml
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
COPY crates/adapters/storage/Cargo.toml crates/adapters/storage/Cargo.toml
COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml
COPY crates/adapters/event-transport/Cargo.toml crates/adapters/event-transport/Cargo.toml COPY crates/adapters/event-transport/Cargo.toml crates/adapters/event-transport/Cargo.toml
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
@@ -35,7 +35,7 @@ RUN cargo fetch
# Now copy real source and build # Now copy real source and build
COPY crates ./crates COPY crates ./crates
RUN cargo build --release -p bootstrap -p worker RUN cargo build --release -p bootstrap -p worker --features storage/s3
# ----- runtime ----- # ----- runtime -----
FROM debian:bookworm-slim FROM debian:bookworm-slim

View File

@@ -63,10 +63,11 @@ bootstrap — binary: thoughts (API server)
worker — binary: thoughts-worker (event consumer — notifications, AP fan-out) worker — binary: thoughts-worker (event consumer — notifications, AP fan-out)
adapters/ adapters/
auth — JWT issuance and validation, Argon2 password hashing auth — JWT issuance and validation, Argon2 password hashing
storage — object storage adapter (local filesystem + S3/MinIO) implementing the MediaStore port
postgres — PostgreSQL repositories for all domain entities postgres — PostgreSQL repositories for all domain entities
postgres-search — PostgreSQL trigram full-text search postgres-search — PostgreSQL trigram full-text search
postgres-federation — PostgreSQL-backed federation repository postgres-federation — PostgreSQL-backed federation repository
activitypub-base — core ActivityPub protocol types, ActivityPubService, federation middleware k-ap (external) — generic AP protocol layer (ActivityPubService, actor management, inbox/outbox routing, follower tracking, WebFinger, NodeInfo, HTTP signatures)
activitypub — project-specific AP wiring (ThoughtsObjectHandler, inbox/outbox) activitypub — project-specific AP wiring (ThoughtsObjectHandler, inbox/outbox)
nats — NATS transport implementing Transport + MessageSource ports nats — NATS transport implementing Transport + MessageSource ports
event-payload — shared event serialization DTOs event-payload — shared event serialization DTOs
@@ -75,6 +76,10 @@ adapters/
The `domain` and `application` crates have zero concrete adapter dependencies. All I/O goes through `&dyn Port` traits, keeping business logic fully testable with in-memory fakes. The `domain` and `application` crates have zero concrete adapter dependencies. All I/O goes through `&dyn Port` traits, keeping business logic fully testable with in-memory fakes.
## Media Storage
Users can upload avatar and banner images via `PUT /users/me/avatar` and `PUT /users/me/banner` (multipart/form-data). Uploaded images are served at `GET /media/*path` (public, no auth required). Set `STORAGE_BACKEND` to configure the backend.
## Prerequisites ## Prerequisites
- Rust stable (1.80+) - Rust stable (1.80+)
@@ -105,6 +110,16 @@ Copy `.env.example` to `.env` and fill in your values.
| `ALLOW_REGISTRATION` | `true` | Set to `false` to close sign-ups | | `ALLOW_REGISTRATION` | `true` | Set to `false` to close sign-ups |
| `RUST_ENV` | `development` | Set to `production` to disable ActivityPub debug logging | | `RUST_ENV` | `development` | Set to `production` to disable ActivityPub debug logging |
| `RUST_LOG` | `info` | Log level filter (`error`, `warn`, `info`, `debug`, `trace`) | | `RUST_LOG` | `info` | Log level filter (`error`, `warn`, `info`, `debug`, `trace`) |
| `STORAGE_BACKEND` | `local` | Storage backend: `local` or `s3` |
| `STORAGE_PATH` | — | Local filesystem path for media (required when `STORAGE_BACKEND=local`) |
| `STORAGE_PREFIX` | — | Optional key prefix for all stored objects |
| `S3_ENDPOINT` | — | S3/MinIO endpoint URL (required when `STORAGE_BACKEND=s3`) |
| `S3_ACCESS_KEY_ID` | — | S3 access key (required when `STORAGE_BACKEND=s3`) |
| `S3_SECRET_ACCESS_KEY` | — | S3 secret key (required when `STORAGE_BACKEND=s3`) |
| `S3_BUCKET` | — | S3 bucket name (required when `STORAGE_BACKEND=s3`) |
| `S3_REGION` | `us-east-1` | S3 region |
| `UPLOAD_MAX_BYTES` | `5242880` | Max upload size in bytes (default 5 MiB) |
| `UPLOAD_ALLOWED_TYPES` | `image/jpeg,image/png,image/gif,image/webp,image/avif` | Comma-separated allowed MIME types |
## Run ## Run
@@ -167,6 +182,9 @@ docker run -p 8000:8000 \
-e JWT_SECRET=change-me \ -e JWT_SECRET=change-me \
-e BASE_URL=https://yourdomain.example.com \ -e BASE_URL=https://yourdomain.example.com \
-e NATS_URL=nats://nats:4222 \ -e NATS_URL=nats://nats:4222 \
-e STORAGE_BACKEND=local \
-e STORAGE_PATH=/data/media \
-v media_vol:/data/media \
thoughts thoughts
# Event worker (same image, different entrypoint) # Event worker (same image, different entrypoint)

View File

@@ -47,11 +47,18 @@ services:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=traefik" - "traefik.docker.network=traefik"
# Original API subdomain — keep for backwards compat and direct API access
- "traefik.http.routers.thoughts-api.rule=Host(`api.thoughts.gabrielkaszewski.dev`)" - "traefik.http.routers.thoughts-api.rule=Host(`api.thoughts.gabrielkaszewski.dev`)"
- "traefik.http.routers.thoughts-api.entrypoints=web,websecure" - "traefik.http.routers.thoughts-api.entrypoints=web,websecure"
- "traefik.http.routers.thoughts-api.tls.certresolver=letsencrypt" - "traefik.http.routers.thoughts-api.tls.certresolver=letsencrypt"
- "traefik.http.routers.thoughts-api.service=thoughts-api" - "traefik.http.routers.thoughts-api.service=thoughts-api"
- "traefik.http.services.thoughts-api.loadbalancer.server.port=8000" - "traefik.http.services.thoughts-api.loadbalancer.server.port=8000"
# Federation routes on the main domain — higher priority than the frontend catch-all
- "traefik.http.routers.thoughts-federation.rule=Host(`thoughts.gabrielkaszewski.dev`) && (PathPrefix(`/.well-known`) || PathPrefix(`/nodeinfo`) || Path(`/inbox`) || (Method(`POST`) && PathPrefix(`/users/`)))"
- "traefik.http.routers.thoughts-federation.entrypoints=web,websecure"
- "traefik.http.routers.thoughts-federation.tls.certresolver=letsencrypt"
- "traefik.http.routers.thoughts-federation.service=thoughts-api"
- "traefik.http.routers.thoughts-federation.priority=1000"
worker: worker:
container_name: thoughts-worker container_name: thoughts-worker
@@ -77,6 +84,7 @@ services:
environment: environment:
NEXT_PUBLIC_SERVER_SIDE_API_URL: http://api:8000 NEXT_PUBLIC_SERVER_SIDE_API_URL: http://api:8000
NEXT_PUBLIC_API_URL: https://api.thoughts.gabrielkaszewski.dev NEXT_PUBLIC_API_URL: https://api.thoughts.gabrielkaszewski.dev
NEXT_PUBLIC_FEDIVERSE_DOMAIN: thoughts.gabrielkaszewski.dev
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
depends_on: depends_on:

View File

@@ -32,6 +32,10 @@ services:
BASE_URL: http://localhost:8000 BASE_URL: http://localhost:8000
NATS_URL: nats://nats:4222 NATS_URL: nats://nats:4222
RUST_LOG: info RUST_LOG: info
STORAGE_BACKEND: local
STORAGE_PATH: /data/media
volumes:
- media_data:/data/media
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -65,3 +69,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
media_data:

View File

@@ -1,22 +0,0 @@
[package]
name = "activitypub-base"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
axum = { workspace = true }
reqwest = { workspace = true }
url = { workspace = true }
domain = { workspace = true }
activitypub_federation = "0.7.0-beta.11"
enum_delegate = "0.2"

View File

@@ -1,851 +0,0 @@
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
kinds::activity::{
AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType,
},
traits::Activity,
};
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Announce")]
pub struct AnnounceType;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename = "Like")]
pub struct LikeType;
impl Default for LikeType {
fn default() -> Self {
Self
}
}
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
use crate::repository::{FollowerStatus, FollowingStatus};
// --- Follow ---
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FollowActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: FollowType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: ObjectId<DbActor>,
}
#[async_trait::async_trait]
impl Activity for FollowActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let target_url = self.object.inner();
let target_domain = match (target_url.host_str(), target_url.port()) {
(Some(host), Some(port)) => format!("{}:{}", host, port),
(Some(host), None) => host.to_string(),
_ => {
return Err(Error::bad_request(anyhow::anyhow!(
"invalid follow target URL"
)));
}
};
if target_domain != data.domain {
return Err(Error::bad_request(anyhow::anyhow!(
"follow target is not a local actor"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let _follower = self.actor.dereference(data).await?;
let local_actor = self.object.dereference(data).await?;
if data
.federation_repo
.is_actor_blocked(local_actor.user_id, self.actor.inner().as_str())
.await?
{
tracing::info!(actor = %self.actor.inner(), "ignoring follow from blocked actor");
return Ok(());
}
data.federation_repo
.add_follower(
local_actor.user_id,
self.actor.inner().as_str(),
FollowerStatus::Pending,
self.id.as_str(),
)
.await?;
tracing::info!(
follower = %self.actor.inner(),
local_user = %local_actor.user_id,
"follow request pending approval"
);
Ok(())
}
}
// --- Accept ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AcceptActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AcceptType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: FollowActivity,
}
#[async_trait::async_trait]
impl Activity for AcceptActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if self.actor.inner() != self.object.object.inner() {
return Err(Error::bad_request(anyhow::anyhow!(
"Accept actor does not match Follow target"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner())
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?;
data.federation_repo
.update_following_status(
local_user_id,
self.actor.inner().as_str(),
FollowingStatus::Accepted,
)
.await?;
tracing::info!(remote_actor = %self.actor.inner(), "follow accepted by remote");
Ok(())
}
}
// --- Reject ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RejectActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: RejectType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: FollowActivity,
}
#[async_trait::async_trait]
impl Activity for RejectActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if self.actor.inner() != self.object.object.inner() {
return Err(Error::bad_request(anyhow::anyhow!(
"Reject actor does not match Follow target"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.actor.inner()) {
data.federation_repo
.remove_following(user_id, self.actor.inner().as_str())
.await?;
}
tracing::info!(actor = %self.actor.inner(), "follow rejected");
Ok(())
}
}
// --- Undo ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UndoActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: UndoType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
}
#[async_trait::async_trait]
impl Activity for UndoActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring Undo from blocked domain");
return Ok(());
}
let obj_type = self
.object
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("");
match obj_type {
"Follow" => {
if let Some(obj_url) = self.object.get("object").and_then(|o| o.as_str())
&& let Ok(url) = Url::parse(obj_url)
&& let Some(user_id) = crate::urls::extract_user_id_from_url(&url)
{
data.federation_repo
.remove_follower(user_id, self.actor.inner().as_str())
.await?;
}
data.object_handler
.on_actor_removed(self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %self.actor.inner(), "unfollowed");
}
"Add" => {
let ap_id_str = self
.object
.get("object")
.and_then(|o| o.get("id"))
.and_then(|id| id.as_str())
.or_else(|| self.object.get("id").and_then(|id| id.as_str()));
if let Some(ap_id_str) = ap_id_str
&& let Ok(ap_id) = Url::parse(ap_id_str)
{
data.object_handler
.on_delete(&ap_id, self.actor.inner())
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(ap_id = %ap_id_str, "undo Add (watchlist remove)");
}
}
"Like" => {
if let Some(obj_url_str) = self.object.get("object").and_then(|o| o.as_str())
&& let Ok(obj_url) = Url::parse(obj_url_str)
&& obj_url.host_str().unwrap_or("") == data.domain
{
data.object_handler
.on_unlike(&obj_url, self.actor.inner())
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to process unlike");
});
}
tracing::info!(actor = %self.actor.inner(), "received Undo(Like)");
}
other => {
tracing::debug!(kind = %other, "ignoring Undo of unknown activity type");
}
}
Ok(())
}
}
// --- Create ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: CreateType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) bto: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) bcc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for CreateActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str())
&& let Ok(attributed_url) = Url::parse(attributed_to)
&& &attributed_url != self.actor.inner()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Create actor does not match object attributedTo"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
// Use the Note's own id, not the Create activity id (which ends in /activity).
// Delete activities reference the Note id, so they must match.
let ap_id = self
.object
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.unwrap_or_else(|| self.id.clone());
let actor_url = self.actor.inner().clone();
data.object_handler
.on_create(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received create activity");
Ok(())
}
}
// --- Delete ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: DeleteType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for DeleteActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let actor_domain = self.actor.inner().host_str().unwrap_or("");
let object_domain = match &self.object {
serde_json::Value::String(s) => Url::parse(s)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()))
.unwrap_or_default(),
serde_json::Value::Object(o) => o
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.and_then(|u| u.host_str().map(|h| h.to_string()))
.unwrap_or_default(),
_ => String::new(),
};
if !object_domain.is_empty() && actor_domain != object_domain {
return Err(Error::bad_request(anyhow::anyhow!(
"Delete actor domain does not match object domain"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let actor_url = self.actor.inner().clone();
// Extract object URL — handles plain string and Tombstone {"id":"...","type":"Tombstone"}
let object_url_str = match &self.object {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Object(o) => o
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default(),
_ => String::new(),
};
let Ok(object_url) = Url::parse(&object_url_str) else {
tracing::warn!(actor = %actor_url, "Delete activity has unparseable object, ignoring");
return Ok(());
};
// Actor self-deletion: Mastodon sends Delete(actor_url) when an account is deleted.
if object_url == *self.actor.inner() {
data.object_handler
.on_actor_removed(&actor_url)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received Delete(actor) — remote account deleted");
return Ok(());
}
// Normal note deletion.
data.object_handler
.on_delete(&object_url, &actor_url)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(object = %object_url, "received Delete(note)");
Ok(())
}
}
// --- Update ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: UpdateType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for UpdateActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if let Some(attributed_to) = self.object.get("attributedTo").and_then(|v| v.as_str())
&& let Ok(attributed_url) = Url::parse(attributed_to)
&& &attributed_url != self.actor.inner()
{
return Err(Error::bad_request(anyhow::anyhow!(
"Update actor does not match object attributedTo"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let ap_id = self
.object
.get("id")
.and_then(|v| v.as_str())
.and_then(|s| Url::parse(s).ok())
.unwrap_or_else(|| self.id.clone());
let actor_url = self.actor.inner().clone();
data.object_handler
.on_update(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received update activity");
Ok(())
}
}
// --- Announce ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnnounceActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AnnounceType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
pub(crate) published: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for AnnounceActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let object_domain = self.object.host_str().unwrap_or("");
if object_domain != data.domain {
tracing::debug!(
actor = %self.actor.inner(),
object = %self.object,
"received Announce of non-local object — skipped (cross-server boost not supported)"
);
return Ok(());
}
data.federation_repo
.add_announce(
self.id.as_str(),
self.object.as_str(),
self.actor.inner().as_str(),
self.published.unwrap_or_else(chrono::Utc::now),
)
.await?;
data.object_handler
.on_announce_received(&self.object, self.actor.inner())
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to process announce notification");
});
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce");
Ok(())
}
}
// --- Like ---
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LikeActivity {
pub id: Url,
#[serde(rename = "type")]
pub kind: LikeType,
pub actor: ObjectId<DbActor>,
pub object: Url,
}
#[async_trait::async_trait]
impl Activity for LikeActivity {
type DataType = FederationData;
type Error = crate::error::Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring Like from blocked domain");
return Ok(());
}
// Only process if the liked object is on our instance.
if self.object.host_str().unwrap_or("") != data.domain {
return Ok(());
}
data.object_handler
.on_like(&self.object, self.actor.inner())
.await
.map_err(|e| crate::error::Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received like");
Ok(())
}
}
// --- Add ---
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Add")]
pub struct AddType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AddActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AddType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: serde_json::Value,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
#[async_trait::async_trait]
impl Activity for AddActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring Add from blocked domain");
return Ok(());
}
let ap_id = self.id.clone();
let actor_url = self.actor.inner().clone();
data.object_handler
.on_create(&ap_id, &actor_url, self.object)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
tracing::info!(actor = %actor_url, "received Add activity");
Ok(())
}
}
// --- Block ---
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Block")]
pub struct BlockType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlockActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: BlockType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
}
#[async_trait::async_trait]
impl Activity for BlockActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
if let Some(local_user_id) = crate::urls::extract_user_id_from_url(&self.object) {
let _ = data
.federation_repo
.remove_following(local_user_id, self.actor.inner().as_str())
.await;
let _ = data
.federation_repo
.remove_follower(local_user_id, self.actor.inner().as_str())
.await;
}
tracing::info!(actor = %self.actor.inner(), "received block — removed following and follower");
Ok(())
}
}
// --- Move (account migration) ---
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Move")]
pub struct MoveType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MoveActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: MoveType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
pub(crate) target: Url,
}
#[async_trait::async_trait]
impl Activity for MoveActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
if &self.object != self.actor.inner() {
return Err(Error::bad_request(anyhow::anyhow!(
"Move object must be the actor itself"
)));
}
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
return Ok(());
}
tracing::info!(
actor = %self.actor.inner(),
target = %self.target,
"received Move (account migration) — target noted"
);
Ok(())
}
}
// --- Inbox dispatch enum ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "type")]
#[enum_delegate::implement(Activity)]
pub enum InboxActivities {
#[serde(rename = "Follow")]
Follow(FollowActivity),
#[serde(rename = "Accept")]
Accept(AcceptActivity),
#[serde(rename = "Reject")]
Reject(RejectActivity),
#[serde(rename = "Undo")]
Undo(UndoActivity),
#[serde(rename = "Create")]
Create(CreateActivity),
#[serde(rename = "Delete")]
Delete(DeleteActivity),
#[serde(rename = "Update")]
Update(UpdateActivity),
#[serde(rename = "Announce")]
Announce(AnnounceActivity),
#[serde(rename = "Add")]
Add(AddActivity),
#[serde(rename = "Block")]
Block(BlockActivity),
#[serde(rename = "Like")]
Like(LikeActivity),
#[serde(rename = "Move")]
Move(MoveActivity),
}

View File

@@ -1,25 +0,0 @@
use activitypub_federation::{
axum::json::FederationJson, config::Data, protocol::context::WithContext, traits::Object,
};
use axum::extract::Path;
use crate::actors::{Person, get_local_actor};
use crate::data::FederationData;
use crate::error::Error;
pub async fn actor_handler(
Path(username): Path<String>,
data: Data<FederationData>,
) -> Result<FederationJson<WithContext<Person>>, Error> {
let ap_user = data
.user_repo
.find_by_username(&username)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("user not found")))?;
let db_actor = get_local_actor(ap_user.id, &data).await?;
let person = db_actor.into_json(&data).await?;
Ok(FederationJson(WithContext::new_default(person)))
}

View File

@@ -1,372 +0,0 @@
use activitypub_federation::{
config::Data,
fetch::object_id::ObjectId,
http_signatures::generate_actor_keypair,
kinds::actor::PersonType,
protocol::{public_key::PublicKey, verification::verify_domains_match},
traits::{Actor, Object},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::data::FederationData;
use crate::error::Error;
use crate::repository::RemoteActor;
use crate::user::ApProfileField;
#[derive(Debug, Clone)]
pub struct DbActor {
pub user_id: uuid::Uuid,
pub username: String,
pub public_key_pem: String,
pub private_key_pem: Option<String>,
pub inbox_url: Url,
pub shared_inbox_url: Option<Url>,
pub outbox_url: Url,
pub followers_url: Url,
pub following_url: Url,
pub ap_id: Url,
pub last_refreshed_at: DateTime<Utc>,
pub bio: Option<String>,
pub avatar_url: Option<Url>,
pub banner_url: Option<Url>,
pub also_known_as: Option<String>,
pub profile_url: Option<Url>,
pub attachment: Vec<ApProfileField>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApImageObject {
#[serde(rename = "type")]
pub kind: String,
pub url: Url,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Endpoints {
pub shared_inbox: Url,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileFieldObject {
#[serde(rename = "type")]
pub kind: String,
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Person {
#[serde(rename = "type")]
kind: PersonType,
id: ObjectId<DbActor>,
preferred_username: String,
inbox: Url,
outbox: Url,
followers: Url,
following: Url,
public_key: PublicKey,
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<ApImageObject>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
discoverable: Option<bool>,
manually_approves_followers: bool,
#[serde(skip_serializing_if = "Option::is_none", default)]
updated: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
endpoints: Option<Endpoints>,
#[serde(skip_serializing_if = "Option::is_none")]
image: Option<ApImageObject>,
#[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)]
also_known_as: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
attachment: Vec<ProfileFieldObject>,
}
struct ActorUrls {
ap_id: Url,
inbox_url: Url,
shared_inbox_url: Option<Url>,
outbox_url: Url,
followers_url: Url,
following_url: Url,
}
impl ActorUrls {
fn build(base_url: &str, user_id: uuid::Uuid) -> Self {
let ap_id = crate::urls::actor_url(base_url, user_id);
Self {
inbox_url: Url::parse(&format!("{}/inbox", &ap_id)).expect("valid url"),
shared_inbox_url: Url::parse(&format!("{}/inbox", base_url)).ok(),
outbox_url: Url::parse(&format!("{}/outbox", &ap_id)).expect("valid url"),
followers_url: Url::parse(&format!("{}/followers", &ap_id)).expect("valid url"),
following_url: Url::parse(&format!("{}/following", &ap_id)).expect("valid url"),
ap_id,
}
}
}
pub async fn get_local_actor(
user_id: uuid::Uuid,
data: &Data<FederationData>,
) -> Result<DbActor, Error> {
let user = data
.user_repo
.find_by_id(user_id)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found: {}", user_id)))?;
let (public_key, private_key) = match data
.federation_repo
.get_local_actor_keypair(user_id)
.await?
{
Some(kp) => kp,
None => {
let kp = generate_actor_keypair()?;
data.federation_repo
.save_local_actor_keypair(user_id, kp.public_key.clone(), kp.private_key.clone())
.await?;
(kp.public_key, kp.private_key)
}
};
let ActorUrls {
ap_id,
inbox_url,
shared_inbox_url,
outbox_url,
followers_url,
following_url,
} = ActorUrls::build(&data.base_url, user_id);
Ok(DbActor {
user_id,
username: user.username,
public_key_pem: public_key,
private_key_pem: Some(private_key),
inbox_url,
shared_inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: user.bio,
avatar_url: user.avatar_url,
banner_url: user.banner_url,
also_known_as: user.also_known_as,
profile_url: user.profile_url,
attachment: user.attachment,
})
}
#[async_trait::async_trait]
impl Object for DbActor {
type DataType = FederationData;
type Kind = Person;
type Error = Error;
fn id(&self) -> &Url {
&self.ap_id
}
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {
Some(self.last_refreshed_at)
}
async fn read_from_id(
object_id: Url,
data: &Data<Self::DataType>,
) -> Result<Option<Self>, Self::Error> {
let user_id = match crate::urls::extract_user_id_from_url(&object_id) {
Some(id) => id,
None => return Ok(None),
};
let user = match data.user_repo.find_by_id(user_id).await {
Ok(Some(u)) => u,
_ => return Ok(None),
};
let keypair = data
.federation_repo
.get_local_actor_keypair(user_id)
.await?;
let (public_key, private_key) = match keypair {
Some(kp) => (kp.0, Some(kp.1)),
None => return Ok(None),
};
let ActorUrls {
ap_id,
inbox_url,
shared_inbox_url,
outbox_url,
followers_url,
following_url,
} = ActorUrls::build(&data.base_url, user_id);
Ok(Some(DbActor {
user_id,
username: user.username,
public_key_pem: public_key,
private_key_pem: private_key,
inbox_url,
shared_inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: None,
avatar_url: None,
banner_url: None,
also_known_as: None,
profile_url: None,
attachment: vec![],
}))
}
async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
let public_key = PublicKey {
id: format!("{}#main-key", &self.ap_id),
owner: self.ap_id.clone(),
public_key_pem: self.public_key_pem.clone(),
};
let icon = self.avatar_url.map(|url| ApImageObject {
kind: "Image".to_string(),
url,
});
let image = self.banner_url.map(|url| ApImageObject {
kind: "Image".to_string(),
url,
});
let profile_url = self.profile_url;
let also_known_as: Vec<String> = self.also_known_as.into_iter().collect();
let attachment: Vec<ProfileFieldObject> = self
.attachment
.into_iter()
.map(|f| ProfileFieldObject {
kind: "PropertyValue".to_string(),
name: f.name,
value: f.value,
})
.collect();
let shared_inbox =
Url::parse(&format!("{}/inbox", data.base_url)).expect("base_url is always valid");
Ok(Person {
kind: Default::default(),
id: self.ap_id.clone().into(),
preferred_username: self.username.clone(),
inbox: self.inbox_url.clone(),
outbox: self.outbox_url.clone(),
followers: self.followers_url.clone(),
following: self.following_url.clone(),
public_key,
name: Some(self.username.clone()),
summary: self.bio.clone(),
icon,
url: profile_url,
discoverable: Some(true),
manually_approves_followers: true,
updated: Some(self.last_refreshed_at),
endpoints: Some(Endpoints { shared_inbox }),
image,
also_known_as,
attachment,
})
}
async fn verify(
json: &Self::Kind,
expected_domain: &Url,
_data: &Data<Self::DataType>,
) -> Result<(), Self::Error> {
verify_domains_match(json.id.inner(), expected_domain)?;
Ok(())
}
async fn from_json(json: Self::Kind, data: &Data<Self::DataType>) -> Result<Self, Self::Error> {
let shared_inbox_url = json.endpoints.as_ref().map(|e| e.shared_inbox.to_string());
let actor = RemoteActor {
url: json.id.inner().to_string(),
handle: json.preferred_username.clone(),
inbox_url: json.inbox.to_string(),
shared_inbox_url,
display_name: json.name.clone(),
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
outbox_url: Some(json.outbox.to_string()),
};
data.federation_repo.upsert_remote_actor(actor).await?;
let url_str = json.id.inner().to_string();
let user_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url_str.as_bytes());
let ap_id = json.id.inner().clone();
let inbox_url = json.inbox.clone();
let shared_inbox_url = json
.endpoints
.as_ref()
.and_then(|e| Url::parse(e.shared_inbox.as_str()).ok());
let outbox_url = json.outbox.clone();
let followers_url = json.followers.clone();
let following_url = json.following.clone();
Ok(DbActor {
user_id,
username: json.preferred_username.clone(),
public_key_pem: json.public_key.public_key_pem,
private_key_pem: None,
inbox_url,
shared_inbox_url,
outbox_url,
followers_url,
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: json.summary.clone(),
avatar_url: json.icon.as_ref().map(|i| i.url.clone()),
banner_url: json.image.as_ref().map(|i| i.url.clone()),
also_known_as: json.also_known_as.into_iter().next(),
profile_url: json.url.clone(),
attachment: json
.attachment
.iter()
.map(|f| crate::user::ApProfileField {
name: f.name.clone(),
value: f.value.clone(),
})
.collect(),
})
}
}
impl Actor for DbActor {
fn public_key_pem(&self) -> &str {
&self.public_key_pem
}
fn private_key_pem(&self) -> Option<String> {
self.private_key_pem.clone()
}
fn inbox(&self) -> Url {
self.inbox_url.clone()
}
}
#[cfg(test)]
#[path = "tests/actors.rs"]
mod tests;

View File

@@ -1,68 +0,0 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use url::Url;
#[async_trait]
pub trait ApObjectHandler: Send + Sync {
/// Returns (ap_id, serialized object) for all local content owned by this user.
/// Used by outbox (count) and backfill (delivery). Must only return locally-authored content.
async fn get_local_objects_for_user(
&self,
user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>;
/// Returns up to `limit` objects ordered newest-first, published before `before`.
/// Returns (ap_id, object_json, published_at).
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<DateTime<Utc>>,
limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;
/// Incoming Create activity — persist remote content.
async fn on_create(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()>;
/// Incoming Update activity — update existing remote content.
async fn on_update(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()>;
/// Incoming Delete activity — remove specific remote content.
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// Actor unfollowed/was removed — clean up all their remote content.
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>;
/// Called when a remote actor likes a local thought.
/// `object_url` is the AP URL of the liked note (e.g. `{base}/thoughts/{uuid}`).
/// `actor_url` is the AP URL of the remote actor who sent the Like.
async fn on_like(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// Called when a remote actor boosts (Announce) a local thought.
/// `object_url` is the AP URL of the announced note.
/// `actor_url` is the AP URL of the remote actor who sent the Announce.
async fn on_announce_received(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// Called when a remote actor removes a Like from a local thought.
async fn on_unlike(&self, object_url: &Url, actor_url: &Url) -> anyhow::Result<()>;
/// Called when an inbound Note tags a local user with a Mention.
async fn on_mention(
&self,
thought_ap_id: &Url,
mentioned_user_uuid: uuid::Uuid,
actor_url: &Url,
) -> anyhow::Result<()>;
/// Total number of locally-authored posts across all users.
async fn count_local_posts(&self) -> anyhow::Result<u64>;
}

View File

@@ -1,49 +0,0 @@
use std::sync::Arc;
use crate::content::ApObjectHandler;
use crate::repository::FederationRepository;
use crate::user::ApUserRepository;
use domain::ports::EventPublisher;
#[derive(Clone)]
pub struct FederationData {
pub(crate) federation_repo: Arc<dyn FederationRepository>,
pub(crate) user_repo: Arc<dyn ApUserRepository>,
pub(crate) object_handler: Arc<dyn ApObjectHandler>,
pub(crate) base_url: String,
pub(crate) domain: String,
pub(crate) allow_registration: bool,
pub(crate) software_name: String,
#[allow(dead_code)]
pub(crate) event_publisher: Option<Arc<dyn EventPublisher>>,
}
impl FederationData {
pub fn new(
federation_repo: Arc<dyn FederationRepository>,
user_repo: Arc<dyn ApUserRepository>,
object_handler: Arc<dyn ApObjectHandler>,
base_url: String,
allow_registration: bool,
software_name: String,
event_publisher: Option<Arc<dyn EventPublisher>>,
) -> Self {
let domain = base_url
.trim_start_matches("https://")
.trim_start_matches("http://")
.split('/')
.next()
.unwrap_or("")
.to_string();
Self {
federation_repo,
user_repo,
object_handler,
base_url,
domain,
allow_registration,
software_name,
event_publisher,
}
}
}

View File

@@ -1,48 +0,0 @@
use std::fmt::{Display, Formatter};
use axum::http::StatusCode;
#[derive(Debug)]
pub struct Error(pub(crate) anyhow::Error, pub(crate) StatusCode);
impl Error {
pub fn not_found(e: impl Into<anyhow::Error>) -> Self {
Self(e.into(), StatusCode::NOT_FOUND)
}
pub fn bad_request(e: impl Into<anyhow::Error>) -> Self {
Self(e.into(), StatusCode::BAD_REQUEST)
}
}
impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}
impl<T> From<T> for Error
where
T: Into<anyhow::Error>,
{
fn from(t: T) -> Self {
Error(t.into(), StatusCode::INTERNAL_SERVER_ERROR)
}
}
impl axum::response::IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
let status = self.1;
if status.is_server_error() {
tracing::error!(error = %self.0, status = status.as_u16(), "federation error");
} else {
tracing::debug!(error = %self.0, status = status.as_u16(), "federation response");
}
let body = if status.is_server_error() {
"internal server error".to_string()
} else {
self.0.to_string()
};
(status, body).into_response()
}
}

View File

@@ -1,49 +0,0 @@
use activitypub_federation::config::{Data, FederationConfig, FederationMiddleware, UrlVerifier};
use activitypub_federation::error::Error as FedError;
use url::Url;
use crate::data::FederationData;
#[derive(Clone)]
struct PermissiveVerifier;
#[async_trait::async_trait]
impl UrlVerifier for PermissiveVerifier {
async fn verify(&self, _url: &Url) -> Result<(), FedError> {
Ok(())
}
}
#[derive(Clone)]
pub struct ApFederationConfig(pub FederationConfig<FederationData>);
impl ApFederationConfig {
pub async fn new(data: FederationData, debug: bool) -> anyhow::Result<Self> {
let config = if debug {
FederationConfig::builder()
.domain(&data.domain)
.app_data(data)
.debug(true)
.http_signature_compat(true)
.url_verifier(Box::new(PermissiveVerifier))
.build()
.await?
} else {
FederationConfig::builder()
.domain(&data.domain)
.app_data(data)
.debug(false)
.build()
.await?
};
Ok(Self(config))
}
pub fn to_request_data(&self) -> Data<FederationData> {
self.0.to_request_data()
}
pub fn middleware(&self) -> FederationMiddleware<FederationData> {
FederationMiddleware::new(self.0.clone())
}
}

View File

@@ -1,105 +0,0 @@
use activitypub_federation::{axum::json::FederationJson, config::Data};
use axum::extract::{Path, Query};
use serde::Deserialize;
use serde_json::json;
use crate::data::FederationData;
use crate::error::Error;
use crate::urls::AP_PAGE_SIZE;
#[derive(Deserialize)]
pub struct PageQuery {
page: Option<u32>,
}
async fn collection_handler(
user_id_str: &str,
query: PageQuery,
data: Data<FederationData>,
collection_type: &str,
) -> Result<FederationJson<serde_json::Value>, Error> {
let user_id = uuid::Uuid::parse_str(user_id_str)
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
data.user_repo
.find_by_id(user_id)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let collection_id = format!(
"{}/users/{}/{}",
data.base_url, user_id_str, collection_type
);
let total = match collection_type {
"followers" => data.federation_repo.count_followers(user_id).await,
_ => data.federation_repo.count_following(user_id).await,
}
.map_err(Error::from)?;
if let Some(page) = query.page {
let page = page.max(1);
let offset = (page.saturating_sub(1) as usize) * AP_PAGE_SIZE;
let items: Vec<String> = match collection_type {
"followers" => data
.federation_repo
.get_followers_page(user_id, offset as u32, AP_PAGE_SIZE)
.await
.map_err(Error::from)?
.into_iter()
.map(|f| f.actor.url)
.collect(),
_ => data
.federation_repo
.get_following_page(user_id, offset as u32, AP_PAGE_SIZE)
.await
.map_err(Error::from)?
.into_iter()
.map(|a| a.url)
.collect(),
};
let has_next = offset + items.len() < total;
let mut obj = json!({
"@context": crate::urls::AP_CONTEXT,
"type": "OrderedCollectionPage",
"id": format!("{}?page={}", collection_id, page),
"partOf": collection_id,
"totalItems": total,
"orderedItems": items,
});
if has_next {
obj["next"] = json!(format!("{}?page={}", collection_id, page + 1));
}
Ok(FederationJson(obj))
} else {
Ok(FederationJson(json!({
"@context": crate::urls::AP_CONTEXT,
"type": "OrderedCollection",
"id": collection_id,
"totalItems": total,
"first": format!("{}?page=1", collection_id),
})))
}
}
pub async fn followers_handler(
Path(user_id_str): Path<String>,
Query(query): Query<PageQuery>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> {
collection_handler(&user_id_str, query, data, "followers").await
}
pub async fn following_handler(
Path(user_id_str): Path<String>,
Query(query): Query<PageQuery>,
data: Data<FederationData>,
) -> Result<FederationJson<serde_json::Value>, Error> {
collection_handler(&user_id_str, query, data, "following").await
}

View File

@@ -1,18 +0,0 @@
use activitypub_federation::{
axum::inbox::{ActivityData, receive_activity},
config::Data,
protocol::context::WithContext,
};
use crate::activities::InboxActivities;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
pub async fn inbox_handler(
data: Data<FederationData>,
activity_data: ActivityData,
) -> Result<(), Error> {
receive_activity::<WithContext<InboxActivities>, DbActor, FederationData>(activity_data, &data)
.await
}

View File

@@ -1,30 +0,0 @@
pub mod activities;
pub mod actor_handler;
pub mod actors;
pub mod ap_ports;
pub mod content;
pub mod data;
pub mod error;
pub mod federation;
pub mod followers_handler;
pub mod inbox;
pub mod nodeinfo;
pub mod outbox;
pub mod repository;
pub mod service;
pub(crate) mod urls;
pub use urls::AS_PUBLIC;
pub mod user;
pub mod webfinger;
pub use activitypub_federation::kinds::object::NoteType;
pub use ap_ports::{ActorApUrls, ActivityPubRepository, OutboxEntry, OutboundFederationPort};
pub use content::ApObjectHandler;
pub use data::FederationData;
pub use error::Error;
pub use federation::ApFederationConfig;
pub use repository::{
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
pub use service::ActivityPubService;
pub use user::{ApProfileField, ApUser, ApUserRepository};

View File

@@ -1,82 +0,0 @@
use activitypub_federation::config::Data;
use axum::Json;
use serde::Serialize;
use crate::data::FederationData;
use crate::error::Error;
const NODEINFO_2_0_REL: &str = "http://nodeinfo.diaspora.software/ns/schema/2.0";
#[derive(Serialize)]
pub struct NodeInfoWellKnown {
pub links: Vec<NodeInfoLink>,
}
#[derive(Serialize)]
pub struct NodeInfoLink {
pub rel: String,
pub href: String,
}
#[derive(Serialize)]
pub struct NodeInfoSoftware {
pub name: String,
pub version: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeInfoUsage {
pub users: NodeInfoUsers,
pub local_posts: u64,
}
#[derive(Serialize)]
pub struct NodeInfoUsers {
pub total: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeInfo {
pub version: String,
pub software: NodeInfoSoftware,
pub protocols: Vec<String>,
pub usage: NodeInfoUsage,
pub open_registrations: bool,
}
pub async fn nodeinfo_well_known_handler(
data: Data<FederationData>,
) -> Result<Json<NodeInfoWellKnown>, Error> {
let href = format!("{}/nodeinfo/2.0", data.base_url);
Ok(Json(NodeInfoWellKnown {
links: vec![NodeInfoLink {
rel: NODEINFO_2_0_REL.to_string(),
href,
}],
}))
}
pub async fn nodeinfo_handler(data: Data<FederationData>) -> Result<Json<NodeInfo>, Error> {
let user_count = data.user_repo.count_users().await.unwrap_or(0);
let local_posts = data.object_handler.count_local_posts().await.unwrap_or(0);
Ok(Json(NodeInfo {
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: data.software_name.clone(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
protocols: vec!["activitypub".to_string()],
usage: NodeInfoUsage {
users: NodeInfoUsers { total: user_count },
local_posts,
},
open_registrations: data.allow_registration,
}))
}
#[cfg(test)]
#[path = "tests/nodeinfo.rs"]
mod tests;

View File

@@ -1,138 +0,0 @@
use axum::extract::{Path, Query};
use axum::response::IntoResponse;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use activitypub_federation::{
config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType,
protocol::context::WithContext,
};
use crate::{activities::CreateActivity, data::FederationData, error::Error, urls::AP_PAGE_SIZE};
#[derive(Deserialize)]
pub struct OutboxQuery {
page: Option<bool>,
before: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderedCollection {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "type")]
kind: String,
id: String,
total_items: u64,
first: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderedCollectionPage {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "type")]
kind: String,
id: String,
part_of: String,
ordered_items: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
next: Option<String>,
}
pub async fn outbox_handler(
Path(user_id_str): Path<String>,
Query(query): Query<OutboxQuery>,
data: Data<FederationData>,
) -> Result<axum::response::Response, Error> {
let uuid = uuid::Uuid::parse_str(&user_id_str)
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
data.user_repo
.find_by_id(uuid)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
if query.page.unwrap_or(false) {
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
let items = data
.object_handler
.get_local_objects_page(uuid, before, AP_PAGE_SIZE)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
let actor_url: Url = format!("{}/users/{}", data.base_url, user_id_str)
.parse()
.expect("valid url");
let has_more = items.len() == AP_PAGE_SIZE;
let oldest_ts = items.last().map(|(_, _, ts)| *ts);
let followers_url = format!("{}/followers", actor_url);
let ordered_items: Vec<serde_json::Value> = items
.into_iter()
.map(|(ap_id, object, _)| {
let create_id = Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
serde_json::to_value(WithContext::new_default(CreateActivity {
id: create_id,
kind: CreateType::default(),
actor: ObjectId::from(actor_url.clone()),
object,
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![followers_url.clone()],
bto: vec![],
bcc: vec![],
}))
.expect("serializable")
})
.collect();
let page_id = match &query.before {
Some(b) => format!("{}?page=true&before={}", outbox_url, b),
None => format!("{}?page=true", outbox_url),
};
let next = if has_more {
oldest_ts.map(|ts| {
// Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding
let ts_str = ts.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
format!("{}?page=true&before={}", outbox_url, ts_str)
})
} else {
None
};
Ok(axum::Json(OrderedCollectionPage {
context: crate::urls::AP_CONTEXT.to_string(),
kind: "OrderedCollectionPage".to_string(),
id: page_id,
part_of: outbox_url,
ordered_items,
next,
})
.into_response())
} else {
let total = data
.object_handler
.get_local_objects_for_user(uuid)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?
.len() as u64;
Ok(axum::Json(OrderedCollection {
context: crate::urls::AP_CONTEXT.to_string(),
kind: "OrderedCollection".to_string(),
id: outbox_url.clone(),
total_items: total,
first: format!("{}?page=true", outbox_url),
})
.into_response())
}
}

View File

@@ -1,134 +0,0 @@
use anyhow::Result;
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowerStatus {
Pending,
Accepted,
Rejected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowingStatus {
Pending,
Accepted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteActor {
pub url: String,
pub handle: String,
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub outbox_url: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Follower {
pub actor: RemoteActor,
pub status: FollowerStatus,
}
#[derive(Debug, Clone)]
pub struct BlockedDomain {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
#[async_trait]
pub trait FederationRepository: Send + Sync {
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
follow_activity_id: &str,
) -> Result<()>;
async fn get_follower_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn remove_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<()>;
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
async fn get_followers_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<Follower>>;
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize>;
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>>;
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()>;
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()>;
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize>;
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>;
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>>;
async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>>;
async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()>;
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>>;
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()>;
async fn get_following_outbox_url(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>>;
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()>;
async fn count_announces(&self, object_url: &str) -> Result<usize>;
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>;
async fn remove_blocked_domain(&self, domain: &str) -> Result<()>;
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>>;
async fn is_domain_blocked(&self, domain: &str) -> Result<bool>;
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()>;
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>>;
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,49 +0,0 @@
use super::*;
#[test]
fn person_serializes_with_enriched_fields() {
let person = Person {
kind: Default::default(),
id: "https://example.com/users/1"
.parse::<url::Url>()
.unwrap()
.into(),
preferred_username: "alice".to_string(),
inbox: "https://example.com/users/1/inbox".parse().unwrap(),
outbox: "https://example.com/users/1/outbox".parse().unwrap(),
followers: "https://example.com/users/1/followers".parse().unwrap(),
following: "https://example.com/users/1/following".parse().unwrap(),
public_key: PublicKey {
id: "https://example.com/users/1#main-key".to_string(),
owner: "https://example.com/users/1".parse().unwrap(),
public_key_pem: "pem".to_string(),
},
name: Some("Alice".to_string()),
summary: Some("Bio text".to_string()),
icon: Some(ApImageObject {
kind: "Image".to_string(),
url: "https://example.com/images/avatars/1".parse().unwrap(),
}),
url: Some("https://example.com/u/alice".parse().unwrap()),
discoverable: Some(true),
manually_approves_followers: true,
updated: Some(Utc::now()),
endpoints: Some(Endpoints {
shared_inbox: "https://example.com/inbox".parse().unwrap(),
}),
image: None,
also_known_as: vec![],
attachment: vec![],
};
let json = serde_json::to_value(&person).unwrap();
assert_eq!(json["discoverable"], true);
assert_eq!(json["summary"], "Bio text");
assert_eq!(json["icon"]["type"], "Image");
assert_eq!(json["manuallyApprovesFollowers"], true);
assert!(json.get("updated").is_some());
assert!(json.get("endpoints").is_some());
assert_eq!(
json["endpoints"]["sharedInbox"],
"https://example.com/inbox"
);
}

View File

@@ -1,40 +0,0 @@
use super::*;
#[test]
fn nodeinfo_well_known_serializes_correctly() {
let doc = NodeInfoWellKnown {
links: vec![NodeInfoLink {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
href: "https://example.com/nodeinfo/2.0".to_string(),
}],
};
let json = serde_json::to_value(&doc).unwrap();
assert_eq!(
json["links"][0]["rel"],
"http://nodeinfo.diaspora.software/ns/schema/2.0"
);
assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0");
}
#[test]
fn nodeinfo_serializes_camel_case() {
let doc = NodeInfo {
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: "my-app".to_string(),
version: "0.1.0".to_string(),
},
protocols: vec!["activitypub".to_string()],
usage: NodeInfoUsage {
users: NodeInfoUsers { total: 3 },
local_posts: 42,
},
open_registrations: false,
};
let json = serde_json::to_value(&doc).unwrap();
assert_eq!(json["version"], "2.0");
assert_eq!(json["software"]["name"], "my-app");
assert_eq!(json["usage"]["users"]["total"], 3);
assert_eq!(json["usage"]["localPosts"], 42);
assert_eq!(json["openRegistrations"], false);
}

View File

@@ -1,75 +0,0 @@
fn _assert_impl_federation_lookup_port()
where
crate::service::ActivityPubService: domain::ports::FederationLookupPort,
{
}
fn _assert_impl_federation_follow_port()
where
crate::service::ActivityPubService: domain::ports::FederationFollowPort,
{
}
fn _assert_impl_federation_follow_request_port()
where
crate::service::ActivityPubService: domain::ports::FederationFollowRequestPort,
{
}
fn _assert_impl_federation_fetch_port()
where
crate::service::ActivityPubService: domain::ports::FederationFetchPort,
{
}
fn _assert_impl_federation_action_port()
where
crate::service::ActivityPubService: domain::ports::FederationActionPort,
{
}
use super::*;
use crate::repository::{Follower, FollowerStatus, RemoteActor};
fn make_follower(inbox: &str, shared: Option<&str>) -> Follower {
Follower {
actor: RemoteActor {
url: format!("https://remote/{}", inbox),
handle: "user".to_string(),
inbox_url: inbox.to_string(),
shared_inbox_url: shared.map(|s| s.to_string()),
display_name: None,
avatar_url: None,
outbox_url: None,
},
status: FollowerStatus::Accepted,
}
}
#[test]
fn collect_inboxes_deduplicates_shared() {
let followers = vec![
make_follower(
"https://mastodon.social/users/a/inbox",
Some("https://mastodon.social/inbox"),
),
make_follower(
"https://mastodon.social/users/b/inbox",
Some("https://mastodon.social/inbox"),
),
make_follower("https://other.instance/users/c/inbox", None),
];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 2);
let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect();
assert!(strs.contains(&"https://mastodon.social/inbox"));
assert!(strs.contains(&"https://other.instance/users/c/inbox"));
}
#[test]
fn collect_inboxes_falls_back_to_individual_inbox() {
let followers = vec![make_follower("https://example.com/users/x/inbox", None)];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 1);
assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox");
}

View File

@@ -1,33 +0,0 @@
use url::Url;
use crate::error::Error;
pub const AS_PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public";
pub const AP_CONTEXT: &str = "https://www.w3.org/ns/activitystreams";
pub const AP_PAGE_SIZE: usize = 20;
pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> {
let path = url.path();
path.strip_prefix("/users/")
.and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok())
}
pub fn activity_url(base_url: &str) -> Result<Url, Error> {
Url::parse(&format!("{}/activities/{}", base_url, uuid::Uuid::new_v4()))
.map_err(|e| Error::bad_request(anyhow::anyhow!(e)))
}
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/users/{}", base_url, user_id))
.expect("base_url is always a valid URL prefix")
}
/// Extract the username segment from a /users/:username URL.
#[allow(dead_code)]
pub fn extract_username_from_url(url: &Url) -> Option<String> {
url.path()
.strip_prefix("/users/")
.and_then(|s| s.split('/').next())
.map(|s| s.to_string())
}

View File

@@ -1,27 +0,0 @@
use async_trait::async_trait;
use url::Url;
#[derive(Debug, Clone)]
pub struct ApProfileField {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone)]
pub struct ApUser {
pub id: uuid::Uuid,
pub username: String,
pub bio: Option<String>,
pub avatar_url: Option<Url>,
pub banner_url: Option<Url>,
pub also_known_as: Option<String>,
pub profile_url: Option<Url>,
pub attachment: Vec<ApProfileField>,
}
#[async_trait]
pub trait ApUserRepository: Send + Sync {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>>;
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>>;
async fn count_users(&self) -> anyhow::Result<usize>;
}

View File

@@ -1,38 +0,0 @@
use activitypub_federation::{
config::Data,
fetch::webfinger::{Webfinger, build_webfinger_response, extract_webfinger_name},
};
use axum::{
extract::Query,
http::header,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use crate::data::FederationData;
use crate::error::Error;
#[derive(Deserialize)]
pub struct WebfingerQuery {
resource: String,
}
pub async fn webfinger_handler(
Query(query): Query<WebfingerQuery>,
data: Data<FederationData>,
) -> Result<Response, Error> {
let name = extract_webfinger_name(&query.resource, &data)?;
let user = data
.user_repo
.find_by_username(name)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let ap_id = crate::urls::actor_url(&data.base_url, user.id);
let wf: Webfinger = build_webfinger_response(query.resource, ap_id);
let body = serde_json::to_string(&wf).map_err(|e| Error::from(anyhow::anyhow!(e)))?;
Ok(([(header::CONTENT_TYPE, "application/jrd+json")], body).into_response())
}

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
activitypub-base = { workspace = true } k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.10" }
domain = { workspace = true } domain = { workspace = true }
url = { workspace = true } url = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
@@ -14,3 +14,8 @@ chrono = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
activitypub_federation = "0.7.0-beta.11"
reqwest = { workspace = true }
futures = { workspace = true }
tokio = { workspace = true }
axum = { workspace = true }

View File

@@ -7,11 +7,12 @@ use chrono::{DateTime, Utc};
use std::sync::Arc; use std::sync::Arc;
use url::Url; use url::Url;
use crate::note::ThoughtNote; use crate::note::{ThoughtNote, ThoughtNoteInput};
use crate::port::{AcceptNoteInput, ActivityPubRepository};
use crate::urls::ThoughtsUrls; use crate::urls::ThoughtsUrls;
use activitypub_base::{ActivityPubRepository, ApObjectHandler};
use domain::ports::{EventPublisher, TagRepository}; use domain::ports::{EventPublisher, TagRepository};
use domain::value_objects::UserId; use domain::value_objects::UserId;
use k_ap::ApObjectHandler;
pub struct ThoughtsObjectHandler { pub struct ThoughtsObjectHandler {
repo: Arc<dyn ActivityPubRepository>, repo: Arc<dyn ActivityPubRepository>,
@@ -52,22 +53,22 @@ impl ApObjectHandler for ThoughtsObjectHandler {
.into_iter() .into_iter()
.map(|e| { .map(|e| {
let note_url = self.urls.thought_url(e.thought.id.as_uuid()); let note_url = self.urls.thought_url(e.thought.id.as_uuid());
let actor_url = self.urls.user_url(e.author_username.as_str()); let actor_url = self.urls.user_url(&user_id.to_string());
let followers = self.urls.user_followers(e.author_username.as_str()); let followers = self.urls.user_followers(&user_id.to_string());
let in_reply_to = e let in_reply_to = e
.thought .thought
.in_reply_to_id .in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid())); .map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public( let note = ThoughtNote::new_public(ThoughtNoteInput {
note_url.clone(), id: note_url.clone(),
actor_url, actor_url,
e.thought.content.as_str().to_owned(), content: e.thought.content.as_str().to_owned(),
e.thought.created_at, published: e.thought.created_at,
in_reply_to, in_reply_to,
e.thought.sensitive, sensitive: e.thought.sensitive,
e.thought.content_warning, summary: e.thought.content_warning,
followers, followers_url: followers,
); });
Ok((note_url, serde_json::to_value(&note)?)) Ok((note_url, serde_json::to_value(&note)?))
}) })
.collect() .collect()
@@ -90,22 +91,22 @@ impl ApObjectHandler for ThoughtsObjectHandler {
.map(|e| { .map(|e| {
let created_at = e.thought.created_at; let created_at = e.thought.created_at;
let note_url = self.urls.thought_url(e.thought.id.as_uuid()); let note_url = self.urls.thought_url(e.thought.id.as_uuid());
let actor_url = self.urls.user_url(e.author_username.as_str()); let actor_url = self.urls.user_url(&user_id.to_string());
let followers = self.urls.user_followers(e.author_username.as_str()); let followers = self.urls.user_followers(&user_id.to_string());
let in_reply_to = e let in_reply_to = e
.thought .thought
.in_reply_to_id .in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid())); .map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public( let note = ThoughtNote::new_public(ThoughtNoteInput {
note_url.clone(), id: note_url.clone(),
actor_url, actor_url,
e.thought.content.as_str().to_owned(), content: e.thought.content.as_str().to_owned(),
created_at, published: created_at,
in_reply_to, in_reply_to,
e.thought.sensitive, sensitive: e.thought.sensitive,
e.thought.content_warning, summary: e.thought.content_warning,
followers, followers_url: followers,
); });
Ok((note_url, serde_json::to_value(&note)?, created_at)) Ok((note_url, serde_json::to_value(&note)?, created_at))
}) })
.collect() .collect()
@@ -117,7 +118,10 @@ impl ApObjectHandler for ThoughtsObjectHandler {
actor_url: &Url, actor_url: &Url,
object: serde_json::Value, object: serde_json::Value,
) -> Result<()> { ) -> Result<()> {
let note: ThoughtNote = serde_json::from_value(object)?; let Some((note, note_extensions)) = ThoughtNote::try_from_ap(object) else {
tracing::debug!(ap_id = %ap_id, "on_create: skipping non-Note object");
return Ok(());
};
let author_id = self let author_id = self
.repo .repo
.intern_remote_actor(actor_url.as_str()) .intern_remote_actor(actor_url.as_str())
@@ -141,17 +145,19 @@ impl ApObjectHandler for ThoughtsObjectHandler {
"direct" "direct"
}; };
let thought_id = self.repo let thought_id = self
.accept_note( .repo
ap_id.as_str(), .accept_note(AcceptNoteInput {
&author_id, ap_id: ap_id.as_str(),
&note.content, author_id: &author_id,
note.published, content: &note.content,
note.sensitive, published: note.published,
note.summary, sensitive: note.sensitive,
content_warning: note.summary,
visibility, visibility,
note.in_reply_to.as_ref().map(|u| u.as_str()), in_reply_to: note.in_reply_to.as_ref().map(|u| u.as_str()),
) note_extensions,
})
.await .await
.map_err(|e| anyhow!("{e}"))?; .map_err(|e| anyhow!("{e}"))?;
@@ -215,7 +221,10 @@ impl ApObjectHandler for ThoughtsObjectHandler {
_actor_url: &Url, _actor_url: &Url,
object: serde_json::Value, object: serde_json::Value,
) -> Result<()> { ) -> Result<()> {
let note: ThoughtNote = serde_json::from_value(object)?; let Some((note, _)) = ThoughtNote::try_from_ap(object) else {
tracing::debug!(ap_id = %ap_id, "on_update: skipping non-Note object");
return Ok(());
};
self.repo self.repo
.apply_note_update(ap_id.as_str(), &note.content) .apply_note_update(ap_id.as_str(), &note.content)
.await .await

View File

@@ -1,7 +1,13 @@
pub mod handler; pub mod handler;
pub mod note; pub mod note;
pub mod port;
pub mod service;
pub mod urls; pub mod urls;
pub use handler::ThoughtsObjectHandler; pub use handler::ThoughtsObjectHandler;
pub use note::ThoughtNote; pub use note::ThoughtNote;
pub use port::{
AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboundFederationPort, OutboxEntry,
};
pub use service::ApFederationAdapter;
pub use urls::ThoughtsUrls; pub use urls::ThoughtsUrls;

View File

@@ -1,9 +1,40 @@
use activitypub_base::NoteType;
use activitypub_base::AS_PUBLIC;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use k_ap::NoteType;
use k_ap::AS_PUBLIC;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
const STANDARD_NOTE_FIELDS: &[&str] = &[
"type",
"id",
"attributedTo",
"content",
"published",
"to",
"cc",
"inReplyTo",
"sensitive",
"summary",
"tag",
"url",
"@context",
"mediaType",
];
pub fn extract_extensions(obj: &serde_json::Value) -> Option<serde_json::Value> {
let extensions: serde_json::Map<String, serde_json::Value> = obj
.as_object()?
.iter()
.filter(|(k, _)| !STANDARD_NOTE_FIELDS.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
if extensions.is_empty() {
None
} else {
Some(serde_json::Value::Object(extensions))
}
}
/// AP Note representing a Thought. /// AP Note representing a Thought.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -11,7 +42,8 @@ pub struct ThoughtNote {
#[serde(rename = "type")] #[serde(rename = "type")]
pub kind: NoteType, pub kind: NoteType,
pub id: Url, pub id: Url,
pub url: Url, // Mastodon uses this as the clickable link #[serde(skip_serializing_if = "Option::is_none", default)]
pub url: Option<Url>,
pub attributed_to: Url, pub attributed_to: Url,
pub content: String, pub content: String,
pub published: DateTime<Utc>, pub published: DateTime<Utc>,
@@ -21,6 +53,7 @@ pub struct ThoughtNote {
pub cc: Vec<String>, pub cc: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<Url>, pub in_reply_to: Option<Url>,
#[serde(default)]
pub sensitive: bool, pub sensitive: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>, pub summary: Option<String>,
@@ -28,30 +61,42 @@ pub struct ThoughtNote {
pub tag: Vec<serde_json::Value>, pub tag: Vec<serde_json::Value>,
} }
pub struct ThoughtNoteInput {
pub id: Url,
pub actor_url: Url,
pub content: String,
pub published: DateTime<Utc>,
pub in_reply_to: Option<Url>,
pub sensitive: bool,
pub summary: Option<String>,
pub followers_url: Url,
}
impl ThoughtNote { impl ThoughtNote {
#[allow(clippy::too_many_arguments)] /// Returns `(note, extensions)` if `value` is a Note object, `None` otherwise.
pub fn new_public( pub fn try_from_ap(value: serde_json::Value) -> Option<(Self, Option<serde_json::Value>)> {
id: Url, if value.get("type").and_then(|v| v.as_str()) != Some("Note") {
actor_url: Url, return None;
content: String, }
published: DateTime<Utc>, let extensions = extract_extensions(&value);
in_reply_to: Option<Url>, serde_json::from_value(value)
sensitive: bool, .ok()
summary: Option<String>, .map(|note| (note, extensions))
followers_url: Url, }
) -> Self {
pub fn new_public(p: ThoughtNoteInput) -> Self {
Self { Self {
kind: Default::default(), kind: Default::default(),
url: id.clone(), url: Some(p.id.clone()),
id, id: p.id,
attributed_to: actor_url, attributed_to: p.actor_url,
content, content: p.content,
published, published: p.published,
to: vec![AS_PUBLIC.to_string()], to: vec![AS_PUBLIC.to_string()],
cc: vec![followers_url.to_string()], cc: vec![p.followers_url.to_string()],
in_reply_to, in_reply_to: p.in_reply_to,
sensitive, sensitive: p.sensitive,
summary, summary: p.summary,
tag: Vec::new(), tag: Vec::new(),
} }
} }

View File

@@ -1,17 +1,67 @@
use super::*; use super::*;
#[test]
fn extract_extensions_picks_up_non_standard_fields() {
let obj = serde_json::json!({
"type": "Note",
"id": "https://example.com/notes/1",
"content": "hello",
"published": "2025-01-01T00:00:00Z",
"movieTitle": "Dune",
"rating": 5,
"posterUrl": "https://example.com/poster.jpg"
});
let ext = extract_extensions(&obj).unwrap();
assert_eq!(ext["movieTitle"], "Dune");
assert_eq!(ext["rating"], 5);
assert_eq!(ext["posterUrl"], "https://example.com/poster.jpg");
assert!(ext.get("type").is_none());
assert!(ext.get("content").is_none());
assert!(ext.get("id").is_none());
}
#[test]
fn extract_extensions_returns_none_for_standard_only_note() {
let obj = serde_json::json!({
"type": "Note",
"content": "hello",
"published": "2025-01-01T00:00:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"tag": []
});
assert!(extract_extensions(&obj).is_none());
}
#[test]
fn extract_extensions_returns_none_for_non_object() {
let obj = serde_json::json!("not an object");
assert!(extract_extensions(&obj).is_none());
}
#[test]
fn try_from_ap_returns_none_for_person() {
let person = serde_json::json!({ "type": "Person", "id": "https://example.com/users/1" });
assert!(ThoughtNote::try_from_ap(person).is_none());
}
#[test]
fn try_from_ap_returns_none_for_missing_type() {
let obj = serde_json::json!({ "content": "hello" });
assert!(ThoughtNote::try_from_ap(obj).is_none());
}
#[test] #[test]
fn note_serializes_with_public_audience() { fn note_serializes_with_public_audience() {
let note = ThoughtNote::new_public( let note = ThoughtNote::new_public(super::ThoughtNoteInput {
"https://example.com/thoughts/1".parse().unwrap(), id: "https://example.com/thoughts/1".parse().unwrap(),
"https://example.com/users/alice".parse().unwrap(), actor_url: "https://example.com/users/alice".parse().unwrap(),
"Hello world".to_string(), content: "Hello world".to_string(),
chrono::Utc::now(), published: chrono::Utc::now(),
None, in_reply_to: None,
false, sensitive: false,
None, summary: None,
"https://example.com/users/alice/followers".parse().unwrap(), followers_url: "https://example.com/users/alice/followers".parse().unwrap(),
); });
let json = serde_json::to_string(&note).unwrap(); let json = serde_json::to_string(&note).unwrap();
assert!(json.contains(AS_PUBLIC)); assert!(json.contains(AS_PUBLIC));
assert!(json.contains("Hello world")); assert!(json.contains("Hello world"));

View File

@@ -5,6 +5,18 @@ use domain::{
value_objects::{ThoughtId, UserId, Username}, value_objects::{ThoughtId, UserId, Username},
}; };
pub struct AcceptNoteInput<'a> {
pub ap_id: &'a str,
pub author_id: &'a UserId,
pub content: &'a str,
pub published: chrono::DateTime<chrono::Utc>,
pub sensitive: bool,
pub content_warning: Option<String>,
pub visibility: &'a str,
pub in_reply_to: Option<&'a str>,
pub note_extensions: Option<serde_json::Value>,
}
/// AP-protocol endpoints for a locally-stored user (local or interned remote). /// AP-protocol endpoints for a locally-stored user (local or interned remote).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ActorApUrls { pub struct ActorApUrls {
@@ -61,18 +73,8 @@ pub trait ActivityPubRepository: Send + Sync {
// ── Inbox processing (remote → local) ─────────────────────────── // ── Inbox processing (remote → local) ───────────────────────────
/// Persist an incoming remote Note. Idempotent on ap_id. /// Persist an incoming remote Note. Idempotent on ap_id.
#[allow(clippy::too_many_arguments)]
async fn accept_note( async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError>;
&self,
ap_id: &str,
author_id: &UserId,
content: &str,
published: chrono::DateTime<chrono::Utc>,
sensitive: bool,
content_warning: Option<String>,
visibility: &str,
in_reply_to: Option<&str>,
) -> Result<ThoughtId, DomainError>;
/// Apply an Update to a previously accepted remote Note. /// Apply an Update to a previously accepted remote Note.
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>; async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>;

View File

@@ -0,0 +1,834 @@
use std::sync::Arc;
use async_trait::async_trait;
use k_ap::ActivityPubService;
use domain::{
errors::DomainError,
models::remote_actor::RemoteActor as DomainRemoteActor,
ports::{
FederationFetchPort, FederationFollowPort, FederationFollowRequestPort,
FederationLookupPort, FederationSchedulerPort, RemoteActorConnectionRepository,
},
value_objects::UserId,
};
const HTTP_FETCH_TIMEOUT_SECS: u64 = 30;
const BATCH_FETCH_SLEEP_MS: u64 = 100;
// ── Helpers ───────────────────────────────────────────────────────────────────
fn content_to_html(text: &str) -> String {
let escaped = text
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;");
let paragraphs: Vec<&str> = escaped.split('\n').filter(|s| !s.is_empty()).collect();
if paragraphs.is_empty() {
format!("<p>{}</p>", escaped)
} else {
paragraphs
.iter()
.map(|p| format!("<p>{}</p>", p))
.collect::<Vec<_>>()
.join("")
}
}
fn build_note_json(
thought: &domain::models::thought::Thought,
local_actor_ap_id: &str,
local_actor_followers_url: &str,
base_url: &str,
in_reply_to_url: Option<&str>,
) -> serde_json::Value {
let ap_id = format!("{}/thoughts/{}", base_url, thought.id);
let (to, cc) = match thought.visibility {
domain::models::thought::Visibility::Public => (
vec![k_ap::AS_PUBLIC.to_string()],
vec![local_actor_followers_url.to_string()],
),
domain::models::thought::Visibility::Unlisted => (
vec![local_actor_followers_url.to_string()],
vec![k_ap::AS_PUBLIC.to_string()],
),
domain::models::thought::Visibility::Followers => {
(vec![local_actor_followers_url.to_string()], vec![])
}
domain::models::thought::Visibility::Direct => (vec![], vec![]),
};
let mut note = serde_json::json!({
"type": "Note",
"id": ap_id,
"url": ap_id,
"attributedTo": local_actor_ap_id,
"content": content_to_html(thought.content.as_str()),
"published": thought.created_at.to_rfc3339(),
"to": to,
"cc": cc,
"sensitive": thought.sensitive,
});
if let Some(ref cw) = thought.content_warning {
note["summary"] = serde_json::json!(cw);
}
if let Some(reply_url) = in_reply_to_url {
note["inReplyTo"] = serde_json::json!(reply_url);
}
if let Some(updated_at) = thought.updated_at {
note["updated"] = serde_json::json!(updated_at.to_rfc3339());
}
let hashtags = domain::hashtag::extract(thought.content.as_str());
if !hashtags.is_empty() {
let ap_tags: Vec<serde_json::Value> = hashtags
.iter()
.map(|h| {
serde_json::json!({
"type": "Hashtag",
"name": h.ap_name,
"href": format!("{}/{}", base_url, h.url_slug),
})
})
.collect();
note["tag"] = serde_json::json!(ap_tags);
}
note
}
fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor {
DomainRemoteActor {
url: a.url,
handle: a.handle,
display_name: a.display_name,
avatar_url: a.avatar_url,
outbox_url: a.outbox_url,
last_fetched_at: chrono::Utc::now(),
bio: None,
banner_url: None,
also_known_as: None,
followers_url: None,
following_url: None,
attachment: vec![],
}
}
async fn resolve_actor_profiles_from_urls(
urls: Vec<String>,
) -> Vec<domain::models::actor_connection_summary::ActorConnectionSummary> {
use futures::future;
async fn fetch_one(
url: String,
) -> Option<domain::models::actor_connection_summary::ActorConnectionSummary> {
let resp: serde_json::Value = tokio::time::timeout(
std::time::Duration::from_secs(5),
reqwest::Client::new()
.get(&url)
.header("Accept", "application/activity+json")
.send(),
)
.await
.ok()?
.ok()?
.json()
.await
.ok()?;
let ap_url = resp["id"].as_str()?.to_string();
let preferred_username = resp["preferredUsername"].as_str().unwrap_or("").to_string();
let domain_str = url::Url::parse(&ap_url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.unwrap_or_default();
let handle = format!("{}@{}", preferred_username, domain_str);
let display_name = resp["name"].as_str().map(|s| s.to_string());
let avatar_url = resp["icon"]["url"].as_str().map(|s| s.to_string());
Some(
domain::models::actor_connection_summary::ActorConnectionSummary {
url: ap_url,
handle,
display_name,
avatar_url,
},
)
}
let futs: Vec<_> = urls.into_iter().map(fetch_one).collect();
let results = future::join_all(futs).await;
results
.into_iter()
.filter_map(|r| {
if r.is_none() {
tracing::warn!("failed to resolve actor profile (timeout or parse error)");
}
r
})
.collect()
}
async fn webfinger_resolve_actor_url(handle: &str) -> anyhow::Result<String> {
let normalized = handle.trim_start_matches('@');
let at = normalized
.rfind('@')
.ok_or_else(|| anyhow::anyhow!("handle must be user@domain"))?;
let (user, domain_str) = (&normalized[..at], &normalized[at + 1..]);
let wf_url = format!(
"https://{}/.well-known/webfinger?resource=acct:{}@{}",
domain_str, user, domain_str
);
let wf: serde_json::Value = reqwest::Client::new()
.get(&wf_url)
.header("Accept", "application/jrd+json, application/json")
.send()
.await?
.json()
.await?;
let self_href = wf["links"]
.as_array()
.and_then(|links| {
links.iter().find(|l| {
l["rel"].as_str() == Some("self")
&& l["type"].as_str() == Some("application/activity+json")
})
})
.and_then(|l| l["href"].as_str())
.ok_or_else(|| anyhow::anyhow!("no self link in WebFinger response"))?
.to_owned();
Ok(self_href)
}
// ── ApFederationAdapter ───────────────────────────────────────────────────────
/// Wraps `k_ap::ActivityPubService` together with the `RemoteActorConnectionRepository`
/// (which k-ap doesn't own), and implements all domain federation port traits.
#[derive(Clone)]
pub struct ApFederationAdapter {
pub(crate) inner: Arc<ActivityPubService>,
pub(crate) connections_repo: Arc<dyn RemoteActorConnectionRepository>,
}
impl ApFederationAdapter {
pub fn new(
inner: Arc<ActivityPubService>,
connections_repo: Arc<dyn RemoteActorConnectionRepository>,
) -> Self {
Self {
inner,
connections_repo,
}
}
pub fn router<S>(&self) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
self.inner.router()
}
fn base_url(&self) -> &str {
self.inner.base_url()
}
fn actor_ap_id(&self, user_uuid: uuid::Uuid) -> String {
format!("{}/users/{}", self.base_url(), user_uuid)
}
fn actor_followers_url(&self, user_uuid: uuid::Uuid) -> String {
format!("{}/followers", self.actor_ap_id(user_uuid))
}
}
// ── OutboundFederationPort ────────────────────────────────────────────────────
#[async_trait]
impl crate::port::OutboundFederationPort for ApFederationAdapter {
async fn broadcast_create(
&self,
author_user_id: &UserId,
thought: &domain::models::thought::Thought,
_author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError> {
let user_uuid = author_user_id.as_uuid();
let ap_id = self.actor_ap_id(user_uuid);
let followers_url = self.actor_followers_url(user_uuid);
let note = build_note_json(
thought,
&ap_id,
&followers_url,
self.base_url(),
in_reply_to_url,
);
self.inner
.broadcast_create_note(user_uuid, note)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn broadcast_delete(
&self,
author_user_id: &UserId,
thought_ap_id: &str,
) -> Result<(), DomainError> {
let ap_id =
url::Url::parse(thought_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_delete_to_followers(author_user_id.as_uuid(), ap_id)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn broadcast_update(
&self,
author_user_id: &UserId,
thought: &domain::models::thought::Thought,
_author_username: &str,
in_reply_to_url: Option<&str>,
) -> Result<(), DomainError> {
let user_uuid = author_user_id.as_uuid();
let ap_id = self.actor_ap_id(user_uuid);
let followers_url = self.actor_followers_url(user_uuid);
let note = build_note_json(
thought,
&ap_id,
&followers_url,
self.base_url(),
in_reply_to_url,
);
self.inner
.broadcast_update_note(user_uuid, note)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn broadcast_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError> {
let ap_id =
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_announce_to_followers(booster_user_id.as_uuid(), ap_id)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn broadcast_undo_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError> {
let ap_id =
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_undo_announce_to_followers(booster_user_id.as_uuid(), ap_id)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn broadcast_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError> {
let object =
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
let inbox =
url::Url::parse(author_inbox_url).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn broadcast_undo_like(
&self,
liker_user_id: &UserId,
object_ap_id: &str,
author_inbox_url: &str,
) -> Result<(), DomainError> {
let object =
url::Url::parse(object_ap_id).map_err(|e| DomainError::Internal(e.to_string()))?;
let inbox =
url::Url::parse(author_inbox_url).map_err(|e| DomainError::Internal(e.to_string()))?;
self.inner
.broadcast_undo_like_to_inbox(liker_user_id.as_uuid(), object, inbox)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError> {
self.inner
.broadcast_actor_update(user_id.as_uuid())
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
}
// ── FederationSchedulerPort ───────────────────────────────────────────────────
#[async_trait]
impl FederationSchedulerPort for ApFederationAdapter {
async fn schedule_actor_posts_fetch(
&self,
actor_ap_url: &str,
outbox_url: &str,
) -> Result<(), DomainError> {
let service = self.inner.clone();
let actor = actor_ap_url.to_string();
let outbox = outbox_url.to_string();
tokio::spawn(async move {
if let Err(e) = service.backfill_outbox(&outbox, &actor).await {
tracing::warn!(actor = %actor, error = %e, "posts backfill failed");
}
});
Ok(())
}
async fn schedule_connections_fetch(
&self,
actor_ap_url: &str,
collection_url: &str,
connection_type: &str,
page: u32,
) -> Result<(), DomainError> {
if page != 1 {
return Ok(());
}
let actor = actor_ap_url.to_string();
let collection = collection_url.to_string();
let conn_type = connection_type.to_string();
let connections_repo = self.connections_repo.clone();
tokio::spawn(async move {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(HTTP_FETCH_TIMEOUT_SECS))
.build()
{
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "connections fetch: failed to build client");
return;
}
};
let mut all_urls: Vec<String> = Vec::new();
let mut current_url: Option<String> = Some(collection.clone());
const MAX_ACTORS: usize = 500;
while let Some(url) = current_url.take() {
let val: serde_json::Value = match client
.get(&url)
.header("Accept", "application/activity+json, application/ld+json")
.send()
.await
{
Ok(r) => match r.json().await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, url = %url, "connections: parse error");
break;
}
},
Err(e) => {
tracing::warn!(error = %e, url = %url, "connections: HTTP error");
break;
}
};
if val["type"].as_str() == Some("OrderedCollection") {
current_url = val["first"].as_str().map(|s| s.to_string());
continue;
}
let empty = vec![];
let items = val["orderedItems"].as_array().unwrap_or(&empty);
for item in items {
let actor_url = item.as_str().or_else(|| item["id"].as_str()).unwrap_or("");
if !actor_url.is_empty() {
all_urls.push(actor_url.to_string());
}
}
if all_urls.len() >= MAX_ACTORS {
break;
}
current_url = val["next"].as_str().map(|s| s.to_string());
if current_url.is_some() {
tokio::time::sleep(std::time::Duration::from_millis(BATCH_FETCH_SLEEP_MS))
.await;
}
}
if all_urls.is_empty() {
tracing::debug!(
actor = %actor,
connection_type = %conn_type,
"connections: empty collection"
);
return;
}
const PAGE_SIZE: usize = 20;
for (idx, chunk) in all_urls.chunks(PAGE_SIZE).enumerate() {
let page_num = (idx + 1) as u32;
let resolved = resolve_actor_profiles_from_urls(chunk.to_vec()).await;
if let Err(e) = connections_repo
.upsert_connections(&actor, &conn_type, page_num, &resolved)
.await
{
tracing::warn!(error = %e, "connections: upsert failed");
}
}
tracing::debug!(
actor = %actor,
connection_type = %conn_type,
count = all_urls.len(),
"connections fetch complete"
);
});
Ok(())
}
}
// ── FederationLookupPort ──────────────────────────────────────────────────────
#[async_trait]
impl FederationLookupPort for ApFederationAdapter {
async fn lookup_actor(&self, handle: &str) -> Result<DomainRemoteActor, DomainError> {
let actor = self
.inner
.lookup_actor_by_handle(handle)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
Ok(DomainRemoteActor {
url: actor.ap_url.to_string(),
handle: actor.handle,
display_name: actor.display_name,
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
outbox_url: actor.outbox_url.as_ref().map(|u| u.to_string()),
last_fetched_at: chrono::Utc::now(),
bio: actor.bio,
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
also_known_as: actor.also_known_as,
followers_url: actor.followers_url.as_ref().map(|u| u.to_string()),
following_url: actor.following_url.as_ref().map(|u| u.to_string()),
attachment: actor
.attachment
.into_iter()
.map(|f| (f.name, f.value))
.collect(),
})
}
async fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError> {
self.inner
.actor_json(&user_id.as_uuid().to_string())
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn followers_collection_json(
&self,
user_id: &UserId,
page: Option<u32>,
) -> Result<String, DomainError> {
self.inner
.followers_collection_json(user_id.as_uuid(), page)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn following_collection_json(
&self,
user_id: &UserId,
page: Option<u32>,
) -> Result<String, DomainError> {
self.inner
.following_collection_json(user_id.as_uuid(), page)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
}
// ── FederationFetchPort ───────────────────────────────────────────────────────
#[async_trait]
impl FederationFetchPort for ApFederationAdapter {
async fn fetch_outbox_page(
&self,
outbox_url: &str,
page: u32,
) -> Result<Vec<domain::models::remote_note::RemoteNote>, DomainError> {
use chrono::DateTime;
let client = reqwest::Client::new();
let base: serde_json::Value = client
.get(outbox_url)
.header("Accept", "application/activity+json, application/ld+json")
.send()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?
.json()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
let url = base["first"]
.as_str()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{}?page={}", outbox_url, page));
let resp: serde_json::Value = client
.get(&url)
.header("Accept", "application/activity+json, application/ld+json")
.send()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?
.json()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
let empty = vec![];
let items = resp["orderedItems"].as_array().unwrap_or(&empty);
let notes = items
.iter()
.filter_map(|item| {
let note = if item["type"].as_str() == Some("Create") {
&item["object"]
} else if item["type"].as_str() == Some("Note") {
item
} else {
return None;
};
let to = note["to"].as_array()?;
let is_public = to
.iter()
.any(|t| t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public"));
if !is_public {
return None;
}
let published = DateTime::parse_from_rfc3339(note["published"].as_str()?)
.ok()?
.with_timezone(&chrono::Utc);
let text = note["content"].as_str().unwrap_or("").to_string();
let has_attachments = note["attachment"]
.as_array()
.map(|a| !a.is_empty())
.unwrap_or(false);
let content = if has_attachments {
let notice =
"<p class=\"media-notice\">📎 Media attachment — not supported</p>";
if text.is_empty() {
notice.to_string()
} else {
format!("{text}{notice}")
}
} else {
text
};
Some(domain::models::remote_note::RemoteNote {
ap_id: note["id"].as_str()?.to_string(),
content,
published,
sensitive: note["sensitive"].as_bool().unwrap_or(false),
content_warning: note["summary"].as_str().map(|s| s.to_string()),
})
})
.collect();
Ok(notes)
}
async fn fetch_actor_urls_from_collection(
&self,
collection_url: &str,
) -> Result<Vec<String>, DomainError> {
let client = reqwest::Client::new();
let base: serde_json::Value = client
.get(collection_url)
.header("Accept", "application/activity+json, application/ld+json")
.send()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?
.json()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
let page = if base["orderedItems"].is_null() {
if let Some(first_url) = base["first"].as_str() {
client
.get(first_url)
.header("Accept", "application/activity+json, application/ld+json")
.send()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?
.json()
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?
} else {
base
}
} else {
base
};
let empty = vec![];
let items = page["orderedItems"].as_array().unwrap_or(&empty);
Ok(items
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect())
}
async fn resolve_actor_profiles(
&self,
urls: Vec<String>,
) -> Vec<domain::models::actor_connection_summary::ActorConnectionSummary> {
resolve_actor_profiles_from_urls(urls).await
}
}
// ── FederationFollowPort ──────────────────────────────────────────────────────
#[async_trait]
impl FederationFollowPort for ApFederationAdapter {
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError> {
self.inner
.follow(local_user_id.as_uuid(), handle)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn unfollow_remote(
&self,
local_user_id: &UserId,
handle: &str,
) -> Result<(), DomainError> {
let actor_url = webfinger_resolve_actor_url(handle)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
self.inner
.unfollow(local_user_id.as_uuid(), &actor_url)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn get_remote_following(
&self,
user_id: &UserId,
) -> Result<Vec<DomainRemoteActor>, DomainError> {
self.inner
.get_following(user_id.as_uuid())
.await
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn broadcast_move(
&self,
user_id: &UserId,
new_actor_url: url::Url,
) -> Result<(), DomainError> {
self.inner
.broadcast_move(user_id.as_uuid(), new_actor_url)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
}
// ── FederationFollowRequestPort ───────────────────────────────────────────────
#[async_trait]
impl FederationFollowRequestPort for ApFederationAdapter {
async fn get_pending_followers(
&self,
user_id: &UserId,
) -> Result<Vec<DomainRemoteActor>, DomainError> {
self.inner
.get_pending_followers(user_id.as_uuid())
.await
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn accept_follow_request(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError> {
self.inner
.accept_follower(user_id.as_uuid(), actor_url)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn reject_follow_request(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError> {
self.inner
.reject_follower(user_id.as_uuid(), actor_url)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn get_remote_followers(
&self,
user_id: &UserId,
) -> Result<Vec<DomainRemoteActor>, DomainError> {
self.inner
.get_accepted_followers(user_id.as_uuid())
.await
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn remove_remote_follower(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError> {
self.inner
.remove_follower(user_id.as_uuid(), actor_url)
.await
.map_err(|e| DomainError::ExternalService(e.to_string()))
}
async fn mark_follower_accepted(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError> {
self.inner
.mark_follower_accepted(user_id.as_uuid(), actor_url)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn mark_follower_rejected(
&self,
user_id: &UserId,
actor_url: &str,
) -> Result<(), DomainError> {
self.inner
.mark_follower_rejected(user_id.as_uuid(), actor_url)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
}
// FederationActionPort is a blanket supertrait; no explicit impl needed.

View File

@@ -18,7 +18,13 @@ impl ApiKeyRepository for FakeApiKeyRepo {
Ok(()) Ok(())
} }
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> { async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
Ok(self.0.lock().unwrap().iter().find(|k| k.key_hash == hash).cloned()) Ok(self
.0
.lock()
.unwrap()
.iter()
.find(|k| k.key_hash == hash)
.cloned())
} }
async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<ApiKey>, DomainError> { async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<ApiKey>, DomainError> {
Ok(vec![]) Ok(vec![])

View File

@@ -71,6 +71,18 @@ pub enum EventPayload {
ProfileUpdated { ProfileUpdated {
user_id: String, user_id: String,
}, },
RemoteFollowAccepted {
local_user_id: String,
remote_actor_url: String,
},
RemoteFollowRejected {
local_user_id: String,
remote_actor_url: String,
},
ActorMoved {
user_id: String,
new_actor_url: String,
},
MentionReceived { MentionReceived {
thought_id: String, thought_id: String,
mentioned_user_id: String, mentioned_user_id: String,
@@ -97,6 +109,9 @@ impl EventPayload {
Self::UserUnblocked { .. } => "users.unblocked", Self::UserUnblocked { .. } => "users.unblocked",
Self::UserRegistered { .. } => "users.registered", Self::UserRegistered { .. } => "users.registered",
Self::ProfileUpdated { .. } => "users.profile_updated", Self::ProfileUpdated { .. } => "users.profile_updated",
Self::RemoteFollowAccepted { .. } => "federation.follow.accepted",
Self::RemoteFollowRejected { .. } => "federation.follow.rejected",
Self::ActorMoved { .. } => "federation.actor.moved",
Self::MentionReceived { .. } => "mentions.received", Self::MentionReceived { .. } => "mentions.received",
} }
} }
@@ -210,6 +225,27 @@ impl From<&DomainEvent> for EventPayload {
DomainEvent::ProfileUpdated { user_id } => Self::ProfileUpdated { DomainEvent::ProfileUpdated { user_id } => Self::ProfileUpdated {
user_id: user_id.to_string(), user_id: user_id.to_string(),
}, },
DomainEvent::RemoteFollowAccepted {
local_user_id,
remote_actor_url,
} => Self::RemoteFollowAccepted {
local_user_id: local_user_id.to_string(),
remote_actor_url: remote_actor_url.clone(),
},
DomainEvent::RemoteFollowRejected {
local_user_id,
remote_actor_url,
} => Self::RemoteFollowRejected {
local_user_id: local_user_id.to_string(),
remote_actor_url: remote_actor_url.clone(),
},
DomainEvent::ActorMoved {
user_id,
new_actor_url,
} => Self::ActorMoved {
user_id: user_id.to_string(),
new_actor_url: new_actor_url.clone(),
},
DomainEvent::MentionReceived { DomainEvent::MentionReceived {
thought_id, thought_id,
mentioned_user_id, mentioned_user_id,
@@ -340,6 +376,27 @@ impl TryFrom<EventPayload> for DomainEvent {
EventPayload::ProfileUpdated { user_id } => DomainEvent::ProfileUpdated { EventPayload::ProfileUpdated { user_id } => DomainEvent::ProfileUpdated {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
}, },
EventPayload::RemoteFollowAccepted {
local_user_id,
remote_actor_url,
} => DomainEvent::RemoteFollowAccepted {
local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?),
remote_actor_url,
},
EventPayload::RemoteFollowRejected {
local_user_id,
remote_actor_url,
} => DomainEvent::RemoteFollowRejected {
local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?),
remote_actor_url,
},
EventPayload::ActorMoved {
user_id,
new_actor_url,
} => DomainEvent::ActorMoved {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
new_actor_url,
},
EventPayload::MentionReceived { EventPayload::MentionReceived {
thought_id, thought_id,
mentioned_user_id, mentioned_user_id,
@@ -356,6 +413,5 @@ impl TryFrom<EventPayload> for DomainEvent {
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -109,6 +109,5 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -239,6 +239,5 @@ impl MessageSource for NatsMessageSource {
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
activitypub-base = { workspace = true } k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.10" }
sqlx = { workspace = true } sqlx = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }

View File

@@ -3,7 +3,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::PgPool; use sqlx::PgPool;
use activitypub_base::{ use k_ap::{
ApUser, ApUserRepository, BlockedDomain, FederationRepository, Follower, FollowerStatus, ApUser, ApUserRepository, BlockedDomain, FederationRepository, Follower, FollowerStatus,
FollowingStatus, RemoteActor, FollowingStatus, RemoteActor,
}; };
@@ -490,6 +490,41 @@ impl FederationRepository for PostgresFederationRepository {
).bind(local_user_id).bind(actor_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; ).bind(local_user_id).bind(actor_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
Ok(n > 0) Ok(n > 0)
} }
async fn migrate_follower_actor(
&self,
old_actor_url: &str,
new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>> {
let mut tx = self.pool.begin().await.map_err(|e| anyhow!(e))?;
// Copy rows to the new actor URL, carrying over existing data.
// ON CONFLICT DO NOTHING skips users already following the new actor.
// RETURNING gives us user IDs that actually need a re-follow.
let affected: Vec<uuid::Uuid> = sqlx::query_scalar(
"INSERT INTO federation_following(local_user_id, remote_actor_url, follow_activity_id, outbox_url)
SELECT local_user_id, $2, follow_activity_id, outbox_url
FROM federation_following
WHERE remote_actor_url = $1
ON CONFLICT (local_user_id, remote_actor_url) DO NOTHING
RETURNING local_user_id",
)
.bind(old_actor_url)
.bind(new_actor_url)
.fetch_all(&mut *tx)
.await
.map_err(|e| anyhow!(e))?;
// Delete the old rows.
sqlx::query("DELETE FROM federation_following WHERE remote_actor_url = $1")
.bind(old_actor_url)
.execute(&mut *tx)
.await
.map_err(|e| anyhow!(e))?;
tx.commit().await.map_err(|e| anyhow!(e))?;
Ok(affected)
}
} }
// ── PostgresApUserRepository ────────────────────────────────────────────────── // ── PostgresApUserRepository ──────────────────────────────────────────────────
@@ -510,16 +545,19 @@ impl PostgresApUserRepository {
username: String, username: String,
bio: Option<String>, bio: Option<String>,
avatar_url: Option<String>, avatar_url: Option<String>,
header_url: Option<String>,
also_known_as: Option<String>,
) -> ApUser { ) -> ApUser {
let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, username)).ok(); let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, username)).ok();
let avatar_url = avatar_url.and_then(|u| url::Url::parse(&u).ok()); let avatar_url = avatar_url.and_then(|u| url::Url::parse(&u).ok());
let banner_url = header_url.and_then(|u| url::Url::parse(&u).ok());
ApUser { ApUser {
id, id,
username, username,
bio, bio,
avatar_url, avatar_url,
banner_url: None, banner_url,
also_known_as: None, also_known_as,
profile_url, profile_url,
attachment: vec![], attachment: vec![],
} }
@@ -535,15 +573,26 @@ impl ApUserRepository for PostgresApUserRepository {
username: String, username: String,
bio: Option<String>, bio: Option<String>,
avatar_url: Option<String>, avatar_url: Option<String>,
header_url: Option<String>,
also_known_as: Option<String>,
} }
let row = sqlx::query_as::<_, Row>( let row = sqlx::query_as::<_, Row>(
"SELECT id,username,bio,avatar_url FROM users WHERE id=$1 AND local=true", "SELECT id,username,bio,avatar_url,header_url,also_known_as FROM users WHERE id=$1 AND local=true",
) )
.bind(id) .bind(id)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| anyhow!(e))?; .map_err(|e| anyhow!(e))?;
Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url))) Ok(row.map(|r| {
self.row_to_ap_user(
r.id,
r.username,
r.bio,
r.avatar_url,
r.header_url,
r.also_known_as,
)
}))
} }
async fn find_by_username(&self, username: &str) -> Result<Option<ApUser>> { async fn find_by_username(&self, username: &str) -> Result<Option<ApUser>> {
@@ -553,15 +602,26 @@ impl ApUserRepository for PostgresApUserRepository {
username: String, username: String,
bio: Option<String>, bio: Option<String>,
avatar_url: Option<String>, avatar_url: Option<String>,
header_url: Option<String>,
also_known_as: Option<String>,
} }
let row = sqlx::query_as::<_, Row>( let row = sqlx::query_as::<_, Row>(
"SELECT id,username,bio,avatar_url FROM users WHERE username=$1 AND local=true", "SELECT id,username,bio,avatar_url,header_url,also_known_as FROM users WHERE username=$1 AND local=true",
) )
.bind(username) .bind(username)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| anyhow!(e))?; .map_err(|e| anyhow!(e))?;
Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url))) Ok(row.map(|r| {
self.row_to_ap_user(
r.id,
r.username,
r.bio,
r.avatar_url,
r.header_url,
r.also_known_as,
)
}))
} }
async fn count_users(&self) -> Result<usize> { async fn count_users(&self) -> Result<usize> {

View File

@@ -10,6 +10,7 @@ sqlx = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }

View File

@@ -52,6 +52,7 @@ struct FeedRow {
reply_count: i64, reply_count: i64,
liked_by_viewer: bool, liked_by_viewer: bool,
boosted_by_viewer: bool, boosted_by_viewer: bool,
note_extensions: Option<serde_json::Value>,
} }
fn feed_select(viewer: Option<uuid::Uuid>) -> String { fn feed_select(viewer: Option<uuid::Uuid>) -> String {
@@ -67,7 +68,7 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\ t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\
t.in_reply_to_id,\n\ t.in_reply_to_id,\n\
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\ t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\
t.created_at AS thought_created_at, t.updated_at,\n\ t.created_at AS thought_created_at, t.updated_at, t.note_extensions,\n\
u.id AS author_id, u.username, u.email, u.password_hash,\n\ u.id AS author_id, u.username, u.email, u.password_hash,\n\
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,\n\ u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,\n\
u.local AS author_local,\n\ u.local AS author_local,\n\
@@ -92,6 +93,7 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
local: r.t_local, local: r.t_local,
created_at: r.thought_created_at, created_at: r.thought_created_at,
updated_at: r.updated_at, updated_at: r.updated_at,
note_extensions: r.note_extensions,
}; };
let author = User { let author = User {
id: UserId::from_uuid(r.author_id), id: UserId::from_uuid(r.author_id),

View File

@@ -1,7 +1,7 @@
use super::*; use super::*;
use domain::{ use domain::{
models::{ models::{
thought::{Thought, Visibility}, thought::{NewThought, Thought, Visibility},
user::User, user::User,
}, },
ports::{SearchPort, ThoughtRepository, UserWriter}, ports::{SearchPort, ThoughtRepository, UserWriter},
@@ -19,15 +19,15 @@ async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (Us
PasswordHash("h".into()), PasswordHash("h".into()),
); );
urepo.save(&u).await.unwrap(); urepo.save(&u).await.unwrap();
let t = Thought::new_local( let t = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
u.id.clone(), user_id: u.id.clone(),
Content::new_local(content).unwrap(), content: Content::new_local(content).unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(u, t) (u, t)
} }

View File

@@ -5,7 +5,7 @@ edition = "2021"
[dependencies] [dependencies]
domain = { workspace = true } domain = { workspace = true }
activitypub-base = { workspace = true } activitypub = { workspace = true }
event-payload = { workspace = true } event-payload = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }

View File

@@ -0,0 +1 @@
ALTER TABLE thoughts ADD COLUMN note_extensions JSONB;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS also_known_as TEXT;

View File

@@ -6,7 +6,7 @@ const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::PgPool; use sqlx::PgPool;
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry}; use activitypub::{AcceptNoteInput, ActivityPubRepository, ActorApUrls, OutboxEntry};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::thought::{Thought, Visibility}, models::thought::{Thought, Visibility},
@@ -65,6 +65,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
local: true, local: true,
created_at: r.created_at, created_at: r.created_at,
updated_at: r.updated_at, updated_at: r.updated_at,
note_extensions: None,
}, },
author_username: Username::from_trusted(r.username), author_username: Username::from_trusted(r.username),
}) })
@@ -130,6 +131,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
local: true, local: true,
created_at: r.created_at, created_at: r.created_at,
updated_at: r.updated_at, updated_at: r.updated_at,
note_extensions: None,
}, },
author_username: Username::from_trusted(r.username), author_username: Username::from_trusted(r.username),
}) })
@@ -210,34 +212,46 @@ impl ActivityPubRepository for PgActivityPubRepository {
.map(|_| ()) .map(|_| ())
} }
async fn accept_note( async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError> {
&self, let AcceptNoteInput {
ap_id: &str, ap_id,
author_id: &UserId, author_id,
content: &str, content,
published: DateTime<Utc>, published,
sensitive: bool, sensitive,
content_warning: Option<String>, content_warning,
visibility: &str, visibility,
in_reply_to: Option<&str>, in_reply_to,
) -> Result<ThoughtId, DomainError> { note_extensions,
} = input;
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect(); let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
let (in_reply_to_id, in_reply_to_url) = match in_reply_to { let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
Some(url) => { Some(url) => {
// If the parent is a local thought, extract its UUID for in_reply_to_id. // Fast path: local thought URL contains the UUID directly.
let local_uuid = url::Url::parse(url).ok().and_then(|u| { let local_uuid = url::Url::parse(url).ok().and_then(|u| {
u.path() u.path()
.strip_prefix(THOUGHTS_PATH_PREFIX) .strip_prefix(THOUGHTS_PATH_PREFIX)
.and_then(|s| s.split('/').next()) .and_then(|s| s.split('/').next())
.and_then(|s| uuid::Uuid::parse_str(s).ok()) .and_then(|s| uuid::Uuid::parse_str(s).ok())
}); });
(local_uuid, Some(url.to_string())) // Slow path: remote parent — look up by ap_id so remote-to-remote
// replies are threaded correctly in the feed.
let resolved = if local_uuid.is_some() {
local_uuid
} else {
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM thoughts WHERE ap_id=$1")
.bind(url)
.fetch_optional(&self.pool)
.await
.into_domain()?
};
(resolved, Some(url.to_string()))
} }
None => (None, None), None => (None, None),
}; };
sqlx::query( sqlx::query(
"INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at,in_reply_to_id,in_reply_to_url) "INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at,in_reply_to_id,in_reply_to_url,note_extensions)
VALUES($1,$2,$3,$4,$8,$5,false,$6,$7,$9,$10) ON CONFLICT(ap_id) DO NOTHING", VALUES($1,$2,$3,$4,$8,$5,false,$6,$7,$9,$10,$11) ON CONFLICT(ap_id) DO NOTHING",
) )
.bind(uuid::Uuid::new_v4()) .bind(uuid::Uuid::new_v4())
.bind(author_id.as_uuid()) .bind(author_id.as_uuid())
@@ -249,13 +263,13 @@ impl ActivityPubRepository for PgActivityPubRepository {
.bind(visibility) .bind(visibility)
.bind(in_reply_to_id) .bind(in_reply_to_id)
.bind(&in_reply_to_url) .bind(&in_reply_to_url)
.bind(note_extensions)
.execute(&self.pool) .execute(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
// SELECT the id — works whether the INSERT was a no-op or not (idempotent). // SELECT the id — works whether the INSERT was a no-op or not (idempotent).
let row: (uuid::Uuid,) = let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
.bind(ap_id) .bind(ap_id)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await

View File

@@ -1,44 +1,45 @@
use super::*; use super::*;
use activitypub_base::ActivityPubRepository; use activitypub::{AcceptNoteInput, ActivityPubRepository};
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) { async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool); let repo = PgActivityPubRepository::new(pool);
let url = "https://mastodon.social/users/alice"; let url = "https://mastodon.social/users/alice";
let id1 = repo.intern_remote_actor(url).await.unwrap(); let id1 = repo.intern_remote_actor(url).await.unwrap();
let id2 = repo.intern_remote_actor(url).await.unwrap(); let id2 = repo.intern_remote_actor(url).await.unwrap();
assert_eq!(id1, id2); assert_eq!(id1, id2);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn accept_and_retract_note(pool: sqlx::PgPool) { async fn accept_and_retract_note(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool); let repo = PgActivityPubRepository::new(pool);
let actor_url = "https://remote.example/users/bob"; let actor_url = "https://remote.example/users/bob";
let ap_id = "https://remote.example/notes/1"; let ap_id = "https://remote.example/notes/1";
let author = repo.intern_remote_actor(actor_url).await.unwrap(); let author = repo.intern_remote_actor(actor_url).await.unwrap();
repo.accept_note( repo.accept_note(AcceptNoteInput {
ap_id, ap_id,
&author, author_id: &author,
"hello from remote", content: "hello from remote",
chrono::Utc::now(), published: chrono::Utc::now(),
false, sensitive: false,
None, content_warning: None,
"public", visibility: "public",
None, in_reply_to: None,
) note_extensions: None,
})
.await .await
.unwrap(); .unwrap();
repo.retract_note(ap_id).await.unwrap(); repo.retract_note(ap_id).await.unwrap();
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) { async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool); let repo = PgActivityPubRepository::new(pool);
assert_eq!(repo.count_local_notes().await.unwrap(), 0); assert_eq!(repo.count_local_notes().await.unwrap(), 0);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn accept_note_returns_thought_id(pool: sqlx::PgPool) { async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool.clone()); let repo = PgActivityPubRepository::new(pool.clone());
let actor_user_id = repo let actor_user_id = repo
.intern_remote_actor("https://remote.example/users/alice") .intern_remote_actor("https://remote.example/users/alice")
@@ -46,16 +47,17 @@
.unwrap(); .unwrap();
let thought_id = repo let thought_id = repo
.accept_note( .accept_note(AcceptNoteInput {
"https://remote.example/notes/1", ap_id: "https://remote.example/notes/1",
&actor_user_id, author_id: &actor_user_id,
"Hello #rust world", content: "Hello #rust world",
chrono::Utc::now(), published: chrono::Utc::now(),
false, sensitive: false,
None, content_warning: None,
"public", visibility: "public",
None, in_reply_to: None,
) note_extensions: None,
})
.await .await
.unwrap(); .unwrap();
@@ -65,4 +67,4 @@
.await .await
.unwrap(); .unwrap();
assert_eq!(thought_id.as_uuid(), row.0); assert_eq!(thought_id.as_uuid(), row.0);
} }

View File

@@ -1,10 +1,10 @@
use super::*; use super::*;
use crate::user::PgUserRepository; use crate::user::PgUserRepository;
use chrono::Utc; use chrono::Utc;
use domain::ports::UserWriter; use domain::ports::UserWriter;
use domain::{models::user::User, value_objects::*}; use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool) -> User { async fn seed_user(pool: &sqlx::PgPool) -> User {
let repo = PgUserRepository::new(pool.clone()); let repo = PgUserRepository::new(pool.clone());
let u = User::new_local( let u = User::new_local(
UserId::new(), UserId::new(),
@@ -14,10 +14,10 @@
); );
repo.save(&u).await.unwrap(); repo.save(&u).await.unwrap();
u u
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_find_by_hash(pool: sqlx::PgPool) { async fn save_and_find_by_hash(pool: sqlx::PgPool) {
let user = seed_user(&pool).await; let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool); let repo = PgApiKeyRepository::new(pool);
let key = ApiKey { let key = ApiKey {
@@ -30,10 +30,10 @@
repo.save(&key).await.unwrap(); repo.save(&key).await.unwrap();
let found = repo.find_by_hash("abc123").await.unwrap().unwrap(); let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
assert_eq!(found.name, "test"); assert_eq!(found.name, "test");
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn delete_key(pool: sqlx::PgPool) { async fn delete_key(pool: sqlx::PgPool) {
let user = seed_user(&pool).await; let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool); let repo = PgApiKeyRepository::new(pool);
let key = ApiKey { let key = ApiKey {
@@ -46,4 +46,4 @@
repo.save(&key).await.unwrap(); repo.save(&key).await.unwrap();
repo.delete(&key.id, &user.id).await.unwrap(); repo.delete(&key.id, &user.id).await.unwrap();
assert!(repo.find_by_hash("def456").await.unwrap().is_none()); assert!(repo.find_by_hash("def456").await.unwrap().is_none());
} }

View File

@@ -1,10 +1,10 @@
use super::*; use super::*;
use crate::test_helpers::seed_user; use crate::test_helpers::seed_user;
use chrono::Utc; use chrono::Utc;
use domain::value_objects::*; use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn block_exists(pool: sqlx::PgPool) { async fn block_exists(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool); let repo = PgBlockRepository::new(pool);
@@ -16,10 +16,10 @@
repo.save(&block).await.unwrap(); repo.save(&block).await.unwrap();
assert!(repo.exists(&alice.id, &bob.id).await.unwrap()); assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap()); assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn unblock(pool: sqlx::PgPool) { async fn unblock(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool); let repo = PgBlockRepository::new(pool);
@@ -31,4 +31,4 @@
repo.save(&block).await.unwrap(); repo.save(&block).await.unwrap();
repo.delete(&alice.id, &bob.id).await.unwrap(); repo.delete(&alice.id, &bob.id).await.unwrap();
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap()); assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
} }

View File

@@ -1,10 +1,10 @@
use super::*; use super::*;
use crate::test_helpers::seed_user_and_thought; use crate::test_helpers::seed_user_and_thought;
use chrono::Utc; use chrono::Utc;
use domain::value_objects::*; use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn boost_and_count(pool: sqlx::PgPool) { async fn boost_and_count(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await; let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgBoostRepository::new(pool); let repo = PgBoostRepository::new(pool);
let boost = Boost { let boost = Boost {
@@ -16,10 +16,10 @@
}; };
repo.save(&boost).await.unwrap(); repo.save(&boost).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn unboost(pool: sqlx::PgPool) { async fn unboost(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await; let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgBoostRepository::new(pool); let repo = PgBoostRepository::new(pool);
let boost = Boost { let boost = Boost {
@@ -32,4 +32,4 @@
repo.save(&boost).await.unwrap(); repo.save(&boost).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap(); repo.delete(&user.id, &thought.id).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
} }

View File

@@ -35,6 +35,7 @@ struct FeedRow {
t_local: bool, t_local: bool,
thought_created_at: DateTime<Utc>, thought_created_at: DateTime<Utc>,
updated_at: Option<DateTime<Utc>>, updated_at: Option<DateTime<Utc>>,
note_extensions: Option<serde_json::Value>,
author_id: uuid::Uuid, author_id: uuid::Uuid,
username: String, username: String,
email: String, email: String,
@@ -82,6 +83,7 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
t.in_reply_to_id, t.in_reply_to_id,
t.visibility, t.content_warning, t.sensitive, t.local AS t_local, t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
t.created_at AS thought_created_at, t.updated_at, t.created_at AS thought_created_at, t.updated_at,
t.note_extensions,
u.id AS author_id, u.id AS author_id,
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != '' CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
THEN '@' || ra.handle || THEN '@' || ra.handle ||
@@ -118,6 +120,7 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
local: r.t_local, local: r.t_local,
created_at: r.thought_created_at, created_at: r.thought_created_at,
updated_at: r.updated_at, updated_at: r.updated_at,
note_extensions: r.note_extensions,
}; };
let author = User { let author = User {
id: UserId::from_uuid(r.author_id), id: UserId::from_uuid(r.author_id),

View File

@@ -1,16 +1,16 @@
use super::*; use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository}; use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{ use domain::{
models::{ models::{
feed::PageParams, feed::PageParams,
thought::{Thought, Visibility}, thought::{NewThought, Thought, Visibility},
user::User, user::User,
}, },
ports::{FeedQuery, ThoughtRepository, UserWriter}, ports::{FeedQuery, ThoughtRepository, UserWriter},
value_objects::*, value_objects::*,
}; };
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) { async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
let urepo = PgUserRepository::new(pool.clone()); let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone()); let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local( let u = User::new_local(
@@ -20,43 +20,49 @@
PasswordHash("h".into()), PasswordHash("h".into()),
); );
urepo.save(&u).await.unwrap(); urepo.save(&u).await.unwrap();
let t = Thought::new_local( let t = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
u.id.clone(), user_id: u.id.clone(),
Content::new_local(content).unwrap(), content: Content::new_local(content).unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(u, t) (u, t)
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) { async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello").await; let (_, _) = seed(&pool, "alice", "hello").await;
let repo = PgFeedRepository::new(pool); let repo = PgFeedRepository::new(pool);
let result = repo let result = repo
.query(&FeedQuery::public( .query(&FeedQuery::public(
PageParams { page: 1, per_page: 20 }, PageParams {
page: 1,
per_page: 20,
},
None, None,
)) ))
.await .await
.unwrap(); .unwrap();
assert_eq!(result.total, 1); assert_eq!(result.total, 1);
assert_eq!(result.items[0].thought.content.as_str(), "hello"); assert_eq!(result.items[0].thought.content.as_str(), "hello");
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn search_returns_matching_thoughts(pool: sqlx::PgPool) { async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello world").await; let (_, _) = seed(&pool, "alice", "hello world").await;
let (_, _) = seed(&pool, "bob", "goodbye world").await; let (_, _) = seed(&pool, "bob", "goodbye world").await;
let repo = PgFeedRepository::new(pool); let repo = PgFeedRepository::new(pool);
let result = repo let result = repo
.query(&FeedQuery::search( .query(&FeedQuery::search(
"hello world", "hello world",
PageParams { page: 1, per_page: 20 }, PageParams {
page: 1,
per_page: 20,
},
None, None,
)) ))
.await .await
@@ -66,4 +72,4 @@
.items .items
.iter() .iter()
.any(|e| e.thought.content.as_str() == "hello world")); .any(|e| e.thought.content.as_str() == "hello world"));
} }

View File

@@ -1,10 +1,10 @@
use super::*; use super::*;
use crate::test_helpers::seed_user; use crate::test_helpers::seed_user;
use chrono::Utc; use chrono::Utc;
use domain::value_objects::*; use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_find_follow(pool: sqlx::PgPool) { async fn save_and_find_follow(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool); let repo = PgFollowRepository::new(pool);
@@ -18,10 +18,10 @@
repo.save(&follow).await.unwrap(); repo.save(&follow).await.unwrap();
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap(); let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
assert_eq!(found.state, FollowState::Accepted); assert_eq!(found.state, FollowState::Accepted);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn update_state(pool: sqlx::PgPool) { async fn update_state(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool); let repo = PgFollowRepository::new(pool);
@@ -38,10 +38,10 @@
.unwrap(); .unwrap();
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap(); let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
assert_eq!(found.state, FollowState::Accepted); assert_eq!(found.state, FollowState::Accepted);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn get_accepted_following_ids(pool: sqlx::PgPool) { async fn get_accepted_following_ids(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool); let repo = PgFollowRepository::new(pool);
@@ -55,4 +55,4 @@
repo.save(&follow).await.unwrap(); repo.save(&follow).await.unwrap();
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap(); let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
assert_eq!(ids, vec![bob.id]); assert_eq!(ids, vec![bob.id]);
} }

View File

@@ -1,15 +1,15 @@
pub mod activitypub; pub mod activitypub;
pub mod engagement;
pub mod api_key; pub mod api_key;
pub mod block; pub mod block;
pub mod boost; pub mod boost;
mod db_error; mod db_error;
pub mod engagement;
pub mod failed_event; pub mod failed_event;
pub mod outbox;
pub mod feed; pub mod feed;
pub mod follow; pub mod follow;
pub mod like; pub mod like;
pub mod notification; pub mod notification;
pub mod outbox;
pub mod remote_actor; pub mod remote_actor;
pub mod remote_actor_connections; pub mod remote_actor_connections;
pub mod tag; pub mod tag;

View File

@@ -1,10 +1,10 @@
use super::*; use super::*;
use crate::test_helpers::seed_user_and_thought; use crate::test_helpers::seed_user_and_thought;
use chrono::Utc; use chrono::Utc;
use domain::value_objects::*; use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn like_and_count(pool: sqlx::PgPool) { async fn like_and_count(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await; let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgLikeRepository::new(pool); let repo = PgLikeRepository::new(pool);
let like = Like { let like = Like {
@@ -16,10 +16,10 @@
}; };
repo.save(&like).await.unwrap(); repo.save(&like).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn unlike(pool: sqlx::PgPool) { async fn unlike(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await; let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgLikeRepository::new(pool); let repo = PgLikeRepository::new(pool);
let like = Like { let like = Like {
@@ -32,4 +32,4 @@
repo.save(&like).await.unwrap(); repo.save(&like).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap(); repo.delete(&user.id, &thought.id).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
} }

View File

@@ -1,13 +1,13 @@
use super::*; use super::*;
use crate::test_helpers; use crate::test_helpers;
use chrono::Utc; use chrono::Utc;
use domain::{ use domain::{
models::{notification::NotificationKind, user::User}, models::{notification::NotificationKind, user::User},
value_objects::*, value_objects::*,
}; };
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_list(pool: sqlx::PgPool) { async fn save_and_list(pool: sqlx::PgPool) {
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await; let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await; let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgNotificationRepository::new(pool); let repo = PgNotificationRepository::new(pool);
@@ -34,10 +34,10 @@
.unwrap(); .unwrap();
assert_eq!(page.total, 1); assert_eq!(page.total, 1);
assert!(!page.items[0].read); assert!(!page.items[0].read);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn mark_all_read(pool: sqlx::PgPool) { async fn mark_all_read(pool: sqlx::PgPool) {
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await; let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await; let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgNotificationRepository::new(pool); let repo = PgNotificationRepository::new(pool);
@@ -64,4 +64,4 @@
.await .await
.unwrap(); .unwrap();
assert!(page.items[0].read); assert!(page.items[0].read);
} }

View File

@@ -32,6 +32,9 @@ fn aggregate_id(event: &DomainEvent) -> Uuid {
DomainEvent::UserUnblocked { blocker_id, .. } => blocker_id.as_uuid(), DomainEvent::UserUnblocked { blocker_id, .. } => blocker_id.as_uuid(),
DomainEvent::UserRegistered { user_id } => user_id.as_uuid(), DomainEvent::UserRegistered { user_id } => user_id.as_uuid(),
DomainEvent::ProfileUpdated { user_id } => user_id.as_uuid(), DomainEvent::ProfileUpdated { user_id } => user_id.as_uuid(),
DomainEvent::RemoteFollowAccepted { local_user_id, .. } => local_user_id.as_uuid(),
DomainEvent::RemoteFollowRejected { local_user_id, .. } => local_user_id.as_uuid(),
DomainEvent::ActorMoved { user_id, .. } => user_id.as_uuid(),
DomainEvent::MentionReceived { thought_id, .. } => thought_id.as_uuid(), DomainEvent::MentionReceived { thought_id, .. } => thought_id.as_uuid(),
} }
} }

View File

@@ -1,25 +1,25 @@
use super::*; use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository}; use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::ports::{ThoughtRepository, UserWriter}; use domain::ports::{ThoughtRepository, UserWriter};
use domain::{ use domain::{
models::{ models::{
thought::{Thought, Visibility}, thought::{NewThought, Thought, Visibility},
user::User, user::User,
}, },
value_objects::*, value_objects::*,
}; };
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn find_or_create_tag(pool: sqlx::PgPool) { async fn find_or_create_tag(pool: sqlx::PgPool) {
let repo = PgTagRepository::new(pool); let repo = PgTagRepository::new(pool);
let t1 = repo.find_or_create("rust").await.unwrap(); let t1 = repo.find_or_create("rust").await.unwrap();
let t2 = repo.find_or_create("rust").await.unwrap(); let t2 = repo.find_or_create("rust").await.unwrap();
assert_eq!(t1.id, t2.id); assert_eq!(t1.id, t2.id);
assert_eq!(t1.name, "rust"); assert_eq!(t1.name, "rust");
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn attach_and_list(pool: sqlx::PgPool) { async fn attach_and_list(pool: sqlx::PgPool) {
let urepo = PgUserRepository::new(pool.clone()); let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone()); let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local( let u = User::new_local(
@@ -29,15 +29,15 @@
PasswordHash("h".into()), PasswordHash("h".into()),
); );
urepo.save(&u).await.unwrap(); urepo.save(&u).await.unwrap();
let t = Thought::new_local( let t = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
u.id.clone(), user_id: u.id.clone(),
Content::new_local("hi").unwrap(), content: Content::new_local("hi").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
let repo = PgTagRepository::new(pool); let repo = PgTagRepository::new(pool);
let tag = repo.find_or_create("greetings").await.unwrap(); let tag = repo.find_or_create("greetings").await.unwrap();
@@ -45,4 +45,4 @@
let tags = repo.list_for_thought(&t.id).await.unwrap(); let tags = repo.list_for_thought(&t.id).await.unwrap();
assert_eq!(tags.len(), 1); assert_eq!(tags.len(), 1);
assert_eq!(tags[0].name, "greetings"); assert_eq!(tags[0].name, "greetings");
} }

View File

@@ -1,7 +1,7 @@
use crate::{thought::PgThoughtRepository, user::PgUserRepository}; use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{ use domain::{
models::{ models::{
thought::{Thought, Visibility}, thought::{NewThought, Thought, Visibility},
user::User, user::User,
}, },
ports::{ThoughtRepository, UserWriter}, ports::{ThoughtRepository, UserWriter},
@@ -23,15 +23,15 @@ pub async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User
pub async fn seed_user_and_thought(pool: &sqlx::PgPool) -> (User, Thought) { pub async fn seed_user_and_thought(pool: &sqlx::PgPool) -> (User, Thought) {
let user = seed_user(pool, "alice", "alice@ex.com").await; let user = seed_user(pool, "alice", "alice@ex.com").await;
let trepo = PgThoughtRepository::new(pool.clone()); let trepo = PgThoughtRepository::new(pool.clone());
let t = Thought::new_local( let t = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
user.id.clone(), user_id: user.id.clone(),
Content::new_local("hi").unwrap(), content: Content::new_local("hi").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(user, t) (user, t)
} }

View File

@@ -34,6 +34,7 @@ pub(crate) struct ThoughtRow {
pub local: bool, pub local: bool,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>, pub updated_at: Option<DateTime<Utc>>,
pub note_extensions: Option<serde_json::Value>,
} }
impl TryFrom<ThoughtRow> for Thought { impl TryFrom<ThoughtRow> for Thought {
@@ -50,12 +51,13 @@ impl TryFrom<ThoughtRow> for Thought {
local: r.local, local: r.local,
created_at: r.created_at, created_at: r.created_at,
updated_at: r.updated_at, updated_at: r.updated_at,
note_extensions: r.note_extensions,
}) })
} }
} }
const THOUGHT_SELECT: &str = const THOUGHT_SELECT: &str =
"SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts"; "SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions FROM thoughts";
#[async_trait] #[async_trait]
impl ThoughtRepository for PgThoughtRepository { impl ThoughtRepository for PgThoughtRepository {
@@ -117,11 +119,11 @@ impl ThoughtRepository for PgThoughtRepository {
sqlx::query_as::<_, ThoughtRow>( sqlx::query_as::<_, ThoughtRow>(
"WITH RECURSIVE thread AS ( "WITH RECURSIVE thread AS (
SELECT id,user_id,content,in_reply_to_id, SELECT id,user_id,content,in_reply_to_id,
visibility,content_warning,sensitive,local,created_at,updated_at visibility,content_warning,sensitive,local,created_at,updated_at,note_extensions
FROM thoughts WHERE id = $1 FROM thoughts WHERE id = $1
UNION ALL UNION ALL
SELECT t.id,t.user_id,t.content,t.in_reply_to_id, SELECT t.id,t.user_id,t.content,t.in_reply_to_id,
t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at,t.note_extensions
FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id
) )
SELECT * FROM thread ORDER BY created_at ASC", SELECT * FROM thread ORDER BY created_at ASC",

View File

@@ -1,90 +1,90 @@
use super::*; use super::*;
use crate::test_helpers::seed_user; use crate::test_helpers::seed_user;
use domain::{ use domain::{
models::thought::{Thought, Visibility}, models::thought::{NewThought, Thought, Visibility},
value_objects::*, value_objects::*,
}; };
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_find_thought(pool: sqlx::PgPool) { async fn save_and_find_thought(pool: sqlx::PgPool) {
let user = seed_user(&pool, "alice", "alice@ex.com").await; let user = seed_user(&pool, "alice", "alice@ex.com").await;
let repo = PgThoughtRepository::new(pool); let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local( let t = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
user.id.clone(), user_id: user.id.clone(),
Content::new_local("hello world").unwrap(), content: Content::new_local("hello world").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
repo.save(&t).await.unwrap(); repo.save(&t).await.unwrap();
let found = repo.find_by_id(&t.id).await.unwrap().unwrap(); let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
assert_eq!(found.content.as_str(), "hello world"); assert_eq!(found.content.as_str(), "hello world");
assert!(found.local); assert!(found.local);
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn delete_thought(pool: sqlx::PgPool) { async fn delete_thought(pool: sqlx::PgPool) {
let user = seed_user(&pool, "bob", "bob@ex.com").await; let user = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgThoughtRepository::new(pool); let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local( let t = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
user.id.clone(), user_id: user.id.clone(),
Content::new_local("bye").unwrap(), content: Content::new_local("bye").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
repo.save(&t).await.unwrap(); repo.save(&t).await.unwrap();
repo.delete(&t.id, &user.id).await.unwrap(); repo.delete(&t.id, &user.id).await.unwrap();
assert!(repo.find_by_id(&t.id).await.unwrap().is_none()); assert!(repo.find_by_id(&t.id).await.unwrap().is_none());
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) { async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgThoughtRepository::new(pool); let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local( let t = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("secret").unwrap(), content: Content::new_local("secret").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
repo.save(&t).await.unwrap(); repo.save(&t).await.unwrap();
let err = repo.delete(&t.id, &bob.id).await.unwrap_err(); let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
assert!(matches!(err, DomainError::NotFound)); assert!(matches!(err, DomainError::NotFound));
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) { async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
let user = seed_user(&pool, "charlie", "charlie@ex.com").await; let user = seed_user(&pool, "charlie", "charlie@ex.com").await;
let repo = PgThoughtRepository::new(pool); let repo = PgThoughtRepository::new(pool);
let root = Thought::new_local( let root = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
user.id.clone(), user_id: user.id.clone(),
Content::new_local("root").unwrap(), content: Content::new_local("root").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
let reply = Thought::new_local( let reply = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
user.id.clone(), user_id: user.id.clone(),
Content::new_local("reply").unwrap(), content: Content::new_local("reply").unwrap(),
Some(root.id.clone()), in_reply_to_id: Some(root.id.clone()),
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
repo.save(&root).await.unwrap(); repo.save(&root).await.unwrap();
repo.save(&reply).await.unwrap(); repo.save(&reply).await.unwrap();
let thread = repo.get_thread(&root.id).await.unwrap(); let thread = repo.get_thread(&root.id).await.unwrap();
assert_eq!(thread.len(), 2); assert_eq!(thread.len(), 2);
} }

View File

@@ -1,9 +1,9 @@
use super::*; use super::*;
use crate::user::PgUserRepository; use crate::user::PgUserRepository;
use domain::ports::UserWriter; use domain::ports::UserWriter;
use domain::{models::user::User, value_objects::*}; use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
let repo = PgUserRepository::new(pool.clone()); let repo = PgUserRepository::new(pool.clone());
let u = User::new_local( let u = User::new_local(
UserId::new(), UserId::new(),
@@ -13,10 +13,10 @@
); );
repo.save(&u).await.unwrap(); repo.save(&u).await.unwrap();
u u
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn set_and_list_top_friends(pool: sqlx::PgPool) { async fn set_and_list_top_friends(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgTopFriendRepository::new(pool); let repo = PgTopFriendRepository::new(pool);
@@ -27,10 +27,10 @@
assert_eq!(friends.len(), 1); assert_eq!(friends.len(), 1);
assert_eq!(friends[0].0.position, 1); assert_eq!(friends[0].0.position, 1);
assert_eq!(friends[0].1.username.as_str(), "bob"); assert_eq!(friends[0].1.username.as_str(), "bob");
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn replace_top_friends(pool: sqlx::PgPool) { async fn replace_top_friends(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let carol = seed_user(&pool, "carol", "carol@ex.com").await; let carol = seed_user(&pool, "carol", "carol@ex.com").await;
@@ -44,4 +44,4 @@
let friends = repo.list_for_user(&alice.id).await.unwrap(); let friends = repo.list_for_user(&alice.id).await.unwrap();
assert_eq!(friends.len(), 1); assert_eq!(friends.len(), 1);
assert_eq!(friends[0].1.username.as_str(), "carol"); assert_eq!(friends[0].1.username.as_str(), "carol");
} }

View File

@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::feed::{PageParams, Paginated, UserSummary}, models::feed::{PageParams, Paginated, UserSummary},
models::user::User, models::user::{UpdateProfileInput, User},
ports::{UserReader, UserWriter}, ports::{UserReader, UserWriter},
value_objects::{Email, PasswordHash, UserId, Username}, value_objects::{Email, PasswordHash, UserId, Username},
}; };
@@ -139,7 +139,10 @@ impl UserReader for PgUserRepository {
.into_domain() .into_domain()
} }
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> { async fn list_paginated(
&self,
page: PageParams,
) -> Result<Paginated<UserSummary>, DomainError> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { struct Row {
id: uuid::Uuid, id: uuid::Uuid,
@@ -187,7 +190,12 @@ impl UserReader for PgUserRepository {
following_count: r.following_count, following_count: r.following_count,
}) })
.collect(); .collect();
Ok(Paginated { items, total, page: page.page, per_page: page.per_page }) Ok(Paginated {
items,
total,
page: page.page,
per_page: page.per_page,
})
} }
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> { async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> {
@@ -195,18 +203,19 @@ impl UserReader for PgUserRepository {
return Ok(HashMap::new()); return Ok(HashMap::new());
} }
let uuids: Vec<uuid::Uuid> = ids.iter().map(|id| id.as_uuid()).collect(); let uuids: Vec<uuid::Uuid> = ids.iter().map(|id| id.as_uuid()).collect();
let rows = sqlx::query_as::<_, UserRow>( let rows = sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id = ANY($1)"))
&format!("{USER_SELECT} WHERE id = ANY($1)")
)
.bind(&uuids[..]) .bind(&uuids[..])
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.into_domain()?; .into_domain()?;
Ok(rows.into_iter().map(|r| { Ok(rows
.into_iter()
.map(|r| {
let user = User::from(r); let user = User::from(r);
(user.id.clone(), user) (user.id.clone(), user)
}).collect()) })
.collect())
} }
} }
@@ -256,21 +265,38 @@ impl UserWriter for PgUserRepository {
async fn update_profile( async fn update_profile(
&self, &self,
user_id: &UserId, user_id: &UserId,
display_name: Option<String>, input: UpdateProfileInput,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
sqlx::query( sqlx::query(
"UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1" "UPDATE users SET \
display_name = COALESCE($2, display_name), \
bio = COALESCE($3, bio), \
avatar_url = COALESCE($4, avatar_url), \
header_url = COALESCE($5, header_url), \
custom_css = COALESCE($6, custom_css), \
updated_at = NOW() \
WHERE id = $1",
) )
.bind(user_id.as_uuid()) .bind(user_id.as_uuid())
.bind(display_name) .bind(input.display_name)
.bind(bio) .bind(input.bio)
.bind(avatar_url) .bind(input.avatar_url)
.bind(header_url) .bind(input.header_url)
.bind(custom_css) .bind(input.custom_css)
.execute(&self.pool)
.await
.into_domain()
.map(|_| ())
}
async fn set_also_known_as(
&self,
user_id: &UserId,
value: Option<String>,
) -> Result<(), DomainError> {
sqlx::query("UPDATE users SET also_known_as = $2, updated_at = NOW() WHERE id = $1")
.bind(user_id.as_uuid())
.bind(value)
.execute(&self.pool) .execute(&self.pool)
.await .await
.into_domain() .into_domain()

View File

@@ -1,8 +1,11 @@
use super::*; use super::*;
use domain::{models::user::User, value_objects::*}; use domain::{
models::user::{UpdateProfileInput, User},
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_find_by_id(pool: sqlx::PgPool) { async fn save_and_find_by_id(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool); let repo = PgUserRepository::new(pool);
let user = User::new_local( let user = User::new_local(
UserId::new(), UserId::new(),
@@ -14,20 +17,20 @@
let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.username.as_str(), "alice"); assert_eq!(found.username.as_str(), "alice");
assert_eq!(found.email.as_str(), "alice@ex.com"); assert_eq!(found.email.as_str(), "alice@ex.com");
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) { async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool); let repo = PgUserRepository::new(pool);
let result = repo let result = repo
.find_by_username(&Username::new("ghost").unwrap()) .find_by_username(&Username::new("ghost").unwrap())
.await .await
.unwrap(); .unwrap();
assert!(result.is_none()); assert!(result.is_none());
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn find_by_email(pool: sqlx::PgPool) { async fn find_by_email(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool); let repo = PgUserRepository::new(pool);
let user = User::new_local( let user = User::new_local(
UserId::new(), UserId::new(),
@@ -41,10 +44,10 @@
.await .await
.unwrap(); .unwrap();
assert!(found.is_some()); assert!(found.is_some());
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn update_profile_changes_fields(pool: sqlx::PgPool) { async fn update_profile_changes_fields(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool); let repo = PgUserRepository::new(pool);
let user = User::new_local( let user = User::new_local(
UserId::new(), UserId::new(),
@@ -55,15 +58,15 @@
repo.save(&user).await.unwrap(); repo.save(&user).await.unwrap();
repo.update_profile( repo.update_profile(
&user.id, &user.id,
Some("Charlie".into()), UpdateProfileInput {
Some("bio".into()), display_name: Some("Charlie".into()),
None, bio: Some("bio".into()),
None, ..Default::default()
None, },
) )
.await .await
.unwrap(); .unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.display_name.as_deref(), Some("Charlie")); assert_eq!(found.display_name.as_deref(), Some("Charlie"));
assert_eq!(found.bio.as_deref(), Some("bio")); assert_eq!(found.bio.as_deref(), Some("bio"));
} }

View File

@@ -0,0 +1,18 @@
[package]
name = "storage"
version = "0.1.0"
edition = "2021"
[features]
s3 = ["object_store/aws"]
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
anyhow = { workspace = true }
object_store = { version = "0.11" }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }

View File

@@ -0,0 +1,237 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::{DataStream, MediaStore},
};
use futures::stream::StreamExt;
use object_store::{path::Path, Error as OsError, ObjectStore};
use std::sync::Arc;
pub struct ObjectStorageAdapter {
store: Arc<dyn ObjectStore>,
prefix: String,
}
fn validate_key(key: &str) -> Result<(), DomainError> {
if key.is_empty() {
return Err(DomainError::InvalidInput(
"storage key must not be empty".into(),
));
}
if key.starts_with('/') {
return Err(DomainError::InvalidInput(format!(
"storage key must not start with '/': {key}"
)));
}
if key.split('/').any(|seg| seg == ".." || seg == ".") {
return Err(DomainError::InvalidInput(format!(
"storage key contains invalid path segment: {key}"
)));
}
Ok(())
}
fn map_os_err(e: OsError) -> DomainError {
match e {
OsError::NotFound { .. } => DomainError::NotFound,
e => DomainError::Internal(e.to_string()),
}
}
impl ObjectStorageAdapter {
pub fn new(
store: Arc<dyn ObjectStore>,
prefix: impl Into<String>,
) -> Result<Self, DomainError> {
let prefix = prefix.into();
if !prefix.is_empty() {
validate_key(&prefix)?;
}
Ok(Self { store, prefix })
}
fn path(&self, key: &str) -> Path {
if self.prefix.is_empty() {
Path::from(key)
} else {
Path::from(format!("{}/{key}", self.prefix))
}
}
}
#[async_trait]
impl MediaStore for ObjectStorageAdapter {
async fn put(&self, key: &str, data: DataStream) -> Result<(), DomainError> {
validate_key(key)?;
let path = self.path(key);
let mut upload = self
.store
.put_multipart(&path)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
let mut stream = data;
while let Some(result) = stream.next().await {
match result {
Ok(bytes) => {
if let Err(e) = upload.put_part(bytes.into()).await {
let _ = upload.abort().await;
return Err(DomainError::Internal(e.to_string()));
}
}
Err(e) => {
let _ = upload.abort().await;
return Err(e);
}
}
}
upload
.complete()
.await
.map(|_| ())
.map_err(|e| DomainError::Internal(e.to_string()))
}
async fn get(&self, key: &str) -> Result<DataStream, DomainError> {
validate_key(key)?;
let path = self.path(key);
let result = self.store.get(&path).await.map_err(map_os_err)?;
let s = result
.into_stream()
.map(|r| r.map_err(|e| DomainError::Internal(e.to_string())));
Ok(Box::pin(s))
}
async fn delete(&self, key: &str) -> Result<(), DomainError> {
validate_key(key)?;
let path = self.path(key);
match self.store.delete(&path).await {
Ok(()) => Ok(()),
Err(OsError::NotFound { .. }) => Ok(()),
Err(e) => Err(DomainError::Internal(e.to_string())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use futures::stream;
use object_store::memory::InMemory;
fn make_adapter() -> ObjectStorageAdapter {
ObjectStorageAdapter::new(Arc::new(InMemory::new()), "test").unwrap()
}
fn one_shot(data: &'static [u8]) -> DataStream {
Box::pin(stream::once(async move { Ok(Bytes::from(data)) }))
}
#[tokio::test]
async fn put_get_roundtrip() {
let a = make_adapter();
a.put("hello.txt", one_shot(b"world")).await.unwrap();
let mut s = a.get("hello.txt").await.unwrap();
let mut out = Vec::new();
while let Some(chunk) = s.next().await {
out.extend_from_slice(&chunk.unwrap());
}
assert_eq!(out, b"world");
}
#[tokio::test]
async fn get_missing_is_not_found() {
let a = make_adapter();
assert!(matches!(
a.get("nope.txt").await,
Err(DomainError::NotFound)
));
}
#[tokio::test]
async fn delete_is_idempotent() {
let a = make_adapter();
a.delete("nope.txt").await.unwrap();
}
#[tokio::test]
async fn delete_removes_key() {
let a = make_adapter();
a.put("file.txt", one_shot(b"data")).await.unwrap();
a.delete("file.txt").await.unwrap();
assert!(matches!(
a.get("file.txt").await,
Err(DomainError::NotFound)
));
}
#[tokio::test]
async fn put_overwrites_existing() {
let a = make_adapter();
a.put("file.txt", one_shot(b"v1")).await.unwrap();
a.put("file.txt", one_shot(b"v2")).await.unwrap();
let mut s = a.get("file.txt").await.unwrap();
let mut out = Vec::new();
while let Some(chunk) = s.next().await {
out.extend_from_slice(&chunk.unwrap());
}
assert_eq!(out, b"v2");
}
#[tokio::test]
async fn rejects_empty_key() {
let a = make_adapter();
assert!(matches!(
a.put("", one_shot(b"x")).await,
Err(DomainError::InvalidInput(_))
));
assert!(matches!(a.get("").await, Err(DomainError::InvalidInput(_))));
assert!(matches!(
a.delete("").await,
Err(DomainError::InvalidInput(_))
));
}
#[tokio::test]
async fn rejects_absolute_key() {
let a = make_adapter();
assert!(matches!(
a.put("/etc/passwd", one_shot(b"x")).await,
Err(DomainError::InvalidInput(_))
));
}
#[tokio::test]
async fn rejects_path_traversal() {
let a = make_adapter();
assert!(matches!(
a.get("../escape").await,
Err(DomainError::InvalidInput(_))
));
assert!(matches!(
a.get("a/../../../etc").await,
Err(DomainError::InvalidInput(_))
));
}
#[test]
fn new_rejects_traversal_prefix() {
assert!(matches!(
ObjectStorageAdapter::new(Arc::new(InMemory::new()), "../evil"),
Err(DomainError::InvalidInput(_))
));
}
#[test]
fn new_rejects_absolute_prefix() {
assert!(matches!(
ObjectStorageAdapter::new(Arc::new(InMemory::new()), "/root"),
Err(DomainError::InvalidInput(_))
));
}
#[test]
fn new_accepts_empty_prefix() {
assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "").is_ok());
}
}

View File

@@ -0,0 +1,67 @@
use anyhow::{Context, Result};
use object_store::local::LocalFileSystem;
use object_store::ObjectStore;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct StorageConfig {
pub backend: String,
pub local_path: Option<String>,
pub s3_endpoint: Option<String>,
pub s3_access_key_id: Option<String>,
pub s3_secret_access_key: Option<String>,
pub s3_bucket: Option<String>,
pub s3_region: Option<String>,
}
pub fn build_store(config: &StorageConfig) -> Result<Arc<dyn ObjectStore>> {
match config.backend.as_str() {
"local" => {
let path = config
.local_path
.as_deref()
.context("STORAGE_PATH must be set when STORAGE_BACKEND=local")?;
std::fs::create_dir_all(path)
.with_context(|| format!("failed to create storage dir: {path}"))?;
let store = LocalFileSystem::new_with_prefix(path)?;
Ok(Arc::new(store))
}
#[cfg(feature = "s3")]
"s3" => {
use object_store::aws::AmazonS3Builder;
let store = AmazonS3Builder::new()
.with_endpoint(
config
.s3_endpoint
.as_deref()
.context("S3_ENDPOINT must be set")?,
)
.with_access_key_id(
config
.s3_access_key_id
.as_deref()
.context("S3_ACCESS_KEY_ID must be set")?,
)
.with_secret_access_key(
config
.s3_secret_access_key
.as_deref()
.context("S3_SECRET_ACCESS_KEY must be set")?,
)
.with_bucket_name(
config
.s3_bucket
.as_deref()
.context("S3_BUCKET must be set")?,
)
.with_region(config.s3_region.as_deref().unwrap_or("us-east-1"))
.with_allow_http(true)
.build()?;
Ok(Arc::new(store))
}
other => anyhow::bail!(
"unknown STORAGE_BACKEND={other:?}; supported: local{}",
if cfg!(feature = "s3") { ", s3" } else { "" },
),
}
}

View File

@@ -0,0 +1,5 @@
pub mod adapter;
pub mod config;
pub use adapter::ObjectStorageAdapter;
pub use config::{build_store, StorageConfig};

View File

@@ -5,6 +5,7 @@ edition = "2021"
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
utoipa = { version = "5.5.0", features = ["uuid", "chrono"] } utoipa = { version = "5.5.0", features = ["uuid", "chrono"] }

View File

@@ -45,6 +45,8 @@ pub struct ThoughtResponse {
pub boosted_by_viewer: bool, pub boosted_by_viewer: bool,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>, pub updated_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note_extensions: Option<serde_json::Value>,
} }
#[derive(Serialize, utoipa::ToSchema)] #[derive(Serialize, utoipa::ToSchema)]

View File

@@ -5,7 +5,7 @@ edition = "2021"
[dependencies] [dependencies]
domain = { workspace = true } domain = { workspace = true }
activitypub-base = { workspace = true } activitypub = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
@@ -15,6 +15,8 @@ hex = "0.4"
tracing = { workspace = true } tracing = { workspace = true }
url = { workspace = true } url = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
bytes = { workspace = true }
futures = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }

View File

@@ -1,4 +1,4 @@
use activitypub_base::{ActivityPubRepository, OutboundFederationPort}; use activitypub::{ActivityPubRepository, OutboundFederationPort};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
@@ -41,7 +41,10 @@ impl FederationEventService {
{ {
t t
} }
_ => return Ok(()), _ => {
tracing::debug!(thought_id = %thought_id, "federation: skipping ThoughtCreated (remote or non-public)");
return Ok(());
}
}; };
let user = match self.users.find_by_id(user_id).await? { let user = match self.users.find_by_id(user_id).await? {
Some(u) => u, Some(u) => u,
@@ -58,6 +61,7 @@ impl FederationEventService {
} else { } else {
None None
}; };
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Create(Note)");
self.ap self.ap
.broadcast_create( .broadcast_create(
user_id, user_id,
@@ -72,8 +76,7 @@ impl FederationEventService {
thought_id, thought_id,
user_id, user_id,
} => { } => {
// No DB lookup — thought is already deleted when this event fires. tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Delete");
// No locality guard: delete commands only reach local thoughts via the use case.
let ap_id = format!("{}/thoughts/{}", self.base_url, thought_id); let ap_id = format!("{}/thoughts/{}", self.base_url, thought_id);
self.ap.broadcast_delete(user_id, &ap_id).await self.ap.broadcast_delete(user_id, &ap_id).await
} }
@@ -106,6 +109,7 @@ impl FederationEventService {
} else { } else {
None None
}; };
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Update(Note)");
self.ap self.ap
.broadcast_update( .broadcast_update(
user_id, user_id,
@@ -121,16 +125,19 @@ impl FederationEventService {
user_id, user_id,
thought_id, thought_id,
} => { } => {
// Only fan-out if the booster is a local user. Remote boosts must not be re-broadcast.
let booster = match self.users.find_by_id(user_id).await? { let booster = match self.users.find_by_id(user_id).await? {
Some(u) if u.local => u, Some(u) if u.local => u,
_ => return Ok(()), _ => {
tracing::debug!(user_id = %user_id, "federation: skipping BoostAdded (remote user)");
return Ok(());
}
}; };
let _ = booster; let _ = booster;
if self.thoughts.find_by_id(thought_id).await?.is_none() { if self.thoughts.find_by_id(thought_id).await?.is_none() {
return Ok(()); return Ok(());
} }
let object_ap_id = self.object_ap_id(thought_id).await?; let object_ap_id = self.object_ap_id(thought_id).await?;
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Announce");
self.ap.broadcast_announce(user_id, &object_ap_id).await self.ap.broadcast_announce(user_id, &object_ap_id).await
} }
@@ -142,6 +149,7 @@ impl FederationEventService {
return Ok(()); return Ok(());
} }
let object_ap_id = self.object_ap_id(thought_id).await?; let object_ap_id = self.object_ap_id(thought_id).await?;
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Undo(Announce)");
self.ap self.ap
.broadcast_undo_announce(user_id, &object_ap_id) .broadcast_undo_announce(user_id, &object_ap_id)
.await .await
@@ -152,10 +160,12 @@ impl FederationEventService {
user_id, user_id,
thought_id, thought_id,
} => { } => {
// Only federate: local liker + remote thought (has ap_id) + author has inbox.
let liker = match self.users.find_by_id(user_id).await? { let liker = match self.users.find_by_id(user_id).await? {
Some(u) if u.local => u, Some(u) if u.local => u,
_ => return Ok(()), _ => {
tracing::debug!(user_id = %user_id, "federation: skipping LikeAdded (remote user)");
return Ok(());
}
}; };
let _ = liker; let _ = liker;
let thought = match self.thoughts.find_by_id(thought_id).await? { let thought = match self.thoughts.find_by_id(thought_id).await? {
@@ -164,12 +174,16 @@ impl FederationEventService {
}; };
let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? { let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? {
Some(id) => id, Some(id) => id,
None => return Ok(()), // local thought — no federation needed None => {
tracing::debug!(thought_id = %thought_id, "federation: skipping LikeAdded (local thought)");
return Ok(());
}
}; };
let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? { let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? {
Some(u) => u, Some(u) => u,
None => return Ok(()), None => return Ok(()),
}; };
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Like");
self.ap self.ap
.broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url) .broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
.await .await
@@ -196,12 +210,14 @@ impl FederationEventService {
Some(u) => u, Some(u) => u,
None => return Ok(()), None => return Ok(()),
}; };
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Undo(Like)");
self.ap self.ap
.broadcast_undo_like(user_id, &thought_ap_id, &actor_urls.inbox_url) .broadcast_undo_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
.await .await
} }
DomainEvent::ProfileUpdated { user_id } => { DomainEvent::ProfileUpdated { user_id } => {
tracing::info!(user_id = %user_id, "federation: broadcasting actor update");
self.ap.broadcast_actor_update(user_id).await self.ap.broadcast_actor_update(user_id).await
} }

View File

@@ -1,11 +1,11 @@
use super::*; use super::*;
use activitypub_base::{ActorApUrls, OutboundFederationPort};
use async_trait::async_trait;
use crate::testing::TestApRepo; use crate::testing::TestApRepo;
use activitypub::{ActorApUrls, OutboundFederationPort};
use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::thought::{Thought, Visibility}, models::thought::{NewThought, Thought, Visibility},
models::user::User, models::user::User,
testing::TestStore, testing::TestStore,
value_objects::*, value_objects::*,
@@ -56,21 +56,12 @@ impl OutboundFederationPort for SpyPort {
self.announced.lock().unwrap().push(ap_id.to_string()); self.announced.lock().unwrap().push(ap_id.to_string());
Ok(()) Ok(())
} }
async fn broadcast_undo_announce( async fn broadcast_undo_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
&self,
_: &UserId,
ap_id: &str,
) -> Result<(), DomainError> {
self.undo_announced.lock().unwrap().push(ap_id.to_string()); self.undo_announced.lock().unwrap().push(ap_id.to_string());
Ok(()) Ok(())
} }
async fn broadcast_like( async fn broadcast_like(&self, _: &UserId, ap_id: &str, _: &str) -> Result<(), DomainError> {
&self,
_: &UserId,
ap_id: &str,
_: &str,
) -> Result<(), DomainError> {
self.liked.lock().unwrap().push(ap_id.to_string()); self.liked.lock().unwrap().push(ap_id.to_string());
Ok(()) Ok(())
} }
@@ -101,15 +92,15 @@ fn alice() -> User {
} }
fn local_thought(author_id: UserId) -> Thought { fn local_thought(author_id: UserId) -> Thought {
Thought::new_local( Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
author_id, user_id: author_id,
Content::new_local("hello").unwrap(), content: Content::new_local("hello").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
) })
} }
fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService { fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
@@ -123,7 +114,11 @@ fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
} }
} }
fn svc_with_ap(store: &TestStore, ap_repo: TestApRepo, spy: Arc<SpyPort>) -> FederationEventService { fn svc_with_ap(
store: &TestStore,
ap_repo: TestApRepo,
spy: Arc<SpyPort>,
) -> FederationEventService {
FederationEventService { FederationEventService {
thoughts: Arc::new(store.clone()), thoughts: Arc::new(store.clone()),
users: Arc::new(store.clone()), users: Arc::new(store.clone()),
@@ -280,15 +275,15 @@ async fn boost_of_remote_thought_announces_remote_ap_id() {
async fn direct_thought_created_does_not_broadcast() { async fn direct_thought_created_does_not_broadcast() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
let thought = Thought::new_local( let thought = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("private").unwrap(), content: Content::new_local("private").unwrap(),
None, in_reply_to_id: None,
Visibility::Direct, visibility: Visibility::Direct,
None, content_warning: None,
false, sensitive: false,
); });
store.users.lock().unwrap().push(alice.clone()); store.users.lock().unwrap().push(alice.clone());
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
@@ -309,15 +304,15 @@ async fn direct_thought_created_does_not_broadcast() {
async fn followers_only_thought_does_not_broadcast_publicly() { async fn followers_only_thought_does_not_broadcast_publicly() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
let thought = Thought::new_local( let thought = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("for followers").unwrap(), content: Content::new_local("for followers").unwrap(),
None, in_reply_to_id: None,
Visibility::Followers, visibility: Visibility::Followers,
None, content_warning: None,
false, sensitive: false,
); });
store.users.lock().unwrap().push(alice.clone()); store.users.lock().unwrap().push(alice.clone());
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());

View File

@@ -0,0 +1,56 @@
use domain::{errors::DomainError, events::DomainEvent, ports::FederationActionPort};
use std::sync::Arc;
pub struct FederationManagementEventService {
pub federation: Arc<dyn FederationActionPort>,
}
impl FederationManagementEventService {
pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> {
match event {
DomainEvent::RemoteFollowAccepted {
local_user_id,
remote_actor_url,
} => {
tracing::info!(
local_user_id = %local_user_id,
actor = %remote_actor_url,
"federation-mgmt: accepting follow — sending Accept + backfill"
);
self.federation
.accept_follow_request(local_user_id, remote_actor_url)
.await
}
DomainEvent::RemoteFollowRejected {
local_user_id,
remote_actor_url,
} => {
tracing::info!(
local_user_id = %local_user_id,
actor = %remote_actor_url,
"federation-mgmt: rejecting follow — sending Reject"
);
self.federation
.reject_follow_request(local_user_id, remote_actor_url)
.await
}
DomainEvent::ActorMoved {
user_id,
new_actor_url,
} => {
tracing::info!(
user_id = %user_id,
target = %new_actor_url,
"federation-mgmt: broadcasting Move"
);
let url = url::Url::parse(new_actor_url)
.map_err(|e| DomainError::Internal(e.to_string()))?;
self.federation
.broadcast_move(user_id, url)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
_ => Ok(()),
}
}
}

View File

@@ -1,5 +1,7 @@
pub mod federation_event; pub mod federation_event;
pub mod federation_management_event;
pub mod notification_event; pub mod notification_event;
pub use federation_event::FederationEventService; pub use federation_event::FederationEventService;
pub use federation_management_event::FederationManagementEventService;
pub use notification_event::NotificationEventService; pub use notification_event::NotificationEventService;

View File

@@ -35,6 +35,7 @@ impl NotificationEventService {
if is_self_action(&thought.user_id, user_id) { if is_self_action(&thought.user_id, user_id) {
return Ok(()); return Ok(());
} }
tracing::info!(from = %user_id, to = %thought.user_id, thought_id = %thought_id, "notification: Like");
self.notifications self.notifications
.save(&Notification { .save(&Notification {
id: NotificationId::new(), id: NotificationId::new(),
@@ -60,6 +61,7 @@ impl NotificationEventService {
if is_self_action(&thought.user_id, user_id) { if is_self_action(&thought.user_id, user_id) {
return Ok(()); return Ok(());
} }
tracing::info!(from = %user_id, to = %thought.user_id, thought_id = %thought_id, "notification: Boost");
self.notifications self.notifications
.save(&Notification { .save(&Notification {
id: NotificationId::new(), id: NotificationId::new(),
@@ -77,6 +79,7 @@ impl NotificationEventService {
follower_id, follower_id,
following_id, following_id,
} => { } => {
tracing::info!(from = %follower_id, to = %following_id, "notification: Follow");
self.notifications self.notifications
.save(&Notification { .save(&Notification {
id: NotificationId::new(), id: NotificationId::new(),
@@ -105,6 +108,7 @@ impl NotificationEventService {
if is_self_action(&original.user_id, user_id) { if is_self_action(&original.user_id, user_id) {
return Ok(()); return Ok(());
} }
tracing::info!(from = %user_id, to = %original.user_id, thought_id = %thought_id, "notification: Reply");
self.notifications self.notifications
.save(&Notification { .save(&Notification {
id: NotificationId::new(), id: NotificationId::new(),
@@ -123,6 +127,7 @@ impl NotificationEventService {
mentioned_user_id, mentioned_user_id,
author_user_id, author_user_id,
} => { } => {
tracing::info!(from = %author_user_id, to = %mentioned_user_id, thought_id = %thought_id, "notification: Mention");
self.notifications self.notifications
.save(&Notification { .save(&Notification {
id: NotificationId::new(), id: NotificationId::new(),

View File

@@ -2,7 +2,7 @@ use super::*;
use domain::{ use domain::{
models::{ models::{
notification::NotificationKind, notification::NotificationKind,
thought::{Thought, Visibility}, thought::{NewThought, Thought, Visibility},
user::User, user::User,
}, },
testing::TestStore, testing::TestStore,
@@ -24,15 +24,15 @@ async fn like_creates_notification_for_thought_author() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
let bob_id = UserId::new(); let bob_id = UserId::new();
let thought = Thought::new_local( let thought = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("hello").unwrap(), content: Content::new_local("hello").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
thoughts: Arc::new(store.clone()), thoughts: Arc::new(store.clone()),
@@ -54,15 +54,15 @@ async fn like_creates_notification_for_thought_author() {
async fn self_like_creates_no_notification() { async fn self_like_creates_no_notification() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
let thought = Thought::new_local( let thought = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("hello").unwrap(), content: Content::new_local("hello").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
thoughts: Arc::new(store.clone()), thoughts: Arc::new(store.clone()),
@@ -103,15 +103,15 @@ async fn reply_creates_notification_for_original_author() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
let bob_id = UserId::new(); let bob_id = UserId::new();
let original = Thought::new_local( let original = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("original").unwrap(), content: Content::new_local("original").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
store.thoughts.lock().unwrap().push(original.clone()); store.thoughts.lock().unwrap().push(original.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
thoughts: Arc::new(store.clone()), thoughts: Arc::new(store.clone()),
@@ -133,15 +133,15 @@ async fn reply_creates_notification_for_original_author() {
async fn self_reply_creates_no_notification() { async fn self_reply_creates_no_notification() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
let original = Thought::new_local( let original = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("original").unwrap(), content: Content::new_local("original").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
store.thoughts.lock().unwrap().push(original.clone()); store.thoughts.lock().unwrap().push(original.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
thoughts: Arc::new(store.clone()), thoughts: Arc::new(store.clone()),
@@ -161,15 +161,15 @@ async fn self_reply_creates_no_notification() {
async fn self_boost_creates_no_notification() { async fn self_boost_creates_no_notification() {
let store = TestStore::default(); let store = TestStore::default();
let alice = alice(); let alice = alice();
let thought = Thought::new_local( let thought = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
alice.id.clone(), user_id: alice.id.clone(),
Content::new_local("hello").unwrap(), content: Content::new_local("hello").unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
); });
store.thoughts.lock().unwrap().push(thought.clone()); store.thoughts.lock().unwrap().push(thought.clone());
let svc = NotificationEventService { let svc = NotificationEventService {
thoughts: Arc::new(store.clone()), thoughts: Arc::new(store.clone()),

View File

@@ -1,5 +1,5 @@
/// Test helpers for application-layer tests that need activitypub_base traits. /// Test helpers for application-layer tests that need activitypub traits.
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry}; use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry};
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
@@ -95,22 +95,11 @@ impl ActivityPubRepository for TestApRepo {
} }
async fn accept_note( async fn accept_note(
&self, &self,
_ap_id: &str, _input: activitypub::AcceptNoteInput<'_>,
_author_id: &UserId,
_content: &str,
_published: chrono::DateTime<chrono::Utc>,
_sensitive: bool,
_content_warning: Option<String>,
_visibility: &str,
_in_reply_to: Option<&str>,
) -> Result<ThoughtId, DomainError> { ) -> Result<ThoughtId, DomainError> {
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4())) Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
} }
async fn apply_note_update( async fn apply_note_update(&self, _ap_id: &str, _new_content: &str) -> Result<(), DomainError> {
&self,
_ap_id: &str,
_new_content: &str,
) -> Result<(), DomainError> {
Ok(()) Ok(())
} }
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> { async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {

View File

@@ -34,19 +34,14 @@ pub async fn register(
} }
let hash = hasher.hash(&input.password).await?; let hash = hasher.hash(&input.password).await?;
let user = User::new_local(UserId::new(), username, email, hash); let user = User::new_local(UserId::new(), username, email, hash);
users users.save(&user).await.map_err(|e| match e {
.save(&user)
.await
.map_err(|e| match e {
DomainError::UniqueViolation { field: "username" } => { DomainError::UniqueViolation { field: "username" } => {
DomainError::Conflict("username taken".into()) DomainError::Conflict("username taken".into())
} }
DomainError::UniqueViolation { field: "email" } => { DomainError::UniqueViolation { field: "email" } => {
DomainError::Conflict("email taken".into()) DomainError::Conflict("email taken".into())
} }
DomainError::UniqueViolation { .. } => { DomainError::UniqueViolation { .. } => DomainError::Conflict("already exists".into()),
DomainError::Conflict("already exists".into())
}
other => other, other => other,
})?; })?;
events events

View File

@@ -3,7 +3,10 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{feed::{PageParams, Paginated, UserSummary}, user::User}, models::{
feed::{PageParams, Paginated, UserSummary},
user::{UpdateProfileInput, User},
},
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter}, ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
testing::{NoOpEventPublisher, TestStore}, testing::{NoOpEventPublisher, TestStore},
value_objects::{Email, PasswordHash, UserId, Username}, value_objects::{Email, PasswordHash, UserId, Username},
@@ -19,10 +22,7 @@ impl UserReader for ConflictOnSaveStore {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
self.0.find_by_id(id).await self.0.find_by_id(id).await
} }
async fn find_by_username( async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
&self,
username: &Username,
) -> Result<Option<User>, DomainError> {
self.0.find_by_username(username).await self.0.find_by_username(username).await
} }
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> { async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
@@ -34,10 +34,16 @@ impl UserReader for ConflictOnSaveStore {
async fn count(&self) -> Result<i64, DomainError> { async fn count(&self) -> Result<i64, DomainError> {
self.0.count().await self.0.count().await
} }
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> { async fn list_paginated(
&self,
page: PageParams,
) -> Result<Paginated<UserSummary>, DomainError> {
self.0.list_paginated(page).await self.0.list_paginated(page).await
} }
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> { async fn find_by_ids(
&self,
ids: &[UserId],
) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
self.0.find_by_ids(ids).await self.0.find_by_ids(ids).await
} }
} }
@@ -50,15 +56,16 @@ impl UserWriter for ConflictOnSaveStore {
async fn update_profile( async fn update_profile(
&self, &self,
user_id: &UserId, user_id: &UserId,
display_name: Option<String>, input: UpdateProfileInput,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
self.0 self.0.update_profile(user_id, input).await
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css) }
.await async fn set_also_known_as(
&self,
user_id: &UserId,
value: Option<String>,
) -> Result<(), DomainError> {
self.0.set_also_known_as(user_id, value).await
} }
} }
@@ -67,10 +74,7 @@ impl UserReader for EmailConflictOnSaveStore {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
self.0.find_by_id(id).await self.0.find_by_id(id).await
} }
async fn find_by_username( async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
&self,
username: &Username,
) -> Result<Option<User>, DomainError> {
self.0.find_by_username(username).await self.0.find_by_username(username).await
} }
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> { async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
@@ -82,10 +86,16 @@ impl UserReader for EmailConflictOnSaveStore {
async fn count(&self) -> Result<i64, DomainError> { async fn count(&self) -> Result<i64, DomainError> {
self.0.count().await self.0.count().await
} }
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> { async fn list_paginated(
&self,
page: PageParams,
) -> Result<Paginated<UserSummary>, DomainError> {
self.0.list_paginated(page).await self.0.list_paginated(page).await
} }
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> { async fn find_by_ids(
&self,
ids: &[UserId],
) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
self.0.find_by_ids(ids).await self.0.find_by_ids(ids).await
} }
} }
@@ -98,15 +108,16 @@ impl UserWriter for EmailConflictOnSaveStore {
async fn update_profile( async fn update_profile(
&self, &self,
user_id: &UserId, user_id: &UserId,
display_name: Option<String>, input: UpdateProfileInput,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
self.0 self.0.update_profile(user_id, input).await
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css) }
.await async fn set_also_known_as(
&self,
user_id: &UserId,
value: Option<String>,
) -> Result<(), DomainError> {
self.0.set_also_known_as(user_id, value).await
} }
} }

View File

@@ -1,21 +1,36 @@
use activitypub_base::ActivityPubRepository; use activitypub::ActivityPubRepository;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent,
models::{ models::{
actor_connection_summary::ActorConnectionSummary, actor_connection_summary::ActorConnectionSummary,
feed::{FeedEntry, PageParams, Paginated}, feed::{FeedEntry, PageParams, Paginated},
remote_actor::RemoteActor, remote_actor::RemoteActor,
}, },
ports::{ ports::{
EventPublisher, FederationActionPort, FederationFollowPort, EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort,
FederationFollowRequestPort, FederationSchedulerPort, FeedQuery, FeedRepository, FederationSchedulerPort, FeedQuery, FeedRepository, FollowRepository,
FollowRepository, RemoteActorConnectionRepository, UserReader, RemoteActorConnectionRepository, UserReader,
}, },
value_objects::UserId, value_objects::UserId,
}; };
use super::social; use super::social;
pub async fn initiate_actor_move(
events: &dyn EventPublisher,
user_id: &UserId,
new_actor_url: url::Url,
) -> Result<(), DomainError> {
events
.publish(&DomainEvent::ActorMoved {
user_id: user_id.clone(),
new_actor_url: new_actor_url.to_string(),
})
.await
.map_err(|e| DomainError::Internal(e.to_string()))
}
pub async fn list_pending_requests( pub async fn list_pending_requests(
federation: &dyn FederationFollowRequestPort, federation: &dyn FederationFollowRequestPort,
user_id: &UserId, user_id: &UserId,
@@ -25,18 +40,38 @@ pub async fn list_pending_requests(
pub async fn accept_follow_request( pub async fn accept_follow_request(
federation: &dyn FederationFollowRequestPort, federation: &dyn FederationFollowRequestPort,
events: &dyn EventPublisher,
user_id: &UserId, user_id: &UserId,
actor_url: &str, actor_url: &str,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
federation.accept_follow_request(user_id, actor_url).await federation
.mark_follower_accepted(user_id, actor_url)
.await?;
events
.publish(&DomainEvent::RemoteFollowAccepted {
local_user_id: user_id.clone(),
remote_actor_url: actor_url.to_string(),
})
.await
.map_err(|e| DomainError::Internal(e.to_string()))
} }
pub async fn reject_follow_request( pub async fn reject_follow_request(
federation: &dyn FederationFollowRequestPort, federation: &dyn FederationFollowRequestPort,
events: &dyn EventPublisher,
user_id: &UserId, user_id: &UserId,
actor_url: &str, actor_url: &str,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
federation.reject_follow_request(user_id, actor_url).await federation
.mark_follower_rejected(user_id, actor_url)
.await?;
events
.publish(&DomainEvent::RemoteFollowRejected {
local_user_id: user_id.clone(),
remote_actor_url: actor_url.to_string(),
})
.await
.map_err(|e| DomainError::Internal(e.to_string()))
} }
pub async fn list_remote_followers( pub async fn list_remote_followers(
@@ -86,7 +121,13 @@ pub async fn get_remote_actor_posts(
Some(id) => id, Some(id) => id,
None => ap_repo.intern_remote_actor(&actor.url).await?, None => ap_repo.intern_remote_actor(&actor.url).await?,
}; };
let result = feed.query(&FeedQuery::user(author_id, page.clone(), viewer_id.cloned())).await?; let result = feed
.query(&FeedQuery::user(
author_id,
page.clone(),
viewer_id.cloned(),
))
.await?;
if let Some(outbox_url) = actor.outbox_url { if let Some(outbox_url) = actor.outbox_url {
let _ = scheduler let _ = scheduler
.schedule_actor_posts_fetch(&actor.url, &outbox_url) .schedule_actor_posts_fetch(&actor.url, &outbox_url)

View File

@@ -13,7 +13,7 @@ async fn list_pending_returns_empty_by_default() {
async fn accept_follow_request_returns_ok() { async fn accept_follow_request_returns_ok() {
let store = TestStore::default(); let store = TestStore::default();
let uid = UserId::new(); let uid = UserId::new();
accept_follow_request(&store, &uid, "https://mastodon.social/users/alice") accept_follow_request(&store, &store, &uid, "https://mastodon.social/users/alice")
.await .await
.unwrap(); .unwrap();
} }
@@ -22,7 +22,7 @@ async fn accept_follow_request_returns_ok() {
async fn reject_follow_request_returns_ok() { async fn reject_follow_request_returns_ok() {
let store = TestStore::default(); let store = TestStore::default();
let uid = UserId::new(); let uid = UserId::new();
reject_follow_request(&store, &uid, "https://mastodon.social/users/alice") reject_follow_request(&store, &store, &uid, "https://mastodon.social/users/alice")
.await .await
.unwrap(); .unwrap();
} }

View File

@@ -13,5 +13,6 @@ pub async fn get_home_feed(
) -> Result<Paginated<FeedEntry>, DomainError> { ) -> Result<Paginated<FeedEntry>, DomainError> {
let mut following_ids = follows.get_accepted_following_ids(user_id).await?; let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
following_ids.push(user_id.clone()); following_ids.push(user_id.clone());
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page)).await feed.query(&FeedQuery::home(user_id.clone(), following_ids, page))
.await
} }

View File

@@ -1,10 +1,16 @@
const MAX_TOP_FRIENDS: usize = 8; const MAX_TOP_FRIENDS: usize = 8;
use bytes::Bytes;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{top_friend::TopFriend, user::User}, models::{
ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter}, top_friend::TopFriend,
user::{UpdateProfileInput, User},
},
ports::{
EventPublisher, MediaStore, TopFriendRepository, UserReader, UserRepository, UserWriter,
},
value_objects::{UserId, Username}, value_objects::{UserId, Username},
}; };
@@ -41,27 +47,13 @@ pub async fn get_user_by_id_or_username(
} }
} }
#[allow(clippy::too_many_arguments)]
pub async fn update_profile( pub async fn update_profile(
users: &dyn UserWriter, users: &dyn UserWriter,
events: &dyn EventPublisher, events: &dyn EventPublisher,
user_id: &UserId, user_id: &UserId,
display_name: Option<String>, input: UpdateProfileInput,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
users users.update_profile(user_id, input).await?;
.update_profile(
user_id,
display_name,
bio,
avatar_url,
header_url,
custom_css,
)
.await?;
events events
.publish(&DomainEvent::ProfileUpdated { .publish(&DomainEvent::ProfileUpdated {
user_id: user_id.clone(), user_id: user_id.clone(),
@@ -92,5 +84,151 @@ pub async fn set_top_friends(
top_friends.set_top_friends(user_id, friends).await top_friends.set_top_friends(user_id, friends).await
} }
#[derive(Clone)]
pub struct UploadConfig {
pub max_bytes: usize,
pub allowed_content_types: Vec<String>,
}
impl Default for UploadConfig {
fn default() -> Self {
Self {
max_bytes: 5 * 1024 * 1024,
allowed_content_types: vec![
"image/jpeg".into(),
"image/png".into(),
"image/gif".into(),
"image/webp".into(),
"image/avif".into(),
],
}
}
}
fn mime_to_ext(mime: &str) -> Result<&'static str, DomainError> {
match mime {
"image/jpeg" => Ok("jpg"),
"image/png" => Ok("png"),
"image/gif" => Ok("gif"),
"image/webp" => Ok("webp"),
"image/avif" => Ok("avif"),
_ => Err(DomainError::InvalidInput("unsupported content type".into())),
}
}
#[allow(clippy::too_many_arguments)]
async fn store_image(
media: &dyn MediaStore,
base_url: &str,
cfg: &UploadConfig,
content_type: &str,
data: Bytes,
user_id: &UserId,
key_segment: &str,
old_url: Option<&str>,
) -> Result<String, DomainError> {
if !cfg.allowed_content_types.iter().any(|t| t == content_type) {
return Err(DomainError::InvalidInput("unsupported content type".into()));
}
if data.len() > cfg.max_bytes {
return Err(DomainError::InvalidInput("file too large".into()));
}
let ext = mime_to_ext(content_type)?;
if let Some(old) = old_url {
let prefix = format!("{base_url}/media/");
if let Some(old_key) = old.strip_prefix(&prefix) {
media.delete(old_key).await?;
}
}
let key = format!("users/{}/{key_segment}.{ext}", user_id.as_uuid());
let stream = Box::pin(futures::stream::once(async move { Ok(data) }));
media.put(&key, stream).await?;
Ok(key)
}
#[allow(clippy::too_many_arguments)]
pub async fn upload_avatar(
users: &dyn UserRepository,
media: &dyn MediaStore,
events: &dyn EventPublisher,
user_id: &UserId,
base_url: &str,
cfg: &UploadConfig,
content_type: &str,
data: Bytes,
) -> Result<(), DomainError> {
let current = users
.find_by_id(user_id)
.await?
.ok_or(DomainError::NotFound)?;
let key = store_image(
media,
base_url,
cfg,
content_type,
data,
user_id,
"avatar",
current.avatar_url.as_deref(),
)
.await?;
users
.update_profile(
user_id,
UpdateProfileInput {
avatar_url: Some(format!("{base_url}/media/{key}")),
..Default::default()
},
)
.await?;
events
.publish(&DomainEvent::ProfileUpdated {
user_id: user_id.clone(),
})
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn upload_banner(
users: &dyn UserRepository,
media: &dyn MediaStore,
events: &dyn EventPublisher,
user_id: &UserId,
base_url: &str,
cfg: &UploadConfig,
content_type: &str,
data: Bytes,
) -> Result<(), DomainError> {
let current = users
.find_by_id(user_id)
.await?
.ok_or(DomainError::NotFound)?;
let key = store_image(
media,
base_url,
cfg,
content_type,
data,
user_id,
"banner",
current.header_url.as_deref(),
)
.await?;
users
.update_profile(
user_id,
UpdateProfileInput {
header_url: Some(format!("{base_url}/media/{key}")),
..Default::default()
},
)
.await?;
events
.publish(&DomainEvent::ProfileUpdated {
user_id: user_id.clone(),
})
.await
}
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -5,6 +5,7 @@ use domain::{
testing::TestStore, testing::TestStore,
value_objects::{Email, PasswordHash, UserId, Username}, value_objects::{Email, PasswordHash, UserId, Username},
}; };
use std::sync::{Arc, Mutex};
fn make_user() -> User { fn make_user() -> User {
User::new_local( User::new_local(
@@ -64,3 +65,219 @@ async fn get_user_by_username_returns_correct_user() {
let found = get_user_by_username(&store, "alice").await.unwrap(); let found = get_user_by_username(&store, "alice").await.unwrap();
assert_eq!(found.id, user.id); assert_eq!(found.id, user.id);
} }
// ── upload tests ──────────────────────────────────────────────────────────────
use bytes::Bytes;
use domain::ports::{DataStream, MediaStore};
use std::collections::HashMap;
#[derive(Default, Clone)]
struct MockMedia {
store: Arc<Mutex<HashMap<String, Bytes>>>,
deleted: Arc<Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl MediaStore for MockMedia {
async fn put(&self, key: &str, mut data: DataStream) -> Result<(), DomainError> {
use futures::stream::StreamExt;
let mut buf = Vec::new();
while let Some(chunk) = data.next().await {
buf.extend_from_slice(&chunk?);
}
self.store
.lock()
.unwrap()
.insert(key.to_string(), Bytes::from(buf));
Ok(())
}
async fn get(&self, key: &str) -> Result<DataStream, DomainError> {
let bytes = self
.store
.lock()
.unwrap()
.get(key)
.cloned()
.ok_or(DomainError::NotFound)?;
Ok(Box::pin(futures::stream::once(async move { Ok(bytes) })))
}
async fn delete(&self, key: &str) -> Result<(), DomainError> {
self.store.lock().unwrap().remove(key);
self.deleted.lock().unwrap().push(key.to_string());
Ok(())
}
}
fn default_cfg() -> UploadConfig {
UploadConfig::default()
}
#[tokio::test]
async fn upload_avatar_rejects_unsupported_mime() {
let store = TestStore::default();
let media = MockMedia::default();
let user = make_user();
store.users.lock().unwrap().push(user.clone());
let err = upload_avatar(
&store,
&media,
&store,
&user.id,
"http://localhost",
&default_cfg(),
"text/plain",
Bytes::from("hi"),
)
.await
.unwrap_err();
assert!(matches!(err, DomainError::InvalidInput(_)));
}
#[tokio::test]
async fn upload_avatar_rejects_oversized_data() {
let store = TestStore::default();
let media = MockMedia::default();
let user = make_user();
store.users.lock().unwrap().push(user.clone());
let big = Bytes::from(vec![0u8; 6 * 1024 * 1024]);
let err = upload_avatar(
&store,
&media,
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/jpeg",
big,
)
.await
.unwrap_err();
assert!(matches!(err, DomainError::InvalidInput(_)));
}
#[tokio::test]
async fn upload_avatar_stores_file_and_updates_url() {
let store = TestStore::default();
let media = MockMedia::default();
let user = make_user();
store.users.lock().unwrap().push(user.clone());
upload_avatar(
&store,
&media,
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/jpeg",
Bytes::from("img"),
)
.await
.unwrap();
let key = format!("users/{}/avatar.jpg", user.id.as_uuid());
assert!(media.store.lock().unwrap().contains_key(&key));
let saved = store
.users
.lock()
.unwrap()
.iter()
.find(|u| u.id == user.id)
.unwrap()
.clone();
assert_eq!(
saved.avatar_url,
Some(format!("http://localhost/media/{key}"))
);
}
#[tokio::test]
async fn upload_avatar_deletes_old_file_on_reupload() {
let store = TestStore::default();
let media = MockMedia::default();
let mut user = make_user();
let old_key = format!("users/{}/avatar.png", user.id.as_uuid());
user.avatar_url = Some(format!("http://localhost/media/{old_key}"));
store.users.lock().unwrap().push(user.clone());
media
.store
.lock()
.unwrap()
.insert(old_key.clone(), Bytes::from("old"));
upload_avatar(
&store,
&media,
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/jpeg",
Bytes::from("new"),
)
.await
.unwrap();
assert!(!media.store.lock().unwrap().contains_key(&old_key));
assert!(media.deleted.lock().unwrap().contains(&old_key));
}
#[tokio::test]
async fn upload_banner_stores_file_and_updates_header_url() {
let store = TestStore::default();
let media = MockMedia::default();
let user = make_user();
store.users.lock().unwrap().push(user.clone());
upload_banner(
&store,
&media,
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/png",
Bytes::from("banner"),
)
.await
.unwrap();
let key = format!("users/{}/banner.png", user.id.as_uuid());
assert!(media.store.lock().unwrap().contains_key(&key));
let saved = store
.users
.lock()
.unwrap()
.iter()
.find(|u| u.id == user.id)
.unwrap()
.clone();
assert_eq!(
saved.header_url,
Some(format!("http://localhost/media/{key}"))
);
}
#[tokio::test]
async fn upload_banner_deletes_old_file_on_reupload() {
let store = TestStore::default();
let media = MockMedia::default();
let mut user = make_user();
let old_key = format!("users/{}/banner.jpg", user.id.as_uuid());
user.header_url = Some(format!("http://localhost/media/{old_key}"));
store.users.lock().unwrap().push(user.clone());
media
.store
.lock()
.unwrap()
.insert(old_key.clone(), Bytes::from("old"));
upload_banner(
&store,
&media,
&store,
&user.id,
"http://localhost",
&default_cfg(),
"image/png",
Bytes::from("new"),
)
.await
.unwrap();
assert!(!media.store.lock().unwrap().contains_key(&old_key));
assert!(media.deleted.lock().unwrap().contains(&old_key));
}

View File

@@ -1,7 +1,7 @@
use super::*; use super::*;
use domain::{ use domain::{
models::{ models::{
thought::{Thought, Visibility}, thought::{NewThought, Thought, Visibility},
user::User, user::User,
}, },
testing::TestStore, testing::TestStore,
@@ -22,15 +22,19 @@ async fn like_and_unlike() {
let store = TestStore::default(); let store = TestStore::default();
let alice = user("alice"); let alice = user("alice");
let tid = ThoughtId::new(); let tid = ThoughtId::new();
store.thoughts.lock().unwrap().push(Thought::new_local( store
tid.clone(), .thoughts
alice.id.clone(), .lock()
Content::new_local("hi").unwrap(), .unwrap()
None, .push(Thought::new_local(NewThought {
Visibility::Public, id: tid.clone(),
None, user_id: alice.id.clone(),
false, content: Content::new_local("hi").unwrap(),
)); in_reply_to_id: None,
visibility: Visibility::Public,
content_warning: None,
sensitive: false,
}));
like_thought(&store, &store, &alice.id, &tid).await.unwrap(); like_thought(&store, &store, &alice.id, &tid).await.unwrap();
assert_eq!(store.likes.lock().unwrap().len(), 1); assert_eq!(store.likes.lock().unwrap().len(), 1);
unlike_thought(&store, &store, &alice.id, &tid) unlike_thought(&store, &store, &alice.id, &tid)

View File

@@ -3,9 +3,12 @@ use domain::{
events::DomainEvent, events::DomainEvent,
models::{ models::{
feed::{EngagementStats, FeedEntry}, feed::{EngagementStats, FeedEntry},
thought::{Thought, Visibility}, thought::{NewThought, Thought, Visibility},
},
ports::{
EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository,
UserReader,
}, },
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader},
value_objects::{Content, ThoughtId, UserId}, value_objects::{Content, ThoughtId, UserId},
}; };
@@ -43,15 +46,15 @@ pub async fn create_thought(
Some("direct") => Visibility::Direct, Some("direct") => Visibility::Direct,
_ => Visibility::Public, _ => Visibility::Public,
}; };
let thought = Thought::new_local( let thought = Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
input.user_id, user_id: input.user_id,
content.clone(), content: content.clone(),
input.in_reply_to_id.clone(), in_reply_to_id: input.in_reply_to_id.clone(),
visibility, visibility,
input.content_warning, content_warning: input.content_warning,
input.sensitive, sensitive: input.sensitive,
); });
thoughts.save(&thought).await?; thoughts.save(&thought).await?;
// Extract and attach hashtags from content. // Extract and attach hashtags from content.
@@ -132,11 +135,23 @@ pub async fn get_thought_view(
.find_by_id(&thought.user_id) .find_by_id(&thought.user_id)
.await? .await?
.ok_or(DomainError::NotFound)?; .ok_or(DomainError::NotFound)?;
let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?; let mut map = engagement
let (stats, viewer_ctx) = map.remove(id).unwrap_or( .get_for_thoughts(std::slice::from_ref(id), viewer)
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None) .await?;
); let (stats, viewer_ctx) = map.remove(id).unwrap_or((
Ok(FeedEntry { thought, author, stats, viewer: viewer_ctx }) EngagementStats {
like_count: 0,
boost_count: 0,
reply_count: 0,
},
None,
));
Ok(FeedEntry {
thought,
author,
stats,
viewer: viewer_ctx,
})
} }
/// Fetches a thread (root + replies) enriched with authors + real engagement stats. /// Fetches a thread (root + replies) enriched with authors + real engagement stats.
@@ -169,10 +184,20 @@ pub async fn get_thread_views(
.get(&thought.user_id) .get(&thought.user_id)
.cloned() .cloned()
.ok_or(DomainError::NotFound)?; .ok_or(DomainError::NotFound)?;
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or( let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or((
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None) EngagementStats {
); like_count: 0,
entries.push(FeedEntry { thought, author, stats, viewer: viewer_ctx }); boost_count: 0,
reply_count: 0,
},
None,
));
entries.push(FeedEntry {
thought,
author,
stats,
viewer: viewer_ctx,
});
} }
Ok(entries) Ok(entries)
} }

View File

@@ -31,7 +31,14 @@ async fn create_thought_saves_and_stages_outbox_event() {
let outbox = TestOutbox::default(); let outbox = TestOutbox::default();
let u = user(); let u = user();
store.users.lock().unwrap().push(u.clone()); store.users.lock().unwrap().push(u.clone());
let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &outbox, input(u.id.clone())) let out = create_thought(
&store,
&store,
&store,
&NoOpEventPublisher,
&outbox,
input(u.id.clone()),
)
.await .await
.unwrap(); .unwrap();
assert_eq!(out.thought.content.as_str(), "hello"); assert_eq!(out.thought.content.as_str(), "hello");
@@ -64,7 +71,9 @@ async fn delete_thought_stages_outbox_event() {
let staged = outbox.staged(); let staged = outbox.staged();
assert_eq!(staged.len(), 1); assert_eq!(staged.len(), 1);
assert!(matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid)); assert!(
matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid)
);
} }
#[tokio::test] #[tokio::test]
@@ -82,7 +91,13 @@ async fn delete_own_thought_succeeds() {
) )
.await .await
.unwrap(); .unwrap();
delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &u.id) delete_thought(
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
&out.thought.id,
&u.id,
)
.await .await
.unwrap(); .unwrap();
assert!(store.thoughts.lock().unwrap().is_empty()); assert!(store.thoughts.lock().unwrap().is_empty());
@@ -113,7 +128,13 @@ async fn delete_other_thought_returns_not_found() {
) )
.await .await
.unwrap(); .unwrap();
let err = delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &bob.id) let err = delete_thought(
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
&out.thought.id,
&bob.id,
)
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, DomainError::NotFound)); assert!(matches!(err, DomainError::NotFound));
@@ -124,7 +145,14 @@ async fn edit_thought_changes_content_and_emits_event() {
let store = TestStore::default(); let store = TestStore::default();
let alice = user(); let alice = user();
store.users.lock().unwrap().push(alice.clone()); store.users.lock().unwrap().push(alice.clone());
let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &NoOpOutboxWriter, input(alice.id.clone())) let out = create_thought(
&store,
&store,
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
input(alice.id.clone()),
)
.await .await
.unwrap(); .unwrap();
let tid = out.thought.id.clone(); let tid = out.thought.id.clone();
@@ -194,7 +222,7 @@ async fn create_reply_sets_in_reply_to_id() {
// enrichment_tests (combined from second cfg(test) block) // enrichment_tests (combined from second cfg(test) block)
use domain::models::thought::{Thought, Visibility}; use domain::models::thought::{NewThought, Thought, Visibility};
use domain::ports::{ThoughtRepository, UserWriter}; use domain::ports::{ThoughtRepository, UserWriter};
fn make_user() -> User { fn make_user() -> User {
@@ -207,24 +235,28 @@ fn make_user() -> User {
} }
fn make_thought(user_id: UserId) -> Thought { fn make_thought(user_id: UserId) -> Thought {
Thought::new_local( Thought::new_local(NewThought {
ThoughtId::new(), id: ThoughtId::new(),
user_id, user_id,
Content::new_local(String::from("hello")).unwrap(), content: Content::new_local(String::from("hello")).unwrap(),
None, in_reply_to_id: None,
Visibility::Public, visibility: Visibility::Public,
None, content_warning: None,
false, sensitive: false,
) })
} }
#[tokio::test] #[tokio::test]
async fn get_thought_view_returns_feed_entry() { async fn get_thought_view_returns_feed_entry() {
let store = TestStore::default(); let store = TestStore::default();
let user = make_user(); let user = make_user();
<TestStore as UserWriter>::save(&store, &user).await.unwrap(); <TestStore as UserWriter>::save(&store, &user)
.await
.unwrap();
let thought = make_thought(user.id.clone()); let thought = make_thought(user.id.clone());
<TestStore as ThoughtRepository>::save(&store, &thought).await.unwrap(); <TestStore as ThoughtRepository>::save(&store, &thought)
.await
.unwrap();
let entry = get_thought_view(&store, &store, &store, &thought.id, None) let entry = get_thought_view(&store, &store, &store, &thought.id, None)
.await .await
@@ -248,19 +280,25 @@ async fn get_thought_view_returns_not_found_for_missing_thought() {
async fn get_thread_views_batches_correctly() { async fn get_thread_views_batches_correctly() {
let store = TestStore::default(); let store = TestStore::default();
let user = make_user(); let user = make_user();
<TestStore as UserWriter>::save(&store, &user).await.unwrap(); <TestStore as UserWriter>::save(&store, &user)
.await
.unwrap();
let root = make_thought(user.id.clone()); let root = make_thought(user.id.clone());
<TestStore as ThoughtRepository>::save(&store, &root).await.unwrap(); <TestStore as ThoughtRepository>::save(&store, &root)
let reply = Thought::new_local( .await
ThoughtId::new(), .unwrap();
user.id.clone(), let reply = Thought::new_local(NewThought {
Content::new_local(String::from("reply")).unwrap(), id: ThoughtId::new(),
Some(root.id.clone()), user_id: user.id.clone(),
Visibility::Public, content: Content::new_local(String::from("reply")).unwrap(),
None, in_reply_to_id: Some(root.id.clone()),
false, visibility: Visibility::Public,
); content_warning: None,
<TestStore as ThoughtRepository>::save(&store, &reply).await.unwrap(); sensitive: false,
});
<TestStore as ThoughtRepository>::save(&store, &reply)
.await
.unwrap();
let entries = get_thread_views(&store, &store, &store, &root.id, None) let entries = get_thread_views(&store, &store, &store, &root.id, None)
.await .await

View File

@@ -14,10 +14,12 @@ postgres = { workspace = true }
postgres-search = { workspace = true } postgres-search = { workspace = true }
postgres-federation = { workspace = true } postgres-federation = { workspace = true }
activitypub = { workspace = true } activitypub = { workspace = true }
activitypub-base = { workspace = true } k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.10" }
nats = { workspace = true } nats = { workspace = true }
event-transport = { workspace = true } event-transport = { workspace = true }
auth = { workspace = true } auth = { workspace = true }
storage = { workspace = true }
application = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
async-nats = { workspace = true } async-nats = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }

View File

@@ -11,6 +11,18 @@ pub struct Config {
pub host: String, pub host: String,
pub cors_origins: String, pub cors_origins: String,
pub rate_limit: Option<u32>, pub rate_limit: Option<u32>,
// Storage
pub storage_backend: String,
pub storage_path: Option<String>,
pub storage_prefix: String,
pub s3_endpoint: Option<String>,
pub s3_access_key_id: Option<String>,
pub s3_secret_access_key: Option<String>,
pub s3_bucket: Option<String>,
pub s3_region: Option<String>,
// Upload limits
pub upload_max_bytes: usize,
pub upload_allowed_types: Vec<String>,
} }
impl Config { impl Config {
@@ -36,6 +48,23 @@ impl Config {
rate_limit: std::env::var("RATE_LIMIT") rate_limit: std::env::var("RATE_LIMIT")
.ok() .ok()
.and_then(|v| v.parse().ok()), .and_then(|v| v.parse().ok()),
storage_backend: std::env::var("STORAGE_BACKEND").unwrap_or_else(|_| "local".into()),
storage_path: std::env::var("STORAGE_PATH").ok(),
storage_prefix: std::env::var("STORAGE_PREFIX").unwrap_or_default(),
s3_endpoint: std::env::var("S3_ENDPOINT").ok(),
s3_access_key_id: std::env::var("S3_ACCESS_KEY_ID").ok(),
s3_secret_access_key: std::env::var("S3_SECRET_ACCESS_KEY").ok(),
s3_bucket: std::env::var("S3_BUCKET").ok(),
s3_region: std::env::var("S3_REGION").ok(),
upload_max_bytes: std::env::var("UPLOAD_MAX_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5 * 1024 * 1024),
upload_allowed_types: std::env::var("UPLOAD_ALLOWED_TYPES")
.unwrap_or_else(|_| "image/jpeg,image/png,image/gif,image/webp,image/avif".into())
.split(',')
.map(|s| s.trim().to_string())
.collect(),
} }
} }
} }

View File

@@ -5,11 +5,18 @@ use async_trait::async_trait;
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
use activitypub::ThoughtsObjectHandler; use application::use_cases::profile::UploadConfig;
use activitypub_base::service::ActivityPubService; use storage::{build_store, ObjectStorageAdapter, StorageConfig};
use activitypub::{ApFederationAdapter, ThoughtsObjectHandler};
use auth::ApiKeyServiceImpl; use auth::ApiKeyServiceImpl;
use domain::{errors::DomainError, events::DomainEvent, ports::{EventPublisher, OutboxWriter}}; use domain::{
errors::DomainError,
events::DomainEvent,
ports::{EventPublisher, OutboxWriter},
};
use event_transport::EventPublisherAdapter; use event_transport::EventPublisherAdapter;
use k_ap::ActivityPubService;
use nats::NatsTransport; use nats::NatsTransport;
use postgres::activitypub::PgActivityPubRepository; use postgres::activitypub::PgActivityPubRepository;
use postgres::engagement::PgEngagementRepository; use postgres::engagement::PgEngagementRepository;
@@ -23,7 +30,7 @@ use crate::config::Config;
/// Everything the binary needs to start serving. /// Everything the binary needs to start serving.
pub struct Infrastructure { pub struct Infrastructure {
pub state: AppState, pub state: AppState,
pub ap_service: Arc<ActivityPubService>, pub ap_service: Arc<ApFederationAdapter>,
} }
struct NoOpEventPublisher; struct NoOpEventPublisher;
@@ -68,8 +75,9 @@ pub async fn build(cfg: &Config) -> Infrastructure {
}; };
// 3. ActivityPub federation // 3. ActivityPub federation
let ap_service = Arc::new( let connections_repo = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
ActivityPubService::new( let raw_ap_service = Arc::new(
ActivityPubService::builder(
Arc::new(PostgresFederationRepository::new(pool.clone())), Arc::new(PostgresFederationRepository::new(pool.clone())),
Arc::new(PostgresApUserRepository::new( Arc::new(PostgresApUserRepository::new(
pool.clone(), pool.clone(),
@@ -82,16 +90,37 @@ pub async fn build(cfg: &Config) -> Infrastructure {
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
)), )),
cfg.base_url.clone(), cfg.base_url.clone(),
cfg.allow_registration,
"thoughts".to_string(),
cfg.debug,
None,
) )
.allow_registration(cfg.allow_registration)
.software_name("thoughts")
.debug(cfg.debug)
.build()
.await .await
.expect("Failed to build ActivityPubService"), .expect("Failed to build ActivityPubService"),
); );
let ap_service = Arc::new(ApFederationAdapter::new(raw_ap_service, connections_repo));
// 4. Application state // 4. Storage adapter
let storage_cfg = StorageConfig {
backend: cfg.storage_backend.clone(),
local_path: cfg.storage_path.clone(),
s3_endpoint: cfg.s3_endpoint.clone(),
s3_access_key_id: cfg.s3_access_key_id.clone(),
s3_secret_access_key: cfg.s3_secret_access_key.clone(),
s3_bucket: cfg.s3_bucket.clone(),
s3_region: cfg.s3_region.clone(),
};
let object_store = build_store(&storage_cfg).expect("Failed to build object store");
let media_adapter: Arc<dyn domain::ports::MediaStore> = Arc::new(
ObjectStorageAdapter::new(object_store, cfg.storage_prefix.clone())
.expect("Failed to create storage adapter"),
);
let upload_config = UploadConfig {
max_bytes: cfg.upload_max_bytes,
allowed_content_types: cfg.upload_allowed_types.clone(),
};
// 5. Application state
let state = AppState { let state = AppState {
users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())), users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())),
thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())), thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())),
@@ -129,10 +158,13 @@ pub async fn build(cfg: &Config) -> Infrastructure {
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())), ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())), remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
federation_scheduler: ap_service.clone() as Arc<dyn domain::ports::FederationSchedulerPort>, federation_scheduler: ap_service.clone() as Arc<dyn domain::ports::FederationSchedulerPort>,
api_key_auth: Arc::new(ApiKeyServiceImpl::new( api_key_auth: Arc::new(ApiKeyServiceImpl::new(Arc::new(
Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())), postgres::api_key::PgApiKeyRepository::new(pool.clone()),
)), ))),
engagement: Arc::new(PgEngagementRepository::new(pool.clone())), engagement: Arc::new(PgEngagementRepository::new(pool.clone())),
media: media_adapter,
upload_config,
base_url: cfg.base_url.clone(),
}; };
Infrastructure { state, ap_service } Infrastructure { state, ap_service }

View File

@@ -12,8 +12,10 @@ thiserror = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
url = { workspace = true } url = { workspace = true }
bytes = { workspace = true }
sha2 = { version = "0.10", optional = true } sha2 = { version = "0.10", optional = true }
hex = { version = "0.4", optional = true } hex = { version = "0.4", optional = true }

View File

@@ -63,6 +63,18 @@ pub enum DomainEvent {
ProfileUpdated { ProfileUpdated {
user_id: UserId, user_id: UserId,
}, },
RemoteFollowAccepted {
local_user_id: UserId,
remote_actor_url: String,
},
RemoteFollowRejected {
local_user_id: UserId,
remote_actor_url: String,
},
ActorMoved {
user_id: UserId,
new_actor_url: String,
},
MentionReceived { MentionReceived {
thought_id: ThoughtId, thought_id: ThoughtId,
mentioned_user_id: UserId, mentioned_user_id: UserId,

View File

@@ -21,6 +21,7 @@ pub struct Thought {
pub local: bool, pub local: bool,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>, pub updated_at: Option<DateTime<Utc>>,
pub note_extensions: Option<serde_json::Value>,
} }
impl Visibility { impl Visibility {
@@ -46,27 +47,30 @@ impl Visibility {
} }
} }
pub struct NewThought {
pub id: ThoughtId,
pub user_id: UserId,
pub content: Content,
pub in_reply_to_id: Option<ThoughtId>,
pub visibility: Visibility,
pub content_warning: Option<String>,
pub sensitive: bool,
}
impl Thought { impl Thought {
pub fn new_local( pub fn new_local(p: NewThought) -> Self {
id: ThoughtId,
user_id: UserId,
content: Content,
in_reply_to_id: Option<ThoughtId>,
visibility: Visibility,
content_warning: Option<String>,
sensitive: bool,
) -> Self {
Self { Self {
id, id: p.id,
user_id, user_id: p.user_id,
content, content: p.content,
in_reply_to_id, in_reply_to_id: p.in_reply_to_id,
visibility, visibility: p.visibility,
content_warning, content_warning: p.content_warning,
sensitive, sensitive: p.sensitive,
local: true, local: true,
created_at: Utc::now(), created_at: Utc::now(),
updated_at: None, updated_at: None,
note_extensions: None,
} }
} }
} }

View File

@@ -1,6 +1,15 @@
use crate::value_objects::{Email, PasswordHash, UserId, Username}; use crate::value_objects::{Email, PasswordHash, UserId, Username};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
#[derive(Debug, Default, Clone)]
pub struct UpdateProfileInput {
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub header_url: Option<String>,
pub custom_css: Option<String>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct User { pub struct User {
pub id: UserId, pub id: UserId,

Some files were not shown because too many files have changed in this diff Show More