Compare commits
93 Commits
master
...
ae0c04a4f3
| Author | SHA1 | Date | |
|---|---|---|---|
| ae0c04a4f3 | |||
| 0b80e48622 | |||
| b9cdb54340 | |||
| caab63596a | |||
| c9e74d9cc3 | |||
| 9d0166f5cb | |||
| 3861d99e1f | |||
| 9fd1eadc95 | |||
| bc3c9e3d23 | |||
| 4714216093 | |||
| 6aebad1f0f | |||
| 0beedfbf0a | |||
| 54c8433c78 | |||
| 80dbdec107 | |||
| 51a70b5260 | |||
| 9a03de8956 | |||
| 0b5d18c4ee | |||
| 83bb5d9847 | |||
| 31f5d5f337 | |||
| bde8040cc3 | |||
| d0568d7933 | |||
| fe9f284531 | |||
| dfa5e1a4db | |||
| 56f3d1ac1b | |||
| 0908b7046a | |||
| 6ae3f54a36 | |||
| 2fb577af65 | |||
| 818ab893a1 | |||
| 4cb2972f63 | |||
| 7f9c252d35 | |||
| 9a4d0c1714 | |||
| 6c295866a0 | |||
| 2f133afb96 | |||
| 62ad9f4017 | |||
| c8a1e8bfe4 | |||
| 0236a488fa | |||
| 53892847f2 | |||
| 5681d0c034 | |||
| fa8e94817a | |||
| c06d3d9207 | |||
| 32dfc84697 | |||
| c31aa033cc | |||
| 98ad1a8fe7 | |||
| b96f03f86f | |||
| d073922308 | |||
| b7e69b69dc | |||
| ec2760fd48 | |||
| 980de9697a | |||
| cb5ffe2797 | |||
| 7c6dff7f70 | |||
| 80072eb463 | |||
| b76f5996da | |||
| a1c3f7760d | |||
| 07c8b011d8 | |||
| 5c5a8c3326 | |||
| 08a97c9ee9 | |||
| 52bbb85980 | |||
| 760acb1f9f | |||
| 74674b157c | |||
| fdf4df38af | |||
| ec22ac1d75 | |||
| 5118bf4700 | |||
| f6383d6c04 | |||
| 8526b77f18 | |||
| a2c713c1fa | |||
| 1e6e9ddf6b | |||
| 5256c6e851 | |||
| 06eec2feae | |||
| 51d92e08a7 | |||
| 2ac1d4e0cf | |||
| c8d9138349 | |||
| 816d39ffd6 | |||
| 614aec8df9 | |||
| a931fb548f | |||
| 2875594ed9 | |||
| d7bac4aec3 | |||
| b5bfdadcd9 | |||
| 57c70afead | |||
| a00a5dc655 | |||
| 9bf9342fc0 | |||
| 04f9c13625 | |||
| 1f724bb064 | |||
| 8bdf55cfe1 | |||
| ab4e4c3138 | |||
| 67597ec9a6 | |||
| 1ece8032ff | |||
| f8fe49f739 | |||
| 79cce2a740 | |||
| 3a05b83320 | |||
| 1b7384386a | |||
| 933400e1c2 | |||
| 59238a36d8 | |||
| 18c179e14c |
@@ -11,13 +11,3 @@ spa/node_modules/
|
|||||||
spa/dist/
|
spa/dist/
|
||||||
spa/.env
|
spa/.env
|
||||||
spa/.env.local
|
spa/.env.local
|
||||||
.sqlx/
|
|
||||||
.superpowers/
|
|
||||||
.worktrees/
|
|
||||||
.claude/
|
|
||||||
.github/
|
|
||||||
.gitea/
|
|
||||||
imgs/
|
|
||||||
screenshots/
|
|
||||||
test_data/
|
|
||||||
*.md
|
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ jobs:
|
|||||||
components: rustfmt, clippy
|
components: rustfmt, clippy
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
- name: fmt
|
- name: fmt
|
||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
@@ -32,7 +39,7 @@ jobs:
|
|||||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
run: cargo test --workspace
|
run: cargo test
|
||||||
|
|
||||||
- name: test (federation off)
|
- name: test (federation off)
|
||||||
run: cargo test --workspace --no-default-features
|
run: cargo test --workspace --no-default-features
|
||||||
@@ -40,4 +47,4 @@ jobs:
|
|||||||
# Layering guards. These live in the Makefile and were previously local-only,
|
# Layering guards. These live in the Makefile and were previously local-only,
|
||||||
# so a PR could go green in CI while violating them.
|
# so a PR could go green in CI while violating them.
|
||||||
- name: guards
|
- name: guards
|
||||||
run: make check-appcontext check-handler-deps check-handler-repos check-handler-services check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack
|
run: make check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-dockerfile-manifests
|
||||||
|
|||||||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -23,7 +23,14 @@ jobs:
|
|||||||
components: rustfmt, clippy
|
components: rustfmt, clippy
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: Swatinem/rust-cache@v2
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-cargo-
|
||||||
|
|
||||||
- name: fmt
|
- name: fmt
|
||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
@@ -32,7 +39,7 @@ jobs:
|
|||||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
run: cargo test --workspace
|
run: cargo test
|
||||||
|
|
||||||
- name: test (federation off)
|
- name: test (federation off)
|
||||||
run: cargo test --workspace --no-default-features
|
run: cargo test --workspace --no-default-features
|
||||||
@@ -40,7 +47,7 @@ jobs:
|
|||||||
# Layering guards. These live in the Makefile and were previously local-only,
|
# Layering guards. These live in the Makefile and were previously local-only,
|
||||||
# so a PR could go green in CI while violating them.
|
# so a PR could go green in CI while violating them.
|
||||||
- name: guards
|
- name: guards
|
||||||
run: make check-appcontext check-handler-deps check-handler-repos check-handler-services check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack
|
run: make check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-dockerfile-manifests
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
name: Build & Push Docker Image
|
name: Build & Push Docker Image
|
||||||
@@ -54,9 +61,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -83,5 +87,3 @@ jobs:
|
|||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
build-args: FEATURES=federation
|
build-args: FEATURES=federation
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ cd spa && bun install && bun run dev
|
|||||||
make check # the full gate; plain `make` runs it too
|
make check # the full gate; plain `make` runs it too
|
||||||
```
|
```
|
||||||
|
|
||||||
`make check` runs, in order: `fmt-check`, `clippy` (workspace, all targets, `-D warnings`), `test`, `test-federation-off` (workspace tests with default features off), then the layering guards (`check-appcontext`, `check-handler-deps`, `check-handler-repos`, `check-handler-services`, `check-http-adapter-deps`, `check-federation-off-social`, `check-sqlite-is-ap-free`, `check-binaries-are-thin`, `check-federation-off-deps`, `check-nats-isolated`, `check-no-second-web-stack`). CI runs the same steps.
|
`make check` runs, in order: `fmt-check`, `clippy` (workspace, all targets, `-D warnings`), `test`, `test-federation-off` (workspace tests with default features off), then the layering guards (`check-appcontext`, `check-handler-deps`, `check-handler-repos`, `check-http-adapter-deps`, `check-federation-off-social`, `check-sqlite-is-ap-free`, `check-binaries-are-thin`, `check-dockerfile-manifests`). CI runs the same steps.
|
||||||
|
|
||||||
For the SPA also run:
|
For the SPA also run:
|
||||||
|
|
||||||
@@ -57,8 +57,6 @@ spa/ → React SPA (TanStack Router + shadcn/ui)
|
|||||||
|
|
||||||
Cargo features: `federation` (default; ActivityPub) and `nats`. `sqlite` and `sqlite-federation` remain as deprecated aliases so old build commands keep working.
|
Cargo features: `federation` (default; ActivityPub) and `nats`. `sqlite` and `sqlite-federation` remain as deprecated aliases so old build commands keep working.
|
||||||
|
|
||||||
**Dependencies.** Every external crate is declared once in the root `[workspace.dependencies]`, usually with `default-features = false`; member crates write `foo = { workspace = true, features = [...] }` and enable only the features they use. A new crate goes into both `members` and `default-members` in the root `Cargo.toml` (only `tui` is left out of `default-members`). `cargo machete` should stay clean, and each crate must compile on its own (`cargo check -p <crate> --all-targets`), not just through feature unification in the binaries.
|
|
||||||
|
|
||||||
### Adding a new feature
|
### Adding a new feature
|
||||||
|
|
||||||
1. **Domain first** — models in `domain/src/models/`, ports in `domain/src/ports/`, events in `domain/src/events.rs`
|
1. **Domain first** — models in `domain/src/models/`, ports in `domain/src/ports/`, events in `domain/src/events.rs`
|
||||||
|
|||||||
154
Cargo.lock
generated
154
Cargo.lock
generated
@@ -757,7 +757,7 @@ dependencies = [
|
|||||||
"object-storage",
|
"object-storage",
|
||||||
"poster-fetcher",
|
"poster-fetcher",
|
||||||
"reqwest 0.13.3",
|
"reqwest 0.13.3",
|
||||||
"rss-adapter",
|
"rss 0.1.0",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlite",
|
"sqlite",
|
||||||
@@ -2022,9 +2022,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 5.3.0",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2034,13 +2036,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"js-sys",
|
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2220,6 +2220,7 @@ dependencies = [
|
|||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
|
"utoipa-scalar",
|
||||||
"utoipa-swagger-ui",
|
"utoipa-swagger-ui",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -2931,9 +2932,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
version = "0.1.3"
|
version = "0.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
@@ -3052,7 +3053,6 @@ dependencies = [
|
|||||||
"config",
|
"config",
|
||||||
"domain",
|
"domain",
|
||||||
"reqwest 0.13.3",
|
"reqwest 0.13.3",
|
||||||
"rustls",
|
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3749,7 +3749,6 @@ dependencies = [
|
|||||||
"config",
|
"config",
|
||||||
"domain",
|
"domain",
|
||||||
"reqwest 0.13.3",
|
"reqwest 0.13.3",
|
||||||
"rustls",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3863,9 +3862,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.12"
|
version = "0.11.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
@@ -3883,15 +3882,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.19"
|
version = "0.11.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.3.4",
|
||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand 0.10.1",
|
"rand 0.9.4",
|
||||||
"rand_pcg",
|
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -3905,16 +3903,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-udp"
|
name = "quinn-udp"
|
||||||
version = "0.5.16"
|
version = "0.5.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4014,15 +4012,6 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_pcg"
|
|
||||||
version = "0.10.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
|
||||||
dependencies = [
|
|
||||||
"rand_core 0.10.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ratatui"
|
name = "ratatui"
|
||||||
version = "0.30.0"
|
version = "0.30.0"
|
||||||
@@ -4354,6 +4343,15 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rss"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"domain",
|
||||||
|
"rss 2.0.13",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rss"
|
name = "rss"
|
||||||
version = "2.0.13"
|
version = "2.0.13"
|
||||||
@@ -4365,15 +4363,6 @@ dependencies = [
|
|||||||
"quick-xml 0.39.4",
|
"quick-xml 0.39.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rss-adapter"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"chrono",
|
|
||||||
"domain",
|
|
||||||
"rss",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rust-embed"
|
name = "rust-embed"
|
||||||
version = "8.11.0"
|
version = "8.11.0"
|
||||||
@@ -5467,9 +5456,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thread_local"
|
name = "thread_local"
|
||||||
version = "1.1.10"
|
version = "1.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
|
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
@@ -5541,7 +5530,6 @@ dependencies = [
|
|||||||
"config",
|
"config",
|
||||||
"domain",
|
"domain",
|
||||||
"reqwest 0.13.3",
|
"reqwest 0.13.3",
|
||||||
"rustls",
|
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6017,6 +6005,18 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utoipa-scalar"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"utoipa",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utoipa-swagger-ui"
|
name = "utoipa-swagger-ui"
|
||||||
version = "9.0.2"
|
version = "9.0.2"
|
||||||
@@ -6437,7 +6437,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6547,6 +6547,15 @@ 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"
|
||||||
@@ -6580,13 +6589,30 @@ 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",
|
"windows_i686_gnullvm 0.52.6",
|
||||||
"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"
|
||||||
@@ -6599,6 +6625,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 = "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"
|
||||||
@@ -6611,6 +6643,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 = "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"
|
||||||
@@ -6623,12 +6661,24 @@ 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"
|
||||||
@@ -6641,6 +6691,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 = "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"
|
||||||
@@ -6653,6 +6709,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 = "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"
|
||||||
@@ -6665,6 +6727,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 = "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"
|
||||||
@@ -6677,6 +6745,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 = "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 = "winnow"
|
name = "winnow"
|
||||||
version = "0.7.15"
|
version = "0.7.15"
|
||||||
|
|||||||
76
Cargo.toml
76
Cargo.toml
@@ -1,37 +1,11 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
default-members = [
|
|
||||||
"crates/adapters/auth",
|
|
||||||
"crates/adapters/event-publisher",
|
|
||||||
"crates/adapters/metadata",
|
|
||||||
"crates/adapters/poster-fetcher",
|
|
||||||
"crates/adapters/object-storage",
|
|
||||||
"crates/adapters/rss-adapter",
|
|
||||||
"crates/adapters/sqlite",
|
|
||||||
"crates/adapters/sqlite-federation",
|
|
||||||
"crates/adapters/activitypub",
|
|
||||||
"crates/adapters/export",
|
|
||||||
"crates/adapters/nats",
|
|
||||||
"crates/adapters/tmdb-enrichment",
|
|
||||||
"crates/adapters/image-converter",
|
|
||||||
"crates/adapters/http-axum",
|
|
||||||
"crates/adapters/importer",
|
|
||||||
"crates/adapters/media-server",
|
|
||||||
"crates/event-payload",
|
|
||||||
"crates/api-types",
|
|
||||||
"crates/application",
|
|
||||||
"crates/domain",
|
|
||||||
"crates/bootstrap",
|
|
||||||
"crates/server",
|
|
||||||
"crates/worker",
|
|
||||||
"crates/config",
|
|
||||||
]
|
|
||||||
members = [
|
members = [
|
||||||
"crates/adapters/auth",
|
"crates/adapters/auth",
|
||||||
"crates/adapters/event-publisher",
|
"crates/adapters/event-publisher",
|
||||||
"crates/adapters/metadata",
|
"crates/adapters/metadata",
|
||||||
"crates/adapters/poster-fetcher",
|
"crates/adapters/poster-fetcher",
|
||||||
"crates/adapters/object-storage",
|
"crates/adapters/object-storage",
|
||||||
"crates/adapters/rss-adapter",
|
"crates/adapters/rss",
|
||||||
"crates/adapters/sqlite",
|
"crates/adapters/sqlite",
|
||||||
"crates/adapters/sqlite-federation",
|
"crates/adapters/sqlite-federation",
|
||||||
"crates/adapters/activitypub",
|
"crates/adapters/activitypub",
|
||||||
@@ -58,20 +32,20 @@ resolver = "2"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1.0", default-features = false }
|
tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] }
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
serde = { version = "1.0", default-features = false, features = ["std"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3.23", default-features = false }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
async-trait = "0.1.92"
|
async-trait = "0.1.92"
|
||||||
uuid = { version = "1.23.0", default-features = false, features = ["std"] }
|
uuid = { version = "1.23.0", features = ["v4", "v5", "serde"] }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
chrono-tz = "0.10"
|
chrono-tz = "0.10"
|
||||||
sqlx = { version = "0.8.6", default-features = false, features = [
|
sqlx = { version = "0.8.6", default-features = false, features = [
|
||||||
"runtime-tokio",
|
"runtime-tokio",
|
||||||
@@ -85,38 +59,10 @@ tempfile = "3"
|
|||||||
reqwest = { version = "0.13", default-features = false, features = ["json", "query", "charset", "http2", "system-proxy", "rustls-no-provider"] }
|
reqwest = { version = "0.13", default-features = false, features = ["json", "query", "charset", "http2", "system-proxy", "rustls-no-provider"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
object_store = { version = "0.11", default-features = false, features = ["aws"] }
|
object_store = { version = "0.11", features = ["aws"] }
|
||||||
axum = { version = "0.8.8", default-features = false }
|
axum = { version = "0.8.8", features = ["macros", "multipart"] }
|
||||||
csv = "1"
|
csv = "1"
|
||||||
figment = { version = "0.10", features = ["toml"] }
|
figment = { version = "0.10", features = ["toml"] }
|
||||||
rustls = { version = "0.23", default-features = false }
|
|
||||||
tower = { version = "0.5", default-features = false }
|
|
||||||
tower-http = { version = "0.6.8", default-features = false }
|
|
||||||
utoipa = "5.5.0"
|
|
||||||
utoipa-swagger-ui = "9.0.2"
|
|
||||||
askama = "0.16.0"
|
|
||||||
infer = "0.19.0"
|
|
||||||
percent-encoding = "2"
|
|
||||||
axum-governor = "2"
|
|
||||||
email_address = "0.2.9"
|
|
||||||
jsonwebtoken = "9"
|
|
||||||
argon2 = { version = "0.5", default-features = false }
|
|
||||||
rand_core = { version = "0.6", default-features = false }
|
|
||||||
async-nats = "0.48.0"
|
|
||||||
k-ap = { version = "0.5.1", registry = "gitea" }
|
|
||||||
url = "2"
|
|
||||||
ammonia = "4"
|
|
||||||
calamine = "0.35"
|
|
||||||
image = { version = "0.25", default-features = false }
|
|
||||||
ravif = { version = "0.11", default-features = false }
|
|
||||||
webp = "0.3"
|
|
||||||
rss = { version = "2" }
|
|
||||||
ratatui = "0.30.0"
|
|
||||||
keyring-core = "1.0.0"
|
|
||||||
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
|
|
||||||
zbus-secret-service-keyring-store = { version = "1.0.0" }
|
|
||||||
windows-native-keyring-store = { version = "1.0.0" }
|
|
||||||
directories = "6"
|
|
||||||
|
|
||||||
api-types = { path = "crates/api-types" }
|
api-types = { path = "crates/api-types" }
|
||||||
domain = { path = "crates/domain" }
|
domain = { path = "crates/domain" }
|
||||||
@@ -129,7 +75,7 @@ metadata = { path = "crates/adapters/metadata" }
|
|||||||
poster-fetcher = { path = "crates/adapters/poster-fetcher" }
|
poster-fetcher = { path = "crates/adapters/poster-fetcher" }
|
||||||
object-storage = { path = "crates/adapters/object-storage" }
|
object-storage = { path = "crates/adapters/object-storage" }
|
||||||
event-publisher = { path = "crates/adapters/event-publisher" }
|
event-publisher = { path = "crates/adapters/event-publisher" }
|
||||||
rss-adapter = { path = "crates/adapters/rss-adapter" }
|
rss = { path = "crates/adapters/rss" }
|
||||||
export = { path = "crates/adapters/export" }
|
export = { path = "crates/adapters/export" }
|
||||||
sqlite = { path = "crates/adapters/sqlite" }
|
sqlite = { path = "crates/adapters/sqlite" }
|
||||||
sqlite-federation = { path = "crates/adapters/sqlite-federation" }
|
sqlite-federation = { path = "crates/adapters/sqlite-federation" }
|
||||||
@@ -146,7 +92,3 @@ debug = 1 # line tables only — still debuggable, much faste
|
|||||||
|
|
||||||
[profile.dev.package."*"]
|
[profile.dev.package."*"]
|
||||||
opt-level = 2 # compile deps faster at runtime; paid once, cached after
|
opt-level = 2 # compile deps faster at runtime; paid once, cached after
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
codegen-units = 1
|
|
||||||
strip = true
|
|
||||||
|
|||||||
72
Dockerfile
72
Dockerfile
@@ -6,41 +6,67 @@ RUN npm ci
|
|||||||
COPY spa/ .
|
COPY spa/ .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ----- chef base -----
|
# ----- build -----
|
||||||
FROM rust:slim-bookworm AS chef
|
FROM rust:slim-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Cache dependency compilation separately from source
|
||||||
|
#
|
||||||
|
# Every workspace member's Cargo.toml must be listed here by hand — `cargo fetch`
|
||||||
|
# below reads the whole workspace graph, so a missing manifest fails the build with
|
||||||
|
# "failed to read .../Cargo.toml". Adding a crate to crates/ means adding a line here.
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY .cargo ./.cargo
|
||||||
|
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
|
||||||
|
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
|
||||||
|
COPY crates/event-payload/Cargo.toml crates/event-payload/Cargo.toml
|
||||||
|
COPY crates/adapters/event-publisher/Cargo.toml crates/adapters/event-publisher/Cargo.toml
|
||||||
|
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
|
||||||
|
COPY crates/adapters/metadata/Cargo.toml crates/adapters/metadata/Cargo.toml
|
||||||
|
COPY crates/adapters/poster-fetcher/Cargo.toml crates/adapters/poster-fetcher/Cargo.toml
|
||||||
|
COPY crates/adapters/object-storage/Cargo.toml crates/adapters/object-storage/Cargo.toml
|
||||||
|
COPY crates/adapters/export/Cargo.toml crates/adapters/export/Cargo.toml
|
||||||
|
COPY crates/adapters/importer/Cargo.toml crates/adapters/importer/Cargo.toml
|
||||||
|
COPY crates/adapters/media-server/Cargo.toml crates/adapters/media-server/Cargo.toml
|
||||||
|
COPY crates/adapters/rss/Cargo.toml crates/adapters/rss/Cargo.toml
|
||||||
|
COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml
|
||||||
|
COPY crates/adapters/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml
|
||||||
|
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
|
||||||
|
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
||||||
|
COPY crates/adapters/tmdb-enrichment/Cargo.toml crates/adapters/tmdb-enrichment/Cargo.toml
|
||||||
|
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
|
||||||
|
COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml
|
||||||
|
COPY crates/adapters/http-axum/Cargo.toml crates/adapters/http-axum/Cargo.toml
|
||||||
|
COPY crates/server/Cargo.toml crates/server/Cargo.toml
|
||||||
|
COPY crates/tui/Cargo.toml crates/tui/Cargo.toml
|
||||||
|
COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml
|
||||||
|
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
|
||||||
|
COPY crates/config/Cargo.toml crates/config/Cargo.toml
|
||||||
|
|
||||||
|
# Stub every crate so cargo can resolve and fetch deps
|
||||||
|
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
|
||||||
|
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && echo "" > {}/src/lib.rs'
|
||||||
|
|
||||||
# libwebp-dev: required at build time by the `webp` crate (C bindings)
|
# libwebp-dev: required at build time by the `webp` crate (C bindings)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libwebp-dev \
|
libwebp-dev \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
RUN cargo install cargo-chef --locked --version 0.1.78
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
FROM chef AS planner
|
RUN cargo fetch
|
||||||
COPY . .
|
|
||||||
RUN cargo chef prepare --recipe-path recipe.json
|
|
||||||
|
|
||||||
# ----- build -----
|
# Now copy real sources (invalidates cache only on source changes)
|
||||||
FROM chef AS builder
|
COPY crates ./crates
|
||||||
|
|
||||||
|
# All sqlx queries use the runtime API (no query! macros), so no database
|
||||||
|
# or .sqlx cache is needed at compile time.
|
||||||
|
#
|
||||||
# Features: federation (ActivityPub), nats (EVENT_BUS_BACKEND=nats).
|
# Features: federation (ActivityPub), nats (EVENT_BUS_BACKEND=nats).
|
||||||
# Build without federation: --build-arg FEATURES=
|
# Build without federation: --build-arg FEATURES=
|
||||||
# To add NATS support: --build-arg FEATURES=federation,nats
|
# To add NATS support: --build-arg FEATURES=federation,nats
|
||||||
ARG FEATURES=federation
|
ARG FEATURES=federation
|
||||||
|
RUN cargo build --release -p server -p worker --no-default-features --features "${FEATURES}"
|
||||||
COPY .cargo ./.cargo
|
|
||||||
COPY --from=planner /build/recipe.json recipe.json
|
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
|
||||||
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
|
|
||||||
cargo chef cook --release -p server -p worker --no-default-features --features "${FEATURES}" --recipe-path recipe.json
|
|
||||||
|
|
||||||
# All sqlx queries use the runtime API (no query! macros), so no database
|
|
||||||
# or .sqlx cache is needed at compile time.
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
|
||||||
COPY crates ./crates
|
|
||||||
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
|
|
||||||
--mount=type=cache,target=/usr/local/cargo/git,sharing=locked \
|
|
||||||
cargo build --release -p server -p worker --no-default-features --features "${FEATURES}"
|
|
||||||
|
|
||||||
# ----- runtime -----
|
# ----- runtime -----
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|||||||
64
Makefile
64
Makefile
@@ -1,6 +1,6 @@
|
|||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
|
|
||||||
check: fmt-check clippy test test-federation-off check-appcontext check-handler-deps check-handler-repos check-handler-services check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack
|
check: fmt-check clippy test test-federation-off check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-dockerfile-manifests
|
||||||
@echo "✅ All checks passed"
|
@echo "✅ All checks passed"
|
||||||
|
|
||||||
check-appcontext:
|
check-appcontext:
|
||||||
@@ -30,16 +30,6 @@ check-handler-repos:
|
|||||||
|
|
||||||
HTTP_ADAPTER_ALLOWED_ADAPTERS :=
|
HTTP_ADAPTER_ALLOWED_ADAPTERS :=
|
||||||
|
|
||||||
check-handler-services:
|
|
||||||
@hits=$$(find crates/adapters/http-axum/src -name '*.rs' -not -path '*/tests/*' -not -name tests.rs \
|
|
||||||
| xargs perl -0ne 'while (/app_ctx\s*\.\s*services\s*\.\s*([a-z_]+)/g) { print "$$ARGV: services.$$1\n" unless $$1 eq "review_logger" }'); \
|
|
||||||
if [ -n "$$hits" ]; then \
|
|
||||||
echo "❌ http-axum reaches a service port directly (call a use case instead):"; \
|
|
||||||
echo "$$hits"; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
echo "✅ No direct service-port calls in http-axum"
|
|
||||||
|
|
||||||
check-http-adapter-deps:
|
check-http-adapter-deps:
|
||||||
@deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/adapters/http-axum/Cargo.toml \
|
@deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/adapters/http-axum/Cargo.toml \
|
||||||
| sed -n 's/^\([A-Za-z0-9_-]\{1,\}\)[[:space:]]*=.*/\1/p'); \
|
| sed -n 's/^\([A-Za-z0-9_-]\{1,\}\)[[:space:]]*=.*/\1/p'); \
|
||||||
@@ -97,43 +87,21 @@ check-sqlite-is-ap-free:
|
|||||||
fi
|
fi
|
||||||
@echo "✅ sqlite crate depends on no k-ap, activitypub, or *-federation crate"
|
@echo "✅ sqlite crate depends on no k-ap, activitypub, or *-federation crate"
|
||||||
|
|
||||||
CARGO_TREE_NAMES := cargo tree -p server -p worker -e normal --prefix none
|
check-dockerfile-manifests:
|
||||||
|
@members=$$(awk '/^members = \[/{f=1;next} f&&/^\]/{f=0} f' Cargo.toml | sed -n 's/^[[:space:]]*"\(.*\)",\{0,1\}[[:space:]]*$$/\1/p'); \
|
||||||
check-federation-off-deps:
|
copied=$$(sed -n 's/^COPY \([^[:space:]]*\)\/Cargo\.toml[[:space:]].*/\1/p' Dockerfile | grep -v '^\(Cargo\.toml\|\.\)$$' || true); \
|
||||||
@tree=$$($(CARGO_TREE_NAMES) --no-default-features) || exit 1; \
|
bad=""; \
|
||||||
hits=$$(echo "$$tree" | grep -E '^(k-ap|activitypub_federation|activitypub|sqlite-federation) ' | sort -u); \
|
for m in $$members; do \
|
||||||
if [ -n "$$hits" ]; then \
|
echo "$$copied" | grep -qxF "$$m" || bad="$$bad missing-COPY:$$m"; \
|
||||||
echo "❌ federation-only crates in the --no-default-features server/worker tree:"; \
|
|
||||||
echo "$$hits"; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
echo "✅ federation-off tree has no k-ap, activitypub_federation, activitypub, or sqlite-federation"
|
|
||||||
|
|
||||||
check-nats-isolated:
|
|
||||||
@tree=$$($(CARGO_TREE_NAMES)) || exit 1; \
|
|
||||||
nats_tree=$$($(CARGO_TREE_NAMES) --features nats) || exit 1; \
|
|
||||||
if echo "$$tree" | grep -qE '^(async-nats|nats) '; then \
|
|
||||||
echo "❌ nats crates in the default server/worker tree:"; \
|
|
||||||
echo "$$tree" | grep -E '^(async-nats|nats) ' | sort -u; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
if ! echo "$$nats_tree" | grep -qE '^async-nats '; then \
|
|
||||||
echo "❌ async-nats missing from the --features nats server/worker tree — feature is not wired"; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
echo "✅ async-nats only appears with --features nats"
|
|
||||||
|
|
||||||
check-no-second-web-stack:
|
|
||||||
@for f in "" "--features nats"; do \
|
|
||||||
tree=$$($(CARGO_TREE_NAMES) $$f) || exit 1; \
|
|
||||||
hits=$$(echo "$$tree" | grep -E '^(actix|aws-lc)' | sort -u); \
|
|
||||||
if [ -n "$$hits" ]; then \
|
|
||||||
echo "❌ actix/aws-lc crates in the server/worker tree ($${f:-default}):"; \
|
|
||||||
echo "$$hits"; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
done; \
|
done; \
|
||||||
echo "✅ no actix-* or aws-lc-* in the default or nats server/worker tree"
|
for c in $$copied; do \
|
||||||
|
echo "$$members" | grep -qxF "$$c" || bad="$$bad not-a-member:$$c"; \
|
||||||
|
done; \
|
||||||
|
if [ -n "$$bad" ]; then \
|
||||||
|
echo "❌ Dockerfile manifest COPY lines out of sync with workspace members:$$bad"; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
echo "✅ Dockerfile copies a manifest for every workspace member"
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
cargo fmt
|
cargo fmt
|
||||||
@@ -177,4 +145,4 @@ dev-spa:
|
|||||||
spa-dev:
|
spa-dev:
|
||||||
@npm --prefix spa run dev
|
@npm --prefix spa run dev
|
||||||
|
|
||||||
.PHONY: check fmt fmt-check clippy test test-federation-off fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-handler-services check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-federation-off-deps check-nats-isolated check-no-second-web-stack
|
.PHONY: check fmt fmt-check clippy test test-federation-off fix dev dev-stop dev-worker dev-spa spa-dev check-appcontext check-handler-deps check-handler-repos check-http-adapter-deps check-federation-off-social check-sqlite-is-ap-free check-binaries-are-thin check-dockerfile-manifests
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -68,7 +68,7 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
|
|||||||
- CSV and JSON diary export
|
- CSV and JSON diary export
|
||||||
- File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports
|
- File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports
|
||||||
- REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
|
- REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
|
||||||
- OpenAPI documentation at `/docs` (Swagger UI)
|
- OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar)
|
||||||
- CSRF protection on all HTML form routes (double-submit cookie, defense-in-depth on top of `SameSite=Strict`)
|
- CSRF protection on all HTML form routes (double-submit cookie, defense-in-depth on top of `SameSite=Strict`)
|
||||||
- Per-IP rate limiting via token bucket (production-grade, backed by `axum-governor`)
|
- Per-IP rate limiting via token bucket (production-grade, backed by `axum-governor`)
|
||||||
- Single-page app at `/app/` — React + TanStack Router + shadcn/ui, built with Vite, served from the backend with client-side routing fallback
|
- Single-page app at `/app/` — React + TanStack Router + shadcn/ui, built with Vite, served from the backend with client-side routing fallback
|
||||||
@@ -100,7 +100,7 @@ application — use cases (commands + queries), business logic orchestra
|
|||||||
server — thin HTTP binary (dotenv, config, bootstrap::build_server, axum::serve); features (federation, nats) forward to bootstrap
|
server — thin HTTP binary (dotenv, config, bootstrap::build_server, axum::serve); features (federation, nats) forward to bootstrap
|
||||||
worker — thin worker binary (bootstrap::build_worker, run until ctrl-c)
|
worker — thin worker binary (bootstrap::build_worker, run until ctrl-c)
|
||||||
adapters/
|
adapters/
|
||||||
http-axum — Axum HTTP adapter: JSON API, classic HTML UI (askama templates), OpenAPI spec, Swagger UI, federation-gated routes; library crate, no binary of its own
|
http-axum — Axum HTTP adapter: JSON API, classic HTML UI (askama templates), OpenAPI spec, Swagger UI + Scalar, federation-gated routes; library crate, no binary of its own
|
||||||
auth — JWT issuance and validation (Argon2 passwords)
|
auth — JWT issuance and validation (Argon2 passwords)
|
||||||
sqlite — SQLite repositories (one module per aggregate under repositories/), search (FTS5), social graph, durable event queue, connection factory + migrations; the only storage adapter
|
sqlite — SQLite repositories (one module per aggregate under repositories/), search (FTS5), social graph, durable event queue, connection factory + migrations; the only storage adapter
|
||||||
metadata — TMDB / OMDb HTTP client
|
metadata — TMDB / OMDb HTTP client
|
||||||
@@ -174,13 +174,6 @@ Copy `.env.example` to `.env` and set the values you need. Malformed values (a n
|
|||||||
|
|
||||||
Build without federation with `--no-default-features`. `sqlite` and `sqlite-federation` are deprecated aliases kept so old build commands still work.
|
Build without federation with `--no-default-features`. `sqlite` and `sqlite-federation` are deprecated aliases kept so old build commands still work.
|
||||||
|
|
||||||
### Build notes
|
|
||||||
|
|
||||||
- `cargo build` / `cargo test` build every crate except the deprecated `tui`, which sits outside `default-members`. Use `-p tui` for it; `make check` and CI use `--workspace`, so it is still linted and tested.
|
|
||||||
- Release builds use `codegen-units = 1` and `strip = true` (smaller binaries; thin LTO was measured and made builds slower and binaries larger, so it is off).
|
|
||||||
- SQLite is compiled from source (bundled `libsqlite3-sys`), so the first clean build spends about a minute on it; incremental builds and the Docker/CI caches don't repeat that.
|
|
||||||
- TLS uses rustls with the `ring` provider only; `aws-lc` and `actix` are kept out of the graph by `make check-no-second-web-stack`.
|
|
||||||
|
|
||||||
The `worker` binary must run alongside `server` to process events:
|
The `worker` binary must run alongside `server` to process events:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -205,6 +198,7 @@ All REST endpoints are under `/api/v1/`. Authentication uses `Authorization: Bea
|
|||||||
Interactive API documentation is available at runtime:
|
Interactive API documentation is available at runtime:
|
||||||
|
|
||||||
- **Swagger UI** — `http://localhost:3000/docs`
|
- **Swagger UI** — `http://localhost:3000/docs`
|
||||||
|
- **Scalar** — `http://localhost:3000/scalar`
|
||||||
|
|
||||||
An [Insomnia](https://insomnia.rest/) collection covering all endpoints is included at [`movies-diary.insomnia.json`](movies-diary.insomnia.json). Import it via **File → Import**, set `base_url` and `token` in the environment, and you're ready to go.
|
An [Insomnia](https://insomnia.rest/) collection covering all endpoints is included at [`movies-diary.insomnia.json`](movies-diary.insomnia.json). Import it via **File → Import**, set `base_url` and `token` in the environment, and you're ready to go.
|
||||||
|
|
||||||
@@ -279,7 +273,7 @@ docker run -p 3000:3000 --env-file .env -v movies-diary-data:/data movies-diary
|
|||||||
docker run --env-file .env -v movies-diary-data:/data --entrypoint ./worker movies-diary
|
docker run --env-file .env -v movies-diary-data:/data --entrypoint ./worker movies-diary
|
||||||
```
|
```
|
||||||
|
|
||||||
Default build enables `federation`; add NATS with `--build-arg FEATURES=federation,nats`. The Dockerfile uses [cargo-chef](https://github.com/LukeMathWalker/cargo-chef): dependencies are compiled in their own layer keyed on the manifests, so a source-only change rebuilds just the workspace crates. BuildKit cache mounts keep the cargo registry between builds. No Dockerfile edit is needed when a crate is added. SQLite is the only database backend (see [ADR-0012](docs/adr/0012-sqlite-is-the-only-backend.md)).
|
Default build enables `federation`; add NATS with `--build-arg FEATURES=federation,nats`. SQLite is the only database backend (see [ADR-0012](docs/adr/0012-sqlite-is-the-only-backend.md)).
|
||||||
|
|
||||||
## Media Server Integration
|
## Media Server Integration
|
||||||
|
|
||||||
|
|||||||
@@ -5,22 +5,20 @@ edition.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
config = { workspace = true }
|
config = { workspace = true }
|
||||||
k-ap = { workspace = true }
|
k-ap = { version = "0.5.1", registry = "gitea" }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
uuid = { workspace = true, features = ["v4", "serde"] }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true, features = ["clock", "serde"] }
|
chrono = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
|
||||||
url = { workspace = true, features = ["serde"] }
|
url = { version = "2", features = ["serde"] }
|
||||||
ammonia = { workspace = true }
|
ammonia = "4"
|
||||||
|
|
||||||
tokio = { workspace = true, features = ["time", "sync"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["rt", "macros"] }
|
tokio = { workspace = true }
|
||||||
domain = { workspace = true, features = ["test-helpers"] }
|
domain = { workspace = true, features = ["test-helpers"] }
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ use k_ap::ActivityPubService;
|
|||||||
|
|
||||||
use crate::urls::actor_url;
|
use crate::urls::actor_url;
|
||||||
|
|
||||||
|
/// Broadcasts the actor `Delete` to the user's accepted followers.
|
||||||
|
///
|
||||||
|
/// k-ap only enqueues the per-inbox deliveries; they are signed later from the
|
||||||
|
/// tombstoned user row and keypair, which the purge keeps until the tombstone
|
||||||
|
/// cleanup (see `AccountPurger`).
|
||||||
pub struct ApDeletionNotifier {
|
pub struct ApDeletionNotifier {
|
||||||
service: Arc<ActivityPubService>,
|
service: Arc<ActivityPubService>,
|
||||||
instance: InstanceIdentity,
|
instance: InstanceIdentity,
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ impl EventHandler for ActivityPubEventHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||||
|
// A deleted account must not speak again: its queued events would
|
||||||
|
// otherwise sign and send from the tombstone after the Delete. Only
|
||||||
|
// the Delete itself (sent by the deletion notifier) goes out.
|
||||||
if let Some(author) = self.outbound_author(event)
|
if let Some(author) = self.outbound_author(event)
|
||||||
&& self.is_tombstoned(&author).await
|
&& self.is_tombstoned(&author).await
|
||||||
{
|
{
|
||||||
@@ -170,6 +173,8 @@ impl EventHandler for ActivityPubEventHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ActivityPubEventHandler {
|
impl ActivityPubEventHandler {
|
||||||
|
/// The local account an outbound activity would be sent as. A queued
|
||||||
|
/// delivery counts unless it is the account's `Delete`.
|
||||||
fn outbound_author(&self, event: &DomainEvent) -> Option<UserId> {
|
fn outbound_author(&self, event: &DomainEvent) -> Option<UserId> {
|
||||||
match event {
|
match event {
|
||||||
DomainEvent::ReviewLogged { user_id, .. }
|
DomainEvent::ReviewLogged { user_id, .. }
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub struct RemoteReviewUpdate<'a> {
|
|||||||
pub spoilers: bool,
|
pub spoilers: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A remote review to persist, with the events that ride the same write.
|
||||||
pub struct NewRemoteReview<'a> {
|
pub struct NewRemoteReview<'a> {
|
||||||
pub review: &'a Review,
|
pub review: &'a Review,
|
||||||
pub ap_id: &'a str,
|
pub ap_id: &'a str,
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
spoilers: obj.contains_spoilers,
|
spoilers: obj.contains_spoilers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The enrichment request rides the review write (one transaction).
|
||||||
let events: Vec<DomainEvent> = obj
|
let events: Vec<DomainEvent> = obj
|
||||||
.external_metadata_id
|
.external_metadata_id
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ impl CompositeSocialAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Best-effort publish for writes k_ap owns (no shared transaction).
|
||||||
async fn announce(&self, events: &[DomainEvent]) {
|
async fn announce(&self, events: &[DomainEvent]) {
|
||||||
for event in events {
|
for event in events {
|
||||||
if let Err(e) = self.event_publisher.publish(event).await {
|
if let Err(e) = self.event_publisher.publish(event).await {
|
||||||
@@ -83,6 +84,8 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// Not atomic: k_ap owns the transaction, so the events are published
|
||||||
|
// best-effort after its write succeeds.
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.follow(follower.value(), &handle)
|
.follow(follower.value(), &handle)
|
||||||
.await
|
.await
|
||||||
@@ -99,6 +102,7 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
match target {
|
match target {
|
||||||
SocialIdentity::Local(_) => self.local.unfollow(follower, target, events).await,
|
SocialIdentity::Local(_) => self.local.unfollow(follower, target, events).await,
|
||||||
|
// Not atomic: k_ap owns the transaction.
|
||||||
SocialIdentity::Remote { .. } => {
|
SocialIdentity::Remote { .. } => {
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.unfollow(follower.value(), &self.instance.actor_url_of(target))
|
.unfollow(follower.value(), &self.instance.actor_url_of(target))
|
||||||
@@ -118,6 +122,7 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
match requester {
|
match requester {
|
||||||
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester, events).await,
|
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester, events).await,
|
||||||
|
// Not atomic: k_ap owns the transaction.
|
||||||
SocialIdentity::Remote { .. } => {
|
SocialIdentity::Remote { .. } => {
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
|
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
|
||||||
@@ -137,6 +142,7 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
match requester {
|
match requester {
|
||||||
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester, events).await,
|
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester, events).await,
|
||||||
|
// Not atomic: k_ap owns the transaction.
|
||||||
SocialIdentity::Remote { .. } => {
|
SocialIdentity::Remote { .. } => {
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
|
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
|
||||||
@@ -156,6 +162,7 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
match follower {
|
match follower {
|
||||||
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower, events).await,
|
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower, events).await,
|
||||||
|
// Not atomic: k_ap owns the transaction.
|
||||||
SocialIdentity::Remote { .. } => {
|
SocialIdentity::Remote { .. } => {
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
|
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
|
||||||
@@ -167,6 +174,7 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Not atomic: k_ap owns the transaction.
|
||||||
async fn block(
|
async fn block(
|
||||||
&self,
|
&self,
|
||||||
blocker: &UserId,
|
blocker: &UserId,
|
||||||
@@ -182,6 +190,7 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Not atomic: k_ap owns the transaction.
|
||||||
async fn unblock(
|
async fn unblock(
|
||||||
&self,
|
&self,
|
||||||
blocker: &UserId,
|
blocker: &UserId,
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use domain::{ports::UserRepository, value_objects::UserId};
|
use domain::{ports::UserRepository, value_objects::UserId};
|
||||||
|
|
||||||
|
/// Answers `410 Gone` for the per-user ActivityPub endpoints of a deleted
|
||||||
|
/// account. k-ap resolves tombstones so it can sign the account's `Delete`
|
||||||
|
/// deliveries; this keeps that from making the actor look alive.
|
||||||
pub async fn tombstone_gate(
|
pub async fn tombstone_gate(
|
||||||
State(users): State<Arc<dyn UserRepository>>,
|
State(users): State<Arc<dyn UserRepository>>,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -24,6 +27,9 @@ pub async fn tombstone_gate(
|
|||||||
next.run(request).await
|
next.run(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user id in any `/users/{id}/...` path (outbox, inbox, collections,
|
||||||
|
/// content objects such as `/users/{id}/reviews/{rid}`). The bare actor path
|
||||||
|
/// has its own tombstone response.
|
||||||
pub fn gated_user(path: &str) -> Option<uuid::Uuid> {
|
pub fn gated_user(path: &str) -> Option<uuid::Uuid> {
|
||||||
let rest = path.strip_prefix("/users/")?;
|
let rest = path.strip_prefix("/users/")?;
|
||||||
let (id, endpoint) = rest.split_once('/')?;
|
let (id, endpoint) = rest.split_once('/')?;
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ impl DomainUserRepoAdapter {
|
|||||||
impl ApUserRepository for DomainUserRepoAdapter {
|
impl ApUserRepository for DomainUserRepoAdapter {
|
||||||
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
|
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
|
||||||
let user_id = UserId::from_uuid(id);
|
let user_id = UserId::from_uuid(id);
|
||||||
|
// A deleted account still resolves here, so k-ap can sign the queued
|
||||||
|
// `Delete` deliveries until the tombstone cleanup. Username lookups
|
||||||
|
// below stay live-only, and the AP endpoints answer 410 for it
|
||||||
|
// (`tombstone_gate`, `actor_document_response`).
|
||||||
let user = match self.repo.find_by_id(&user_id).await? {
|
let user = match self.repo.find_by_id(&user_id).await? {
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => match self.repo.find_tombstoned_by_id(&user_id).await? {
|
None => match self.repo.find_tombstoned_by_id(&user_id).await? {
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
//! Outbound activity ids across a redelivered event, through the real k-ap
|
||||||
|
//! `ActivityPubService` (delivery captured via its `EventPublisher` seam).
|
||||||
|
//!
|
||||||
|
//! k-ap 0.5.0 derives Create ids from the object id (UUIDv5,
|
||||||
|
//! `service/broadcast.rs:285-288`) but mints Update/Delete/Move/actor-update ids
|
||||||
|
//! randomly (`broadcast.rs:81,129,157,210,321,343,377` -> `url_scheme.rs:53`,
|
||||||
|
//! `Uuid::new_v4`) with no override.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -181,6 +189,8 @@ async fn activitypub_is_idempotent_when_run_twice() {
|
|||||||
assert_eq!(first, second, "a redelivered Create keeps its activity id");
|
assert_eq!(first, second, "a redelivered Create keeps its activity id");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Characterises the k-ap 0.5.0 limitation recorded in ADR-0015. If k-ap starts
|
||||||
|
/// minting deterministic Delete ids this fails: upgrade the ADR and flip it.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() {
|
async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() {
|
||||||
let mut h = harness().await;
|
let mut h = harness().await;
|
||||||
@@ -197,6 +207,8 @@ async fn redelivered_delete_gets_a_fresh_activity_id_known_k_ap_limitation() {
|
|||||||
assert_ne!(first, second);
|
assert_ne!(first, second);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Queued events of a deleted account must not sign and send from its
|
||||||
|
/// tombstone; the account's own Delete still goes out.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_deleted_account_sends_nothing_but_its_delete() {
|
async fn a_deleted_account_sends_nothing_but_its_delete() {
|
||||||
let mut h = harness().await;
|
let mut h = harness().await;
|
||||||
@@ -228,6 +240,7 @@ async fn a_deleted_account_sends_nothing_but_its_delete() {
|
|||||||
signing_actor_id: h.user.value(),
|
signing_actor_id: h.user.value(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The review repository panics if the handler gets past the guard.
|
||||||
for event in [&logged, &added, &queued] {
|
for event in [&logged, &added, &queued] {
|
||||||
h.handler.handle(event).await.unwrap();
|
h.handler.handle(event).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ config = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
chrono = { workspace = true, features = ["clock"] }
|
chrono = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true }
|
||||||
jsonwebtoken = { workspace = true }
|
jsonwebtoken = "9"
|
||||||
argon2 = { workspace = true, features = ["std"] }
|
argon2 = { version = "0.5", features = ["std"] }
|
||||||
rand_core = { workspace = true, features = ["getrandom"] }
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
|||||||
@@ -7,8 +7,5 @@ edition.workspace = true
|
|||||||
config = { workspace = true }
|
config = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["sync"] }
|
tokio = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio = { workspace = true, features = ["rt", "macros"] }
|
|
||||||
|
|||||||
@@ -13,4 +13,4 @@ async-stream = { workspace = true }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
domain = { workspace = true, features = ["test-helpers"] }
|
domain = { workspace = true, features = ["test-helpers"] }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["rt", "macros"] }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -7,33 +7,35 @@ license = "MIT"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["federation"]
|
default = ["federation"]
|
||||||
|
# Enables federation-gated handlers; forwards to application/federation
|
||||||
federation = ["application/federation"]
|
federation = ["application/federation"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tower-http = { workspace = true, features = ["cors", "fs", "trace", "tracing"] }
|
tower-http = { version = "0.6.8", features = ["cors", "fs", "trace", "tracing"] }
|
||||||
infer = { workspace = true }
|
infer = "0.19.0"
|
||||||
percent-encoding = { workspace = true }
|
percent-encoding = "2"
|
||||||
axum-governor = { workspace = true }
|
axum-governor = "2"
|
||||||
|
|
||||||
axum = { workspace = true, features = ["macros", "multipart", "http1", "json", "query", "form", "tokio", "matched-path", "original-uri", "tower-log", "tracing"] }
|
axum = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
uuid = { workspace = true, features = ["v4", "serde"] }
|
tokio = { workspace = true }
|
||||||
chrono = { workspace = true, features = ["clock", "serde"] }
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
chrono-tz = { workspace = true }
|
chrono-tz = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
|
|
||||||
api-types = { workspace = true }
|
api-types = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
application = { workspace = true }
|
application = { workspace = true }
|
||||||
askama = { workspace = true }
|
askama = { version = "0.16.0" }
|
||||||
utoipa = { workspace = true, features = ["axum_extras", "uuid"] }
|
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
|
||||||
utoipa-swagger-ui = { workspace = true, features = ["axum", "vendored"] }
|
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
|
||||||
|
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
tower = { workspace = true, features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
tokio = { workspace = true, features = ["rt", "macros"] }
|
|
||||||
domain = { workspace = true, features = ["test-helpers"] }
|
domain = { workspace = true, features = ["test-helpers"] }
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ use axum::{Router, routing};
|
|||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// JSON API routes (mounted under `/api/v1`), minus the webhook ingestion
|
||||||
|
/// endpoints ([`webhook_router`], separately rate limited) and the
|
||||||
|
/// federation-gated admin endpoints (`federation::api_routes`).
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
let base = Router::new()
|
let base = Router::new()
|
||||||
.route("/diary", routing::get(diary::get_diary))
|
.route("/diary", routing::get(diary::get_diary))
|
||||||
@@ -147,6 +150,7 @@ pub fn router() -> Router<AppState> {
|
|||||||
base.merge(social_routes())
|
base.merge(social_routes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Media-server webhook ingestion endpoints (mounted under `/api/v1`).
|
||||||
pub fn webhook_router() -> Router<AppState> {
|
pub fn webhook_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -163,9 +163,8 @@ pub async fn post_reindex_search(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_admin: crate::extractors::AdminApiUser,
|
_admin: crate::extractors::AdminApiUser,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
match application::search::request_reindex::execute(&state.app_ctx.deps.search.request_reindex)
|
let event = domain::events::DomainEvent::SearchReindexRequested;
|
||||||
.await
|
match state.app_ctx.services.event_publisher.publish(&event).await {
|
||||||
{
|
|
||||||
Ok(()) => StatusCode::ACCEPTED.into_response(),
|
Ok(()) => StatusCode::ACCEPTED.into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("failed to publish reindex event: {:?}", e);
|
tracing::error!("failed to publish reindex event: {:?}", e);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub const CONFLICT: &str = "CONFLICT";
|
|||||||
pub const RATE_LIMITED: &str = "RATE_LIMITED";
|
pub const RATE_LIMITED: &str = "RATE_LIMITED";
|
||||||
pub const INTERNAL: &str = "INTERNAL";
|
pub const INTERNAL: &str = "INTERNAL";
|
||||||
|
|
||||||
|
/// The only constructor of JSON error bodies in this crate.
|
||||||
pub fn refuse(status: StatusCode, code: &'static str, message: impl Into<String>) -> Response {
|
pub fn refuse(status: StatusCode, code: &'static str, message: impl Into<String>) -> Response {
|
||||||
(
|
(
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//! JSON API extractors whose rejections are refused in the one error shape.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{
|
extract::{
|
||||||
FromRequest, FromRequestParts, Multipart, Path, Query, Request,
|
FromRequest, FromRequestParts, Multipart, Path, Query, Request,
|
||||||
@@ -11,6 +13,10 @@ use serde::de::DeserializeOwned;
|
|||||||
|
|
||||||
use crate::errors::{INTERNAL, INVALID_INPUT, refuse};
|
use crate::errors::{INTERNAL, INVALID_INPUT, refuse};
|
||||||
|
|
||||||
|
/// Malformed request (bad JSON, query or path), refused as `INVALID_INPUT`.
|
||||||
|
/// Status follows axum: 400 syntax, 415 content type, 422 data errors, 413 size.
|
||||||
|
/// A rejection with a 5xx status is a server-side fault (e.g. a route that
|
||||||
|
/// declares no path parameter), refused as `INTERNAL`.
|
||||||
pub struct MalformedRequest {
|
pub struct MalformedRequest {
|
||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
message: String,
|
message: String,
|
||||||
@@ -63,6 +69,7 @@ impl From<MultipartRejection> for MalformedRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `axum::extract::Multipart` with refusals in the one error shape.
|
||||||
pub struct Upload(pub Multipart);
|
pub struct Upload(pub Multipart);
|
||||||
|
|
||||||
impl<S> FromRequest<S> for Upload
|
impl<S> FromRequest<S> for Upload
|
||||||
@@ -76,6 +83,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `axum::Json` with refusals in the one error shape.
|
||||||
pub struct Body<T>(pub T);
|
pub struct Body<T>(pub T);
|
||||||
|
|
||||||
impl<S, T> FromRequest<S> for Body<T>
|
impl<S, T> FromRequest<S> for Body<T>
|
||||||
@@ -91,6 +99,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `axum::extract::Query` with refusals in the one error shape.
|
||||||
pub struct Params<T>(pub T);
|
pub struct Params<T>(pub T);
|
||||||
|
|
||||||
impl<S, T> FromRequestParts<S> for Params<T>
|
impl<S, T> FromRequestParts<S> for Params<T>
|
||||||
@@ -106,6 +115,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `axum::extract::Path` with refusals in the one error shape.
|
||||||
pub struct PathId<T>(pub T);
|
pub struct PathId<T>(pub T);
|
||||||
|
|
||||||
impl<S, T> FromRequestParts<S> for PathId<T>
|
impl<S, T> FromRequestParts<S> for PathId<T>
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ use domain::{errors::DomainError, value_objects::UserId};
|
|||||||
|
|
||||||
use crate::{errors::ApiError, state::AppState};
|
use crate::{errors::ApiError, state::AppState};
|
||||||
|
|
||||||
async fn authenticate(state: &AppState, token: &str) -> Result<UserId, DomainError> {
|
async fn ensure_active(state: &AppState, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
application::auth::authenticate::execute(&state.app_ctx.deps.auth.authenticate, token).await
|
application::users::ensure_active::execute(&state.app_ctx.deps.users.ensure_active, user_id)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AuthenticatedUser(pub UserId);
|
pub struct AuthenticatedUser(pub UserId);
|
||||||
@@ -35,7 +36,13 @@ where
|
|||||||
"Missing or invalid auth token".into(),
|
"Missing or invalid auth token".into(),
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let user_id = authenticate(&app_state, token).await?;
|
let user_id = app_state
|
||||||
|
.app_ctx
|
||||||
|
.services
|
||||||
|
.auth
|
||||||
|
.validate_token(token)
|
||||||
|
.await?;
|
||||||
|
ensure_active(&app_state, &user_id).await?;
|
||||||
Ok(AuthenticatedUser(user_id))
|
Ok(AuthenticatedUser(user_id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,14 +74,24 @@ where
|
|||||||
let Some(token) = extract_token_from_cookie(parts) else {
|
let Some(token) = extract_token_from_cookie(parts) else {
|
||||||
return Ok(OptionalCookieUser(None));
|
return Ok(OptionalCookieUser(None));
|
||||||
};
|
};
|
||||||
let user_id = match authenticate(&app_state, &token).await {
|
let user_id = app_state
|
||||||
Ok(id) => Some(id),
|
.app_ctx
|
||||||
Err(e) => {
|
.services
|
||||||
if e.is_transient() {
|
.auth
|
||||||
tracing::warn!("authenticate failed, treating as anonymous: {e:?}");
|
.validate_token(&token)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
let user_id = match user_id {
|
||||||
|
Some(id) => match ensure_active(&app_state, &id).await {
|
||||||
|
Ok(()) => Some(id),
|
||||||
|
Err(e) => {
|
||||||
|
if e.is_transient() {
|
||||||
|
tracing::warn!("ensure_active failed, treating as anonymous: {e:?}");
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
None
|
},
|
||||||
}
|
None => None,
|
||||||
};
|
};
|
||||||
Ok(OptionalCookieUser(user_id))
|
Ok(OptionalCookieUser(user_id))
|
||||||
}
|
}
|
||||||
@@ -91,7 +108,14 @@ where
|
|||||||
let app_state = AppState::from_ref(state);
|
let app_state = AppState::from_ref(state);
|
||||||
let token = extract_token_from_cookie(parts)
|
let token = extract_token_from_cookie(parts)
|
||||||
.ok_or_else(|| Redirect::to("/login").into_response())?;
|
.ok_or_else(|| Redirect::to("/login").into_response())?;
|
||||||
let user_id = authenticate(&app_state, &token)
|
let user_id = app_state
|
||||||
|
.app_ctx
|
||||||
|
.services
|
||||||
|
.auth
|
||||||
|
.validate_token(&token)
|
||||||
|
.await
|
||||||
|
.map_err(|_| Redirect::to("/login").into_response())?;
|
||||||
|
ensure_active(&app_state, &user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
DomainError::InfrastructureError(_) => {
|
DomainError::InfrastructureError(_) => {
|
||||||
@@ -122,6 +146,7 @@ where
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(ApiError)?
|
.map_err(ApiError)?
|
||||||
|
// `None` only if the account vanished between `ensure_active` and here.
|
||||||
.ok_or_else(|| ApiError(DomainError::NotFound("user not found".into())))?;
|
.ok_or_else(|| ApiError(DomainError::NotFound("user not found".into())))?;
|
||||||
if is_admin {
|
if is_admin {
|
||||||
Ok(AdminApiUser(user_id))
|
Ok(AdminApiUser(user_id))
|
||||||
@@ -150,6 +175,7 @@ where
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
|
||||||
|
// `None` only if the account vanished between `ensure_active` and here.
|
||||||
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
|
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
|
||||||
if is_admin {
|
if is_admin {
|
||||||
Ok(AdminUser(user_id))
|
Ok(AdminUser(user_id))
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
//! Federation-only HTTP surface. This is the one module that decides, per
|
||||||
|
//! build, whether federation routes exist: both builds expose the same
|
||||||
|
//! functions, so `router.rs` and the api/html modules stay free of `cfg`.
|
||||||
|
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
pub(crate) mod blocklist_api;
|
pub(crate) mod blocklist_api;
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
@@ -14,6 +18,7 @@ use axum::{Router, routing};
|
|||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Classic-UI routes that only exist with federation (AP collections, admin pages).
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
pub fn html_routes() -> Router<AppState> {
|
pub fn html_routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -48,6 +53,7 @@ pub fn html_routes() -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// JSON API routes that only exist with federation (blocklist administration).
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
pub fn api_routes() -> Router<AppState> {
|
pub fn api_routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -74,6 +80,8 @@ pub fn api_routes() -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Without federation the follower/following collection URLs fall back to the
|
||||||
|
/// classic list pages.
|
||||||
#[cfg(not(feature = "federation"))]
|
#[cfg(not(feature = "federation"))]
|
||||||
pub fn html_routes() -> Router<AppState> {
|
pub fn html_routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -100,6 +108,8 @@ pub fn api_routes() -> Router<AppState> {
|
|||||||
Router::new()
|
Router::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ActivityPub content negotiation for `GET /users/{id}`: federation builds
|
||||||
|
/// answer AP clients with the actor document, others always render HTML.
|
||||||
#[cfg(not(feature = "federation"))]
|
#[cfg(not(feature = "federation"))]
|
||||||
pub async fn actor_document_response(
|
pub async fn actor_document_response(
|
||||||
_state: &AppState,
|
_state: &AppState,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Answers ActivityPub clients (`Accept: application/activity+json` or
|
||||||
|
/// `application/ld+json`) with the actor document; `None` means "render HTML".
|
||||||
pub async fn actor_document_response(
|
pub async fn actor_document_response(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub fn extract_from_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Router state for [`csrf_middleware`]: whether cookies carry the `Secure` attribute.
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct SecureCookies(pub bool);
|
pub struct SecureCookies(pub bool);
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ pub async fn get_image(
|
|||||||
if key.starts_with("http://") || key.starts_with("https://") {
|
if key.starts_with("http://") || key.starts_with("https://") {
|
||||||
return axum::response::Redirect::temporary(&key).into_response();
|
return axum::response::Redirect::temporary(&key).into_response();
|
||||||
}
|
}
|
||||||
match application::images::get_image::execute(&state.app_ctx.deps.images.get_image, &key).await
|
match state.app_ctx.services.object_storage.get(&key).await {
|
||||||
{
|
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
let mime = infer::get(&bytes)
|
let mime = infer::get(&bytes)
|
||||||
.map(|t| t.mime_type())
|
.map(|t| t.mime_type())
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ use axum::{Router, routing};
|
|||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Classic server-rendered UI routes, minus the rate-limited auth forms
|
||||||
|
/// ([`auth_router`]) and the federation-gated pages (`federation::html_routes`).
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
let base = Router::new()
|
let base = Router::new()
|
||||||
.route("/", routing::get(diary::get_activity_feed_html))
|
.route("/", routing::get(diary::get_activity_feed_html))
|
||||||
@@ -125,6 +127,7 @@ pub fn router() -> Router<AppState> {
|
|||||||
base.merge(social_routes())
|
base.merge(social_routes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Login, logout and registration forms; the caller adds the auth rate limiter.
|
||||||
pub fn auth_router() -> Router<AppState> {
|
pub fn auth_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -233,8 +233,11 @@ pub async fn post_delete_user(
|
|||||||
let target_id = UserId::from_uuid(target);
|
let target_id = UserId::from_uuid(target);
|
||||||
let own = actor == target_id;
|
let own = actor == target_id;
|
||||||
match delete_account(&state, actor, target_id, &form.password).await {
|
match delete_account(&state, actor, target_id, &form.password).await {
|
||||||
|
// An admin removing their own account ends their own session too.
|
||||||
Ok(()) if own => super::auth::get_logout(State(state)).await.into_response(),
|
Ok(()) if own => super::auth::get_logout(State(state)).await.into_response(),
|
||||||
Ok(()) => axum::response::Redirect::to("/users").into_response(),
|
Ok(()) => axum::response::Redirect::to("/users").into_response(),
|
||||||
|
// Same as other forbidden classic actions: a plain 403, not a redirect
|
||||||
|
// to a page that would not show the error.
|
||||||
Err(e @ DomainError::Forbidden(_)) => crate::errors::domain_error_response(e),
|
Err(e @ DomainError::Forbidden(_)) => crate::errors::domain_error_response(e),
|
||||||
Err(e) => axum::response::Redirect::to(&format!(
|
Err(e) => axum::response::Redirect::to(&format!(
|
||||||
"/users/{target}?error={}",
|
"/users/{target}?error={}",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use utoipa::{
|
|||||||
Modify, OpenApi,
|
Modify, OpenApi,
|
||||||
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
|
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
|
||||||
};
|
};
|
||||||
|
use utoipa_scalar::{Scalar, Servable};
|
||||||
use utoipa_swagger_ui::SwaggerUi;
|
use utoipa_swagger_ui::SwaggerUi;
|
||||||
|
|
||||||
struct SecurityAddon;
|
struct SecurityAddon;
|
||||||
@@ -61,7 +62,9 @@ pub fn spec() -> utoipa::openapi::OpenApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn serve(router: Router) -> Router {
|
pub fn serve(router: Router) -> Router {
|
||||||
tracing::info!("API docs at /docs (Swagger)");
|
tracing::info!("API docs at /docs (Swagger) and /scalar");
|
||||||
let spec = spec();
|
let spec = spec();
|
||||||
router.merge(SwaggerUi::new("/docs").url("/openapi.json", spec))
|
router
|
||||||
|
.merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone()))
|
||||||
|
.merge(Scalar::with_url("/scalar", spec))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ async fn health_handler() -> axum::Json<serde_json::Value> {
|
|||||||
axum::Json(serde_json::json!({ "status": "ok" }))
|
axum::Json(serde_json::json!({ "status": "ok" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// JSON API rate-limit refusals use the one error shape.
|
||||||
fn slow_down(reason: RejectionReason) -> axum::response::Response {
|
fn slow_down(reason: RejectionReason) -> axum::response::Response {
|
||||||
match reason {
|
match reason {
|
||||||
RejectionReason::QuotaExceeded { .. } => refuse(
|
RejectionReason::QuotaExceeded { .. } => refuse(
|
||||||
@@ -63,6 +64,7 @@ fn slow_down(reason: RejectionReason) -> axum::response::Response {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every `/api/v1` limiter is built here so its refusals use the one error shape.
|
||||||
fn api_governor(quota: Quota) -> axum_governor::GovernorConfig<std::net::IpAddr> {
|
fn api_governor(quota: Quota) -> axum_governor::GovernorConfig<std::net::IpAddr> {
|
||||||
GovernorConfigBuilder::default()
|
GovernorConfigBuilder::default()
|
||||||
.with_extractor(PeerIp::default())
|
.with_extractor(PeerIp::default())
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//! Request/response helpers shared by the JSON API and the classic HTML UI.
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use domain::models::import::DomainField;
|
use domain::models::import::DomainField;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//! Fixtures shared by the in-crate mapper tests.
|
||||||
|
|
||||||
use domain::value_objects::{SocialActor, SocialIdentity, UserId};
|
use domain::value_objects::{SocialActor, SocialIdentity, UserId};
|
||||||
|
|
||||||
pub(crate) fn local_actor(uid: &UserId) -> SocialActor {
|
pub(crate) fn local_actor(uid: &UserId) -> SocialActor {
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ domain = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["rt"] }
|
tokio = { workspace = true }
|
||||||
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||||
ravif = { workspace = true }
|
ravif = { version = "0.11", default-features = false }
|
||||||
webp = { workspace = true }
|
webp = "0.3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
object-storage = { workspace = true }
|
object-storage = { workspace = true }
|
||||||
object_store = { workspace = true }
|
object_store = { workspace = true }
|
||||||
uuid = { workspace = true, features = ["v4"] }
|
uuid = { workspace = true }
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ impl EventHandler for ImageConversionHandler {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `get` falls back to `{key}.webp` / `{key}.avif`, which would re-encode
|
||||||
|
// an already converted image on redelivery. Probe the exact key first:
|
||||||
|
// once the original is gone, an earlier delivery finished the conversion.
|
||||||
if !self.storage.exists(&key).await? {
|
if !self.storage.exists(&key).await? {
|
||||||
tracing::warn!(%key, "original image already gone, treating conversion as done");
|
tracing::warn!(%key, "original image already gone, treating conversion as done");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ async fn image_conversion_is_idempotent_when_run_twice() {
|
|||||||
|
|
||||||
handler.handle(&event).await.unwrap();
|
handler.handle(&event).await.unwrap();
|
||||||
let converted = storage.get(&new_key).await.unwrap();
|
let converted = storage.get(&new_key).await.unwrap();
|
||||||
|
// Redelivery after the original was swapped and deleted.
|
||||||
handler.handle(&event).await.unwrap();
|
handler.handle(&event).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(image_ref.swaps().len(), 1, "one swap");
|
assert_eq!(image_ref.swaps().len(), 1, "one swap");
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ xlsx = ["dep:calamine"]
|
|||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
csv = { workspace = true }
|
csv = { workspace = true }
|
||||||
calamine = { workspace = true, optional = true }
|
calamine = { version = "0.35", optional = true }
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ edition.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -8,6 +8,5 @@ config = { workspace = true }
|
|||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
rustls = { workspace = true, features = ["ring", "std"] }
|
serde = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ pub(crate) struct OmdbProvider {
|
|||||||
|
|
||||||
impl OmdbProvider {
|
impl OmdbProvider {
|
||||||
pub(crate) fn new(api_key: String) -> Self {
|
pub(crate) fn new(api_key: String) -> Self {
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
Self {
|
Self {
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
api_key,
|
api_key,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ pub(crate) struct TmdbProvider {
|
|||||||
|
|
||||||
impl TmdbProvider {
|
impl TmdbProvider {
|
||||||
pub(crate) fn new(api_key: String) -> Self {
|
pub(crate) fn new(api_key: String) -> Self {
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
Self {
|
Self {
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
api_key,
|
api_key,
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
#[test]
|
|
||||||
fn omdb_client_builds_without_bootstrap() {
|
|
||||||
let _ = metadata::MetadataClientImpl::new_omdb("key".into());
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#[test]
|
|
||||||
fn tmdb_client_builds_without_bootstrap() {
|
|
||||||
let _ = metadata::MetadataClientImpl::new_tmdb("key".into());
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ edition.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
config = { workspace = true }
|
config = { workspace = true }
|
||||||
async-nats = { workspace = true }
|
async-nats = "0.48.0"
|
||||||
|
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
event-payload = { workspace = true }
|
event-payload = { workspace = true }
|
||||||
@@ -13,7 +13,7 @@ async-trait = { workspace = true }
|
|||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
uuid = { workspace = true, features = ["serde"] }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true, features = ["serde"] }
|
chrono = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["rt", "sync"] }
|
tokio = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub struct NatsConfig {
|
|||||||
pub subject_prefix: String,
|
pub subject_prefix: String,
|
||||||
pub stream_name: String,
|
pub stream_name: String,
|
||||||
pub consumer_name: String,
|
pub consumer_name: String,
|
||||||
|
/// JetStream redelivery bound, same as the DB queue's attempt limit.
|
||||||
pub max_deliver: i64,
|
pub max_deliver: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ struct NatsJetStreamAckHandle {
|
|||||||
max_deliver: i64,
|
max_deliver: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How to nak a failed delivery: the same backoff schedule as the SQLite
|
||||||
|
/// queue, keyed by JetStream's delivery count (1 on first delivery).
|
||||||
struct NakPlan {
|
struct NakPlan {
|
||||||
delay: std::time::Duration,
|
delay: std::time::Duration,
|
||||||
|
/// This was the last delivery `max_deliver` allows.
|
||||||
exhausted: bool,
|
exhausted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +82,9 @@ impl AckHandle for NatsJetStreamAckHandle {
|
|||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// JetStream counts every redelivery toward `max_deliver`, deferrals
|
||||||
|
/// included. The delay is at least the remaining claim lease, so the
|
||||||
|
/// deliveries allowed by `max_deliver` still span more than one lease.
|
||||||
async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError> {
|
async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError> {
|
||||||
self.message
|
self.message
|
||||||
.ack_with(AckKind::Nak(Some(delay)))
|
.ack_with(AckKind::Nak(Some(delay)))
|
||||||
@@ -153,10 +159,20 @@ fn consume_from_rx(rx: EnvelopeRx) -> BoxStream<'static, Result<EventEnvelope, D
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long JetStream remembers `Nats-Msg-Id`s. A row re-relayed within this
|
||||||
|
/// window (crash between publish and mark-done, or a lease reclaim) is dropped
|
||||||
|
/// by the broker; a duplicate arriving later is caught by the processed-event
|
||||||
|
/// ledger, which keeps its rows for 30 days. Set explicitly so it does not
|
||||||
|
/// silently follow the server default.
|
||||||
const DEDUP_WINDOW: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
const DEDUP_WINDOW: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
||||||
|
|
||||||
const ACK_WAIT: std::time::Duration = std::time::Duration::from_secs(600);
|
const ACK_WAIT: std::time::Duration = std::time::Duration::from_secs(600);
|
||||||
|
|
||||||
|
/// `Some(updated)` when an existing stream's config differs from what this
|
||||||
|
/// build needs. `get_or_create_stream` only applies config on creation, so a
|
||||||
|
/// stream created by an older build would otherwise keep the server default
|
||||||
|
/// duplicate window (2 minutes). Only the fields this code owns are changed;
|
||||||
|
/// everything else the operator set is preserved.
|
||||||
fn stream_config_update(existing: &StreamConfig) -> Option<StreamConfig> {
|
fn stream_config_update(existing: &StreamConfig) -> Option<StreamConfig> {
|
||||||
(existing.duplicate_window != DEDUP_WINDOW).then(|| StreamConfig {
|
(existing.duplicate_window != DEDUP_WINDOW).then(|| StreamConfig {
|
||||||
duplicate_window: DEDUP_WINDOW,
|
duplicate_window: DEDUP_WINDOW,
|
||||||
@@ -164,6 +180,10 @@ fn stream_config_update(existing: &StreamConfig) -> Option<StreamConfig> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Some(updated)` when an existing durable consumer disagrees on `max_deliver`
|
||||||
|
/// or `ack_wait`. The update is a clone of the existing config with only those
|
||||||
|
/// two fields changed, so operator-set fields survive and immutable ones
|
||||||
|
/// (durable name, filter subject, deliver policy) cannot mismatch.
|
||||||
fn consumer_config_update(
|
fn consumer_config_update(
|
||||||
existing: &jetstream::consumer::Config,
|
existing: &jetstream::consumer::Config,
|
||||||
desired_max_deliver: i64,
|
desired_max_deliver: i64,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use domain::ports::{EventConsumer, EventRelaySink};
|
use domain::ports::{EventConsumer, EventRelaySink};
|
||||||
|
|
||||||
|
/// The worker's NATS side: where handlers read events from, and where the
|
||||||
|
/// outbox relay writes them to.
|
||||||
pub async fn create_bus(
|
pub async fn create_bus(
|
||||||
cfg: NatsConfig,
|
cfg: NatsConfig,
|
||||||
) -> anyhow::Result<(Arc<dyn EventConsumer>, Arc<dyn EventRelaySink>)> {
|
) -> anyhow::Result<(Arc<dyn EventConsumer>, Arc<dyn EventRelaySink>)> {
|
||||||
@@ -27,6 +29,7 @@ pub async fn create_bus(
|
|||||||
Arc::new(NatsRelaySink::new_core(client, cfg.subject_prefix)),
|
Arc::new(NatsRelaySink::new_core(client, cfg.subject_prefix)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// The consumer creates the stream the relay publishes into.
|
||||||
NatsMode::JetStream => (
|
NatsMode::JetStream => (
|
||||||
Arc::new(NatsJetStreamConsumer::create(&cfg, client.clone()).await?),
|
Arc::new(NatsJetStreamConsumer::create(&cfg, client.clone()).await?),
|
||||||
Arc::new(NatsRelaySink::new_jetstream(client, cfg.subject_prefix)),
|
Arc::new(NatsRelaySink::new_jetstream(client, cfg.subject_prefix)),
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ use event_payload::EventPayload;
|
|||||||
|
|
||||||
use crate::{payload::NatsEventMessage, subject::event_to_subject};
|
use crate::{payload::NatsEventMessage, subject::event_to_subject};
|
||||||
|
|
||||||
|
/// What goes on the wire for one outbox row. Built purely from the row, so
|
||||||
|
/// relaying it again (after a crash between publish and mark-done) yields the
|
||||||
|
/// same `Nats-Msg-Id` and JetStream drops the duplicate.
|
||||||
pub(crate) struct RelayMessage {
|
pub(crate) struct RelayMessage {
|
||||||
pub subject: String,
|
pub subject: String,
|
||||||
pub payload: Vec<u8>,
|
pub payload: Vec<u8>,
|
||||||
|
/// `None` for rows queued before identities existed: those get no dedup.
|
||||||
pub msg_id: Option<String>,
|
pub msg_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +42,8 @@ enum Transport {
|
|||||||
JetStream(jetstream::Context),
|
JetStream(jetstream::Context),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Publishes outbox rows to NATS. JetStream mode waits for the broker ack and
|
||||||
|
/// sets `Nats-Msg-Id`; core mode is fire-and-forget with no broker-side dedup.
|
||||||
pub struct NatsRelaySink {
|
pub struct NatsRelaySink {
|
||||||
transport: Transport,
|
transport: Transport,
|
||||||
subject_prefix: String,
|
subject_prefix: String,
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ futures = { workspace = true }
|
|||||||
object_store = { workspace = true }
|
object_store = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["rt", "macros"] }
|
tokio = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|||||||
@@ -8,5 +8,4 @@ config = { workspace = true }
|
|||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
rustls = { workspace = true, features = ["ring", "std"] }
|
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ pub struct ReqwestPosterFetcher {
|
|||||||
|
|
||||||
impl ReqwestPosterFetcher {
|
impl ReqwestPosterFetcher {
|
||||||
pub fn new(config: PosterFetcherConfig) -> anyhow::Result<Self> {
|
pub fn new(config: PosterFetcherConfig) -> anyhow::Result<Self> {
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(config.timeout_seconds))
|
.timeout(Duration::from_secs(config.timeout_seconds))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn poster_fetcher_builds_without_bootstrap() {
|
|
||||||
ReqwestPosterFetcher::new(PosterFetcherConfig { timeout_seconds: 5 }).expect("client builds");
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rss-adapter"
|
name = "rss"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rss = { workspace = true, features = ["atom"] }
|
rss-feed = { package = "rss", version = "2", features = ["atom"] }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use domain::errors::DomainError;
|
use domain::errors::DomainError;
|
||||||
use domain::models::DiaryEntry;
|
use domain::models::DiaryEntry;
|
||||||
use domain::ports::{FeedChannel, RssFeedRenderer};
|
use domain::ports::{FeedChannel, RssFeedRenderer};
|
||||||
use rss::extension::atom::{self, AtomExtensionBuilder, Link};
|
use rss_feed::extension::atom::{self, AtomExtensionBuilder, Link};
|
||||||
use rss::{ChannelBuilder, GuidBuilder, ItemBuilder};
|
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
|
||||||
|
|
||||||
pub struct RssAdapter {
|
pub struct RssAdapter {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
@@ -3,7 +3,7 @@ use domain::models::ReviewEdit;
|
|||||||
use domain::ports::{FeedChannel, RssFeedRenderer};
|
use domain::ports::{FeedChannel, RssFeedRenderer};
|
||||||
use domain::testing::{test_movie_with_year, test_review};
|
use domain::testing::{test_movie_with_year, test_review};
|
||||||
use domain::value_objects::{Comment, UserId};
|
use domain::value_objects::{Comment, UserId};
|
||||||
use rss_adapter::RssAdapter;
|
use rss::RssAdapter;
|
||||||
|
|
||||||
fn channel<'a>(title: &'a str) -> FeedChannel<'a> {
|
fn channel<'a>(title: &'a str) -> FeedChannel<'a> {
|
||||||
FeedChannel {
|
FeedChannel {
|
||||||
@@ -6,17 +6,17 @@ edition.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
activitypub = { workspace = true }
|
activitypub = { workspace = true }
|
||||||
k-ap = { workspace = true }
|
k-ap = { version = "0.5.1", registry = "gitea" }
|
||||||
sqlite = { workspace = true }
|
sqlite = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
uuid = { workspace = true, features = ["v4", "serde"] }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true, features = ["clock", "serde"] }
|
chrono = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["rt", "macros"] }
|
tokio = { workspace = true }
|
||||||
domain = { workspace = true, features = ["test-helpers"] }
|
domain = { workspace = true, features = ["test-helpers"] }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//! A remote review and the enrichment request it triggers commit together.
|
||||||
|
|
||||||
use activitypub::{NewRemoteReview, RemoteReviewRepository};
|
use activitypub::{NewRemoteReview, RemoteReviewRepository};
|
||||||
use domain::{
|
use domain::{
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ event-payload = { workspace = true }
|
|||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
uuid = { workspace = true, features = ["v4", "serde"] }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true, features = ["clock", "serde"] }
|
chrono = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["rt", "sync", "time", "macros", "rt-multi-thread"] }
|
tokio = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
async-stream = { workspace = true }
|
async-stream = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ use tokio::sync::{Mutex, mpsc};
|
|||||||
use crate::shared::enqueue_events;
|
use crate::shared::enqueue_events;
|
||||||
pub(crate) use payload::DbEventPayload;
|
pub(crate) use payload::DbEventPayload;
|
||||||
|
|
||||||
|
/// How long a claim holds a row. A worker that dies between claim and
|
||||||
|
/// ack/nack strands its rows in `processing`; once the lease lapses the claim
|
||||||
|
/// query takes them again. Must exceed the slowest handler.
|
||||||
pub(crate) const CLAIM_LEASE_SECONDS: i64 = 600;
|
pub(crate) const CLAIM_LEASE_SECONDS: i64 = 600;
|
||||||
|
|
||||||
pub struct DbEventQueueConfig {
|
pub struct DbEventQueueConfig {
|
||||||
@@ -137,6 +140,9 @@ struct QueueRow {
|
|||||||
claimed_at: Option<String>,
|
claimed_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Claims a batch in one atomic statement. A read-then-write transaction can
|
||||||
|
/// fail with SQLITE_BUSY_SNAPSHOT (517) when another connection commits in
|
||||||
|
/// between, which `busy_timeout` cannot retry.
|
||||||
async fn claim_batch(
|
async fn claim_batch(
|
||||||
pool: &SqlitePool,
|
pool: &SqlitePool,
|
||||||
config: &DbEventQueueConfig,
|
config: &DbEventQueueConfig,
|
||||||
@@ -162,10 +168,13 @@ async fn claim_batch(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::InfrastructureError(format!("claim batch: {e}")))?;
|
.map_err(|e| DomainError::InfrastructureError(format!("claim batch: {e}")))?;
|
||||||
|
|
||||||
|
// RETURNING order is unspecified; deliver oldest first.
|
||||||
rows.sort_by(|a, b| (&a.next_attempt_at, a.id).cmp(&(&b.next_attempt_at, b.id)));
|
rows.sort_by(|a, b| (&a.next_attempt_at, a.id).cmp(&(&b.next_attempt_at, b.id)));
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A row that cannot be decoded can never succeed, and has no ack handle to
|
||||||
|
/// settle it with; park it so it is neither retried forever nor left claimed.
|
||||||
async fn dead_letter_undecodable(pool: &SqlitePool, row_id: i64, error: &DomainError) {
|
async fn dead_letter_undecodable(pool: &SqlitePool, row_id: i64, error: &DomainError) {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
row_id,
|
row_id,
|
||||||
@@ -214,10 +223,14 @@ struct DbAckHandle {
|
|||||||
row_id: i64,
|
row_id: i64,
|
||||||
attempts: i32,
|
attempts: i32,
|
||||||
max_attempts: i32,
|
max_attempts: i32,
|
||||||
|
/// The claim this handle belongs to. Settling is conditional on it, so a
|
||||||
|
/// worker whose lease lapsed and was reclaimed cannot flip the new claim's row.
|
||||||
claimed_at: Option<String>,
|
claimed_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DbAckHandle {
|
impl DbAckHandle {
|
||||||
|
/// Runs a settle statement whose last two binds are the row id and claim
|
||||||
|
/// stamp. Zero rows changed means the claim is no longer ours.
|
||||||
async fn settle<'q>(
|
async fn settle<'q>(
|
||||||
&self,
|
&self,
|
||||||
what: &str,
|
what: &str,
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
//! Inline (not under `tests/`) because `claim_batch` is private and the race
|
||||||
|
//! only shows on its claim path; two pools on a file-backed WAL database are
|
||||||
|
//! needed, as in production.
|
||||||
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
@@ -69,6 +73,7 @@ async fn claiming_while_another_connection_writes_does_not_fail() {
|
|||||||
.await
|
.await
|
||||||
.expect("claim must not fail while another connection writes");
|
.expect("claim must not fail while another connection writes");
|
||||||
if batch.is_empty() {
|
if batch.is_empty() {
|
||||||
|
// an empty UPDATE still takes the write lock; don't starve the writer
|
||||||
tokio::task::yield_now().await;
|
tokio::task::yield_now().await;
|
||||||
}
|
}
|
||||||
claimed += batch.len();
|
claimed += batch.len();
|
||||||
@@ -310,6 +315,8 @@ mod stale_handle {
|
|||||||
use super::{claim_batch, config};
|
use super::{claim_batch, config};
|
||||||
use crate::event_queue::{CLAIM_LEASE_SECONDS, SqliteEventQueue};
|
use crate::event_queue::{CLAIM_LEASE_SECONDS, SqliteEventQueue};
|
||||||
|
|
||||||
|
/// Claims a row, lets its lease lapse, and has another worker reclaim it.
|
||||||
|
/// Returns the first (now stale) worker's envelope.
|
||||||
async fn reclaimed() -> (sqlx::SqlitePool, EventEnvelope) {
|
async fn reclaimed() -> (sqlx::SqlitePool, EventEnvelope) {
|
||||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
.max_connections(1)
|
.max_connections(1)
|
||||||
@@ -333,6 +340,8 @@ mod stale_handle {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(claim_batch(&pool, &config(10)).await.unwrap().len(), 1);
|
assert_eq!(claim_batch(&pool, &config(10)).await.unwrap().len(), 1);
|
||||||
|
// Real reclaims happen a lease later, so their timestamp differs from
|
||||||
|
// the stale handle's; the claim clock has only second resolution.
|
||||||
let second_claim = (chrono::Utc::now() - chrono::Duration::seconds(5))
|
let second_claim = (chrono::Utc::now() - chrono::Duration::seconds(5))
|
||||||
.format("%Y-%m-%dT%H:%M:%SZ")
|
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ pub use repositories::{
|
|||||||
create_federated_profile_query, create_image_ref, create_person_adapter, create_search_adapter,
|
create_federated_profile_query, create_image_ref, create_person_adapter, create_search_adapter,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Row helpers shared with the `sqlite-federation` crate (which cannot live
|
||||||
|
/// here because it depends on `activitypub`/`k-ap`).
|
||||||
pub mod persistence {
|
pub mod persistence {
|
||||||
pub use crate::shared::{datetime_to_str, enqueue_events};
|
pub use crate::shared::{datetime_to_str, enqueue_events};
|
||||||
}
|
}
|
||||||
@@ -77,6 +79,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
|
|||||||
wire_pool(pool).await
|
wire_pool(pool).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Migrates an already-connected pool and builds every repository on it.
|
||||||
pub async fn wire_pool(pool: SqlitePool) -> anyhow::Result<SqliteWireOutput> {
|
pub async fn wire_pool(pool: SqlitePool) -> anyhow::Result<SqliteWireOutput> {
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ use std::time::Duration;
|
|||||||
use domain::errors::DomainError;
|
use domain::errors::DomainError;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
/// Server and worker both migrate at startup, so two processes can race on the
|
||||||
|
/// same database. The loser's migration fails (e.g. duplicate column) because
|
||||||
|
/// the winner already applied it; re-running re-reads `_sqlx_migrations` and
|
||||||
|
/// skips what is done. Only an error that persists across all retries is returned.
|
||||||
const RETRY_BACKOFFS: [Duration; 5] = [
|
const RETRY_BACKOFFS: [Duration; 5] = [
|
||||||
Duration::from_millis(100),
|
Duration::from_millis(100),
|
||||||
Duration::from_millis(250),
|
Duration::from_millis(250),
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ use crate::shared::map_sqlx_error;
|
|||||||
|
|
||||||
const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
|
const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
|
||||||
|
|
||||||
|
/// Every table the purge empties for the account, with the column that holds
|
||||||
|
/// the user id. `reviews` is further restricted to the user's own (non-remote)
|
||||||
|
/// rows. A guard test compares this list with the schema, so a new
|
||||||
|
/// user-owned table cannot be forgotten.
|
||||||
pub const PURGED_USER_TABLES: &[(&str, &str)] = &[
|
pub const PURGED_USER_TABLES: &[(&str, &str)] = &[
|
||||||
("reviews", "user_id"),
|
("reviews", "user_id"),
|
||||||
("watchlist_entries", "user_id"),
|
("watchlist_entries", "user_id"),
|
||||||
@@ -26,8 +30,13 @@ pub const PURGED_USER_TABLES: &[(&str, &str)] = &[
|
|||||||
("blocked_actors", "local_user_id"),
|
("blocked_actors", "local_user_id"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Tables that reference a user but are kept until the tombstone cleanup:
|
||||||
|
/// the keypair is needed to sign queued federation deliveries.
|
||||||
pub const RETAINED_USER_TABLES: &[&str] = &["ap_local_actors"];
|
pub const RETAINED_USER_TABLES: &[&str] = &["ap_local_actors"];
|
||||||
|
|
||||||
|
/// Columns that identify the user by actor or content URL rather than by id.
|
||||||
|
/// The purge matches rows on them; the guard test asserts each still exists so
|
||||||
|
/// a rename cannot silently stop the purge from clearing them.
|
||||||
pub const URL_KEYED_USER_COLUMNS: &[(&str, &str)] = &[
|
pub const URL_KEYED_USER_COLUMNS: &[(&str, &str)] = &[
|
||||||
("ap_announces", "object_url"),
|
("ap_announces", "object_url"),
|
||||||
("ap_announces", "actor_url"),
|
("ap_announces", "actor_url"),
|
||||||
@@ -35,6 +44,9 @@ pub const URL_KEYED_USER_COLUMNS: &[(&str, &str)] = &[
|
|||||||
("ap_following", "remote_actor_url"),
|
("ap_following", "remote_actor_url"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Columns holding serialized events and activities that carry the user's id
|
||||||
|
/// (the outbox log keeps finished rows, including authored activity JSON) and
|
||||||
|
/// the global wrap-up reports, whose `most_active_user` names a member.
|
||||||
pub const PAYLOAD_KEYED_USER_COLUMNS: &[(&str, &str)] = &[
|
pub const PAYLOAD_KEYED_USER_COLUMNS: &[(&str, &str)] = &[
|
||||||
("event_queue", "payload"),
|
("event_queue", "payload"),
|
||||||
("wrap_up_records", "report_json"),
|
("wrap_up_records", "report_json"),
|
||||||
@@ -83,6 +95,8 @@ impl AccountPurger for SqliteAccountPurger {
|
|||||||
let base = self.instance.base_url().to_string();
|
let base = self.instance.base_url().to_string();
|
||||||
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
// Strip first: it claims the purge. Only a tombstoned, not yet purged
|
||||||
|
// account matches, so a live account (or a re-run) changes nothing.
|
||||||
let claimed = sqlx::query(
|
let claimed = sqlx::query(
|
||||||
"UPDATE users SET email = 'deleted-' || id || '@deleted.invalid',
|
"UPDATE users SET email = 'deleted-' || id || '@deleted.invalid',
|
||||||
password_hash = '!purged', display_name = NULL, bio = NULL,
|
password_hash = '!purged', display_name = NULL, bio = NULL,
|
||||||
@@ -99,6 +113,7 @@ impl AccountPurger for SqliteAccountPurger {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Announces of the user's content, matched before the reviews go.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"DELETE FROM ap_announces
|
"DELETE FROM ap_announces
|
||||||
WHERE object_url IN (
|
WHERE object_url IN (
|
||||||
@@ -115,6 +130,11 @@ impl AccountPurger for SqliteAccountPurger {
|
|||||||
.await
|
.await
|
||||||
.map_err(map_sqlx_error)?;
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
// The outbox log: finished events and activities authored as, or
|
||||||
|
// mentioning, this user. Unfinished rows are never touched (they
|
||||||
|
// belong to other users' work too and finish or dead-letter on their
|
||||||
|
// own); the tombstone cleanup removes their history later. The
|
||||||
|
// `UserDeleted` row that triggered this run stays until then as well.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"DELETE FROM event_queue
|
"DELETE FROM event_queue
|
||||||
WHERE instr(payload, ?1) > 0
|
WHERE instr(payload, ?1) > 0
|
||||||
@@ -126,6 +146,8 @@ impl AccountPurger for SqliteAccountPurger {
|
|||||||
.await
|
.await
|
||||||
.map_err(map_sqlx_error)?;
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
// Global wrap-up reports name their most active member; redact it
|
||||||
|
// (the type allows none) and keep the rest of the report.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE wrap_up_records
|
"UPDATE wrap_up_records
|
||||||
SET report_json = json_set(report_json, '$.most_active_user', NULL)
|
SET report_json = json_set(report_json, '$.most_active_user', NULL)
|
||||||
@@ -137,6 +159,7 @@ impl AccountPurger for SqliteAccountPurger {
|
|||||||
.await
|
.await
|
||||||
.map_err(map_sqlx_error)?;
|
.map_err(map_sqlx_error)?;
|
||||||
|
|
||||||
|
// Other accounts' follows of this one.
|
||||||
for table in ["ap_followers", "ap_following"] {
|
for table in ["ap_followers", "ap_following"] {
|
||||||
sqlx::query(&format!("DELETE FROM {table} WHERE remote_actor_url = ?"))
|
sqlx::query(&format!("DELETE FROM {table} WHERE remote_actor_url = ?"))
|
||||||
.bind(&actor_url)
|
.bind(&actor_url)
|
||||||
@@ -178,6 +201,9 @@ impl AccountPurger for SqliteAccountPurger {
|
|||||||
) -> Result<u64, DomainError> {
|
) -> Result<u64, DomainError> {
|
||||||
let cutoff = cutoff.format(TIMESTAMP_FORMAT).to_string();
|
let cutoff = cutoff.format(TIMESTAMP_FORMAT).to_string();
|
||||||
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
|
||||||
|
// A tombstone whose id is still named by an unfinished event waits for
|
||||||
|
// the next run: that event may yet be handled, and its history row
|
||||||
|
// must be erased with the rest.
|
||||||
let expiring: Vec<String> = sqlx::query_scalar(
|
let expiring: Vec<String> = sqlx::query_scalar(
|
||||||
"SELECT id FROM users u
|
"SELECT id FROM users u
|
||||||
WHERE purged_at IS NOT NULL AND purged_at < ?
|
WHERE purged_at IS NOT NULL AND purged_at < ?
|
||||||
@@ -191,11 +217,15 @@ impl AccountPurger for SqliteAccountPurger {
|
|||||||
.await
|
.await
|
||||||
.map_err(map_sqlx_error)?;
|
.map_err(map_sqlx_error)?;
|
||||||
for id in &expiring {
|
for id in &expiring {
|
||||||
|
// Includes the `UserDeleted` row and the `Delete` deliveries.
|
||||||
sqlx::query("DELETE FROM event_queue WHERE instr(payload, ?) > 0")
|
sqlx::query("DELETE FROM event_queue WHERE instr(payload, ?) > 0")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(map_sqlx_error)?;
|
.map_err(map_sqlx_error)?;
|
||||||
|
// A Follow arriving on the shared inbox after the purge can leave
|
||||||
|
// a late row for the tombstone; delete it explicitly rather than
|
||||||
|
// rely on the foreign-key cascade of the final `users` delete.
|
||||||
for table in ["ap_followers", "ap_following"] {
|
for table in ["ap_followers", "ap_following"] {
|
||||||
sqlx::query(&format!("DELETE FROM {table} WHERE local_user_id = ?"))
|
sqlx::query(&format!("DELETE FROM {table} WHERE local_user_id = ?"))
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ impl DiaryQuery for SqliteDiaryRepository {
|
|||||||
FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC",
|
FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tombstoned local authors vanish from the feed; remote and orphan
|
||||||
|
// rows have no `users` match, so `deleted_at` is NULL for them.
|
||||||
where_parts.push("u.deleted_at IS NULL".to_string());
|
where_parts.push("u.deleted_at IS NULL".to_string());
|
||||||
|
|
||||||
let where_clause = where_parts.join(" AND ");
|
let where_clause = where_parts.join(" AND ");
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ pub fn create_image_ref(pool: SqlitePool) -> (Arc<dyn ImageRefCommand>, Arc<dyn
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ImageRefCommand for SqliteImageRefAdapter {
|
impl ImageRefCommand for SqliteImageRefAdapter {
|
||||||
|
// Tombstoned users (`deleted_at` set) are intentionally included: their
|
||||||
|
// avatar stays referenced, and so protected from GC, until the purge.
|
||||||
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
|
async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> {
|
||||||
let mut tx = self
|
let mut tx = self
|
||||||
.pool
|
.pool
|
||||||
@@ -52,6 +54,8 @@ impl ImageRefCommand for SqliteImageRefAdapter {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ImageRefQuery for SqliteImageRefAdapter {
|
impl ImageRefQuery for SqliteImageRefAdapter {
|
||||||
|
// Tombstoned users are intentionally visible here until the purge removes
|
||||||
|
// the row, so their avatar is not garbage-collected early.
|
||||||
async fn list_keys(&self) -> Result<Vec<String>, DomainError> {
|
async fn list_keys(&self) -> Result<Vec<String>, DomainError> {
|
||||||
let rows: Vec<(String,)> = sqlx::query_as(
|
let rows: Vec<(String,)> = sqlx::query_as(
|
||||||
"SELECT avatar_path FROM users WHERE avatar_path IS NOT NULL
|
"SELECT avatar_path FROM users WHERE avatar_path IS NOT NULL
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
|
|||||||
.await
|
.await
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
|
||||||
|
// The old row still holds the external id (it is deleted last); release it
|
||||||
|
// so the canonical upsert does not violate UNIQUE(external_metadata_id).
|
||||||
sqlx::query("UPDATE movies SET external_metadata_id = NULL WHERE id = ?")
|
sqlx::query("UPDATE movies SET external_metadata_id = NULL WHERE id = ?")
|
||||||
.bind(&old)
|
.bind(&old)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use domain::{
|
|||||||
};
|
};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
/// Matches the column default in migration 0101, so string comparison orders rows by time.
|
||||||
const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
|
const TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
|
||||||
const CLAIM_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.3fZ";
|
const CLAIM_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.3fZ";
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ impl SqliteProcessedEventLedger {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A shorter lease than production's, so lease takeover can be tested
|
||||||
|
/// without waiting half an hour.
|
||||||
pub fn with_lease(pool: SqlitePool, lease: std::time::Duration) -> Self {
|
pub fn with_lease(pool: SqlitePool, lease: std::time::Duration) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
@@ -34,6 +37,7 @@ fn stamp(at: chrono::NaiveDateTime) -> String {
|
|||||||
at.format(TIMESTAMP_FORMAT).to_string()
|
at.format(TIMESTAMP_FORMAT).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Millisecond precision, so two claims taken in one second have distinct tokens.
|
||||||
fn claim_stamp(at: chrono::NaiveDateTime) -> String {
|
fn claim_stamp(at: chrono::NaiveDateTime) -> String {
|
||||||
at.format(CLAIM_FORMAT).to_string()
|
at.format(CLAIM_FORMAT).to_string()
|
||||||
}
|
}
|
||||||
@@ -47,9 +51,13 @@ fn parse_claim_stamp(value: &str) -> Option<chrono::NaiveDateTime> {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ProcessedEventLedger for SqliteProcessedEventLedger {
|
impl ProcessedEventLedger for SqliteProcessedEventLedger {
|
||||||
async fn begin(&self, id: &EventId, handler: &str) -> Result<Begin, DomainError> {
|
async fn begin(&self, id: &EventId, handler: &str) -> Result<Begin, DomainError> {
|
||||||
|
// Retried once: the row can vanish (released) between the claim attempt
|
||||||
|
// and the state lookup.
|
||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
let now = chrono::Utc::now().naive_utc();
|
let now = chrono::Utc::now().naive_utc();
|
||||||
let started = claim_stamp(now);
|
let started = claim_stamp(now);
|
||||||
|
// One statement, so two workers cannot both win: insert if absent,
|
||||||
|
// or take over an in_progress claim past its lease.
|
||||||
let claimed = sqlx::query(
|
let claimed = sqlx::query(
|
||||||
"INSERT INTO processed_events (event_id, handler, status, started_at, processed_at)
|
"INSERT INTO processed_events (event_id, handler, status, started_at, processed_at)
|
||||||
VALUES (?, ?, 'in_progress', ?, ?)
|
VALUES (?, ?, 'in_progress', ?, ?)
|
||||||
@@ -206,6 +214,7 @@ mod tests {
|
|||||||
token(ledger.begin(&id, "b").await.unwrap());
|
token(ledger.begin(&id, "b").await.unwrap());
|
||||||
token(ledger.begin(&EventId::new(), "a").await.unwrap());
|
token(ledger.begin(&EventId::new(), "a").await.unwrap());
|
||||||
|
|
||||||
|
// The blocked delivery is told to come back when the lease runs out.
|
||||||
let lease = std::time::Duration::from_secs(HANDLER_LEASE_SECONDS as u64);
|
let lease = std::time::Duration::from_secs(HANDLER_LEASE_SECONDS as u64);
|
||||||
assert!(retry_after <= lease && retry_after > lease - std::time::Duration::from_secs(5));
|
assert!(retry_after <= lease && retry_after > lease - std::time::Duration::from_secs(5));
|
||||||
}
|
}
|
||||||
@@ -251,6 +260,7 @@ mod tests {
|
|||||||
insert(&pool, &id, "in_progress", "2020-01-01T00:00:00Z").await;
|
insert(&pool, &id, "in_progress", "2020-01-01T00:00:00Z").await;
|
||||||
|
|
||||||
token(ledger.begin(&id, "h").await.unwrap());
|
token(ledger.begin(&id, "h").await.unwrap());
|
||||||
|
// The takeover refreshed the lease, so a third delivery is blocked.
|
||||||
blocked(ledger.begin(&id, "h").await.unwrap());
|
blocked(ledger.begin(&id, "h").await.unwrap());
|
||||||
let processed_at: String = sqlx::query_scalar("SELECT processed_at FROM processed_events")
|
let processed_at: String = sqlx::query_scalar("SELECT processed_at FROM processed_events")
|
||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
@@ -285,6 +295,7 @@ mod tests {
|
|||||||
ledger.release(&id, "a", &lapsed).await.unwrap();
|
ledger.release(&id, "a", &lapsed).await.unwrap();
|
||||||
ledger.complete(&id, "a", &lapsed).await.unwrap();
|
ledger.complete(&id, "a", &lapsed).await.unwrap();
|
||||||
|
|
||||||
|
// Still the new owner's live claim: neither call touched it.
|
||||||
blocked(ledger.begin(&id, "a").await.unwrap());
|
blocked(ledger.begin(&id, "a").await.unwrap());
|
||||||
ledger.complete(&id, "a", &owner).await.unwrap();
|
ledger.complete(&id, "a", &owner).await.unwrap();
|
||||||
assert_eq!(ledger.begin(&id, "a").await.unwrap(), Begin::AlreadyDone);
|
assert_eq!(ledger.begin(&id, "a").await.unwrap(), Begin::AlreadyDone);
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
// The last-admin guard lives in this single statement, so two admins
|
||||||
|
// deleting each other concurrently cannot both pass it: the write lock
|
||||||
|
// serialises them and the loser sees the winner's tombstone.
|
||||||
let stamped = sqlx::query(
|
let stamped = sqlx::query(
|
||||||
"UPDATE users SET deleted_at = ?1 WHERE id = ?2 AND deleted_at IS NULL
|
"UPDATE users SET deleted_at = ?1 WHERE id = ?2 AND deleted_at IS NULL
|
||||||
AND (role != 'admin' OR EXISTS (
|
AND (role != 'admin' OR EXISTS (
|
||||||
@@ -280,6 +283,8 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SqliteUserRepository {
|
impl SqliteUserRepository {
|
||||||
|
/// Uniqueness check over every row, tombstones included: a deleted
|
||||||
|
/// account keeps its email and username until the purge removes the row.
|
||||||
async fn row_exists(&self, column: &str, value: &str) -> Result<bool, DomainError> {
|
async fn row_exists(&self, column: &str, value: &str) -> Result<bool, DomainError> {
|
||||||
sqlx::query_scalar::<_, i64>(&format!(
|
sqlx::query_scalar::<_, i64>(&format!(
|
||||||
"SELECT EXISTS(SELECT 1 FROM users WHERE {column} = ?)"
|
"SELECT EXISTS(SELECT 1 FROM users WHERE {column} = ?)"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ impl WatchlistRepository for SqliteWatchlistRepository {
|
|||||||
.map_err(crate::shared::map_sqlx_error)?
|
.map_err(crate::shared::map_sqlx_error)?
|
||||||
.rows_affected()
|
.rows_affected()
|
||||||
> 0;
|
> 0;
|
||||||
|
// A repeat add changed nothing, so it announces nothing.
|
||||||
if inserted {
|
if inserted {
|
||||||
enqueue_events(&mut tx, events).await?;
|
enqueue_events(&mut tx, events).await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ use domain::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// THE outbox write path: inserts one `event_queue` row per event, each with a
|
||||||
|
/// fresh `EventId`, inside the caller's transaction so a change and the events
|
||||||
|
/// announcing it commit or roll back together.
|
||||||
pub async fn enqueue_events(
|
pub async fn enqueue_events(
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
events: &[domain::events::DomainEvent],
|
events: &[domain::events::DomainEvent],
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
//! The purge removes every row a deleted account owns, strips the user row to
|
||||||
|
//! a data-free tombstone, and the cleanup later drops the tombstone itself.
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
@@ -66,6 +69,8 @@ struct Seeded {
|
|||||||
movie_id: String,
|
movie_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Alice owns a row in every table; bob has a little of everything, including
|
||||||
|
/// a follow of alice and an announce of his own review, which must survive.
|
||||||
async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded {
|
async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded {
|
||||||
let alice = test_user("alice");
|
let alice = test_user("alice");
|
||||||
let bob = test_user("bob");
|
let bob = test_user("bob");
|
||||||
@@ -200,6 +205,7 @@ async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded {
|
|||||||
follower_actor_url: REMOTE_ACTOR.into(),
|
follower_actor_url: REMOTE_ACTOR.into(),
|
||||||
status: FollowStatus::Accepted,
|
status: FollowStatus::Accepted,
|
||||||
},
|
},
|
||||||
|
// bob follows alice, and alice follows bob
|
||||||
FollowWrite::AddFollow {
|
FollowWrite::AddFollow {
|
||||||
follower_id: b.value(),
|
follower_id: b.value(),
|
||||||
target_actor_url: alice_actor.clone(),
|
target_actor_url: alice_actor.clone(),
|
||||||
@@ -215,6 +221,7 @@ async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
// A remote review attributed to the same account id is not the user's.
|
||||||
exec(
|
exec(
|
||||||
pool,
|
pool,
|
||||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
|
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
|
||||||
@@ -227,6 +234,7 @@ async fn seed(pool: &SqlitePool, parts: &sqlite::SqliteWireOutput) -> Seeded {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
// Announces of alice's review and goal, of bob's review, and one by alice.
|
||||||
let alice_review: String =
|
let alice_review: String =
|
||||||
sqlx::query_scalar("SELECT id FROM reviews WHERE user_id = ? AND remote_actor_url IS NULL")
|
sqlx::query_scalar("SELECT id FROM reviews WHERE user_id = ? AND remote_actor_url IS NULL")
|
||||||
.bind(&a_str)
|
.bind(&a_str)
|
||||||
@@ -344,6 +352,7 @@ async fn purge_removes_every_user_owned_row() {
|
|||||||
purger(&pool).purge(&s.user).await.unwrap();
|
purger(&pool).purge(&s.user).await.unwrap();
|
||||||
|
|
||||||
for (table, column) in PURGED_USER_TABLES {
|
for (table, column) in PURGED_USER_TABLES {
|
||||||
|
// Reviews: only the remote one, asserted below, is left.
|
||||||
let left = if *table == "reviews" { 1 } else { 0 };
|
let left = if *table == "reviews" { 1 } else { 0 };
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
count_for(&pool, table, column, &s.user).await,
|
count_for(&pool, table, column, &s.user).await,
|
||||||
@@ -397,12 +406,14 @@ async fn purge_removes_every_user_owned_row() {
|
|||||||
"finished rows mentioning the user are gone; pending/processing rows (incl. other users' events about her) and bob's own rows stay"
|
"finished rows mentioning the user are gone; pending/processing rows (incl. other users' events about her) and bob's own rows stay"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The other account is untouched.
|
||||||
for (table, column) in PURGED_USER_TABLES {
|
for (table, column) in PURGED_USER_TABLES {
|
||||||
assert!(
|
assert!(
|
||||||
count_for(&pool, table, column, &s.other).await > 0,
|
count_for(&pool, table, column, &s.other).await > 0,
|
||||||
"{table} of the other user must survive"
|
"{table} of the other user must survive"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The keypair stays for queued deliveries.
|
||||||
for table in RETAINED_USER_TABLES {
|
for table in RETAINED_USER_TABLES {
|
||||||
assert_eq!(count_for(&pool, table, "user_id", &s.user).await, 1);
|
assert_eq!(count_for(&pool, table, "user_id", &s.user).await, 1);
|
||||||
}
|
}
|
||||||
@@ -447,6 +458,8 @@ async fn the_purge_table_lists_cover_every_user_referencing_column_in_the_schema
|
|||||||
"{t} has a foreign key to users but is not in the purge lists"
|
"{t} has a foreign key to users but is not in the purge lists"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Columns the purge matches by URL or payload must still exist, so a
|
||||||
|
// rename fails here instead of silently leaving rows behind.
|
||||||
for (table, column) in URL_KEYED_USER_COLUMNS
|
for (table, column) in URL_KEYED_USER_COLUMNS
|
||||||
.iter()
|
.iter()
|
||||||
.chain(PAYLOAD_KEYED_USER_COLUMNS)
|
.chain(PAYLOAD_KEYED_USER_COLUMNS)
|
||||||
@@ -461,6 +474,9 @@ async fn the_purge_table_lists_cover_every_user_referencing_column_in_the_schema
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ap_activities` (k-ap's inbound dedup) holds only remote activity ids and a
|
||||||
|
/// timestamp: nothing in it is attributable to a local user. If it ever grows a
|
||||||
|
/// column, the purge must start clearing it.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ap_activities_holds_nothing_attributable_to_a_local_user() {
|
async fn ap_activities_holds_nothing_attributable_to_a_local_user() {
|
||||||
let pool = migrated_pool().await;
|
let pool = migrated_pool().await;
|
||||||
@@ -539,6 +555,7 @@ async fn purge_strips_the_user_row_but_keeps_the_username_and_state() {
|
|||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
);
|
);
|
||||||
|
// Images stay referenced until the objects are gone.
|
||||||
let mut keys = p.owned_image_keys(&s.user).await.unwrap();
|
let mut keys = p.owned_image_keys(&s.user).await.unwrap();
|
||||||
keys.sort();
|
keys.sort();
|
||||||
assert_eq!(keys, vec!["avatars/a.webp", "banners/a.webp"]);
|
assert_eq!(keys, vec!["avatars/a.webp", "banners/a.webp"]);
|
||||||
@@ -554,6 +571,8 @@ async fn purging_twice_is_a_no_op() {
|
|||||||
parts.user.mark_deleted(&s.user, &[]).await.unwrap();
|
parts.user.mark_deleted(&s.user, &[]).await.unwrap();
|
||||||
let p = purger(&pool);
|
let p = purger(&pool);
|
||||||
p.purge(&s.user).await.unwrap();
|
p.purge(&s.user).await.unwrap();
|
||||||
|
// New data cannot appear for a purged account, but a re-run must not
|
||||||
|
// touch anything else or fail.
|
||||||
let before: i64 = count(&pool, "SELECT COUNT(*) FROM reviews").await;
|
let before: i64 = count(&pool, "SELECT COUNT(*) FROM reviews").await;
|
||||||
let stamp: String = sqlx::query_scalar("SELECT purged_at FROM users WHERE id = ?")
|
let stamp: String = sqlx::query_scalar("SELECT purged_at FROM users WHERE id = ?")
|
||||||
.bind(s.user.value().to_string())
|
.bind(s.user.value().to_string())
|
||||||
@@ -570,6 +589,7 @@ async fn purging_twice_is_a_no_op() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(stamp, again);
|
assert_eq!(stamp, again);
|
||||||
|
// And an id that never existed is fine too.
|
||||||
p.purge(&UserId::generate()).await.unwrap();
|
p.purge(&UserId::generate()).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,6 +666,7 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// Younger than the cutoff: kept.
|
||||||
let removed = p
|
let removed = p
|
||||||
.purge_expired_tombstones(now - chrono::Duration::days(9))
|
.purge_expired_tombstones(now - chrono::Duration::days(9))
|
||||||
.await
|
.await
|
||||||
@@ -653,6 +674,9 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o
|
|||||||
assert_eq!(removed, 0);
|
assert_eq!(removed, 0);
|
||||||
assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1);
|
assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1);
|
||||||
|
|
||||||
|
// Unfinished events still name the user (the pending Delete, bob's
|
||||||
|
// pending FollowRequested, a processing row): the tombstone waits and no
|
||||||
|
// unfinished row is ever deleted.
|
||||||
let alice = s.user.value().to_string();
|
let alice = s.user.value().to_string();
|
||||||
let unfinished = || async {
|
let unfinished = || async {
|
||||||
sqlx::query_scalar::<_, i64>(
|
sqlx::query_scalar::<_, i64>(
|
||||||
@@ -672,6 +696,7 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o
|
|||||||
assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1);
|
assert_eq!(count_for(&pool, "users", "id", &s.user).await, 1);
|
||||||
assert_eq!(unfinished().await, before);
|
assert_eq!(unfinished().await, before);
|
||||||
|
|
||||||
|
// A Follow that reached the shared inbox after the purge left a row.
|
||||||
exec(
|
exec(
|
||||||
&pool,
|
&pool,
|
||||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at)
|
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at)
|
||||||
@@ -679,6 +704,7 @@ async fn tombstone_cleanup_removes_rows_older_than_retention_and_keeps_younger_o
|
|||||||
&[&alice],
|
&[&alice],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
// Once they finish, the cleanup takes the tombstone and the history.
|
||||||
exec(&pool, "UPDATE event_queue SET status = 'done'", &[]).await;
|
exec(&pool, "UPDATE event_queue SET status = 'done'", &[]).await;
|
||||||
let removed = p
|
let removed = p
|
||||||
.purge_expired_tombstones(now - chrono::Duration::days(7))
|
.purge_expired_tombstones(now - chrono::Duration::days(7))
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
//! Real-database counterpart of the `InMemoryStore` cascade tests in the domain
|
||||||
|
//! crate. Both iterate the same table lists (`domain::testing::*_CASCADE_TABLES`),
|
||||||
|
//! so the fake and the schema cannot drift apart unnoticed.
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
@@ -24,6 +28,8 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
|||||||
|
|
||||||
const REMOTE_ACTOR: &str = "https://remote.example/users/ann";
|
const REMOTE_ACTOR: &str = "https://remote.example/users/ann";
|
||||||
|
|
||||||
|
/// Private in-memory database with plain `SqliteConnectOptions`; relies on
|
||||||
|
/// sqlx's default `foreign_keys = ON`, which production also depends on.
|
||||||
async fn migrated_pool() -> SqlitePool {
|
async fn migrated_pool() -> SqlitePool {
|
||||||
let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
|
let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
@@ -196,6 +202,7 @@ async fn seed_user_with_dependents(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
// No repository in this crate owns these two tables (federation crate).
|
||||||
sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES (?, ?, 'now')")
|
sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES (?, ?, 'now')")
|
||||||
.bind(user_id.value().to_string())
|
.bind(user_id.value().to_string())
|
||||||
.bind(REMOTE_ACTOR)
|
.bind(REMOTE_ACTOR)
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ async fn insert_review(pool: &SqlitePool, movie: &MovieId) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirrors what `application::movies::merge_duplicates` builds when the
|
||||||
|
/// canonical row does not exist.
|
||||||
fn canonical_from(old: &Movie, canonical_id: MovieId) -> Movie {
|
fn canonical_from(old: &Movie, canonical_id: MovieId) -> Movie {
|
||||||
Movie::from_persistence(
|
Movie::from_persistence(
|
||||||
canonical_id,
|
canonical_id,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//! Shared helpers for the transactional-outbox repository tests.
|
||||||
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
@@ -23,6 +25,8 @@ pub async fn count(pool: &SqlitePool, table: &str) -> i64 {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Makes every later `event_queue` insert abort, so a write whose own row
|
||||||
|
/// already landed inside the transaction must be rolled back with it.
|
||||||
pub async fn fail_event_inserts(pool: &SqlitePool) {
|
pub async fn fail_event_inserts(pool: &SqlitePool) {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TRIGGER fail_event_insert BEFORE INSERT ON event_queue \
|
"CREATE TRIGGER fail_event_insert BEFORE INSERT ON event_queue \
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//! Goal, watchlist and profile writes commit with their events or not at all.
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{ProfileField, UserProfile, WatchlistEntry},
|
models::{ProfileField, UserProfile, WatchlistEntry},
|
||||||
@@ -794,6 +796,8 @@ async fn a_failing_follow_write_rolls_back_the_rows_already_applied() {
|
|||||||
use domain::ports::{FollowCommand, FollowWrite};
|
use domain::ports::{FollowCommand, FollowWrite};
|
||||||
let (pool, user) = pool_with_user().await;
|
let (pool, user) = pool_with_user().await;
|
||||||
let mut writes = follow_writes(user.id());
|
let mut writes = follow_writes(user.id());
|
||||||
|
// An unknown local user violates the ap_following foreign key after the
|
||||||
|
// follower row was already written.
|
||||||
writes.push(FollowWrite::AddFollow {
|
writes.push(FollowWrite::AddFollow {
|
||||||
follower_id: uuid::Uuid::new_v4(),
|
follower_id: uuid::Uuid::new_v4(),
|
||||||
target_actor_url: "https://md.example/users/other".into(),
|
target_actor_url: "https://md.example/users/other".into(),
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ async fn marking_a_user_deleted_hides_them_from_every_lookup() {
|
|||||||
repo.save(&admin).await.unwrap();
|
repo.save(&admin).await.unwrap();
|
||||||
let other = test_user("other");
|
let other = test_user("other");
|
||||||
repo.save(&other).await.unwrap();
|
repo.save(&other).await.unwrap();
|
||||||
|
// A second admin, so the first is not the last one standing.
|
||||||
sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?")
|
sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?")
|
||||||
.bind(other.id().value().to_string())
|
.bind(other.id().value().to_string())
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -144,6 +145,7 @@ async fn marking_a_user_deleted_hides_them_from_every_lookup() {
|
|||||||
assert!(emails.contains(&other.email().value()));
|
assert!(emails.contains(&other.email().value()));
|
||||||
assert!(!emails.contains(&admin.email().value()));
|
assert!(!emails.contains(&admin.email().value()));
|
||||||
assert_eq!(repo.count_active_admins().await.unwrap(), 1);
|
assert_eq!(repo.count_active_admins().await.unwrap(), 1);
|
||||||
|
// The purge can still read the tombstone; a live user is not one.
|
||||||
assert!(
|
assert!(
|
||||||
repo.find_tombstoned_by_id(admin.id())
|
repo.find_tombstoned_by_id(admin.id())
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ edition.workspace = true
|
|||||||
config = { workspace = true }
|
config = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
rustls = { workspace = true, features = ["ring", "std"] }
|
serde = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ impl TmdbEnrichmentClient {
|
|||||||
.tmdb_api_key
|
.tmdb_api_key
|
||||||
.clone()
|
.clone()
|
||||||
.ok_or_else(|| DomainError::InfrastructureError("TMDB_API_KEY is not set".into()))?;
|
.ok_or_else(|| DomainError::InfrastructureError("TMDB_API_KEY is not set".into()))?;
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
api_key,
|
api_key,
|
||||||
http: reqwest::Client::new(),
|
http: reqwest::Client::new(),
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
use tmdb_enrichment::TmdbEnrichmentClient;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tmdb_enrichment_builds_without_bootstrap() {
|
|
||||||
let cfg = config::MetadataConfig {
|
|
||||||
tmdb_api_key: Some("key".into()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
TmdbEnrichmentClient::from_config(&cfg).expect("client builds");
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,9 @@ version = "0.1.0"
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
utoipa = { workspace = true, features = ["axum_extras", "uuid"] }
|
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ pub type WatchlistResponse = PaginatedResponse<crate::watchlist::WatchlistEntryD
|
|||||||
pub type PaginatedMovieHits = PaginatedResponse<crate::search::MovieSearchHitDto>;
|
pub type PaginatedMovieHits = PaginatedResponse<crate::search::MovieSearchHitDto>;
|
||||||
pub type PaginatedPersonHits = PaginatedResponse<crate::search::PersonSearchHitDto>;
|
pub type PaginatedPersonHits = PaginatedResponse<crate::search::PersonSearchHitDto>;
|
||||||
|
|
||||||
|
/// The one body every JSON API refusal carries.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
pub struct ErrorResponse {
|
pub struct ErrorResponse {
|
||||||
|
/// Human-readable message.
|
||||||
pub error: String,
|
pub error: String,
|
||||||
|
/// Stable machine-readable code, e.g. `NOT_FOUND`.
|
||||||
pub code: String,
|
pub code: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
//! Pure domain -> DTO mappers (depend only on `domain` and this crate).
|
||||||
mod diary;
|
mod diary;
|
||||||
mod goals;
|
mod goals;
|
||||||
mod movies;
|
mod movies;
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ pub struct UserProfileResponse {
|
|||||||
pub goals: Option<Vec<GoalDto>>,
|
pub goals: Option<Vec<GoalDto>>,
|
||||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
pub is_federated: bool,
|
pub is_federated: bool,
|
||||||
|
/// The instance's service account: never deletable, so UIs hide the action.
|
||||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
pub is_service_account: bool,
|
pub is_service_account: bool,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -155,6 +156,7 @@ pub struct UpdateProfileFieldsRequest {
|
|||||||
pub fields: Vec<ProfileFieldDto>,
|
pub fields: Vec<ProfileFieldDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Proof of intent for account deletion: the acting user's own password.
|
||||||
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
|
||||||
pub struct DeleteAccountRequest {
|
pub struct DeleteAccountRequest {
|
||||||
pub password: String,
|
pub password: String,
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ edition.workspace = true
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
config = { workspace = true }
|
config = { workspace = true }
|
||||||
uuid = { workspace = true, features = ["v4"] }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true, features = ["clock"] }
|
chrono = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["rt", "sync", "time", "macros"] }
|
tokio = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
@@ -22,5 +22,5 @@ xlsx = []
|
|||||||
federation = []
|
federation = []
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread"] }
|
tokio = { workspace = true }
|
||||||
domain = { workspace = true, features = ["test-helpers"] }
|
domain = { workspace = true, features = ["test-helpers"] }
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
use domain::{errors::DomainError, value_objects::UserId};
|
|
||||||
|
|
||||||
use crate::auth::deps::AuthenticateDeps;
|
|
||||||
|
|
||||||
pub async fn execute(deps: &AuthenticateDeps, token: &str) -> Result<UserId, DomainError> {
|
|
||||||
let user_id = deps.auth.validate_token(token).await?;
|
|
||||||
match deps.user.find_by_id(&user_id).await? {
|
|
||||||
Some(_) => Ok(user_id),
|
|
||||||
None => Err(DomainError::Unauthorized("account is not active".into())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,11 +32,6 @@ pub struct RegisterAndLoginDeps {
|
|||||||
pub config: AppSettings,
|
pub config: AppSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AuthenticateDeps {
|
|
||||||
pub auth: Arc<dyn AuthService>,
|
|
||||||
pub user: Arc<dyn UserRepository>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct LogoutDeps {
|
pub struct LogoutDeps {
|
||||||
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
pub mod authenticate;
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use domain::ports::{EventPublisher, MediaServerParser, ObjectStorage, PersonEnrichmentClient};
|
use domain::ports::{EventPublisher, MediaServerParser, ObjectStorage, PersonEnrichmentClient};
|
||||||
|
|
||||||
use crate::auth::deps::{
|
use crate::auth::deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterAndLoginDeps, RegisterDeps};
|
||||||
AuthenticateDeps, LoginDeps, LogoutDeps, RefreshDeps, RegisterAndLoginDeps, RegisterDeps,
|
|
||||||
};
|
|
||||||
use crate::diary::deps::{
|
use crate::diary::deps::{
|
||||||
DeleteReviewDeps, EditReviewDeps, ExportDiaryDeps, GetActivityFeedDeps, GetDiaryDeps,
|
DeleteReviewDeps, EditReviewDeps, ExportDiaryDeps, GetActivityFeedDeps, GetDiaryDeps,
|
||||||
GetMovieSocialPageDeps, GetReviewHistoryDeps, GetUserFeedDeps,
|
GetMovieSocialPageDeps, GetReviewHistoryDeps, GetUserFeedDeps,
|
||||||
};
|
};
|
||||||
use crate::goals::deps::{GoalCommandDeps, GoalQueryDeps};
|
use crate::goals::deps::{GoalCommandDeps, GoalQueryDeps};
|
||||||
use crate::images::deps::GetImageDeps;
|
|
||||||
use crate::import::deps::{
|
use crate::import::deps::{
|
||||||
ApplyMappingDeps, ApplyProfileAndMapDeps, ApplyProfileDeps, CreateSessionDeps,
|
ApplyMappingDeps, ApplyProfileAndMapDeps, ApplyProfileDeps, CreateSessionDeps,
|
||||||
DeleteImportProfileDeps, ExecuteImportDeps, GetMappingStageDeps, GetPreviewStageDeps,
|
DeleteImportProfileDeps, ExecuteImportDeps, GetMappingStageDeps, GetPreviewStageDeps,
|
||||||
@@ -26,10 +23,10 @@ use crate::movies::deps::{
|
|||||||
};
|
};
|
||||||
use crate::movies::merge_duplicates::MergeDuplicatesDeps;
|
use crate::movies::merge_duplicates::MergeDuplicatesDeps;
|
||||||
use crate::person::deps::{EnrichPersonDeps, GetPersonDeps};
|
use crate::person::deps::{EnrichPersonDeps, GetPersonDeps};
|
||||||
use crate::search::deps::{RequestReindexDeps, SearchDeps};
|
use crate::search::deps::SearchDeps;
|
||||||
use crate::social::deps::{SocialCommandDeps, SocialQueryDeps};
|
use crate::social::deps::{SocialCommandDeps, SocialQueryDeps};
|
||||||
use crate::users::deps::{
|
use crate::users::deps::{
|
||||||
AuthorizeAdminDeps, GetCurrentProfileDeps, GetFederatedProfileDeps,
|
AuthorizeAdminDeps, EnsureActiveDeps, GetCurrentProfileDeps, GetFederatedProfileDeps,
|
||||||
GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps,
|
GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps,
|
||||||
GetSettingsDeps, GetUsersListDeps, RequestAccountDeletionDeps, ResolveUsernameDeps,
|
GetSettingsDeps, GetUsersListDeps, RequestAccountDeletionDeps, ResolveUsernameDeps,
|
||||||
UpdateProfileDeps, UpdateProfileFieldsDeps, UpdateSettingsDeps,
|
UpdateProfileDeps, UpdateProfileFieldsDeps, UpdateSettingsDeps,
|
||||||
@@ -49,7 +46,6 @@ pub struct AuthGroup {
|
|||||||
pub refresh: RefreshDeps,
|
pub refresh: RefreshDeps,
|
||||||
pub register_and_login: RegisterAndLoginDeps,
|
pub register_and_login: RegisterAndLoginDeps,
|
||||||
pub logout: LogoutDeps,
|
pub logout: LogoutDeps,
|
||||||
pub authenticate: AuthenticateDeps,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DiaryGroup {
|
pub struct DiaryGroup {
|
||||||
@@ -68,10 +64,6 @@ pub struct GoalsGroup {
|
|||||||
pub query: GoalQueryDeps,
|
pub query: GoalQueryDeps,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ImagesGroup {
|
|
||||||
pub get_image: GetImageDeps,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ImportGroup {
|
pub struct ImportGroup {
|
||||||
pub create_session: CreateSessionDeps,
|
pub create_session: CreateSessionDeps,
|
||||||
pub apply_mapping: ApplyMappingDeps,
|
pub apply_mapping: ApplyMappingDeps,
|
||||||
@@ -117,7 +109,6 @@ pub struct PersonGroup {
|
|||||||
|
|
||||||
pub struct SearchGroup {
|
pub struct SearchGroup {
|
||||||
pub execute: SearchDeps,
|
pub execute: SearchDeps,
|
||||||
pub request_reindex: RequestReindexDeps,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SocialGroup {
|
pub struct SocialGroup {
|
||||||
@@ -134,6 +125,7 @@ pub struct UsersGroup {
|
|||||||
pub get_users_list: GetUsersListDeps,
|
pub get_users_list: GetUsersListDeps,
|
||||||
pub update_profile: UpdateProfileDeps,
|
pub update_profile: UpdateProfileDeps,
|
||||||
pub request_account_deletion: RequestAccountDeletionDeps,
|
pub request_account_deletion: RequestAccountDeletionDeps,
|
||||||
|
pub ensure_active: EnsureActiveDeps,
|
||||||
pub get_current_profile: GetCurrentProfileDeps,
|
pub get_current_profile: GetCurrentProfileDeps,
|
||||||
pub update_profile_fields: UpdateProfileFieldsDeps,
|
pub update_profile_fields: UpdateProfileFieldsDeps,
|
||||||
pub get_settings: GetSettingsDeps,
|
pub get_settings: GetSettingsDeps,
|
||||||
@@ -171,7 +163,6 @@ pub struct Deps {
|
|||||||
pub auth: AuthGroup,
|
pub auth: AuthGroup,
|
||||||
pub diary: DiaryGroup,
|
pub diary: DiaryGroup,
|
||||||
pub goals: GoalsGroup,
|
pub goals: GoalsGroup,
|
||||||
pub images: ImagesGroup,
|
|
||||||
pub import: ImportGroup,
|
pub import: ImportGroup,
|
||||||
pub insights: InsightsGroup,
|
pub insights: InsightsGroup,
|
||||||
pub integrations: IntegrationsGroup,
|
pub integrations: IntegrationsGroup,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Resul
|
|||||||
}
|
}
|
||||||
|
|
||||||
let movie_id = review.movie_id().clone();
|
let movie_id = review.movie_id().clone();
|
||||||
|
// Read before the write: the orphan event carries the poster to clean up.
|
||||||
let poster_path = deps
|
let poster_path = deps
|
||||||
.diary
|
.diary
|
||||||
.get_review_history(&movie_id)
|
.get_review_history(&movie_id)
|
||||||
@@ -28,6 +29,7 @@ pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Resul
|
|||||||
.poster_path()
|
.poster_path()
|
||||||
.cloned();
|
.cloned();
|
||||||
|
|
||||||
|
// One write: the review, plus its movie when this was the last review.
|
||||||
deps.review
|
deps.review
|
||||||
.delete_review_and_orphaned_movie(
|
.delete_review_and_orphaned_movie(
|
||||||
&review_id,
|
&review_id,
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ impl ReviewLogger for DefaultReviewLogger {
|
|||||||
watched_at: *review.watched_at(),
|
watched_at: *review.watched_at(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Events ride the review save (the decisive write) so they commit with it.
|
||||||
let mut events = Vec::with_capacity(2);
|
let mut events = Vec::with_capacity(2);
|
||||||
if let Some(ext_id) = movie.external_metadata_id() {
|
if let Some(ext_id) = movie.external_metadata_id() {
|
||||||
events.push(DomainEvent::MovieEnrichmentRequested {
|
events.push(DomainEvent::MovieEnrichmentRequested {
|
||||||
@@ -86,6 +87,7 @@ impl ReviewLogger for DefaultReviewLogger {
|
|||||||
events.push(review_event);
|
events.push(review_event);
|
||||||
self.review_repo.save_review(&review, &events).await?;
|
self.review_repo.save_review(&review, &events).await?;
|
||||||
|
|
||||||
|
// The removal event is recorded only if an entry was actually removed.
|
||||||
self.watchlist_repo
|
self.watchlist_repo
|
||||||
.remove_if_present(
|
.remove_if_present(
|
||||||
review.user_id(),
|
review.user_id(),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub async fn execute(
|
|||||||
cmd.target_count,
|
cmd.target_count,
|
||||||
GoalType::Movies,
|
GoalType::Movies,
|
||||||
)?;
|
)?;
|
||||||
|
// Reads that can fail happen before the write, so an error never follows a commit.
|
||||||
let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?;
|
let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?;
|
||||||
let event = DomainEvent::GoalCreated {
|
let event = DomainEvent::GoalCreated {
|
||||||
goal_id: g.id().clone(),
|
goal_id: g.id().clone(),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub async fn execute(
|
|||||||
.ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?;
|
.ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?;
|
||||||
|
|
||||||
g.update_target(cmd.target_count)?;
|
g.update_target(cmd.target_count)?;
|
||||||
|
// Reads that can fail happen before the write, so an error never follows a commit.
|
||||||
let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?;
|
let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?;
|
||||||
let event = DomainEvent::GoalUpdated {
|
let event = DomainEvent::GoalUpdated {
|
||||||
goal_id: g.id().clone(),
|
goal_id: g.id().clone(),
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use domain::ports::ObjectStorage;
|
|
||||||
|
|
||||||
pub struct GetImageDeps {
|
|
||||||
pub object_storage: Arc<dyn ObjectStorage>,
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
use domain::errors::DomainError;
|
|
||||||
|
|
||||||
use crate::images::deps::GetImageDeps;
|
|
||||||
|
|
||||||
pub async fn execute(deps: &GetImageDeps, key: &str) -> Result<Vec<u8>, DomainError> {
|
|
||||||
deps.object_storage.get(key).await
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
pub mod deps;
|
|
||||||
pub mod get_image;
|
|
||||||
@@ -88,6 +88,7 @@ pub async fn execute(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Row-level validation failures keep their bare message in the summary.
|
||||||
fn import_error_message(e: DomainError) -> String {
|
fn import_error_message(e: DomainError) -> String {
|
||||||
match e {
|
match e {
|
||||||
DomainError::ValidationError(msg) => msg,
|
DomainError::ValidationError(msg) => msg,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user