Compare commits
28 Commits
master
...
74eeb9fcb9
| Author | SHA1 | Date | |
|---|---|---|---|
| 74eeb9fcb9 | |||
| 7ee22ae79f | |||
| 3f26456d77 | |||
| 379f31e27d | |||
| 9c99f7a7a8 | |||
| 636d3d453d | |||
| 9172c82d54 | |||
| cd2eb48ddb | |||
| c5d9833c8b | |||
| f39c1a614d | |||
| 30c8a17168 | |||
| 6a8c8b1fb8 | |||
| 4ec0725ff8 | |||
| 31e0f2958c | |||
| 555121ea75 | |||
| 9e795eefdc | |||
| 18cf2c9f54 | |||
| b58c96b843 | |||
| 8ea24461ba | |||
| e14a9f90c8 | |||
| 28756ef4cd | |||
| 7f27ae49c3 | |||
| 59f3423c00 | |||
| c48aa33592 | |||
| 8f3aa4b891 | |||
| 32bfb00970 | |||
| 7ce2901c2a | |||
| 8bbc713093 |
@@ -1,9 +0,0 @@
|
|||||||
[registry]
|
|
||||||
default = "gitea"
|
|
||||||
|
|
||||||
[registries.gitea]
|
|
||||||
index = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/" # Sparse index
|
|
||||||
# index = "https://git.gabrielkaszewski.dev/GKaszewski/_cargo-index.git" # Git
|
|
||||||
|
|
||||||
[net]
|
|
||||||
git-fetch-with-cli = true
|
|
||||||
@@ -9,7 +9,7 @@ BASE_URL=http://localhost:3000
|
|||||||
|
|
||||||
# Optional
|
# Optional
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=8000
|
PORT=3000
|
||||||
|
|
||||||
# CORS — comma-separated allowed origins, or * for permissive (default: *)
|
# CORS — comma-separated allowed origins, or * for permissive (default: *)
|
||||||
CORS_ORIGINS=*
|
CORS_ORIGINS=*
|
||||||
|
|||||||
@@ -21,3 +21,32 @@ 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
2
.gitignore
vendored
@@ -1,6 +1,6 @@
|
|||||||
.env
|
.env
|
||||||
/.superpowers/
|
|
||||||
|
|
||||||
/target
|
/target
|
||||||
/docs/superpowers/
|
/docs/superpowers/
|
||||||
|
|
||||||
/media
|
/media
|
||||||
164
ARCHITECTURE.md
164
ARCHITECTURE.md
@@ -1,164 +0,0 @@
|
|||||||
# Architecture
|
|
||||||
|
|
||||||
Hexagonal (ports & adapters) architecture. Dependencies point inward — adapters implement domain ports, application orchestrates use cases, presentation handles HTTP.
|
|
||||||
|
|
||||||
## Crate dependency graph
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
subgraph Entry Points
|
|
||||||
bootstrap["bootstrap<br/><small>HTTP server, DI wiring</small>"]
|
|
||||||
worker["worker<br/><small>background job consumer</small>"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Interface Layer
|
|
||||||
presentation["presentation<br/><small>axum handlers, extractors, AppState</small>"]
|
|
||||||
api_types["api-types<br/><small>DTOs, OpenAPI</small>"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Application Layer
|
|
||||||
application["application<br/><small>use cases, FederationEventService</small>"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Domain Layer
|
|
||||||
domain["domain<br/><small>models, value objects, events, port traits</small>"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Adapters
|
|
||||||
postgres["postgres<br/><small>UserRepo, ThoughtRepo, LikeRepo,<br/>BoostRepo, FollowRepo, BlockRepo,<br/>TagRepo, FeedRepo, FederationContentRepo, ...</small>"]
|
|
||||||
activitypub["activitypub<br/><small>FederationActionPort,<br/>FederationBroadcastPort,<br/>FederationSchedulerPort<br/>(wraps k-ap)</small>"]
|
|
||||||
postgres_fed["postgres-federation<br/><small>k-ap DB traits</small>"]
|
|
||||||
postgres_search["postgres-search<br/><small>SearchPort</small>"]
|
|
||||||
auth["auth<br/><small>AuthService, ApiKeyService</small>"]
|
|
||||||
nats["nats<br/><small>EventPublisher, EventConsumer</small>"]
|
|
||||||
storage["storage<br/><small>MediaStore</small>"]
|
|
||||||
event_transport["event-transport<br/><small>event delivery</small>"]
|
|
||||||
event_payload["event-payload<br/><small>event serialization</small>"]
|
|
||||||
end
|
|
||||||
|
|
||||||
bootstrap --> presentation
|
|
||||||
bootstrap --> application
|
|
||||||
bootstrap --> postgres
|
|
||||||
bootstrap --> postgres_fed
|
|
||||||
bootstrap --> postgres_search
|
|
||||||
bootstrap --> activitypub
|
|
||||||
bootstrap --> auth
|
|
||||||
bootstrap --> nats
|
|
||||||
bootstrap --> storage
|
|
||||||
bootstrap --> event_transport
|
|
||||||
bootstrap --> event_payload
|
|
||||||
|
|
||||||
worker --> application
|
|
||||||
worker --> activitypub
|
|
||||||
worker --> postgres
|
|
||||||
worker --> postgres_fed
|
|
||||||
worker --> nats
|
|
||||||
worker --> event_transport
|
|
||||||
worker --> event_payload
|
|
||||||
|
|
||||||
presentation --> application
|
|
||||||
presentation --> api_types
|
|
||||||
presentation --> domain
|
|
||||||
|
|
||||||
application --> domain
|
|
||||||
|
|
||||||
postgres --> domain
|
|
||||||
activitypub --> domain
|
|
||||||
postgres_fed -.-> domain
|
|
||||||
postgres_search --> domain
|
|
||||||
postgres_search --> postgres
|
|
||||||
auth --> domain
|
|
||||||
nats --> domain
|
|
||||||
storage --> domain
|
|
||||||
event_transport --> domain
|
|
||||||
event_payload --> domain
|
|
||||||
```
|
|
||||||
|
|
||||||
## Domain ports
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
classDiagram
|
|
||||||
class domain {
|
|
||||||
<<core>>
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace Data Ports {
|
|
||||||
class UserRepository {
|
|
||||||
<<trait>>
|
|
||||||
find_by_id()
|
|
||||||
find_by_username()
|
|
||||||
save()
|
|
||||||
update_profile()
|
|
||||||
}
|
|
||||||
class ThoughtRepository {
|
|
||||||
<<trait>>
|
|
||||||
save()
|
|
||||||
find_by_id()
|
|
||||||
delete()
|
|
||||||
update_content()
|
|
||||||
}
|
|
||||||
class LikeRepository { <<trait>> }
|
|
||||||
class BoostRepository { <<trait>> }
|
|
||||||
class FollowRepository { <<trait>> }
|
|
||||||
class BlockRepository { <<trait>> }
|
|
||||||
class TagRepository { <<trait>> }
|
|
||||||
class FeedRepository { <<trait>> }
|
|
||||||
class NotificationRepository { <<trait>> }
|
|
||||||
class EngagementRepository { <<trait>> }
|
|
||||||
class SearchPort { <<trait>> }
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace Federation Ports {
|
|
||||||
class FederationContentRepository {
|
|
||||||
<<trait>>
|
|
||||||
outbox_entries_for_actor()
|
|
||||||
find_remote_actor_id()
|
|
||||||
intern_remote_actor()
|
|
||||||
accept_note()
|
|
||||||
retract_note()
|
|
||||||
}
|
|
||||||
class FederationBroadcastPort {
|
|
||||||
<<trait>>
|
|
||||||
broadcast_create()
|
|
||||||
broadcast_delete()
|
|
||||||
broadcast_update()
|
|
||||||
broadcast_announce()
|
|
||||||
broadcast_like()
|
|
||||||
}
|
|
||||||
class FederationActionPort {
|
|
||||||
<<supertrait>>
|
|
||||||
}
|
|
||||||
class FederationLookupPort { <<trait>> }
|
|
||||||
class FederationFollowPort { <<trait>> }
|
|
||||||
class FederationFollowRequestPort { <<trait>> }
|
|
||||||
class FederationFetchPort { <<trait>> }
|
|
||||||
class FederationBlockPort { <<trait>> }
|
|
||||||
class FederationSchedulerPort { <<trait>> }
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace Infra Ports {
|
|
||||||
class EventPublisher { <<trait>> }
|
|
||||||
class EventConsumer { <<trait>> }
|
|
||||||
class AuthService { <<trait>> }
|
|
||||||
class PasswordHasher { <<trait>> }
|
|
||||||
class MediaStore { <<trait>> }
|
|
||||||
}
|
|
||||||
|
|
||||||
FederationActionPort --|> FederationLookupPort
|
|
||||||
FederationActionPort --|> FederationFollowPort
|
|
||||||
FederationActionPort --|> FederationFollowRequestPort
|
|
||||||
FederationActionPort --|> FederationFetchPort
|
|
||||||
FederationActionPort --|> FederationBlockPort
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependency rule
|
|
||||||
|
|
||||||
```
|
|
||||||
bootstrap/worker ──► presentation ──► application ──► domain ◄── adapters
|
|
||||||
```
|
|
||||||
|
|
||||||
- **domain** — zero framework deps, pure business logic, defines all port traits
|
|
||||||
- **application** — orchestrates use cases, depends only on domain
|
|
||||||
- **presentation** — HTTP handlers (axum), depends on domain + application
|
|
||||||
- **adapters** — implement domain ports, depend inward on domain only
|
|
||||||
- **bootstrap/worker** — composition roots, wire adapters into ports
|
|
||||||
283
Cargo.lock
generated
283
Cargo.lock
generated
@@ -6,6 +6,7 @@ version = 4
|
|||||||
name = "activitypub"
|
name = "activitypub"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"activitypub_federation",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -13,7 +14,7 @@ dependencies = [
|
|||||||
"domain",
|
"domain",
|
||||||
"futures",
|
"futures",
|
||||||
"k-ap",
|
"k-ap",
|
||||||
"reqwest 0.13.4",
|
"reqwest 0.13.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -42,7 +43,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"http-signature-normalization",
|
"http-signature-normalization",
|
||||||
"http-signature-normalization-reqwest",
|
"http-signature-normalization-reqwest",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
@@ -51,7 +52,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.13.4",
|
"reqwest 0.13.3",
|
||||||
"reqwest-middleware",
|
"reqwest-middleware",
|
||||||
"rsa",
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -216,7 +217,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"socket2 0.6.4",
|
"socket2 0.6.3",
|
||||||
"time",
|
"time",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
@@ -264,7 +265,6 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -273,13 +273,13 @@ dependencies = [
|
|||||||
name = "application"
|
name = "application"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"activitypub",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"futures",
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
"serde_json",
|
|
||||||
"sha2",
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -425,9 +425,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-lc-rs"
|
name = "aws-lc-rs"
|
||||||
@@ -462,7 +462,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
@@ -494,7 +494,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"mime",
|
"mime",
|
||||||
@@ -583,7 +583,6 @@ name = "bootstrap"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub",
|
"activitypub",
|
||||||
"anyhow",
|
|
||||||
"application",
|
"application",
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -591,16 +590,14 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"domain",
|
"domain",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"event-payload",
|
|
||||||
"event-transport",
|
"event-transport",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"k-ap",
|
"k-ap",
|
||||||
"nats",
|
"nats",
|
||||||
"postgres",
|
"postgres",
|
||||||
"postgres-federation",
|
"postgres-federation",
|
||||||
"postgres-search",
|
"postgres-search",
|
||||||
"presentation",
|
"presentation",
|
||||||
"serde_json",
|
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"storage",
|
"storage",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -612,9 +609,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.20.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
@@ -916,9 +913,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dashmap"
|
name = "dashmap"
|
||||||
version = "6.2.1"
|
version = "6.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
@@ -1034,9 +1031,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.6"
|
version = "0.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1053,7 +1050,6 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
|
||||||
"sha2",
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1103,9 +1099,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.15.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@@ -1374,9 +1370,9 @@ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-timer"
|
name = "futures-timer"
|
||||||
version = "3.0.4"
|
version = "3.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
@@ -1480,7 +1476,7 @@ dependencies = [
|
|||||||
"fnv",
|
"fnv",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"slab",
|
"slab",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1583,9 +1579,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.1"
|
version = "1.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
|
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"itoa",
|
"itoa",
|
||||||
@@ -1598,7 +1594,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1609,7 +1605,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
@@ -1633,7 +1629,7 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"http-signature-normalization",
|
"http-signature-normalization",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
"reqwest 0.13.4",
|
"reqwest 0.13.3",
|
||||||
"reqwest-middleware",
|
"reqwest-middleware",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1659,16 +1655,16 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.10.0"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc"
|
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2",
|
"h2",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
@@ -1685,7 +1681,7 @@ version = "0.27.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -1718,14 +1714,14 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"hyper",
|
"hyper",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"libc",
|
"libc",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2 0.6.4",
|
"socket2 0.6.3",
|
||||||
"system-configuration",
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -1990,9 +1986,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.99"
|
version = "0.3.98"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
|
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -2017,9 +2013,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "k-ap"
|
name = "k-ap"
|
||||||
version = "0.4.0"
|
version = "0.1.0"
|
||||||
source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
|
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.3#7901b29f7c09415e82f7f098f89c1df6b86bbfd3"
|
||||||
checksum = "ccaa914953bfd45ea206e11826da8f61ce1fbe02f8fe0622880527046ad6ae24"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub_federation",
|
"activitypub_federation",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -2028,14 +2023,13 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"enum_delegate",
|
"enum_delegate",
|
||||||
"futures",
|
"futures",
|
||||||
"reqwest 0.13.4",
|
"reqwest 0.13.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
"zeroize",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2073,14 +2067,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.17"
|
version = "0.1.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
|
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"libc",
|
"libc",
|
||||||
"plain",
|
"plain",
|
||||||
"redox_syscall 0.8.0",
|
"redox_syscall 0.7.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2116,9 +2110,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.30"
|
version = "0.4.29"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
@@ -2153,9 +2147,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.1"
|
version = "2.8.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
@@ -2185,9 +2179,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.1"
|
version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -2224,7 +2218,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"httparse",
|
"httparse",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
@@ -2322,9 +2316,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
@@ -2554,7 +2548,6 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"k-ap",
|
"k-ap",
|
||||||
"serde_json",
|
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2570,7 +2563,6 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"postgres",
|
"postgres",
|
||||||
"serde_json",
|
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -2604,6 +2596,7 @@ dependencies = [
|
|||||||
name = "presentation"
|
name = "presentation"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"activitypub",
|
||||||
"api-types",
|
"api-types",
|
||||||
"application",
|
"application",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -2682,7 +2675,7 @@ dependencies = [
|
|||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2 0.6.4",
|
"socket2 0.6.3",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2720,9 +2713,9 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2 0.6.4",
|
"socket2 0.6.3",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2842,9 +2835,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.8.0"
|
version = "0.7.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d"
|
checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
@@ -2895,7 +2888,7 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
@@ -2928,9 +2921,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2938,7 +2931,7 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2",
|
"h2",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
@@ -2971,14 +2964,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest-middleware"
|
name = "reqwest-middleware"
|
||||||
version = "0.5.2"
|
version = "0.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58"
|
checksum = "199dda04a536b532d0cc04d7979e39b1c763ea749bf91507017069c00b96056f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"reqwest 0.13.4",
|
"reqwest 0.13.3",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
@@ -3239,9 +3232,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.150"
|
version = "1.0.149"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"itoa",
|
"itoa",
|
||||||
@@ -3451,9 +3444,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.4"
|
version = "0.6.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -3913,7 +3906,7 @@ dependencies = [
|
|||||||
"parking_lot",
|
"parking_lot",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2 0.6.4",
|
"socket2 0.6.3",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -3973,7 +3966,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"httparse",
|
"httparse",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"ring",
|
"ring",
|
||||||
@@ -3995,7 +3988,7 @@ dependencies = [
|
|||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"h2",
|
"h2",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
@@ -4003,7 +3996,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"socket2 0.6.4",
|
"socket2 0.6.3",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
@@ -4034,14 +4027,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-http"
|
name = "tower-http"
|
||||||
version = "0.6.11"
|
version = "0.6.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"http-body",
|
"http-body",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -4072,7 +4065,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"forwarded-header-value",
|
"forwarded-header-value",
|
||||||
"governor",
|
"governor",
|
||||||
"http 1.4.1",
|
"http 1.4.0",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tonic",
|
"tonic",
|
||||||
@@ -4378,9 +4371,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.122"
|
version = "0.2.121"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
|
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -4391,9 +4384,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-futures"
|
name = "wasm-bindgen-futures"
|
||||||
version = "0.4.72"
|
version = "0.4.71"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
|
checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -4401,9 +4394,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.122"
|
version = "0.2.121"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
|
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -4411,9 +4404,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.122"
|
version = "0.2.121"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
|
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -4424,9 +4417,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.122"
|
version = "0.2.121"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
|
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -4493,9 +4486,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.99"
|
version = "0.3.98"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
|
checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -4667,15 +4660,6 @@ dependencies = [
|
|||||||
"windows-targets 0.52.6",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-sys"
|
|
||||||
version = "0.60.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
|
||||||
dependencies = [
|
|
||||||
"windows-targets 0.53.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -4709,30 +4693,13 @@ dependencies = [
|
|||||||
"windows_aarch64_gnullvm 0.52.6",
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
"windows_aarch64_msvc 0.52.6",
|
"windows_aarch64_msvc 0.52.6",
|
||||||
"windows_i686_gnu 0.52.6",
|
"windows_i686_gnu 0.52.6",
|
||||||
"windows_i686_gnullvm 0.52.6",
|
"windows_i686_gnullvm",
|
||||||
"windows_i686_msvc 0.52.6",
|
"windows_i686_msvc 0.52.6",
|
||||||
"windows_x86_64_gnu 0.52.6",
|
"windows_x86_64_gnu 0.52.6",
|
||||||
"windows_x86_64_gnullvm 0.52.6",
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
"windows_x86_64_msvc 0.52.6",
|
"windows_x86_64_msvc 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-targets"
|
|
||||||
version = "0.53.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
|
||||||
dependencies = [
|
|
||||||
"windows-link",
|
|
||||||
"windows_aarch64_gnullvm 0.53.1",
|
|
||||||
"windows_aarch64_msvc 0.53.1",
|
|
||||||
"windows_i686_gnu 0.53.1",
|
|
||||||
"windows_i686_gnullvm 0.53.1",
|
|
||||||
"windows_i686_msvc 0.53.1",
|
|
||||||
"windows_x86_64_gnu 0.53.1",
|
|
||||||
"windows_x86_64_gnullvm 0.53.1",
|
|
||||||
"windows_x86_64_msvc 0.53.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -4745,12 +4712,6 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_gnullvm"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -4763,12 +4724,6 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_aarch64_msvc"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -4781,24 +4736,12 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnu"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "windows_i686_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_gnullvm"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -4811,12 +4754,6 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_i686_msvc"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -4829,12 +4766,6 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_gnu"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -4847,12 +4778,6 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_gnullvm"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -4865,12 +4790,6 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows_x86_64_msvc"
|
|
||||||
version = "0.53.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
@@ -4986,8 +4905,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
|
||||||
"uuid",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5021,18 +4938,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.49"
|
version = "0.8.48"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b"
|
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.49"
|
version = "0.8.48"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e"
|
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -5065,20 +4982,6 @@ name = "zeroize"
|
|||||||
version = "1.8.2"
|
version = "1.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
dependencies = [
|
|
||||||
"zeroize_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zeroize_derive"
|
|
||||||
version = "1.4.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ FROM rust:slim-bookworm AS builder
|
|||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
# Cache dependency compilation separately from source
|
# Cache dependency compilation separately from source
|
||||||
COPY .cargo/ .cargo/
|
|
||||||
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/auth/Cargo.toml crates/adapters/auth/Cargo.toml
|
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
|
||||||
@@ -52,7 +51,7 @@ WORKDIR /app
|
|||||||
COPY --from=builder /build/target/release/thoughts ./thoughts
|
COPY --from=builder /build/target/release/thoughts ./thoughts
|
||||||
COPY --from=builder /build/target/release/thoughts-worker ./thoughts-worker
|
COPY --from=builder /build/target/release/thoughts-worker ./thoughts-worker
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV RUST_LOG=info
|
ENV RUST_LOG=info
|
||||||
|
|
||||||
|
|||||||
48
Makefile
48
Makefile
@@ -1,48 +0,0 @@
|
|||||||
.DEFAULT_GOAL := check
|
|
||||||
|
|
||||||
# Run the full local check suite — same order as CI would.
|
|
||||||
check: fmt-check clippy test
|
|
||||||
@echo "✅ All checks passed"
|
|
||||||
|
|
||||||
# Apply rustfmt to all files.
|
|
||||||
fmt:
|
|
||||||
cargo fmt
|
|
||||||
|
|
||||||
# Check formatting without modifying files (CI-safe).
|
|
||||||
fmt-check:
|
|
||||||
cargo fmt --check
|
|
||||||
|
|
||||||
# Run Clippy and treat warnings as errors.
|
|
||||||
clippy:
|
|
||||||
cargo clippy -- -D warnings
|
|
||||||
|
|
||||||
# Run the full test suite (requires DATABASE_URL).
|
|
||||||
test:
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
# Unit tests only — no database required.
|
|
||||||
test-unit:
|
|
||||||
cargo test -p domain -p application -p api-types -p activitypub
|
|
||||||
|
|
||||||
# Integration tests only — requires DATABASE_URL.
|
|
||||||
test-integration:
|
|
||||||
cargo test -p postgres -p postgres-federation -p postgres-search -p presentation
|
|
||||||
|
|
||||||
# Apply fmt + clippy auto-fixes in one shot.
|
|
||||||
fix:
|
|
||||||
cargo fmt
|
|
||||||
cargo clippy --fix --allow-dirty --allow-staged
|
|
||||||
|
|
||||||
# Start infra (Postgres + NATS) for local development.
|
|
||||||
dev-infra:
|
|
||||||
docker compose up postgres nats -d
|
|
||||||
|
|
||||||
# Stop infra.
|
|
||||||
dev-infra-down:
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# Full Docker stack.
|
|
||||||
up:
|
|
||||||
docker compose up --build
|
|
||||||
|
|
||||||
.PHONY: check fmt fmt-check clippy test test-unit test-integration fix dev-infra dev-infra-down up
|
|
||||||
85
README.md
85
README.md
@@ -14,14 +14,7 @@ A self-hosted microblogging server with full ActivityPub federation. Write short
|
|||||||
- JWT authentication (Bearer token) with API key support for third-party clients
|
- JWT authentication (Bearer token) with API key support for third-party clients
|
||||||
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
|
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
|
||||||
- Full-text search over thoughts and users via PostgreSQL trigram indexes
|
- Full-text search over thoughts and users via PostgreSQL trigram indexes
|
||||||
- **Profile fields** — up to 4 custom key/value fields (Website, Pronouns, etc.), federated as AP `PropertyValue` attachment
|
- Top friends — pin up to 5 users as highlighted contacts
|
||||||
- **Custom CSS** — per-user stylesheet applied to their profile page
|
|
||||||
- **Visibility levels** — public, followers-only, unlisted, and direct posts
|
|
||||||
- **Content warnings** — optional CW label and sensitive flag on posts
|
|
||||||
- **Feed controls** — sort by newest, oldest, most liked, most boosted, or most discussed; filter to originals only, replies only, local only, or hide sensitive
|
|
||||||
- **Popular tags** — trending hashtag discovery
|
|
||||||
- Top friends — pin up to 8 users as highlighted contacts
|
|
||||||
- Account migration — set `alsoKnownAs` for Fediverse actor moves
|
|
||||||
- Home feed, public feed, and per-user thought timelines
|
- Home feed, public feed, and per-user thought timelines
|
||||||
- Rate limiting and registration control
|
- Rate limiting and registration control
|
||||||
|
|
||||||
@@ -92,11 +85,6 @@ Users can upload avatar and banner images via `PUT /users/me/avatar` and `PUT /u
|
|||||||
- Rust stable (1.80+)
|
- Rust stable (1.80+)
|
||||||
- PostgreSQL 15+
|
- PostgreSQL 15+
|
||||||
- NATS with JetStream (optional — see [Without NATS](#without-nats))
|
- NATS with JetStream (optional — see [Without NATS](#without-nats))
|
||||||
- Docker & Docker Compose (for the easiest local setup)
|
|
||||||
|
|
||||||
### Private cargo registry
|
|
||||||
|
|
||||||
The `k-ap` crate (ActivityPub protocol library) is hosted on a private Gitea registry configured in `.cargo/config.toml`. To build the project you need read access to `git.gabrielkaszewski.dev`. If you're contributing and don't have access, open an issue and I'll sort it out.
|
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
@@ -115,7 +103,7 @@ Copy `.env.example` to `.env` and fill in your values.
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `HOST` | `0.0.0.0` | Interface to bind |
|
| `HOST` | `0.0.0.0` | Interface to bind |
|
||||||
| `PORT` | `8000` | Port to listen on |
|
| `PORT` | `3000` | Port to listen on |
|
||||||
| `NATS_URL` | — | NATS connection string. If unset, a no-op publisher is used and events are not delivered to the worker |
|
| `NATS_URL` | — | NATS connection string. If unset, a no-op publisher is used and events are not delivered to the worker |
|
||||||
| `CORS_ORIGINS` | `*` | Comma-separated allowed origins for CORS, e.g. `https://app.example.com` |
|
| `CORS_ORIGINS` | `*` | Comma-separated allowed origins for CORS, e.g. `https://app.example.com` |
|
||||||
| `RATE_LIMIT` | disabled | Max requests per minute per IP |
|
| `RATE_LIMIT` | disabled | Max requests per minute per IP |
|
||||||
@@ -133,42 +121,8 @@ Copy `.env.example` to `.env` and fill in your values.
|
|||||||
| `UPLOAD_MAX_BYTES` | `5242880` | Max upload size in bytes (default 5 MiB) |
|
| `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 |
|
| `UPLOAD_ALLOWED_TYPES` | `image/jpeg,image/png,image/gif,image/webp,image/avif` | Comma-separated allowed MIME types |
|
||||||
|
|
||||||
### Frontend environment
|
|
||||||
|
|
||||||
Copy `thoughts-frontend/.env.example` to `thoughts-frontend/.env.local` and adjust:
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|---|---|
|
|
||||||
| `NEXT_PUBLIC_API_URL` | API URL for client-side (browser) requests, e.g. `http://localhost:8000` |
|
|
||||||
| `NEXT_PUBLIC_SERVER_SIDE_API_URL` | API URL for SSR requests — same as above locally, or `http://api:8000` inside Docker |
|
|
||||||
| `NEXT_PUBLIC_FEDIVERSE_DOMAIN` | (Optional) Domain shown on profile fediverse handles, e.g. `yourinstance.example.com` |
|
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
### Local development (recommended)
|
|
||||||
|
|
||||||
Start only the infrastructure containers (Postgres + NATS), then run the Rust backend and Next.js frontend natively for fast iteration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Start Postgres + NATS
|
|
||||||
make dev-infra
|
|
||||||
|
|
||||||
# 2. Copy and fill in env files
|
|
||||||
cp .env.example .env
|
|
||||||
cp thoughts-frontend/.env.example thoughts-frontend/.env.local
|
|
||||||
|
|
||||||
# 3. API server (runs migrations automatically on startup)
|
|
||||||
cargo run -p bootstrap
|
|
||||||
|
|
||||||
# 4. Event worker (separate terminal, optional)
|
|
||||||
cargo run -p worker
|
|
||||||
|
|
||||||
# 5. Frontend (separate terminal)
|
|
||||||
cd thoughts-frontend && bun install && bun dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bare metal
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# API server (runs migrations automatically on startup)
|
# API server (runs migrations automatically on startup)
|
||||||
cargo run -p bootstrap
|
cargo run -p bootstrap
|
||||||
@@ -182,20 +136,14 @@ Both processes share the same PostgreSQL database. The worker is optional but re
|
|||||||
## Test
|
## Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Unit tests only — no database required
|
# Unit tests — no database required
|
||||||
make test-unit
|
cargo test -p application
|
||||||
|
|
||||||
# Integration tests — requires DATABASE_URL pointing to a running PostgreSQL
|
# Full workspace (requires DATABASE_URL pointing to a running PostgreSQL)
|
||||||
make test-integration
|
cargo test --workspace
|
||||||
|
|
||||||
# Everything (unit + integration)
|
|
||||||
make test
|
|
||||||
|
|
||||||
# Full check suite: fmt + clippy + tests
|
|
||||||
make check
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`make test-unit` runs domain, application, api-types, and activitypub tests using in-memory fakes — the fastest feedback loop for business logic. `make test-integration` runs the adapter crates against a live PostgreSQL.
|
The `application` crate contains unit tests for all event services and use cases backed by in-memory fakes from `domain`'s `test-helpers` feature. These are the fastest feedback loop for business logic.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -208,7 +156,18 @@ Interactive API documentation is available at runtime:
|
|||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
The Next.js frontend lives in `thoughts-frontend/`. See [Frontend environment](#frontend-environment) for required env vars, or follow the [local development](#local-development-recommended) steps above.
|
The Next.js frontend lives in `thoughts-frontend/`. It requires two environment variables:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8000 # client-side requests
|
||||||
|
NEXT_PUBLIC_SERVER_SIDE_API_URL=http://localhost:8000 # SSR requests
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd thoughts-frontend
|
||||||
|
bun install
|
||||||
|
bun run dev # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
@@ -244,12 +203,12 @@ docker build -t thoughts-frontend \
|
|||||||
docker run -p 3000:3000 thoughts-frontend
|
docker run -p 3000:3000 thoughts-frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
### Full Docker stack
|
### Local development stack
|
||||||
|
|
||||||
`compose.yml` spins up the full stack: PostgreSQL, NATS (with JetStream and monitoring on port 8222), the API server, the event worker, and the frontend.
|
`compose.yml` spins up the full stack: PostgreSQL, NATS (with JetStream and monitoring on port 8222), the API server, the event worker, and the frontend.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make up # or: docker compose up --build
|
docker compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
Services:
|
Services:
|
||||||
@@ -266,7 +225,7 @@ Services:
|
|||||||
|
|
||||||
Contributions are welcome. A few guidelines:
|
Contributions are welcome. A few guidelines:
|
||||||
|
|
||||||
- **Run tests before opening a PR.** At minimum: `make test-unit` (no database needed). For adapter changes: `make test-integration` with a live database. `make check` runs the full suite (fmt + clippy + tests).
|
- **Run tests before opening a PR.** At minimum: `cargo test -p application` (no database needed). For adapter changes: `cargo test --workspace` with a live database.
|
||||||
- **Keep the hexagonal boundary.** `domain` and `application` must not import any adapter crate. Use `&dyn Port` traits for all I/O.
|
- **Keep the hexagonal boundary.** `domain` and `application` must not import any adapter crate. Use `&dyn Port` traits for all I/O.
|
||||||
- **No ORM.** The project uses raw `sqlx`. Keep it that way.
|
- **No ORM.** The project uses raw `sqlx`. Keep it that way.
|
||||||
- **ActivityPub changes** — test against a live Mastodon instance if possible, or use the AP debug logs (`RUST_ENV=development`).
|
- **ActivityPub changes** — test against a live Mastodon instance if possible, or use the AP debug logs (`RUST_ENV=development`).
|
||||||
|
|||||||
@@ -47,18 +47,11 @@ 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
|
||||||
@@ -84,7 +77,6 @@ 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:
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ services:
|
|||||||
DATABASE_URL: postgres://postgres:postgres@postgres:5432/thoughts
|
DATABASE_URL: postgres://postgres:postgres@postgres:5432/thoughts
|
||||||
JWT_SECRET: change-me-in-production
|
JWT_SECRET: change-me-in-production
|
||||||
BASE_URL: http://localhost:8000
|
BASE_URL: http://localhost:8000
|
||||||
PORT: 8000
|
|
||||||
NATS_URL: nats://nats:4222
|
NATS_URL: nats://nats:4222
|
||||||
RUST_LOG: info
|
RUST_LOG: info
|
||||||
STORAGE_BACKEND: local
|
STORAGE_BACKEND: local
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-ap = { version = "0.4.0", registry = "gitea" }
|
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.3" }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
@@ -14,6 +14,7 @@ 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 }
|
reqwest = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -10,17 +10,45 @@ use url::Url;
|
|||||||
use crate::note::{ThoughtNote, ThoughtNoteInput};
|
use crate::note::{ThoughtNote, ThoughtNoteInput};
|
||||||
use crate::port::{AcceptNoteInput, ActivityPubRepository};
|
use crate::port::{AcceptNoteInput, ActivityPubRepository};
|
||||||
use crate::urls::ThoughtsUrls;
|
use crate::urls::ThoughtsUrls;
|
||||||
use domain::ports::{BoostRepository, EventPublisher, LikeRepository, TagRepository};
|
use domain::ports::{EventPublisher, TagRepository};
|
||||||
use domain::value_objects::UserId;
|
use domain::value_objects::UserId;
|
||||||
use k_ap::{ApContentReader, ApObjectHandler};
|
use k_ap::ApObjectHandler;
|
||||||
|
|
||||||
|
fn extract_note_extensions(obj: &serde_json::Value) -> Option<serde_json::Value> {
|
||||||
|
const STANDARD: &[&str] = &[
|
||||||
|
"type",
|
||||||
|
"id",
|
||||||
|
"attributedTo",
|
||||||
|
"content",
|
||||||
|
"published",
|
||||||
|
"to",
|
||||||
|
"cc",
|
||||||
|
"inReplyTo",
|
||||||
|
"sensitive",
|
||||||
|
"summary",
|
||||||
|
"tag",
|
||||||
|
"url",
|
||||||
|
"@context",
|
||||||
|
"mediaType",
|
||||||
|
];
|
||||||
|
let extensions: serde_json::Map<String, serde_json::Value> = obj
|
||||||
|
.as_object()?
|
||||||
|
.iter()
|
||||||
|
.filter(|(k, _)| !STANDARD.contains(&k.as_str()))
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect();
|
||||||
|
if extensions.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(serde_json::Value::Object(extensions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ThoughtsObjectHandler {
|
pub struct ThoughtsObjectHandler {
|
||||||
repo: Arc<dyn ActivityPubRepository>,
|
repo: Arc<dyn ActivityPubRepository>,
|
||||||
urls: ThoughtsUrls,
|
urls: ThoughtsUrls,
|
||||||
event_publisher: Option<Arc<dyn EventPublisher>>,
|
event_publisher: Option<Arc<dyn EventPublisher>>,
|
||||||
tag_repo: Arc<dyn TagRepository>,
|
tag_repo: Arc<dyn TagRepository>,
|
||||||
likes: Arc<dyn LikeRepository>,
|
|
||||||
boosts: Arc<dyn BoostRepository>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ThoughtsObjectHandler {
|
impl ThoughtsObjectHandler {
|
||||||
@@ -29,24 +57,53 @@ impl ThoughtsObjectHandler {
|
|||||||
base_url: &str,
|
base_url: &str,
|
||||||
event_publisher: Option<Arc<dyn EventPublisher>>,
|
event_publisher: Option<Arc<dyn EventPublisher>>,
|
||||||
tag_repo: Arc<dyn TagRepository>,
|
tag_repo: Arc<dyn TagRepository>,
|
||||||
likes: Arc<dyn LikeRepository>,
|
|
||||||
boosts: Arc<dyn BoostRepository>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
repo,
|
repo,
|
||||||
urls: ThoughtsUrls::new(base_url),
|
urls: ThoughtsUrls::new(base_url),
|
||||||
event_publisher,
|
event_publisher,
|
||||||
tag_repo,
|
tag_repo,
|
||||||
likes,
|
|
||||||
boosts,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ApContentReader ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ApContentReader for ThoughtsObjectHandler {
|
impl ApObjectHandler for ThoughtsObjectHandler {
|
||||||
|
async fn get_local_objects_for_user(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<(Url, serde_json::Value)>> {
|
||||||
|
let uid = UserId::from_uuid(user_id);
|
||||||
|
let entries = self
|
||||||
|
.repo
|
||||||
|
.outbox_entries_for_actor(&uid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| {
|
||||||
|
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 followers = self.urls.user_followers(e.author_username.as_str());
|
||||||
|
let in_reply_to = e
|
||||||
|
.thought
|
||||||
|
.in_reply_to_id
|
||||||
|
.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||||
|
let note = ThoughtNote::new_public(ThoughtNoteInput {
|
||||||
|
id: note_url.clone(),
|
||||||
|
actor_url,
|
||||||
|
content: e.thought.content.as_str().to_owned(),
|
||||||
|
published: e.thought.created_at,
|
||||||
|
in_reply_to,
|
||||||
|
sensitive: e.thought.sensitive,
|
||||||
|
summary: e.thought.content_warning,
|
||||||
|
followers_url: followers,
|
||||||
|
});
|
||||||
|
Ok((note_url, serde_json::to_value(¬e)?))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_local_objects_page(
|
async fn get_local_objects_page(
|
||||||
&self,
|
&self,
|
||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
@@ -64,8 +121,8 @@ impl ApContentReader 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(&user_id.to_string());
|
let actor_url = self.urls.user_url(e.author_username.as_str());
|
||||||
let followers = self.urls.user_followers(&user_id.to_string());
|
let followers = self.urls.user_followers(e.author_username.as_str());
|
||||||
let in_reply_to = e
|
let in_reply_to = e
|
||||||
.thought
|
.thought
|
||||||
.in_reply_to_id
|
.in_reply_to_id
|
||||||
@@ -85,38 +142,21 @@ impl ApContentReader for ThoughtsObjectHandler {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn count_local_posts(&self) -> Result<u64> {
|
|
||||||
self.repo
|
|
||||||
.count_local_notes()
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow!("{e}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ApObjectHandler ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ApObjectHandler for ThoughtsObjectHandler {
|
|
||||||
async fn on_create(
|
async fn on_create(
|
||||||
&self,
|
&self,
|
||||||
ap_id: &Url,
|
ap_id: &Url,
|
||||||
actor_url: &Url,
|
actor_url: &Url,
|
||||||
object: serde_json::Value,
|
object: serde_json::Value,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let Some((note, note_extensions)) = ThoughtNote::try_from_ap(object) else {
|
let note_extensions = extract_note_extensions(&object);
|
||||||
tracing::debug!(ap_id = %ap_id, "on_create: skipping non-Note object");
|
let note: ThoughtNote = serde_json::from_value(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())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
let _ = self
|
|
||||||
.repo
|
|
||||||
.sync_remote_actor_to_user(actor_url.as_str())
|
|
||||||
.await;
|
|
||||||
|
|
||||||
|
// Derive visibility from AP addressing conventions.
|
||||||
let as_public = "https://www.w3.org/ns/activitystreams#Public";
|
let as_public = "https://www.w3.org/ns/activitystreams#Public";
|
||||||
let in_to = note.to.iter().any(|s| s == as_public);
|
let in_to = note.to.iter().any(|s| s == as_public);
|
||||||
let in_cc = note.cc.iter().any(|s| s == as_public);
|
let in_cc = note.cc.iter().any(|s| s == as_public);
|
||||||
@@ -149,6 +189,7 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
// Extract and index hashtags from the AP tag array.
|
||||||
let hashtag_names: Vec<String> = note
|
let hashtag_names: Vec<String> = note
|
||||||
.tag
|
.tag
|
||||||
.iter()
|
.iter()
|
||||||
@@ -164,6 +205,7 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fire mention notifications for local @mentions in the note's tag array.
|
||||||
let base_url = url::Url::parse(&self.urls.base_url)
|
let base_url = url::Url::parse(&self.urls.base_url)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|u| u.host_str().map(|h| h.to_string()))
|
.and_then(|u| u.host_str().map(|h| h.to_string()))
|
||||||
@@ -204,51 +246,15 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
async fn on_update(
|
async fn on_update(
|
||||||
&self,
|
&self,
|
||||||
ap_id: &Url,
|
ap_id: &Url,
|
||||||
actor_url: &Url,
|
_actor_url: &Url,
|
||||||
object: serde_json::Value,
|
object: serde_json::Value,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let obj_type = object.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||||
match obj_type {
|
|
||||||
"Note" | "Article" | "Page" => {
|
|
||||||
let Some((note, note_extensions)) = ThoughtNote::try_from_ap(object) else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
self.repo
|
self.repo
|
||||||
.apply_note_update(ap_id.as_str(), ¬e.content, note_extensions)
|
.apply_note_update(ap_id.as_str(), ¬e.content)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))
|
.map_err(|e| anyhow!("{e}"))
|
||||||
}
|
}
|
||||||
"Person" | "Service" | "Application" | "Group" | "Organization" => {
|
|
||||||
let display_name = object.get("name").and_then(|v| v.as_str());
|
|
||||||
let avatar_url = object
|
|
||||||
.get("icon")
|
|
||||||
.and_then(|v| v.get("url"))
|
|
||||||
.and_then(|v| v.as_str());
|
|
||||||
self.repo
|
|
||||||
.update_remote_actor_display(
|
|
||||||
&self
|
|
||||||
.repo
|
|
||||||
.find_remote_actor_id(actor_url.as_str())
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow!("{e}"))?
|
|
||||||
.ok_or_else(|| anyhow!("unknown actor"))?,
|
|
||||||
display_name,
|
|
||||||
avatar_url,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
|
||||||
let _ = self
|
|
||||||
.repo
|
|
||||||
.sync_remote_actor_to_user(actor_url.as_str())
|
|
||||||
.await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
tracing::debug!(ap_id = %ap_id, obj_type, "on_update: skipping");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
|
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
|
||||||
self.repo
|
self.repo
|
||||||
@@ -288,24 +294,14 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
let actor_user_id = match actor_user_id {
|
let actor_user_id = match actor_user_id {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => {
|
None => {
|
||||||
tracing::debug!(actor = %actor_url, "on_like: remote actor not interned, skipping");
|
tracing::debug!(actor = %actor_url, "on_like: remote actor not interned, skipping notification");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some(ep) = &self.event_publisher {
|
||||||
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
||||||
let like_id = domain::value_objects::LikeId::new();
|
let like_id = domain::value_objects::LikeId::new();
|
||||||
|
|
||||||
let like = domain::models::social::Like {
|
|
||||||
id: like_id.clone(),
|
|
||||||
user_id: actor_user_id.clone(),
|
|
||||||
thought_id: thought_id.clone(),
|
|
||||||
ap_id: Some(object_url.to_string()),
|
|
||||||
created_at: Utc::now(),
|
|
||||||
};
|
|
||||||
let _ = self.likes.save(&like).await;
|
|
||||||
|
|
||||||
if let Some(ep) = &self.event_publisher {
|
|
||||||
ep.publish(&domain::events::DomainEvent::LikeAdded {
|
ep.publish(&domain::events::DomainEvent::LikeAdded {
|
||||||
like_id,
|
like_id,
|
||||||
user_id: actor_user_id,
|
user_id: actor_user_id,
|
||||||
@@ -347,13 +343,10 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
|
||||||
let _ = self.likes.delete(&actor_user_id, &thought_id).await;
|
|
||||||
|
|
||||||
if let Some(ep) = &self.event_publisher {
|
if let Some(ep) = &self.event_publisher {
|
||||||
ep.publish(&domain::events::DomainEvent::LikeRemoved {
|
ep.publish(&domain::events::DomainEvent::LikeRemoved {
|
||||||
user_id: actor_user_id,
|
user_id: actor_user_id,
|
||||||
thought_id,
|
thought_id: domain::value_objects::ThoughtId::from_uuid(thought_uuid),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
@@ -425,19 +418,9 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some(ep) = &self.event_publisher {
|
||||||
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
||||||
let boost_id = domain::value_objects::BoostId::new();
|
let boost_id = domain::value_objects::BoostId::new();
|
||||||
|
|
||||||
let boost = domain::models::social::Boost {
|
|
||||||
id: boost_id.clone(),
|
|
||||||
user_id: actor_user_id.clone(),
|
|
||||||
thought_id: thought_id.clone(),
|
|
||||||
ap_id: Some(object_url.to_string()),
|
|
||||||
created_at: Utc::now(),
|
|
||||||
};
|
|
||||||
let _ = self.boosts.save(&boost).await;
|
|
||||||
|
|
||||||
if let Some(ep) = &self.event_publisher {
|
|
||||||
ep.publish(&domain::events::DomainEvent::BoostAdded {
|
ep.publish(&domain::events::DomainEvent::BoostAdded {
|
||||||
boost_id,
|
boost_id,
|
||||||
user_id: actor_user_id,
|
user_id: actor_user_id,
|
||||||
@@ -450,45 +433,53 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_announce_removed(&self, object_url: &Url, actor_url: &Url) -> Result<()> {
|
async fn count_local_posts(&self) -> Result<u64> {
|
||||||
let thought_uuid = object_url
|
self.repo
|
||||||
.path()
|
.count_local_notes()
|
||||||
.strip_prefix(THOUGHTS_PATH_PREFIX)
|
|
||||||
.and_then(|s| s.split('/').next())
|
|
||||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
|
||||||
|
|
||||||
let thought_uuid = match thought_uuid {
|
|
||||||
Some(u) => u,
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let actor_user_id = self
|
|
||||||
.repo
|
|
||||||
.find_remote_actor_id(actor_url.as_str())
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))
|
||||||
|
}
|
||||||
let actor_user_id = match actor_user_id {
|
}
|
||||||
Some(id) => id,
|
|
||||||
None => return Ok(()),
|
#[cfg(test)]
|
||||||
};
|
mod extract_tests {
|
||||||
|
use super::extract_note_extensions;
|
||||||
let thought_id = domain::value_objects::ThoughtId::from_uuid(thought_uuid);
|
|
||||||
let _ = self.boosts.delete(&actor_user_id, &thought_id).await;
|
#[test]
|
||||||
|
fn extracts_non_standard_fields() {
|
||||||
if let Some(ep) = &self.event_publisher {
|
let obj = serde_json::json!({
|
||||||
ep.publish(&domain::events::DomainEvent::BoostRemoved {
|
"type": "Note",
|
||||||
user_id: actor_user_id,
|
"id": "https://example.com/notes/1",
|
||||||
thought_id,
|
"content": "hello",
|
||||||
})
|
"published": "2025-01-01T00:00:00Z",
|
||||||
.await
|
"movieTitle": "Dune",
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
"rating": 5,
|
||||||
}
|
"posterUrl": "https://example.com/poster.jpg"
|
||||||
|
});
|
||||||
Ok(())
|
let ext = extract_note_extensions(&obj).unwrap();
|
||||||
}
|
assert_eq!(ext["movieTitle"], "Dune");
|
||||||
|
assert_eq!(ext["rating"], 5);
|
||||||
async fn on_announce_of_remote(&self, _object_url: &Url, _actor_url: &Url) -> Result<()> {
|
assert_eq!(ext["posterUrl"], "https://example.com/poster.jpg");
|
||||||
Ok(())
|
assert!(ext.get("type").is_none());
|
||||||
|
assert!(ext.get("content").is_none());
|
||||||
|
assert!(ext.get("id").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn 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_note_extensions(&obj).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_none_for_non_object() {
|
||||||
|
let obj = serde_json::json!("not an object");
|
||||||
|
assert!(extract_note_extensions(&obj).is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ pub mod port;
|
|||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod urls;
|
pub mod urls;
|
||||||
|
|
||||||
pub const INSTANCE_ACTOR_ID: uuid::Uuid =
|
|
||||||
uuid::Uuid::from_bytes([0, 0, 0, 0, 0, 0, 0x40, 0, 0x80, 0, 0, 0, 0, 0, 0, 0]);
|
|
||||||
|
|
||||||
pub use handler::ThoughtsObjectHandler;
|
pub use handler::ThoughtsObjectHandler;
|
||||||
pub use note::ThoughtNote;
|
pub use note::ThoughtNote;
|
||||||
pub use port::{
|
pub use port::{
|
||||||
@@ -14,49 +11,3 @@ pub use port::{
|
|||||||
};
|
};
|
||||||
pub use service::ApFederationAdapter;
|
pub use service::ApFederationAdapter;
|
||||||
pub use urls::ThoughtsUrls;
|
pub use urls::ThoughtsUrls;
|
||||||
|
|
||||||
use domain::ports::RemoteActorConnectionRepository;
|
|
||||||
use k_ap::ActivityPubService;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
pub struct ApServiceConfig {
|
|
||||||
pub base_url: String,
|
|
||||||
pub activity_repo: Arc<dyn k_ap::ActivityRepository>,
|
|
||||||
pub follow_repo: Arc<dyn k_ap::FollowRepository>,
|
|
||||||
pub actor_repo: Arc<dyn k_ap::ActorRepository>,
|
|
||||||
pub blocklist_repo: Arc<dyn k_ap::BlocklistRepository>,
|
|
||||||
pub user_repo: Arc<dyn k_ap::ApUserRepository>,
|
|
||||||
pub ap_handler: Arc<ThoughtsObjectHandler>,
|
|
||||||
pub connections_repo: Arc<dyn RemoteActorConnectionRepository>,
|
|
||||||
pub event_publisher: Option<Arc<dyn k_ap::data::EventPublisher>>,
|
|
||||||
pub allow_registration: bool,
|
|
||||||
pub debug: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn build_ap_service(
|
|
||||||
cfg: ApServiceConfig,
|
|
||||||
) -> (Arc<ActivityPubService>, Arc<ApFederationAdapter>) {
|
|
||||||
let mut builder = ActivityPubService::builder(cfg.base_url)
|
|
||||||
.activity_repo(cfg.activity_repo)
|
|
||||||
.follow_repo(cfg.follow_repo)
|
|
||||||
.actor_repo(cfg.actor_repo)
|
|
||||||
.blocklist_repo(cfg.blocklist_repo)
|
|
||||||
.user_repo(cfg.user_repo)
|
|
||||||
.content_reader(cfg.ap_handler.clone())
|
|
||||||
.object_handler(cfg.ap_handler)
|
|
||||||
.allow_registration(cfg.allow_registration)
|
|
||||||
.software_name("thoughts")
|
|
||||||
.debug(cfg.debug)
|
|
||||||
.signed_fetch_actor_id(INSTANCE_ACTOR_ID);
|
|
||||||
if let Some(publisher) = cfg.event_publisher {
|
|
||||||
builder = builder.event_publisher(publisher);
|
|
||||||
}
|
|
||||||
let raw = Arc::new(
|
|
||||||
builder
|
|
||||||
.build()
|
|
||||||
.await
|
|
||||||
.expect("Failed to build ActivityPubService"),
|
|
||||||
);
|
|
||||||
let adapter = Arc::new(ApFederationAdapter::new(raw.clone(), cfg.connections_repo));
|
|
||||||
(raw, adapter)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,37 +4,6 @@ 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")]
|
||||||
@@ -73,21 +42,6 @@ pub struct ThoughtNoteInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ThoughtNote {
|
impl ThoughtNote {
|
||||||
/// Returns `(note, extensions)` if `value` is a Note object, `None` otherwise.
|
|
||||||
pub fn try_from_ap(mut value: serde_json::Value) -> Option<(Self, Option<serde_json::Value>)> {
|
|
||||||
let obj_type = value.get("type").and_then(|v| v.as_str());
|
|
||||||
if !matches!(obj_type, Some("Note" | "Article" | "Page")) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let extensions = extract_extensions(&value);
|
|
||||||
if let Some(obj) = value.as_object_mut() {
|
|
||||||
obj.insert("type".to_string(), serde_json::json!("Note"));
|
|
||||||
}
|
|
||||||
serde_json::from_value(value)
|
|
||||||
.ok()
|
|
||||||
.map(|note| (note, extensions))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_public(p: ThoughtNoteInput) -> Self {
|
pub fn new_public(p: ThoughtNoteInput) -> Self {
|
||||||
Self {
|
Self {
|
||||||
kind: Default::default(),
|
kind: Default::default(),
|
||||||
|
|||||||
@@ -1,55 +1,5 @@
|
|||||||
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(super::ThoughtNoteInput {
|
let note = ThoughtNote::new_public(super::ThoughtNoteInput {
|
||||||
|
|||||||
@@ -1,5 +1,169 @@
|
|||||||
pub use domain::ports::{
|
use async_trait::async_trait;
|
||||||
AcceptNoteInput, ActorFederationUrls as ActorApUrls,
|
use domain::{
|
||||||
FederationBroadcastPort as OutboundFederationPort,
|
errors::DomainError,
|
||||||
FederationContentRepository as ActivityPubRepository, OutboxEntry,
|
models::thought::Thought,
|
||||||
|
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).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ActorApUrls {
|
||||||
|
pub ap_id: String,
|
||||||
|
pub inbox_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A local thought ready for AP serialization, with the author's username
|
||||||
|
/// pre-joined so the handler can build AP URLs without a second query.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OutboxEntry {
|
||||||
|
pub thought: Thought,
|
||||||
|
pub author_username: Username,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ActivityPubRepository: Send + Sync {
|
||||||
|
// ── Outbox (local → remote) ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// All public local thoughts for this actor. Used for outbox totals
|
||||||
|
/// and full-collection delivery.
|
||||||
|
async fn outbox_entries_for_actor(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Vec<OutboxEntry>, DomainError>;
|
||||||
|
|
||||||
|
/// Cursor page of public local thoughts, newest-first, before `before`.
|
||||||
|
/// Used for OrderedCollectionPage responses.
|
||||||
|
async fn outbox_page_for_actor(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<OutboxEntry>, DomainError>;
|
||||||
|
|
||||||
|
// ── Remote actor resolution ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// Find the local UserId for a remote actor by its AP URL.
|
||||||
|
async fn find_remote_actor_id(&self, actor_ap_url: &str)
|
||||||
|
-> Result<Option<UserId>, DomainError>;
|
||||||
|
|
||||||
|
/// Ensure a remote actor placeholder exists; create one if absent.
|
||||||
|
/// Idempotent — safe to call multiple times with the same URL.
|
||||||
|
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError>;
|
||||||
|
|
||||||
|
/// Update display_name and avatar_url for an already-interned remote actor.
|
||||||
|
async fn update_remote_actor_display(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
display_name: Option<&str>,
|
||||||
|
avatar_url: Option<&str>,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
// ── Inbox processing (remote → local) ───────────────────────────
|
||||||
|
|
||||||
|
/// Persist an incoming remote Note. Idempotent on ap_id.
|
||||||
|
|
||||||
|
async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError>;
|
||||||
|
|
||||||
|
/// Apply an Update to a previously accepted remote Note.
|
||||||
|
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Remove a specific remote Note (Delete activity). Only touches
|
||||||
|
/// remotely-originated thoughts.
|
||||||
|
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Remove all Notes from a remote actor (actor-level Delete/Tombstone).
|
||||||
|
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
// ── Node-level stats ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Total locally-authored thought count for NodeInfo responses.
|
||||||
|
async fn count_local_notes(&self) -> Result<u64, DomainError>;
|
||||||
|
|
||||||
|
/// Return the ActivityPub object URL for a thought, if one is stored.
|
||||||
|
/// Returns None for local thoughts (caller constructs URL from base_url + thought_id).
|
||||||
|
async fn get_thought_ap_id(
|
||||||
|
&self,
|
||||||
|
thought_id: &ThoughtId,
|
||||||
|
) -> Result<Option<String>, DomainError>;
|
||||||
|
|
||||||
|
/// Return the AP actor URL and inbox URL for a user, if stored.
|
||||||
|
/// Returns None for users that have not been federated.
|
||||||
|
async fn get_actor_ap_urls(&self, user_id: &UserId)
|
||||||
|
-> Result<Option<ActorApUrls>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait OutboundFederationPort: Send + Sync {
|
||||||
|
/// Fan out a new local Note to all accepted followers.
|
||||||
|
async fn broadcast_create(
|
||||||
|
&self,
|
||||||
|
author_user_id: &UserId,
|
||||||
|
thought: &Thought,
|
||||||
|
author_username: &str,
|
||||||
|
in_reply_to_url: Option<&str>,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Fan out a Delete tombstone for a now-deleted local Note.
|
||||||
|
/// `thought_ap_id` is pre-constructed by the caller because the thought
|
||||||
|
/// has already been deleted from the DB when this fires.
|
||||||
|
async fn broadcast_delete(
|
||||||
|
&self,
|
||||||
|
author_user_id: &UserId,
|
||||||
|
thought_ap_id: &str,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Fan out an Update(Note) for an edited local thought.
|
||||||
|
async fn broadcast_update(
|
||||||
|
&self,
|
||||||
|
author_user_id: &UserId,
|
||||||
|
thought: &Thought,
|
||||||
|
author_username: &str,
|
||||||
|
in_reply_to_url: Option<&str>,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Fan out an Announce(object_ap_id) for a boost.
|
||||||
|
async fn broadcast_announce(
|
||||||
|
&self,
|
||||||
|
booster_user_id: &UserId,
|
||||||
|
object_ap_id: &str,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Fan out an Undo(Announce) to followers when a boost is removed.
|
||||||
|
async fn broadcast_undo_announce(
|
||||||
|
&self,
|
||||||
|
booster_user_id: &UserId,
|
||||||
|
object_ap_id: &str,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Send a Like activity to a remote thought author's inbox.
|
||||||
|
/// Only called when a LOCAL user likes a REMOTE thought (one with an ap_id).
|
||||||
|
async fn broadcast_like(
|
||||||
|
&self,
|
||||||
|
liker_user_id: &UserId,
|
||||||
|
object_ap_id: &str,
|
||||||
|
author_inbox_url: &str,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Send Undo(Like) to a remote thought author's inbox.
|
||||||
|
async fn broadcast_undo_like(
|
||||||
|
&self,
|
||||||
|
liker_user_id: &UserId,
|
||||||
|
object_ap_id: &str,
|
||||||
|
author_inbox_url: &str,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Fan out an Update(Actor) to all accepted followers after a profile change.
|
||||||
|
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ fn content_to_html(text: &str) -> String {
|
|||||||
.replace('&', "&")
|
.replace('&', "&")
|
||||||
.replace('<', "<")
|
.replace('<', "<")
|
||||||
.replace('>', ">")
|
.replace('>', ">")
|
||||||
.replace('"', """)
|
.replace('"', """);
|
||||||
.replace('\'', "'");
|
|
||||||
let paragraphs: Vec<&str> = escaped.split('\n').filter(|s| !s.is_empty()).collect();
|
let paragraphs: Vec<&str> = escaped.split('\n').filter(|s| !s.is_empty()).collect();
|
||||||
if paragraphs.is_empty() {
|
if paragraphs.is_empty() {
|
||||||
format!("<p>{}</p>", escaped)
|
format!("<p>{}</p>", escaped)
|
||||||
@@ -95,28 +94,9 @@ fn build_note_json(
|
|||||||
.collect();
|
.collect();
|
||||||
note["tag"] = serde_json::json!(ap_tags);
|
note["tag"] = serde_json::json!(ap_tags);
|
||||||
}
|
}
|
||||||
if let Some(ref mood) = thought.mood {
|
|
||||||
note["mood"] = serde_json::json!(mood);
|
|
||||||
}
|
|
||||||
if let Some(ref ext) = thought.note_extensions {
|
|
||||||
if let Some(obj) = ext.as_object() {
|
|
||||||
for (k, v) in obj {
|
|
||||||
note.as_object_mut().unwrap().entry(k).or_insert(v.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
note
|
note
|
||||||
}
|
}
|
||||||
|
|
||||||
fn thought_to_ap_visibility(v: &domain::models::thought::Visibility) -> k_ap::ApVisibility {
|
|
||||||
match v {
|
|
||||||
domain::models::thought::Visibility::Public => k_ap::ApVisibility::Public,
|
|
||||||
domain::models::thought::Visibility::Unlisted => k_ap::ApVisibility::Public,
|
|
||||||
domain::models::thought::Visibility::Followers => k_ap::ApVisibility::FollowersOnly,
|
|
||||||
domain::models::thought::Visibility::Direct => k_ap::ApVisibility::Private,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor {
|
fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor {
|
||||||
DomainRemoteActor {
|
DomainRemoteActor {
|
||||||
url: a.url,
|
url: a.url,
|
||||||
@@ -124,14 +104,12 @@ fn k_ap_actor_to_domain(a: k_ap::RemoteActor) -> DomainRemoteActor {
|
|||||||
display_name: a.display_name,
|
display_name: a.display_name,
|
||||||
avatar_url: a.avatar_url,
|
avatar_url: a.avatar_url,
|
||||||
outbox_url: a.outbox_url,
|
outbox_url: a.outbox_url,
|
||||||
last_fetched_at: a.fetched_at.unwrap_or_else(chrono::Utc::now),
|
last_fetched_at: chrono::Utc::now(),
|
||||||
bio: a.bio,
|
bio: None,
|
||||||
banner_url: a.banner_url,
|
banner_url: None,
|
||||||
also_known_as: a.also_known_as,
|
also_known_as: None,
|
||||||
followers_url: a.followers_url,
|
followers_url: None,
|
||||||
following_url: a.following_url,
|
following_url: None,
|
||||||
inbox_url: Some(a.inbox_url),
|
|
||||||
shared_inbox_url: a.shared_inbox_url,
|
|
||||||
attachment: vec![],
|
attachment: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,9 +192,7 @@ async fn webfinger_resolve_actor_url(handle: &str) -> anyhow::Result<String> {
|
|||||||
.and_then(|links| {
|
.and_then(|links| {
|
||||||
links.iter().find(|l| {
|
links.iter().find(|l| {
|
||||||
l["rel"].as_str() == Some("self")
|
l["rel"].as_str() == Some("self")
|
||||||
&& l["type"].as_str().is_some_and(|t| {
|
&& l["type"].as_str() == Some("application/activity+json")
|
||||||
t == "application/activity+json" || t.starts_with("application/ld+json")
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.and_then(|l| l["href"].as_str())
|
.and_then(|l| l["href"].as_str())
|
||||||
@@ -288,12 +264,7 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
|
|||||||
in_reply_to_url,
|
in_reply_to_url,
|
||||||
);
|
);
|
||||||
self.inner
|
self.inner
|
||||||
.broadcast_create_note(
|
.broadcast_create_note(user_uuid, note)
|
||||||
user_uuid,
|
|
||||||
note,
|
|
||||||
thought_to_ap_visibility(&thought.visibility),
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
@@ -329,12 +300,7 @@ impl crate::port::OutboundFederationPort for ApFederationAdapter {
|
|||||||
in_reply_to_url,
|
in_reply_to_url,
|
||||||
);
|
);
|
||||||
self.inner
|
self.inner
|
||||||
.broadcast_update_note(
|
.broadcast_update_note(user_uuid, note)
|
||||||
user_uuid,
|
|
||||||
note,
|
|
||||||
thought_to_ap_visibility(&thought.visibility),
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
@@ -418,7 +384,7 @@ impl FederationSchedulerPort for ApFederationAdapter {
|
|||||||
let actor = actor_ap_url.to_string();
|
let actor = actor_ap_url.to_string();
|
||||||
let outbox = outbox_url.to_string();
|
let outbox = outbox_url.to_string();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = service.import_remote_outbox(&outbox, &actor).await {
|
if let Err(e) = service.backfill_outbox(&outbox, &actor).await {
|
||||||
tracing::warn!(actor = %actor, error = %e, "posts backfill failed");
|
tracing::warn!(actor = %actor, error = %e, "posts backfill failed");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -430,8 +396,11 @@ impl FederationSchedulerPort for ApFederationAdapter {
|
|||||||
actor_ap_url: &str,
|
actor_ap_url: &str,
|
||||||
collection_url: &str,
|
collection_url: &str,
|
||||||
connection_type: &str,
|
connection_type: &str,
|
||||||
_page: u32,
|
page: u32,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
|
if page != 1 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let actor = actor_ap_url.to_string();
|
let actor = actor_ap_url.to_string();
|
||||||
let collection = collection_url.to_string();
|
let collection = collection_url.to_string();
|
||||||
let conn_type = connection_type.to_string();
|
let conn_type = connection_type.to_string();
|
||||||
@@ -533,35 +502,72 @@ impl FederationSchedulerPort for ApFederationAdapter {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FederationLookupPort for ApFederationAdapter {
|
impl FederationLookupPort for ApFederationAdapter {
|
||||||
async fn lookup_actor(&self, handle: &str) -> Result<DomainRemoteActor, DomainError> {
|
async fn lookup_actor(&self, handle: &str) -> Result<DomainRemoteActor, DomainError> {
|
||||||
let actor = self
|
let normalized = handle.trim_start_matches('@');
|
||||||
.inner
|
let at = normalized
|
||||||
.lookup_actor_by_handle(handle)
|
.rfind('@')
|
||||||
|
.ok_or_else(|| DomainError::InvalidInput("handle must be user@domain".into()))?;
|
||||||
|
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
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||||
|
.json()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
|
||||||
|
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(DomainError::NotFound)?
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
let actor_json: serde_json::Value = reqwest::Client::new()
|
||||||
|
.get(&self_href)
|
||||||
|
.header("Accept", "application/activity+json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
|
||||||
|
let ap_url = actor_json["id"].as_str().unwrap_or(&self_href).to_string();
|
||||||
|
let preferred_username = actor_json["preferredUsername"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let domain_part = url::Url::parse(&ap_url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let full_handle = format!("{}@{}", preferred_username, domain_part);
|
||||||
|
|
||||||
Ok(DomainRemoteActor {
|
Ok(DomainRemoteActor {
|
||||||
url: actor.ap_url.to_string(),
|
url: ap_url.clone(),
|
||||||
handle: actor.handle,
|
handle: full_handle,
|
||||||
display_name: actor.display_name,
|
display_name: actor_json["name"].as_str().map(|s| s.to_string()),
|
||||||
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
|
avatar_url: actor_json["icon"]["url"].as_str().map(|s| s.to_string()),
|
||||||
outbox_url: actor.outbox_url.as_ref().map(|u| u.to_string()),
|
outbox_url: actor_json["outbox"].as_str().map(|s| s.to_string()),
|
||||||
last_fetched_at: chrono::Utc::now(),
|
last_fetched_at: chrono::Utc::now(),
|
||||||
bio: actor.bio,
|
bio: actor_json["summary"].as_str().map(|s| s.to_string()),
|
||||||
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
|
banner_url: actor_json["image"]["url"].as_str().map(|s| s.to_string()),
|
||||||
also_known_as: actor
|
also_known_as: None,
|
||||||
.also_known_as
|
followers_url: actor_json["followers"].as_str().map(|s| s.to_string()),
|
||||||
.into_iter()
|
following_url: actor_json["following"].as_str().map(|s| s.to_string()),
|
||||||
.map(|u| u.to_string())
|
attachment: vec![],
|
||||||
.collect(),
|
|
||||||
followers_url: actor.followers_url.as_ref().map(|u| u.to_string()),
|
|
||||||
following_url: actor.following_url.as_ref().map(|u| u.to_string()),
|
|
||||||
inbox_url: None,
|
|
||||||
shared_inbox_url: None,
|
|
||||||
attachment: actor
|
|
||||||
.attachment
|
|
||||||
.into_iter()
|
|
||||||
.map(|f| (f.name, f.value))
|
|
||||||
.collect(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,19 +623,13 @@ impl FederationFetchPort for ApFederationAdapter {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
|
||||||
let first_url = base["first"]
|
let url = base["first"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.unwrap_or_else(|| format!("{}?page=1", outbox_url));
|
.unwrap_or_else(|| format!("{}?page={}", outbox_url, page));
|
||||||
|
|
||||||
let mut current_url = first_url;
|
let resp: serde_json::Value = client
|
||||||
let mut hops = 0u32;
|
.get(&url)
|
||||||
let target_page = page.max(1);
|
|
||||||
let max_hops = 10u32;
|
|
||||||
|
|
||||||
let resp: serde_json::Value = loop {
|
|
||||||
let page_resp: serde_json::Value = client
|
|
||||||
.get(¤t_url)
|
|
||||||
.header("Accept", "application/activity+json, application/ld+json")
|
.header("Accept", "application/activity+json, application/ld+json")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -638,16 +638,6 @@ impl FederationFetchPort for ApFederationAdapter {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||||
|
|
||||||
hops += 1;
|
|
||||||
if hops >= target_page || hops >= max_hops {
|
|
||||||
break page_resp;
|
|
||||||
}
|
|
||||||
match page_resp["next"].as_str() {
|
|
||||||
Some(next) => current_url = next.to_string(),
|
|
||||||
None => break page_resp,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let empty = vec![];
|
let empty = vec![];
|
||||||
let items = resp["orderedItems"].as_array().unwrap_or(&empty);
|
let items = resp["orderedItems"].as_array().unwrap_or(&empty);
|
||||||
|
|
||||||
@@ -789,17 +779,6 @@ impl FederationFollowPort for ApFederationAdapter {
|
|||||||
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
|
.map(|v| v.into_iter().map(k_ap_actor_to_domain).collect())
|
||||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
.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 ───────────────────────────────────────────────
|
// ── FederationFollowRequestPort ───────────────────────────────────────────────
|
||||||
@@ -860,57 +839,6 @@ impl FederationFollowRequestPort for ApFederationAdapter {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
.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()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── FederationBlockPort ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl domain::ports::FederationBlockPort for ApFederationAdapter {
|
|
||||||
async fn block_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
|
|
||||||
.block_actor(local_user_id.as_uuid(), &actor_url)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn unblock_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
|
|
||||||
.unblock_actor(local_user_id.as_uuid(), &actor_url)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::ExternalService(e.to_string()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FederationActionPort is a blanket supertrait; no explicit impl needed.
|
// FederationActionPort is a blanket supertrait; no explicit impl needed.
|
||||||
|
|||||||
@@ -11,24 +11,24 @@ impl ThoughtsUrls {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn user_url(&self, id: &str) -> Url {
|
pub fn user_url(&self, username: &str) -> Url {
|
||||||
Url::parse(&format!("{}/users/{}", self.base_url, id)).expect("valid URL")
|
Url::parse(&format!("{}/users/{}", self.base_url, username)).expect("valid URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn thought_url(&self, thought_id: uuid::Uuid) -> Url {
|
pub fn thought_url(&self, thought_id: uuid::Uuid) -> Url {
|
||||||
Url::parse(&format!("{}/thoughts/{}", self.base_url, thought_id)).expect("valid URL")
|
Url::parse(&format!("{}/thoughts/{}", self.base_url, thought_id)).expect("valid URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn user_inbox(&self, id: &str) -> Url {
|
pub fn user_inbox(&self, username: &str) -> Url {
|
||||||
Url::parse(&format!("{}/users/{}/inbox", self.base_url, id)).expect("valid URL")
|
Url::parse(&format!("{}/users/{}/inbox", self.base_url, username)).expect("valid URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn user_outbox(&self, id: &str) -> Url {
|
pub fn user_outbox(&self, username: &str) -> Url {
|
||||||
Url::parse(&format!("{}/users/{}/outbox", self.base_url, id)).expect("valid URL")
|
Url::parse(&format!("{}/users/{}/outbox", self.base_url, username)).expect("valid URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn user_followers(&self, id: &str) -> Url {
|
pub fn user_followers(&self, username: &str) -> Url {
|
||||||
Url::parse(&format!("{}/users/{}/followers", self.base_url, id)).expect("valid URL")
|
Url::parse(&format!("{}/users/{}/followers", self.base_url, username)).expect("valid URL")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,32 +71,11 @@ 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,
|
||||||
author_user_id: String,
|
author_user_id: String,
|
||||||
},
|
},
|
||||||
FederationDeliveryRequested {
|
|
||||||
inbox: String,
|
|
||||||
activity: serde_json::Value,
|
|
||||||
signing_actor_id: String,
|
|
||||||
},
|
|
||||||
FederationBackfillRequested {
|
|
||||||
owner_user_id: String,
|
|
||||||
follower_inbox_url: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventPayload {
|
impl EventPayload {
|
||||||
@@ -118,12 +97,7 @@ 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",
|
||||||
Self::FederationDeliveryRequested { .. } => "federation.delivery.requested",
|
|
||||||
Self::FederationBackfillRequested { .. } => "federation.backfill.requested",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,27 +210,6 @@ 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,
|
||||||
@@ -387,27 +340,6 @@ 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,
|
||||||
@@ -420,12 +352,6 @@ impl TryFrom<EventPayload> for DomainEvent {
|
|||||||
)?),
|
)?),
|
||||||
author_user_id: UserId::from_uuid(parse_uuid(&author_user_id, "author_user_id")?),
|
author_user_id: UserId::from_uuid(parse_uuid(&author_user_id, "author_user_id")?),
|
||||||
},
|
},
|
||||||
EventPayload::FederationDeliveryRequested { .. }
|
|
||||||
| EventPayload::FederationBackfillRequested { .. } => {
|
|
||||||
return Err(DomainError::Internal(
|
|
||||||
"federation infrastructure event — not a domain event".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ use async_trait::async_trait;
|
|||||||
use domain::value_objects::{ThoughtId, UserId};
|
use domain::value_objects::{ThoughtId, UserId};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
type CallLog = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
|
||||||
|
|
||||||
struct SpyTransport {
|
struct SpyTransport {
|
||||||
calls: CallLog,
|
calls: Arc<Mutex<Vec<(String, Vec<u8>)>>>,
|
||||||
}
|
}
|
||||||
impl SpyTransport {
|
impl SpyTransport {
|
||||||
fn new() -> (Self, CallLog) {
|
fn new() -> (Self, Arc<Mutex<Vec<(String, Vec<u8>)>>>) {
|
||||||
let calls = Arc::new(Mutex::new(vec![]));
|
let calls = Arc::new(Mutex::new(vec![]));
|
||||||
(
|
(
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::*;
|
||||||
use domain::{
|
use domain::{
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
value_objects::{LikeId, ThoughtId, UserId},
|
value_objects::{LikeId, ThoughtId, UserId},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-ap = { version = "0.4.0", registry = "gitea" }
|
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.3" }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
@@ -12,7 +12,6 @@ tracing = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
../postgres/migrations
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,9 @@ use domain::{
|
|||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::SearchPort,
|
ports::SearchPort,
|
||||||
value_objects::{Content, ThoughtId, UserId},
|
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||||
};
|
};
|
||||||
use postgres::user::USER_SELECT;
|
use postgres::user::{UserRow, USER_SELECT};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
pub struct PgSearchRepository {
|
pub struct PgSearchRepository {
|
||||||
@@ -34,16 +34,25 @@ struct FeedRow {
|
|||||||
sensitive: bool,
|
sensitive: bool,
|
||||||
t_local: bool,
|
t_local: bool,
|
||||||
thought_created_at: DateTime<Utc>,
|
thought_created_at: DateTime<Utc>,
|
||||||
thought_updated_at: Option<DateTime<Utc>>,
|
updated_at: Option<DateTime<Utc>>,
|
||||||
note_extensions: Option<serde_json::Value>,
|
author_id: uuid::Uuid,
|
||||||
mood: Option<String>,
|
username: String,
|
||||||
#[sqlx(flatten)]
|
email: String,
|
||||||
author: postgres::user::UserRow,
|
password_hash: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
header_url: Option<String>,
|
||||||
|
custom_css: Option<String>,
|
||||||
|
author_local: bool,
|
||||||
|
author_created_at: DateTime<Utc>,
|
||||||
|
author_updated_at: DateTime<Utc>,
|
||||||
like_count: i64,
|
like_count: i64,
|
||||||
boost_count: i64,
|
boost_count: i64,
|
||||||
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 {
|
||||||
@@ -59,11 +68,11 @@ 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 AS thought_updated_at, t.note_extensions, t.mood,\n\
|
t.created_at AS thought_created_at, t.updated_at, t.note_extensions,\n\
|
||||||
u.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, u.profile_fields, u.custom_moods,\n\
|
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,\n\
|
||||||
u.local,\n\
|
u.local AS author_local,\n\
|
||||||
u.created_at, u.updated_at,\n\
|
u.created_at AS author_created_at, u.updated_at AS author_updated_at,\n\
|
||||||
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,\n\
|
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,\n\
|
||||||
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,\n\
|
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,\n\
|
||||||
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,\n\
|
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,\n\
|
||||||
@@ -83,11 +92,23 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
|
|||||||
sensitive: r.sensitive,
|
sensitive: r.sensitive,
|
||||||
local: r.t_local,
|
local: r.t_local,
|
||||||
created_at: r.thought_created_at,
|
created_at: r.thought_created_at,
|
||||||
updated_at: r.thought_updated_at,
|
updated_at: r.updated_at,
|
||||||
note_extensions: r.note_extensions,
|
note_extensions: r.note_extensions,
|
||||||
mood: r.mood,
|
|
||||||
};
|
};
|
||||||
let author = User::from(r.author);
|
let author = User {
|
||||||
|
id: UserId::from_uuid(r.author_id),
|
||||||
|
username: Username::from_trusted(r.username),
|
||||||
|
email: Email::from_trusted(r.email),
|
||||||
|
password_hash: PasswordHash(r.password_hash),
|
||||||
|
display_name: r.display_name,
|
||||||
|
bio: r.bio,
|
||||||
|
avatar_url: r.avatar_url,
|
||||||
|
header_url: r.header_url,
|
||||||
|
custom_css: r.custom_css,
|
||||||
|
local: r.author_local,
|
||||||
|
created_at: r.author_created_at,
|
||||||
|
updated_at: r.author_updated_at,
|
||||||
|
};
|
||||||
Ok(FeedEntry {
|
Ok(FeedEntry {
|
||||||
thought,
|
thought,
|
||||||
author,
|
author,
|
||||||
@@ -168,7 +189,7 @@ impl SearchPort for PgSearchRepository {
|
|||||||
ORDER BY similarity(username || ' ' || COALESCE(display_name,''), $1) DESC
|
ORDER BY similarity(username || ' ' || COALESCE(display_name,''), $1) DESC
|
||||||
LIMIT $2 OFFSET $3"
|
LIMIT $2 OFFSET $3"
|
||||||
);
|
);
|
||||||
let rows = sqlx::query_as::<_, postgres::user::UserRow>(&sql)
|
let rows = sqlx::query_as::<_, UserRow>(&sql)
|
||||||
.bind(query)
|
.bind(query)
|
||||||
.bind(page.limit())
|
.bind(page.limit())
|
||||||
.bind(page.offset())
|
.bind(page.offset())
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use domain::{
|
|||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{SearchPort, ThoughtRepository, UserWriter},
|
ports::{SearchPort, ThoughtRepository, UserWriter},
|
||||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
value_objects::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
|
async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
|
||||||
@@ -27,7 +27,6 @@ async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (Us
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
trepo.save(&t).await.unwrap();
|
trepo.save(&t).await.unwrap();
|
||||||
(u, t)
|
(u, t)
|
||||||
@@ -103,9 +102,9 @@ async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) {
|
|||||||
#[sqlx::test(migrations = "../postgres/migrations")]
|
#[sqlx::test(migrations = "../postgres/migrations")]
|
||||||
async fn search_thoughts_viewer_context(pool: sqlx::PgPool) {
|
async fn search_thoughts_viewer_context(pool: sqlx::PgPool) {
|
||||||
use domain::models::social::Like;
|
use domain::models::social::Like;
|
||||||
use domain::ports::LikeRepository;
|
use domain::ports::{LikeRepository, UserWriter};
|
||||||
use domain::value_objects::LikeId;
|
use domain::value_objects::LikeId;
|
||||||
use postgres::like::PgLikeRepository;
|
use postgres::{like::PgLikeRepository, user::PgUserRepository};
|
||||||
|
|
||||||
let (alice, thought) = seed_thought(&pool, "alice", "hello world").await;
|
let (alice, thought) = seed_thought(&pool, "alice", "hello world").await;
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS also_known_as TEXT;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS federation_processed_activities (
|
|
||||||
activity_id TEXT PRIMARY KEY,
|
|
||||||
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_fed_processed_activities_at
|
|
||||||
ON federation_processed_activities(processed_at);
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
ALTER TABLE remote_actors
|
|
||||||
ADD COLUMN IF NOT EXISTS bio TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS banner_url TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS followers_url TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS following_url TEXT,
|
|
||||||
ADD COLUMN IF NOT EXISTS also_known_as TEXT[];
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
-- Indexes for feed engagement counts and sorting.
|
|
||||||
-- likes and boosts are joined/counted per thought on every feed query.
|
|
||||||
-- thoughts(in_reply_to_id) is scanned for reply_count.
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_likes_thought_id ON likes(thought_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_boosts_thought_id ON boosts(thought_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_thoughts_in_reply_to_id ON thoughts(in_reply_to_id) WHERE in_reply_to_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- Viewer-context lookups: "did I like/boost this?"
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_likes_user_thought ON likes(user_id, thought_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_boosts_user_thought ON boosts(user_id, thought_id);
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE users ALTER COLUMN username TYPE VARCHAR(255);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE federation_following
|
|
||||||
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'accepted';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE remote_actors ADD COLUMN IF NOT EXISTS attachment JSONB DEFAULT '[]'::jsonb;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_fields JSONB DEFAULT '[]'::jsonb;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE thoughts ADD COLUMN IF NOT EXISTS mood VARCHAR(64);
|
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS custom_moods JSONB DEFAULT '[]'::jsonb;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
INSERT INTO users (id, username, email, password_hash, display_name, bio)
|
|
||||||
VALUES (
|
|
||||||
'00000000-0000-4000-8000-000000000000',
|
|
||||||
'instance',
|
|
||||||
'noreply@instance.invalid',
|
|
||||||
'!service-actor-no-login',
|
|
||||||
NULL,
|
|
||||||
NULL
|
|
||||||
)
|
|
||||||
ON CONFLICT (id) DO NOTHING;
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::db_error::IntoDbResult;
|
use crate::db_error::IntoDbResult;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
const MAX_REMOTE_CONTENT_CHARS: usize = 5000;
|
const MAX_REMOTE_CONTENT_CHARS: usize = 500;
|
||||||
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
|
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -13,41 +13,6 @@ use domain::{
|
|||||||
value_objects::{Content, ThoughtId, UserId, Username},
|
value_objects::{Content, ThoughtId, UserId, Username},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct OutboxRow {
|
|
||||||
id: uuid::Uuid,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
content: String,
|
|
||||||
created_at: DateTime<Utc>,
|
|
||||||
in_reply_to_id: Option<uuid::Uuid>,
|
|
||||||
content_warning: Option<String>,
|
|
||||||
sensitive: bool,
|
|
||||||
username: String,
|
|
||||||
updated_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OutboxRow {
|
|
||||||
fn into_entry(self) -> OutboxEntry {
|
|
||||||
OutboxEntry {
|
|
||||||
thought: Thought {
|
|
||||||
id: ThoughtId::from_uuid(self.id),
|
|
||||||
user_id: UserId::from_uuid(self.user_id),
|
|
||||||
content: Content::new_remote(self.content),
|
|
||||||
in_reply_to_id: self.in_reply_to_id.map(ThoughtId::from_uuid),
|
|
||||||
visibility: Visibility::Public,
|
|
||||||
content_warning: self.content_warning,
|
|
||||||
sensitive: self.sensitive,
|
|
||||||
local: true,
|
|
||||||
created_at: self.created_at,
|
|
||||||
updated_at: self.updated_at,
|
|
||||||
note_extensions: None,
|
|
||||||
mood: None,
|
|
||||||
},
|
|
||||||
author_username: Username::from_trusted(self.username),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PgActivityPubRepository {
|
pub struct PgActivityPubRepository {
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
}
|
}
|
||||||
@@ -64,7 +29,19 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
) -> Result<Vec<OutboxEntry>, DomainError> {
|
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||||
sqlx::query_as::<_, OutboxRow>(
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
content: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
in_reply_to_id: Option<uuid::Uuid>,
|
||||||
|
content_warning: Option<String>,
|
||||||
|
sensitive: bool,
|
||||||
|
username: String,
|
||||||
|
updated_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
||||||
@@ -74,7 +51,26 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map(|rows| rows.into_iter().map(OutboxRow::into_entry).collect())
|
.map(|rows| {
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|r| OutboxEntry {
|
||||||
|
thought: Thought {
|
||||||
|
id: ThoughtId::from_uuid(r.id),
|
||||||
|
user_id: UserId::from_uuid(r.user_id),
|
||||||
|
content: Content::new_remote(r.content),
|
||||||
|
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||||
|
visibility: Visibility::Public,
|
||||||
|
content_warning: r.content_warning,
|
||||||
|
sensitive: r.sensitive,
|
||||||
|
local: true,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
note_extensions: None,
|
||||||
|
},
|
||||||
|
author_username: Username::from_trusted(r.username),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn outbox_page_for_actor(
|
async fn outbox_page_for_actor(
|
||||||
@@ -83,8 +79,20 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
before: Option<DateTime<Utc>>,
|
before: Option<DateTime<Utc>>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<OutboxEntry>, DomainError> {
|
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
content: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
in_reply_to_id: Option<uuid::Uuid>,
|
||||||
|
content_warning: Option<String>,
|
||||||
|
sensitive: bool,
|
||||||
|
username: String,
|
||||||
|
updated_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
let rows = if let Some(before) = before {
|
let rows = if let Some(before) = before {
|
||||||
sqlx::query_as::<_, OutboxRow>(
|
sqlx::query_as::<_, Row>(
|
||||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2
|
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2
|
||||||
@@ -96,7 +104,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, OutboxRow>(
|
sqlx::query_as::<_, Row>(
|
||||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
||||||
@@ -109,7 +117,25 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
}
|
}
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(rows.into_iter().map(OutboxRow::into_entry).collect())
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| OutboxEntry {
|
||||||
|
thought: Thought {
|
||||||
|
id: ThoughtId::from_uuid(r.id),
|
||||||
|
user_id: UserId::from_uuid(r.user_id),
|
||||||
|
content: Content::new_remote(r.content),
|
||||||
|
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||||
|
visibility: Visibility::Public,
|
||||||
|
content_warning: r.content_warning,
|
||||||
|
sensitive: r.sensitive,
|
||||||
|
local: true,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
note_extensions: None,
|
||||||
|
},
|
||||||
|
author_username: Username::from_trusted(r.username),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_remote_actor_id(
|
async fn find_remote_actor_id(
|
||||||
@@ -129,28 +155,24 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
return Ok(id);
|
return Ok(id);
|
||||||
}
|
}
|
||||||
let new_id = uuid::Uuid::new_v4();
|
let new_id = uuid::Uuid::new_v4();
|
||||||
let parsed = url::Url::parse(actor_ap_url).ok();
|
// Use the last path segment as username (e.g. /users/alice → "alice").
|
||||||
let domain_str = parsed
|
// Falls back to a random short id for long segments (e.g. UUID-based actor URLs).
|
||||||
.as_ref()
|
// username column is VARCHAR(32).
|
||||||
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
let last_seg = url::Url::parse(actor_ap_url)
|
||||||
.unwrap_or_default();
|
.ok()
|
||||||
let last_seg = parsed
|
|
||||||
.and_then(|u| {
|
.and_then(|u| {
|
||||||
u.path_segments()
|
u.path_segments()
|
||||||
.and_then(|mut s| s.next_back().map(|s| s.to_string()))
|
.and_then(|mut s| s.next_back().map(|s| s.to_string()))
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let handle = if last_seg.is_empty() || domain_str.is_empty() {
|
let handle = if last_seg.is_empty() {
|
||||||
format!("r_{}", &new_id.to_string()[..13])
|
format!("remote_{}", &new_id.to_string()[..13])
|
||||||
|
} else if last_seg.len() <= 32 {
|
||||||
|
last_seg
|
||||||
} else {
|
} else {
|
||||||
let candidate = format!("{}@{}", last_seg, domain_str);
|
format!("remote_{}", &new_id.to_string()[..13])
|
||||||
if candidate.len() <= 255 {
|
|
||||||
candidate
|
|
||||||
} else {
|
|
||||||
format!("r_{}", &new_id.to_string()[..13])
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let result = sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
|
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
|
||||||
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
|
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
|
||||||
)
|
)
|
||||||
@@ -159,24 +181,9 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
.bind(format!("{}@remote", new_id))
|
.bind(format!("{}@remote", new_id))
|
||||||
.bind(actor_ap_url)
|
.bind(actor_ap_url)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await;
|
|
||||||
|
|
||||||
if result.is_err() {
|
|
||||||
let fallback = format!("r_{}", &new_id.to_string()[..13]);
|
|
||||||
let new_id2 = uuid::Uuid::new_v4();
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
|
|
||||||
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
|
|
||||||
)
|
|
||||||
.bind(new_id2)
|
|
||||||
.bind(&fallback)
|
|
||||||
.bind(format!("{}@remote", new_id2))
|
|
||||||
.bind(actor_ap_url)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
}
|
// Re-fetch to get whichever id won the race
|
||||||
|
|
||||||
self.find_remote_actor_id(actor_ap_url)
|
self.find_remote_actor_id(actor_ap_url)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -220,25 +227,14 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
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) => {
|
||||||
// Fast path: local thought URL contains the UUID directly.
|
// If the parent is a local thought, extract its UUID for in_reply_to_id.
|
||||||
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())
|
||||||
});
|
});
|
||||||
// Slow path: remote parent — look up by ap_id so remote-to-remote
|
(local_uuid, Some(url.to_string()))
|
||||||
// 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),
|
||||||
};
|
};
|
||||||
@@ -270,19 +266,13 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
Ok(ThoughtId::from_uuid(row.0))
|
Ok(ThoughtId::from_uuid(row.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
note_extensions: Option<serde_json::Value>,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
let capped: String = new_content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
let capped: String = new_content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE thoughts SET content=$2,note_extensions=$3,updated_at=NOW() WHERE ap_id=$1 AND local=false",
|
"UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false",
|
||||||
)
|
)
|
||||||
.bind(ap_id)
|
.bind(ap_id)
|
||||||
.bind(&capped)
|
.bind(&capped)
|
||||||
.bind(¬e_extensions)
|
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
@@ -344,19 +334,6 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
.into_domain()
|
.into_domain()
|
||||||
.map(|opt| opt.map(|(ap_id, inbox_url)| ActorApUrls { ap_id, inbox_url }))
|
.map(|opt| opt.map(|(ap_id, inbox_url)| ActorApUrls { ap_id, inbox_url }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sync_remote_actor_to_user(&self, actor_ap_url: &str) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE users SET display_name = ra.display_name, avatar_url = ra.avatar_url, updated_at = NOW()
|
|
||||||
FROM remote_actors ra
|
|
||||||
WHERE users.ap_id = ra.url AND users.ap_id = $1 AND users.local = false",
|
|
||||||
)
|
|
||||||
.bind(actor_ap_url)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.into_domain()
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -9,27 +9,6 @@ use domain::{
|
|||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct ApiKeyRow {
|
|
||||||
id: uuid::Uuid,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
key_hash: String,
|
|
||||||
name: String,
|
|
||||||
created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApiKeyRow {
|
|
||||||
fn into_domain(self) -> ApiKey {
|
|
||||||
ApiKey {
|
|
||||||
id: ApiKeyId::from_uuid(self.id),
|
|
||||||
user_id: UserId::from_uuid(self.user_id),
|
|
||||||
key_hash: self.key_hash,
|
|
||||||
name: self.name,
|
|
||||||
created_at: self.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PgApiKeyRepository {
|
pub struct PgApiKeyRepository {
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
}
|
}
|
||||||
@@ -57,21 +36,45 @@ impl ApiKeyRepository for PgApiKeyRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
|
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
|
||||||
sqlx::query_as::<_, ApiKeyRow>(
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
key_hash: String,
|
||||||
|
name: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
"SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1",
|
"SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1",
|
||||||
)
|
)
|
||||||
.bind(hash)
|
.bind(hash)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map(|o| o.map(ApiKeyRow::into_domain))
|
.map(|o| {
|
||||||
|
o.map(|r| ApiKey {
|
||||||
|
id: ApiKeyId::from_uuid(r.id),
|
||||||
|
user_id: UserId::from_uuid(r.user_id),
|
||||||
|
key_hash: r.key_hash,
|
||||||
|
name: r.name,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError> {
|
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError> {
|
||||||
sqlx::query_as::<_, ApiKeyRow>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE user_id=$1 ORDER BY created_at DESC")
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
key_hash: String,
|
||||||
|
name: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE user_id=$1 ORDER BY created_at DESC")
|
||||||
.bind(user_id.as_uuid()).fetch_all(&self.pool).await
|
.bind(user_id.as_uuid()).fetch_all(&self.pool).await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map(|rows| rows.into_iter().map(ApiKeyRow::into_domain).collect())
|
.map(|rows| rows.into_iter().map(|r| ApiKey { id: ApiKeyId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), key_hash: r.key_hash, name: r.name, created_at: r.created_at }).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> {
|
async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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::*;
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn block_exists(pool: sqlx::PgPool) {
|
async fn block_exists(pool: sqlx::PgPool) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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::*;
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn boost_and_count(pool: sqlx::PgPool) {
|
async fn boost_and_count(pool: sqlx::PgPool) {
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
pub const STATUS_ACCEPTED: &str = "accepted";
|
|
||||||
pub const STATUS_PENDING: &str = "pending";
|
|
||||||
pub const STATUS_REJECTED: &str = "rejected";
|
|
||||||
|
|
||||||
pub const VIS_PUBLIC: &str = "public";
|
|
||||||
pub const VIS_UNLISTED: &str = "unlisted";
|
|
||||||
pub const VIS_FOLLOWERS: &str = "followers";
|
|
||||||
pub const VIS_DIRECT: &str = "direct";
|
|
||||||
@@ -9,8 +9,8 @@ use domain::{
|
|||||||
thought::{Thought, Visibility},
|
thought::{Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{FeedOptions, FeedRepository, FeedRequest, FeedScope, FeedSort},
|
ports::{FeedQuery, FeedRepository, FeedScope},
|
||||||
value_objects::{Content, ThoughtId, UserId},
|
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
@@ -34,11 +34,20 @@ struct FeedRow {
|
|||||||
sensitive: bool,
|
sensitive: bool,
|
||||||
t_local: bool,
|
t_local: bool,
|
||||||
thought_created_at: DateTime<Utc>,
|
thought_created_at: DateTime<Utc>,
|
||||||
thought_updated_at: Option<DateTime<Utc>>,
|
updated_at: Option<DateTime<Utc>>,
|
||||||
note_extensions: Option<serde_json::Value>,
|
note_extensions: Option<serde_json::Value>,
|
||||||
mood: Option<String>,
|
author_id: uuid::Uuid,
|
||||||
#[sqlx(flatten)]
|
username: String,
|
||||||
author: crate::user::UserRow,
|
email: String,
|
||||||
|
password_hash: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
header_url: Option<String>,
|
||||||
|
custom_css: Option<String>,
|
||||||
|
author_local: bool,
|
||||||
|
author_created_at: DateTime<Utc>,
|
||||||
|
author_updated_at: DateTime<Utc>,
|
||||||
like_count: i64,
|
like_count: i64,
|
||||||
boost_count: i64,
|
boost_count: i64,
|
||||||
reply_count: i64,
|
reply_count: i64,
|
||||||
@@ -46,6 +55,59 @@ struct FeedRow {
|
|||||||
boosted_by_viewer: bool,
|
boosted_by_viewer: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn federation_following_clause(follower: Option<uuid::Uuid>) -> String {
|
||||||
|
match follower {
|
||||||
|
Some(fid) => format!(
|
||||||
|
" OR t.user_id IN (
|
||||||
|
SELECT u2.id FROM users u2
|
||||||
|
JOIN federation_following ff ON u2.ap_id = ff.remote_actor_url
|
||||||
|
WHERE ff.local_user_id = '{fid}'
|
||||||
|
)"
|
||||||
|
),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||||
|
let viewer_checks = match viewer {
|
||||||
|
Some(uid) => format!(
|
||||||
|
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,
|
||||||
|
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer"
|
||||||
|
),
|
||||||
|
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"
|
||||||
|
SELECT
|
||||||
|
t.id AS thought_id, t.user_id AS t_user_id, t.content,
|
||||||
|
t.in_reply_to_id,
|
||||||
|
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
||||||
|
t.created_at AS thought_created_at, t.updated_at,
|
||||||
|
t.note_extensions,
|
||||||
|
u.id AS author_id,
|
||||||
|
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
|
||||||
|
THEN '@' || ra.handle ||
|
||||||
|
CASE WHEN ra.handle NOT LIKE '%@%'
|
||||||
|
THEN '@' || SPLIT_PART(ra.url, '/', 3)
|
||||||
|
ELSE '' END
|
||||||
|
ELSE u.username END AS username,
|
||||||
|
u.email, u.password_hash,
|
||||||
|
COALESCE(ra.display_name, u.display_name) AS display_name,
|
||||||
|
u.bio,
|
||||||
|
COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url,
|
||||||
|
u.header_url, u.custom_css,
|
||||||
|
u.local AS author_local,
|
||||||
|
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
|
||||||
|
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
|
||||||
|
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
|
||||||
|
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,
|
||||||
|
{viewer_checks}
|
||||||
|
FROM thoughts t
|
||||||
|
JOIN users u ON u.id=t.user_id
|
||||||
|
LEFT JOIN remote_actors ra ON u.ap_id = ra.url"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
|
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
|
||||||
let thought = Thought {
|
let thought = Thought {
|
||||||
id: ThoughtId::from_uuid(r.thought_id),
|
id: ThoughtId::from_uuid(r.thought_id),
|
||||||
@@ -57,11 +119,23 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
|
|||||||
sensitive: r.sensitive,
|
sensitive: r.sensitive,
|
||||||
local: r.t_local,
|
local: r.t_local,
|
||||||
created_at: r.thought_created_at,
|
created_at: r.thought_created_at,
|
||||||
updated_at: r.thought_updated_at,
|
updated_at: r.updated_at,
|
||||||
note_extensions: r.note_extensions,
|
note_extensions: r.note_extensions,
|
||||||
mood: r.mood,
|
|
||||||
};
|
};
|
||||||
let author = User::from(r.author);
|
let author = User {
|
||||||
|
id: UserId::from_uuid(r.author_id),
|
||||||
|
username: Username::from_trusted(r.username),
|
||||||
|
email: Email::from_trusted(r.email),
|
||||||
|
password_hash: PasswordHash(r.password_hash),
|
||||||
|
display_name: r.display_name,
|
||||||
|
bio: r.bio,
|
||||||
|
avatar_url: r.avatar_url,
|
||||||
|
header_url: r.header_url,
|
||||||
|
custom_css: r.custom_css,
|
||||||
|
local: r.author_local,
|
||||||
|
created_at: r.author_created_at,
|
||||||
|
updated_at: r.author_updated_at,
|
||||||
|
};
|
||||||
Ok(FeedEntry {
|
Ok(FeedEntry {
|
||||||
thought,
|
thought,
|
||||||
author,
|
author,
|
||||||
@@ -77,227 +151,36 @@ fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, Dom
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FeedSqlBuilder<'a> {
|
|
||||||
options: &'a FeedOptions,
|
|
||||||
scope: &'a FeedScope,
|
|
||||||
viewer: Option<uuid::Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> FeedSqlBuilder<'a> {
|
|
||||||
fn new(options: &'a FeedOptions, scope: &'a FeedScope, viewer: Option<uuid::Uuid>) -> Self {
|
|
||||||
Self {
|
|
||||||
options,
|
|
||||||
scope,
|
|
||||||
viewer,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select(&self, viewer_param: &str) -> String {
|
|
||||||
let (viewer_cols, viewer_joins) = match self.viewer {
|
|
||||||
Some(_) => (
|
|
||||||
"(lv.thought_id IS NOT NULL) AS liked_by_viewer,
|
|
||||||
(bv.thought_id IS NOT NULL) AS boosted_by_viewer".to_string(),
|
|
||||||
format!(
|
|
||||||
"LEFT JOIN (SELECT thought_id FROM likes WHERE user_id={viewer_param}) lv ON lv.thought_id = t.id
|
|
||||||
LEFT JOIN (SELECT thought_id FROM boosts WHERE user_id={viewer_param}) bv ON bv.thought_id = t.id"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
None => (
|
|
||||||
"false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
|
|
||||||
String::new(),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
format!(
|
|
||||||
"
|
|
||||||
SELECT
|
|
||||||
t.id AS thought_id, t.user_id AS t_user_id, t.content,
|
|
||||||
t.in_reply_to_id,
|
|
||||||
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
|
||||||
t.created_at AS thought_created_at, t.updated_at AS thought_updated_at,
|
|
||||||
t.note_extensions, t.mood,
|
|
||||||
u.id,
|
|
||||||
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
|
|
||||||
THEN '@' || ra.handle ||
|
|
||||||
CASE WHEN ra.handle NOT LIKE '%@%'
|
|
||||||
THEN '@' || SPLIT_PART(ra.url, '/', 3)
|
|
||||||
ELSE '' END
|
|
||||||
ELSE u.username END AS username,
|
|
||||||
u.email, u.password_hash,
|
|
||||||
COALESCE(ra.display_name, u.display_name) AS display_name,
|
|
||||||
u.bio,
|
|
||||||
COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url,
|
|
||||||
u.header_url, u.custom_css, u.profile_fields, u.custom_moods,
|
|
||||||
u.local,
|
|
||||||
u.created_at, u.updated_at,
|
|
||||||
COALESCE(l_agg.cnt, 0) AS like_count,
|
|
||||||
COALESCE(b_agg.cnt, 0) AS boost_count,
|
|
||||||
COALESCE(r_agg.cnt, 0) AS reply_count,
|
|
||||||
{viewer_cols}
|
|
||||||
FROM thoughts t
|
|
||||||
JOIN users u ON u.id=t.user_id
|
|
||||||
LEFT JOIN remote_actors ra ON u.ap_id = ra.url
|
|
||||||
LEFT JOIN (SELECT thought_id, COUNT(*) AS cnt FROM likes GROUP BY thought_id) l_agg ON l_agg.thought_id = t.id
|
|
||||||
LEFT JOIN (SELECT thought_id, COUNT(*) AS cnt FROM boosts GROUP BY thought_id) b_agg ON b_agg.thought_id = t.id
|
|
||||||
LEFT JOIN (SELECT in_reply_to_id, COUNT(*) AS cnt FROM thoughts WHERE in_reply_to_id IS NOT NULL GROUP BY in_reply_to_id) r_agg ON r_agg.in_reply_to_id = t.id
|
|
||||||
{viewer_joins}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fed_clause(&self, viewer_param: &str) -> String {
|
|
||||||
match self.viewer {
|
|
||||||
Some(_) => format!(
|
|
||||||
" OR t.user_id IN (
|
|
||||||
SELECT u2.id FROM users u2
|
|
||||||
JOIN federation_following ff ON u2.ap_id = ff.remote_actor_url
|
|
||||||
WHERE ff.local_user_id = {viewer_param}
|
|
||||||
)"
|
|
||||||
),
|
|
||||||
None => String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn filter_sql(&self) -> String {
|
|
||||||
let f = &self.options.filter;
|
|
||||||
let mut s = String::new();
|
|
||||||
if f.originals_only {
|
|
||||||
s += " AND t.in_reply_to_id IS NULL";
|
|
||||||
}
|
|
||||||
if f.replies_only {
|
|
||||||
s += " AND t.in_reply_to_id IS NOT NULL";
|
|
||||||
}
|
|
||||||
if f.local_only {
|
|
||||||
s += " AND t.local = true";
|
|
||||||
}
|
|
||||||
if f.hide_sensitive {
|
|
||||||
s += " AND t.sensitive = false";
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
fn order_sql(&self) -> &'static str {
|
|
||||||
if matches!(self.scope, FeedScope::Search { .. }) {
|
|
||||||
return "ORDER BY similarity(t.content, $1) DESC";
|
|
||||||
}
|
|
||||||
match &self.options.sort {
|
|
||||||
FeedSort::Newest => "ORDER BY t.created_at DESC",
|
|
||||||
FeedSort::Oldest => "ORDER BY t.created_at ASC",
|
|
||||||
FeedSort::MostLiked => "ORDER BY like_count DESC, t.created_at DESC",
|
|
||||||
FeedSort::MostBoosted => "ORDER BY boost_count DESC, t.created_at DESC",
|
|
||||||
FeedSort::MostDiscussed => "ORDER BY reply_count DESC, t.created_at DESC",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn public(&self) -> (String, String) {
|
|
||||||
let filter = self.filter_sql();
|
|
||||||
let order = self.order_sql();
|
|
||||||
let count = format!(
|
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'{}",
|
|
||||||
filter
|
|
||||||
);
|
|
||||||
let data = format!(
|
|
||||||
"{} WHERE t.local=true AND t.visibility='public'{} {} LIMIT $1 OFFSET $2",
|
|
||||||
self.select("$3"),
|
|
||||||
filter,
|
|
||||||
order
|
|
||||||
);
|
|
||||||
(count, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn home(&self) -> (String, String) {
|
|
||||||
let filter = self.filter_sql();
|
|
||||||
let order = self.order_sql();
|
|
||||||
let count = format!(
|
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'{}",
|
|
||||||
self.fed_clause("$2"), filter
|
|
||||||
);
|
|
||||||
let data =
|
|
||||||
format!(
|
|
||||||
"{} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'{} {} LIMIT $2 OFFSET $3",
|
|
||||||
self.select("$4"), self.fed_clause("$4"), filter, order
|
|
||||||
);
|
|
||||||
(count, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn search(&self) -> (String, String) {
|
|
||||||
let filter = self.filter_sql();
|
|
||||||
let order = self.order_sql();
|
|
||||||
let count = format!(
|
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'{}",
|
|
||||||
filter
|
|
||||||
);
|
|
||||||
let data = format!(
|
|
||||||
"{} WHERE t.content % $1 AND t.visibility='public'{} {} LIMIT $2 OFFSET $3",
|
|
||||||
self.select("$4"),
|
|
||||||
filter,
|
|
||||||
order
|
|
||||||
);
|
|
||||||
(count, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tag(&self) -> (String, String) {
|
|
||||||
let filter = self.filter_sql();
|
|
||||||
let order = self.order_sql();
|
|
||||||
let count = format!(
|
|
||||||
"SELECT COUNT(*) FROM thoughts t
|
|
||||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
|
||||||
JOIN tags tg ON tg.id = tt.tag_id
|
|
||||||
WHERE tg.name = $1 AND t.visibility = 'public'{}",
|
|
||||||
filter
|
|
||||||
);
|
|
||||||
let data = format!(
|
|
||||||
"{}
|
|
||||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
|
||||||
JOIN tags tg ON tg.id = tt.tag_id
|
|
||||||
WHERE tg.name = $1 AND t.visibility = 'public'{} {} LIMIT $2 OFFSET $3",
|
|
||||||
self.select("$4"),
|
|
||||||
filter,
|
|
||||||
order
|
|
||||||
);
|
|
||||||
(count, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn user(&self) -> (String, String) {
|
|
||||||
let filter = self.filter_sql();
|
|
||||||
let order = self.order_sql();
|
|
||||||
let count = format!(
|
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND ($2::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $2 AND following_id = $1 AND state = 'accepted'))))){}",
|
|
||||||
filter
|
|
||||||
);
|
|
||||||
let data = format!(
|
|
||||||
"{} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))){} {} LIMIT $2 OFFSET $3",
|
|
||||||
self.select("$4"), filter, order
|
|
||||||
);
|
|
||||||
(count, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FeedRepository for PgFeedRepository {
|
impl FeedRepository for PgFeedRepository {
|
||||||
async fn query(&self, req: &FeedRequest) -> Result<Paginated<FeedEntry>, DomainError> {
|
async fn query(&self, q: &FeedQuery) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
let viewer = req.query.viewer_id.as_ref().map(|v| v.as_uuid());
|
let viewer = q.viewer_id.as_ref().map(|v| v.as_uuid());
|
||||||
let page = &req.query.page;
|
let page = &q.page;
|
||||||
let builder = FeedSqlBuilder::new(&req.options, &req.query.scope, viewer);
|
|
||||||
|
|
||||||
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
|
match &q.scope {
|
||||||
|
|
||||||
match &req.query.scope {
|
|
||||||
FeedScope::Home { following_ids } => {
|
FeedScope::Home { following_ids } => {
|
||||||
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
||||||
let (count_sql, data_sql) = builder.home();
|
let fed_clause = federation_following_clause(viewer);
|
||||||
|
let count_sql = format!(
|
||||||
|
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'",
|
||||||
|
fed_clause
|
||||||
|
);
|
||||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||||
.bind(&ids)
|
.bind(&ids)
|
||||||
.bind(viewer_uuid)
|
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
|
|
||||||
|
let sel = feed_select(viewer);
|
||||||
|
let sql = format!("{sel} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3", fed_clause);
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
.bind(&ids)
|
.bind(&ids)
|
||||||
.bind(page.limit())
|
.bind(page.limit())
|
||||||
.bind(page.offset())
|
.bind(page.offset())
|
||||||
.bind(viewer_uuid)
|
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows
|
items: rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -310,18 +193,22 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FeedScope::Public => {
|
FeedScope::Public => {
|
||||||
let (count_sql, data_sql) = builder.public();
|
let total: i64 = sqlx::query_scalar(
|
||||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'",
|
||||||
|
)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
|
|
||||||
|
let sel = feed_select(viewer);
|
||||||
|
let sql = format!("{sel} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
.bind(page.limit())
|
.bind(page.limit())
|
||||||
.bind(page.offset())
|
.bind(page.offset())
|
||||||
.bind(viewer_uuid)
|
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows
|
items: rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -334,20 +221,24 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FeedScope::Search { query } => {
|
FeedScope::Search { query } => {
|
||||||
let (count_sql, data_sql) = builder.search();
|
let total: i64 = sqlx::query_scalar(
|
||||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'",
|
||||||
|
)
|
||||||
.bind(query)
|
.bind(query)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
|
|
||||||
|
let sel = feed_select(viewer);
|
||||||
|
let sql = format!("{sel} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3");
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
.bind(query)
|
.bind(query)
|
||||||
.bind(page.limit())
|
.bind(page.limit())
|
||||||
.bind(page.offset())
|
.bind(page.offset())
|
||||||
.bind(viewer_uuid)
|
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows
|
items: rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -360,20 +251,33 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FeedScope::Tag { tag_name } => {
|
FeedScope::Tag { tag_name } => {
|
||||||
let (count_sql, data_sql) = builder.tag();
|
let total: i64 = sqlx::query_scalar(
|
||||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
"SELECT COUNT(*) FROM thoughts t
|
||||||
|
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||||
|
JOIN tags tg ON tg.id = tt.tag_id
|
||||||
|
WHERE tg.name = $1 AND t.visibility = 'public'",
|
||||||
|
)
|
||||||
.bind(tag_name)
|
.bind(tag_name)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
|
|
||||||
|
let sel = feed_select(viewer);
|
||||||
|
let sql = format!(
|
||||||
|
"{sel}
|
||||||
|
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||||
|
JOIN tags tg ON tg.id = tt.tag_id
|
||||||
|
WHERE tg.name = $1 AND t.visibility = 'public'
|
||||||
|
ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"
|
||||||
|
);
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
.bind(tag_name)
|
.bind(tag_name)
|
||||||
.bind(page.limit())
|
.bind(page.limit())
|
||||||
.bind(page.offset())
|
.bind(page.offset())
|
||||||
.bind(viewer_uuid)
|
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows
|
items: rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -387,14 +291,21 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
|
|
||||||
FeedScope::User { user_id } => {
|
FeedScope::User { user_id } => {
|
||||||
let uid = user_id.as_uuid();
|
let uid = user_id.as_uuid();
|
||||||
let (count_sql, data_sql) = builder.user();
|
// Use nil UUID for unauthenticated viewers — won't match owner or follower checks.
|
||||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
|
||||||
|
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND ($2::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $2 AND following_id = $1 AND state = 'accepted')))))",
|
||||||
|
)
|
||||||
.bind(uid)
|
.bind(uid)
|
||||||
.bind(viewer_uuid)
|
.bind(viewer_uuid)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&data_sql)
|
|
||||||
|
let sel = feed_select(viewer);
|
||||||
|
let sql = format!("{sel} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))) ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
.bind(uid)
|
.bind(uid)
|
||||||
.bind(page.limit())
|
.bind(page.limit())
|
||||||
.bind(page.offset())
|
.bind(page.offset())
|
||||||
@@ -402,6 +313,7 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows
|
items: rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use domain::{
|
|||||||
thought::{NewThought, Thought, Visibility},
|
thought::{NewThought, Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{FeedOptions, FeedQuery, FeedRequest, ThoughtRepository, UserWriter},
|
ports::{FeedQuery, ThoughtRepository, UserWriter},
|
||||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
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) {
|
||||||
@@ -28,7 +28,6 @@ async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thou
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
trepo.save(&t).await.unwrap();
|
trepo.save(&t).await.unwrap();
|
||||||
(u, t)
|
(u, t)
|
||||||
@@ -39,16 +38,13 @@ 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(&FeedRequest {
|
.query(&FeedQuery::public(
|
||||||
query: FeedQuery::public(
|
|
||||||
PageParams {
|
PageParams {
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
),
|
))
|
||||||
options: FeedOptions::default(),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(result.total, 1);
|
assert_eq!(result.total, 1);
|
||||||
@@ -61,17 +57,14 @@ async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
|
|||||||
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(&FeedRequest {
|
.query(&FeedQuery::search(
|
||||||
query: FeedQuery::search(
|
|
||||||
"hello world",
|
"hello world",
|
||||||
PageParams {
|
PageParams {
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
),
|
))
|
||||||
options: FeedOptions::default(),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(result.total >= 1);
|
assert!(result.total >= 1);
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
||||||
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.custom_moods,u.local,u.created_at,u.updated_at
|
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.created_at,u.updated_at
|
||||||
FROM users u JOIN follows f ON f.follower_id=u.id
|
FROM users u JOIN follows f ON f.follower_id=u.id
|
||||||
WHERE f.following_id=$1 AND f.state='accepted'
|
WHERE f.following_id=$1 AND f.state='accepted'
|
||||||
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
||||||
@@ -154,7 +154,7 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
||||||
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.custom_moods,u.local,u.created_at,u.updated_at
|
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.created_at,u.updated_at
|
||||||
FROM users u JOIN follows f ON f.following_id=u.id
|
FROM users u JOIN follows f ON f.following_id=u.id
|
||||||
WHERE f.follower_id=$1 AND f.state='accepted'
|
WHERE f.follower_id=$1 AND f.state='accepted'
|
||||||
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
ORDER BY f.created_at DESC LIMIT $2 OFFSET $3"
|
||||||
@@ -187,59 +187,6 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
Ok(ids.into_iter().map(UserId::from_uuid).collect())
|
Ok(ids.into_iter().map(UserId::from_uuid).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_mutual(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
page: &PageParams,
|
|
||||||
) -> Result<Paginated<User>, DomainError> {
|
|
||||||
let total: i64 = sqlx::query_scalar(
|
|
||||||
"SELECT COUNT(*) FROM follows f1
|
|
||||||
WHERE f1.follower_id = $1 AND f1.state = 'accepted'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM follows f2
|
|
||||||
WHERE f2.follower_id = f1.following_id
|
|
||||||
AND f2.following_id = f1.follower_id
|
|
||||||
AND f2.state = 'accepted'
|
|
||||||
)",
|
|
||||||
)
|
|
||||||
.bind(user_id.as_uuid())
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await
|
|
||||||
.into_domain()?;
|
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
|
||||||
"SELECT u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
|
|
||||||
u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods, u.local,
|
|
||||||
u.created_at, u.updated_at
|
|
||||||
FROM users u
|
|
||||||
JOIN follows f1
|
|
||||||
ON f1.follower_id = $1
|
|
||||||
AND f1.following_id = u.id
|
|
||||||
AND f1.state = 'accepted'
|
|
||||||
WHERE EXISTS (
|
|
||||||
SELECT 1 FROM follows f2
|
|
||||||
WHERE f2.follower_id = u.id
|
|
||||||
AND f2.following_id = $1
|
|
||||||
AND f2.state = 'accepted'
|
|
||||||
)
|
|
||||||
ORDER BY f1.created_at DESC
|
|
||||||
LIMIT $2 OFFSET $3",
|
|
||||||
)
|
|
||||||
.bind(user_id.as_uuid())
|
|
||||||
.bind(page.limit())
|
|
||||||
.bind(page.offset())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.into_domain()?;
|
|
||||||
|
|
||||||
Ok(Paginated {
|
|
||||||
items: rows.into_iter().map(User::from).collect(),
|
|
||||||
total,
|
|
||||||
page: page.page,
|
|
||||||
per_page: page.per_page,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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::*;
|
||||||
|
|
||||||
#[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) {
|
||||||
@@ -55,86 +56,3 @@ async fn get_accepted_following_ids(pool: sqlx::PgPool) {
|
|||||||
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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
|
||||||
async fn list_mutual_returns_only_mutual_accepted_follows(pool: sqlx::PgPool) {
|
|
||||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
|
||||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
|
||||||
let carol = seed_user(&pool, "carol", "carol@ex.com").await;
|
|
||||||
let repo = PgFollowRepository::new(pool);
|
|
||||||
let page = domain::models::feed::PageParams {
|
|
||||||
page: 1,
|
|
||||||
per_page: 20,
|
|
||||||
};
|
|
||||||
|
|
||||||
// alice → bob (accepted), bob → alice (accepted) = friends
|
|
||||||
repo.save(&Follow {
|
|
||||||
follower_id: alice.id.clone(),
|
|
||||||
following_id: bob.id.clone(),
|
|
||||||
state: FollowState::Accepted,
|
|
||||||
ap_id: None,
|
|
||||||
created_at: Utc::now(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
repo.save(&Follow {
|
|
||||||
follower_id: bob.id.clone(),
|
|
||||||
following_id: alice.id.clone(),
|
|
||||||
state: FollowState::Accepted,
|
|
||||||
ap_id: None,
|
|
||||||
created_at: Utc::now(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// alice → carol (accepted), carol does NOT follow back = not a friend
|
|
||||||
repo.save(&Follow {
|
|
||||||
follower_id: alice.id.clone(),
|
|
||||||
following_id: carol.id.clone(),
|
|
||||||
state: FollowState::Accepted,
|
|
||||||
ap_id: None,
|
|
||||||
created_at: Utc::now(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let result = repo.list_mutual(&alice.id, &page).await.unwrap();
|
|
||||||
assert_eq!(result.total, 1);
|
|
||||||
assert_eq!(result.items.len(), 1);
|
|
||||||
assert_eq!(result.items[0].id, bob.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
|
||||||
async fn list_mutual_excludes_pending_follows(pool: sqlx::PgPool) {
|
|
||||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
|
||||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
|
||||||
let repo = PgFollowRepository::new(pool);
|
|
||||||
let page = domain::models::feed::PageParams {
|
|
||||||
page: 1,
|
|
||||||
per_page: 20,
|
|
||||||
};
|
|
||||||
|
|
||||||
// alice → bob (accepted), bob → alice (PENDING) = NOT a friend
|
|
||||||
repo.save(&Follow {
|
|
||||||
follower_id: alice.id.clone(),
|
|
||||||
following_id: bob.id.clone(),
|
|
||||||
state: FollowState::Accepted,
|
|
||||||
ap_id: None,
|
|
||||||
created_at: Utc::now(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
repo.save(&Follow {
|
|
||||||
follower_id: bob.id.clone(),
|
|
||||||
following_id: alice.id.clone(),
|
|
||||||
state: FollowState::Pending,
|
|
||||||
ap_id: None,
|
|
||||||
created_at: Utc::now(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let result = repo.list_mutual(&alice.id, &page).await.unwrap();
|
|
||||||
assert_eq!(result.total, 0);
|
|
||||||
assert!(result.items.is_empty());
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
pub fn parse_name_value(v: Option<serde_json::Value>) -> Vec<(String, String)> {
|
|
||||||
v.and_then(|v| v.as_array().cloned())
|
|
||||||
.map(|arr| {
|
|
||||||
arr.into_iter()
|
|
||||||
.filter_map(|item| {
|
|
||||||
let name = item.get("name")?.as_str()?.to_string();
|
|
||||||
let value = item.get("value")?.as_str()?.to_string();
|
|
||||||
Some((name, value))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn serialize_name_value(fields: &[(String, String)]) -> serde_json::Value {
|
|
||||||
fields
|
|
||||||
.iter()
|
|
||||||
.map(|(n, v)| serde_json::json!({"name": n, "value": v}))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
@@ -2,13 +2,11 @@ pub mod activitypub;
|
|||||||
pub mod api_key;
|
pub mod api_key;
|
||||||
pub mod block;
|
pub mod block;
|
||||||
pub mod boost;
|
pub mod boost;
|
||||||
pub mod constants;
|
|
||||||
mod db_error;
|
mod db_error;
|
||||||
pub mod engagement;
|
pub mod engagement;
|
||||||
pub mod failed_event;
|
pub mod failed_event;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod follow;
|
pub mod follow;
|
||||||
pub(crate) mod jsonb;
|
|
||||||
pub mod like;
|
pub mod like;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod outbox;
|
pub mod outbox;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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::*;
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn like_and_count(pool: sqlx::PgPool) {
|
async fn like_and_count(pool: sqlx::PgPool) {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_helpers;
|
use crate::test_helpers;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::models::notification::NotificationKind;
|
use domain::{
|
||||||
|
models::{notification::NotificationKind, user::User},
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ 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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,40 +18,14 @@ impl PgRemoteActorRepository {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl RemoteActorRepository for PgRemoteActorRepository {
|
impl RemoteActorRepository for PgRemoteActorRepository {
|
||||||
async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> {
|
async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> {
|
||||||
let also_known_as: Option<Vec<&str>> = if a.also_known_as.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(a.also_known_as.iter().map(|s| s.as_str()).collect())
|
|
||||||
};
|
|
||||||
let attachment_json = crate::jsonb::serialize_name_value(&a.attachment);
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO remote_actors(url,handle,display_name,avatar_url,last_fetched_at,
|
"INSERT INTO remote_actors(url,handle,display_name,avatar_url,last_fetched_at)
|
||||||
bio,banner_url,outbox_url,followers_url,following_url,also_known_as,attachment)
|
VALUES($1,$2,$3,$4,$5)
|
||||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
|
||||||
ON CONFLICT(url) DO UPDATE SET
|
avatar_url=EXCLUDED.avatar_url,last_fetched_at=EXCLUDED.last_fetched_at"
|
||||||
handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
|
|
||||||
avatar_url=EXCLUDED.avatar_url,last_fetched_at=EXCLUDED.last_fetched_at,
|
|
||||||
bio=EXCLUDED.bio,banner_url=EXCLUDED.banner_url,
|
|
||||||
outbox_url=EXCLUDED.outbox_url,followers_url=EXCLUDED.followers_url,
|
|
||||||
following_url=EXCLUDED.following_url,also_known_as=EXCLUDED.also_known_as,
|
|
||||||
attachment=EXCLUDED.attachment",
|
|
||||||
)
|
)
|
||||||
.bind(&a.url)
|
.bind(&a.url).bind(&a.handle).bind(&a.display_name).bind(&a.avatar_url).bind(a.last_fetched_at)
|
||||||
.bind(&a.handle)
|
.execute(&self.pool).await.into_domain().map(|_| ())
|
||||||
.bind(&a.display_name)
|
|
||||||
.bind(&a.avatar_url)
|
|
||||||
.bind(a.last_fetched_at)
|
|
||||||
.bind(&a.bio)
|
|
||||||
.bind(&a.banner_url)
|
|
||||||
.bind(&a.outbox_url)
|
|
||||||
.bind(&a.followers_url)
|
|
||||||
.bind(&a.following_url)
|
|
||||||
.bind(also_known_as.as_deref())
|
|
||||||
.bind(&attachment_json)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.into_domain()
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError> {
|
async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError> {
|
||||||
@@ -62,43 +36,24 @@ impl RemoteActorRepository for PgRemoteActorRepository {
|
|||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
avatar_url: Option<String>,
|
avatar_url: Option<String>,
|
||||||
last_fetched_at: DateTime<Utc>,
|
last_fetched_at: DateTime<Utc>,
|
||||||
bio: Option<String>,
|
|
||||||
banner_url: Option<String>,
|
|
||||||
outbox_url: Option<String>,
|
|
||||||
followers_url: Option<String>,
|
|
||||||
following_url: Option<String>,
|
|
||||||
also_known_as: Option<Vec<String>>,
|
|
||||||
inbox_url: Option<String>,
|
|
||||||
shared_inbox_url: Option<String>,
|
|
||||||
attachment: Option<serde_json::Value>,
|
|
||||||
}
|
}
|
||||||
sqlx::query_as::<_, Row>(
|
sqlx::query_as::<_, Row>(
|
||||||
"SELECT url,handle,display_name,avatar_url,last_fetched_at,
|
"SELECT url,handle,display_name,avatar_url,last_fetched_at FROM remote_actors WHERE url=$1"
|
||||||
bio,banner_url,outbox_url,followers_url,following_url,also_known_as,
|
).bind(url).fetch_optional(&self.pool).await
|
||||||
inbox_url,shared_inbox_url,attachment
|
|
||||||
FROM remote_actors WHERE url=$1",
|
|
||||||
)
|
|
||||||
.bind(url)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map(|o| {
|
.map(|o| o.map(|r| RemoteActor {
|
||||||
o.map(|r| RemoteActor {
|
|
||||||
url: r.url,
|
url: r.url,
|
||||||
handle: r.handle,
|
handle: r.handle,
|
||||||
display_name: r.display_name,
|
display_name: r.display_name,
|
||||||
avatar_url: r.avatar_url,
|
avatar_url: r.avatar_url,
|
||||||
last_fetched_at: r.last_fetched_at,
|
last_fetched_at: r.last_fetched_at,
|
||||||
bio: r.bio,
|
bio: None,
|
||||||
banner_url: r.banner_url,
|
banner_url: None,
|
||||||
also_known_as: r.also_known_as.unwrap_or_default(),
|
also_known_as: None,
|
||||||
outbox_url: r.outbox_url,
|
outbox_url: None,
|
||||||
followers_url: r.followers_url,
|
followers_url: None,
|
||||||
following_url: r.following_url,
|
following_url: None,
|
||||||
inbox_url: r.inbox_url,
|
attachment: vec![],
|
||||||
shared_inbox_url: r.shared_inbox_url,
|
}))
|
||||||
attachment: crate::jsonb::parse_name_value(r.attachment),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,6 @@ use domain::{
|
|||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct TagRow {
|
|
||||||
id: i32,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PgTagRepository {
|
pub struct PgTagRepository {
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
}
|
}
|
||||||
@@ -36,7 +30,12 @@ impl TagRepository for PgTagRepository {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
let row = sqlx::query_as::<_, TagRow>("SELECT id,name FROM tags WHERE name=$1")
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: i32,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
let row = sqlx::query_as::<_, Row>("SELECT id,name FROM tags WHERE name=$1")
|
||||||
.bind(&name)
|
.bind(&name)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
@@ -73,7 +72,12 @@ impl TagRepository for PgTagRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result<Vec<Tag>, DomainError> {
|
async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result<Vec<Tag>, DomainError> {
|
||||||
sqlx::query_as::<_, TagRow>(
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
id: i32,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
"SELECT t.id,t.name FROM tags t JOIN thought_tags tt ON tt.tag_id=t.id WHERE tt.thought_id=$1"
|
"SELECT t.id,t.name FROM tags t JOIN thought_tags tt ON tt.tag_id=t.id WHERE tt.thought_id=$1"
|
||||||
).bind(thought_id.as_uuid()).fetch_all(&self.pool).await
|
).bind(thought_id.as_uuid()).fetch_all(&self.pool).await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ async fn attach_and_list(pool: sqlx::PgPool) {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
trepo.save(&t).await.unwrap();
|
trepo.save(&t).await.unwrap();
|
||||||
let repo = PgTagRepository::new(pool);
|
let repo = PgTagRepository::new(pool);
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ pub async fn seed_user_and_thought(pool: &sqlx::PgPool) -> (User, Thought) {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
trepo.save(&t).await.unwrap();
|
trepo.save(&t).await.unwrap();
|
||||||
(user, t)
|
(user, t)
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ pub(crate) struct ThoughtRow {
|
|||||||
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>,
|
pub note_extensions: Option<serde_json::Value>,
|
||||||
pub mood: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<ThoughtRow> for Thought {
|
impl TryFrom<ThoughtRow> for Thought {
|
||||||
@@ -53,20 +52,19 @@ impl TryFrom<ThoughtRow> for Thought {
|
|||||||
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,
|
note_extensions: r.note_extensions,
|
||||||
mood: r.mood,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,note_extensions,mood 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 {
|
||||||
async fn save(&self, t: &Thought) -> Result<(), DomainError> {
|
async fn save(&self, t: &Thought) -> Result<(), DomainError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO thoughts(id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,mood)
|
"INSERT INTO thoughts(id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at)
|
||||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||||
ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()"
|
ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()"
|
||||||
)
|
)
|
||||||
.bind(t.id.as_uuid())
|
.bind(t.id.as_uuid())
|
||||||
@@ -78,7 +76,6 @@ impl ThoughtRepository for PgThoughtRepository {
|
|||||||
.bind(t.sensitive)
|
.bind(t.sensitive)
|
||||||
.bind(t.local)
|
.bind(t.local)
|
||||||
.bind(t.created_at)
|
.bind(t.created_at)
|
||||||
.bind(&t.mood)
|
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
@@ -122,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,note_extensions,mood
|
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.note_extensions,t.mood
|
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",
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_helpers::seed_user;
|
use crate::test_helpers::seed_user;
|
||||||
use domain::models::thought::{NewThought, Thought, Visibility};
|
use domain::{
|
||||||
|
models::thought::{NewThought, Thought, Visibility},
|
||||||
|
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) {
|
||||||
@@ -14,7 +17,6 @@ async fn save_and_find_thought(pool: sqlx::PgPool) {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
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();
|
||||||
@@ -34,7 +36,6 @@ async fn delete_thought(pool: sqlx::PgPool) {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
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();
|
||||||
@@ -54,7 +55,6 @@ async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
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();
|
||||||
@@ -73,7 +73,6 @@ async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
let reply = Thought::new_local(NewThought {
|
let reply = Thought::new_local(NewThought {
|
||||||
id: ThoughtId::new(),
|
id: ThoughtId::new(),
|
||||||
@@ -83,7 +82,6 @@ async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
repo.save(&root).await.unwrap();
|
repo.save(&root).await.unwrap();
|
||||||
repo.save(&reply).await.unwrap();
|
repo.save(&reply).await.unwrap();
|
||||||
|
|||||||
@@ -44,17 +44,27 @@ impl TopFriendRepository for PgTopFriendRepository {
|
|||||||
|
|
||||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError> {
|
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError> {
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct TopFriendRow {
|
struct Row {
|
||||||
tf_user_id: uuid::Uuid,
|
tf_user_id: uuid::Uuid,
|
||||||
friend_id: uuid::Uuid,
|
friend_id: uuid::Uuid,
|
||||||
position: i16,
|
position: i16,
|
||||||
#[sqlx(flatten)]
|
id: uuid::Uuid,
|
||||||
user: crate::user::UserRow,
|
username: String,
|
||||||
|
email: String,
|
||||||
|
password_hash: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
header_url: Option<String>,
|
||||||
|
custom_css: Option<String>,
|
||||||
|
local: bool,
|
||||||
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
let rows = sqlx::query_as::<_, TopFriendRow>(
|
let rows = sqlx::query_as::<_, Row>(
|
||||||
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
|
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
|
||||||
u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
|
u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
|
||||||
u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.custom_moods, u.local,
|
u.avatar_url, u.header_url, u.custom_css, u.local,
|
||||||
u.created_at, u.updated_at
|
u.created_at, u.updated_at
|
||||||
FROM top_friends tf JOIN users u ON u.id=tf.friend_id
|
FROM top_friends tf JOIN users u ON u.id=tf.friend_id
|
||||||
WHERE tf.user_id=$1 ORDER BY tf.position",
|
WHERE tf.user_id=$1 ORDER BY tf.position",
|
||||||
@@ -67,12 +77,27 @@ impl TopFriendRepository for PgTopFriendRepository {
|
|||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
|
use domain::value_objects::{Email, PasswordHash, Username};
|
||||||
let tf = TopFriend {
|
let tf = TopFriend {
|
||||||
user_id: UserId::from_uuid(r.tf_user_id),
|
user_id: UserId::from_uuid(r.tf_user_id),
|
||||||
friend_id: UserId::from_uuid(r.friend_id),
|
friend_id: UserId::from_uuid(r.friend_id),
|
||||||
position: r.position,
|
position: r.position,
|
||||||
};
|
};
|
||||||
(tf, User::from(r.user))
|
let u = User {
|
||||||
|
id: UserId::from_uuid(r.id),
|
||||||
|
username: Username::from_trusted(r.username),
|
||||||
|
email: Email::from_trusted(r.email),
|
||||||
|
password_hash: PasswordHash(r.password_hash),
|
||||||
|
display_name: r.display_name,
|
||||||
|
bio: r.bio,
|
||||||
|
avatar_url: r.avatar_url,
|
||||||
|
header_url: r.header_url,
|
||||||
|
custom_css: r.custom_css,
|
||||||
|
local: r.local,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
};
|
||||||
|
(tf, u)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ pub struct UserRow {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
pub profile_fields: Option<serde_json::Value>,
|
|
||||||
pub custom_moods: Option<serde_json::Value>,
|
|
||||||
pub local: bool,
|
pub local: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
@@ -50,8 +48,6 @@ impl From<UserRow> for User {
|
|||||||
avatar_url: r.avatar_url,
|
avatar_url: r.avatar_url,
|
||||||
header_url: r.header_url,
|
header_url: r.header_url,
|
||||||
custom_css: r.custom_css,
|
custom_css: r.custom_css,
|
||||||
profile_fields: crate::jsonb::parse_name_value(r.profile_fields),
|
|
||||||
custom_moods: crate::jsonb::parse_name_value(r.custom_moods),
|
|
||||||
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,
|
||||||
@@ -61,7 +57,7 @@ impl From<UserRow> for User {
|
|||||||
|
|
||||||
pub const USER_SELECT: &str =
|
pub const USER_SELECT: &str =
|
||||||
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
|
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
|
||||||
custom_css,profile_fields,custom_moods,local,created_at,updated_at FROM users";
|
custom_css,local,created_at,updated_at FROM users";
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserReader for PgUserRepository {
|
impl UserReader for PgUserRepository {
|
||||||
@@ -226,18 +222,14 @@ impl UserReader for PgUserRepository {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserWriter for PgUserRepository {
|
impl UserWriter for PgUserRepository {
|
||||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||||
let profile_fields_json = crate::jsonb::serialize_name_value(&user.profile_fields);
|
|
||||||
let custom_moods_json = crate::jsonb::serialize_name_value(&user.custom_moods);
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,profile_fields,custom_moods,local,created_at,updated_at)
|
"INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,created_at,updated_at)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
username=EXCLUDED.username, email=EXCLUDED.email,
|
username=EXCLUDED.username, email=EXCLUDED.email,
|
||||||
password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name,
|
password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name,
|
||||||
bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url,
|
bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url,
|
||||||
header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css,
|
header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css,
|
||||||
profile_fields=EXCLUDED.profile_fields,
|
|
||||||
custom_moods=EXCLUDED.custom_moods,
|
|
||||||
local=EXCLUDED.local,
|
local=EXCLUDED.local,
|
||||||
updated_at=NOW()"
|
updated_at=NOW()"
|
||||||
)
|
)
|
||||||
@@ -250,8 +242,6 @@ impl UserWriter for PgUserRepository {
|
|||||||
.bind(&user.avatar_url)
|
.bind(&user.avatar_url)
|
||||||
.bind(&user.header_url)
|
.bind(&user.header_url)
|
||||||
.bind(&user.custom_css)
|
.bind(&user.custom_css)
|
||||||
.bind(&profile_fields_json)
|
|
||||||
.bind(&custom_moods_json)
|
|
||||||
.bind(user.local)
|
.bind(user.local)
|
||||||
.bind(user.created_at)
|
.bind(user.created_at)
|
||||||
.bind(user.updated_at)
|
.bind(user.updated_at)
|
||||||
@@ -277,14 +267,6 @@ impl UserWriter for PgUserRepository {
|
|||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
input: UpdateProfileInput,
|
input: UpdateProfileInput,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let profile_fields_json: Option<serde_json::Value> = input
|
|
||||||
.profile_fields
|
|
||||||
.as_ref()
|
|
||||||
.map(|f| crate::jsonb::serialize_name_value(f));
|
|
||||||
let custom_moods_json: Option<serde_json::Value> = input
|
|
||||||
.custom_moods
|
|
||||||
.as_ref()
|
|
||||||
.map(|f| crate::jsonb::serialize_name_value(f));
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE users SET \
|
"UPDATE users SET \
|
||||||
display_name = COALESCE($2, display_name), \
|
display_name = COALESCE($2, display_name), \
|
||||||
@@ -292,8 +274,6 @@ impl UserWriter for PgUserRepository {
|
|||||||
avatar_url = COALESCE($4, avatar_url), \
|
avatar_url = COALESCE($4, avatar_url), \
|
||||||
header_url = COALESCE($5, header_url), \
|
header_url = COALESCE($5, header_url), \
|
||||||
custom_css = COALESCE($6, custom_css), \
|
custom_css = COALESCE($6, custom_css), \
|
||||||
profile_fields = COALESCE($7, profile_fields), \
|
|
||||||
custom_moods = COALESCE($8, custom_moods), \
|
|
||||||
updated_at = NOW() \
|
updated_at = NOW() \
|
||||||
WHERE id = $1",
|
WHERE id = $1",
|
||||||
)
|
)
|
||||||
@@ -303,22 +283,6 @@ impl UserWriter for PgUserRepository {
|
|||||||
.bind(input.avatar_url)
|
.bind(input.avatar_url)
|
||||||
.bind(input.header_url)
|
.bind(input.header_url)
|
||||||
.bind(input.custom_css)
|
.bind(input.custom_css)
|
||||||
.bind(profile_fields_json)
|
|
||||||
.bind(custom_moods_json)
|
|
||||||
.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()
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use domain::models::user::{UpdateProfileInput, User};
|
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) {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ pub struct CreateThoughtRequest {
|
|||||||
pub visibility: Option<String>,
|
pub visibility: Option<String>,
|
||||||
pub content_warning: Option<String>,
|
pub content_warning: Option<String>,
|
||||||
pub sensitive: Option<bool>,
|
pub sensitive: Option<bool>,
|
||||||
pub mood: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, utoipa::ToSchema)]
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
@@ -48,8 +47,6 @@ pub struct UpdateProfileRequest {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
pub profile_fields: Option<Vec<crate::responses::ProfileField>>,
|
|
||||||
pub custom_moods: Option<Vec<crate::responses::ProfileField>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, utoipa::ToSchema)]
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Serialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Serialize, utoipa::ToSchema)]
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
@@ -19,8 +19,6 @@ pub struct UserResponse {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
pub profile_fields: Vec<ProfileField>,
|
|
||||||
pub custom_moods: Vec<ProfileField>,
|
|
||||||
pub local: bool,
|
pub local: bool,
|
||||||
pub is_followed_by_viewer: bool,
|
pub is_followed_by_viewer: bool,
|
||||||
#[serde(rename = "joinedAt")]
|
#[serde(rename = "joinedAt")]
|
||||||
@@ -49,8 +47,6 @@ pub struct ThoughtResponse {
|
|||||||
pub updated_at: Option<DateTime<Utc>>,
|
pub updated_at: Option<DateTime<Utc>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub note_extensions: Option<serde_json::Value>,
|
pub note_extensions: Option<serde_json::Value>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub mood: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, utoipa::ToSchema)]
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
@@ -87,13 +83,6 @@ pub struct TopFriendsResponse {
|
|||||||
pub top_friends: Vec<UserResponse>,
|
pub top_friends: Vec<UserResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, utoipa::ToSchema)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct NotificationSummaryResponse {
|
|
||||||
pub total: i64,
|
|
||||||
pub unread: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, utoipa::ToSchema)]
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ErrorResponse {
|
pub struct ErrorResponse {
|
||||||
@@ -109,7 +98,7 @@ pub struct CreatedApiKeyResponse {
|
|||||||
pub key: String,
|
pub key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
#[derive(Serialize, Clone, utoipa::ToSchema)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ProfileField {
|
pub struct ProfileField {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -125,7 +114,7 @@ pub struct RemoteActorResponse {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
pub banner_url: Option<String>,
|
pub banner_url: Option<String>,
|
||||||
pub also_known_as: Vec<String>,
|
pub also_known_as: Option<String>,
|
||||||
pub outbox_url: Option<String>,
|
pub outbox_url: Option<String>,
|
||||||
pub followers_url: Option<String>,
|
pub followers_url: Option<String>,
|
||||||
pub following_url: Option<String>,
|
pub following_url: Option<String>,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { 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 }
|
||||||
@@ -20,4 +21,3 @@ futures = { workspace = true }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
domain = { workspace = true, features = ["test-helpers"] }
|
domain = { workspace = true, features = ["test-helpers"] }
|
||||||
serde_json = { workspace = true }
|
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
|
use activitypub::{ActivityPubRepository, OutboundFederationPort};
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::thought::Visibility,
|
models::thought::Visibility,
|
||||||
ports::{FederationBroadcastPort, FederationContentRepository, ThoughtRepository, UserReader},
|
ports::{ThoughtRepository, UserReader},
|
||||||
value_objects::ThoughtId,
|
value_objects::ThoughtId,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
fn should_broadcast(t: &domain::models::thought::Thought) -> bool {
|
|
||||||
t.local && matches!(t.visibility, Visibility::Public | Visibility::Unlisted)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct FederationEventService {
|
pub struct FederationEventService {
|
||||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||||
pub users: Arc<dyn UserReader>,
|
pub users: Arc<dyn UserReader>,
|
||||||
pub ap: Arc<dyn FederationBroadcastPort>,
|
pub ap: Arc<dyn OutboundFederationPort>,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub ap_repo: Arc<dyn FederationContentRepository>,
|
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FederationEventService {
|
impl FederationEventService {
|
||||||
@@ -35,11 +32,16 @@ impl FederationEventService {
|
|||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||||
Some(t) if should_broadcast(&t) => t,
|
Some(t)
|
||||||
_ => {
|
if t.local
|
||||||
tracing::debug!(thought_id = %thought_id, "federation: skipping ThoughtCreated (remote or non-public)");
|
&& matches!(
|
||||||
return Ok(());
|
t.visibility,
|
||||||
|
Visibility::Public | Visibility::Unlisted
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
t
|
||||||
}
|
}
|
||||||
|
_ => 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,
|
||||||
@@ -56,7 +58,6 @@ 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,
|
||||||
@@ -71,7 +72,8 @@ impl FederationEventService {
|
|||||||
thought_id,
|
thought_id,
|
||||||
user_id,
|
user_id,
|
||||||
} => {
|
} => {
|
||||||
tracing::info!(thought_id = %thought_id, user_id = %user_id, "federation: broadcasting Delete");
|
// No DB lookup — thought is already deleted when this event fires.
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
@@ -81,7 +83,15 @@ impl FederationEventService {
|
|||||||
user_id,
|
user_id,
|
||||||
} => {
|
} => {
|
||||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||||
Some(t) if should_broadcast(&t) => t,
|
Some(t)
|
||||||
|
if t.local
|
||||||
|
&& matches!(
|
||||||
|
t.visibility,
|
||||||
|
Visibility::Public | Visibility::Unlisted
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
t
|
||||||
|
}
|
||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
};
|
};
|
||||||
let user = match self.users.find_by_id(user_id).await? {
|
let user = match self.users.find_by_id(user_id).await? {
|
||||||
@@ -96,7 +106,6 @@ 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,
|
||||||
@@ -112,15 +121,16 @@ impl FederationEventService {
|
|||||||
user_id,
|
user_id,
|
||||||
thought_id,
|
thought_id,
|
||||||
} => {
|
} => {
|
||||||
if !matches!(self.users.find_by_id(user_id).await?, Some(u) if u.local) {
|
// Only fan-out if the booster is a local user. Remote boosts must not be re-broadcast.
|
||||||
tracing::debug!(user_id = %user_id, "federation: skipping BoostAdded (remote user)");
|
let booster = match self.users.find_by_id(user_id).await? {
|
||||||
return Ok(());
|
Some(u) if u.local => u,
|
||||||
}
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +142,6 @@ 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
|
||||||
@@ -143,26 +152,24 @@ impl FederationEventService {
|
|||||||
user_id,
|
user_id,
|
||||||
thought_id,
|
thought_id,
|
||||||
} => {
|
} => {
|
||||||
if !matches!(self.users.find_by_id(user_id).await?, Some(u) if u.local) {
|
// Only federate: local liker + remote thought (has ap_id) + author has inbox.
|
||||||
tracing::debug!(user_id = %user_id, "federation: skipping LikeAdded (remote user)");
|
let liker = match self.users.find_by_id(user_id).await? {
|
||||||
return Ok(());
|
Some(u) if u.local => u,
|
||||||
}
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
let _ = liker;
|
||||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||||
Some(t) => t,
|
Some(t) => t,
|
||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
};
|
};
|
||||||
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 => {
|
None => return Ok(()), // local thought — no federation needed
|
||||||
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
|
||||||
@@ -172,9 +179,11 @@ impl FederationEventService {
|
|||||||
user_id,
|
user_id,
|
||||||
thought_id,
|
thought_id,
|
||||||
} => {
|
} => {
|
||||||
if !matches!(self.users.find_by_id(user_id).await?, Some(u) if u.local) {
|
let liker = match self.users.find_by_id(user_id).await? {
|
||||||
return Ok(());
|
Some(u) if u.local => u,
|
||||||
}
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
let _ = liker;
|
||||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||||
Some(t) => t,
|
Some(t) => t,
|
||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
@@ -187,14 +196,12 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::testing::TestApRepo;
|
use crate::testing::TestApRepo;
|
||||||
|
use activitypub::{ActorApUrls, OutboundFederationPort};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{ActorFederationUrls, FederationBroadcastPort};
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
@@ -27,7 +27,7 @@ struct SpyPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FederationBroadcastPort for SpyPort {
|
impl OutboundFederationPort for SpyPort {
|
||||||
async fn broadcast_create(
|
async fn broadcast_create(
|
||||||
&self,
|
&self,
|
||||||
_: &UserId,
|
_: &UserId,
|
||||||
@@ -100,7 +100,6 @@ fn local_thought(author_id: UserId) -> Thought {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +283,6 @@ async fn direct_thought_created_does_not_broadcast() {
|
|||||||
visibility: Visibility::Direct,
|
visibility: Visibility::Direct,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
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());
|
||||||
@@ -314,7 +312,6 @@ async fn followers_only_thought_does_not_broadcast_publicly() {
|
|||||||
visibility: Visibility::Followers,
|
visibility: Visibility::Followers,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
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());
|
||||||
@@ -482,7 +479,7 @@ async fn like_added_local_user_remote_thought_broadcasts_like() {
|
|||||||
let ap_repo = TestApRepo::new(store.clone());
|
let ap_repo = TestApRepo::new(store.clone());
|
||||||
ap_repo.actor_ap_urls.lock().unwrap().insert(
|
ap_repo.actor_ap_urls.lock().unwrap().insert(
|
||||||
author.id.clone(),
|
author.id.clone(),
|
||||||
ActorFederationUrls {
|
ActorApUrls {
|
||||||
ap_id: "https://mastodon.social/users/author".into(),
|
ap_id: "https://mastodon.social/users/author".into(),
|
||||||
inbox_url: "https://mastodon.social/users/author/inbox".into(),
|
inbox_url: "https://mastodon.social/users/author/inbox".into(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
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(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
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;
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ 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(),
|
||||||
@@ -61,7 +60,6 @@ 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(),
|
||||||
@@ -79,7 +77,6 @@ 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(),
|
||||||
@@ -108,7 +105,6 @@ 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(),
|
||||||
@@ -127,7 +123,6 @@ 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(),
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ async fn like_creates_notification_for_thought_author() {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
store.thoughts.lock().unwrap().push(thought.clone());
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
let svc = NotificationEventService {
|
let svc = NotificationEventService {
|
||||||
@@ -63,7 +62,6 @@ async fn self_like_creates_no_notification() {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
store.thoughts.lock().unwrap().push(thought.clone());
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
let svc = NotificationEventService {
|
let svc = NotificationEventService {
|
||||||
@@ -113,7 +111,6 @@ async fn reply_creates_notification_for_original_author() {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
store.thoughts.lock().unwrap().push(original.clone());
|
store.thoughts.lock().unwrap().push(original.clone());
|
||||||
let svc = NotificationEventService {
|
let svc = NotificationEventService {
|
||||||
@@ -144,7 +141,6 @@ async fn self_reply_creates_no_notification() {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
store.thoughts.lock().unwrap().push(original.clone());
|
store.thoughts.lock().unwrap().push(original.clone());
|
||||||
let svc = NotificationEventService {
|
let svc = NotificationEventService {
|
||||||
@@ -173,7 +169,6 @@ async fn self_boost_creates_no_notification() {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
store.thoughts.lock().unwrap().push(thought.clone());
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
let svc = NotificationEventService {
|
let svc = NotificationEventService {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
/// Test helpers for application-layer tests that need activitypub traits.
|
||||||
|
use activitypub::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::user::User,
|
models::user::User,
|
||||||
ports::{AcceptNoteInput, ActorFederationUrls, FederationContentRepository, OutboxEntry},
|
|
||||||
testing::TestStore,
|
testing::TestStore,
|
||||||
value_objects::{Email, ThoughtId, UserId, Username},
|
value_objects::{Email, PasswordHash, ThoughtId, UserId, Username},
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -13,8 +14,8 @@ use std::sync::{Arc, Mutex};
|
|||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
pub struct TestApRepo {
|
pub struct TestApRepo {
|
||||||
pub inner: TestStore,
|
pub inner: TestStore,
|
||||||
/// UserId → ActorFederationUrls (for get_actor_ap_urls)
|
/// UserId → ActorApUrls (for get_actor_ap_urls)
|
||||||
pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorFederationUrls>>>,
|
pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorApUrls>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestApRepo {
|
impl TestApRepo {
|
||||||
@@ -27,7 +28,7 @@ impl TestApRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FederationContentRepository for TestApRepo {
|
impl ActivityPubRepository for TestApRepo {
|
||||||
async fn outbox_entries_for_actor(
|
async fn outbox_entries_for_actor(
|
||||||
&self,
|
&self,
|
||||||
_uid: &UserId,
|
_uid: &UserId,
|
||||||
@@ -62,11 +63,20 @@ impl FederationContentRepository for TestApRepo {
|
|||||||
let handle = url::Url::parse(actor_ap_url)
|
let handle = url::Url::parse(actor_ap_url)
|
||||||
.map(|u| u.path().trim_start_matches('/').replace('/', "_"))
|
.map(|u| u.path().trim_start_matches('/').replace('/', "_"))
|
||||||
.unwrap_or_else(|_| format!("remote_{}", &uid.to_string()[..8]));
|
.unwrap_or_else(|_| format!("remote_{}", &uid.to_string()[..8]));
|
||||||
let user = User::new_remote(
|
let user = User {
|
||||||
uid.clone(),
|
id: uid.clone(),
|
||||||
Username::from_trusted(handle),
|
username: Username::from_trusted(handle),
|
||||||
Email::from_trusted(format!("{}@remote", uid)),
|
email: Email::from_trusted(format!("{}@remote", uid)),
|
||||||
);
|
password_hash: PasswordHash("".into()),
|
||||||
|
display_name: None,
|
||||||
|
bio: None,
|
||||||
|
avatar_url: None,
|
||||||
|
header_url: None,
|
||||||
|
custom_css: None,
|
||||||
|
local: false,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
updated_at: chrono::Utc::now(),
|
||||||
|
};
|
||||||
self.inner.users.lock().unwrap().push(user);
|
self.inner.users.lock().unwrap().push(user);
|
||||||
self.inner
|
self.inner
|
||||||
.actor_ap_ids
|
.actor_ap_ids
|
||||||
@@ -83,15 +93,13 @@ impl FederationContentRepository for TestApRepo {
|
|||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn accept_note(&self, _input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError> {
|
async fn accept_note(
|
||||||
|
&self,
|
||||||
|
_input: activitypub::AcceptNoteInput<'_>,
|
||||||
|
) -> 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,
|
|
||||||
_: Option<serde_json::Value>,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
|
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
|
||||||
@@ -125,10 +133,7 @@ impl FederationContentRepository for TestApRepo {
|
|||||||
async fn get_actor_ap_urls(
|
async fn get_actor_ap_urls(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
) -> Result<Option<ActorFederationUrls>, DomainError> {
|
) -> Result<Option<ActorApUrls>, DomainError> {
|
||||||
Ok(self.actor_ap_urls.lock().unwrap().get(user_id).cloned())
|
Ok(self.actor_ap_urls.lock().unwrap().get(user_id).cloned())
|
||||||
}
|
}
|
||||||
async fn sync_remote_actor_to_user(&self, _actor_ap_url: &str) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,13 +60,6 @@ impl UserWriter for ConflictOnSaveStore {
|
|||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
self.0.update_profile(user_id, input).await
|
self.0.update_profile(user_id, input).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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -112,13 +105,6 @@ impl UserWriter for EmailConflictOnSaveStore {
|
|||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
self.0.update_profile(user_id, input).await
|
self.0.update_profile(user_id, input).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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FakeHasher;
|
struct FakeHasher;
|
||||||
|
|||||||
@@ -1,36 +1,21 @@
|
|||||||
|
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, FederationContentRepository, FederationFollowPort,
|
EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort,
|
||||||
FederationFollowRequestPort, FederationSchedulerPort, FeedOptions, FeedQuery,
|
FederationSchedulerPort, FeedQuery, FeedRepository, FollowRepository,
|
||||||
FeedRepository, FeedRequest, FollowRepository, RemoteActorConnectionRepository, UserReader,
|
RemoteActorConnectionRepository, UserReader,
|
||||||
UserWriter,
|
|
||||||
},
|
},
|
||||||
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,
|
||||||
@@ -40,34 +25,18 @@ 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> {
|
||||||
events
|
federation.accept_follow_request(user_id, actor_url).await
|
||||||
.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()))?;
|
|
||||||
federation.mark_follower_accepted(user_id, actor_url).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
||||||
events
|
federation.reject_follow_request(user_id, actor_url).await
|
||||||
.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()))?;
|
|
||||||
federation.mark_follower_rejected(user_id, actor_url).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_remote_followers(
|
pub async fn list_remote_followers(
|
||||||
@@ -92,20 +61,6 @@ pub async fn list_remote_following(
|
|||||||
federation.get_remote_following(user_id).await
|
federation.get_remote_following(user_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_remote_friends(
|
|
||||||
federation: &dyn FederationActionPort,
|
|
||||||
user_id: &UserId,
|
|
||||||
) -> Result<Vec<RemoteActor>, DomainError> {
|
|
||||||
use std::collections::HashSet;
|
|
||||||
let following = federation.get_remote_following(user_id).await?;
|
|
||||||
let followers = federation.get_remote_followers(user_id).await?;
|
|
||||||
let follower_urls: HashSet<&str> = followers.iter().map(|a| a.url.as_str()).collect();
|
|
||||||
Ok(following
|
|
||||||
.into_iter()
|
|
||||||
.filter(|a| follower_urls.contains(a.url.as_str()))
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn remove_remote_following(
|
pub async fn remove_remote_following(
|
||||||
follows: &dyn FollowRepository,
|
follows: &dyn FollowRepository,
|
||||||
users: &dyn UserReader,
|
users: &dyn UserReader,
|
||||||
@@ -119,7 +74,7 @@ pub async fn remove_remote_following(
|
|||||||
|
|
||||||
pub async fn get_remote_actor_posts(
|
pub async fn get_remote_actor_posts(
|
||||||
federation: &dyn FederationActionPort,
|
federation: &dyn FederationActionPort,
|
||||||
ap_repo: &dyn FederationContentRepository,
|
ap_repo: &dyn ActivityPubRepository,
|
||||||
feed: &dyn FeedRepository,
|
feed: &dyn FeedRepository,
|
||||||
scheduler: &dyn FederationSchedulerPort,
|
scheduler: &dyn FederationSchedulerPort,
|
||||||
handle: &str,
|
handle: &str,
|
||||||
@@ -132,10 +87,11 @@ pub async fn get_remote_actor_posts(
|
|||||||
None => ap_repo.intern_remote_actor(&actor.url).await?,
|
None => ap_repo.intern_remote_actor(&actor.url).await?,
|
||||||
};
|
};
|
||||||
let result = feed
|
let result = feed
|
||||||
.query(&FeedRequest {
|
.query(&FeedQuery::user(
|
||||||
query: FeedQuery::user(author_id, page.clone(), viewer_id.cloned()),
|
author_id,
|
||||||
options: FeedOptions::default(),
|
page.clone(),
|
||||||
})
|
viewer_id.cloned(),
|
||||||
|
))
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(outbox_url) = actor.outbox_url {
|
if let Some(outbox_url) = actor.outbox_url {
|
||||||
let _ = scheduler
|
let _ = scheduler
|
||||||
@@ -175,22 +131,13 @@ pub async fn get_actor_connections_page(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if stale {
|
if stale {
|
||||||
// Always fetch from page 1 — the full collection is fetched and chunked.
|
|
||||||
let _ = scheduler
|
let _ = scheduler
|
||||||
.schedule_connections_fetch(&actor.url, &collection_url, connection_type, 1)
|
.schedule_connections_fetch(&actor.url, &collection_url, connection_type, page)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
let has_more = items.len() >= PAGE_SIZE;
|
let has_more = items.len() >= PAGE_SIZE;
|
||||||
Ok((items, has_more))
|
Ok((items, has_more))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_also_known_as(
|
|
||||||
users: &dyn UserWriter,
|
|
||||||
user_id: &UserId,
|
|
||||||
value: Option<String>,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
users.set_also_known_as(user_id, value).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -1,27 +1,6 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use chrono::Utc;
|
|
||||||
use domain::models::remote_actor::RemoteActor;
|
|
||||||
use domain::testing::TestStore;
|
use domain::testing::TestStore;
|
||||||
|
|
||||||
fn remote_actor(url: &str, handle: &str) -> RemoteActor {
|
|
||||||
RemoteActor {
|
|
||||||
url: url.to_string(),
|
|
||||||
handle: handle.to_string(),
|
|
||||||
display_name: None,
|
|
||||||
avatar_url: None,
|
|
||||||
bio: None,
|
|
||||||
banner_url: None,
|
|
||||||
also_known_as: vec![],
|
|
||||||
outbox_url: None,
|
|
||||||
followers_url: None,
|
|
||||||
following_url: None,
|
|
||||||
inbox_url: None,
|
|
||||||
shared_inbox_url: None,
|
|
||||||
attachment: vec![],
|
|
||||||
last_fetched_at: Utc::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_pending_returns_empty_by_default() {
|
async fn list_pending_returns_empty_by_default() {
|
||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
@@ -34,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, &store, &uid, "https://mastodon.social/users/alice")
|
accept_follow_request(&store, &uid, "https://mastodon.social/users/alice")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -43,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, &store, &uid, "https://mastodon.social/users/alice")
|
reject_follow_request(&store, &uid, "https://mastodon.social/users/alice")
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -72,41 +51,3 @@ async fn list_remote_following_returns_empty_by_default() {
|
|||||||
let result = list_remote_following(&store, &uid).await.unwrap();
|
let result = list_remote_following(&store, &uid).await.unwrap();
|
||||||
assert!(result.is_empty());
|
assert!(result.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_remote_friends_returns_intersection() {
|
|
||||||
let store = TestStore::default();
|
|
||||||
let uid = UserId::new();
|
|
||||||
|
|
||||||
let bob = remote_actor("https://bob.example.com/users/bob", "bob@bob.example.com");
|
|
||||||
let carol = remote_actor(
|
|
||||||
"https://carol.example.com/users/carol",
|
|
||||||
"carol@carol.example.com",
|
|
||||||
);
|
|
||||||
|
|
||||||
// uid follows bob and carol
|
|
||||||
store
|
|
||||||
.remote_following
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.extend([bob.clone(), carol.clone()]);
|
|
||||||
// only bob follows back
|
|
||||||
store.remote_followers.lock().unwrap().push(bob.clone());
|
|
||||||
|
|
||||||
let friends = get_remote_friends(&store, &uid).await.unwrap();
|
|
||||||
assert_eq!(friends.len(), 1);
|
|
||||||
assert_eq!(friends[0].url, bob.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_remote_friends_empty_when_no_mutual() {
|
|
||||||
let store = TestStore::default();
|
|
||||||
let uid = UserId::new();
|
|
||||||
|
|
||||||
let bob = remote_actor("https://bob.example.com/users/bob", "bob@bob.example.com");
|
|
||||||
store.remote_following.lock().unwrap().push(bob.clone());
|
|
||||||
// bob does NOT follow back
|
|
||||||
|
|
||||||
let friends = get_remote_friends(&store, &uid).await.unwrap();
|
|
||||||
assert!(friends.is_empty());
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::feed::{FeedEntry, PageParams, Paginated},
|
models::feed::{FeedEntry, PageParams, Paginated},
|
||||||
ports::{FeedOptions, FeedQuery, FeedRepository, FeedRequest, FollowRepository, TagRepository},
|
ports::{FeedQuery, FeedRepository, FollowRepository},
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -10,61 +10,9 @@ pub async fn get_home_feed(
|
|||||||
follows: &dyn FollowRepository,
|
follows: &dyn FollowRepository,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
page: PageParams,
|
page: PageParams,
|
||||||
opts: FeedOptions,
|
|
||||||
) -> 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(&FeedRequest {
|
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page))
|
||||||
query: FeedQuery::home(user_id.clone(), following_ids, page),
|
|
||||||
options: opts,
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_public_feed(
|
|
||||||
feed: &dyn FeedRepository,
|
|
||||||
viewer: Option<UserId>,
|
|
||||||
page: PageParams,
|
|
||||||
opts: FeedOptions,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
feed.query(&FeedRequest {
|
|
||||||
query: FeedQuery::public(page, viewer),
|
|
||||||
options: opts,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_user_feed(
|
|
||||||
feed: &dyn FeedRepository,
|
|
||||||
user_id: UserId,
|
|
||||||
page: PageParams,
|
|
||||||
opts: FeedOptions,
|
|
||||||
viewer: Option<UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
feed.query(&FeedRequest {
|
|
||||||
query: FeedQuery::user(user_id, page, viewer),
|
|
||||||
options: opts,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_tag_feed(
|
|
||||||
feed: &dyn FeedRepository,
|
|
||||||
tag: &str,
|
|
||||||
page: PageParams,
|
|
||||||
opts: FeedOptions,
|
|
||||||
viewer: Option<UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
feed.query(&FeedRequest {
|
|
||||||
query: FeedQuery::tag(tag, page, viewer),
|
|
||||||
options: opts,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_popular_tags(
|
|
||||||
tags: &dyn TagRepository,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<(String, i64)>, DomainError> {
|
|
||||||
tags.popular_tags(limit).await
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
const MAX_TOP_FRIENDS: usize = 8;
|
const MAX_TOP_FRIENDS: usize = 8;
|
||||||
const MAX_PROFILE_FIELDS: usize = 4;
|
|
||||||
const MAX_FIELD_NAME_LEN: usize = 64;
|
|
||||||
const MAX_FIELD_VALUE_LEN: usize = 256;
|
|
||||||
const MAX_CUSTOM_MOODS: usize = 8;
|
|
||||||
const MAX_MOOD_LABEL_LEN: usize = 32;
|
|
||||||
const MAX_MOOD_EMOJI_LEN: usize = 8;
|
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
feed::{PageParams, Paginated, UserSummary},
|
|
||||||
top_friend::TopFriend,
|
top_friend::TopFriend,
|
||||||
user::{UpdateProfileInput, User},
|
user::{UpdateProfileInput, User},
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
EventPublisher, FollowRepository, MediaStore, TopFriendRepository, UserReader,
|
EventPublisher, MediaStore, TopFriendRepository, UserReader, UserRepository, UserWriter,
|
||||||
UserRepository, UserWriter,
|
|
||||||
},
|
},
|
||||||
value_objects::{UserId, Username},
|
value_objects::{UserId, Username},
|
||||||
};
|
};
|
||||||
@@ -61,34 +53,6 @@ pub async fn update_profile(
|
|||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
input: UpdateProfileInput,
|
input: UpdateProfileInput,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
if let Some(ref fields) = input.profile_fields {
|
|
||||||
if fields.len() > MAX_PROFILE_FIELDS {
|
|
||||||
return Err(DomainError::InvalidInput(format!(
|
|
||||||
"profile fields: max {MAX_PROFILE_FIELDS}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
for (name, value) in fields {
|
|
||||||
if name.len() > MAX_FIELD_NAME_LEN || value.len() > MAX_FIELD_VALUE_LEN {
|
|
||||||
return Err(DomainError::InvalidInput(
|
|
||||||
"profile field name or value too long".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(ref moods) = input.custom_moods {
|
|
||||||
if moods.len() > MAX_CUSTOM_MOODS {
|
|
||||||
return Err(DomainError::InvalidInput(format!(
|
|
||||||
"custom moods: max {MAX_CUSTOM_MOODS}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
for (label, emoji) in moods {
|
|
||||||
if label.len() > MAX_MOOD_LABEL_LEN || emoji.len() > MAX_MOOD_EMOJI_LEN {
|
|
||||||
return Err(DomainError::InvalidInput(
|
|
||||||
"custom mood label or emoji too long".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
users.update_profile(user_id, input).await?;
|
users.update_profile(user_id, input).await?;
|
||||||
events
|
events
|
||||||
.publish(&DomainEvent::ProfileUpdated {
|
.publish(&DomainEvent::ProfileUpdated {
|
||||||
@@ -152,25 +116,17 @@ fn mime_to_ext(mime: &str) -> Result<&'static str, DomainError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct UploadContext<'a> {
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub users: &'a dyn UserRepository,
|
|
||||||
pub media: &'a dyn MediaStore,
|
|
||||||
pub events: &'a dyn EventPublisher,
|
|
||||||
pub upload_config: &'a UploadConfig,
|
|
||||||
pub base_url: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn store_image(
|
async fn store_image(
|
||||||
ctx: &UploadContext<'_>,
|
media: &dyn MediaStore,
|
||||||
|
base_url: &str,
|
||||||
|
cfg: &UploadConfig,
|
||||||
content_type: &str,
|
content_type: &str,
|
||||||
data: Bytes,
|
data: Bytes,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
key_segment: &str,
|
key_segment: &str,
|
||||||
old_url: Option<&str>,
|
old_url: Option<&str>,
|
||||||
) -> Result<String, DomainError> {
|
) -> Result<String, DomainError> {
|
||||||
let cfg = ctx.upload_config;
|
|
||||||
let media = ctx.media;
|
|
||||||
let base_url = ctx.base_url;
|
|
||||||
if !cfg.allowed_content_types.iter().any(|t| t == content_type) {
|
if !cfg.allowed_content_types.iter().any(|t| t == content_type) {
|
||||||
return Err(DomainError::InvalidInput("unsupported content type".into()));
|
return Err(DomainError::InvalidInput("unsupported content type".into()));
|
||||||
}
|
}
|
||||||
@@ -190,19 +146,25 @@ async fn store_image(
|
|||||||
Ok(key)
|
Ok(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn upload_avatar(
|
pub async fn upload_avatar(
|
||||||
ctx: &UploadContext<'_>,
|
users: &dyn UserRepository,
|
||||||
|
media: &dyn MediaStore,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
|
base_url: &str,
|
||||||
|
cfg: &UploadConfig,
|
||||||
content_type: &str,
|
content_type: &str,
|
||||||
data: Bytes,
|
data: Bytes,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let current = ctx
|
let current = users
|
||||||
.users
|
|
||||||
.find_by_id(user_id)
|
.find_by_id(user_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(DomainError::NotFound)?;
|
.ok_or(DomainError::NotFound)?;
|
||||||
let key = store_image(
|
let key = store_image(
|
||||||
ctx,
|
media,
|
||||||
|
base_url,
|
||||||
|
cfg,
|
||||||
content_type,
|
content_type,
|
||||||
data,
|
data,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -210,35 +172,41 @@ pub async fn upload_avatar(
|
|||||||
current.avatar_url.as_deref(),
|
current.avatar_url.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
ctx.users
|
users
|
||||||
.update_profile(
|
.update_profile(
|
||||||
user_id,
|
user_id,
|
||||||
UpdateProfileInput {
|
UpdateProfileInput {
|
||||||
avatar_url: Some(format!("{}/media/{key}", ctx.base_url)),
|
avatar_url: Some(format!("{base_url}/media/{key}")),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
ctx.events
|
events
|
||||||
.publish(&DomainEvent::ProfileUpdated {
|
.publish(&DomainEvent::ProfileUpdated {
|
||||||
user_id: user_id.clone(),
|
user_id: user_id.clone(),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn upload_banner(
|
pub async fn upload_banner(
|
||||||
ctx: &UploadContext<'_>,
|
users: &dyn UserRepository,
|
||||||
|
media: &dyn MediaStore,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
|
base_url: &str,
|
||||||
|
cfg: &UploadConfig,
|
||||||
content_type: &str,
|
content_type: &str,
|
||||||
data: Bytes,
|
data: Bytes,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let current = ctx
|
let current = users
|
||||||
.users
|
|
||||||
.find_by_id(user_id)
|
.find_by_id(user_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(DomainError::NotFound)?;
|
.ok_or(DomainError::NotFound)?;
|
||||||
let key = store_image(
|
let key = store_image(
|
||||||
ctx,
|
media,
|
||||||
|
base_url,
|
||||||
|
cfg,
|
||||||
content_type,
|
content_type,
|
||||||
data,
|
data,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -246,62 +214,21 @@ pub async fn upload_banner(
|
|||||||
current.header_url.as_deref(),
|
current.header_url.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
ctx.users
|
users
|
||||||
.update_profile(
|
.update_profile(
|
||||||
user_id,
|
user_id,
|
||||||
UpdateProfileInput {
|
UpdateProfileInput {
|
||||||
header_url: Some(format!("{}/media/{key}", ctx.base_url)),
|
header_url: Some(format!("{base_url}/media/{key}")),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
ctx.events
|
events
|
||||||
.publish(&DomainEvent::ProfileUpdated {
|
.publish(&DomainEvent::ProfileUpdated {
|
||||||
user_id: user_id.clone(),
|
user_id: user_id.clone(),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user_profile(
|
|
||||||
users: &dyn UserReader,
|
|
||||||
follows: &dyn FollowRepository,
|
|
||||||
id_or_username: &str,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<(User, bool), DomainError> {
|
|
||||||
let user = get_user_by_id_or_username(users, id_or_username).await?;
|
|
||||||
let is_followed = match viewer_id {
|
|
||||||
Some(vid) if vid != &user.id => follows.find(vid, &user.id).await?.is_some(),
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
Ok((user, is_followed))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_users(
|
|
||||||
users: &dyn UserReader,
|
|
||||||
page: PageParams,
|
|
||||||
) -> Result<Paginated<UserSummary>, DomainError> {
|
|
||||||
users.list_paginated(page).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn count_local_users(users: &dyn UserReader) -> Result<i64, DomainError> {
|
|
||||||
users.count().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_local_followers(
|
|
||||||
follows: &dyn FollowRepository,
|
|
||||||
user_id: &UserId,
|
|
||||||
page: PageParams,
|
|
||||||
) -> Result<Paginated<User>, DomainError> {
|
|
||||||
follows.list_followers(user_id, &page).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_local_following(
|
|
||||||
follows: &dyn FollowRepository,
|
|
||||||
user_id: &UserId,
|
|
||||||
page: PageParams,
|
|
||||||
) -> Result<Paginated<User>, DomainError> {
|
|
||||||
follows.list_following(user_id, &page).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -113,29 +113,22 @@ fn default_cfg() -> UploadConfig {
|
|||||||
UploadConfig::default()
|
UploadConfig::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_ctx<'a>(
|
|
||||||
store: &'a TestStore,
|
|
||||||
media: &'a MockMedia,
|
|
||||||
cfg: &'a UploadConfig,
|
|
||||||
) -> UploadContext<'a> {
|
|
||||||
UploadContext {
|
|
||||||
users: store,
|
|
||||||
media,
|
|
||||||
events: store,
|
|
||||||
upload_config: cfg,
|
|
||||||
base_url: "http://localhost",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn upload_avatar_rejects_unsupported_mime() {
|
async fn upload_avatar_rejects_unsupported_mime() {
|
||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
let media = MockMedia::default();
|
let media = MockMedia::default();
|
||||||
let user = make_user();
|
let user = make_user();
|
||||||
store.users.lock().unwrap().push(user.clone());
|
store.users.lock().unwrap().push(user.clone());
|
||||||
let cfg = default_cfg();
|
let err = upload_avatar(
|
||||||
let ctx = make_ctx(&store, &media, &cfg);
|
&store,
|
||||||
let err = upload_avatar(&ctx, &user.id, "text/plain", Bytes::from("hi"))
|
&media,
|
||||||
|
&store,
|
||||||
|
&user.id,
|
||||||
|
"http://localhost",
|
||||||
|
&default_cfg(),
|
||||||
|
"text/plain",
|
||||||
|
Bytes::from("hi"),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, DomainError::InvalidInput(_)));
|
assert!(matches!(err, DomainError::InvalidInput(_)));
|
||||||
@@ -148,9 +141,16 @@ async fn upload_avatar_rejects_oversized_data() {
|
|||||||
let user = make_user();
|
let user = make_user();
|
||||||
store.users.lock().unwrap().push(user.clone());
|
store.users.lock().unwrap().push(user.clone());
|
||||||
let big = Bytes::from(vec![0u8; 6 * 1024 * 1024]);
|
let big = Bytes::from(vec![0u8; 6 * 1024 * 1024]);
|
||||||
let cfg = default_cfg();
|
let err = upload_avatar(
|
||||||
let ctx = make_ctx(&store, &media, &cfg);
|
&store,
|
||||||
let err = upload_avatar(&ctx, &user.id, "image/jpeg", big)
|
&media,
|
||||||
|
&store,
|
||||||
|
&user.id,
|
||||||
|
"http://localhost",
|
||||||
|
&default_cfg(),
|
||||||
|
"image/jpeg",
|
||||||
|
big,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, DomainError::InvalidInput(_)));
|
assert!(matches!(err, DomainError::InvalidInput(_)));
|
||||||
@@ -162,9 +162,16 @@ async fn upload_avatar_stores_file_and_updates_url() {
|
|||||||
let media = MockMedia::default();
|
let media = MockMedia::default();
|
||||||
let user = make_user();
|
let user = make_user();
|
||||||
store.users.lock().unwrap().push(user.clone());
|
store.users.lock().unwrap().push(user.clone());
|
||||||
let cfg = default_cfg();
|
upload_avatar(
|
||||||
let ctx = make_ctx(&store, &media, &cfg);
|
&store,
|
||||||
upload_avatar(&ctx, &user.id, "image/jpeg", Bytes::from("img"))
|
&media,
|
||||||
|
&store,
|
||||||
|
&user.id,
|
||||||
|
"http://localhost",
|
||||||
|
&default_cfg(),
|
||||||
|
"image/jpeg",
|
||||||
|
Bytes::from("img"),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let key = format!("users/{}/avatar.jpg", user.id.as_uuid());
|
let key = format!("users/{}/avatar.jpg", user.id.as_uuid());
|
||||||
@@ -196,9 +203,16 @@ async fn upload_avatar_deletes_old_file_on_reupload() {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.insert(old_key.clone(), Bytes::from("old"));
|
.insert(old_key.clone(), Bytes::from("old"));
|
||||||
let cfg = default_cfg();
|
upload_avatar(
|
||||||
let ctx = make_ctx(&store, &media, &cfg);
|
&store,
|
||||||
upload_avatar(&ctx, &user.id, "image/jpeg", Bytes::from("new"))
|
&media,
|
||||||
|
&store,
|
||||||
|
&user.id,
|
||||||
|
"http://localhost",
|
||||||
|
&default_cfg(),
|
||||||
|
"image/jpeg",
|
||||||
|
Bytes::from("new"),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!media.store.lock().unwrap().contains_key(&old_key));
|
assert!(!media.store.lock().unwrap().contains_key(&old_key));
|
||||||
@@ -211,9 +225,16 @@ async fn upload_banner_stores_file_and_updates_header_url() {
|
|||||||
let media = MockMedia::default();
|
let media = MockMedia::default();
|
||||||
let user = make_user();
|
let user = make_user();
|
||||||
store.users.lock().unwrap().push(user.clone());
|
store.users.lock().unwrap().push(user.clone());
|
||||||
let cfg = default_cfg();
|
upload_banner(
|
||||||
let ctx = make_ctx(&store, &media, &cfg);
|
&store,
|
||||||
upload_banner(&ctx, &user.id, "image/png", Bytes::from("banner"))
|
&media,
|
||||||
|
&store,
|
||||||
|
&user.id,
|
||||||
|
"http://localhost",
|
||||||
|
&default_cfg(),
|
||||||
|
"image/png",
|
||||||
|
Bytes::from("banner"),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let key = format!("users/{}/banner.png", user.id.as_uuid());
|
let key = format!("users/{}/banner.png", user.id.as_uuid());
|
||||||
@@ -245,9 +266,16 @@ async fn upload_banner_deletes_old_file_on_reupload() {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.insert(old_key.clone(), Bytes::from("old"));
|
.insert(old_key.clone(), Bytes::from("old"));
|
||||||
let cfg = default_cfg();
|
upload_banner(
|
||||||
let ctx = make_ctx(&store, &media, &cfg);
|
&store,
|
||||||
upload_banner(&ctx, &user.id, "image/png", Bytes::from("new"))
|
&media,
|
||||||
|
&store,
|
||||||
|
&user.id,
|
||||||
|
"http://localhost",
|
||||||
|
&default_cfg(),
|
||||||
|
"image/png",
|
||||||
|
Bytes::from("new"),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!media.store.lock().unwrap().contains_key(&old_key));
|
assert!(!media.store.lock().unwrap().contains_key(&old_key));
|
||||||
|
|||||||
@@ -2,14 +2,10 @@ use chrono::Utc;
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{
|
models::social::{Block, Boost, Follow, FollowState, Like},
|
||||||
feed::{PageParams, Paginated},
|
|
||||||
social::{Block, Boost, Follow, FollowState, Like},
|
|
||||||
user::User,
|
|
||||||
},
|
|
||||||
ports::{
|
ports::{
|
||||||
BlockRepository, BoostRepository, EventPublisher, FederationBlockPort,
|
BlockRepository, BoostRepository, EventPublisher, FederationFollowPort, FollowRepository,
|
||||||
FederationFollowPort, FollowRepository, LikeRepository, UserReader,
|
LikeRepository, UserReader,
|
||||||
},
|
},
|
||||||
value_objects::{BoostId, LikeId, ThoughtId, UserId, Username},
|
value_objects::{BoostId, LikeId, ThoughtId, UserId, Username},
|
||||||
};
|
};
|
||||||
@@ -217,14 +213,10 @@ pub async fn reject_follow(
|
|||||||
pub async fn block_by_username(
|
pub async fn block_by_username(
|
||||||
blocks: &dyn BlockRepository,
|
blocks: &dyn BlockRepository,
|
||||||
users: &dyn UserReader,
|
users: &dyn UserReader,
|
||||||
federation: &dyn FederationBlockPort,
|
|
||||||
events: &dyn EventPublisher,
|
events: &dyn EventPublisher,
|
||||||
blocker_id: &UserId,
|
blocker_id: &UserId,
|
||||||
username: &str,
|
username: &str,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
if username.contains('@') {
|
|
||||||
return federation.block_remote(blocker_id, username).await;
|
|
||||||
}
|
|
||||||
let uname = Username::new(username).map_err(|_| DomainError::NotFound)?;
|
let uname = Username::new(username).map_err(|_| DomainError::NotFound)?;
|
||||||
let target = users
|
let target = users
|
||||||
.find_by_username(&uname)
|
.find_by_username(&uname)
|
||||||
@@ -236,14 +228,10 @@ pub async fn block_by_username(
|
|||||||
pub async fn unblock_by_username(
|
pub async fn unblock_by_username(
|
||||||
blocks: &dyn BlockRepository,
|
blocks: &dyn BlockRepository,
|
||||||
users: &dyn UserReader,
|
users: &dyn UserReader,
|
||||||
federation: &dyn FederationBlockPort,
|
|
||||||
events: &dyn EventPublisher,
|
events: &dyn EventPublisher,
|
||||||
blocker_id: &UserId,
|
blocker_id: &UserId,
|
||||||
username: &str,
|
username: &str,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
if username.contains('@') {
|
|
||||||
return federation.unblock_remote(blocker_id, username).await;
|
|
||||||
}
|
|
||||||
let uname = Username::new(username).map_err(|_| DomainError::NotFound)?;
|
let uname = Username::new(username).map_err(|_| DomainError::NotFound)?;
|
||||||
let target = users
|
let target = users
|
||||||
.find_by_username(&uname)
|
.find_by_username(&uname)
|
||||||
@@ -292,13 +280,5 @@ pub async fn unblock_user(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_local_friends(
|
|
||||||
follows: &dyn FollowRepository,
|
|
||||||
user_id: &UserId,
|
|
||||||
page: &PageParams,
|
|
||||||
) -> Result<Paginated<User>, DomainError> {
|
|
||||||
follows.list_mutual(user_id, page).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ async fn like_and_unlike() {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
}));
|
}));
|
||||||
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);
|
||||||
@@ -205,51 +204,3 @@ async fn boost_and_unboost() {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, DomainEvent::BoostRemoved { .. })));
|
.any(|e| matches!(e, DomainEvent::BoostRemoved { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_local_friends_returns_mutual_follows() {
|
|
||||||
use domain::models::feed::PageParams;
|
|
||||||
let store = TestStore::default();
|
|
||||||
let alice = user("alice");
|
|
||||||
let bob = user("bob");
|
|
||||||
let carol = user("carol");
|
|
||||||
|
|
||||||
store
|
|
||||||
.users
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.extend([alice.clone(), bob.clone(), carol.clone()]);
|
|
||||||
|
|
||||||
// alice ↔ bob = friends; alice → carol but not back
|
|
||||||
store.follows.lock().unwrap().extend([
|
|
||||||
domain::models::social::Follow {
|
|
||||||
follower_id: alice.id.clone(),
|
|
||||||
following_id: bob.id.clone(),
|
|
||||||
state: domain::models::social::FollowState::Accepted,
|
|
||||||
ap_id: None,
|
|
||||||
created_at: chrono::Utc::now(),
|
|
||||||
},
|
|
||||||
domain::models::social::Follow {
|
|
||||||
follower_id: bob.id.clone(),
|
|
||||||
following_id: alice.id.clone(),
|
|
||||||
state: domain::models::social::FollowState::Accepted,
|
|
||||||
ap_id: None,
|
|
||||||
created_at: chrono::Utc::now(),
|
|
||||||
},
|
|
||||||
domain::models::social::Follow {
|
|
||||||
follower_id: alice.id.clone(),
|
|
||||||
following_id: carol.id.clone(),
|
|
||||||
state: domain::models::social::FollowState::Accepted,
|
|
||||||
ap_id: None,
|
|
||||||
created_at: chrono::Utc::now(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
let page = PageParams {
|
|
||||||
page: 1,
|
|
||||||
per_page: 20,
|
|
||||||
};
|
|
||||||
let result = get_local_friends(&store, &alice.id, &page).await.unwrap();
|
|
||||||
assert_eq!(result.total, 1);
|
|
||||||
assert_eq!(result.items[0].id, bob.id);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ pub struct CreateThoughtInput {
|
|||||||
pub visibility: Option<String>,
|
pub visibility: Option<String>,
|
||||||
pub content_warning: Option<String>,
|
pub content_warning: Option<String>,
|
||||||
pub sensitive: bool,
|
pub sensitive: bool,
|
||||||
pub mood: Option<String>,
|
|
||||||
}
|
}
|
||||||
pub struct CreateThoughtOutput {
|
pub struct CreateThoughtOutput {
|
||||||
pub thought: Thought,
|
pub thought: Thought,
|
||||||
@@ -40,11 +39,6 @@ pub async fn create_thought(
|
|||||||
outbox: &dyn OutboxWriter,
|
outbox: &dyn OutboxWriter,
|
||||||
input: CreateThoughtInput,
|
input: CreateThoughtInput,
|
||||||
) -> Result<CreateThoughtOutput, DomainError> {
|
) -> Result<CreateThoughtOutput, DomainError> {
|
||||||
if let Some(ref m) = input.mood {
|
|
||||||
if m.len() > 64 {
|
|
||||||
return Err(DomainError::InvalidInput("mood: max 64 chars".into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let content = Content::new_local(input.content)?;
|
let content = Content::new_local(input.content)?;
|
||||||
let visibility = match input.visibility.as_deref() {
|
let visibility = match input.visibility.as_deref() {
|
||||||
Some("followers") => Visibility::Followers,
|
Some("followers") => Visibility::Followers,
|
||||||
@@ -60,7 +54,6 @@ pub async fn create_thought(
|
|||||||
visibility,
|
visibility,
|
||||||
content_warning: input.content_warning,
|
content_warning: input.content_warning,
|
||||||
sensitive: input.sensitive,
|
sensitive: input.sensitive,
|
||||||
mood: input.mood,
|
|
||||||
});
|
});
|
||||||
thoughts.save(&thought).await?;
|
thoughts.save(&thought).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ fn input(uid: UserId) -> CreateThoughtInput {
|
|||||||
visibility: None,
|
visibility: None,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +207,6 @@ async fn create_reply_sets_in_reply_to_id() {
|
|||||||
visibility: None,
|
visibility: None,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -245,7 +243,6 @@ fn make_thought(user_id: UserId) -> Thought {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +295,6 @@ async fn get_thread_views_batches_correctly() {
|
|||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: None,
|
content_warning: None,
|
||||||
sensitive: false,
|
sensitive: false,
|
||||||
mood: None,
|
|
||||||
});
|
});
|
||||||
<TestStore as ThoughtRepository>::save(&store, &reply)
|
<TestStore as ThoughtRepository>::save(&store, &reply)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -14,12 +14,9 @@ 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 }
|
||||||
k-ap = { version = "0.4.0", registry = "gitea" }
|
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.3" }
|
||||||
serde_json = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
nats = { workspace = true }
|
nats = { workspace = true }
|
||||||
event-transport = { workspace = true }
|
event-transport = { workspace = true }
|
||||||
event-payload = { workspace = true }
|
|
||||||
auth = { workspace = true }
|
auth = { workspace = true }
|
||||||
storage = { workspace = true }
|
storage = { workspace = true }
|
||||||
application = { workspace = true }
|
application = { workspace = true }
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ impl Config {
|
|||||||
Self {
|
Self {
|
||||||
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL is required"),
|
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL is required"),
|
||||||
jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET is required"),
|
jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET is required"),
|
||||||
base_url: std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:8000".into()),
|
base_url: std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()),
|
||||||
nats_url: std::env::var("NATS_URL").ok(),
|
nats_url: std::env::var("NATS_URL").ok(),
|
||||||
port: std::env::var("PORT")
|
port: std::env::var("PORT")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|p| p.parse().ok())
|
.and_then(|p| p.parse().ok())
|
||||||
.unwrap_or(8000),
|
.unwrap_or(3000),
|
||||||
allow_registration: std::env::var("ALLOW_REGISTRATION")
|
allow_registration: std::env::var("ALLOW_REGISTRATION")
|
||||||
.map(|v| v == "true")
|
.map(|v| v == "true")
|
||||||
.unwrap_or(true),
|
.unwrap_or(true),
|
||||||
|
|||||||
@@ -8,21 +8,21 @@ use std::sync::Arc;
|
|||||||
use application::use_cases::profile::UploadConfig;
|
use application::use_cases::profile::UploadConfig;
|
||||||
use storage::{build_store, ObjectStorageAdapter, StorageConfig};
|
use storage::{build_store, ObjectStorageAdapter, StorageConfig};
|
||||||
|
|
||||||
use activitypub::{build_ap_service, ApFederationAdapter, ApServiceConfig, ThoughtsObjectHandler};
|
use activitypub::{ApFederationAdapter, ThoughtsObjectHandler};
|
||||||
use auth::ApiKeyServiceImpl;
|
use auth::ApiKeyServiceImpl;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
ports::{EventPublisher, OutboxWriter},
|
ports::{EventPublisher, OutboxWriter},
|
||||||
};
|
};
|
||||||
use event_transport::{EventPublisherAdapter, Transport};
|
use event_transport::EventPublisherAdapter;
|
||||||
use k_ap::FederationEvent;
|
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;
|
||||||
use postgres::outbox::PgOutboxWriter;
|
use postgres::outbox::PgOutboxWriter;
|
||||||
use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
|
use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
|
||||||
use postgres_federation::{PgApUserRepository, PgFederationRepository};
|
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
||||||
use presentation::state::AppState;
|
use presentation::state::AppState;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
@@ -42,46 +42,6 @@ impl EventPublisher for NoOpEventPublisher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct KapPublisher(NatsTransport);
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl k_ap::data::EventPublisher for KapPublisher {
|
|
||||||
async fn publish(&self, event: FederationEvent) -> anyhow::Result<()> {
|
|
||||||
let (subject, payload) = match event {
|
|
||||||
FederationEvent::DeliveryRequested {
|
|
||||||
inbox,
|
|
||||||
activity,
|
|
||||||
signing_actor_id,
|
|
||||||
} => (
|
|
||||||
"federation.delivery.requested",
|
|
||||||
serde_json::to_vec(&event_payload::EventPayload::FederationDeliveryRequested {
|
|
||||||
inbox: inbox.to_string(),
|
|
||||||
activity,
|
|
||||||
signing_actor_id: signing_actor_id.to_string(),
|
|
||||||
})?,
|
|
||||||
),
|
|
||||||
FederationEvent::BackfillRequested {
|
|
||||||
owner_user_id,
|
|
||||||
follower_inbox_url,
|
|
||||||
} => (
|
|
||||||
"federation.backfill.requested",
|
|
||||||
serde_json::to_vec(&event_payload::EventPayload::FederationBackfillRequested {
|
|
||||||
owner_user_id: owner_user_id.to_string(),
|
|
||||||
follower_inbox_url,
|
|
||||||
})?,
|
|
||||||
),
|
|
||||||
FederationEvent::DeliveryFailed { inbox, error, .. } => {
|
|
||||||
tracing::warn!(%inbox, %error, "AP delivery failed permanently");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
self.0
|
|
||||||
.publish_bytes(subject, &payload)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!(e))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn build(cfg: &Config) -> Infrastructure {
|
pub async fn build(cfg: &Config) -> Infrastructure {
|
||||||
// 1. Database connection + migrations
|
// 1. Database connection + migrations
|
||||||
let pool = PgPool::connect(&cfg.database_url)
|
let pool = PgPool::connect(&cfg.database_url)
|
||||||
@@ -94,64 +54,51 @@ pub async fn build(cfg: &Config) -> Infrastructure {
|
|||||||
tracing::info!("Database connected and migrations applied");
|
tracing::info!("Database connected and migrations applied");
|
||||||
|
|
||||||
// 2. Event publisher — real NATS or no-op fallback
|
// 2. Event publisher — real NATS or no-op fallback
|
||||||
let nats_client: Option<async_nats::Client> = match &cfg.nats_url {
|
let event_publisher: Arc<dyn EventPublisher> = match &cfg.nats_url {
|
||||||
Some(url) => match async_nats::connect(url).await {
|
Some(url) => match async_nats::connect(url).await {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
tracing::info!("Connected to NATS at {url}");
|
tracing::info!("Connected to NATS at {url}");
|
||||||
if let Err(e) = nats::ensure_stream(&client).await {
|
if let Err(e) = nats::ensure_stream(&client).await {
|
||||||
tracing::warn!("JetStream stream setup failed: {e} — events may be lost");
|
tracing::warn!("JetStream stream setup failed: {e} — events may be lost");
|
||||||
}
|
}
|
||||||
Some(client)
|
Arc::new(EventPublisherAdapter::new(NatsTransport::new(client)))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("NATS connect failed ({e}) — falling back to no-op publisher");
|
tracing::warn!("NATS connect failed ({e}) — falling back to no-op publisher");
|
||||||
None
|
Arc::new(NoOpEventPublisher)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
tracing::info!("NATS_URL not set — using no-op event publisher");
|
tracing::info!("NATS_URL not set — using no-op event publisher");
|
||||||
None
|
Arc::new(NoOpEventPublisher)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let event_publisher: Arc<dyn EventPublisher> = match &nats_client {
|
|
||||||
Some(client) => Arc::new(EventPublisherAdapter::new(NatsTransport::new(
|
|
||||||
client.clone(),
|
|
||||||
))),
|
|
||||||
None => Arc::new(NoOpEventPublisher),
|
|
||||||
};
|
|
||||||
let kap_publisher: Option<Arc<dyn k_ap::data::EventPublisher>> = nats_client
|
|
||||||
.as_ref()
|
|
||||||
.map(|c| Arc::new(KapPublisher(NatsTransport::new(c.clone()))) as _);
|
|
||||||
|
|
||||||
// 3. ActivityPub federation
|
// 3. ActivityPub federation
|
||||||
let connections_repo = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
|
let connections_repo = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()));
|
||||||
let fed_repo = Arc::new(PgFederationRepository::new(pool.clone()));
|
let raw_ap_service = Arc::new(
|
||||||
let likes: Arc<dyn domain::ports::LikeRepository> =
|
ActivityPubService::builder(
|
||||||
Arc::new(postgres::like::PgLikeRepository::new(pool.clone()));
|
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
||||||
let boosts: Arc<dyn domain::ports::BoostRepository> =
|
Arc::new(PostgresApUserRepository::new(
|
||||||
Arc::new(postgres::boost::PgBoostRepository::new(pool.clone()));
|
pool.clone(),
|
||||||
let ap_handler = Arc::new(ThoughtsObjectHandler::new(
|
cfg.base_url.clone(),
|
||||||
|
)),
|
||||||
|
Arc::new(ThoughtsObjectHandler::new(
|
||||||
Arc::new(PgActivityPubRepository::new(pool.clone())),
|
Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||||
&cfg.base_url,
|
&cfg.base_url,
|
||||||
Some(event_publisher.clone()),
|
Some(event_publisher.clone()),
|
||||||
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
||||||
likes.clone(),
|
)),
|
||||||
boosts.clone(),
|
cfg.base_url.clone(),
|
||||||
));
|
)
|
||||||
let (_raw, ap_service) = build_ap_service(ApServiceConfig {
|
.allow_registration(cfg.allow_registration)
|
||||||
base_url: cfg.base_url.clone(),
|
.software_name("thoughts")
|
||||||
activity_repo: fed_repo.clone(),
|
.debug(cfg.debug)
|
||||||
follow_repo: fed_repo.clone(),
|
.build()
|
||||||
actor_repo: fed_repo.clone(),
|
.await
|
||||||
blocklist_repo: fed_repo.clone(),
|
.expect("Failed to build ActivityPubService"),
|
||||||
user_repo: Arc::new(PgApUserRepository::new(pool.clone(), cfg.base_url.clone())),
|
);
|
||||||
ap_handler,
|
let ap_service = Arc::new(ApFederationAdapter::new(raw_ap_service, connections_repo));
|
||||||
connections_repo,
|
|
||||||
event_publisher: kap_publisher,
|
|
||||||
allow_registration: cfg.allow_registration,
|
|
||||||
debug: cfg.debug,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// 4. Storage adapter
|
// 4. Storage adapter
|
||||||
let storage_cfg = StorageConfig {
|
let storage_cfg = StorageConfig {
|
||||||
@@ -177,8 +124,8 @@ pub async fn build(cfg: &Config) -> Infrastructure {
|
|||||||
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())),
|
||||||
likes: likes.clone(),
|
likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())),
|
||||||
boosts: boosts.clone(),
|
boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())),
|
||||||
follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())),
|
follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())),
|
||||||
blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())),
|
blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())),
|
||||||
tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
||||||
|
|||||||
@@ -35,13 +35,8 @@ async fn main() {
|
|||||||
.allow_headers(tower_http::cors::Any)
|
.allow_headers(tower_http::cors::Any)
|
||||||
};
|
};
|
||||||
|
|
||||||
let ap_router = infra
|
|
||||||
.ap_service
|
|
||||||
.router::<presentation::state::AppState>()
|
|
||||||
.layer(axum::extract::DefaultBodyLimit::max(256 * 1024));
|
|
||||||
|
|
||||||
let base = presentation::routes::router()
|
let base = presentation::routes::router()
|
||||||
.merge(ap_router)
|
.merge(infra.ap_service.router::<presentation::state::AppState>())
|
||||||
.with_state(infra.state)
|
.with_state(infra.state)
|
||||||
.layer(cors);
|
.layer(cors);
|
||||||
|
|
||||||
|
|||||||
@@ -63,18 +63,6 @@ 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,
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ pub struct RemoteActor {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
pub banner_url: Option<String>,
|
pub banner_url: Option<String>,
|
||||||
pub also_known_as: Vec<String>,
|
pub also_known_as: Option<String>,
|
||||||
pub outbox_url: Option<String>,
|
pub outbox_url: Option<String>,
|
||||||
pub followers_url: Option<String>,
|
pub followers_url: Option<String>,
|
||||||
pub following_url: Option<String>,
|
pub following_url: Option<String>,
|
||||||
pub inbox_url: Option<String>,
|
|
||||||
pub shared_inbox_url: Option<String>,
|
|
||||||
pub attachment: Vec<(String, String)>,
|
pub attachment: Vec<(String, String)>,
|
||||||
pub last_fetched_at: DateTime<Utc>,
|
pub last_fetched_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ pub struct Thought {
|
|||||||
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>,
|
pub note_extensions: Option<serde_json::Value>,
|
||||||
pub mood: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Visibility {
|
impl Visibility {
|
||||||
@@ -56,7 +55,6 @@ pub struct NewThought {
|
|||||||
pub visibility: Visibility,
|
pub visibility: Visibility,
|
||||||
pub content_warning: Option<String>,
|
pub content_warning: Option<String>,
|
||||||
pub sensitive: bool,
|
pub sensitive: bool,
|
||||||
pub mood: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Thought {
|
impl Thought {
|
||||||
@@ -73,7 +71,6 @@ impl Thought {
|
|||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
updated_at: None,
|
updated_at: None,
|
||||||
note_extensions: None,
|
note_extensions: None,
|
||||||
mood: p.mood,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ pub struct UpdateProfileInput {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
pub profile_fields: Option<Vec<(String, String)>>,
|
|
||||||
pub custom_moods: Option<Vec<(String, String)>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -23,8 +21,6 @@ pub struct User {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
pub profile_fields: Vec<(String, String)>,
|
|
||||||
pub custom_moods: Vec<(String, String)>,
|
|
||||||
pub local: bool,
|
pub local: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
@@ -48,31 +44,9 @@ impl User {
|
|||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
header_url: None,
|
header_url: None,
|
||||||
custom_css: None,
|
custom_css: None,
|
||||||
profile_fields: vec![],
|
|
||||||
custom_moods: vec![],
|
|
||||||
local: true,
|
local: true,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_remote(id: UserId, username: Username, email: Email) -> Self {
|
|
||||||
let now = Utc::now();
|
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
username,
|
|
||||||
email,
|
|
||||||
password_hash: PasswordHash(String::new()),
|
|
||||||
display_name: None,
|
|
||||||
bio: None,
|
|
||||||
avatar_url: None,
|
|
||||||
header_url: None,
|
|
||||||
custom_css: None,
|
|
||||||
profile_fields: vec![],
|
|
||||||
custom_moods: vec![],
|
|
||||||
local: false,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,11 +83,6 @@ pub trait UserWriter: Send + Sync {
|
|||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
input: UpdateProfileInput,
|
input: UpdateProfileInput,
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
async fn set_also_known_as(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
value: Option<String>,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Combined supertrait — `AppState.users` stays `Arc<dyn UserRepository>`.
|
/// Combined supertrait — `AppState.users` stays `Arc<dyn UserRepository>`.
|
||||||
@@ -171,11 +166,6 @@ pub trait FollowRepository: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
) -> Result<Vec<UserId>, DomainError>;
|
) -> Result<Vec<UserId>, DomainError>;
|
||||||
async fn list_mutual(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
page: &PageParams,
|
|
||||||
) -> Result<Paginated<User>, DomainError>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -297,11 +287,6 @@ pub trait FederationFollowPort: Send + Sync {
|
|||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
async fn get_remote_following(&self, user_id: &UserId)
|
async fn get_remote_following(&self, user_id: &UserId)
|
||||||
-> Result<Vec<RemoteActor>, DomainError>;
|
-> Result<Vec<RemoteActor>, DomainError>;
|
||||||
async fn broadcast_move(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
new_actor_url: url::Url,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -327,20 +312,6 @@ pub trait FederationFollowRequestPort: Send + Sync {
|
|||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
actor_url: &str,
|
actor_url: &str,
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
/// Update follower status to Accepted in DB only — no federation activity sent.
|
|
||||||
async fn mark_follower_accepted(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
actor_url: &str,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
/// Remove follower from DB only — no federation activity sent.
|
|
||||||
async fn mark_follower_rejected(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
actor_url: &str,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -360,27 +331,15 @@ pub trait FederationFetchPort: Send + Sync {
|
|||||||
) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary>;
|
) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait FederationBlockPort: Send + Sync {
|
|
||||||
async fn block_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
|
|
||||||
async fn unblock_remote(&self, local_user_id: &UserId, handle: &str)
|
|
||||||
-> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait FederationActionPort:
|
pub trait FederationActionPort:
|
||||||
FederationLookupPort
|
FederationLookupPort + FederationFollowPort + FederationFollowRequestPort + FederationFetchPort
|
||||||
+ FederationFollowPort
|
|
||||||
+ FederationFollowRequestPort
|
|
||||||
+ FederationFetchPort
|
|
||||||
+ FederationBlockPort
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
impl<
|
impl<
|
||||||
T: FederationLookupPort
|
T: FederationLookupPort
|
||||||
+ FederationFollowPort
|
+ FederationFollowPort
|
||||||
+ FederationFollowRequestPort
|
+ FederationFollowRequestPort
|
||||||
+ FederationFetchPort
|
+ FederationFetchPort,
|
||||||
+ FederationBlockPort,
|
|
||||||
> FederationActionPort for T
|
> FederationActionPort for T
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -443,38 +402,9 @@ impl FeedQuery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub enum FeedSort {
|
|
||||||
#[default]
|
|
||||||
Newest,
|
|
||||||
Oldest,
|
|
||||||
MostLiked,
|
|
||||||
MostBoosted,
|
|
||||||
MostDiscussed,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct FeedFilter {
|
|
||||||
pub originals_only: bool,
|
|
||||||
pub replies_only: bool,
|
|
||||||
pub local_only: bool,
|
|
||||||
pub hide_sensitive: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct FeedOptions {
|
|
||||||
pub sort: FeedSort,
|
|
||||||
pub filter: FeedFilter,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct FeedRequest {
|
|
||||||
pub query: FeedQuery,
|
|
||||||
pub options: FeedOptions,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait FeedRepository: Send + Sync {
|
pub trait FeedRepository: Send + Sync {
|
||||||
async fn query(&self, req: &FeedRequest) -> Result<Paginated<FeedEntry>, DomainError>;
|
async fn query(&self, q: &FeedQuery) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -511,136 +441,3 @@ pub trait FederationSchedulerPort: Send + Sync {
|
|||||||
page: u32,
|
page: u32,
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Federation content & broadcast ports ────────────────────────────────
|
|
||||||
|
|
||||||
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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ActorFederationUrls {
|
|
||||||
pub ap_id: String,
|
|
||||||
pub inbox_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct OutboxEntry {
|
|
||||||
pub thought: Thought,
|
|
||||||
pub author_username: Username,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait FederationContentRepository: Send + Sync {
|
|
||||||
async fn outbox_entries_for_actor(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
) -> Result<Vec<OutboxEntry>, DomainError>;
|
|
||||||
|
|
||||||
async fn outbox_page_for_actor(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<OutboxEntry>, DomainError>;
|
|
||||||
|
|
||||||
async fn find_remote_actor_id(&self, actor_ap_url: &str)
|
|
||||||
-> Result<Option<UserId>, DomainError>;
|
|
||||||
|
|
||||||
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError>;
|
|
||||||
|
|
||||||
async fn update_remote_actor_display(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
display_name: Option<&str>,
|
|
||||||
avatar_url: Option<&str>,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn accept_note(&self, input: AcceptNoteInput<'_>) -> Result<ThoughtId, DomainError>;
|
|
||||||
|
|
||||||
async fn apply_note_update(
|
|
||||||
&self,
|
|
||||||
ap_id: &str,
|
|
||||||
new_content: &str,
|
|
||||||
note_extensions: Option<serde_json::Value>,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn count_local_notes(&self) -> Result<u64, DomainError>;
|
|
||||||
|
|
||||||
async fn get_thought_ap_id(
|
|
||||||
&self,
|
|
||||||
thought_id: &ThoughtId,
|
|
||||||
) -> Result<Option<String>, DomainError>;
|
|
||||||
|
|
||||||
async fn get_actor_ap_urls(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
) -> Result<Option<ActorFederationUrls>, DomainError>;
|
|
||||||
|
|
||||||
async fn sync_remote_actor_to_user(&self, actor_ap_url: &str) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait FederationBroadcastPort: Send + Sync {
|
|
||||||
async fn broadcast_create(
|
|
||||||
&self,
|
|
||||||
author_user_id: &UserId,
|
|
||||||
thought: &Thought,
|
|
||||||
author_username: &str,
|
|
||||||
in_reply_to_url: Option<&str>,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn broadcast_delete(
|
|
||||||
&self,
|
|
||||||
author_user_id: &UserId,
|
|
||||||
thought_ap_id: &str,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn broadcast_update(
|
|
||||||
&self,
|
|
||||||
author_user_id: &UserId,
|
|
||||||
thought: &Thought,
|
|
||||||
author_username: &str,
|
|
||||||
in_reply_to_url: Option<&str>,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn broadcast_announce(
|
|
||||||
&self,
|
|
||||||
booster_user_id: &UserId,
|
|
||||||
object_ap_id: &str,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn broadcast_undo_announce(
|
|
||||||
&self,
|
|
||||||
booster_user_id: &UserId,
|
|
||||||
object_ap_id: &str,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn broadcast_like(
|
|
||||||
&self,
|
|
||||||
liker_user_id: &UserId,
|
|
||||||
object_ap_id: &str,
|
|
||||||
author_inbox_url: &str,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn broadcast_undo_like(
|
|
||||||
&self,
|
|
||||||
liker_user_id: &UserId,
|
|
||||||
object_ap_id: &str,
|
|
||||||
author_inbox_url: &str,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ use crate::{
|
|||||||
user::{UpdateProfileInput, User},
|
user::{UpdateProfileInput, User},
|
||||||
},
|
},
|
||||||
ports::*,
|
ports::*,
|
||||||
value_objects::{ApiKeyId, Content, Email, NotificationId, ThoughtId, UserId, Username},
|
value_objects::{
|
||||||
|
ApiKeyId, Content, Email, NotificationId, PasswordHash, ThoughtId, UserId, Username,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -37,8 +39,6 @@ pub struct TestStore {
|
|||||||
pub actor_ap_ids: Arc<Mutex<HashMap<String, UserId>>>,
|
pub actor_ap_ids: Arc<Mutex<HashMap<String, UserId>>>,
|
||||||
/// ThoughtId → AP object URL (used by get_thought_ap_id)
|
/// ThoughtId → AP object URL (used by get_thought_ap_id)
|
||||||
pub thought_ap_ids: Arc<Mutex<HashMap<ThoughtId, String>>>,
|
pub thought_ap_ids: Arc<Mutex<HashMap<ThoughtId, String>>>,
|
||||||
pub remote_following: Arc<Mutex<Vec<RemoteActor>>>,
|
|
||||||
pub remote_followers: Arc<Mutex<Vec<RemoteActor>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -152,14 +152,6 @@ impl UserWriter for TestStore {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_also_known_as(
|
|
||||||
&self,
|
|
||||||
_user_id: &UserId,
|
|
||||||
_value: Option<String>,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -454,46 +446,6 @@ impl FollowRepository for TestStore {
|
|||||||
.map(|f| f.following_id.clone())
|
.map(|f| f.following_id.clone())
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
async fn list_mutual(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
page: &PageParams,
|
|
||||||
) -> Result<Paginated<User>, DomainError> {
|
|
||||||
use std::collections::HashSet;
|
|
||||||
let follows = self.follows.lock().unwrap();
|
|
||||||
let following_ids: HashSet<UserId> = follows
|
|
||||||
.iter()
|
|
||||||
.filter(|f| &f.follower_id == user_id && f.state == FollowState::Accepted)
|
|
||||||
.map(|f| f.following_id.clone())
|
|
||||||
.collect();
|
|
||||||
let follower_ids: HashSet<UserId> = follows
|
|
||||||
.iter()
|
|
||||||
.filter(|f| &f.following_id == user_id && f.state == FollowState::Accepted)
|
|
||||||
.map(|f| f.follower_id.clone())
|
|
||||||
.collect();
|
|
||||||
let mutual_ids: HashSet<UserId> =
|
|
||||||
following_ids.intersection(&follower_ids).cloned().collect();
|
|
||||||
drop(follows);
|
|
||||||
let users = self.users.lock().unwrap();
|
|
||||||
let all_items: Vec<User> = users
|
|
||||||
.iter()
|
|
||||||
.filter(|u| mutual_ids.contains(&u.id))
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
let total = all_items.len() as i64;
|
|
||||||
let offset = page.offset() as usize;
|
|
||||||
let items: Vec<User> = all_items
|
|
||||||
.into_iter()
|
|
||||||
.skip(offset)
|
|
||||||
.take(page.limit() as usize)
|
|
||||||
.collect();
|
|
||||||
Ok(Paginated {
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
page: page.page,
|
|
||||||
per_page: page.per_page,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -752,15 +704,7 @@ impl FederationFollowPort for TestStore {
|
|||||||
&self,
|
&self,
|
||||||
_user_id: &UserId,
|
_user_id: &UserId,
|
||||||
) -> Result<Vec<RemoteActor>, DomainError> {
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
||||||
Ok(self.remote_following.lock().unwrap().clone())
|
Ok(vec![])
|
||||||
}
|
|
||||||
|
|
||||||
async fn broadcast_move(
|
|
||||||
&self,
|
|
||||||
_user_id: &UserId,
|
|
||||||
_new_actor_url: url::Url,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,7 +737,7 @@ impl FederationFollowRequestPort for TestStore {
|
|||||||
&self,
|
&self,
|
||||||
_user_id: &UserId,
|
_user_id: &UserId,
|
||||||
) -> Result<Vec<RemoteActor>, DomainError> {
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
||||||
Ok(self.remote_followers.lock().unwrap().clone())
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_remote_follower(
|
async fn remove_remote_follower(
|
||||||
@@ -803,22 +747,6 @@ impl FederationFollowRequestPort for TestStore {
|
|||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mark_follower_accepted(
|
|
||||||
&self,
|
|
||||||
_user_id: &UserId,
|
|
||||||
_actor_url: &str,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mark_follower_rejected(
|
|
||||||
&self,
|
|
||||||
_user_id: &UserId,
|
|
||||||
_actor_url: &str,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -846,24 +774,6 @@ impl FederationFetchPort for TestStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl FederationBlockPort for TestStore {
|
|
||||||
async fn block_remote(
|
|
||||||
&self,
|
|
||||||
_local_user_id: &UserId,
|
|
||||||
_handle: &str,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn unblock_remote(
|
|
||||||
&self,
|
|
||||||
_local_user_id: &UserId,
|
|
||||||
_handle: &str,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl RemoteActorConnectionRepository for TestStore {
|
impl RemoteActorConnectionRepository for TestStore {
|
||||||
async fn upsert_connections(
|
async fn upsert_connections(
|
||||||
@@ -900,7 +810,7 @@ impl RemoteActorConnectionRepository for TestStore {
|
|||||||
impl FeedRepository for TestStore {
|
impl FeedRepository for TestStore {
|
||||||
async fn query(
|
async fn query(
|
||||||
&self,
|
&self,
|
||||||
_req: &crate::ports::FeedRequest,
|
_q: &crate::ports::FeedQuery,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: vec![],
|
items: vec![],
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ impl Email {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct PasswordHash(pub String);
|
pub struct PasswordHash(pub String);
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
|
activitypub = { workspace = true }
|
||||||
application = { workspace = true }
|
application = { workspace = true }
|
||||||
api-types = { workspace = true }
|
api-types = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
|
|||||||
@@ -37,13 +37,11 @@ pub async fn post_api_key(
|
|||||||
Deps(d): Deps<ApiKeysDeps>,
|
Deps(d): Deps<ApiKeysDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Json(body): Json<CreateApiKeyRequest>,
|
Json(body): Json<CreateApiKeyRequest>,
|
||||||
) -> Result<Json<CreatedApiKeyResponse>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let (key, raw) = create_api_key(&*d.api_keys, &uid, body.name).await?;
|
let (key, raw) = create_api_key(&*d.api_keys, &uid, body.name).await?;
|
||||||
Ok(Json(CreatedApiKeyResponse {
|
Ok(Json(
|
||||||
id: key.id.as_uuid(),
|
serde_json::json!({ "id": key.id.as_uuid(), "name": key.name, "key": raw }),
|
||||||
name: key.name,
|
))
|
||||||
key: raw,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
#[utoipa::path(delete, path = "/api-keys/{id}", params(("id" = uuid::Uuid, Path, description = "Key ID")), responses((status = 204, description = "Deleted")), security(("bearer_auth" = [])))]
|
#[utoipa::path(delete, path = "/api-keys/{id}", params(("id" = uuid::Uuid, Path, description = "Key ID")), responses((status = 204, description = "Deleted")), security(("bearer_auth" = [])))]
|
||||||
pub async fn delete_api_key_handler(
|
pub async fn delete_api_key_handler(
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
use crate::{deps_struct, errors::ApiError, extractors::Deps};
|
use crate::{deps_struct, errors::ApiError, extractors::Deps};
|
||||||
use api_types::{
|
use api_types::{
|
||||||
requests::{LoginRequest, RegisterRequest},
|
requests::{LoginRequest, RegisterRequest},
|
||||||
responses::{AuthResponse, ErrorResponse, ProfileField, UserResponse},
|
responses::{AuthResponse, ErrorResponse, UserResponse},
|
||||||
};
|
};
|
||||||
use application::use_cases::auth::{login, register, LoginInput, RegisterInput};
|
use application::use_cases::auth::{login, register, LoginInput, RegisterInput};
|
||||||
use axum::{http::StatusCode, response::IntoResponse, Json};
|
use axum::{http::StatusCode, response::IntoResponse, Json};
|
||||||
use domain::models::feed::UserSummary;
|
|
||||||
use domain::ports::{AuthService, EventPublisher, PasswordHasher, UserRepository};
|
use domain::ports::{AuthService, EventPublisher, PasswordHasher, UserRepository};
|
||||||
|
|
||||||
deps_struct!(AuthDeps {
|
deps_struct!(AuthDeps {
|
||||||
@@ -24,45 +23,12 @@ pub fn to_user_response(u: &domain::models::user::User) -> UserResponse {
|
|||||||
avatar_url: u.avatar_url.clone(),
|
avatar_url: u.avatar_url.clone(),
|
||||||
header_url: u.header_url.clone(),
|
header_url: u.header_url.clone(),
|
||||||
custom_css: u.custom_css.clone(),
|
custom_css: u.custom_css.clone(),
|
||||||
profile_fields: u
|
|
||||||
.profile_fields
|
|
||||||
.iter()
|
|
||||||
.map(|(n, v)| ProfileField {
|
|
||||||
name: n.clone(),
|
|
||||||
value: v.clone(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
custom_moods: u
|
|
||||||
.custom_moods
|
|
||||||
.iter()
|
|
||||||
.map(|(n, v)| ProfileField {
|
|
||||||
name: n.clone(),
|
|
||||||
value: v.clone(),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
local: u.local,
|
local: u.local,
|
||||||
is_followed_by_viewer: false,
|
is_followed_by_viewer: false,
|
||||||
created_at: u.created_at,
|
created_at: u.created_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_summary_response(u: &UserSummary) -> UserResponse {
|
|
||||||
UserResponse {
|
|
||||||
id: u.id.as_uuid(),
|
|
||||||
username: u.username.clone(),
|
|
||||||
display_name: u.display_name.clone(),
|
|
||||||
bio: u.bio.clone(),
|
|
||||||
avatar_url: u.avatar_url.clone(),
|
|
||||||
header_url: None,
|
|
||||||
custom_css: None,
|
|
||||||
profile_fields: vec![],
|
|
||||||
custom_moods: vec![],
|
|
||||||
local: true,
|
|
||||||
is_followed_by_viewer: false,
|
|
||||||
created_at: chrono::Utc::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post, path = "/auth/register",
|
post, path = "/auth/register",
|
||||||
request_body = RegisterRequest,
|
request_body = RegisterRequest,
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ use crate::{
|
|||||||
handlers::feed::to_thought_response,
|
handlers::feed::to_thought_response,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
use activitypub::ActivityPubRepository;
|
||||||
use api_types::{
|
use api_types::{
|
||||||
requests::PaginationQuery,
|
requests::PaginationQuery,
|
||||||
responses::{
|
responses::{ActorConnectionPageResponse, ActorConnectionResponse},
|
||||||
ActorConnectionPageResponse, ActorConnectionResponse, PagedResponse, ThoughtResponse,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use application::use_cases::federation_management::{
|
use application::use_cases::federation_management::{
|
||||||
get_actor_connections_page, get_remote_actor_posts,
|
get_actor_connections_page, get_remote_actor_posts,
|
||||||
@@ -17,7 +16,6 @@ use axum::{
|
|||||||
extract::{Path, Query},
|
extract::{Path, Query},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use domain::ports::FederationContentRepository;
|
|
||||||
use domain::{
|
use domain::{
|
||||||
models::feed::PageParams,
|
models::feed::PageParams,
|
||||||
ports::{
|
ports::{
|
||||||
@@ -29,7 +27,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
pub struct FederationActorsDeps {
|
pub struct FederationActorsDeps {
|
||||||
pub federation: Arc<dyn FederationActionPort>,
|
pub federation: Arc<dyn FederationActionPort>,
|
||||||
pub ap_repo: Arc<dyn FederationContentRepository>,
|
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||||
pub feed: Arc<dyn FeedRepository>,
|
pub feed: Arc<dyn FeedRepository>,
|
||||||
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
|
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
|
||||||
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
|
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
|
||||||
@@ -47,20 +45,12 @@ impl FromAppState for FederationActorsDeps {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get, path = "/federation/actors/{handle}/posts",
|
|
||||||
params(
|
|
||||||
("handle" = String, Path, description = "Fediverse handle (@user@instance.tld)"),
|
|
||||||
PaginationQuery,
|
|
||||||
),
|
|
||||||
responses((status = 200, description = "Posts by this remote actor"))
|
|
||||||
)]
|
|
||||||
pub async fn remote_actor_posts_handler(
|
pub async fn remote_actor_posts_handler(
|
||||||
Deps(d): Deps<FederationActorsDeps>,
|
Deps(d): Deps<FederationActorsDeps>,
|
||||||
Path(handle): Path<String>,
|
Path(handle): Path<String>,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
) -> Result<Json<PagedResponse<ThoughtResponse>>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let page = PageParams {
|
let page = PageParams {
|
||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
@@ -75,22 +65,14 @@ pub async fn remote_actor_posts_handler(
|
|||||||
viewer.as_ref(),
|
viewer.as_ref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(PagedResponse {
|
Ok(Json(serde_json::json!({
|
||||||
items: result.items.iter().map(to_thought_response).collect(),
|
"total": result.total,
|
||||||
total: result.total,
|
"page": result.page,
|
||||||
page: result.page,
|
"per_page": result.per_page,
|
||||||
per_page: result.per_page,
|
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||||
}))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get, path = "/federation/actors/{handle}/followers-list",
|
|
||||||
params(
|
|
||||||
("handle" = String, Path, description = "Fediverse handle (@user@instance.tld)"),
|
|
||||||
PaginationQuery,
|
|
||||||
),
|
|
||||||
responses((status = 200, description = "Followers of this remote actor", body = ActorConnectionPageResponse)),
|
|
||||||
)]
|
|
||||||
pub async fn actor_followers_handler(
|
pub async fn actor_followers_handler(
|
||||||
Deps(d): Deps<FederationActorsDeps>,
|
Deps(d): Deps<FederationActorsDeps>,
|
||||||
Path(handle): Path<String>,
|
Path(handle): Path<String>,
|
||||||
@@ -99,14 +81,6 @@ pub async fn actor_followers_handler(
|
|||||||
actor_connections_handler(d, handle, "followers", q.page() as u32).await
|
actor_connections_handler(d, handle, "followers", q.page() as u32).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get, path = "/federation/actors/{handle}/following-list",
|
|
||||||
params(
|
|
||||||
("handle" = String, Path, description = "Fediverse handle (@user@instance.tld)"),
|
|
||||||
PaginationQuery,
|
|
||||||
),
|
|
||||||
responses((status = 200, description = "Accounts this remote actor follows", body = ActorConnectionPageResponse)),
|
|
||||||
)]
|
|
||||||
pub async fn actor_following_handler(
|
pub async fn actor_following_handler(
|
||||||
Deps(d): Deps<FederationActorsDeps>,
|
Deps(d): Deps<FederationActorsDeps>,
|
||||||
Path(handle): Path<String>,
|
Path(handle): Path<String>,
|
||||||
|
|||||||
@@ -3,34 +3,25 @@ use crate::{
|
|||||||
errors::ApiError,
|
errors::ApiError,
|
||||||
extractors::{AuthUser, Deps},
|
extractors::{AuthUser, Deps},
|
||||||
};
|
};
|
||||||
use api_types::responses::{ErrorResponse, ProfileField, RemoteActorResponse};
|
use api_types::responses::{ProfileField, RemoteActorResponse};
|
||||||
use application::use_cases::federation_management::{
|
use application::use_cases::federation_management::{
|
||||||
accept_follow_request, get_remote_friends, initiate_actor_move, list_pending_requests,
|
accept_follow_request, list_pending_requests, list_remote_followers, list_remote_following,
|
||||||
list_remote_followers, list_remote_following, reject_follow_request, remove_remote_following,
|
reject_follow_request, remove_remote_following,
|
||||||
set_also_known_as,
|
|
||||||
};
|
};
|
||||||
use axum::{http::StatusCode, Json};
|
use axum::{http::StatusCode, Json};
|
||||||
use domain::ports::{EventPublisher, FederationActionPort, FollowRepository, UserRepository};
|
use domain::ports::{EventPublisher, FederationActionPort, FollowRepository, UserRepository};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Deserialize, utoipa::ToSchema)]
|
#[derive(Deserialize)]
|
||||||
pub struct ActorUrlBody {
|
pub struct ActorUrlBody {
|
||||||
/// Full ActivityPub actor URL
|
|
||||||
pub actor_url: String,
|
pub actor_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, utoipa::ToSchema)]
|
#[derive(Deserialize)]
|
||||||
pub struct HandleBody {
|
pub struct HandleBody {
|
||||||
/// Fediverse handle (`@user@instance.tld`)
|
|
||||||
pub handle: String,
|
pub handle: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, utoipa::ToSchema)]
|
|
||||||
pub struct MoveBody {
|
|
||||||
/// New actor URL to migrate to
|
|
||||||
pub new_actor_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
deps_struct!(FederationManagementDeps {
|
deps_struct!(FederationManagementDeps {
|
||||||
federation: FederationActionPort,
|
federation: FederationActionPort,
|
||||||
follows: FollowRepository,
|
follows: FollowRepository,
|
||||||
@@ -58,11 +49,6 @@ fn to_response(a: domain::models::remote_actor::RemoteActor) -> RemoteActorRespo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get, path = "/federation/me/followers/pending",
|
|
||||||
responses((status = 200, description = "Pending inbound follow requests", body = Vec<RemoteActorResponse>)),
|
|
||||||
security(("bearer_auth" = []))
|
|
||||||
)]
|
|
||||||
pub async fn get_pending_requests(
|
pub async fn get_pending_requests(
|
||||||
Deps(d): Deps<FederationManagementDeps>,
|
Deps(d): Deps<FederationManagementDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
@@ -71,47 +57,24 @@ pub async fn get_pending_requests(
|
|||||||
Ok(Json(actors.into_iter().map(to_response).collect()))
|
Ok(Json(actors.into_iter().map(to_response).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post, path = "/federation/me/followers/accept",
|
|
||||||
request_body = ActorUrlBody,
|
|
||||||
responses(
|
|
||||||
(status = 204, description = "Follow request accepted"),
|
|
||||||
(status = 400, description = "Invalid request", body = ErrorResponse),
|
|
||||||
),
|
|
||||||
security(("bearer_auth" = []))
|
|
||||||
)]
|
|
||||||
pub async fn post_accept_request(
|
pub async fn post_accept_request(
|
||||||
Deps(d): Deps<FederationManagementDeps>,
|
Deps(d): Deps<FederationManagementDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Json(body): Json<ActorUrlBody>,
|
Json(body): Json<ActorUrlBody>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
accept_follow_request(&*d.federation, &*d.events, &uid, &body.actor_url).await?;
|
accept_follow_request(&*d.federation, &uid, &body.actor_url).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
delete, path = "/federation/me/followers",
|
|
||||||
request_body = ActorUrlBody,
|
|
||||||
responses(
|
|
||||||
(status = 204, description = "Follower removed / request rejected"),
|
|
||||||
(status = 400, description = "Invalid request", body = ErrorResponse),
|
|
||||||
),
|
|
||||||
security(("bearer_auth" = []))
|
|
||||||
)]
|
|
||||||
pub async fn delete_follower(
|
pub async fn delete_follower(
|
||||||
Deps(d): Deps<FederationManagementDeps>,
|
Deps(d): Deps<FederationManagementDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Json(body): Json<ActorUrlBody>,
|
Json(body): Json<ActorUrlBody>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
reject_follow_request(&*d.federation, &*d.events, &uid, &body.actor_url).await?;
|
reject_follow_request(&*d.federation, &uid, &body.actor_url).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get, path = "/federation/me/followers",
|
|
||||||
responses((status = 200, description = "Accepted remote followers", body = Vec<RemoteActorResponse>)),
|
|
||||||
security(("bearer_auth" = []))
|
|
||||||
)]
|
|
||||||
pub async fn get_remote_followers(
|
pub async fn get_remote_followers(
|
||||||
Deps(d): Deps<FederationManagementDeps>,
|
Deps(d): Deps<FederationManagementDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
@@ -120,11 +83,6 @@ pub async fn get_remote_followers(
|
|||||||
Ok(Json(actors.into_iter().map(to_response).collect()))
|
Ok(Json(actors.into_iter().map(to_response).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get, path = "/federation/me/following",
|
|
||||||
responses((status = 200, description = "Remote accounts I follow", body = Vec<RemoteActorResponse>)),
|
|
||||||
security(("bearer_auth" = []))
|
|
||||||
)]
|
|
||||||
pub async fn get_remote_following(
|
pub async fn get_remote_following(
|
||||||
Deps(d): Deps<FederationManagementDeps>,
|
Deps(d): Deps<FederationManagementDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
@@ -133,28 +91,6 @@ pub async fn get_remote_following(
|
|||||||
Ok(Json(actors.into_iter().map(to_response).collect()))
|
Ok(Json(actors.into_iter().map(to_response).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get, path = "/federation/me/friends",
|
|
||||||
responses((status = 200, description = "Remote mutual follows (I follow them and they follow me)", body = Vec<RemoteActorResponse>)),
|
|
||||||
security(("bearer_auth" = []))
|
|
||||||
)]
|
|
||||||
pub async fn get_remote_friends_handler(
|
|
||||||
Deps(d): Deps<FederationManagementDeps>,
|
|
||||||
AuthUser(uid): AuthUser,
|
|
||||||
) -> Result<Json<Vec<RemoteActorResponse>>, ApiError> {
|
|
||||||
let actors = get_remote_friends(&*d.federation, &uid).await?;
|
|
||||||
Ok(Json(actors.into_iter().map(to_response).collect()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
delete, path = "/federation/me/following",
|
|
||||||
request_body = HandleBody,
|
|
||||||
responses(
|
|
||||||
(status = 204, description = "Unfollowed remote account"),
|
|
||||||
(status = 400, description = "Invalid handle", body = ErrorResponse),
|
|
||||||
),
|
|
||||||
security(("bearer_auth" = []))
|
|
||||||
)]
|
|
||||||
pub async fn delete_following(
|
pub async fn delete_following(
|
||||||
Deps(d): Deps<FederationManagementDeps>,
|
Deps(d): Deps<FederationManagementDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
@@ -171,44 +107,3 @@ pub async fn delete_following(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
post, path = "/federation/me/move",
|
|
||||||
request_body = MoveBody,
|
|
||||||
responses(
|
|
||||||
(status = 204, description = "Account move initiated"),
|
|
||||||
(status = 400, description = "Invalid URL", body = ErrorResponse),
|
|
||||||
),
|
|
||||||
security(("bearer_auth" = []))
|
|
||||||
)]
|
|
||||||
pub async fn post_move_account(
|
|
||||||
Deps(d): Deps<FederationManagementDeps>,
|
|
||||||
AuthUser(uid): AuthUser,
|
|
||||||
Json(body): Json<MoveBody>,
|
|
||||||
) -> Result<StatusCode, ApiError> {
|
|
||||||
let new_url = url::Url::parse(&body.new_actor_url)
|
|
||||||
.map_err(|_| ApiError::BadRequest("invalid new_actor_url".into()))?;
|
|
||||||
initiate_actor_move(&*d.events, &uid, new_url).await?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, utoipa::ToSchema)]
|
|
||||||
pub struct AlsoKnownAsBody {
|
|
||||||
/// Actor URL of the account this identity is also known as (for migration verification)
|
|
||||||
pub also_known_as: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
patch, path = "/federation/me/also-known-as",
|
|
||||||
request_body = AlsoKnownAsBody,
|
|
||||||
responses((status = 204, description = "Also-known-as updated")),
|
|
||||||
security(("bearer_auth" = []))
|
|
||||||
)]
|
|
||||||
pub async fn patch_also_known_as(
|
|
||||||
Deps(d): Deps<FederationManagementDeps>,
|
|
||||||
AuthUser(uid): AuthUser,
|
|
||||||
Json(body): Json<AlsoKnownAsBody>,
|
|
||||||
) -> Result<StatusCode, ApiError> {
|
|
||||||
set_also_known_as(&*d.users, &uid, body.also_known_as).await?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,14 +5,9 @@ use crate::{
|
|||||||
handlers::auth::to_user_response,
|
handlers::auth::to_user_response,
|
||||||
};
|
};
|
||||||
use api_types::requests::{PaginationQuery, SearchQuery};
|
use api_types::requests::{PaginationQuery, SearchQuery};
|
||||||
use api_types::responses::{PagedResponse, ThoughtResponse};
|
use api_types::responses::ThoughtResponse;
|
||||||
use application::use_cases::feed::{
|
use application::use_cases::feed::get_home_feed;
|
||||||
get_home_feed, get_popular_tags as uc_get_popular_tags, get_public_feed, get_tag_feed,
|
use application::use_cases::profile::{get_user_by_id_or_username, get_user_by_username};
|
||||||
get_user_feed,
|
|
||||||
};
|
|
||||||
use application::use_cases::profile::{
|
|
||||||
get_user_by_id_or_username, get_user_by_username, list_local_followers, list_local_following,
|
|
||||||
};
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query},
|
extract::{Path, Query},
|
||||||
http::{header, HeaderMap},
|
http::{header, HeaderMap},
|
||||||
@@ -22,66 +17,11 @@ use axum::{
|
|||||||
use domain::{
|
use domain::{
|
||||||
models::feed::PageParams,
|
models::feed::PageParams,
|
||||||
ports::{
|
ports::{
|
||||||
FederationActionPort, FeedFilter, FeedOptions, FeedRepository, FeedSort, FollowRepository,
|
FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort,
|
||||||
SearchPort, TagRepository, UserRepository,
|
TagRepository, UserRepository,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(serde::Deserialize, Default, utoipa::IntoParams)]
|
|
||||||
#[into_params(parameter_in = Query)]
|
|
||||||
pub struct FeedOptionsQuery {
|
|
||||||
/// Sort order: `newest` (default), `oldest`, `most_liked`, `most_boosted`, `most_discussed`
|
|
||||||
pub sort: Option<String>,
|
|
||||||
/// Show only original posts (mutually exclusive with `replies_only`)
|
|
||||||
pub originals_only: Option<bool>,
|
|
||||||
/// Show only replies (mutually exclusive with `originals_only`)
|
|
||||||
pub replies_only: Option<bool>,
|
|
||||||
/// Show only posts from this instance
|
|
||||||
pub local_only: Option<bool>,
|
|
||||||
/// Hide posts marked as sensitive
|
|
||||||
pub hide_sensitive: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<FeedOptionsQuery> for FeedOptions {
|
|
||||||
type Error = crate::errors::ApiError;
|
|
||||||
|
|
||||||
fn try_from(q: FeedOptionsQuery) -> Result<Self, Self::Error> {
|
|
||||||
if q.originals_only.unwrap_or(false) && q.replies_only.unwrap_or(false) {
|
|
||||||
return Err(crate::errors::ApiError::BadRequest(
|
|
||||||
"originals_only and replies_only are mutually exclusive".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let sort = match q.sort.as_deref() {
|
|
||||||
None | Some("newest") => FeedSort::Newest,
|
|
||||||
Some("oldest") => FeedSort::Oldest,
|
|
||||||
Some("most_liked") => FeedSort::MostLiked,
|
|
||||||
Some("most_boosted") => FeedSort::MostBoosted,
|
|
||||||
Some("most_discussed") => FeedSort::MostDiscussed,
|
|
||||||
Some(other) => {
|
|
||||||
return Err(crate::errors::ApiError::BadRequest(format!(
|
|
||||||
"unknown sort value: {other}"
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(FeedOptions {
|
|
||||||
sort,
|
|
||||||
filter: FeedFilter {
|
|
||||||
originals_only: q.originals_only.unwrap_or(false),
|
|
||||||
replies_only: q.replies_only.unwrap_or(false),
|
|
||||||
local_only: q.local_only.unwrap_or(false),
|
|
||||||
hide_sensitive: q.hide_sensitive.unwrap_or(false),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wants_activity_json(headers: &HeaderMap) -> bool {
|
|
||||||
headers
|
|
||||||
.get(header::ACCEPT)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.is_some_and(|a| a.contains("application/activity+json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
deps_struct!(FeedDeps {
|
deps_struct!(FeedDeps {
|
||||||
feed: FeedRepository,
|
feed: FeedRepository,
|
||||||
follows: FollowRepository,
|
follows: FollowRepository,
|
||||||
@@ -109,13 +49,12 @@ pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtRespon
|
|||||||
created_at: e.thought.created_at,
|
created_at: e.thought.created_at,
|
||||||
updated_at: e.thought.updated_at,
|
updated_at: e.thought.updated_at,
|
||||||
note_extensions: e.thought.note_extensions.clone(),
|
note_extensions: e.thought.note_extensions.clone(),
|
||||||
mood: e.thought.mood.clone(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get, path = "/feed",
|
get, path = "/feed",
|
||||||
params(PaginationQuery, FeedOptionsQuery),
|
params(PaginationQuery),
|
||||||
responses((status = 200, description = "Home feed")),
|
responses((status = 200, description = "Home feed")),
|
||||||
security(("bearer_auth" = []))
|
security(("bearer_auth" = []))
|
||||||
)]
|
)]
|
||||||
@@ -123,45 +62,41 @@ pub async fn home_feed(
|
|||||||
Deps(d): Deps<FeedDeps>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
Query(opts_q): Query<FeedOptionsQuery>,
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
) -> Result<Json<PagedResponse<ThoughtResponse>>, ApiError> {
|
|
||||||
let page = PageParams {
|
let page = PageParams {
|
||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let opts = FeedOptions::try_from(opts_q)?;
|
let result = get_home_feed(&*d.feed, &*d.follows, &uid, page).await?;
|
||||||
let result = get_home_feed(&*d.feed, &*d.follows, &uid, page, opts).await?;
|
Ok(Json(serde_json::json!({
|
||||||
Ok(Json(PagedResponse {
|
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||||
items: result.items.iter().map(to_thought_response).collect(),
|
"total": result.total,
|
||||||
total: result.total,
|
"page": result.page,
|
||||||
page: result.page,
|
"per_page": result.per_page,
|
||||||
per_page: result.per_page,
|
})))
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get, path = "/feed/public",
|
get, path = "/feed/public",
|
||||||
params(PaginationQuery, FeedOptionsQuery),
|
params(PaginationQuery),
|
||||||
responses((status = 200, description = "Public feed"))
|
responses((status = 200, description = "Public feed"))
|
||||||
)]
|
)]
|
||||||
pub async fn public_feed(
|
pub async fn public_feed(
|
||||||
Deps(d): Deps<FeedDeps>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
Query(opts_q): Query<FeedOptionsQuery>,
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
) -> Result<Json<PagedResponse<ThoughtResponse>>, ApiError> {
|
|
||||||
let page = PageParams {
|
let page = PageParams {
|
||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let opts = FeedOptions::try_from(opts_q)?;
|
let result = d.feed.query(&FeedQuery::public(page, viewer)).await?;
|
||||||
let result = get_public_feed(&*d.feed, viewer, page, opts).await?;
|
Ok(Json(serde_json::json!({
|
||||||
Ok(Json(PagedResponse {
|
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||||
items: result.items.iter().map(to_thought_response).collect(),
|
"total": result.total,
|
||||||
total: result.total,
|
"page": result.page,
|
||||||
page: result.page,
|
"per_page": result.per_page,
|
||||||
per_page: result.per_page,
|
})))
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -204,21 +139,18 @@ pub async fn search_handler(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get, path = "/users/{username}/following",
|
|
||||||
params(
|
|
||||||
("username" = String, Path, description = "Username"),
|
|
||||||
PaginationQuery,
|
|
||||||
),
|
|
||||||
responses((status = 200, description = "Users this account follows"))
|
|
||||||
)]
|
|
||||||
pub async fn get_following_handler(
|
pub async fn get_following_handler(
|
||||||
Deps(d): Deps<FeedDeps>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
Path(param): Path<String>,
|
Path(param): Path<String>,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
if wants_activity_json(&headers) {
|
let accept = headers
|
||||||
|
.get(header::ACCEPT)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
if accept.contains("application/activity+json") {
|
||||||
let user = get_user_by_id_or_username(&*d.users, ¶m).await?;
|
let user = get_user_by_id_or_username(&*d.users, ¶m).await?;
|
||||||
let user_id = user.id;
|
let user_id = user.id;
|
||||||
let page = q.page().try_into().ok();
|
let page = q.page().try_into().ok();
|
||||||
@@ -234,31 +166,26 @@ pub async fn get_following_handler(
|
|||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = list_local_following(&*d.follows, &user.id, page).await?;
|
let result = d.follows.list_following(&user.id, &page).await?;
|
||||||
Ok(Json(PagedResponse {
|
Ok(Json(serde_json::json!({
|
||||||
items: result.items.iter().map(to_user_response).collect(),
|
"total": result.total,
|
||||||
total: result.total,
|
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>()
|
||||||
page: result.page,
|
}))
|
||||||
per_page: result.per_page,
|
|
||||||
})
|
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get, path = "/users/{username}/followers",
|
|
||||||
params(
|
|
||||||
("username" = String, Path, description = "Username"),
|
|
||||||
PaginationQuery,
|
|
||||||
),
|
|
||||||
responses((status = 200, description = "Accounts that follow this user"))
|
|
||||||
)]
|
|
||||||
pub async fn get_followers_handler(
|
pub async fn get_followers_handler(
|
||||||
Deps(d): Deps<FeedDeps>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
Path(param): Path<String>,
|
Path(param): Path<String>,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
if wants_activity_json(&headers) {
|
let accept = headers
|
||||||
|
.get(header::ACCEPT)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
if accept.contains("application/activity+json") {
|
||||||
let user = get_user_by_id_or_username(&*d.users, ¶m).await?;
|
let user = get_user_by_id_or_username(&*d.users, ¶m).await?;
|
||||||
let user_id = user.id;
|
let user_id = user.id;
|
||||||
let page = q.page().try_into().ok();
|
let page = q.page().try_into().ok();
|
||||||
@@ -274,13 +201,11 @@ pub async fn get_followers_handler(
|
|||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let result = list_local_followers(&*d.follows, &user.id, page).await?;
|
let result = d.follows.list_followers(&user.id, &page).await?;
|
||||||
Ok(Json(PagedResponse {
|
Ok(Json(serde_json::json!({
|
||||||
items: result.items.iter().map(to_user_response).collect(),
|
"total": result.total,
|
||||||
total: result.total,
|
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>()
|
||||||
page: result.page,
|
}))
|
||||||
per_page: result.per_page,
|
|
||||||
})
|
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +214,6 @@ pub async fn get_followers_handler(
|
|||||||
params(
|
params(
|
||||||
("username" = String, Path, description = "Username"),
|
("username" = String, Path, description = "Username"),
|
||||||
PaginationQuery,
|
PaginationQuery,
|
||||||
FeedOptionsQuery,
|
|
||||||
),
|
),
|
||||||
responses((status = 200, description = "User's public thoughts"))
|
responses((status = 200, description = "User's public thoughts"))
|
||||||
)]
|
)]
|
||||||
@@ -298,30 +222,24 @@ pub async fn user_thoughts_handler(
|
|||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
Query(opts_q): Query<FeedOptionsQuery>,
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
) -> Result<Json<PagedResponse<ThoughtResponse>>, ApiError> {
|
|
||||||
let user = get_user_by_username(&*d.users, &username).await?;
|
let user = get_user_by_username(&*d.users, &username).await?;
|
||||||
let page = PageParams {
|
let page = PageParams {
|
||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let opts = FeedOptions::try_from(opts_q)?;
|
let result = d
|
||||||
let result = get_user_feed(&*d.feed, user.id.clone(), page, opts, viewer).await?;
|
.feed
|
||||||
Ok(Json(PagedResponse {
|
.query(&FeedQuery::user(user.id.clone(), page, viewer))
|
||||||
items: result.items.iter().map(to_thought_response).collect(),
|
.await?;
|
||||||
total: result.total,
|
Ok(Json(serde_json::json!({
|
||||||
page: result.page,
|
"total": result.total,
|
||||||
per_page: result.per_page,
|
"page": result.page,
|
||||||
}))
|
"per_page": result.per_page,
|
||||||
|
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>()
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
|
||||||
get, path = "/tags/popular",
|
|
||||||
params(
|
|
||||||
("limit" = Option<u64>, Query, description = "Max tags to return (default 20, max 100)"),
|
|
||||||
),
|
|
||||||
responses((status = 200, description = "Most-used tags"))
|
|
||||||
)]
|
|
||||||
pub async fn get_popular_tags(
|
pub async fn get_popular_tags(
|
||||||
Deps(d): Deps<FeedDeps>,
|
Deps(d): Deps<FeedDeps>,
|
||||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
@@ -329,9 +247,11 @@ pub async fn get_popular_tags(
|
|||||||
let limit: usize = params
|
let limit: usize = params
|
||||||
.get("limit")
|
.get("limit")
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize)
|
.unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize);
|
||||||
.min(api_types::requests::MAX_PER_PAGE as usize);
|
let tags = d
|
||||||
let tags = uc_get_popular_tags(&*d.tags, limit).await?;
|
.tags
|
||||||
|
.popular_tags(limit.min(api_types::requests::MAX_PER_PAGE as usize))
|
||||||
|
.await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"tags": tags.iter().map(|(name, count)| serde_json::json!({
|
"tags": tags.iter().map(|(name, count)| serde_json::json!({
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -345,7 +265,6 @@ pub async fn get_popular_tags(
|
|||||||
params(
|
params(
|
||||||
("name" = String, Path, description = "Tag name"),
|
("name" = String, Path, description = "Tag name"),
|
||||||
PaginationQuery,
|
PaginationQuery,
|
||||||
FeedOptionsQuery,
|
|
||||||
),
|
),
|
||||||
responses((status = 200, description = "Thoughts with this tag"))
|
responses((status = 200, description = "Thoughts with this tag"))
|
||||||
)]
|
)]
|
||||||
@@ -354,18 +273,20 @@ pub async fn tag_thoughts_handler(
|
|||||||
Path(tag_name): Path<String>,
|
Path(tag_name): Path<String>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
Query(opts_q): Query<FeedOptionsQuery>,
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
) -> Result<Json<PagedResponse<ThoughtResponse>>, ApiError> {
|
|
||||||
let page = PageParams {
|
let page = PageParams {
|
||||||
page: q.page(),
|
page: q.page(),
|
||||||
per_page: q.per_page(),
|
per_page: q.per_page(),
|
||||||
};
|
};
|
||||||
let opts = FeedOptions::try_from(opts_q)?;
|
let result = d
|
||||||
let result = get_tag_feed(&*d.feed, &tag_name, page, opts, viewer).await?;
|
.feed
|
||||||
Ok(Json(PagedResponse {
|
.query(&FeedQuery::tag(&tag_name, page, viewer))
|
||||||
items: result.items.iter().map(to_thought_response).collect(),
|
.await?;
|
||||||
total: result.total,
|
Ok(Json(serde_json::json!({
|
||||||
page: result.page,
|
"tag": tag_name,
|
||||||
per_page: result.per_page,
|
"total": result.total,
|
||||||
}))
|
"page": result.page,
|
||||||
|
"per_page": result.per_page,
|
||||||
|
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,3 @@ pub mod notifications;
|
|||||||
pub mod social;
|
pub mod social;
|
||||||
pub mod thoughts;
|
pub mod thoughts;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod well_known;
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use crate::{
|
|||||||
errors::ApiError,
|
errors::ApiError,
|
||||||
extractors::{AuthUser, Deps},
|
extractors::{AuthUser, Deps},
|
||||||
};
|
};
|
||||||
use api_types::{requests::NotificationUpdateRequest, responses::NotificationSummaryResponse};
|
use api_types::requests::NotificationUpdateRequest;
|
||||||
use application::use_cases::notifications::{
|
use application::use_cases::notifications::{
|
||||||
count_unread_notifications, list_notifications as uc_list_notifications,
|
count_unread_notifications, list_notifications as uc_list_notifications,
|
||||||
mark_all_notifications_read, mark_notification_read as uc_mark_notification_read,
|
mark_all_notifications_read, mark_notification_read as uc_mark_notification_read,
|
||||||
@@ -22,17 +22,17 @@ deps_struct!(NotificationsDeps {
|
|||||||
pub async fn list_notifications(
|
pub async fn list_notifications(
|
||||||
Deps(d): Deps<NotificationsDeps>,
|
Deps(d): Deps<NotificationsDeps>,
|
||||||
AuthUser(uid): AuthUser,
|
AuthUser(uid): AuthUser,
|
||||||
) -> Result<Json<NotificationSummaryResponse>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let page = PageParams {
|
let page = PageParams {
|
||||||
page: 1,
|
page: 1,
|
||||||
per_page: 20,
|
per_page: 20,
|
||||||
};
|
};
|
||||||
let result = uc_list_notifications(&*d.notifications, &uid, page).await?;
|
let result = uc_list_notifications(&*d.notifications, &uid, page).await?;
|
||||||
let unread = count_unread_notifications(&*d.notifications, &uid).await?;
|
let unread = count_unread_notifications(&*d.notifications, &uid).await?;
|
||||||
Ok(Json(NotificationSummaryResponse {
|
Ok(Json(serde_json::json!({
|
||||||
total: result.total,
|
"total": result.total,
|
||||||
unread,
|
"unread": unread
|
||||||
}))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(patch, path = "/notifications/{id}", params(("id" = uuid::Uuid, Path, description = "Notification ID")), request_body = NotificationUpdateRequest, responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))]
|
#[utoipa::path(patch, path = "/notifications/{id}", params(("id" = uuid::Uuid, Path, description = "Notification ID")), request_body = NotificationUpdateRequest, responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use crate::testing::make_state;
|
|||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
http::{header, Request},
|
http::{header, Request},
|
||||||
routing::patch,
|
routing::{get, patch},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user