Compare commits
506
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db9ada0f31 | ||
|
|
ae84f62123 | ||
|
|
2a40cb7b23 | ||
|
|
2ed45b1094 | ||
|
|
339243e861 | ||
|
|
8919957d4a | ||
|
|
16ddfa6529 | ||
|
|
85fe925763 | ||
|
|
46b38f9800 | ||
|
|
783d8bfd43 | ||
|
|
d041f498cf | ||
|
|
540f581927 | ||
|
|
16c84c450e | ||
|
|
f50521072d | ||
|
|
16085c723a | ||
|
|
820df9a196 | ||
|
|
940b28dd9b | ||
|
|
2cb1bae5ce | ||
|
|
b537009198 | ||
|
|
44a3334f99 | ||
|
|
ecc8cc80c0 | ||
|
|
31ea755c86 | ||
|
|
1e11c93eb5 | ||
|
|
da3a0d9cfa | ||
|
|
3d289884f8 | ||
|
|
bb933d4091 | ||
|
|
84674ffdaf | ||
|
|
152ac785b3 | ||
|
|
45c211f3fd | ||
|
|
ee3380eed9 | ||
|
|
ff283cd895 | ||
|
|
3ab4162a8b | ||
|
|
aff408cc07 | ||
|
|
1988a68e67 | ||
|
|
c9e13ce143 | ||
|
|
f6fd002981 | ||
|
|
b6eb14b13f | ||
|
|
8e401b80a0 | ||
|
|
cc0a88ed9f | ||
|
|
94899e69d3 | ||
|
|
ce73552b59 | ||
|
|
1b0b119a09 | ||
|
|
7fe6335583 | ||
|
|
8d17597202 | ||
|
|
40c3e2cd8b | ||
|
|
6297c3733b | ||
|
|
23f73519d3 | ||
|
|
768e828246 | ||
|
|
39ad3144f1 | ||
|
|
bc8577db0f | ||
|
|
79437d73e8 | ||
|
|
b0395ce9cf | ||
|
|
eef17eb79b | ||
|
|
84f3bd22c7 | ||
|
|
b1df79cad0 | ||
|
|
0ff95251f5 | ||
|
|
82367dc1d5 | ||
|
|
02691b7d0f | ||
|
|
e5d77916d4 | ||
|
|
5828df5658 | ||
|
|
7f03994e97 | ||
|
|
fe398df8f6 | ||
|
|
0df423a84f | ||
|
|
08bffdb43d | ||
|
|
4660a81d51 | ||
|
|
d63c90cb5f | ||
|
|
779d4d66e1 | ||
|
|
cea4fa1a5a | ||
|
|
92d2855bff | ||
|
|
5e737fac6c | ||
|
|
28a92fcc9b | ||
|
|
e959d0eda2 | ||
|
|
799cbe1bc9 | ||
|
|
6e3fea0abb | ||
|
|
fde5a5c907 | ||
|
|
dd097b952c | ||
|
|
58ff04f782 | ||
|
|
6afb6abccf | ||
|
|
8d70d0312c | ||
|
|
daea20bcb2 | ||
|
|
dca32b078b | ||
|
|
cf3c38bed0 | ||
|
|
a28c61af69 | ||
|
|
a1bc642615 | ||
|
|
214cebfac2 | ||
|
|
83632c2439 | ||
|
|
8a70232f18 | ||
|
|
ccfa91aa57 | ||
|
|
b29d58213f | ||
|
|
bd9f00ecbf | ||
|
|
c83037c04e | ||
|
|
0008f48988 | ||
|
|
63e21bb102 | ||
|
|
2ca50ae36c | ||
|
|
b2ecd940de | ||
|
|
05e999b117 | ||
|
|
45032d3e47 | ||
|
|
96259f0e35 | ||
|
|
d5b73fc4e9 | ||
|
|
7c086a5615 | ||
|
|
8c2828716f | ||
|
|
6a342f665c | ||
|
|
8ae0bdea6a | ||
|
|
228e08fdf8 | ||
|
|
884322069a | ||
|
|
1dbca84485 | ||
|
|
06a92f1f61 | ||
|
|
1684d7901e | ||
|
|
eefb374978 | ||
|
|
1a409900d9 | ||
|
|
f9661946ab | ||
|
|
8210544f74 | ||
|
|
d67f5fe0a3 | ||
|
|
06f74f1623 | ||
|
|
66c7a22d04 | ||
|
|
361ba45fe8 | ||
|
|
105454bd61 | ||
|
|
2b2fd2b5b7 | ||
|
|
54f98cbfd2 | ||
|
|
260e13273d | ||
|
|
c58d1180e7 | ||
|
|
bca8bad822 | ||
|
|
eabc0d93fe | ||
|
|
dc84a8b650 | ||
|
|
f78ee25258 | ||
|
|
838ad745f3 | ||
|
|
884ea49238 | ||
|
|
902333e336 | ||
|
|
32317236d9 | ||
|
|
f42f32980d | ||
|
|
23298758f4 | ||
|
|
07c7eb0f14 | ||
|
|
72df1d17aa | ||
|
|
9964b5c158 | ||
|
|
a30c12193d | ||
|
|
470d4f3944 | ||
|
|
c409fd1fe5 | ||
|
|
125a044a75 | ||
|
|
ff5220869c | ||
|
|
ed94a46b07 | ||
|
|
b03d3c890d | ||
|
|
fd98b38364 | ||
|
|
f5a1806ae3 | ||
|
|
07dd1d545c | ||
|
|
9268930c10 | ||
|
|
c7bf4db085 | ||
|
|
1e4a7460ce | ||
|
|
d62769067d | ||
|
|
3dacf217a0 | ||
|
|
4dde14bf5f | ||
|
|
41dc66574d | ||
|
|
7877300617 | ||
|
|
d23da226a0 | ||
|
|
044ecdd0f6 | ||
|
|
2ccc0381bc | ||
|
|
01a55d2de7 | ||
|
|
c47d9d7c14 | ||
|
|
ae01637dfa | ||
|
|
431b904ee9 | ||
|
|
dfbc56402f | ||
|
|
516941192d | ||
|
|
60bd728a04 | ||
|
|
b0b95810e0 | ||
|
|
d19124f5dc | ||
|
|
0c1d1b5796 | ||
|
|
80ebf75313 | ||
|
|
b15f0dc9ea | ||
|
|
7383d47bad | ||
|
|
4ead8376e7 | ||
|
|
d8f5145ff3 | ||
|
|
dc977fe0bd | ||
|
|
18b087202d | ||
|
|
65d265f267 | ||
|
|
9244bcef15 | ||
|
|
2bfa84efa9 | ||
|
|
cade9cb389 | ||
|
|
fd3be7207b | ||
|
|
235f6d04d5 | ||
|
|
b9c75b2141 | ||
|
|
f81cc4ecdb | ||
|
|
573a58622f | ||
|
|
d4f3cceb52 | ||
|
|
7fb7ee80f2 | ||
|
|
68082cec49 | ||
|
|
7e1eb65f3b | ||
|
|
a9a1975163 | ||
|
|
ce3ec96528 | ||
|
|
0d8decb366 | ||
|
|
4d938cd5aa | ||
|
|
3f96be2c0a | ||
|
|
ef6c10f06e | ||
|
|
b50bf6159a | ||
|
|
c57119e9a7 | ||
|
|
beb0dbc1f4 | ||
|
|
fe820e8c4d | ||
|
|
7e43f673a0 | ||
|
|
6e38d0c093 | ||
|
|
973dbeb449 | ||
|
|
6547ae05fa | ||
|
|
558f097fd6 | ||
|
|
a64dd77efa | ||
|
|
0338a193db | ||
|
|
c4b22628af | ||
|
|
808695d715 | ||
|
|
76d4c5c9a2 | ||
|
|
910ed151f1 | ||
|
|
2512a9f62b | ||
|
|
541f073a2e | ||
|
|
39852ab381 | ||
|
|
5a9aac18ea | ||
|
|
f79ecd11ae | ||
|
|
46fdc2764c | ||
|
|
6d450caebf | ||
|
|
49232fd547 | ||
|
|
6e7ea8b9d6 | ||
|
|
6c030a8109 | ||
|
|
68eeb6396d | ||
|
|
fdc596854e | ||
|
|
c2c4d9151c | ||
|
|
45579e53c7 | ||
|
|
1bbf85e0d4 | ||
|
|
8389326c97 | ||
|
|
a5304518c8 | ||
|
|
ba1de69fc5 | ||
|
|
cffb74326a | ||
|
|
f1fb388ded | ||
|
|
30b5975534 | ||
|
|
687ec881ca | ||
|
|
fba3273c67 | ||
|
|
ed96df0ac3 | ||
|
|
697aabeec3 | ||
|
|
40fd91b9e1 | ||
|
|
fdee8658c3 | ||
|
|
58a0c6ef64 | ||
|
|
13d1b459fc | ||
|
|
49703d7e88 | ||
|
|
e4eae71314 | ||
|
|
47cd915e40 | ||
|
|
530c497277 | ||
|
|
cb79ac5321 | ||
|
|
b52214f7a0 | ||
|
|
a876dc3d0b | ||
|
|
057150d377 | ||
|
|
5d66d2758d | ||
|
|
ee7b9b897a | ||
|
|
d94ff097d2 | ||
|
|
0dda84e5c4 | ||
|
|
abcf77eae3 | ||
|
|
cae0099d6f | ||
|
|
1c6daab92a | ||
|
|
f28c334c72 | ||
|
|
07de8de380 | ||
|
|
e3775771ca | ||
|
|
4228ce443c | ||
|
|
af85d2be53 | ||
|
|
876a069d06 | ||
|
|
96dfed1ec5 | ||
|
|
3211852acd | ||
|
|
048007ea2d | ||
|
|
d4b359dbae | ||
|
|
a4ede20204 | ||
|
|
9c95b8732f | ||
|
|
9f0df2ac44 | ||
|
|
719e723816 | ||
|
|
88d473f6e1 | ||
|
|
c3bfbe8519 | ||
|
|
9771378bfd | ||
|
|
12e2a82b28 | ||
|
|
a7cc7084f2 | ||
|
|
2a2ae7da02 | ||
|
|
e8683aa5b1 | ||
|
|
a49d4d7128 | ||
|
|
a3f0bf0af7 | ||
|
|
e844bbe1c7 | ||
|
|
08c93f4aad | ||
|
|
367c32bb08 | ||
|
|
cc9f02dfd2 | ||
|
|
715e86c901 | ||
|
|
8615e0bc8d | ||
|
|
cc24055d6c | ||
|
|
805e5bcae1 | ||
|
|
6c84a6a771 | ||
|
|
f3264c8de5 | ||
|
|
19207282f9 | ||
|
|
65b51b98f4 | ||
|
|
081c8215c1 | ||
|
|
4b6d102415 | ||
|
|
d46f6ceeeb | ||
|
|
607f54260e | ||
|
|
b984b2a698 | ||
|
|
cb33fe26e4 | ||
|
|
a0cb29744d | ||
|
|
c2d2b00c5c | ||
|
|
ea3367ed45 | ||
|
|
559883b007 | ||
|
|
2e761666d5 | ||
|
|
1971aeb3e3 | ||
|
|
b8e512fed6 | ||
|
|
8ffbf5ff6e | ||
|
|
744923f7d3 | ||
|
|
140f4d91cd | ||
|
|
a9edcd6b3e | ||
|
|
6fba95fe5a | ||
|
|
9ce04627dd | ||
|
|
df7677d23f | ||
|
|
2fee0339cb | ||
|
|
051dc7e3df | ||
|
|
11f016a944 | ||
|
|
c78b6021c3 | ||
|
|
f4fa575fa0 | ||
|
|
6c985b8da3 | ||
|
|
a94b9c64aa | ||
|
|
4e167cbcf8 | ||
|
|
5db0d5acc3 | ||
|
|
69cd4021f2 | ||
|
|
a67cffe88d | ||
|
|
2b04f9a79c | ||
|
|
2fad10c8de | ||
|
|
3fc37642cd | ||
|
|
b2ada09b7c | ||
|
|
883c5a7c76 | ||
|
|
7946ef8636 | ||
|
|
6650ae2ca2 | ||
|
|
631c2bc73a | ||
|
|
57923b0a5f | ||
|
|
febdda968e | ||
|
|
6faebff8ba | ||
|
|
e5b52b84a5 | ||
|
|
5c0b6402ed | ||
|
|
e110dd1c0c | ||
|
|
67a24d6a65 | ||
|
|
908e366006 | ||
|
|
2a2a4552f7 | ||
|
|
a2e6138279 | ||
|
|
aa10bd1247 | ||
|
|
5492080526 | ||
|
|
7e11f78eb4 | ||
|
|
2fa82e4506 | ||
|
|
45b3e48779 | ||
|
|
e54f83df8f | ||
|
|
131c39cf74 | ||
|
|
104e0601ff | ||
|
|
bf7fb425eb | ||
|
|
9af49291e9 | ||
|
|
fefcbfdbc4 | ||
|
|
29668d3adb | ||
|
|
83ba98ab42 | ||
|
|
0c25449566 | ||
|
|
10d31ae13c | ||
|
|
e97f958f45 | ||
|
|
3d0c67eb9b | ||
|
|
6f098cd9c2 | ||
|
|
d849a2f794 | ||
|
|
041f1fa2d3 | ||
|
|
cfd9a596c0 | ||
|
|
eaecab16ca | ||
|
|
9a041bed18 | ||
|
|
053f03be49 | ||
|
|
cedfbb2b07 | ||
|
|
7ae812e3f6 | ||
|
|
bc386965da | ||
|
|
7abd04a7f6 | ||
|
|
441733646f | ||
|
|
ccf823590a | ||
|
|
d9775ac144 | ||
|
|
0925c58821 | ||
|
|
2d27f9c475 | ||
|
|
868e46fac9 | ||
|
|
2aa77d1b7b | ||
|
|
a6b9e7ab49 | ||
|
|
a902cc84fe | ||
|
|
157c9ec055 | ||
|
|
415826f0a6 | ||
|
|
69857c4ace | ||
|
|
e6e46a1427 | ||
|
|
e0b2181ae9 | ||
|
|
446fa7b7fd | ||
|
|
ba8b1f29b2 | ||
|
|
b8266c2872 | ||
|
|
5aa74d0513 | ||
|
|
daac47cac4 | ||
|
|
138a541d01 | ||
|
|
833c939220 | ||
|
|
2c1bcacf0a | ||
|
|
f1d0092e57 | ||
|
|
7dfb0e0013 | ||
|
|
775d7b9877 | ||
|
|
c18ebd7f5b | ||
|
|
494d248356 | ||
|
|
3acefecc24 | ||
|
|
19c49c6605 | ||
|
|
d6e0c142c6 | ||
|
|
57729f8e18 | ||
|
|
4fdadad89d | ||
|
|
f4d3455496 | ||
|
|
227174e541 | ||
|
|
2e72b38778 | ||
|
|
0be7aee49d | ||
|
|
6d5f3ffb85 | ||
|
|
d1bc1273d4 | ||
|
|
96fb5a4f19 | ||
|
|
f91c1f33db | ||
|
|
02b840f2d1 | ||
|
|
f992780957 | ||
|
|
c82c1eee98 | ||
|
|
2992443d5d | ||
|
|
259c353147 | ||
|
|
1724ea05d1 | ||
|
|
c1e20a71ae | ||
|
|
bf56956790 | ||
|
|
2f1a3ade07 | ||
|
|
ef8254272c | ||
|
|
d50be13232 | ||
|
|
439b55a236 | ||
|
|
5ab65f7581 | ||
|
|
169bf77de6 | ||
|
|
7c4169867c | ||
|
|
acf544500f | ||
|
|
7d767c8cb0 | ||
|
|
eb3ccfa00b | ||
|
|
eda28c4cd6 | ||
|
|
d69e845216 | ||
|
|
dc962c53b0 | ||
|
|
6ac26f637c | ||
|
|
27d81e956d | ||
|
|
eb39391223 | ||
|
|
b02ba4100d | ||
|
|
3daea6623b | ||
|
|
d42f448e31 | ||
|
|
1566f1bb00 | ||
|
|
0677924a64 | ||
|
|
971d477795 | ||
|
|
f12042f194 | ||
|
|
e7cf336665 | ||
|
|
8ca20de82e | ||
|
|
1fa654cb6a | ||
|
|
c993d9dd0d | ||
|
|
33d2b3ce60 | ||
|
|
c7ce35bd43 | ||
|
|
ad1d71a462 | ||
|
|
33477f284b | ||
|
|
03e38d1ca3 | ||
|
|
bded929812 | ||
|
|
3612458e86 | ||
|
|
8d9fad1749 | ||
|
|
d25ed492c9 | ||
|
|
1f9abefc35 | ||
|
|
b634f41a1c | ||
|
|
0f85f588fb | ||
|
|
e5fc99d66c | ||
|
|
8b74803290 | ||
|
|
540639d2c1 | ||
|
|
562871b1ce | ||
|
|
cca3f8bfcd | ||
|
|
89c08be712 | ||
|
|
b4ecf86c13 | ||
|
|
b14fe78306 | ||
|
|
3f0c1038c3 | ||
|
|
1fbefce6df | ||
|
|
63cb68451a | ||
|
|
17cfebbe26 | ||
|
|
379fb930fc | ||
|
|
1bebdeac0f | ||
|
|
f458591132 | ||
|
|
6155539254 | ||
|
|
76e0f1f3b6 | ||
|
|
ba6ce2cdb6 | ||
|
|
8212049f57 | ||
|
|
5814f47659 | ||
|
|
94f5e892c3 | ||
|
|
a3b6467047 | ||
|
|
66db6497ec | ||
|
|
81be17f09f | ||
|
|
4237fb5e79 | ||
|
|
4302138b4f | ||
|
|
3b9b74dae5 | ||
|
|
4021c1f496 | ||
|
|
5f8de584bc | ||
|
|
38de1b3310 | ||
|
|
abfbccc906 | ||
|
|
9d4e74e094 | ||
|
|
db355b759c | ||
|
|
31d77f01ac | ||
|
|
1b0ed281b2 | ||
|
|
9c6580f5c0 | ||
|
|
83abb0485d | ||
|
|
b35409ca74 | ||
|
|
700d39c425 | ||
|
|
4272c47ee5 | ||
|
|
c7cb043485 | ||
|
|
4dfe79290e | ||
|
|
d3e3df6d24 | ||
|
|
969570e38b | ||
|
|
b73d646db5 | ||
|
|
8c37ff412c | ||
|
|
06bf359535 | ||
|
|
a4f3415f0f | ||
|
|
c9c9ebe6d4 | ||
|
|
100993445b | ||
|
|
a4f80e7ec1 | ||
|
|
4ad34d3a0a | ||
|
|
c9bae926a5 | ||
|
|
cb3f7e8720 | ||
|
|
eb98ebb682 | ||
|
|
dc7b598558 | ||
|
|
69f3a355c7 |
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: archipelago-app
|
||||
description: Build, package, test, and update a manifest-driven Archipelago app using the real app developer contract, rootless runtime, and lifecycle acceptance flow.
|
||||
metadata:
|
||||
short-description: Build a real Archipelago app
|
||||
---
|
||||
|
||||
# Archipelago app development
|
||||
|
||||
Use this skill when creating or changing an Archipelago app, manifest, container
|
||||
build, app integration, credentials, signer flow, or app preview. Always load
|
||||
`archipelago-design` for newly authored UI.
|
||||
|
||||
Read [app-contract.md](references/app-contract.md),
|
||||
`docs/app-developer-guide.md`, and `docs/app-manifest-spec.md` before coding.
|
||||
|
||||
## Required loop
|
||||
|
||||
1. Create a dedicated worktree or project directory and choose the smallest
|
||||
useful app scope. Prefer the maintained starter and its pinned dependencies.
|
||||
2. Implement the app as a manifest, rootless container, persistent data path,
|
||||
truthful health/readiness check, declared interface, and tests. Never add a
|
||||
per-app Rust installer or rootful/Docker-socket shortcut.
|
||||
3. Use generated secrets or declared secret files; never put credentials in a
|
||||
manifest, image, logs, URL, or frontend bundle. Keep production wallets and
|
||||
app databases outside development fixtures.
|
||||
4. Validate the manifest and build context, then run the app through install,
|
||||
start, stop, restart, manager restart, uninstall with data preservation, and
|
||||
reinstall. Verify the real My Apps/Services launch path, not only a direct
|
||||
port.
|
||||
5. For UI, exercise standalone and embedded modes at 320, 390, 768, and 1440px
|
||||
plus phone landscape, keyboard navigation, loading/empty/error/retry/success,
|
||||
safe areas, and reduced motion.
|
||||
6. Report source, disposable-node, actual-node, and release-artifact evidence
|
||||
separately. Catalog publication is a separate request.
|
||||
|
||||
Important runtime facts:
|
||||
|
||||
- `/opt/archipelago/apps` is rebuilt from the runtime payload at backend start;
|
||||
staging only there will be lost. Follow the developer guide's payload path.
|
||||
- Signed catalog entries take precedence over disk manifests for catalog apps.
|
||||
- Installed does not mean ready or launchable; use health and readiness evidence.
|
||||
- App interfaces describe the service behind the gate. Do not hard-code a node
|
||||
IP, scheme, app path, or host frame URL into app code.
|
||||
|
||||
## References
|
||||
|
||||
- [app contract and acceptance](references/app-contract.md)
|
||||
- [design routing](../archipelago-design/SKILL.md)
|
||||
@@ -0,0 +1,7 @@
|
||||
interface:
|
||||
display_name: "Archipelago app"
|
||||
short_description: "Build a real Archipelago app"
|
||||
brand_color: "#F7931A"
|
||||
default_prompt: "Use $archipelago-app to build and validate this Archipelago app."
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -0,0 +1,17 @@
|
||||
# App contract
|
||||
|
||||
Start with `docs/app-developer-guide.md` and `docs/app-manifest-spec.md`; those
|
||||
files are authoritative for fields and launch behavior. Validate with:
|
||||
|
||||
```bash
|
||||
./scripts/validate-app-manifest.sh apps/<id>/manifest.yml
|
||||
python3 scripts/generate-app-catalog.py
|
||||
python3 scripts/check-app-catalog-drift.py --release --strict
|
||||
```
|
||||
|
||||
Use pinned image versions, read-only root, no-new-privileges, minimal
|
||||
capabilities, rootless Podman, declared persistent data under
|
||||
`/var/lib/archipelago/<id>`, health checks, generated/declared secrets, and
|
||||
truthful interfaces. Test install/start/stop/restart/manager-restart/uninstall
|
||||
with data preservation/reinstall on a disposable node. Do not replace the
|
||||
signed catalog to make a local test app appear.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: archipelago-design
|
||||
description: Create or change Archipelago UI using the shared semantic tokens, components, responsive patterns, accessibility states, and embedded/standalone host contract.
|
||||
metadata:
|
||||
short-description: Keep Archipelago UI consistent
|
||||
---
|
||||
|
||||
# Archipelago design system
|
||||
|
||||
Use this skill for any new or changed Archipelago UI, including app screens,
|
||||
Terminal, setup flows, dialogs, dashboards, and app wrappers. Read
|
||||
[design-contract.md](references/design-contract.md) before implementation.
|
||||
|
||||
## Rules
|
||||
|
||||
- Find and reuse the closest shared component and pattern before creating one.
|
||||
- Use semantic `--archy-*` tokens and the pinned kit version. A bespoke color,
|
||||
font, radius, shadow, or z-index needs a documented reason.
|
||||
- Preserve the dark baseline, readable surfaces, 4px spacing rhythm, bottom
|
||||
action placement, 44px touch targets, visible focus, and safe-area behavior.
|
||||
- Keep terminal/editor/tmux keys inside the terminal focus boundary; generic
|
||||
modal Escape/arrow handlers must not consume them.
|
||||
- Implement loading, empty, offline, denied, validation-error, retry, disabled,
|
||||
and confirmed-success states. Status color must have text or icon support.
|
||||
- Test standalone and embedded modes. Embedded apps do not duplicate the host
|
||||
wallpaper or navigation and must work without a host handshake.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the reference screen and source; choose the matching component/pattern.
|
||||
2. Build with the shared kit and local assets, not runtime dashboard CSS or CDN
|
||||
fonts. Keep host RPC, signer, and credential bridges behind typed adapters.
|
||||
3. Render the gallery/preview at required viewports and inspect screenshots and
|
||||
keyboard behavior. Check contrast on the real composited surface.
|
||||
4. Record deliberate exceptions and update the kit only when a reusable need is
|
||||
demonstrated. Do not silently accept visual-diff changes.
|
||||
|
||||
## References
|
||||
|
||||
- [design contract](references/design-contract.md)
|
||||
- [existing component map](references/component-map.md)
|
||||
@@ -0,0 +1,7 @@
|
||||
interface:
|
||||
display_name: "Archipelago design"
|
||||
short_description: "Keep Archipelago UI consistent"
|
||||
brand_color: "#F7931A"
|
||||
default_prompt: "Use $archipelago-design to implement this UI in Archipelago's design system."
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -0,0 +1,15 @@
|
||||
# Existing component map
|
||||
|
||||
Reuse these first:
|
||||
|
||||
- Structure: `BaseModal.vue`, `BackButton.vue`, `EmptyState.vue`,
|
||||
`SkeletonCard.vue`.
|
||||
- Controls: `ToggleSwitch.vue`, `PasswordRevealInput.vue`,
|
||||
`AppSearchField.vue`, `CopyButton.vue`.
|
||||
- Feedback: `ToastStack.vue`, `ContainerStatus.vue`, existing upload/progress
|
||||
components.
|
||||
- Completion: `PaymentSuccessPane.vue`, `IdentitySuccessPane.vue`.
|
||||
- App flows: `AppLauncherOverlay.vue`, `AppCredentialInterstitial.vue`.
|
||||
|
||||
The terminal must have an explicit keyboard ownership boundary and must not be
|
||||
wrapped in the generic arrow-key modal behavior without adapting it.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Design contract
|
||||
|
||||
The current baseline is defined by `neode-ui/src/style.css`,
|
||||
`neode-ui/tailwind.config.js`, `BaseModal.vue`, `AppSearchField.vue`,
|
||||
`EmptyState.vue`, `SkeletonCard.vue`, and `PaymentSuccessPane.vue`. Preserve the
|
||||
dark canvas, glass-card surface, 4px spacing scale, semantic orange accent,
|
||||
local licensed fonts, bottom card actions, 40px desktop/52px mobile search,
|
||||
44px touch targets, visible focus, dynamic viewport, safe-area and embedded
|
||||
canvas rules while the shared kit is extracted.
|
||||
|
||||
Use semantic tokens, not raw values. New controls must document keyboard,
|
||||
focus, disabled, loading, validation, error, retry, success, responsive, and
|
||||
reduced-motion behavior. No essential action may be hover-only. Use the existing
|
||||
dialog footer/content split and restore focus after close.
|
||||
|
||||
Visual acceptance covers 320/390/768/1440px, phone landscape, enlarged text,
|
||||
standalone/embedded modes, dark native controls, no-blur fallback, and actual
|
||||
Companion WebView behavior where applicable.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: archipelago
|
||||
description: Configure, troubleshoot, and safely modify an Archipelago node or its developer environment using the repository's typed operations, ownership rules, and recovery workflow.
|
||||
metadata:
|
||||
short-description: Work safely on Archipelago systems
|
||||
---
|
||||
|
||||
# Archipelago system work
|
||||
|
||||
Use this skill for node configuration, terminal/developer setup, service
|
||||
diagnosis, network and SSH work, supported app operations, and system changes.
|
||||
For app implementation load `archipelago-app`; for any new or changed UI also
|
||||
load `archipelago-design`.
|
||||
|
||||
Read [system-map.md](references/system-map.md) before acting. Read `AGENTS.md`
|
||||
and the relevant project documentation in the current checkout.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the node, repository/worktree, owner, and whether the request is
|
||||
planning, source work, a disposable test, or a live-node operation.
|
||||
2. Inspect current state before changing it. Prefer `archy`/Archipelago RPC
|
||||
operations and existing scripts over direct edits or ad-hoc service commands.
|
||||
3. Preserve wallets, app data, credentials, user uninstall decisions, and
|
||||
unrelated services. Back up only the scoped state before a mutation.
|
||||
4. Make the smallest reversible change. Keep generated state separate from
|
||||
user overrides; never edit generated or packaged files when an owned source
|
||||
or managed override exists.
|
||||
5. Verify the actual result and report source tests, disposable integration,
|
||||
live-node acceptance, and packaged-artifact checks separately.
|
||||
|
||||
For repository work, use a dedicated worktree and focused branch. For backend
|
||||
unit tests use `scripts/test-backend-isolated.sh`; do not run unrestricted
|
||||
`cargo test` on a node with installed apps. Do not publish OTA, ISO, catalog,
|
||||
or Git mirrors from this skill unless that publication is explicitly requested
|
||||
and every release gate is satisfied.
|
||||
|
||||
## Terminal and sessions
|
||||
|
||||
Treat terminal close as detach. Resume the existing named session; never create
|
||||
a duplicate shell or replay a lost command. A reboot can restore workspace and
|
||||
Codex conversation metadata but cannot restore the old process. Distinguish
|
||||
running, detached, ended, and interrupted states in user-facing output.
|
||||
|
||||
Keep credentials, wallet material, terminal output, and command contents out of
|
||||
diagnostics and support bundles. A terminal is a privileged capability: verify
|
||||
the authenticated owner, origin, attachment grant, and account boundary before
|
||||
any PTY bytes flow.
|
||||
|
||||
## References
|
||||
|
||||
- [system map and safe recipes](references/system-map.md)
|
||||
- [app and design routing](references/routing.md)
|
||||
@@ -0,0 +1,7 @@
|
||||
interface:
|
||||
display_name: "Archipelago system"
|
||||
short_description: "Work safely on Archipelago systems"
|
||||
brand_color: "#F7931A"
|
||||
default_prompt: "Use $archipelago to inspect and safely change this Archipelago node."
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -0,0 +1,10 @@
|
||||
# Skill routing
|
||||
|
||||
Load `archipelago-app` for manifests, containers, app previews, lifecycle,
|
||||
credentials, signer integration, or app packaging. Load `archipelago-design` for
|
||||
any newly authored or changed UI. For backend-only work, use only the system
|
||||
skill and the relevant project docs.
|
||||
|
||||
Planning stays planning. A source change, node mutation, deployment, or
|
||||
publication requires that explicit scope from the user. A skill supplies
|
||||
workflow knowledge; it does not grant additional privileges.
|
||||
@@ -0,0 +1,19 @@
|
||||
# System map and safe recipes
|
||||
|
||||
The native backend is `core/archipelago`; the Vue dashboard is `neode-ui`; ISO
|
||||
and first-boot material lives under `image-recipe` and `scripts`; app manifests
|
||||
are under `apps/<id>/manifest.yml`. Read `CLAUDE.md`, `AGENTS.md`, and the current
|
||||
release checklist before release work.
|
||||
|
||||
Use the existing typed RPC and orchestration layers for app lifecycle, network,
|
||||
wallet, identity, and service operations. Inspect a matching handler before
|
||||
adding an endpoint. Preserve the existing session cookie, CSRF, Origin, and
|
||||
app-gate protections.
|
||||
|
||||
For source tests, run frontend checks from `neode-ui`. Backend unit tests run
|
||||
only through `scripts/test-backend-isolated.sh`; live host checks must be named
|
||||
and explicit. Do not touch real wallet/payment/channel state for a test fixture.
|
||||
|
||||
Developer changes belong in a dedicated worktree. Keep generated catalog,
|
||||
runtime payload, release artifacts, and local node state separate until the
|
||||
request explicitly includes packaging or deployment.
|
||||
+7
-1
@@ -4,7 +4,13 @@
|
||||
# Allow neode-ui (frontend + mock backend + docker configs)
|
||||
!neode-ui/
|
||||
|
||||
# Allow demo assets (AIUI pre-built dist)
|
||||
!aiui/
|
||||
aiui/**/node_modules
|
||||
aiui/**/dist
|
||||
aiui/**/.turbo
|
||||
aiui/**/.build-aiui-last-*
|
||||
|
||||
# Allow curated demo assets
|
||||
!demo/
|
||||
|
||||
# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*)
|
||||
|
||||
@@ -17,6 +17,9 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'neode-ui/**'
|
||||
- 'aiui/**'
|
||||
- 'scripts/build-aiui.sh'
|
||||
- '.dockerignore'
|
||||
- 'docker-compose.demo.yml'
|
||||
- '.gitea/workflows/demo-images.yml'
|
||||
workflow_dispatch:
|
||||
@@ -65,10 +68,12 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
VITE_DEMO=1
|
||||
SOURCE_REVISION=${{ github.sha }}
|
||||
tags: |
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
||||
|
||||
- name: Trigger Portainer redeploy
|
||||
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
|
||||
# Source pushes prepare images; public deployment is an explicit post-release action.
|
||||
if: ${{ success() && github.event_name == 'workflow_dispatch' && secrets.PORTAINER_WEBHOOK != '' }}
|
||||
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
|
||||
|
||||
@@ -17,6 +17,9 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'neode-ui/**'
|
||||
- 'aiui/**'
|
||||
- 'scripts/build-aiui.sh'
|
||||
- '.dockerignore'
|
||||
- 'docker-compose.demo.yml'
|
||||
- '.github/workflows/demo-images.yml'
|
||||
workflow_dispatch:
|
||||
@@ -65,10 +68,12 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
VITE_DEMO=1
|
||||
SOURCE_REVISION=${{ github.sha }}
|
||||
tags: |
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo
|
||||
${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }}
|
||||
|
||||
- name: Trigger Portainer redeploy
|
||||
if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }}
|
||||
# Source pushes prepare images; public deployment is an explicit post-release action.
|
||||
if: ${{ success() && github.event_name == 'workflow_dispatch' && secrets.PORTAINER_WEBHOOK != '' }}
|
||||
run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}"
|
||||
|
||||
@@ -162,3 +162,6 @@ uploads/
|
||||
|
||||
# Generated PWA dev output (vite-plugin-pwa) — never a source artifact
|
||||
neode-ui/dev-dist/
|
||||
|
||||
# Isolated feature worktree compilation and validation artifacts
|
||||
.build/
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
## Unit tests on a live node
|
||||
|
||||
Run backend unit tests through `scripts/test-backend-isolated.sh`. Do not run
|
||||
unrestricted `cargo test` on a node with installed apps: older mocked-runtime
|
||||
tests still reached real service commands. The runner isolates wallet data,
|
||||
service buses, container storage, networking, and process IDs. Compilation with
|
||||
`cargo test --no-run` is safe. Keep separately authorized live checks explicit.
|
||||
|
||||
## Active release regression checklist
|
||||
|
||||
Before resuming release work, read
|
||||
`docs/post-1.8.22-regressions-20261001.md` and retain its unfinished tasks.
|
||||
The operator requested that every reported issue be tracked, fixed and tested
|
||||
before another OTA/ISO. Keep source/unit-test results separate from actual-node
|
||||
acceptance. In particular, paid-file recovery must not send another payment,
|
||||
and app cleanup must preserve wallets, persistent data and uninstall decisions.
|
||||
Do not mark the new paid-file incident resolved merely because the earlier
|
||||
Framework LND startup incident was closed.
|
||||
|
||||
## Gitea and ngit mirror parity
|
||||
|
||||
Nostr Git (`ngit`) is the canonical contribution and review platform. Gitea
|
||||
(`origin`) mirrors accepted code on `main` and release tags. Both are required
|
||||
publication mirrors; duplicate PRs and proposal branches on Gitea are not required.
|
||||
For every change, including fixes and release preparation:
|
||||
|
||||
- Review and merge once. Push the exact same resulting commits to both mirrors;
|
||||
never independently squash, rebase or merge the same change on each platform.
|
||||
- Open new contributions and PRs on ngit; review and merge there, then mirror the
|
||||
exact accepted main commits to Gitea. Record the ngit proposal and resulting
|
||||
merge commit in the release ledger. Existing Gitea PRs must be reviewed and
|
||||
explicitly linked to their ngit replacement or accepted result before closing;
|
||||
do not abandon contributions or mark unmerged changes as merged. PR numbers,
|
||||
reviews and discussions remain platform-specific; matching Git refs does not
|
||||
prove their synchronization.
|
||||
- Push main and release tags to both mirrors. Preserve commit history
|
||||
and annotated tag objects/signatures. Do not resolve drift by force pushing,
|
||||
deleting remote refs, or rewriting published history without explicit approval.
|
||||
- After publishing source, run `python3 scripts/check-git-mirrors.py --local`.
|
||||
Include each additional shared branch or release tag with repeated `--ref`
|
||||
arguments (full `refs/heads/...` or `refs/tags/...` names).
|
||||
- Before OTA, catalog or ISO publication, require matching reviewed local and
|
||||
remote main and release tag refs, and record ngit PR dispositions in the
|
||||
release acceptance ledger. A failed push, unavailable mirror, missing ref or
|
||||
mismatch blocks publication; never describe a partial push as synchronized.
|
||||
Run `--all` for a complete advertised branch/tag audit; a main-only pass must
|
||||
never be described as full historical mirror parity. Proposal-only branches
|
||||
may intentionally differ. Existing unrelated drift
|
||||
must be inventoried explicitly rather than silently overwritten.
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 52
|
||||
versionName = "0.5.32"
|
||||
versionCode = 57
|
||||
versionName = "0.5.37"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
@@ -142,6 +142,9 @@ tasks.matching {
|
||||
}.configureEach { dependsOn("buildRustArm64") }
|
||||
|
||||
dependencies {
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||
testImplementation("org.robolectric:robolectric:4.14.1")
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
|
||||
implementation(composeBom)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<!-- Embedded FIPS mesh tunnel (ArchyVpnService) runs as a foreground service. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
@@ -37,11 +38,13 @@
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:resizeableActivity="true"
|
||||
android:theme="@style/Theme.Archipelago.Splash"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -65,6 +68,12 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".ui.screens.CompanionAudioService"
|
||||
android:exported="false"
|
||||
android:stopWithTask="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<!-- Embedded FIPS mesh node: split-tunnel VpnService (fd00::/8 only),
|
||||
configured entirely by scanning the node's pairing QR. -->
|
||||
<service
|
||||
|
||||
@@ -9,11 +9,18 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.archipelago.app.ui.navigation.AppNavHost
|
||||
import com.archipelago.app.ui.screens.releaseKioskWebView
|
||||
import com.archipelago.app.ui.screens.finishKioskActivity
|
||||
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
internal var cloudVideoPip: com.archipelago.app.ui.screens.CloudVideoPip? = null
|
||||
override fun onPictureInPictureModeChanged(active: Boolean, config: android.content.res.Configuration) {
|
||||
super.onPictureInPictureModeChanged(active, config)
|
||||
cloudVideoPip?.modeChanged(active)
|
||||
}
|
||||
override fun onStop() { cloudVideoPip?.stopped(); super.onStop() }
|
||||
|
||||
|
||||
// Pairing deep link (archipelago://pair?...) from the launch intent or a
|
||||
// later one (launchMode=singleTask). Consumed by AppNavHost.
|
||||
@@ -49,11 +56,8 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// Swiped out of recents (or otherwise finished) — let go of the
|
||||
// retained kiosk WebView so the next launch starts clean. Without
|
||||
// this the FIPS service keeps the process (and the static WebView)
|
||||
// alive, and "close the app" no longer restarted it. isFinishing
|
||||
// keeps config changes (rotation) on the fast reattach path.
|
||||
if (isFinishing) releaseKioskWebView()
|
||||
// Keep an authorized playing WebView owned by the media service;
|
||||
// discard ordinary dashboard state when the task is finished.
|
||||
if (isFinishing) finishKioskActivity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.ui.screens.restartCompanionApp
|
||||
import com.archipelago.app.ui.screens.CompanionAudioDiagnostics
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceDark
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
@@ -252,6 +253,7 @@ private fun MenuPanel(
|
||||
HubPage.FIPS -> "FIPS Mesh"
|
||||
HubPage.BACKUP -> "Backup & Restore"
|
||||
HubPage.SIGNER -> "Remote Signer"
|
||||
HubPage.AUDIO -> "Playback diagnostics"
|
||||
HubPage.HUB -> "Menu"
|
||||
},
|
||||
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
|
||||
@@ -307,6 +309,7 @@ private fun MenuPanel(
|
||||
onDismiss()
|
||||
restartCompanionApp(hubContext)
|
||||
}
|
||||
HubCard(Icons.Default.Dashboard, "Playback diagnostics", "Local background audio status") { page = HubPage.AUDIO }
|
||||
val versionLabel = remember {
|
||||
runCatching {
|
||||
hubContext.packageManager
|
||||
@@ -451,6 +454,17 @@ private fun MenuPanel(
|
||||
}
|
||||
}
|
||||
|
||||
HubPage.AUDIO -> {
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var report by remember { mutableStateOf(CompanionAudioDiagnostics.report(context)) }
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
Text("Local status only. No track names, addresses, credentials, or automatic uploads.", color = TextMuted, fontSize = 12.sp)
|
||||
Text(report, color = TextPrimary, fontSize = 12.sp)
|
||||
MenuItem(label = "Refresh", onClick = { report = CompanionAudioDiagnostics.report(context); copied = false })
|
||||
MenuItem(label = if (copied) "Copied" else "Copy report", onClick = { clipboard.setText(AnnotatedString(report)); copied = true })
|
||||
}
|
||||
|
||||
HubPage.FIPS -> {
|
||||
FipsSection(embedded = true)
|
||||
}
|
||||
@@ -470,7 +484,7 @@ private fun MenuPanel(
|
||||
}
|
||||
}
|
||||
|
||||
private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER }
|
||||
private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER, AUDIO }
|
||||
|
||||
/** Big tappable destination card for the hub page: icon + title + subtitle. */
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.util.Rational
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.webkit.JavaScriptReplyProxy
|
||||
import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import com.archipelago.app.MainActivity
|
||||
import org.json.JSONObject
|
||||
import java.net.URI
|
||||
import java.util.UUID
|
||||
|
||||
internal fun cloudVideoOrigin(value: String?): String? = runCatching {
|
||||
val uri = URI(value ?: return null)
|
||||
val scheme = uri.scheme?.lowercase() ?: return null
|
||||
if (scheme !in setOf("http", "https") || uri.userInfo != null) return null
|
||||
val host = uri.host?.lowercase() ?: return null
|
||||
val port = uri.port.takeUnless { it == -1 || it == if (scheme == "https") 443 else 80 }
|
||||
"$scheme://$host${port?.let { ":$it" } ?: ""}"
|
||||
}.getOrNull()
|
||||
|
||||
internal fun cloudVideoSenderAllowed(currentUrl: String?, source: String, allowed: Set<String>, mainFrame: Boolean): Boolean {
|
||||
val origin = cloudVideoOrigin(source)
|
||||
return mainFrame && origin != null && origin in allowed && cloudVideoOrigin(currentUrl) == origin
|
||||
}
|
||||
|
||||
/** Only the dashboard's origin-restricted main-frame channel can arm Cloud PiP.
|
||||
* No URL, cookies, bearer token or second media player enters native storage. */
|
||||
internal class CloudVideoPip(private val activity: MainActivity?, private val fullscreen: WebViewFullscreen) {
|
||||
private class Binding(val origins: Set<String>, var owner: java.lang.ref.WeakReference<CloudVideoPip>)
|
||||
companion object {
|
||||
private val bindings = java.util.WeakHashMap<WebView, Binding>()
|
||||
}
|
||||
private var webView: WebView? = null
|
||||
private var session: String? = null
|
||||
private var reply: JavaScriptReplyProxy? = null
|
||||
private var playing = false
|
||||
private var ratio = Rational(16, 9)
|
||||
private var entered = false
|
||||
private var registered = false
|
||||
private val action = "com.archipelago.app.CLOUD_VIDEO_PIP.${UUID.randomUUID()}"
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (!entered || intent?.action != action || intent.getStringExtra("session") != session) return
|
||||
event("command", if (playing) "pause" else "play")
|
||||
}
|
||||
}
|
||||
private fun supported() = activity?.packageManager?.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) == true
|
||||
private fun event(state: String, command: String? = null) {
|
||||
val message = JSONObject().put("type", "event").put("session", session).put("state", state)
|
||||
if (command != null) message.put("command", command)
|
||||
runCatching { reply?.postMessage(message.toString()) }
|
||||
}
|
||||
private fun params(): PictureInPictureParams {
|
||||
val owner = requireNotNull(activity)
|
||||
val intent = Intent(action).setPackage(owner.packageName).putExtra("session", session)
|
||||
val pending = PendingIntent.getBroadcast(owner, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
val control = RemoteAction(Icon.createWithResource(owner, if (playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play),
|
||||
if (playing) "Pause" else "Play", if (playing) "Pause video" else "Play video", pending)
|
||||
val bounds = Rect()
|
||||
val builder = PictureInPictureParams.Builder().setAspectRatio(ratio).setActions(listOf(control))
|
||||
if (fullscreen.bounds(bounds)) builder.setSourceRectHint(bounds)
|
||||
return builder.build()
|
||||
}
|
||||
fun attach(view: WebView, allowedUrls: List<String>) {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return
|
||||
val origins = allowedUrls.mapNotNull(::cloudVideoOrigin).toSet()
|
||||
if (origins.isEmpty()) return
|
||||
webView = view
|
||||
val existing = bindings[view]
|
||||
if (existing != null) {
|
||||
// Rebind the retained document; removing/re-adding a listener would
|
||||
// require a reload and strand the page's existing JS bridge.
|
||||
if (existing.origins != origins) {
|
||||
existing.owner.clear(); webView = null
|
||||
return // The page receives a bounded unavailable response; reconnect reloads policy.
|
||||
}
|
||||
existing.owner = java.lang.ref.WeakReference(this)
|
||||
return
|
||||
}
|
||||
val binding = Binding(origins, java.lang.ref.WeakReference(this))
|
||||
bindings[view] = binding
|
||||
WebViewCompat.addWebMessageListener(view, "ArchipelagoCloudVideo", origins,
|
||||
object : WebViewCompat.WebMessageListener {
|
||||
override fun onPostMessage(web: WebView, message: WebMessageCompat, sourceOrigin: Uri, isMainFrame: Boolean, proxy: JavaScriptReplyProxy) {
|
||||
binding.owner.get()?.receive(web, message, sourceOrigin, isMainFrame, proxy, binding.origins)
|
||||
}
|
||||
})
|
||||
}
|
||||
private fun receive(web: WebView, message: WebMessageCompat, sourceOrigin: Uri, isMainFrame: Boolean, proxy: JavaScriptReplyProxy, origins: Set<String>) {
|
||||
if (web !== webView || !cloudVideoSenderAllowed(web.url, sourceOrigin.toString(), origins, isMainFrame)) return
|
||||
val raw = runCatching { message.data }.getOrNull() ?: return
|
||||
if (raw.length > 2048) return
|
||||
val request = runCatching { JSONObject(raw) }.getOrNull() ?: return
|
||||
val id = request.optString("id")
|
||||
if (!id.matches(Regex("[0-9a-f-]{36}"))) return
|
||||
val response = JSONObject().put("id", id)
|
||||
runCatching {
|
||||
when (request.optString("action")) {
|
||||
"capabilities" -> response.put("supported", supported()).put("version", 1)
|
||||
"arm" -> {
|
||||
check(supported()) { "Picture-in-picture is unavailable on this device." }
|
||||
check(!entered) { "A video is already in picture-in-picture." }
|
||||
val width = request.optInt("width", 0); val height = request.optInt("height", 0)
|
||||
check(width in 1..16384 && height in 1..16384) { "Video dimensions are not ready." }
|
||||
ratio = Rational(((width.toDouble() / height).coerceIn(1.0 / 2.39, 2.39) * 10000).toInt(), 10000)
|
||||
session = id; reply = proxy; playing = request.optBoolean("playing", false)
|
||||
response.put("session", id)
|
||||
}
|
||||
"enter" -> {
|
||||
check(request.optString("session") == session && session != null && fullscreen.isActive) { "Open the selected Cloud video fullscreen first." }
|
||||
val owner = requireNotNull(activity)
|
||||
if (!registered) {
|
||||
ContextCompat.registerReceiver(owner, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
registered = true
|
||||
}
|
||||
check(owner.enterPictureInPictureMode(params())) { "Picture-in-picture is disabled or unavailable. Check this app's system setting." }
|
||||
entered = true
|
||||
response.put("active", true)
|
||||
}
|
||||
"state" -> {
|
||||
check(request.optString("session") == session && session != null) { "Video session changed." }
|
||||
playing = request.optBoolean("playing", false)
|
||||
if (entered) activity?.setPictureInPictureParams(params())
|
||||
}
|
||||
"release" -> {
|
||||
check(request.optString("session") == session && session != null) { "Video session changed." }
|
||||
reset()
|
||||
}
|
||||
else -> error("Unsupported Cloud video action.")
|
||||
}
|
||||
Unit
|
||||
}.onFailure { response.put("error", it.message ?: "Picture-in-picture is unavailable.") }
|
||||
proxy.postMessage(response.toString())
|
||||
}
|
||||
fun modeChanged(active: Boolean) {
|
||||
if (active) { entered = true; event("entered") }
|
||||
else if (entered) {
|
||||
entered = false
|
||||
event("restored")
|
||||
// Restoring the viewer is not a stop request. Retire the native
|
||||
// session before Chromium's hide callback can recursively reset it.
|
||||
session = null; reply = null
|
||||
fullscreen.hide()
|
||||
}
|
||||
}
|
||||
fun stopped() { if (entered) { event("command", "pause"); event("closed") } }
|
||||
fun reset() {
|
||||
event("command", "pause")
|
||||
event("closed")
|
||||
session = null; reply = null
|
||||
fullscreen.hide()
|
||||
}
|
||||
fun dispose() {
|
||||
reset()
|
||||
if (registered) runCatching { activity?.unregisterReceiver(receiver) }
|
||||
registered = false
|
||||
webView?.let { view -> bindings[view]?.takeIf { it.owner.get() === this }?.owner?.clear() }
|
||||
webView = null
|
||||
}
|
||||
}
|
||||
private fun Context.pipActivity(): MainActivity? = when(this) {
|
||||
is MainActivity -> this
|
||||
is ContextWrapper -> baseContext.takeIf { it !== this }?.pipActivity()
|
||||
else -> null
|
||||
}
|
||||
@Composable
|
||||
internal fun rememberCloudVideoPip(fullscreen: WebViewFullscreen): CloudVideoPip {
|
||||
val owner = LocalContext.current.pipActivity()
|
||||
val pip = remember(owner, fullscreen) { CloudVideoPip(owner, fullscreen) }
|
||||
DisposableEffect(pip) {
|
||||
owner?.cloudVideoPip = pip
|
||||
onDispose { if (owner != null && owner.cloudVideoPip === pip) owner.cloudVideoPip = null; pip.dispose() }
|
||||
}
|
||||
return pip
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.SystemClock
|
||||
import android.webkit.WebView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.webkit.JavaScriptReplyProxy
|
||||
import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import org.json.JSONObject
|
||||
import com.archipelago.app.ui.screens.CompanionAudioDiagnostics.Event
|
||||
|
||||
/** Metadata/control only: the authorized WebView owns the stream and queue. */
|
||||
internal data class CompanionAudioState(
|
||||
val session: String, val sequence: Long, val title: String,
|
||||
val playing: Boolean, val position: Double, val duration: Double,
|
||||
val previous: Boolean, val next: Boolean, val shuffle: Boolean,
|
||||
val shuffled: Boolean, val artwork: String,
|
||||
) {
|
||||
companion object {
|
||||
fun parse(value: JSONObject): CompanionAudioState {
|
||||
require(value.optInt("version") == 1 && value.getString("action") == "state")
|
||||
val session = value.getString("session")
|
||||
require(session.matches(Regex("[0-9a-f-]{36}")))
|
||||
val sequence = value.getLong("sequence")
|
||||
require(sequence >= 0 && sequence <= 9007199254740991L)
|
||||
val position = value.getDouble("position"); val duration = value.getDouble("duration")
|
||||
require(position.isFinite() && duration.isFinite() && duration in 0.0..604800.0 && position in 0.0..duration)
|
||||
val title = value.getString("title"); require(title.length <= 512)
|
||||
val artwork = value.optString("artwork", "")
|
||||
require(artwork.length <= 90000 && (artwork.isEmpty() || artwork.startsWith("data:image/jpeg;base64,")))
|
||||
return CompanionAudioState(session, sequence, title, value.getBoolean("playing"), position, duration,
|
||||
value.optBoolean("previous"), value.optBoolean("next"), value.optBoolean("shuffle"),
|
||||
value.optBoolean("shuffled"), artwork)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal object CompanionAudioBridge {
|
||||
private val bindings = java.util.WeakHashMap<WebView, Set<String>>()
|
||||
private val retired = linkedSetOf<String>()
|
||||
private var view: WebView? = null
|
||||
private var origin: String? = null
|
||||
private var reply: ((String) -> Unit)? = null
|
||||
var state: CompanionAudioState? = null
|
||||
private set
|
||||
var updatedAt: Long = 0
|
||||
private set
|
||||
fun retains(web: WebView?) = web != null && view === web && state != null
|
||||
fun attach(web: WebView, urls: List<String>) {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { CompanionAudioDiagnostics.record(Event.BRIDGE_UNSUPPORTED); return }
|
||||
val origins = urls.mapNotNull(::cloudVideoOrigin).toSet()
|
||||
if (origins.isEmpty()) { CompanionAudioDiagnostics.record(Event.NO_APPROVED_ORIGIN); return }
|
||||
val existing = bindings[web]
|
||||
if (existing != null) {
|
||||
if (existing != origins) { CompanionAudioDiagnostics.record(Event.ORIGIN_CHANGED); bindings[web] = emptySet(); release(web) }
|
||||
return
|
||||
}
|
||||
bindings[web] = origins
|
||||
WebViewCompat.addWebMessageListener(web, "ArchipelagoAudio", origins,
|
||||
object : WebViewCompat.WebMessageListener {
|
||||
override fun onPostMessage(web: WebView, message: WebMessageCompat, source: Uri, main: Boolean, proxy: JavaScriptReplyProxy) {
|
||||
if (bindings[web] != origins) return
|
||||
val raw = runCatching { message.data }.getOrNull() ?: return
|
||||
receive(web, raw, source.toString(), main, origins) { proxy.postMessage(it) }
|
||||
}
|
||||
})
|
||||
CompanionAudioDiagnostics.record(Event.BRIDGE_ATTACHED)
|
||||
}
|
||||
internal fun receive(web: WebView, raw: String, source: String, main: Boolean,
|
||||
origins: Set<String>, proxy: (String) -> Unit) {
|
||||
CompanionAudioDiagnostics.record(Event.MESSAGE_RECEIVED)
|
||||
if (!cloudVideoSenderAllowed(web.url, source, origins, main)) { CompanionAudioDiagnostics.record(Event.SENDER_REJECTED); return }
|
||||
if (raw.length > 96000) { CompanionAudioDiagnostics.record(Event.MESSAGE_TOO_LARGE); return }
|
||||
val data = runCatching { JSONObject(raw) }.getOrNull() ?: run { CompanionAudioDiagnostics.record(Event.INVALID_JSON); return }
|
||||
val session = data.optString("session")
|
||||
if (data.optString("action") == "release") {
|
||||
if (web === view && session == state?.session) { CompanionAudioDiagnostics.record(Event.SESSION_RELEASED); terminate() }
|
||||
return
|
||||
}
|
||||
val incoming = runCatching { CompanionAudioState.parse(data) }.getOrNull() ?: run { CompanionAudioDiagnostics.record(Event.INVALID_STATE); return }
|
||||
if (session in retired) { CompanionAudioDiagnostics.record(Event.RETIRED_SESSION); return }
|
||||
val old = state
|
||||
if (old != null && old.session == session) {
|
||||
if (view !== web || incoming.sequence <= old.sequence) { CompanionAudioDiagnostics.record(Event.STALE_STATE); return }
|
||||
} else {
|
||||
if (!incoming.playing) { CompanionAudioDiagnostics.record(Event.IDLE_STATE); return } // Do not start a service for idle metadata.
|
||||
if (old != null) { command("pause"); retire(old.session) }
|
||||
}
|
||||
CompanionAudioDiagnostics.record(Event.STATE_ACCEPTED)
|
||||
view = web; origin = cloudVideoOrigin(source); reply = proxy
|
||||
state = if (old != null && old.session == session && !data.has("artwork")) incoming.copy(artwork = old.artwork) else incoming; updatedAt = SystemClock.elapsedRealtime()
|
||||
runCatching {
|
||||
val service = CompanionAudioService.instance
|
||||
if (service != null) service.refresh()
|
||||
else {
|
||||
CompanionAudioDiagnostics.record(Event.SERVICE_REQUESTED)
|
||||
ContextCompat.startForegroundService(web.context.applicationContext,
|
||||
Intent(web.context.applicationContext, CompanionAudioService::class.java))
|
||||
}
|
||||
}.onFailure {
|
||||
CompanionAudioDiagnostics.record(when {
|
||||
it is SecurityException -> Event.SERVICE_PERMISSION_DENIED
|
||||
it.javaClass.simpleName == "ForegroundServiceStartNotAllowedException" -> Event.SERVICE_BACKGROUND_START_DENIED
|
||||
else -> Event.SERVICE_REQUEST_FAILED
|
||||
})
|
||||
event("error", "Background playback could not start. Reopen the companion and press Play.")
|
||||
command("pause"); terminate()
|
||||
}
|
||||
}
|
||||
private fun retire(session: String) {
|
||||
retired.add(session)
|
||||
while (retired.size > 64) retired.remove(retired.first())
|
||||
}
|
||||
private fun event(type: String, value: String? = null, position: Double? = null) {
|
||||
val current = state ?: return
|
||||
if (cloudVideoOrigin(view?.url) != origin) { terminate(); return }
|
||||
val message = JSONObject().put("version", 1).put("session", current.session).put("type", type)
|
||||
if (value != null) message.put(if (type == "error") "error" else "command", value)
|
||||
if (position != null) message.put("position", position)
|
||||
runCatching { reply?.invoke(message.toString()) }
|
||||
}
|
||||
fun command(name: String, position: Double? = null) = event("command", name, position)
|
||||
fun release(web: WebView) { if (view === web) { CompanionAudioDiagnostics.record(Event.PAGE_RELEASED); command("stop"); terminate() } }
|
||||
fun terminate() {
|
||||
state?.session?.let(::retire)
|
||||
state = null; view = null; origin = null; reply = null
|
||||
CompanionAudioService.instance?.finishPlayback()
|
||||
releaseDetachedKioskWebView()
|
||||
}
|
||||
fun stop() { command("stop"); terminate() }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.webkit.WebView
|
||||
|
||||
/** Local, memory-only allowlisted status. Never accepts URLs, titles, IDs, or exception text. */
|
||||
internal object CompanionAudioDiagnostics {
|
||||
enum class Event {
|
||||
BRIDGE_ATTACHED, BRIDGE_UNSUPPORTED, NO_APPROVED_ORIGIN, ORIGIN_CHANGED,
|
||||
MESSAGE_RECEIVED, SENDER_REJECTED, MESSAGE_TOO_LARGE, INVALID_JSON, INVALID_STATE,
|
||||
RETIRED_SESSION, STALE_STATE, IDLE_STATE, STATE_ACCEPTED, SERVICE_REQUESTED,
|
||||
SERVICE_REQUEST_FAILED, SERVICE_PERMISSION_DENIED, SERVICE_BACKGROUND_START_DENIED, SERVICE_CREATED, SERVICE_STARTED, FOREGROUND_ACTIVE,
|
||||
SERVICE_FINISHED, SERVICE_DESTROYED, PAGE_RELEASED, SESSION_RELEASED, HEARTBEAT_EXPIRED,
|
||||
}
|
||||
private val counts = linkedMapOf<Event, Long>()
|
||||
private val recent = ArrayDeque<Pair<Long, Event>>()
|
||||
@Synchronized fun record(event: Event) {
|
||||
counts[event] = (counts[event] ?: 0) + 1
|
||||
// Position updates must not displace the useful startup/failure sequence.
|
||||
if (recent.lastOrNull()?.second != event && event !in setOf(Event.MESSAGE_RECEIVED, Event.STATE_ACCEPTED, Event.FOREGROUND_ACTIVE)) {
|
||||
recent.addLast(SystemClock.elapsedRealtime() to event)
|
||||
while (recent.size > 12) recent.removeFirst()
|
||||
}
|
||||
}
|
||||
@Synchronized internal fun events(): String = buildString {
|
||||
counts.forEach { (event, count) -> append("${event.name}: $count\n") }
|
||||
append("Recent transitions (seconds since boot):\n")
|
||||
recent.forEach { (at, event) -> append("${at / 1000}: ${event.name}\n") }
|
||||
}
|
||||
fun report(context: Context): String = buildString {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
append("Companion playback diagnostics v1\n")
|
||||
val app = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
append("App: ${app.versionName}\n")
|
||||
append("Android API: ${Build.VERSION.SDK_INT}\n")
|
||||
append("WebView: ${WebView.getCurrentWebViewPackage()?.versionName ?: "unavailable"}\n")
|
||||
append("Notifications enabled: ${manager.areNotificationsEnabled()}\n")
|
||||
append("Audio channel importance: ${manager.getNotificationChannel("companion-audio")?.importance ?: "not created"}\n")
|
||||
append("Native session: ${CompanionAudioBridge.state != null}\n")
|
||||
append("Native playing: ${CompanionAudioBridge.state?.playing ?: false}\n")
|
||||
append("Service present: ${CompanionAudioService.instance != null}\n")
|
||||
append(events())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaMetadata
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Base64
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.archipelago.app.MainActivity
|
||||
import com.archipelago.app.ui.screens.CompanionAudioDiagnostics.Event
|
||||
|
||||
/** Foreground ownership of the existing authenticated WebView player. No stream
|
||||
* URL, auth token or cookie is copied into native playback or notifications. */
|
||||
class CompanionAudioService : Service() {
|
||||
companion object {
|
||||
internal var instance: CompanionAudioService? = null
|
||||
private const val CHANNEL = "companion-audio"
|
||||
private const val NOTIFICATION = 4056
|
||||
}
|
||||
private lateinit var media: MediaSession
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var lastArtwork = ""
|
||||
private var bitmap: Bitmap? = null
|
||||
private var finishing = false
|
||||
private val noisy = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) CompanionAudioBridge.command("pause")
|
||||
}
|
||||
}
|
||||
private val watchdog = object : Runnable {
|
||||
override fun run() {
|
||||
val state = CompanionAudioBridge.state ?: return
|
||||
val age = SystemClock.elapsedRealtime() - CompanionAudioBridge.updatedAt
|
||||
if (age > 90000) { CompanionAudioDiagnostics.record(Event.HEARTBEAT_EXPIRED); CompanionAudioBridge.stop() }
|
||||
else {
|
||||
if (age > 15000) CompanionAudioBridge.command("sync")
|
||||
handler.postDelayed(this, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onCreate() {
|
||||
super.onCreate(); instance = this
|
||||
CompanionAudioDiagnostics.record(Event.SERVICE_CREATED)
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, "Audio playback", NotificationManager.IMPORTANCE_LOW))
|
||||
media = MediaSession(this, "Archipelago audio")
|
||||
media.setCallback(object : MediaSession.Callback() {
|
||||
override fun onPlay() = CompanionAudioBridge.command("play")
|
||||
override fun onPause() = CompanionAudioBridge.command("pause")
|
||||
override fun onStop() = CompanionAudioBridge.stop()
|
||||
override fun onSkipToNext() { if (CompanionAudioBridge.state?.next == true) CompanionAudioBridge.command("next") }
|
||||
override fun onSkipToPrevious() { if (CompanionAudioBridge.state?.previous == true) CompanionAudioBridge.command("previous") }
|
||||
override fun onSeekTo(pos: Long) {
|
||||
val duration = CompanionAudioBridge.state?.duration ?: return
|
||||
CompanionAudioBridge.command("seek", (pos / 1000.0).coerceIn(0.0, duration))
|
||||
}
|
||||
override fun onCustomAction(action: String, extras: Bundle?) {
|
||||
if (action == "shuffle" && CompanionAudioBridge.state?.shuffle == true) CompanionAudioBridge.command("shuffle")
|
||||
}
|
||||
}, handler)
|
||||
media.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS)
|
||||
media.setSessionActivity(openPlayer())
|
||||
media.isActive = true
|
||||
ContextCompat.registerReceiver(this, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY), ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
handler.postDelayed(watchdog, 5000)
|
||||
}
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
CompanionAudioDiagnostics.record(Event.SERVICE_STARTED)
|
||||
val state = CompanionAudioBridge.state
|
||||
if (state == null) { finishPlayback(); return START_NOT_STICKY }
|
||||
finishing = false; instance = this
|
||||
if (intent?.action != null && intent.getStringExtra("session") == state.session) {
|
||||
when (intent.action) {
|
||||
"stop" -> CompanionAudioBridge.stop()
|
||||
"play", "pause" -> CompanionAudioBridge.command(intent.action!!)
|
||||
"next" -> if (state.next) CompanionAudioBridge.command("next")
|
||||
"previous" -> if (state.previous) CompanionAudioBridge.command("previous")
|
||||
"shuffle" -> if (state.shuffle) CompanionAudioBridge.command("shuffle")
|
||||
}
|
||||
}
|
||||
if (!finishing) refresh()
|
||||
return START_NOT_STICKY // Never reconstruct an authorized stream after process death.
|
||||
}
|
||||
private fun openPlayer() = PendingIntent.getActivity(this, 0,
|
||||
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
private fun action(name: String, label: String, icon: Int, session: String): Notification.Action {
|
||||
val intent = Intent(this, CompanionAudioService::class.java).setAction(name).putExtra("session", session)
|
||||
val pending = PendingIntent.getService(this, name.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
return Notification.Action.Builder(icon, label, pending).build()
|
||||
}
|
||||
internal fun refresh() {
|
||||
if (finishing) return
|
||||
val state = CompanionAudioBridge.state ?: return
|
||||
if (state.artwork != lastArtwork) {
|
||||
lastArtwork = state.artwork
|
||||
bitmap = decodeArtwork(state.artwork)
|
||||
}
|
||||
val metadata = MediaMetadata.Builder().putString(MediaMetadata.METADATA_KEY_TITLE, state.title)
|
||||
.putString(MediaMetadata.METADATA_KEY_ARTIST, "Archipelago")
|
||||
.putLong(MediaMetadata.METADATA_KEY_DURATION, (state.duration * 1000).toLong())
|
||||
bitmap?.let { metadata.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, it) }
|
||||
media.setMetadata(metadata.build())
|
||||
var actions = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_STOP
|
||||
if (state.duration > 0) actions = actions or PlaybackState.ACTION_SEEK_TO
|
||||
if (state.previous) actions = actions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
|
||||
if (state.next) actions = actions or PlaybackState.ACTION_SKIP_TO_NEXT
|
||||
val playback = PlaybackState.Builder().setActions(actions)
|
||||
.setState(if (state.playing) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED,
|
||||
(state.position * 1000).toLong(), if (state.playing) 1f else 0f, SystemClock.elapsedRealtime())
|
||||
if (state.shuffle) playback.addCustomAction("shuffle", if (state.shuffled) "Shuffle on" else "Shuffle off", android.R.drawable.ic_menu_rotate)
|
||||
media.setPlaybackState(playback.build())
|
||||
val controls = mutableListOf<Notification.Action>()
|
||||
if (state.previous) controls.add(action("previous", "Previous", android.R.drawable.ic_media_previous, state.session))
|
||||
controls.add(action(if (state.playing) "pause" else "play", if (state.playing) "Pause" else "Play",
|
||||
if (state.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play, state.session))
|
||||
if (state.next) controls.add(action("next", "Next", android.R.drawable.ic_media_next, state.session))
|
||||
val compact = controls.indices.toList().toIntArray()
|
||||
if (state.shuffle) controls.add(action("shuffle", if (state.shuffled) "Shuffle on" else "Shuffle off", android.R.drawable.ic_menu_rotate, state.session))
|
||||
controls.add(action("stop", "Stop", android.R.drawable.ic_menu_close_clear_cancel, state.session))
|
||||
val notification = Notification.Builder(this, CHANNEL)
|
||||
.setSmallIcon(android.R.drawable.ic_media_play).setContentTitle(state.title).setContentText("Archipelago")
|
||||
.setContentIntent(openPlayer()).setOnlyAlertOnce(true).setOngoing(state.playing)
|
||||
.setVisibility(Notification.VISIBILITY_PUBLIC).setCategory(Notification.CATEGORY_TRANSPORT)
|
||||
.setStyle(Notification.MediaStyle().setMediaSession(media.sessionToken).setShowActionsInCompactView(*compact))
|
||||
.setActions(*controls.toTypedArray())
|
||||
bitmap?.let { notification.setLargeIcon(it) }
|
||||
startForeground(NOTIFICATION, notification.build())
|
||||
CompanionAudioDiagnostics.record(Event.FOREGROUND_ACTIVE)
|
||||
}
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
if (CompanionAudioBridge.state?.playing != true) CompanionAudioBridge.stop()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
internal fun finishPlayback() {
|
||||
if (finishing) return
|
||||
finishing = true
|
||||
CompanionAudioDiagnostics.record(Event.SERVICE_FINISHED)
|
||||
if (instance === this) instance = null
|
||||
stopForeground(STOP_FOREGROUND_REMOVE); stopSelf()
|
||||
}
|
||||
override fun onDestroy() {
|
||||
CompanionAudioDiagnostics.record(Event.SERVICE_DESTROYED)
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
runCatching { unregisterReceiver(noisy) }
|
||||
media.isActive = false; media.release(); bitmap = null
|
||||
if (instance === this) { instance = null; CompanionAudioBridge.stop() }
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun decodeArtwork(data: String): Bitmap? = runCatching {
|
||||
if (!data.startsWith("data:image/jpeg;base64,") || data.length > 90000) return null
|
||||
val bytes = Base64.decode(data.substringAfter(','), Base64.NO_WRAP)
|
||||
if (bytes.size < 4 || bytes[0] != 0xff.toByte() || bytes[1] != 0xd8.toByte() || bytes[2] != 0xff.toByte()) return null
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth !in 1..512 || bounds.outHeight !in 1..512) return null
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
}.getOrNull()
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.DownloadListener
|
||||
import android.webkit.URLUtil
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.Call
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal data class WebDownload(val url: String, val userAgent: String, val cookies: String, val name: String, val mime: String)
|
||||
|
||||
/** Only the starting origin receives its WebView cookies, even across redirects. */
|
||||
internal fun streamWebDownload(
|
||||
download: WebDownload,
|
||||
output: OutputStream,
|
||||
client: OkHttpClient,
|
||||
onCall: (Call) -> Unit = {},
|
||||
checkCancelled: () -> Unit = {},
|
||||
onProgress: (Long, Long) -> Unit = { _, _ -> },
|
||||
): Long {
|
||||
val transport = client.newBuilder().followRedirects(false).followSslRedirects(false).build()
|
||||
val original = download.url.toHttpUrlOrNull() ?: throw IOException("Unsupported download link")
|
||||
var url = original
|
||||
var redirects = 0
|
||||
while (true) {
|
||||
checkCancelled()
|
||||
if (url.username.isNotEmpty() || url.password.isNotEmpty()) throw IOException("Unsupported download link")
|
||||
val request = Request.Builder().url(url).header("User-Agent", download.userAgent)
|
||||
if (url.scheme == original.scheme && url.host == original.host && url.port == original.port && download.cookies.isNotBlank()) {
|
||||
request.header("Cookie", download.cookies)
|
||||
}
|
||||
val call = transport.newCall(request.build())
|
||||
onCall(call)
|
||||
call.execute().use { response ->
|
||||
if (response.code in listOf(301, 302, 303, 307, 308)) {
|
||||
if (++redirects > 5) throw IOException("Too many download redirects")
|
||||
val next = response.header("Location")?.let { url.resolve(it) } ?: throw IOException("Invalid download redirect")
|
||||
if (url.isHttps && !next.isHttps) throw IOException("Insecure download redirect blocked")
|
||||
url = next
|
||||
} else {
|
||||
if (response.code == 401 || response.code == 403) throw IOException("Sign in to the node again, then retry the download")
|
||||
if (!response.isSuccessful) throw IOException("Download failed (HTTP ${response.code})")
|
||||
if (response.header("Content-Type")?.substringBefore(';')?.trim()?.lowercase() == "text/html" &&
|
||||
download.mime != "text/html" && !download.name.endsWith(".html", true) && !download.name.endsWith(".htm", true)) {
|
||||
throw IOException("Sign in to the node again, then retry the download")
|
||||
}
|
||||
val body = response.body ?: throw IOException("The download was empty")
|
||||
val total = body.contentLength()
|
||||
var written = 0L
|
||||
body.byteStream().use { input ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var lastUpdate = 0L
|
||||
while (true) {
|
||||
checkCancelled()
|
||||
val count = input.read(buffer)
|
||||
if (count == -1) break
|
||||
output.write(buffer, 0, count)
|
||||
written += count
|
||||
val now = System.nanoTime()
|
||||
if (now - lastUpdate > 100_000_000L) { onProgress(written, total); lastUpdate = now }
|
||||
}
|
||||
}
|
||||
if (total >= 0 && written != total) throw IOException("Download interrupted; please retry")
|
||||
onProgress(written, total)
|
||||
return written
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Uses the system Save dialog: no broad storage permission and no external browser login. */
|
||||
@Composable
|
||||
internal fun rememberWebViewDownloads(): DownloadListener {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var pending by remember { mutableStateOf<WebDownload?>(null) }
|
||||
var active by remember { mutableStateOf<WebDownload?>(null) }
|
||||
var progress by remember { mutableStateOf<Pair<Long, Long>>(0L to -1L) }
|
||||
var failure by remember { mutableStateOf<String?>(null) }
|
||||
var job by remember { mutableStateOf<Job?>(null) }
|
||||
val currentCall = remember { java.util.concurrent.atomic.AtomicReference<Call?>(null) }
|
||||
val client = remember {
|
||||
OkHttpClient.Builder().followRedirects(false).followSslRedirects(false)
|
||||
.connectTimeout(20, TimeUnit.SECONDS).readTimeout(60, TimeUnit.SECONDS).build()
|
||||
}
|
||||
fun cancel() { job?.cancel(); currentCall.getAndSet(null)?.cancel() }
|
||||
DisposableEffect(Unit) { onDispose { currentCall.getAndSet(null)?.cancel() } }
|
||||
val save = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val download = pending
|
||||
pending = null
|
||||
val uri = result.data?.data
|
||||
if (result.resultCode != Activity.RESULT_OK || uri == null || download == null) return@rememberLauncherForActivityResult
|
||||
job = scope.launch {
|
||||
active = download
|
||||
progress = 0L to -1L
|
||||
var complete = false
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val task = currentCoroutineContext()
|
||||
context.contentResolver.openOutputStream(uri, "w")?.use { output ->
|
||||
streamWebDownload(download, output, client,
|
||||
onCall = { call -> currentCall.set(call); if (!task.isActive) call.cancel() },
|
||||
checkCancelled = { task.ensureActive() },
|
||||
onProgress = { done, total -> scope.launch { progress = done to total } })
|
||||
} ?: throw IOException("Unable to open the selected destination")
|
||||
}
|
||||
complete = true
|
||||
Toast.makeText(context, "Download complete: ${download.name}", Toast.LENGTH_LONG).show()
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
if (currentCoroutineContext().isActive) {
|
||||
// Do not expose authenticated URLs or request headers in UI/logs.
|
||||
failure = when {
|
||||
error is javax.net.ssl.SSLException -> "The server certificate could not be verified."
|
||||
error is IOException && error.message?.startsWith("Sign in") == true -> error.message
|
||||
else -> "Download failed. Check your connection and available storage, then try again."
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
currentCall.getAndSet(null)?.cancel()
|
||||
if (!complete) withContext(NonCancellable + Dispatchers.IO) {
|
||||
// This URI was newly created by ACTION_CREATE_DOCUMENT; never remove an existing user file.
|
||||
runCatching { DocumentsContract.deleteDocument(context.contentResolver, uri) }
|
||||
}
|
||||
active = null
|
||||
job = null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (active != null) {
|
||||
AlertDialog(onDismissRequest = {}, title = { Text("Downloading") }, text = {
|
||||
Column {
|
||||
Text(active!!.name)
|
||||
if (progress.second > 0) LinearProgressIndicator(progress = (progress.first.toFloat() / progress.second).coerceIn(0f, 1f))
|
||||
else LinearProgressIndicator()
|
||||
}
|
||||
}, confirmButton = {}, dismissButton = { TextButton(onClick = { cancel() }) { Text("Cancel") } })
|
||||
}
|
||||
failure?.let { message ->
|
||||
AlertDialog(onDismissRequest = { failure = null }, title = { Text("Download unavailable") },
|
||||
text = { Text(message) }, confirmButton = { TextButton(onClick = { failure = null }) { Text("OK") } })
|
||||
}
|
||||
return DownloadListener { url, userAgent, disposition, mimeType, _ ->
|
||||
if (active != null || pending != null) {
|
||||
Toast.makeText(context, "Finish or cancel the current download first", Toast.LENGTH_SHORT).show()
|
||||
} else if (url.toHttpUrlOrNull() == null) {
|
||||
failure = "This download link is not supported. Open the file from Cloud and try again."
|
||||
} else {
|
||||
val mime = mimeType?.substringBefore(';')?.takeIf { it.contains('/') } ?: "application/octet-stream"
|
||||
val name = URLUtil.guessFileName(url, disposition, mime).replace(Regex("[\\\\/\\p{Cntrl}]"), "_").take(180).ifBlank { "download" }
|
||||
pending = WebDownload(url, userAgent ?: "Archipelago Companion", CookieManager.getInstance().getCookie(url).orEmpty(), name, mime)
|
||||
try {
|
||||
save.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE); type = mime; putExtra(Intent.EXTRA_TITLE, name)
|
||||
})
|
||||
} catch (_: Exception) { pending = null; failure = "No file-saving app is available on this device." }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.graphics.Color
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebChromeClient
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
|
||||
private fun Context.fullscreenActivity(): ComponentActivity? = when (this) {
|
||||
is ComponentActivity -> this
|
||||
is ContextWrapper -> baseContext.takeIf { it !== this }?.fullscreenActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Hosts Chromium's custom fullscreen view without replacing or reloading its WebView. */
|
||||
internal class WebViewFullscreen(private val activity: ComponentActivity?) {
|
||||
private var overlay: FrameLayout? = null
|
||||
private var callback: WebChromeClient.CustomViewCallback? = null
|
||||
private var back: OnBackPressedCallback? = null
|
||||
val isActive: Boolean get() = overlay != null
|
||||
fun bounds(rect: android.graphics.Rect): Boolean = overlay?.getGlobalVisibleRect(rect) == true
|
||||
private var visibleBars = 0
|
||||
private var originalBehavior = 0
|
||||
|
||||
fun show(view: View?, onHidden: WebChromeClient.CustomViewCallback?) {
|
||||
val owner = activity
|
||||
// A second enter must not detach the active video or strand its callback.
|
||||
if (owner == null || owner.isFinishing || owner.isDestroyed || view == null ||
|
||||
view.parent != null || overlay != null
|
||||
) {
|
||||
onHidden?.onCustomViewHidden()
|
||||
return
|
||||
}
|
||||
val decor = owner.window.decorView as? ViewGroup
|
||||
if (decor == null) { onHidden?.onCustomViewHidden(); return }
|
||||
val controller = WindowCompat.getInsetsController(owner.window, decor)
|
||||
val insets = ViewCompat.getRootWindowInsets(decor)
|
||||
visibleBars = 0
|
||||
if (insets?.isVisible(WindowInsetsCompat.Type.statusBars()) != false) {
|
||||
visibleBars = visibleBars or WindowInsetsCompat.Type.statusBars()
|
||||
}
|
||||
if (insets?.isVisible(WindowInsetsCompat.Type.navigationBars()) != false) {
|
||||
visibleBars = visibleBars or WindowInsetsCompat.Type.navigationBars()
|
||||
}
|
||||
originalBehavior = controller.systemBarsBehavior
|
||||
val host = FrameLayout(owner).apply {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
keepScreenOn = true
|
||||
addView(view, FrameLayout.LayoutParams(-1, -1))
|
||||
}
|
||||
overlay = host
|
||||
callback = onHidden
|
||||
decor.addView(host, ViewGroup.LayoutParams(-1, -1))
|
||||
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
back = object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() = hide()
|
||||
}.also { owner.onBackPressedDispatcher.addCallback(it) }
|
||||
view.requestFocus()
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
val host = overlay ?: return
|
||||
if (activity?.isInPictureInPictureMode == true) {
|
||||
// A navigation/logout/custom-view exit must never expose the node
|
||||
// management UI in the small OS window. Keep a black cover until
|
||||
// the activity leaves PiP; the ordinary hide then removes it.
|
||||
val notify = callback; callback = null
|
||||
back?.remove(); back = null
|
||||
host.keepScreenOn = false; host.removeAllViews()
|
||||
host.addView(android.widget.TextView(host.context).apply {
|
||||
text = "Video paused. Expand to return."
|
||||
setTextColor(Color.WHITE)
|
||||
gravity = android.view.Gravity.CENTER
|
||||
contentDescription = "Video paused. Use picture-in-picture controls to expand or close."
|
||||
}, FrameLayout.LayoutParams(-1, -1))
|
||||
notify?.onCustomViewHidden()
|
||||
return
|
||||
}
|
||||
// Clear first: Chromium may synchronously call onHideCustomView again.
|
||||
overlay = null
|
||||
val notify = callback
|
||||
callback = null
|
||||
back?.remove()
|
||||
back = null
|
||||
host.keepScreenOn = false
|
||||
host.removeAllViews()
|
||||
(host.parent as? ViewGroup)?.removeView(host)
|
||||
activity?.let { owner ->
|
||||
val controller = WindowCompat.getInsetsController(owner.window, owner.window.decorView)
|
||||
controller.systemBarsBehavior = originalBehavior
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
if (visibleBars != 0) controller.show(visibleBars)
|
||||
}
|
||||
notify?.onCustomViewHidden()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberWebViewFullscreen(): WebViewFullscreen {
|
||||
val context = LocalContext.current
|
||||
val fullscreen = remember(context) { WebViewFullscreen(context.fullscreenActivity()) }
|
||||
DisposableEffect(fullscreen) { onDispose { fullscreen.hide() } }
|
||||
return fullscreen
|
||||
}
|
||||
@@ -144,6 +144,29 @@ private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||
* this when the task is genuinely finishing. */
|
||||
fun releaseKioskWebView() = KioskWebView.drop()
|
||||
|
||||
/** A playing session is owned by the foreground media service after task close. */
|
||||
fun finishKioskActivity() {
|
||||
val view = KioskWebView.instance ?: return
|
||||
if (!CompanionAudioBridge.retains(view)) { KioskWebView.drop(); return }
|
||||
(view.parent as? ViewGroup)?.removeView(view)
|
||||
KioskWebView.backgroundOwned = true
|
||||
KioskWebView.clearDelegates()
|
||||
view.setOnTouchListener(null)
|
||||
view.setOnApplyWindowInsetsListener(null)
|
||||
view.setDownloadListener(null)
|
||||
view.webChromeClient = null
|
||||
view.webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(web: WebView?, url: String?, favicon: Bitmap?) {
|
||||
web?.let { CompanionAudioBridge.release(it) }
|
||||
}
|
||||
}
|
||||
(view.context as? android.content.MutableContextWrapper)?.baseContext = view.context.applicationContext
|
||||
}
|
||||
|
||||
internal fun releaseDetachedKioskWebView() {
|
||||
if (KioskWebView.backgroundOwned && !CompanionAudioBridge.retains(KioskWebView.instance)) KioskWebView.drop()
|
||||
}
|
||||
|
||||
/** Restart the app in place: throw away the retained page and relaunch the
|
||||
* task from scratch. The mesh/VPN service is deliberately left running — this
|
||||
* is the "give me a clean app" button (hub menu), not a process kill. */
|
||||
@@ -294,6 +317,7 @@ private fun isSameHost(url: String, base: String): Boolean {
|
||||
data class InAppLaunch(val url: String, val icon: String? = null, val name: String? = null)
|
||||
|
||||
private object KioskWebView {
|
||||
var backgroundOwned = false
|
||||
var instance: WebView? = null
|
||||
var url: String? = null
|
||||
|
||||
@@ -306,13 +330,19 @@ private object KioskWebView {
|
||||
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
|
||||
var onQrClose: () -> Unit = {}
|
||||
|
||||
fun clearDelegates() {
|
||||
onRouteOutbound = {}; onOpenInApp = {}; onQrOpen = {}
|
||||
onQrStatus = { _, _ -> }; onQrClose = {}
|
||||
}
|
||||
fun drop() {
|
||||
instance?.let {
|
||||
val old = instance
|
||||
instance = null; url = null; backgroundOwned = false
|
||||
clearDelegates()
|
||||
old?.let {
|
||||
CompanionAudioBridge.release(it)
|
||||
(it.parent as? ViewGroup)?.removeView(it)
|
||||
it.destroy()
|
||||
}
|
||||
instance = null
|
||||
url = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -611,6 +641,9 @@ fun WebViewScreen(
|
||||
// before surfacing the error page: the mesh tunnel works from anywhere.
|
||||
meshFallbackUrl: String? = null,
|
||||
) {
|
||||
val fullscreen = rememberWebViewFullscreen()
|
||||
val cloudPip = rememberCloudVideoPip(fullscreen)
|
||||
val downloads = rememberWebViewDownloads()
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
// First kiosk load (often over the FIPS mesh) gets the full branded
|
||||
// loader; later navigations keep just the slim top progress bar.
|
||||
@@ -898,7 +931,9 @@ fun WebViewScreen(
|
||||
// stale closures from the previous visit are replaced.
|
||||
if (KioskWebView.url != serverUrl) KioskWebView.drop()
|
||||
val reused = KioskWebView.instance
|
||||
(reused ?: WebView(context)).apply {
|
||||
(reused ?: WebView(android.content.MutableContextWrapper(context))).apply {
|
||||
(this.context as? android.content.MutableContextWrapper)?.baseContext = context
|
||||
KioskWebView.backgroundOwned = false
|
||||
(parent as? ViewGroup)?.removeView(this)
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
@@ -913,6 +948,9 @@ fun WebViewScreen(
|
||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||
|
||||
applyArchipelagoSettings()
|
||||
cloudPip.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
|
||||
CompanionAudioBridge.attach(this, listOfNotNull(serverUrl, meshFallbackUrl))
|
||||
setDownloadListener(downloads)
|
||||
settings.apply {
|
||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||
// Let JS open windows without a synchronous user-gesture
|
||||
@@ -1086,6 +1124,8 @@ fun WebViewScreen(
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
CompanionAudioBridge.release(view ?: return)
|
||||
cloudPip.reset()
|
||||
isLoading = true
|
||||
hasError = false
|
||||
// New document — the injected safe-area style is
|
||||
@@ -1181,6 +1221,12 @@ fun WebViewScreen(
|
||||
}
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowCustomView(view: android.view.View?, callback: CustomViewCallback?) {
|
||||
fullscreen.show(view, callback)
|
||||
}
|
||||
|
||||
override fun onHideCustomView() { cloudPip.reset(); fullscreen.hide() }
|
||||
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
loadProgress = newProgress
|
||||
}
|
||||
@@ -1546,6 +1592,8 @@ private fun InAppBrowser(
|
||||
appName: String? = null,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val fullscreen = rememberWebViewFullscreen()
|
||||
val downloads = rememberWebViewDownloads()
|
||||
val context = LocalContext.current
|
||||
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
|
||||
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
|
||||
@@ -1643,6 +1691,7 @@ private fun InAppBrowser(
|
||||
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
||||
applyArchipelagoSettings()
|
||||
setDownloadListener(downloads)
|
||||
// Node apps (BTCPay invoices, LND, Portainer tokens) are
|
||||
// served over plain HTTP too — same dead-clipboard trap.
|
||||
addClipboardBridge()
|
||||
@@ -1662,6 +1711,12 @@ private fun InAppBrowser(
|
||||
)
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowCustomView(view: android.view.View?, callback: CustomViewCallback?) {
|
||||
fullscreen.show(view, callback)
|
||||
}
|
||||
|
||||
override fun onHideCustomView() = fullscreen.hide()
|
||||
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
progress = newProgress
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class CloudVideoPipTest {
|
||||
@Test fun nativeChannelRejectsSiblingFrameAndStaleOrForeignPage() {
|
||||
val allowed = setOf("https://node.test", "http://[fd00::1]")
|
||||
assertTrue(cloudVideoSenderAllowed("https://node.test/cloud", "https://node.test", allowed, true))
|
||||
assertFalse(cloudVideoSenderAllowed("https://node.test/cloud", "https://node.test", allowed, false))
|
||||
assertFalse(cloudVideoSenderAllowed("https://other.test", "https://node.test", allowed, true))
|
||||
assertFalse(cloudVideoSenderAllowed("https://node.test:7778", "https://node.test:7778", allowed, true))
|
||||
assertFalse(cloudVideoSenderAllowed("https://node.test", "http://node.test", allowed, true))
|
||||
}
|
||||
|
||||
@Test fun exactOriginIncludesSchemeAndNonDefaultPort() {
|
||||
assertEquals("https://node.test", cloudVideoOrigin("https://NODE.test:443/cloud"))
|
||||
assertEquals("http://node.test:8080", cloudVideoOrigin("http://node.test:8080/cloud?file=video"))
|
||||
assertEquals("http://[fd00::1]", cloudVideoOrigin("http://[fd00::1]/cloud"))
|
||||
assertNotEquals(cloudVideoOrigin("https://node.test"), cloudVideoOrigin("http://node.test"))
|
||||
assertNotEquals(cloudVideoOrigin("https://node.test"), cloudVideoOrigin("https://node.test:7778"))
|
||||
}
|
||||
@Test fun unsupportedAndCredentialOriginsCannotReceiveBridge() {
|
||||
for (url in listOf("javascript:alert(1)", "file:///video", "data:text/plain,video", "https://user:password@node.test/video", "not-a-url")) assertNull(cloudVideoOrigin(url))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.Before
|
||||
import org.robolectric.Shadows
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, sdk = [28, 35])
|
||||
class CompanionAudioTest {
|
||||
@Before fun compatReceiverPermission() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
// The real merged manifest contributes this AndroidX permission.
|
||||
Shadows.shadowOf(app).grantPermissions(app.packageName + ".DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION")
|
||||
}
|
||||
@Test fun actualBridgeBindsOriginSessionAndSequenceAndReleasesStoppedPlayback() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val web = android.webkit.WebView(app)
|
||||
web.loadUrl("https://node.test/cloud")
|
||||
val events = mutableListOf<JSONObject>()
|
||||
fun send(message: JSONObject, origin: String = "https://node.test", main: Boolean = true) {
|
||||
CompanionAudioBridge.receive(web, message.toString(), origin, main, setOf("https://node.test")) { events.add(JSONObject(it)) }
|
||||
}
|
||||
try {
|
||||
send(state(), main = false); assertNull(CompanionAudioBridge.state)
|
||||
send(state(), origin = "https://foreign.test"); assertNull(CompanionAudioBridge.state)
|
||||
send(state()); assertTrue(CompanionAudioBridge.retains(web))
|
||||
send(state().put("sequence", 0).put("playing", false)); assertTrue(CompanionAudioBridge.state!!.playing)
|
||||
CompanionAudioBridge.command("seek", 32.0)
|
||||
assertEquals("seek", events.last().getString("command")); assertEquals(32.0, events.last().getDouble("position"), 0.0)
|
||||
send(state().put("sequence", 2).put("playing", false)); assertFalse(CompanionAudioBridge.state!!.playing)
|
||||
CompanionAudioBridge.stop(); assertNull(CompanionAudioBridge.state)
|
||||
assertEquals("stop", events.last().getString("command"))
|
||||
send(state().put("sequence", 3)); assertNull(CompanionAudioBridge.state) // delayed state cannot revive a stopped session
|
||||
} finally { CompanionAudioBridge.release(web); web.destroy() }
|
||||
}
|
||||
@Test fun liveBridgeBuildsForegroundMediaNotificationForSameSession() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val web = android.webkit.WebView(app); web.loadUrl("https://node.test/cloud")
|
||||
val payload = state().put("session", "22345678-1234-1234-1234-123456789abc")
|
||||
CompanionAudioBridge.receive(web, payload.toString(), "https://node.test", true, setOf("https://node.test")) {}
|
||||
val lifecycle = Robolectric.buildService(CompanionAudioService::class.java).create()
|
||||
try {
|
||||
lifecycle.get().onStartCommand(null, 0, 1)
|
||||
val notification = Shadows.shadowOf(lifecycle.get()).lastForegroundNotification
|
||||
assertNotNull(notification)
|
||||
assertEquals("Current song", notification.extras.getString(android.app.Notification.EXTRA_TITLE))
|
||||
assertEquals(5, notification.actions.size)
|
||||
assertNotNull(notification.extras.getParcelable<android.media.session.MediaSession.Token>(android.app.Notification.EXTRA_MEDIA_SESSION))
|
||||
lifecycle.get().onTaskRemoved(null)
|
||||
assertTrue(CompanionAudioBridge.retains(web))
|
||||
} finally { CompanionAudioBridge.release(web); lifecycle.destroy(); web.destroy() }
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(shadows = [RecordingAudioMediaSession::class])
|
||||
fun jpegArtworkReachesNotificationAndMediaDescription() {
|
||||
// Real 16x16 JPEG generated by Chromium canvas, independent of Android bitmap shadows.
|
||||
val artwork = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAAQABADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAn/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFAEBAAAAAAAAAAAAAAAAAAAAAP/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AJVAA//Z"
|
||||
assertNotNull(decodeArtwork(artwork))
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val web = android.webkit.WebView(app); web.loadUrl("https://node.test/cloud")
|
||||
val payload = state().put("session", "32345678-1234-1234-1234-123456789abc").put("artwork", artwork)
|
||||
CompanionAudioBridge.receive(web, payload.toString(), "https://node.test", true, setOf("https://node.test")) {}
|
||||
val lifecycle = Robolectric.buildService(CompanionAudioService::class.java).create()
|
||||
try {
|
||||
lifecycle.get().onStartCommand(null, 0, 1)
|
||||
val notification = Shadows.shadowOf(lifecycle.get()).lastForegroundNotification
|
||||
assertNotNull(notification.getLargeIcon())
|
||||
// Robolectric's MediaController does not read MediaSession metadata;
|
||||
// capture the actual service's setMetadata call instead.
|
||||
val metadata = RecordingAudioMediaSession.metadata!!
|
||||
assertNotNull(metadata.getBitmap(android.media.MediaMetadata.METADATA_KEY_ALBUM_ART))
|
||||
assertNotNull(metadata.description.iconBitmap)
|
||||
// Position-only refresh must retain the same thumbnail.
|
||||
CompanionAudioBridge.receive(web, state().put("session", payload.getString("session")).put("sequence", 2).toString(),
|
||||
"https://node.test", true, setOf("https://node.test")) {}
|
||||
assertNotNull(Shadows.shadowOf(lifecycle.get()).lastForegroundNotification.getLargeIcon())
|
||||
// A following song without art must not keep the previous cover.
|
||||
CompanionAudioBridge.receive(web, state().put("session", payload.getString("session")).put("sequence", 3).put("artwork", "").toString(),
|
||||
"https://node.test", true, setOf("https://node.test")) {}
|
||||
assertNull(Shadows.shadowOf(lifecycle.get()).lastForegroundNotification.getLargeIcon())
|
||||
assertNull(RecordingAudioMediaSession.metadata!!.description.iconBitmap)
|
||||
} finally { CompanionAudioBridge.release(web); lifecycle.destroy(); web.destroy() }
|
||||
}
|
||||
|
||||
@Test fun diagnosticReportExcludesPrivateMessagesAndRecordsRejectionStage() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val web = android.webkit.WebView(app)
|
||||
web.loadUrl("https://private-node.test/cloud?token=private-token")
|
||||
try {
|
||||
CompanionAudioBridge.receive(web, "private-invalid-payload", "https://private-node.test", true,
|
||||
setOf("https://private-node.test")) {}
|
||||
CompanionAudioBridge.receive(web, state().put("title", "PRIVATE SONG").put("duration", -1).toString(),
|
||||
"https://private-node.test", true, setOf("https://private-node.test")) {}
|
||||
val report = CompanionAudioDiagnostics.report(app)
|
||||
assertTrue(report.contains("INVALID_JSON:"))
|
||||
assertTrue(report.contains("INVALID_STATE:"))
|
||||
for (privateValue in listOf("private-node", "private-token", "private-invalid-payload", "PRIVATE SONG", "12345678")) {
|
||||
assertFalse(report.contains(privateValue))
|
||||
}
|
||||
} finally { web.destroy() }
|
||||
}
|
||||
@Test fun diagnosticHistoryIsBoundedWithoutDroppingStageCounts() {
|
||||
repeat(100) {
|
||||
CompanionAudioDiagnostics.record(CompanionAudioDiagnostics.Event.SERVICE_CREATED)
|
||||
CompanionAudioDiagnostics.record(CompanionAudioDiagnostics.Event.SERVICE_DESTROYED)
|
||||
}
|
||||
val report = CompanionAudioDiagnostics.events()
|
||||
val history = report.substringAfter("Recent transitions (seconds since boot):\n")
|
||||
assertEquals(12, history.lines().count { it.isNotBlank() })
|
||||
assertTrue(report.contains("SERVICE_CREATED:"))
|
||||
}
|
||||
|
||||
private fun state() = JSONObject("""{"version":1,"action":"state","session":"12345678-1234-1234-1234-123456789abc","sequence":1,"title":"Current song","playing":true,"position":10,"duration":120,"previous":true,"next":true,"shuffle":true,"shuffled":false}""")
|
||||
@Test fun malformedOrUnboundedMetadataCannotBecomeNativePlayback() {
|
||||
for ((key, value) in listOf("version" to 2, "session" to "foreign", "sequence" to -1,
|
||||
"position" to -1, "position" to 121, "duration" to 604801, "title" to "x".repeat(513),
|
||||
"artwork" to "https://node.test/protected?token=secret")) {
|
||||
assertTrue("Must reject $key", runCatching { CompanionAudioState.parse(state().put(key, value)) }.isFailure)
|
||||
}
|
||||
}
|
||||
@Test fun currentMetadataPreservesPauseSeekAndQueueCapabilities() {
|
||||
val parsed = CompanionAudioState.parse(state().put("playing", false).put("shuffled", true))
|
||||
assertFalse(parsed.playing); assertTrue(parsed.shuffled)
|
||||
assertTrue(parsed.previous && parsed.next && parsed.shuffle)
|
||||
assertEquals(10.0, parsed.position, 0.0)
|
||||
assertEquals(120.0, parsed.duration, 0.0)
|
||||
assertEquals("", parsed.artwork)
|
||||
}
|
||||
@Test fun artworkNeverFetchesProtectedUrlsAndRejectsInvalidBytes() {
|
||||
assertNull(decodeArtwork("https://node.test/protected"))
|
||||
assertNull(decodeArtwork("data:image/jpeg;base64,AAAA"))
|
||||
assertNull(decodeArtwork("data:image/jpeg;base64," + "A".repeat(90000)))
|
||||
}
|
||||
@Test fun serviceRestartWithoutLiveAuthorizedSessionDoesNotResumePlayback() {
|
||||
val lifecycle = Robolectric.buildService(CompanionAudioService::class.java).create()
|
||||
try {
|
||||
assertEquals(android.app.Service.START_NOT_STICKY, lifecycle.get().onStartCommand(null, 0, 1))
|
||||
assertNull(CompanionAudioBridge.state)
|
||||
assertNull(CompanionAudioService.instance)
|
||||
} finally { lifecycle.destroy() }
|
||||
}
|
||||
}
|
||||
|
||||
@org.robolectric.annotation.Implements(android.media.session.MediaSession::class)
|
||||
class RecordingAudioMediaSession : org.robolectric.shadows.ShadowMediaSession() {
|
||||
companion object { var metadata: android.media.MediaMetadata? = null }
|
||||
@org.robolectric.annotation.Implementation
|
||||
fun setMetadata(value: android.media.MediaMetadata) { metadata = value }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
class WebViewDownloadsTest {
|
||||
private fun spec(url: String) = WebDownload(url, "test-agent", "session=test-only", "file.bin", "application/octet-stream")
|
||||
@Test fun authenticatedDownloadWritesExactBytesAndReportsCompletion() {
|
||||
MockWebServer().use { server ->
|
||||
val bytes = ByteArray(256 * 1024 + 13) { (it % 251).toByte() }
|
||||
server.enqueue(MockResponse().setBody(Buffer().write(bytes)))
|
||||
val out = ByteArrayOutputStream()
|
||||
var progress = 0L to 0L
|
||||
assertEquals(bytes.size.toLong(), streamWebDownload(spec(server.url("/file").toString()), out, OkHttpClient(), onProgress = { done, total -> progress = done to total }))
|
||||
assertArrayEquals(bytes, out.toByteArray())
|
||||
assertEquals(bytes.size.toLong() to bytes.size.toLong(), progress)
|
||||
assertEquals("session=test-only", server.takeRequest().getHeader("Cookie"))
|
||||
}
|
||||
}
|
||||
@Test fun sameOriginRedirectKeepsSessionButCrossOriginNeverReceivesIt() {
|
||||
MockWebServer().use { first -> MockWebServer().use { second ->
|
||||
second.enqueue(MockResponse().setBody("final"))
|
||||
first.enqueue(MockResponse().setResponseCode(302).addHeader("Location", "/relative"))
|
||||
first.enqueue(MockResponse().setResponseCode(307).addHeader("Location", second.url("/target")))
|
||||
val out = ByteArrayOutputStream()
|
||||
streamWebDownload(spec(first.url("/start").toString()), out, OkHttpClient())
|
||||
assertEquals("final", out.toString())
|
||||
assertEquals("session=test-only", first.takeRequest().getHeader("Cookie"))
|
||||
assertEquals("session=test-only", first.takeRequest().getHeader("Cookie"))
|
||||
assertNull(second.takeRequest().getHeader("Cookie"))
|
||||
} }
|
||||
}
|
||||
@Test fun authenticationFailureDoesNotSaveErrorBody() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setResponseCode(401).setBody("login required"))
|
||||
val out = ByteArrayOutputStream()
|
||||
val error = assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/").toString()), out, OkHttpClient()) }
|
||||
assertTrue(error.message!!.startsWith("Sign in"))
|
||||
assertEquals(0, out.size())
|
||||
}
|
||||
}
|
||||
@Test fun redirectsAreBoundedAndUnsafeSchemesAreRejected() {
|
||||
MockWebServer().use { server ->
|
||||
repeat(6) { server.enqueue(MockResponse().setResponseCode(302).addHeader("Location", "/loop")) }
|
||||
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/loop").toString()), ByteArrayOutputStream(), OkHttpClient()) }
|
||||
assertEquals(6, server.requestCount)
|
||||
}
|
||||
for (url in listOf("file:///etc/passwd", "data:text/plain,test", "blob:test")) {
|
||||
assertThrows(IOException::class.java) { streamWebDownload(spec(url), ByteArrayOutputStream(), OkHttpClient()) }
|
||||
}
|
||||
}
|
||||
@Test fun cancellationAndDestinationFailureAreNotReportedAsComplete() {
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().setBody("bytes"))
|
||||
assertThrows(CancellationException::class.java) { streamWebDownload(spec(server.url("/").toString()), ByteArrayOutputStream(), OkHttpClient(), checkCancelled = { throw CancellationException() }) }
|
||||
assertEquals(0, server.requestCount)
|
||||
var progressCalled = false
|
||||
val out = object : java.io.OutputStream() { override fun write(b: Int) { throw IOException("disk full") } }
|
||||
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/").toString()), out, OkHttpClient(), onProgress = { _, _ -> progressCalled = true }) }
|
||||
assertFalse(progressCalled)
|
||||
}
|
||||
}
|
||||
@Test fun tlsDowngradeAndLoginHtmlAreRejected() {
|
||||
var requests = 0
|
||||
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||
requests++
|
||||
okhttp3.Response.Builder().request(chain.request()).protocol(okhttp3.Protocol.HTTP_1_1)
|
||||
.code(302).message("redirect").header("Location", "http://example.test/file").body("".toResponseBody(null)).build()
|
||||
}.build()
|
||||
assertThrows(IOException::class.java) { streamWebDownload(spec("https://example.test/file"), ByteArrayOutputStream(), client) }
|
||||
assertEquals(1, requests)
|
||||
MockWebServer().use { server ->
|
||||
server.enqueue(MockResponse().addHeader("Content-Type", "Text/HTML; charset=utf-8").setBody("<html>Sign in</html>"))
|
||||
val out = ByteArrayOutputStream()
|
||||
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/file").toString()), out, OkHttpClient()) }
|
||||
assertEquals(0, out.size())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.ComponentActivity
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, sdk = [28, 35])
|
||||
class WebViewFullscreenTest {
|
||||
@Test fun closingVideoInPipKeepsOpaqueCoverUntilActivityReturns() {
|
||||
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
|
||||
try {
|
||||
val activity = lifecycle.get()
|
||||
val fullscreen = WebViewFullscreen(activity)
|
||||
val video = View(activity)
|
||||
var hidden = 0
|
||||
fullscreen.show(video) { hidden++ }
|
||||
assertTrue(activity.enterPictureInPictureMode(android.app.PictureInPictureParams.Builder().build()))
|
||||
assertTrue(activity.isInPictureInPictureMode)
|
||||
fullscreen.hide()
|
||||
assertNull(video.parent)
|
||||
assertTrue(fullscreen.isActive)
|
||||
assertEquals(1, hidden)
|
||||
fullscreen.hide()
|
||||
assertEquals(1, hidden)
|
||||
} finally { lifecycle.pause().stop().destroy() }
|
||||
}
|
||||
|
||||
@Test fun backExitsFullscreenWithoutFinishingActivityAndNotifiesOnce() {
|
||||
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
|
||||
try {
|
||||
val activity = lifecycle.get()
|
||||
val fullscreen = WebViewFullscreen(activity)
|
||||
val video = View(activity)
|
||||
var hidden = 0
|
||||
fullscreen.show(video) { hidden++ }
|
||||
assertNotNull(video.parent)
|
||||
activity.onBackPressedDispatcher.onBackPressed()
|
||||
assertNull(video.parent)
|
||||
assertFalse(activity.isFinishing)
|
||||
assertEquals(1, hidden)
|
||||
fullscreen.hide()
|
||||
assertEquals(1, hidden)
|
||||
} finally { lifecycle.pause().stop().destroy() }
|
||||
}
|
||||
|
||||
@Test fun duplicateRequestPreservesActiveViewAndCanReenterAfterExit() {
|
||||
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
|
||||
try {
|
||||
val activity = lifecycle.get()
|
||||
val fullscreen = WebViewFullscreen(activity)
|
||||
val first = View(activity)
|
||||
val second = View(activity)
|
||||
var firstHidden = 0
|
||||
var secondHidden = 0
|
||||
fullscreen.show(first) { firstHidden++ }
|
||||
fullscreen.show(second) { secondHidden++ }
|
||||
assertNotNull(first.parent)
|
||||
assertNull(second.parent)
|
||||
assertEquals(0, firstHidden)
|
||||
assertEquals(1, secondHidden)
|
||||
fullscreen.hide()
|
||||
fullscreen.show(second) { secondHidden++ }
|
||||
assertNotNull(second.parent)
|
||||
fullscreen.hide()
|
||||
assertEquals(1, firstHidden)
|
||||
assertEquals(2, secondHidden)
|
||||
} finally { lifecycle.pause().stop().destroy() }
|
||||
}
|
||||
|
||||
@Test fun rejectsOwnedViewWithoutReparentingAndHandlesUnavailableActivity() {
|
||||
val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup()
|
||||
try {
|
||||
val activity = lifecycle.get()
|
||||
val video = View(activity)
|
||||
val owner = FrameLayout(activity).apply { addView(video) }
|
||||
var hidden = 0
|
||||
WebViewFullscreen(activity).show(video) { hidden++ }
|
||||
assertSame(owner, video.parent)
|
||||
WebViewFullscreen(null).show(null) { hidden++ }
|
||||
assertEquals(2, hidden)
|
||||
} finally { lifecycle.pause().stop().destroy() }
|
||||
}
|
||||
}
|
||||
+111
-1
@@ -1,6 +1,116 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## v1.9.0-alpha (2026-10-05)
|
||||
|
||||
Unpublished release candidate; qualification is still in progress.
|
||||
|
||||
- Keep Cuprate and NetBird supporting components out of app listings and consolidate BTCPay Server under Commerce.
|
||||
- Default on-chain sends, channel opens and cooperative closes to a dynamic next-block fee target, preserving explicit slower and custom choices.
|
||||
- Add reviewed fee-bump quotes, explicit budgets and durable operation tracking for supported wallet transactions.
|
||||
- Preserve Nginx Proxy Manager storage, same-node upstream connectivity, certificates and access controls through managed migrations.
|
||||
- Restrict public management access while retaining configured public apps and ACME certificate validation.
|
||||
- Serve the Mempool explorer on the Angor indexer origin alongside its API.
|
||||
- Include the self-contained LoRa flashing tool and explicit board selection in update and installer payloads.
|
||||
- Preserve paid-file Lightning entitlements across restarts and recover settled invoices from LND. Retry delivery without paying again and retain purchased files in the owned cache.
|
||||
- Return explicit payment-status errors with safe retry guidance when verification is unavailable.
|
||||
- Keep upload progress on its original screen, show completion there or notify on other screens, and cancel active and queued uploads.
|
||||
- Resume interrupted uploads while the app remains open, preserve the original destination, and verify saved file contents before reporting completion.
|
||||
- Provision a unique private File Browser login on each node while keeping Cloud sign-in automatic and preserving existing accounts and files.
|
||||
- Update Nostr dependencies to reject forged relay events and oversized encrypted messages; preserve native signing and encryption compatibility.
|
||||
- Allow apps to opt in to a validated public-key list of user identities without granting signing access.
|
||||
- Make transaction filters transparent and horizontally scrollable on mobile.
|
||||
- Keep Immich internal services out of My Apps, avoid false recovery states for healthy stacks, and allow removal of retired catalog apps.
|
||||
- Repair the redundant managed Portainer network override that can prevent startup, preserving custom overrides and persistent state.
|
||||
- Offer Standard, Medium, Fast and custom fees when cooperatively closing Lightning channels.
|
||||
- Add a clear-search icon to My Apps, Services and the App Store on desktop and mobile.
|
||||
- Keep Angor Indexer and the optional Angor Relay in the signed app catalog. Full indexing requires a synced, unpruned Bitcoin node and its indexing dependencies.
|
||||
|
||||
## v1.8.22-alpha (2026-09-30)
|
||||
|
||||
- Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.
|
||||
|
||||
- Network diagnostic failures no longer stop all apps or rebuild shared container networking.
|
||||
- Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.
|
||||
- Fixed companion dashboard builds still referencing a retired image registry.
|
||||
|
||||
- Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.
|
||||
|
||||
- Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.
|
||||
- Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.
|
||||
|
||||
- Checked Bitcoin and Electrum companion dashboards instead of backend protocol ports, preserving dashboard access during initial sync.
|
||||
- Removed web-interface waiting messages from headless services such as Phoenixd and clarified which interface is unavailable for launchable apps.
|
||||
|
||||
- Finished runtime app-file promotion before manifest loading, preventing startup catalog refresh from forgetting disk-only apps.
|
||||
|
||||
- Named the app in compact readiness messages and kept app-card actions aligned at the bottom.
|
||||
- Removed duplicate Mempool cards caused by frontend container aliases in restored inventory.
|
||||
|
||||
- Kept installed apps visible through restarts and hard refreshes, and delayed app launches until their web interface is ready.
|
||||
- Made Bitcoin version selection readable and usable in the ThinkPad kiosk, above the pruning settings.
|
||||
- Restored GitWorkshop build files in installation/update payloads and made slow image-pull progress clearer.
|
||||
- Fixed same-node Gitea access from Portainer, with persistent runtime migration, state backups and recovery after failed restarts.
|
||||
- Preserved Gitea configuration and SSH operation during fresh setup and upgrades.
|
||||
- Improved paid-file delivery, saved-file permissions and repeat-download compatibility; verified Tor-only payment with change, rejection refunds and free repeat downloads.
|
||||
- Added a headless Angor Indexer service using the existing Mempool/ElectrumX stack, and an optional separate Angor relay.
|
||||
- Prevented manifest command arguments containing apostrophes from being corrupted in generated services.
|
||||
|
||||
## v1.8.21-alpha (2026-09-30)
|
||||
|
||||
- Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.
|
||||
- Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.
|
||||
- Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.
|
||||
- Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.
|
||||
|
||||
## v1.8.20-alpha (2026-09-29)
|
||||
|
||||
- Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.
|
||||
- Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging.
|
||||
- Improved saving paid files into Files and reopening purchases without paying again.
|
||||
- Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning.
|
||||
- Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts.
|
||||
- LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure.
|
||||
- Bitcoin startup messages explain block-index loading without exposing raw RPC errors, and Lightning keeps known balances clearly marked during outages.
|
||||
- Changed the public transaction-explorer default to mempool.space while preserving local explorers and custom choices.
|
||||
|
||||
## v1.8.19-alpha (2026-09-28)
|
||||
|
||||
- Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.
|
||||
- Embedded AIUI now stays transparent so the dashboard background appears once.
|
||||
- AIUI background fixes are now included reliably in OTA updates and fresh installations.
|
||||
|
||||
## v1.8.18-alpha (2026-09-18)
|
||||
|
||||
- Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.
|
||||
- Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.
|
||||
- Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.
|
||||
|
||||
## v1.8.17-alpha (2026-09-15)
|
||||
|
||||
- Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.
|
||||
- Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.
|
||||
- Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.
|
||||
- Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs.
|
||||
|
||||
## v1.8.16-alpha (2026-09-15)
|
||||
|
||||
- App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.
|
||||
- Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.
|
||||
- Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.
|
||||
- Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.
|
||||
|
||||
## v1.8.15-alpha (2026-09-13)
|
||||
|
||||
- Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.
|
||||
- Added regression coverage for Cuprate install and installed-state grouping.
|
||||
- Release validation was rerun on the corrected tree before OTA and ISO publication.
|
||||
|
||||
## v1.8.14-alpha (2026-09-13)
|
||||
|
||||
- **Cuprate gains a first-party companion dashboard.** The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.
|
||||
- **Bitcoin Core Tor enrollment uses the correct protocol identity.** `bitcoin-core` is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.
|
||||
- **GitWorkshop opens Archipelago’s canonical ngit repository by default.** The launcher and registry promotion use the full maintainer/relay/`archy` coordinate, with regression coverage for Companion and browser-tab launches.
|
||||
- **Release validation is stricter.** The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites.
|
||||
|
||||
## v1.8.13-alpha (2026-09-12)
|
||||
|
||||
|
||||
+38
-1
@@ -64,12 +64,49 @@ App submissions must:
|
||||
|
||||
## Pull requests
|
||||
|
||||
1. Open one focused PR per behavior or documentation change.
|
||||
Contributions, PRs and reviews live on **ngit**. Clone the canonical repository:
|
||||
|
||||
```text
|
||||
nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
|
||||
```
|
||||
|
||||
Gitea is a conventional Git mirror of accepted `main` commits and release tags.
|
||||
You do not need to open a duplicate Gitea PR. Existing Gitea contributions will
|
||||
be reviewed and linked to their ngit replacement or accepted result before
|
||||
closure.
|
||||
|
||||
1. Open one focused ngit PR per behavior or documentation change.
|
||||
2. Explain what changed, why it changed, and how it was verified.
|
||||
3. Include screenshots for UI changes.
|
||||
4. Link relevant issues or docs.
|
||||
5. Keep generated catalog changes in sync with manifest changes.
|
||||
|
||||
### Maintainer publication gate
|
||||
|
||||
Merge once through the ngit contribution workflow, then push the exact same
|
||||
accepted commits to Gitea. Do not independently merge or squash on each mirror.
|
||||
Publish identical release tag objects, including annotations and signatures.
|
||||
After pushing main, verify:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-git-mirrors.py --local
|
||||
```
|
||||
|
||||
Before publishing release artifacts, also check the actual release tag:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-git-mirrors.py --local --ref refs/tags/v1.9.0-alpha
|
||||
```
|
||||
|
||||
Use the release's actual tag name. Missing refs, inaccessible mirrors or differing
|
||||
object IDs block publication. Record the ngit PR disposition and resulting merge
|
||||
commit in the release acceptance ledger. Resolve drift deliberately; do not
|
||||
force-push or delete published history without explicit approval.
|
||||
|
||||
The checker is read-only. `--all` audits every advertised branch and tag; ngit
|
||||
proposal branches may intentionally differ from Gitea. A main-only pass proves
|
||||
only main parity, and no Git ref check verifies PR discussions or review state.
|
||||
|
||||
Suggested commit format:
|
||||
|
||||
```text
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Archipelago
|
||||
|
||||
> **Alpha testing — funds at your own risk.** Archipelago is experimental software and is not production-ready. Any funds you put on it are at your own risk. Substantial security hardening is planned before production readiness; current builds are for testing and feedback.
|
||||
|
||||
> Self-sovereign Bitcoin node OS and manifest-driven app platform.
|
||||
|
||||
Archipelago is a bootable personal server OS for Bitcoin infrastructure,
|
||||
@@ -11,14 +13,19 @@ Podman containers managed by the Rust backend.
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[](https://source.archipelago-foundation.org/lfg2025/archy/releases)
|
||||
[](https://source.archipelago-foundation.org/lfg2025/archy/releases)
|
||||
|
||||
## Current release
|
||||
|
||||
The current pre-release is **v1.8.13-alpha**. Release notes and signed OTA
|
||||
artifacts are published on [Gitea](https://source.archipelago-foundation.org/lfg2025/archy/releases).
|
||||
The same source is mirrored through ngit for Nostr-native cloning and
|
||||
contribution:
|
||||
The latest published pre-release is **v1.9.0-alpha**. Download the
|
||||
[x86_64 server installer ISO](https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.9.0-alpha/archipelago-installer-1.9.0-alpha-unbundled-x86_64.iso)
|
||||
or read the [release notes and known limitations](https://source.archipelago-foundation.org/lfg2025/archy/releases/tag/v1.9.0-alpha).
|
||||
Newer changes on `main` and private UAT deployments are not included in that
|
||||
published ISO. Check the [releases page](https://source.archipelago-foundation.org/lfg2025/archy/releases)
|
||||
for subsequent published installers and signed OTA artifacts.
|
||||
|
||||
**ngit is the canonical source contribution and review platform.** Gitea mirrors
|
||||
accepted source and hosts release downloads. For Nostr-native cloning:
|
||||
|
||||
```
|
||||
nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
|
||||
@@ -27,6 +34,50 @@ nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ng
|
||||
Clone with ngit, or use the Gitea mirror when you need a conventional Git
|
||||
remote. Contributions should follow [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Install on a server
|
||||
|
||||
Use a dedicated **x86_64 Intel/AMD server, mini PC, or PC**, an SSD, and an
|
||||
8 GB or larger USB drive. For Bitcoin and multiple apps, 8 GB or more RAM and
|
||||
a generously sized SSD are recommended; an unpruned Bitcoin node needs room
|
||||
for the growing blockchain. Connect Ethernet and a monitor/keyboard, or use
|
||||
your server's remote console and virtual installation media.
|
||||
|
||||
1. **Download the installer and checksum:**
|
||||
- [Archipelago 1.9.0-alpha ISO](https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.9.0-alpha/archipelago-installer-1.9.0-alpha-unbundled-x86_64.iso) (approximately 2.7 GB).
|
||||
- [SHA-256 checksum](https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.9.0-alpha/archipelago-installer-1.9.0-alpha-unbundled-x86_64.iso.sha256).
|
||||
- [Signed checksum JSON](https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.9.0-alpha/archipelago-installer-1.9.0-alpha-unbundled-x86_64.iso.sha256.json).
|
||||
2. **Verify the download.** Save the ISO and `.sha256` file together, then on
|
||||
Linux run:
|
||||
|
||||
```bash
|
||||
sha256sum --check archipelago-installer-1.9.0-alpha-unbundled-x86_64.iso.sha256
|
||||
```
|
||||
|
||||
The result must be `OK`. This checks file integrity; the signed JSON is
|
||||
provided separately for release-signature verification.
|
||||
3. **Write the ISO to USB** using [Balena Etcher](https://etcher.balena.io/) or
|
||||
your preferred image writer. Write the image rather than copying the ISO
|
||||
into the USB filesystem. For a remotely managed server, attach the ISO as
|
||||
virtual installation media instead. This is a raw `.iso`; no decompression
|
||||
is needed.
|
||||
4. **Boot the server from that media.** Disable Secure Boot for this installer
|
||||
and follow the console installation prompts. **Installation erases the
|
||||
selected target disk**, so back up its contents and check the disk selection
|
||||
carefully.
|
||||
5. **Remove/eject the installation media and boot from the installed disk.**
|
||||
Find the server's address in your router's DHCP client list or local console,
|
||||
then open `http://<server-ip>` from another device on the same network.
|
||||
6. **Complete first-run setup:** create your dashboard password and follow the
|
||||
onboarding wizard to choose apps and Bitcoin storage settings. The unbundled
|
||||
ISO downloads app images as needed, so allow internet access for app setup.
|
||||
|
||||
For an existing Archipelago server, use the dashboard's **Settings** update
|
||||
flow for a published OTA rather than reinstalling. See the
|
||||
[user walkthrough](docs/user-walkthrough.md) for onboarding and daily use.
|
||||
|
||||
**This remains alpha software: funds are at your own risk, and production
|
||||
security hardening is still ahead.**
|
||||
|
||||
## What is here
|
||||
|
||||
- `core/` - Rust workspace: backend API, container runtime, security, OpenWrt
|
||||
|
||||
@@ -46,13 +46,14 @@ interface RateBucket {
|
||||
|
||||
const rateBuckets = new Map<string, RateBucket>()
|
||||
|
||||
// Clean up stale buckets every 5 minutes
|
||||
// Vite imports this module during builds too; cleanup must not keep the
|
||||
// process alive once compilation has finished.
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, bucket] of rateBuckets) {
|
||||
if (now > bucket.resetAt) rateBuckets.delete(key)
|
||||
}
|
||||
}, 5 * 60_000)
|
||||
}, 5 * 60_000).unref()
|
||||
|
||||
function getClientIp(req: IncomingMessage): string {
|
||||
return req.socket.remoteAddress ?? 'unknown'
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { archyBridge } from '@/services/archyBridge'
|
||||
const originalParent = window.parent
|
||||
const origin = 'https://node.example'
|
||||
afterEach(() => { archyBridge.destroy(); Object.defineProperty(window, 'parent', { value: originalParent, configurable: true }); vi.restoreAllMocks() })
|
||||
describe('trusted provider setup bridge', () => {
|
||||
it('accepts configuration only from the embedding parent, rejects siblings and other origins', () => {
|
||||
const parent = { postMessage: vi.fn() }
|
||||
Object.defineProperty(window, 'parent', { value: parent, configurable: true })
|
||||
archyBridge.init(origin)
|
||||
const listener = vi.fn(); const unsubscribe = archyBridge.onProviderConfigured(listener)
|
||||
const send = (source: unknown, from: string, provider = 'openai') => window.dispatchEvent(new MessageEvent('message', { source: source as Window, origin: from, data: { type: 'ai:provider-configured', provider, model: 'test-model' } }))
|
||||
send({}, origin); send(parent, 'https://evil.example'); send(parent, origin, 'arbitrary')
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
send(parent, origin)
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith({ provider: 'openai', model: 'test-model' })
|
||||
archyBridge.requestAISetup()
|
||||
expect(parent.postMessage).toHaveBeenLastCalledWith({ type: 'ai:setup-request' }, origin)
|
||||
unsubscribe()
|
||||
})
|
||||
it('replays the selection when the composer mounts after the handshake', () => {
|
||||
const parent = { postMessage: vi.fn() }; Object.defineProperty(window, 'parent', { value: parent, configurable: true })
|
||||
archyBridge.init(origin)
|
||||
window.dispatchEvent(new MessageEvent('message', { source: parent as unknown as Window, origin, data: { type: 'ai:provider-configured', provider: 'local' } }))
|
||||
const listener = vi.fn(); const unsubscribe = archyBridge.onProviderConfigured(listener)
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith({ provider: 'local', model: '' }); unsubscribe()
|
||||
})
|
||||
})
|
||||
@@ -61,6 +61,12 @@ function mockClaudeResponse(events: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
it('starts on Routstr before any saved provider selection', () => {
|
||||
setActivePinia(createPinia())
|
||||
const { activeProvider } = useAI()
|
||||
expect(activeProvider.value).toBe('routstr')
|
||||
})
|
||||
|
||||
describe('useAI', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -74,11 +80,6 @@ describe('useAI', () => {
|
||||
})
|
||||
|
||||
describe('provider selection', () => {
|
||||
it('defaults to claude provider', () => {
|
||||
const { activeProvider } = useAI()
|
||||
expect(activeProvider.value).toBe('claude')
|
||||
})
|
||||
|
||||
it('switches provider via setProvider', () => {
|
||||
const { setProvider, activeProvider, activeModel } = useAI()
|
||||
setProvider('openrouter')
|
||||
@@ -249,6 +250,24 @@ describe('useAI', () => {
|
||||
expect(chatStore.isStreaming).toBe(false)
|
||||
})
|
||||
|
||||
it.each([502, 503, 429, 401])('distinguishes HTTP %s from a missing credential', async (status) => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status, text: async () => 'Provider request failed' })
|
||||
const store = useChatStore(); store.webSearchEnabled = false
|
||||
const ai = useAI(); ai.needsApiKey.value = false
|
||||
await ai.sendMessage('test')
|
||||
expect(ai.needsApiKey.value).toBe(status === 401)
|
||||
expect(store.isStreaming).toBe(false)
|
||||
})
|
||||
|
||||
it('offers funding for a Routstr payment-required response without mislabeling it a key error', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 402, text: async () => JSON.stringify({ error: { message: 'Your spending allowance is exhausted' } }) })
|
||||
const store = useChatStore(); store.webSearchEnabled = false
|
||||
const ai = useAI(); ai.setProvider('routstr'); ai.needsApiKey.value = false; ai.needsFunding.value = false
|
||||
await ai.sendMessage('test')
|
||||
expect(ai.needsFunding.value).toBe(true); expect(ai.needsApiKey.value).toBe(false)
|
||||
expect(store.messages.find(m => m.role === 'assistant')?.content).toContain('spending allowance')
|
||||
})
|
||||
|
||||
it('handles connection errors gracefully', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network failure'))
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
:style="modelPickerDropdownStyle"
|
||||
@click.stop
|
||||
>
|
||||
<button v-if="archyBridge.isInArchy()" class="w-full text-left rounded-lg px-3 py-2 text-sm text-white/90 hover:bg-white/10" @click="showModelPicker = false; archyBridge.requestAISetup()">AI connection</button>
|
||||
<div v-for="provider in availableProviders" :key="provider.id">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider mb-1.5 px-1 text-white/40">
|
||||
{{ provider.name }}
|
||||
@@ -247,6 +248,7 @@ import { useAI } from '@/composables/useAI'
|
||||
import { useContentPanel } from '@/composables/useContentPanel'
|
||||
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
|
||||
import { parseImportFile } from '@/utils/conversation-import'
|
||||
import { archyBridge } from '@/services/archyBridge'
|
||||
import { useComparisonMode } from '@/composables/useComparisonMode'
|
||||
|
||||
defineProps<{
|
||||
@@ -332,8 +334,7 @@ const modelDisplayName = computed(() => {
|
||||
})
|
||||
|
||||
function selectModel(providerId: string, modelId: string) {
|
||||
setProvider(providerId as 'routstr' | 'claude' | 'openrouter' | 'mock')
|
||||
setModel(modelId)
|
||||
if (setProvider(providerId as Parameters<typeof setProvider>[0])) setModel(modelId)
|
||||
showModelPicker.value = false
|
||||
}
|
||||
|
||||
|
||||
@@ -186,8 +186,9 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey } = useAI()
|
||||
const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse, activeModel, needsApiKey, needsFunding } = useAI()
|
||||
const { updatePanelFromText, panelOpen, panelFilms, panelTitle, activeTab, availableTabs, setActiveTab, enterDesignSystemMode } = useContentPanel()
|
||||
import { archyBridge } from '@/services/archyBridge'
|
||||
import { useCodeContext } from '@/composables/useCodeContext'
|
||||
import { useVisualViewport } from '@/composables/useVisualViewport'
|
||||
const codeContext = useCodeContext()
|
||||
@@ -206,11 +207,19 @@ const showSettings = ref(false)
|
||||
// without fixing anything).
|
||||
watch(needsApiKey, (needs) => {
|
||||
if (needs) {
|
||||
showSettings.value = true
|
||||
if (archyBridge.isInArchy()) archyBridge.requestAISetup()
|
||||
else showSettings.value = true
|
||||
needsApiKey.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(needsFunding, needed => {
|
||||
if (!needed) return
|
||||
if (archyBridge.isInArchy()) archyBridge.requestAISetup('funding')
|
||||
else showSettings.value = true
|
||||
needsFunding.value = false
|
||||
})
|
||||
|
||||
// Scroll position memory per conversation
|
||||
const scrollPositions = new Map<string, number>()
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
>
|
||||
Run
|
||||
</button>
|
||||
<button v-if="isHtml && embedded" class="text-xs px-2.5 py-1 rounded bg-accent/15 text-accent/80" :disabled="preparingWebsite" @click="prepareWebsite">
|
||||
Continue to website setup
|
||||
</button>
|
||||
<button
|
||||
v-if="consoleOutput.length > 0"
|
||||
class="text-xs px-2 py-1 rounded bg-white/5 text-white/40 hover:text-white/60 hover:bg-white/10 transition-colors"
|
||||
@@ -19,6 +22,8 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="websiteError" role="alert" class="px-3 py-2 text-xs text-red-300">{{ websiteError }}</p>
|
||||
|
||||
<!-- Code display -->
|
||||
<pre class="px-3 py-2 text-xs text-white/70 overflow-x-auto max-h-48 bg-black/20"><code>{{ code }}</code></pre>
|
||||
|
||||
@@ -53,6 +58,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { archyBridge } from '@/services/archyBridge'
|
||||
|
||||
const props = defineProps<{
|
||||
code: string
|
||||
@@ -64,6 +70,18 @@ interface ConsoleEntry {
|
||||
text: string
|
||||
}
|
||||
|
||||
const embedded = window.parent !== window
|
||||
const preparingWebsite = ref(false)
|
||||
const websiteError = ref('')
|
||||
async function prepareWebsite() {
|
||||
preparingWebsite.value = true; websiteError.value = ''
|
||||
try {
|
||||
const result = await archyBridge.requestAction('prepare-website', { html: props.code })
|
||||
if (!result.success) websiteError.value = result.error || 'Could not open website setup'
|
||||
} catch (e) { websiteError.value = e instanceof Error ? e.message : 'Could not open website setup' }
|
||||
finally { preparingWebsite.value = false }
|
||||
}
|
||||
|
||||
const consoleOutput = ref<ConsoleEntry[]>([])
|
||||
const showIframe = ref(false)
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div v-if="embedded" class="space-y-3">
|
||||
<p class="text-sm">AI connections and private keys are managed by this node.</p>
|
||||
<button class="rounded-lg px-3 py-2 bg-white/10 text-sm" @click="archyBridge.requestAISetup()">Manage AI connection</button>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
|
||||
API Keys
|
||||
</h3>
|
||||
@@ -93,10 +97,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { archyBridge } from '@/services/archyBridge'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { storeApiKey, getApiKey, deleteApiKey, listProviders, maskApiKey } from '@/utils/key-vault'
|
||||
|
||||
const embedded = archyBridge.isInArchy()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
interface ProviderInfo {
|
||||
@@ -156,5 +162,5 @@ async function removeKey(provider: string) {
|
||||
await loadProviders()
|
||||
}
|
||||
|
||||
onMounted(loadProviders)
|
||||
onMounted(() => { if (!embedded) void loadProviders() })
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useCodeContext } from '@/composables/useCodeContext'
|
||||
import { apiFetch } from '@/utils/api-fetch'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock'
|
||||
type Provider = 'routstr' | 'claude' | 'openrouter' | 'mock' | 'openai' | 'auto' | 'local'
|
||||
|
||||
// API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/)
|
||||
const BASE = import.meta.env.BASE_URL || '/'
|
||||
@@ -120,34 +120,15 @@ Prioritize Podcasting 2.0–friendly platforms: Fountain.fm, Podcast Index, Cast
|
||||
Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out.
|
||||
${librarySection}`
|
||||
|
||||
const activeProvider = ref<Provider>('claude')
|
||||
const activeProvider = ref<Provider>('routstr')
|
||||
|
||||
const activeModel = ref('claude-haiku-4.5')
|
||||
const activeModel = ref('routstr-unavailable')
|
||||
|
||||
// One-shot signal a send/regenerate/edit failure looked like a missing or
|
||||
// invalid API key (or an unreachable proxy) rather than a transient/server
|
||||
// error — consumed by ChatWindow.vue to auto-open Settings so the user isn't
|
||||
// left in a dead end with no obvious next step. Deliberately narrow (401/403,
|
||||
// explicit "api key"/"unauthorized" text, or a connection-level failure to
|
||||
// reach the proxy at all) so a rate-limited or momentarily-flaky provider
|
||||
// response does NOT send the user to Settings for a problem Settings can't
|
||||
// fix. Reset to false by the consumer immediately after acting on it, so it
|
||||
// behaves as a pulse rather than sticky state (each new failure can re-fire).
|
||||
// Credentials require setup; network failures and provider outages require retry.
|
||||
const needsApiKey = ref(false)
|
||||
|
||||
const needsFunding = ref(false)
|
||||
function looksLikeMissingApiKey(err: string): boolean {
|
||||
const lower = err.toLowerCase()
|
||||
return (
|
||||
/\b(401|403)\b/.test(err) ||
|
||||
lower.includes('api key') ||
|
||||
lower.includes('x-api-key') ||
|
||||
lower.includes('unauthorized') ||
|
||||
lower.includes('authentication_error') ||
|
||||
lower.includes('failed to fetch') ||
|
||||
lower.includes('econnrefused') ||
|
||||
lower.includes(' 502') ||
|
||||
lower.includes(' 503')
|
||||
)
|
||||
return /\b(401|403)\b|api[ _-]?key|unauthorized|authentication_error|credential/i.test(err)
|
||||
}
|
||||
|
||||
// ─── Routstr model catalog (fetched from the node's session-gated proxy) ───
|
||||
@@ -161,7 +142,7 @@ async function refreshRoutstrModels() {
|
||||
routstrModelsFetched = true
|
||||
try {
|
||||
const res = await apiFetch(ROUTSTR_MODELS_PATH)
|
||||
if (!res.ok) return
|
||||
if (!res.ok) { routstrModelsFetched = false; return }
|
||||
const data = await res.json()
|
||||
if (Array.isArray(data?.data)) {
|
||||
routstrModels.value = data.data
|
||||
@@ -170,13 +151,21 @@ async function refreshRoutstrModels() {
|
||||
id: m.id as string,
|
||||
name: (m.name as string) || (m.id as string),
|
||||
}))
|
||||
}
|
||||
if (activeProvider.value === 'routstr' && activeModel.value === 'routstr-unavailable' && routstrModels.value[0]) activeModel.value = routstrModels.value[0].id
|
||||
} else { routstrModelsFetched = false }
|
||||
} catch {
|
||||
routstrModelsFetched = false // allow a retry on the next send/open
|
||||
}
|
||||
}
|
||||
|
||||
const availableProviders = computed(() => {
|
||||
if (archyBridge.isInArchy()) return [
|
||||
{ id: 'routstr' as Provider, name: 'Routstr (sats)', models: routstrModels.value.length ? routstrModels.value : [{ id: 'routstr-unavailable', name: 'Models unavailable — retry' }] },
|
||||
{ id: 'claude' as Provider, name: 'Claude API', models: [{ id: 'node', name: 'Node configuration' }] },
|
||||
{ id: 'openai' as Provider, name: 'OpenAI API', models: [{ id: activeProvider.value === 'openai' ? activeModel.value : 'node', name: activeProvider.value === 'openai' ? activeModel.value : 'Configure model' }] },
|
||||
{ id: 'auto' as Provider, name: 'Node AI', models: [{ id: 'node', name: 'Node configuration' }] },
|
||||
{ id: 'local' as Provider, name: 'Local AI', models: [{ id: 'node', name: 'Node configuration' }] },
|
||||
]
|
||||
const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
|
||||
{
|
||||
id: 'routstr',
|
||||
@@ -187,7 +176,7 @@ const availableProviders = computed(() => {
|
||||
},
|
||||
{
|
||||
id: 'claude',
|
||||
name: 'Claude (Max)',
|
||||
name: 'Claude API',
|
||||
models: [
|
||||
{ id: 'claude-haiku-4.5', name: 'Claude 4.5 Haiku' },
|
||||
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4' },
|
||||
@@ -207,24 +196,36 @@ const availableProviders = computed(() => {
|
||||
})
|
||||
providers.push({
|
||||
id: 'mock',
|
||||
name: 'Local (no API)',
|
||||
name: 'Demo echo',
|
||||
models: [{ id: 'echo', name: 'Echo (mirror input)' }],
|
||||
})
|
||||
return providers
|
||||
})
|
||||
|
||||
function setProvider(provider: Provider) {
|
||||
if (archyBridge.isInArchy()) {
|
||||
if (provider === 'routstr' && activeProvider.value === 'routstr') return true
|
||||
archyBridge.requestAISetup(); return false
|
||||
}
|
||||
activeProvider.value = provider
|
||||
const p = availableProviders.value.find((pp) => pp.id === provider)
|
||||
if (p && p.models.length > 0) {
|
||||
activeModel.value = p.models[0].id
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function setModel(model: string) {
|
||||
if (archyBridge.isInArchy() && activeProvider.value !== 'routstr') { archyBridge.requestAISetup(); return }
|
||||
activeModel.value = model
|
||||
}
|
||||
|
||||
archyBridge.onProviderConfigured(({ provider, model }) => {
|
||||
activeProvider.value = provider
|
||||
activeModel.value = model || (provider === 'routstr' ? routstrModels.value[0]?.id || 'routstr-unavailable' : 'node')
|
||||
if (provider === 'routstr') void refreshRoutstrModels()
|
||||
})
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
@@ -448,6 +449,7 @@ async function streamRoutstr(
|
||||
})
|
||||
|
||||
const bodyText = await res.text().catch(() => '')
|
||||
if (res.status === 402) needsFunding.value = true
|
||||
if (!res.ok) {
|
||||
// The node's refusals carry a plain-language error.message (budget not
|
||||
// set, budget spent, wallet can't fund) — surface it verbatim.
|
||||
@@ -974,5 +976,6 @@ export function useAI() {
|
||||
setProvider,
|
||||
setModel,
|
||||
needsApiKey,
|
||||
needsFunding,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ const PWA_CACHE_VERSION = '2'
|
||||
// Only embedded when explicitly requested via ?embedded param
|
||||
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
|
||||
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
|
||||
document.documentElement.classList.toggle('aiui-embedded', _embeddedFlag)
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div
|
||||
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
|
||||
:class="[]"
|
||||
:style="isDark
|
||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||
: isEmbedded
|
||||
? { background: 'transparent' }
|
||||
:style="isEmbedded
|
||||
? { background: 'transparent' }
|
||||
: isDark
|
||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||
: { backgroundColor: '#f5f4f1' }"
|
||||
>
|
||||
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||
<div v-if="isDark && !isEmbedded" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||
|
||||
<!-- Desktop layout -->
|
||||
<div
|
||||
|
||||
@@ -55,6 +55,10 @@ interface ThemeInfo {
|
||||
type PermissionsCallback = (categories: AIContextCategory[]) => void
|
||||
type ThemeCallback = (theme: ThemeInfo) => void
|
||||
|
||||
export interface AIProviderSelection { provider: 'auto' | 'local' | 'claude' | 'openai' | 'routstr'; model: string }
|
||||
const providerCallbacks = new Set<(selection: AIProviderSelection) => void>()
|
||||
let currentProvider: AIProviderSelection | null = null
|
||||
|
||||
let requestId = 0
|
||||
const pendingRequests = new Map<string, {
|
||||
resolve: (value: unknown) => void
|
||||
@@ -80,12 +84,19 @@ function postToParent(msg: unknown) {
|
||||
|
||||
function handleMessage(event: MessageEvent) {
|
||||
// Always validate origin — reject if not configured or mismatched
|
||||
if (!allowedOrigin || event.origin !== allowedOrigin) return
|
||||
if (!allowedOrigin || event.origin !== allowedOrigin || event.source !== window.parent) return
|
||||
|
||||
const msg = event.data
|
||||
if (!msg || typeof msg.type !== 'string') return
|
||||
|
||||
switch (msg.type) {
|
||||
case 'ai:provider-configured': {
|
||||
if (!['auto', 'local', 'claude', 'openai', 'routstr'].includes(msg.provider)) break
|
||||
const selection = { provider: msg.provider as AIProviderSelection['provider'], model: typeof msg.model === 'string' ? msg.model : '' }
|
||||
currentProvider = selection
|
||||
for (const callback of providerCallbacks) callback(selection)
|
||||
break
|
||||
}
|
||||
case 'context:response': {
|
||||
const pending = pendingRequests.get(msg.id)
|
||||
if (pending) {
|
||||
@@ -223,11 +234,21 @@ export const archyBridge = {
|
||||
}
|
||||
},
|
||||
|
||||
requestAISetup(reason?: 'funding') { postToParent({ type: 'ai:setup-request', ...(reason ? { reason } : {}) }) },
|
||||
|
||||
onProviderConfigured(callback: (selection: AIProviderSelection) => void) {
|
||||
providerCallbacks.add(callback)
|
||||
if (currentProvider) callback(currentProvider)
|
||||
return () => { providerCallbacks.delete(callback) }
|
||||
},
|
||||
|
||||
/** Clean up listeners */
|
||||
destroy() {
|
||||
window.removeEventListener('message', handleMessage)
|
||||
pendingRequests.clear()
|
||||
initialized = false
|
||||
currentProvider = null
|
||||
allowedOrigin = null
|
||||
},
|
||||
|
||||
/** Check if running inside Archy iframe */
|
||||
|
||||
@@ -57,12 +57,8 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
/* Every page paints its own explicit background (bg-[#0a0a0a] / bg-[#faf9f6])
|
||||
EXCEPT the embedded Chat page, which intentionally goes transparent so
|
||||
Archy's own dark chrome can show behind it (Chat.vue's iframe host). With
|
||||
no background-color here, "transparent" fell through to the browser's
|
||||
default white canvas instead. Match the theme's own dark/light default so
|
||||
nothing above this ever needs to guess. */
|
||||
/* Standalone canvas fallback. Embedded mode overrides this below so
|
||||
Archy's wallpaper remains visible through the iframe. */
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
@@ -70,6 +66,19 @@ html.light body {
|
||||
background-color: #faf9f6;
|
||||
}
|
||||
|
||||
/* The host owns the wallpaper when AIUI is embedded. The document canvas
|
||||
must be transparent too, otherwise it hides the host behind ChatPage. */
|
||||
html.aiui-embedded {
|
||||
/* Match Archy's dark canvas scheme. Browsers otherwise give an iframe
|
||||
with a different scheme an opaque canvas despite transparent CSS. */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.aiui-embedded,
|
||||
html.aiui-embedded body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
|
||||
|
||||
@layer components {
|
||||
|
||||
+101
-5
@@ -378,13 +378,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
@@ -436,13 +436,13 @@
|
||||
{
|
||||
"id": "nginx-proxy-manager",
|
||||
"title": "Nginx Proxy Manager",
|
||||
"version": "2.12.1",
|
||||
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
|
||||
"version": "2.14.0",
|
||||
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. The node's public web server forwards configured domains through this service, preserving its access lists, certificates and custom routes.",
|
||||
"icon": "/assets/img/app-icons/nginx.svg",
|
||||
"author": "Nginx Proxy Manager",
|
||||
"category": "networking",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08",
|
||||
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
|
||||
},
|
||||
{
|
||||
@@ -644,6 +644,102 @@
|
||||
"/var/lib/archipelago/vaultwarden:/data"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "angor-indexer",
|
||||
"title": "Angor Indexer",
|
||||
"version": "1.0.2",
|
||||
"description": "Bitcoin indexer endpoint for Angor with the existing Mempool explorer. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.2",
|
||||
"author": "Angor / Archipelago",
|
||||
"requires": [
|
||||
"Mempool API",
|
||||
"Unpruned Bitcoin"
|
||||
],
|
||||
"category": "money",
|
||||
"tier": "optional",
|
||||
"icon": "/assets/img/app-icons/angor-green.png",
|
||||
"repoUrl": "https://github.com/block-core/angor"
|
||||
},
|
||||
{
|
||||
"id": "angor-relay",
|
||||
"title": "Angor Relay",
|
||||
"version": "1.1.2",
|
||||
"description": "Optional dedicated Nostr relay for Angor project metadata. Separate storage and access settings keep the node’s internal relay private. Add this service’s address to Angor’s relay settings; use WSS for browser clients.",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/angor-relay:1.1.2",
|
||||
"author": "Angor / Archipelago",
|
||||
"requires": [],
|
||||
"category": "nostr",
|
||||
"tier": "optional",
|
||||
"icon": "/assets/img/app-icons/angor-green.png",
|
||||
"repoUrl": "https://github.com/hoytech/strfry"
|
||||
},
|
||||
{
|
||||
"id": "justworks",
|
||||
"title": "Just Works",
|
||||
"version": "0.1.0",
|
||||
"description": "Turn your existing business links into a website with Just Works. Open your website editor and business tools from one lightweight launcher. Uses the hosted Just Works services; an internet connection is required.",
|
||||
"icon": "/assets/img/app-icons/justworks.svg",
|
||||
"author": "Just Works contributors",
|
||||
"category": "business",
|
||||
"tier": "optional",
|
||||
"repoUrl": "https://github.com/bencoin21/justworks.cash",
|
||||
"dockerImage": "localhost/archipelago-justworks:0.1.0"
|
||||
},
|
||||
{
|
||||
"id": "datum",
|
||||
"author": "OCEAN contributors",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
],
|
||||
"title": "DATUM",
|
||||
"version": "0.4.1-beta.1",
|
||||
"description": "Build Bitcoin mining templates on your own node and connect your miners to OCEAN through DATUM.",
|
||||
"dockerImage": "localhost/archipelago-datum:0.4.1-beta.1",
|
||||
"category": "bitcoin",
|
||||
"tier": "optional",
|
||||
"icon": "/assets/img/app-icons/datum.svg",
|
||||
"repoUrl": "https://github.com/OCEAN-xyz/datum_gateway"
|
||||
},
|
||||
{
|
||||
"id": "gashboard",
|
||||
"author": "Gashboard contributors",
|
||||
"requires": [
|
||||
"datum"
|
||||
],
|
||||
"title": "Gashboard",
|
||||
"version": "0.2.1",
|
||||
"description": "A playful mining dashboard for your DATUM fleet, with live hashrates, share history, lottery odds, chat and a Nostr viewer access list.",
|
||||
"dockerImage": "localhost/archipelago-gashboard:0.2.1",
|
||||
"category": "bitcoin",
|
||||
"tier": "optional",
|
||||
"icon": "/assets/img/app-icons/gashboard.svg",
|
||||
"repoUrl": "https://gitworkshop.dev/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy"
|
||||
},
|
||||
{
|
||||
"id": "blossom",
|
||||
"author": "hzrd149 / Archipelago",
|
||||
"requires": [],
|
||||
"tier": "optional",
|
||||
"title": "Blossom",
|
||||
"version": "6.4.1-archy.2",
|
||||
"description": "Local file storage for Nostr and websites, using your Archipelago signer. External publishing is a separate explicit choice.",
|
||||
"dockerImage": "localhost/archipelago-blossom:6.4.1-archy.2",
|
||||
"category": "data",
|
||||
"repoUrl": "https://github.com/hzrd149/blossom-server",
|
||||
"icon": "/assets/img/app-icons/blossom.svg"
|
||||
},
|
||||
{
|
||||
"id": "public-web-router",
|
||||
"author": "Archipelago",
|
||||
"requires": [],
|
||||
"tier": "optional",
|
||||
"title": "Public Web Router",
|
||||
"version": "0.1.0",
|
||||
"description": "Connect explicitly published websites to your own public gateway. HTTPS keys stay on this node. Configure routes through Setup.",
|
||||
"dockerImage": "localhost/archipelago-public-web-router:0.1.0",
|
||||
"category": "networking",
|
||||
"icon": "/assets/img/app-icons/nginx.svg"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -91,3 +91,5 @@ Adding a new app requires updates in multiple places:
|
||||
## Port Assignments
|
||||
|
||||
See [PORTS.md](./PORTS.md) for complete mapping. Dev ports are offset by +10000.
|
||||
|
||||
Before submitting an app, complete **Launch acceptance: credentials, signer, and HTTP nodes** in `docs/app-developer-guide.md`. A generated password needs an authenticated credential interstitial; native Nostr login needs a tested first-launch chooser. Container health alone is not launch acceptance.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Angor Indexer
|
||||
|
||||
Mainnet indexer endpoint for Angor, serving the existing Mempool explorer at
|
||||
the same origin. The service reuses this node's Mempool frontend/backend and
|
||||
Electrum index instead of creating another explorer or blockchain database.
|
||||
An unpruned, fully synced Bitcoin node is required. Installing against a pruned
|
||||
node must show the existing archival-node requirement; it must never silently
|
||||
unprune or replace its Bitcoin data.
|
||||
|
||||
## Connect Angor
|
||||
|
||||
Install **Angor Indexer** in the store. Its API appears under **Services**.
|
||||
In Angor settings, use `http://<node-address>:8998/` as the custom indexer origin.
|
||||
The `/health` endpoint reports readiness against Mempool's indexed block height;
|
||||
it returns 503 while that backend is unavailable. Index building may take time.
|
||||
|
||||
Browser clients require a reachable HTTPS origin with a trusted certificate.
|
||||
Configure your HTTPS reverse proxy to forward to port 8998, then use that HTTPS
|
||||
origin in Angor. Do not disable browser TLS checks. The API supports both
|
||||
`/api/v1/` and `/api/` paths, transaction broadcast, and CORS without cookies.
|
||||
|
||||
This endpoint intentionally exposes public blockchain queries and transaction
|
||||
broadcast through the app gate without dashboard-cookie login. It has no Bitcoin
|
||||
RPC password, wallet keys, or persistent wallet data. The backend stays on the
|
||||
managed container network; its private port does not become publicly exposed.
|
||||
You can change network access using the node's normal access controls.
|
||||
|
||||
## Relay
|
||||
|
||||
A relay is optional. Angor can continue using its configured external relays.
|
||||
Install **Angor Relay** separately to host project metadata locally, then add
|
||||
`ws://<node-address>:8091/` in Angor, or a trusted `wss://` proxy origin for browser
|
||||
clients. Its storage and configuration are separate from the node's internal
|
||||
relay; installing or uninstalling it does not change the internal relay.
|
||||
|
||||
## Verify the complete client flow
|
||||
|
||||
The root URL opens the Mempool explorer. `/health` and fee
|
||||
estimates establish API availability; they do not prove that project discovery,
|
||||
address history, or browser CORS works. Test a known funded project's address
|
||||
history, its original Nostr announcement, the Explore page, and project details
|
||||
in the actual Angor client. A certificate alone does not establish public routing.
|
||||
|
||||
Keep existing discovery relays when adding a new relay. A new relay has no
|
||||
historical project data and does not automatically replicate other relays.
|
||||
Even with existing relays, an empty Explore page can be a client discovery
|
||||
failure: Angor Hub v2.0.0 was observed to stop after a batch whose announcements
|
||||
all failed on-chain validation. The same failure reproduced with our indexer
|
||||
and Angor's public indexer. Do not bypass the funding transaction's event-ID
|
||||
commitment or substitute an unsigned announcement to make a project appear.
|
||||
|
||||
For opt-in read-only browser acceptance, install the frontend test dependencies
|
||||
and Playwright Chromium, then run:
|
||||
|
||||
```sh
|
||||
ANGOR_TEST_INDEXER=https://indexer.example.com/ \
|
||||
ANGOR_TEST_RELAY=wss://relay.example.com/ \
|
||||
ANGOR_TEST_RELAYS='["wss://relay.angor.io","wss://relay.example.com/"]' \
|
||||
node tests/lifecycle/angor-public-browser.cjs
|
||||
```
|
||||
|
||||
The relay under test must already contain the known original public project
|
||||
announcement documented in the test. The test does not import events, send
|
||||
funds, change your browser profile, or disable TLS verification. It checks the
|
||||
funding transaction/event commitment and real browser discovery and details.
|
||||
Relay signed writes, invalid-signature rejection, persistence, full node sync,
|
||||
and proxy upgrade/renewal tests remain separate acceptance requirements.
|
||||
|
||||
## Packaging
|
||||
|
||||
Build the pinned image with:
|
||||
|
||||
```
|
||||
podman build -t source.archipelago-foundation.org/chaum/angor-indexer:1.0.2 apps/angor-indexer/container
|
||||
```
|
||||
|
||||
The image runs as UID 101 with a read-only root filesystem and no capabilities.
|
||||
Only temporary nginx state is writable. Runtime DNS is read from resolv.conf so
|
||||
Mempool recreation does not require editing IP addresses or restarting this app.
|
||||
No app-specific Rust installer is required.
|
||||
|
||||
Source documentation: [Angor's official deployment guide](https://github.com/block-core/angor/blob/869dd43cf38332dd7128a284a6bf4c1cac44c1a7/docker/DEPLOY-INDEXER-AND-RELAY.md).
|
||||
The app icon is based on [Angor’s dark-mode app icon](https://angor.io/images/app-icon-dark-mode.png), retrieved 2026-09-30. At the operator’s request, the outer corners use the same green as the background. The built-in imagegen edit preserved the black mark and filled the square green; the project asset is `neode-ui/public/assets/img/app-icons/angor-green.png`.
|
||||
|
||||
Tests and release acceptance are recorded in the next-release checklist. The
|
||||
health probe establishes backend availability, not a guarantee that every
|
||||
address query is indexed at the latest Bitcoin tip.
|
||||
|
||||
Install Mempool Explorer first. The declarative `install_prerequisites` check
|
||||
refuses a new adapter installation if its Mempool API component is absent, before
|
||||
creating an installed-app record. It does not install or resync Bitcoin for you.
|
||||
|
||||
## Explorer on the public indexer origin
|
||||
|
||||
The linked official deployment guide exposes **Mempool frontend and API together**
|
||||
on the public indexer URL. It uses standard Mempool images and requires no custom
|
||||
Angor fork or `ANGOR_ENABLED` flag.
|
||||
|
||||
The operator now requires that same browser experience: opening the configured
|
||||
indexer domain must show the existing Mempool explorer, while Angor API requests
|
||||
continue working on that origin. Reuse the existing Mempool stack, including its
|
||||
live WebSocket feed; do not install a second explorer or blockchain database.
|
||||
|
||||
**Candidate 1.0.2:** `/` and frontend paths proxy to the existing Mempool
|
||||
frontend; `/api/`, `/api/v1/`, `/health` and the WebSocket feed retain their
|
||||
indexer routes. Version 1.0.1 served only service JSON at `/`. The candidate
|
||||
remains pending deployment/release acceptance, which must cover assets and deep links,
|
||||
desktop/mobile rendering, WebSocket updates, API/CORS/broadcast, trusted HTTPS,
|
||||
restart/upgrade and management-access isolation before documenting it as shipped.
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM docker.io/library/nginx:1.31.3-alpine@sha256:1d40e3eb3bf4f138de1d67193f2aa5309fcaf343eb5ffadbf5e9439de1eb1ebb
|
||||
COPY nginx.conf /etc/angor-nginx.conf.template
|
||||
COPY entrypoint.sh /usr/local/bin/angor-indexer
|
||||
USER 101:101
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/angor-indexer"]
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
# Resolve through the container runtime's DNS, including after dependency
|
||||
# recreation. Never bake a container IP into the indexer endpoint.
|
||||
DNS_RESOLVER=$(awk '/^nameserver[[:space:]]/ {print $2; exit}' /etc/resolv.conf)
|
||||
case "$DNS_RESOLVER" in
|
||||
''|*[!0-9a-fA-F.:]*) echo 'Container DNS resolver is unavailable' >&2; exit 1 ;;
|
||||
esac
|
||||
case "$DNS_RESOLVER" in *:*) DNS_RESOLVER="[$DNS_RESOLVER]" ;; esac
|
||||
export DNS_RESOLVER
|
||||
envsubst '${DNS_RESOLVER}' < /etc/angor-nginx.conf.template > /tmp/nginx.conf
|
||||
exec nginx -c /tmp/nginx.conf -g 'daemon off;'
|
||||
@@ -0,0 +1,81 @@
|
||||
worker_processes 1;
|
||||
pid /tmp/nginx.pid;
|
||||
error_log /dev/stderr warn;
|
||||
events { worker_connections 512; }
|
||||
http {
|
||||
access_log off;
|
||||
server_tokens off;
|
||||
client_body_temp_path /tmp/client_temp;
|
||||
proxy_temp_path /tmp/proxy_temp;
|
||||
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||
scgi_temp_path /tmp/scgi_temp;
|
||||
resolver ${DNS_RESOLVER} valid=10s ipv6=off;
|
||||
upstream mempool_backend {
|
||||
zone mempool_backend 64k;
|
||||
server mempool-api:8999 resolve;
|
||||
}
|
||||
upstream mempool_frontend {
|
||||
zone mempool_frontend 64k;
|
||||
server mempool:8080 resolve;
|
||||
}
|
||||
map $http_upgrade $angor_connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
server {
|
||||
listen 8080;
|
||||
client_max_body_size 4m;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Connection $angor_connection_upgrade;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Authorization "";
|
||||
proxy_set_header Cookie "";
|
||||
proxy_hide_header Access-Control-Allow-Origin;
|
||||
add_header Access-Control-Allow-Origin '*' always;
|
||||
add_header Access-Control-Allow-Methods 'GET, HEAD, POST, OPTIONS' always;
|
||||
add_header Access-Control-Allow-Headers 'Content-Type' always;
|
||||
add_header Cache-Control 'no-store' always;
|
||||
if ($request_method = OPTIONS) { return 204; }
|
||||
# Mempool's backend uses /api/v1. Match its frontend's shorter /api
|
||||
# surface too, without doubling already-versioned Angor URLs.
|
||||
rewrite ^/api/(?!v1/)(.*)$ /api/v1/$1 last;
|
||||
# Readiness checks the indexing backend, not this gateway's process.
|
||||
location = /health {
|
||||
limit_except GET { deny all; }
|
||||
proxy_pass http://mempool_backend/api/v1/blocks/tip/height;
|
||||
proxy_intercept_errors on;
|
||||
error_page 500 502 503 504 =503 @waiting;
|
||||
}
|
||||
location @waiting {
|
||||
default_type application/json;
|
||||
return 503 '{"status":"waiting","message":"Waiting for Bitcoin and Mempool indexing"}\n';
|
||||
}
|
||||
location ~ ^/api/(v1/)?tx$ {
|
||||
limit_except GET POST { deny all; }
|
||||
proxy_pass http://mempool_backend;
|
||||
}
|
||||
location = /api/v1/ws {
|
||||
limit_except GET { deny all; }
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_pass http://mempool_backend;
|
||||
}
|
||||
location /api/ {
|
||||
limit_except GET { deny all; }
|
||||
proxy_pass http://mempool_backend;
|
||||
}
|
||||
# Share the already-installed explorer; no second frontend or index DB.
|
||||
# Its SPA handles transaction/block deep links and static assets.
|
||||
location / {
|
||||
limit_except GET { deny all; }
|
||||
proxy_pass http://mempool_frontend;
|
||||
proxy_intercept_errors on;
|
||||
error_page 500 502 503 504 =503 @waiting;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
app:
|
||||
id: angor-indexer
|
||||
name: Angor Indexer
|
||||
version: 1.0.2
|
||||
description: Bitcoin indexer endpoint for Angor with the existing Mempool explorer.
|
||||
Reuses this node’s Mempool
|
||||
and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s
|
||||
address as the custom indexer in Angor settings. A relay is optional and installed
|
||||
separately.
|
||||
category: money
|
||||
install_prerequisites:
|
||||
- mempool
|
||||
- mempool-api
|
||||
upstream:
|
||||
kind: github
|
||||
repo: block-core/angor
|
||||
container:
|
||||
image: source.archipelago-foundation.org/chaum/angor-indexer:1.0.2
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
dependencies:
|
||||
- app_id: mempool
|
||||
version: '>=3.0.0'
|
||||
- app_id: mempool-api
|
||||
version: '>=3.0.0'
|
||||
- bitcoin:archival
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 128Mi
|
||||
disk_limit: 128Mi
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 101
|
||||
network_policy: isolated
|
||||
ports:
|
||||
- host: 8998
|
||||
container: 8080
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: open
|
||||
auth_rationale: Public Bitcoin chain-data API and validated transaction broadcast for Angor clients; no wallet keys or node RPC credentials are exposed. Browser cookie login would break machine clients.
|
||||
interfaces:
|
||||
main:
|
||||
name: Angor Indexer API
|
||||
description: Use this origin as Angor’s custom mainnet indexer URL, or open it
|
||||
to view the existing Mempool explorer. HTTPS is required for browser clients.
|
||||
type: api
|
||||
port: 8998
|
||||
protocol: http
|
||||
path: /
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 8s
|
||||
retries: 3
|
||||
bitcoin_integration:
|
||||
rpc_access: none
|
||||
sync_required: true
|
||||
pruning_support: false
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/angor-green.png
|
||||
tier: optional
|
||||
repo: https://github.com/block-core/angor
|
||||
features:
|
||||
- Angor mainnet API
|
||||
- Mempool explorer on the same origin
|
||||
- Reuses existing Mempool indexing
|
||||
- No separate blockchain database
|
||||
- Optional independent relay
|
||||
@@ -0,0 +1,33 @@
|
||||
# Angor Relay
|
||||
|
||||
Optional standalone strfry relay for Angor's public project metadata. See
|
||||
[Angor Indexer setup](../angor-indexer/README.md) for client URLs and HTTPS/WSS.
|
||||
|
||||
The gate exposes port 8091 for Nostr clients. strfry validates event signatures;
|
||||
this is a public relay, not a private messaging archive. It mounts only
|
||||
`/var/lib/archipelago/angor-relay` and its separate configuration directory.
|
||||
It never opens, reconfigures or shares the node's internal strfry database.
|
||||
|
||||
For a public domain, proxy HTTPS to node port **8091**, enable WebSocket upgrade,
|
||||
and add `wss://your-relay-domain/` in Angor. Test both NIP-11 metadata (send
|
||||
`Accept: application/nostr+json`) and a real Nostr subscription over WSS. An
|
||||
Archipelago login page at this domain is a routing failure, not relay readiness.
|
||||
|
||||
New relays start without project history. Keep existing discovery relays alongside
|
||||
yours until the needed original signed announcements and metadata are available
|
||||
locally. Relays do not automatically synchronize. Any history import must retain
|
||||
the original event IDs and signatures; verify funded projects against their
|
||||
on-chain commitments. A working WebSocket with zero stored events is not proof
|
||||
that the client's project discovery works. See the indexer README's browser test.
|
||||
|
||||
The configuration is seeded only when absent, preserving operator changes.
|
||||
Stop the service before making a consistent backup of its event database.
|
||||
Ordinary start/restart/recreation preserves both mounts. Use the standard app
|
||||
lifecycle; do not manually recreate a systemd-managed container.
|
||||
|
||||
## Image provenance
|
||||
|
||||
Mirrored from `docker.io/dockurr/strfry:1.1.2`, upstream manifest digest
|
||||
`sha256:e81d238db13507f6ef24c49d47cd0b0ea58ff207961f10581fa2a7c901054df4`.
|
||||
The public Angor policy is supplied by this app's own configuration; it does not
|
||||
reuse the internal relay's event whitelist.
|
||||
@@ -0,0 +1,223 @@
|
||||
app:
|
||||
id: angor-relay
|
||||
name: Angor Relay
|
||||
version: 1.1.2
|
||||
upstream:
|
||||
kind: github
|
||||
repo: hoytech/strfry
|
||||
description: Optional dedicated Nostr relay for Angor project metadata. Separate
|
||||
storage and access settings keep the node’s internal relay private. Add this service’s
|
||||
address to Angor’s relay settings; use WSS for browser clients.
|
||||
container:
|
||||
image: source.archipelago-foundation.org/chaum/angor-relay:1.1.2
|
||||
pull_policy: if-not-present
|
||||
dependencies:
|
||||
- storage: 5Gi
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 5Gi
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: nostr-relay
|
||||
ports:
|
||||
- host: 8091
|
||||
container: 7777
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: open
|
||||
auth_rationale: Dedicated public Nostr relay for Angor project metadata; strfry verifies event signatures. It has separate storage from the private node relay and no wallet or node credentials.
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/angor-relay
|
||||
target: /app/strfry-db
|
||||
options:
|
||||
- rw
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/angor-relay-config/angor-relay.conf
|
||||
target: /etc/strfry.conf
|
||||
options:
|
||||
- ro
|
||||
files:
|
||||
- path: /var/lib/archipelago/angor-relay-config/angor-relay.conf
|
||||
overwrite: false
|
||||
content: |
|
||||
##
|
||||
## Default strfry config
|
||||
##
|
||||
|
||||
# Directory that contains the strfry LMDB database (restart required)
|
||||
db = "./strfry-db/"
|
||||
|
||||
dbParams {
|
||||
# Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required)
|
||||
maxreaders = 256
|
||||
|
||||
# Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required)
|
||||
mapsize = 10995116277760
|
||||
|
||||
# Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required)
|
||||
noReadAhead = false
|
||||
}
|
||||
|
||||
events {
|
||||
# Maximum size of normalised JSON, in bytes
|
||||
maxEventSize = 65536
|
||||
|
||||
# Events newer than this will be rejected
|
||||
rejectEventsNewerThanSeconds = 900
|
||||
|
||||
# Events older than this will be rejected
|
||||
rejectEventsOlderThanSeconds = 94608000
|
||||
|
||||
# Ephemeral events older than this will be rejected
|
||||
rejectEphemeralEventsOlderThanSeconds = 60
|
||||
|
||||
# Ephemeral events will be deleted from the DB when older than this
|
||||
ephemeralEventsLifetimeSeconds = 300
|
||||
|
||||
# Maximum number of tags allowed
|
||||
maxNumTags = 2000
|
||||
|
||||
# Maximum size for tag values, in bytes
|
||||
maxTagValSize = 1024
|
||||
}
|
||||
|
||||
relay {
|
||||
# Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required)
|
||||
bind = "0.0.0.0"
|
||||
|
||||
# Port to open for the nostr websocket protocol (restart required)
|
||||
port = 7777
|
||||
|
||||
# Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)
|
||||
nofiles = 0
|
||||
|
||||
# HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)
|
||||
realIpHeader = ""
|
||||
|
||||
info {
|
||||
# NIP-11: Name of this server. Short/descriptive (< 30 characters)
|
||||
name = "Angor Relay"
|
||||
|
||||
# NIP-11: Detailed information about relay, free-form
|
||||
description = "Dedicated public relay for Angor project metadata."
|
||||
|
||||
# NIP-11: Administrative nostr pubkey, for contact purposes
|
||||
pubkey = ""
|
||||
|
||||
# NIP-11: Alternative administrative contact (email, website, etc)
|
||||
contact = ""
|
||||
|
||||
# NIP-11: URL pointing to an image to be used as an icon for the relay
|
||||
icon = ""
|
||||
|
||||
# List of supported lists as JSON array, or empty string to use default. Example: "[1,2]"
|
||||
nips = ""
|
||||
}
|
||||
|
||||
# Maximum accepted incoming websocket frame size (should be larger than max event) (restart required)
|
||||
maxWebsocketPayloadSize = 131072
|
||||
|
||||
# Maximum number of filters allowed in a REQ
|
||||
maxReqFilterSize = 200
|
||||
|
||||
# Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required)
|
||||
autoPingSeconds = 55
|
||||
|
||||
# If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy)
|
||||
enableTcpKeepalive = false
|
||||
|
||||
# How much uninterrupted CPU time a REQ query should get during its DB scan
|
||||
queryTimesliceBudgetMicroseconds = 10000
|
||||
|
||||
# Maximum records that can be returned per filter
|
||||
maxFilterLimit = 500
|
||||
|
||||
# Maximum number of subscriptions (concurrent REQs) a connection can have open at any time
|
||||
maxSubsPerConnection = 20
|
||||
|
||||
writePolicy {
|
||||
# If non-empty, path to an executable script that implements the writePolicy plugin logic
|
||||
plugin = ""
|
||||
}
|
||||
|
||||
compression {
|
||||
# Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required)
|
||||
enabled = true
|
||||
|
||||
# Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required)
|
||||
slidingWindow = true
|
||||
}
|
||||
|
||||
logging {
|
||||
# Dump all incoming messages
|
||||
dumpInAll = false
|
||||
|
||||
# Dump all incoming EVENT messages
|
||||
dumpInEvents = false
|
||||
|
||||
# Dump all incoming REQ/CLOSE messages
|
||||
dumpInReqs = false
|
||||
|
||||
# Log performance metrics for initial REQ database scans
|
||||
dbScanPerf = false
|
||||
|
||||
# Log reason for invalid event rejection? Can be disabled to silence excessive logging
|
||||
invalidEvents = true
|
||||
}
|
||||
|
||||
numThreads {
|
||||
# Ingester threads: route incoming requests, validate events/sigs (restart required)
|
||||
ingester = 3
|
||||
|
||||
# reqWorker threads: Handle initial DB scan for events (restart required)
|
||||
reqWorker = 3
|
||||
|
||||
# reqMonitor threads: Handle filtering of new events (restart required)
|
||||
reqMonitor = 3
|
||||
|
||||
# negentropy threads: Handle negentropy protocol messages (restart required)
|
||||
negentropy = 2
|
||||
}
|
||||
|
||||
negentropy {
|
||||
# Support negentropy protocol messages
|
||||
enabled = true
|
||||
|
||||
# Maximum records that sync will process before returning an error
|
||||
maxSyncEvents = 1000000
|
||||
}
|
||||
}
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:7777
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
nostr_integration:
|
||||
relay_type: public
|
||||
monetization_enabled: false
|
||||
category: nostr
|
||||
interfaces:
|
||||
main:
|
||||
name: Angor Relay
|
||||
description: Nostr WebSocket endpoint; use ws:// for LAN or wss:// through your
|
||||
HTTPS domain.
|
||||
type: api
|
||||
port: 8091
|
||||
protocol: http
|
||||
path: /
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/angor-green.png
|
||||
tier: optional
|
||||
repo: https://github.com/hoytech/strfry
|
||||
features:
|
||||
- Angor project metadata
|
||||
- Separate from the node relay
|
||||
- Persistent Nostr event storage
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: archy-mempool-web
|
||||
name: Mempool Web
|
||||
version: 3.0.1
|
||||
version: 3.3.1-archy1
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -12,7 +12,7 @@ app:
|
||||
container_name: mempool
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
|
||||
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
|
||||
@@ -45,7 +45,9 @@ app:
|
||||
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
|
||||
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /
|
||||
# Probe the backend through nginx: a static page can be healthy while
|
||||
# every API/WebSocket request is stuck on a dead backend address.
|
||||
path: /api/v1/backend-info
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -54,7 +54,7 @@ app:
|
||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
|
||||
@@ -60,7 +60,7 @@ app:
|
||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||
fi;
|
||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
else
|
||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Blossom on Archipelago
|
||||
|
||||
Candidate package, not a published catalogue release. Follow
|
||||
[`docs/app-developer-guide.md`](../../docs/app-developer-guide.md) and
|
||||
[`docs/candidate-catalog-qualification.md`](../../docs/candidate-catalog-qualification.md)
|
||||
for lifecycle and catalogue acceptance.
|
||||
|
||||
## Package contract
|
||||
|
||||
- MIT upstream `hzrd149/blossom-server` 6.4.1, source commit
|
||||
`a492dc61c4a581bbd0992546b2aec6f9aa543f75`. The Dockerfile verifies the source
|
||||
archive SHA-256 and uses upstream's frozen dependency lock for the server.
|
||||
- Manifest-owned local build; the runtime payload must include `docker/blossom`.
|
||||
No unpublished registry image is advertised. Initial installation needs access
|
||||
to the open-source build dependencies; normal startup uses cached dependencies.
|
||||
- Rootless container, read-only root, no capabilities, no new privileges,
|
||||
explicit `slirp4netns`. Host port 8191 binds IPv4 loopback behind AppGate.
|
||||
Keep that private backend binding: any FIPS/IPv6 ingress belongs at the gate.
|
||||
- Persistent data and SQLite under `/var/lib/archipelago/blossom/data`.
|
||||
Preserve this directory on uninstall. No automatic expiry/pruning, automatic
|
||||
mirroring, media conversion, or upstream administration dashboard.
|
||||
- Uploads require BUD-11 signatures from the profile identities supplied by
|
||||
`{{NODE_IDENTITY_PUBKEYS}}`. No profile means startup fails closed. Changes to
|
||||
that allowlist take effect on restart, including revocation of removed profiles.
|
||||
The appliance identity is excluded. Listing requires the owner's signature.
|
||||
- The custom local UI loads the canonical, host-managed `nostr-provider.js`
|
||||
through the documented lifecycle hook. A missing provider fails verification.
|
||||
The UI uses the platform identity chooser and ordinary NIP-07 signing; there
|
||||
is no generated browser key, nsec input, or second consent modal.
|
||||
- Upload authorization is scoped to the file hash, actual server hostname and
|
||||
five-minute expiry. Local upload does not send a public Nostr announcement.
|
||||
- Uploaded files are returned as sandboxed attachments. Untrusted HTML/SVG must
|
||||
not acquire this app's origin or signer access. Published website rendering
|
||||
needs the separate website origin, not a relaxation of this policy.
|
||||
|
||||
AppGate protects reads as well as the UI. Blossom itself is content-addressed,
|
||||
not an encrypted per-user vault: other authorized node users who know a hash can
|
||||
retrieve its bytes. Do not open the whole app gate to publish one website. Public
|
||||
asset serving must authorize exact selected hashes; external replication requires
|
||||
its own explicit content/destination review. An inaccessible local URL is not a
|
||||
working public Blossom endpoint.
|
||||
|
||||
## Qualification evidence — 2026-10-08
|
||||
|
||||
- Manifest preflight: 16 passed, no warnings. Generated catalogue drift: zero.
|
||||
- Candidate built and started on Framework with read-only root and the declared
|
||||
resource/security constraints. All protocol tests use synthetic identities and
|
||||
files; no public relay or external Blossom server is contacted.
|
||||
- `tests/apps/blossom/protocol.ts` passed against the candidate: authenticated
|
||||
upload/readback, exact hash/size/bytes, wrong identity/server/expired/anonymous
|
||||
upload rejection, owner-only listing, disabled mirror, canonical provider and
|
||||
health endpoint. HTML response has sandbox CSP and attachment headers.
|
||||
- Fixture survived container recreation with the same data directory and restart
|
||||
with explicit slirp4netns. An earlier test using Podman's default pasta hit a
|
||||
transient port teardown conflict; that is not the package's configured network.
|
||||
- Canonical Rust parser: all shipped manifests parse in the isolated test runner.
|
||||
- Setup/source tests: 13 passed, dashboard typecheck passed including the final
|
||||
receipt-review presentation changes.
|
||||
- Packaged UI passed a real Chromium test at mobile width: explicit identity
|
||||
chooser, consent before upload, signer refusal blocks upload, hash/host-scoped
|
||||
upload, consent reset and no external requests. Signer and upload transport
|
||||
were mocked for this UI test; live protocol checks above are separate.
|
||||
- Framework's normal installer succeeded after the operator temporarily disabled
|
||||
dashboard 2FA. Candidate manifest and build context are staged in the runtime
|
||||
payload. The app is healthy, with its canonical bridge installed by the hook,
|
||||
read-only root, slirp4netns and a loopback backend behind AppGate. Anonymous
|
||||
HTTPS access on port 8191 returns the gate's 401 sign-in page.
|
||||
- Real HTTPS tab signer acceptance passed: profile chooser, refusal prevents any
|
||||
upload, and an approved BUD-11 authorization stores a synthetic local file.
|
||||
No real identity key was exported or public Nostr event sent. Existing native
|
||||
Bitcoin/LND processes retained their original start times during installation.
|
||||
- No signed catalogue, source proposal, public Nostr event, OTA or ISO published.
|
||||
|
||||
- Real HTTP tab signing also passed. Normal app stop/start, restart with a new
|
||||
container, uninstall with `preserve_data:true`, reinstall, and management restart
|
||||
all preserved the uploaded synthetic file, verified by hash. Native Bitcoin/LND
|
||||
processes retained their original start times.
|
||||
- Local website archive integration is implemented in source: an explicit action
|
||||
signs a hash/server-scoped upload, stores the saved draft through the local
|
||||
manifest-owned Blossom backend, verifies exact readback and records a receipt.
|
||||
It does not announce or replicate anything. Its backend RPC is not yet deployed.
|
||||
|
||||
Still required before release: cross-profile identity switch (only one profile
|
||||
was available), HTTP/HTTPS iframe and physical companion validation, arranged
|
||||
reboot, integrated website archive acceptance, then reviewed source/mirror parity
|
||||
and signed catalogue gates. Selective public asset routes remain separate work;
|
||||
the authenticated app address must never be advertised as a public Blossom URL.
|
||||
Restore dashboard 2FA with the operator after live testing.
|
||||
|
||||
### Companion follow-up — 2026-10-08
|
||||
|
||||
Blossom now requests the canonical identity chooser once when opened, identifies
|
||||
itself explicitly to the tab signer, and disables the provider's unrelated
|
||||
NIP-98 web-app login. Cancelled selection leaves a retry button; uploads still
|
||||
require file review and signer approval. A host signer bug sent a Vue reactive
|
||||
Proxy through postMessage after selection, closing the picker but stranding the
|
||||
app behind an empty signer. The host now copies only public identity fields.
|
||||
The reactive-object regression test and a real direct-app mobile-width browser
|
||||
check pass: automatic chooser, closed signer, visible app, denied upload and
|
||||
approved local upload. Physical companion confirmation remains pending.
|
||||
The corrected image is a private rebuild of the existing candidate tag; assign
|
||||
an updated package/image version before reviewed catalogue publication.
|
||||
@@ -0,0 +1,87 @@
|
||||
app:
|
||||
id: blossom
|
||||
name: Blossom
|
||||
version: 6.4.1-archy.2
|
||||
upstream:
|
||||
kind: github
|
||||
repo: hzrd149/blossom-server
|
||||
description: Local file storage for Nostr and websites, using your Archipelago signer. External publishing is a separate explicit choice.
|
||||
category: data
|
||||
container:
|
||||
network: slirp4netns
|
||||
build:
|
||||
context: /opt/archipelago/docker/blossom
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/archipelago-blossom:6.4.1-archy.2
|
||||
derived_env:
|
||||
- key: ARCHY_BLOSSOM_PUBKEYS
|
||||
template: '{{NODE_IDENTITY_PUBKEYS}}'
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512m
|
||||
disk_limit: 5Gi
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
seccomp_profile: default
|
||||
ports:
|
||||
- host: 8191
|
||||
container: 3000
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/blossom/data
|
||||
target: /data
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/blossom/bridge
|
||||
target: /bridge
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/blossom/config.json
|
||||
target: /config/config.json
|
||||
options: [ro]
|
||||
- type: tmpfs
|
||||
target: /tmp
|
||||
options: [rw, nosuid, nodev, size=64m]
|
||||
files:
|
||||
- path: /var/lib/archipelago/blossom/config.json
|
||||
overwrite: false
|
||||
content: '{}'
|
||||
hooks:
|
||||
post_install:
|
||||
- copy_from_host:
|
||||
src: web-ui/nostr-provider.js
|
||||
dest: /bridge/nostr-provider.js
|
||||
- exec: [sh, -c, 'test -s /bridge/nostr-provider.js']
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:3000
|
||||
path: /healthz
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
interfaces:
|
||||
main:
|
||||
name: Local files
|
||||
type: ui
|
||||
port: 8191
|
||||
protocol: http
|
||||
path: /
|
||||
metadata:
|
||||
author: hzrd149 / Archipelago
|
||||
tier: optional
|
||||
icon: /assets/img/app-icons/blossom.svg
|
||||
license: MIT
|
||||
repo: https://github.com/hzrd149/blossom-server
|
||||
tags: [nostr, blossom, storage, websites]
|
||||
launch:
|
||||
open_in_new_tab: false
|
||||
requires_host_frame: false
|
||||
@@ -0,0 +1,86 @@
|
||||
# DATUM on Archipelago
|
||||
|
||||
Packages OCEAN DATUM v0.4.1beta, pinned to upstream commit
|
||||
`5b061233a3d3323771b2be98e17f543e59346619`. The local build context must ship at
|
||||
`/opt/archipelago/docker/datum`; no published registry image is assumed.
|
||||
|
||||
## First launch
|
||||
|
||||
Install a Bitcoin node and allow it to synchronize, then install DATUM. Open its
|
||||
app tile and set your own Bitcoin payout address in DATUM's configuration page.
|
||||
The initial address is deliberately empty: upstream keeps the UI available while
|
||||
waiting for a valid address instead of mining to somebody else's address.
|
||||
The admin username is `admin`. The generated password is stored on the node at
|
||||
`/var/lib/archipelago/secrets/datum-admin-password`; retrieve it locally as the
|
||||
node administrator. Do not put it in miner passwords or share it with miners.
|
||||
|
||||
Point miners at `stratum+tcp://<node-LAN-hostname>:23334`. Use a unique worker
|
||||
name for every miner, following upstream's payout/worker naming rules:
|
||||
https://github.com/OCEAN-xyz/datum_gateway/blob/v0.4.1beta/doc/usernames.md
|
||||
The default is pooled mining only; loss of the pool connection stops mining
|
||||
rather than silently switching to solo mining. DATUM's web UI reports template,
|
||||
Bitcoin and pool readiness; an HTTP health check only proves the UI is alive.
|
||||
|
||||
## Stable connections
|
||||
|
||||
Gashboard connects inside `archy-net` to `http://datum:7152`, using Podman's DNS
|
||||
alias. Never copy a container IP into either app's configuration. Bitcoin's DNS
|
||||
name is resolved from `BITCOIN_HOST` on each start, and the shared RPC secret and
|
||||
DATUM admin secret are refreshed without discarding the operator's settings.
|
||||
|
||||
External miners connect to the **node**, not its container. Use a DHCP reservation
|
||||
on your router and a LAN DNS name if the miner supports DNS. Some miners do not
|
||||
support mDNS (`.local`); use the reserved LAN IP for those. Container DNS fixes
|
||||
container recreation, while the reservation prevents the node's DHCP address
|
||||
from moving. Neither setting requires host networking.
|
||||
|
||||
Only Stratum is published directly. The admin UI is loopback-bound behind the
|
||||
Archipelago app gate and retains DATUM's admin authentication. The backend uses
|
||||
upstream's block notification polling fallback, so installing DATUM does not
|
||||
rewrite or restart Bitcoin to add a `blocknotify` command.
|
||||
|
||||
## Data and validation
|
||||
|
||||
Settings live in `/var/lib/archipelago/datum/config.json` with mode 0600. Preserve
|
||||
that directory and the platform secrets when uninstalling/reinstalling.
|
||||
|
||||
Before catalog publication, validate install, setup, Bitcoin IBD and recovery,
|
||||
accepted shares from a real miner, stop/start, container recreation, preserved-data
|
||||
reinstall, backend restart and a controlled node reboot. Verify Gashboard recovers
|
||||
after DATUM receives a different container address. These live-node checks are
|
||||
separate from the local manifest/build checks and require a dedicated test node.
|
||||
|
||||
## Local validation (2026-10-06)
|
||||
|
||||
The pinned image builds on Linux/amd64. Its UI returns HTTP 200 while waiting
|
||||
for setup, `/clients` rejects unauthenticated requests, and its config is 0600.
|
||||
The container runs with read-only root, cap-drop ALL and no-new-privileges.
|
||||
Three config regression tests cover empty first-run payout, preserved payout
|
||||
policy (including explicit false settings), secret/DNS refresh and invalid input.
|
||||
Gashboard successfully polls this image using digest authentication and reconnects
|
||||
when its container IP changes. Manifest preflight and generated catalog drift
|
||||
checks pass. The catalog entries in this branch are review candidates; no signed
|
||||
catalog or image has been published. Real mining shares and full lifecycle acceptance remain required before release.
|
||||
|
||||
## Operator-authorized node deployment (2026-10-06)
|
||||
|
||||
Installed as rootless Podman apps on archi-dev-box and yaya-server, with the
|
||||
manifest and build context staged in the runtime payload. Both nodes report
|
||||
DATUM healthy. Chromium verified the normalized My Apps icon, title, Launch
|
||||
button, and embedded native UI with its Config navigation on both nodes.
|
||||
|
||||
On yaya, a normal package restart recreated DATUM at 10.89.0.11 instead of
|
||||
10.89.0.9. Its configuration checksum was unchanged, and the still-running
|
||||
Gashboard resolved `datum` to the new address and resumed successful authenticated
|
||||
polling. Existing app container IDs remained unchanged on both hosts.
|
||||
|
||||
The payout address remains unset. HTTP health proves that setup is available,
|
||||
not that Bitcoin/pool readiness or accepted mining shares have been established.
|
||||
No node reboot, IBD experiment, preserved-data reinstall, signed catalog release,
|
||||
or registry publication was performed in this deployment.
|
||||
|
||||
The deployed platform drops manifest UI/icon metadata from its initial Installing
|
||||
placeholder, briefly showing a disk-only app under Services. Once scanned, both
|
||||
apps appear in My Apps with their declared icons and launch interfaces. A separate
|
||||
platform fix is being prepared; do not claim the installation-placeholder issue
|
||||
is fixed merely because the completed installation is displayed correctly.
|
||||
@@ -0,0 +1,84 @@
|
||||
app:
|
||||
id: datum
|
||||
name: DATUM
|
||||
version: 0.4.1-beta.1
|
||||
description: Build Bitcoin mining templates on your own node and connect your miners to OCEAN through DATUM.
|
||||
upstream:
|
||||
kind: github
|
||||
repo: OCEAN-xyz/datum_gateway
|
||||
container_name: datum
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/datum
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/archipelago-datum:0.4.1-beta.1
|
||||
network: archy-net
|
||||
network_aliases: [datum]
|
||||
data_uid: "1000:1000"
|
||||
derived_env:
|
||||
- key: BITCOIN_RPC_HOST
|
||||
template: "{{BITCOIN_HOST}}"
|
||||
generated_secrets:
|
||||
- name: datum-admin-password
|
||||
kind: hex32
|
||||
secret_env:
|
||||
- key: BITCOIN_RPC_PASSWORD
|
||||
secret_file: bitcoin-rpc-password
|
||||
- key: DATUM_ADMIN_PASSWORD
|
||||
secret_file: datum-admin-password
|
||||
dependencies:
|
||||
- app_id: bitcoin-knots
|
||||
- storage: 1Gi
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 512m
|
||||
disk_limit: 1Gi
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
ports:
|
||||
- host: 7152
|
||||
container: 7152
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
- host: 23334
|
||||
container: 23334
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: Stratum mining clients require a raw TCP connection and cannot complete a browser login. Payout worker names are handled by DATUM; the administration UI uses a separate gated port.
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/datum
|
||||
target: /data
|
||||
options: [rw]
|
||||
- type: tmpfs
|
||||
target: /tmp
|
||||
tmpfs_options: rw,noexec,nosuid,size=16m
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:7152
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
interfaces:
|
||||
main:
|
||||
name: DATUM Gateway
|
||||
type: ui
|
||||
port: 7152
|
||||
protocol: http
|
||||
path: /
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
pruning_support: true
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/datum.svg
|
||||
category: bitcoin
|
||||
tier: optional
|
||||
repo: https://github.com/OCEAN-xyz/datum_gateway
|
||||
launch:
|
||||
open_in_new_tab: false
|
||||
@@ -0,0 +1,129 @@
|
||||
# Gashboard on Archipelago
|
||||
|
||||
Install DATUM and configure its payout address, then install Gashboard. The app
|
||||
connects to `http://datum:7152` on `archy-net`, so recreating DATUM does not require
|
||||
copying a new container IP. The shared DATUM admin password is injected as a
|
||||
platform secret and never sent to the browser. This PR depends on the DATUM app
|
||||
package being merged and its build context being shipped.
|
||||
|
||||
## Sign in and invite miners
|
||||
|
||||
Use **Sign in with Nostr** inside Archipelago to choose a node identity through
|
||||
the native signer. A standalone visitor can use a browser extension or remote
|
||||
Nostr signer. No private key is entered into Gashboard.
|
||||
|
||||
All user identities offered by Archipelago's signer are dashboard owners through
|
||||
`NODE_IDENTITY_PUBKEYS`; the appliance identity is excluded. Owners can open
|
||||
**Access**, paste another miner's `npub`, and add them as a viewer. Share the
|
||||
Gashboard URL on port 1337 over your intended node access route. Viewers can read
|
||||
the entire fleet and join dashboard chat; they cannot edit membership or configure
|
||||
DATUM. Access is independent of the miner's Stratum worker name. Signing with an
|
||||
unlisted identity is rejected even if that person mines through DATUM.
|
||||
|
||||
Owners can remove a viewer in Access. Every authenticated request rechecks the
|
||||
list, so an already-issued JWT stops working immediately. Removing a viewer does
|
||||
not erase information or chat keys that their browser previously received.
|
||||
Membership lives in `/var/lib/archipelago/gashboard/access.json`; preserve this
|
||||
directory and the platform JWT secret across reinstall. Node owners are managed
|
||||
in Archipelago identities, not in Gashboard. Restart Gashboard after changing
|
||||
node identities to refresh owner access.
|
||||
|
||||
The app gate uses `auth: open` because invited miners have Gashboard membership,
|
||||
not node administrator accounts. Gashboard still authenticates every data API.
|
||||
The gate supplies HTTPS and iframe header handling. A node administrator can
|
||||
enable the gate's extra login if only node users should reach the app.
|
||||
|
||||
## Source and native signer
|
||||
|
||||
`docker/gashboard` vendors the user-supplied Gashboard source at commit
|
||||
`68b606b` from `/home/yaya/Projects/gashboard`, with Archipelago integration and
|
||||
membership changes reviewed here. Its configured remote,
|
||||
`https://git.tx1138.com/lfg2025/gashboard.git`, was unavailable over TLS during
|
||||
preparation; upstream freshness has not been verified. Vendoring makes the build
|
||||
independent of that server. The original application declares the MIT license.
|
||||
|
||||
The image bakes the canonical `neode-ui/public/nostr-provider.js`; the install
|
||||
hook refreshes its persisted copy from the node. Refresh the baked copy when
|
||||
updating the package. The server serves the provider uncached with
|
||||
`data-app-id="gashboard"` and `data-no-nip98`, preserving Gashboard's own NIP-98
|
||||
login. The service worker never caches the signer. Existing NIP-07 browser
|
||||
providers take precedence over the node provider. The CSP permits the native
|
||||
signer broker frame in standalone/companion launches.
|
||||
|
||||
`/healthz` checks that the dashboard API is serving. DATUM connection failures are
|
||||
reported in the dashboard snapshot rather than disguised as a healthy mining
|
||||
fleet. Contribution history persists under `/data`; live miner connections and
|
||||
current hash rate recover through repeated DNS-based polling. Miner display names
|
||||
come from their worker names, and history uses the full Stratum username so
|
||||
multiple miners of the same model are not combined under a preset nickname.
|
||||
Use a distinct worker name for each miner. Old Umbrel history should not be
|
||||
copied blindly: its ledger used preset nicknames as identities.
|
||||
|
||||
Before release, validate HTTP/HTTPS iframe launch, companion launch, native
|
||||
identity consent, invited-user login, revocation, DATUM address change/recovery,
|
||||
and install/start/stop/reinstall/reboot on a dedicated Archipelago test node.
|
||||
|
||||
## Local validation (2026-10-06)
|
||||
|
||||
API access-control and worker-identity tests, TypeScript checks, production builds,
|
||||
manifest validation and generated catalog drift checks pass. Both container images
|
||||
build and run with read-only roots, cap-drop ALL and no-new-privileges on Docker.
|
||||
Gashboard's digest-authenticated polling recovered after DATUM moved from
|
||||
172.22.0.2 to 172.22.0.4, without restarting or reconfiguring Gashboard, and after
|
||||
another DATUM restart with preserved settings.
|
||||
|
||||
The access/login flow passed Chromium 153, Firefox 155 and WebKit 26.6, each at
|
||||
1440x900 and 390x844: native-provider NIP-98 through a simulated host frame,
|
||||
invalid-key feedback, invitation, reload persistence, removal, and layout fit.
|
||||
API tests also verify owner-only edits, rejected outsider login, persisted
|
||||
membership, owner protection, corruption refusal, and revoked JWT/SSE access.
|
||||
Browser scenarios and screenshots remain outside the repository in the shared
|
||||
browser-check workspace. These browser tests simulate the app gate and signer
|
||||
host; they do not establish real-node consent, HTTPS or Android acceptance.
|
||||
|
||||
The generated storefront entries are review candidates. No signed catalog,
|
||||
registry image or release was published. Keep actual-node acceptance
|
||||
separate from these local results.
|
||||
|
||||
## Operator-authorized node deployment (2026-10-06)
|
||||
|
||||
Installed alongside DATUM on archi-dev-box and yaya-server using rootless Podman,
|
||||
read-only roots and the node-generated secrets. Both apps report healthy. Their
|
||||
My Apps tiles show normalized icons, names and Launch controls. Chromium verified
|
||||
Gashboard's embedded launch, native identity selection/signing, and owner Access
|
||||
page on both nodes. Standalone native login and fresh DATUM polling passed too.
|
||||
|
||||
On yaya, Chromium 153, Firefox 155 and WebKit 26.6 passed owner login, Access-page
|
||||
layout and reload persistence at 1440x900 and 390x844. These are Linux browser and
|
||||
viewport checks, not Android companion or physical iPhone acceptance. Anonymous
|
||||
requests to mining data and membership endpoints returned 401. No viewers were
|
||||
added to the live allowlist during these read-only UI checks.
|
||||
|
||||
DATUM's normal package restart on yaya changed its address from 10.89.0.9 to
|
||||
10.89.0.11; Gashboard was not restarted or reconfigured and its authenticated stats
|
||||
API returned a successful poll less than five seconds old afterwards. Gashboard
|
||||
also completed its own normal package restart. The previous Docker tests cover
|
||||
invitation, revocation and membership persistence; full live-node membership,
|
||||
HTTPS, companion, preserved-data reinstall and reboot acceptance remain separate.
|
||||
|
||||
Payouts are not configured and no real miner shares were submitted in this check.
|
||||
These are node test deployments of review candidates, not catalog publication.
|
||||
|
||||
### Private chat persistence and invitations
|
||||
|
||||
Encrypted messages, reactions and per-recipient room-key wraps are saved atomically
|
||||
in `/data/chat.json` (mode 0600), alongside the viewer list. The server never saves
|
||||
the plaintext room key or decrypted messages. Back up the whole app data directory;
|
||||
keep chat history and key wraps together. Invalid saved chat data stops startup
|
||||
instead of silently discarding history.
|
||||
|
||||
An existing member with chat open shares the existing room key with newly invited
|
||||
viewers. If no existing member is online, the new viewer sees a pending-key message;
|
||||
an existing member must open chat, then the viewer can reopen the panel. A new
|
||||
viewer or simultaneous first visitor cannot replace the established key. Existing
|
||||
history becomes readable to invited viewers. Revoking membership blocks API access
|
||||
but cannot erase a key or history already received by that viewer.
|
||||
|
||||
When upgrading from 0.2.0, export the authenticated `/api/chat` snapshot to
|
||||
`/data/chat.json` before stopping the old container: that version holds chat only
|
||||
in memory. Preserve the snapshot and data-directory backup through the upgrade.
|
||||
@@ -0,0 +1,90 @@
|
||||
app:
|
||||
id: gashboard
|
||||
name: Gashboard
|
||||
version: 0.2.1
|
||||
description: A playful mining dashboard for your DATUM fleet, with live hashrates, share history, lottery odds, chat and a Nostr viewer access list.
|
||||
upstream:
|
||||
kind: internal
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/gashboard
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/archipelago-gashboard:0.2.1
|
||||
network: archy-net
|
||||
data_uid: "1000:1000"
|
||||
derived_env:
|
||||
- key: NOSTR_OWNER_PUBKEYS
|
||||
template: "{{NODE_IDENTITY_PUBKEYS}}"
|
||||
generated_secrets:
|
||||
- name: gashboard-jwt-secret
|
||||
kind: hex32
|
||||
secret_env:
|
||||
- key: JWT_SECRET
|
||||
secret_file: gashboard-jwt-secret
|
||||
- key: DATUM_ADMIN_PASSWORD
|
||||
secret_file: datum-admin-password
|
||||
install_prerequisites: [datum]
|
||||
dependencies:
|
||||
- app_id: datum
|
||||
- storage: 1Gi
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512m
|
||||
disk_limit: 1Gi
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
ports:
|
||||
- host: 1337
|
||||
container: 1337
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: open
|
||||
auth_rationale: Gashboard verifies NIP-98 signatures and an owner-managed Nostr access list before issuing sessions. Every data route rechecks membership; invited miners must not need a node administrator login.
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/gashboard
|
||||
target: /data
|
||||
options: [rw]
|
||||
- type: tmpfs
|
||||
target: /tmp
|
||||
tmpfs_options: rw,noexec,nosuid,size=16m
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=1337
|
||||
- DATUM_URL=http://datum:7152
|
||||
- DATUM_ADMIN_USER=admin
|
||||
- CONTRIBUTION_LEDGER_PATH=/data/contribution-ledger.json
|
||||
- ACCESS_LIST_PATH=/data/access.json
|
||||
- CHAT_STORE_PATH=/data/chat.json
|
||||
- ARCHIPELAGO_PROVIDER_PATH=/data/nostr-provider.js
|
||||
- MEMPOOL_API_URL=https://mempool.space/api
|
||||
hooks:
|
||||
post_install:
|
||||
- copy_from_host:
|
||||
src: web-ui/nostr-provider.js
|
||||
dest: /data/nostr-provider.js
|
||||
- exec: ["test", "-s", "/data/nostr-provider.js"]
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://localhost:1337
|
||||
path: /healthz
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
interfaces:
|
||||
main:
|
||||
name: Mining dashboard
|
||||
type: ui
|
||||
port: 1337
|
||||
protocol: http
|
||||
path: /
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/gashboard.svg
|
||||
category: bitcoin
|
||||
tier: optional
|
||||
repo: https://gitworkshop.dev/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
|
||||
launch:
|
||||
open_in_new_tab: false
|
||||
+15
-8
@@ -15,6 +15,9 @@ app:
|
||||
image: source.archipelago-foundation.org/lfg2025/gitea:1.27.3
|
||||
pull_policy: if-not-present
|
||||
|
||||
# Preserve repositories, database, keys and configuration during runtime repairs.
|
||||
backup_before_runtime_change: true
|
||||
|
||||
dependencies:
|
||||
# Source history, LFS objects, release artifacts and OCI layers all share
|
||||
# this persistent store. 500Mi was only suitable for an empty demo node.
|
||||
@@ -25,7 +28,7 @@ app:
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
|
||||
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE, SYS_CHROOT]
|
||||
readonly_root: false
|
||||
no_new_privileges: false
|
||||
network_policy: bridge
|
||||
@@ -62,6 +65,17 @@ app:
|
||||
target: /etc/gitea
|
||||
options: [rw]
|
||||
|
||||
# Seed a fresh installation with the same origin advertised by the app gate.
|
||||
# Existing app.ini (including custom HTTPS/domain settings) is never replaced.
|
||||
files:
|
||||
- path: /var/lib/archipelago/gitea/data/gitea/conf/app.ini
|
||||
overwrite: false
|
||||
content: |
|
||||
[server]
|
||||
DOMAIN = {{HOST_IP}}
|
||||
SSH_DOMAIN = {{HOST_IP}}
|
||||
ROOT_URL = http://{{HOST_IP}}:3001/
|
||||
|
||||
environment:
|
||||
- GITEA__database__DB_TYPE=sqlite3
|
||||
- GITEA__server__SSH_PORT=2222
|
||||
@@ -106,10 +120,3 @@ app:
|
||||
- Issue tracking and pull requests
|
||||
- CI/CD via Gitea Actions
|
||||
- Lightweight SQLite deployment
|
||||
|
||||
nginx_proxy:
|
||||
listen: 3000
|
||||
proxy_pass: http://127.0.0.1:3001
|
||||
extra_headers:
|
||||
- proxy_hide_header X-Frame-Options
|
||||
- proxy_hide_header Content-Security-Policy
|
||||
|
||||
@@ -67,6 +67,7 @@ app:
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
|
||||
icon: /assets/img/app-icons/homeassistant.png
|
||||
category: home
|
||||
author: Home Assistant
|
||||
|
||||
@@ -84,5 +84,6 @@ app:
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
|
||||
launch:
|
||||
open_in_new_tab: true
|
||||
|
||||
@@ -15,6 +15,8 @@ app:
|
||||
container_name: indeedhub-api
|
||||
|
||||
container:
|
||||
# Public identity pins only; registration/publication stay disabled by default.
|
||||
media_registration_identity: true
|
||||
image: source.archipelago-foundation.org/lfg2025/indeedhub-api:1.0.0
|
||||
pull_policy: if-not-present
|
||||
network: indeedhub-net
|
||||
|
||||
@@ -69,10 +69,12 @@ app:
|
||||
- copy_from_host:
|
||||
src: "web-ui/nostr-provider.js"
|
||||
dest: "/usr/share/nginx/html/nostr-provider.js"
|
||||
- exec: ["sh", "-c", "grep -qF 'location = /nostr-provider.js {' /etc/nginx/conf.d/default.conf || sed -i '/location = \/sw.js {/i\\ location = /nostr-provider.js {\\n add_header Cache-Control \"no-cache, no-store, must-revalidate\";\\n expires off;\\n }\\n' /etc/nginx/conf.d/default.conf"]
|
||||
- exec: ["sh", "-c", "grep -q nostr-provider /etc/nginx/conf.d/default.conf || sed -i 's#</head>#<script src=\"/nostr-provider.js\"></script></head>#' /etc/nginx/conf.d/default.conf"]
|
||||
- exec: ["sed", "-i", "s#tab-signer-v2#tab-signer-v4#g; s#tab-signer-v3#tab-signer-v4#g", "/etc/nginx/conf.d/default.conf"]
|
||||
- exec: ["sed", "-i", "s#src=\"/nostr-provider.js\"#src=\"/nostr-provider.js?v=tab-signer-v4\"#g", "/etc/nginx/conf.d/default.conf"]
|
||||
- exec: ["sh", "-c", "grep -qF 'location = /nostr-provider.js {' /etc/nginx/conf.d/default.conf || sed -i '/location = .*sw[.]js {/i\\ location = /nostr-provider.js {\\n add_header Cache-Control \"no-cache, no-store, must-revalidate\";\\n expires off;\\n }\\n' /etc/nginx/conf.d/default.conf"]
|
||||
- exec: ["sh", "-c", "if ! grep -qE '<script[^>]*nostr-provider' /usr/share/nginx/html/index.html && ! grep -qE '(sub_filter|<script).*nostr-provider' /etc/nginx/conf.d/default.conf; then sed -i 's#</head>#<script src=\"/nostr-provider.js\"></script></head>#' /usr/share/nginx/html/index.html; fi"]
|
||||
- exec: ["sed", "-i", "s#tab-signer-v2#tab-signer-v4#g; s#tab-signer-v3#tab-signer-v4#g", "/etc/nginx/conf.d/default.conf", "/usr/share/nginx/html/index.html"]
|
||||
- exec: ["sed", "-i", "s#src=\"/nostr-provider.js\"#src=\"/nostr-provider.js?v=tab-signer-v4\"#g", "/etc/nginx/conf.d/default.conf", "/usr/share/nginx/html/index.html"]
|
||||
# Compose the outer app-proxy prefix for NIP-98 signed URL verification.
|
||||
- exec: ["sed", "-i", "s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|", "/etc/nginx/conf.d/default.conf"]
|
||||
- exec: ["nginx", "-s", "reload"]
|
||||
|
||||
# TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at
|
||||
|
||||
@@ -63,6 +63,7 @@ app:
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
|
||||
icon: /assets/img/app-icons/jellyfin.webp
|
||||
category: data
|
||||
author: Jellyfin
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
app:
|
||||
id: justworks
|
||||
name: Just Works
|
||||
version: 0.1.0
|
||||
description: >-
|
||||
Turn your existing business links into a website with Just Works.
|
||||
Open your website editor and business tools from one lightweight launcher.
|
||||
Uses the hosted Just Works services; an internet connection is required.
|
||||
category: business
|
||||
upstream:
|
||||
kind: manual
|
||||
url: https://github.com/bencoin21/justworks-business
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/justworks
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/archipelago-justworks:0.1.0
|
||||
network: archy-net
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 256m
|
||||
disk_limit: 128Mi
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
ports:
|
||||
- host: 8340
|
||||
container: 8340
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
session_passthrough: true
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/justworks
|
||||
target: /data
|
||||
environment: []
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8340
|
||||
path: /healthz
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
interfaces:
|
||||
main:
|
||||
name: Just Works
|
||||
description: Website and business tools
|
||||
type: ui
|
||||
port: 8340
|
||||
protocol: http
|
||||
path: /
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/justworks.svg
|
||||
author: Just Works contributors
|
||||
repo: https://github.com/bencoin21/justworks.cash
|
||||
tier: optional
|
||||
launch:
|
||||
requires_host_frame: true
|
||||
open_in_new_tab: false
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: mempool
|
||||
name: Mempool Explorer
|
||||
version: 3.0.0
|
||||
version: 3.3.1-archy1
|
||||
# Where this app comes from, so scripts/check-upstream-releases.py can
|
||||
# tell us when the pin below has fallen behind. Without it nothing can:
|
||||
# container.image names our mirror, not the project it was mirrored from.
|
||||
@@ -11,7 +11,7 @@ app:
|
||||
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
|
||||
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ app:
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
|
||||
icon: /assets/img/app-icons/nextcloud.webp
|
||||
category: data
|
||||
author: Nextcloud
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
app:
|
||||
id: nginx-proxy-manager
|
||||
name: Nginx Proxy Manager
|
||||
version: 2.12.1
|
||||
version: 2.14.0
|
||||
upstream:
|
||||
kind: github
|
||||
repo: NginxProxyManager/nginx-proxy-manager
|
||||
description: >-
|
||||
Reverse proxy with SSL. Beautiful web interface for managing proxies.
|
||||
On a node, this manages its admin UI and upstream configuration — the
|
||||
proxy's own :80/:443 listeners are not published (the node's web server
|
||||
owns those ports).
|
||||
The node's public web server forwards configured domains through this
|
||||
service, preserving its access lists, certificates and custom routes.
|
||||
backup_before_runtime_change: true
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest
|
||||
image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
# Rootless pasta copies the LAN IP, preventing requests back to this node.
|
||||
# Retain the old pasta host gateway used by saved NPM upstreams, plus
|
||||
# host.containers.internal. This subnet stays inside the private rootless namespace.
|
||||
network: slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
@@ -49,6 +52,17 @@ app:
|
||||
Nginx Proxy Manager enforces its own admin account on every page;
|
||||
the initial setup wizard also has to answer before any account exists.
|
||||
|
||||
- host: 8088
|
||||
container: 80
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
- host: 8444
|
||||
container: 443
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/nginx-proxy-manager
|
||||
@@ -64,9 +78,11 @@ app:
|
||||
|
||||
environment: []
|
||||
|
||||
# Probe the admin API inside the container, independent of optional
|
||||
# tunnel listeners. This also verifies the Node backend is ready.
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:81
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:81/api/
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -60,6 +60,7 @@ app:
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
guest_access: true # Explicit app-only sharing through AppGate; app accounts still apply.
|
||||
icon: /assets/img/app-icons/photoprism.svg
|
||||
category: data
|
||||
author: PhotoPrism
|
||||
|
||||
@@ -14,8 +14,16 @@ app:
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
|
||||
pull_policy: if-not-present
|
||||
# Portainer fetches Git sources and images from services on this same node.
|
||||
# Rootless pasta copies the host LAN address into its namespace, so a LAN
|
||||
# URL points back at Portainer itself. Give it a private address with the
|
||||
# supported rootless slirp backend; public app URLs still traverse the gate.
|
||||
network: slirp4netns
|
||||
data_uid: "1000:1000"
|
||||
|
||||
# Snapshot state before an upgrade recreates this app with new networking.
|
||||
backup_before_runtime_change: true
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Public Web Router
|
||||
|
||||
Optional, manifest-first rootless app for node-terminated HTTPS through an
|
||||
operator-owned frp gateway. Uses pinned frpc0.71.0 and Caddy2.11.7 binaries and a
|
||||
pinned multi-architecture Python base. No host network, host port, capabilities,
|
||||
privileged socket, or node signing keys are needed. FIPS connects the isolated
|
||||
container to explicitly published website listeners.
|
||||
|
||||
Setup stores the private enrollment and derived routes in
|
||||
`/var/lib/archipelago/public-web-router/config/router.json` (0600). The app mounts
|
||||
that directory read-only, watches for atomic replacement, validates input, and
|
||||
supervises only its own Caddy and frpc processes. Removing or invalidating config
|
||||
stops both. The gateway CA is pinned; HTTPS SNI passes through to Caddy. Caddy
|
||||
keeps certificate keys under the persistent `/data` bind mount. Uninstall and
|
||||
Disconnect must preserve that data unless the user explicitly requests removal.
|
||||
|
||||
The automatic adapter accepts website IDs or guest-enabled app IDs and resolves
|
||||
saved domains, FIPS addresses and listener ports on the backend. Arbitrary target
|
||||
URLs/ports and management endpoints are not accepted. App routes require the
|
||||
installed catalogue policy to enable guest sharing and retain authentication.
|
||||
Each request carries the expected project/app identity. The app gate rechecks
|
||||
its live policy before login actions or static exceptions; a stale route cannot
|
||||
follow a reassigned port or a disabled gate. Existing manual proxies still work.
|
||||
|
||||
No Nostr signer integration is requested: routing neither signs nor broadcasts
|
||||
Nostr events. Blossom/nsite publication continues to use its explicit profile
|
||||
signer and exact-byte review. Enrollment files contain private credentials and
|
||||
must never enter that publishing flow.
|
||||
|
||||
Public mode requests ACME using TLS-ALPN-01. A dedicated public443 path must reach
|
||||
the node through the gateway; competing gateways/proxies must not claim it.
|
||||
Explicit test mode uses a private Caddy CA and is not browser-trusted public TLS.
|
||||
The process-health probe reports supervision, not external reachability or
|
||||
certificate issuance. Setup's independent HTTPS exact-content check remains
|
||||
required before claiming public reachability.
|
||||
|
||||
Framework qualification passed the signed private catalogue, normal manifest
|
||||
installer, owner-RPC enrollment, exact website bytes through isolated Yaya TLS,
|
||||
and app guest-cookie issue/revocation. Public ACME on port 443 remains untested;
|
||||
the isolated test uses a private CA. General publication still requires the
|
||||
repository release gates.
|
||||
|
||||
Distribution must include both `apps/public-web-router` and
|
||||
`docker/public-web-router` in the runtime payload. Build-source manifests defer
|
||||
to the shipped disk manifest; the catalogue alone cannot install the build
|
||||
context. On nodes with `web-ui/archipelago-runtime`, update that payload too:
|
||||
startup restores it into `/opt/archipelago`. Do not patch only the live copy.
|
||||
|
||||
The manifest requests CPU/memory limits. Framework's rootless runtime currently
|
||||
reports no enforced memory cgroup limit; do not present the requested 256 MiB as
|
||||
an enforced limit on that host. Read-only root, dropped capabilities, slirp and
|
||||
read-only configuration mounts were verified on the normally installed app.
|
||||
@@ -0,0 +1,49 @@
|
||||
app:
|
||||
id: public-web-router
|
||||
name: Public Web Router
|
||||
version: 0.1.0
|
||||
description: Connect explicitly published websites to your own public gateway. HTTPS keys stay on this node. Configure routes through Setup.
|
||||
container:
|
||||
network: slirp4netns
|
||||
build:
|
||||
context: /opt/archipelago/docker/public-web-router
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/archipelago-public-web-router:0.1.0
|
||||
dependencies:
|
||||
- storage: 256Mi
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 256m
|
||||
disk_limit: 512Mi
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
seccomp_profile: default
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/public-web-router/data
|
||||
target: /data
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/public-web-router/config
|
||||
target: /config
|
||||
options: [ro]
|
||||
- type: tmpfs
|
||||
target: /tmp
|
||||
options: [rw, nosuid, nodev, size=16m]
|
||||
health_check:
|
||||
type: exec
|
||||
endpoint: python3 -c "import pathlib,time; assert time.time()-pathlib.Path('/tmp/router/heartbeat').stat().st_mtime < 30"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
metadata:
|
||||
author: Archipelago
|
||||
category: networking
|
||||
tier: optional
|
||||
license: Apache-2.0 / MIT
|
||||
icon: /assets/img/app-icons/nginx.svg
|
||||
tags: [networking, websites, privacy]
|
||||
@@ -219,3 +219,6 @@ app:
|
||||
nostr_integration:
|
||||
relay_type: public
|
||||
monetization_enabled: true
|
||||
|
||||
metadata:
|
||||
guest_access: true
|
||||
|
||||
+2
-2
@@ -67,13 +67,13 @@
|
||||
{
|
||||
"id": "mempool",
|
||||
"title": "Mempool Explorer",
|
||||
"version": "3.0.0",
|
||||
"version": "3.3.1-archy1",
|
||||
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
|
||||
"icon": "/assets/img/app-icons/mempool.webp",
|
||||
"author": "Mempool",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
|
||||
"repoUrl": "https://github.com/mempool/mempool",
|
||||
"requires": [
|
||||
"bitcoin-knots",
|
||||
|
||||
Generated
+80
-5
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.13-alpha"
|
||||
version = "1.9.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
@@ -137,9 +137,11 @@ dependencies = [
|
||||
"hyper 0.14.32",
|
||||
"hyper-util",
|
||||
"hyper-ws-listener",
|
||||
"image",
|
||||
"iroh",
|
||||
"iroh-blobs",
|
||||
"libc",
|
||||
"lightning-invoice",
|
||||
"lofty",
|
||||
"mainline",
|
||||
"mdns-sd",
|
||||
@@ -228,6 +230,22 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "archipelago-publishing-tests"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"hyper 0.14.32",
|
||||
"reqwest 0.11.27",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "archipelago-security"
|
||||
version = "0.1.0"
|
||||
@@ -1567,6 +1585,15 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
@@ -2503,8 +2530,22 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3636,9 +3677,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr"
|
||||
version = "0.44.2"
|
||||
version = "0.44.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1"
|
||||
checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.22.1",
|
||||
@@ -3681,9 +3722,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nostr-relay-pool"
|
||||
version = "0.44.0"
|
||||
version = "0.44.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b"
|
||||
checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1"
|
||||
dependencies = [
|
||||
"async-utility",
|
||||
"async-wsocket",
|
||||
@@ -4142,6 +4183,19 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
@@ -4366,6 +4420,12 @@ dependencies = [
|
||||
"image",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -7322,3 +7382,18 @@ dependencies = [
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ members = [
|
||||
"openwrt",
|
||||
"performance",
|
||||
"security",
|
||||
"publishing-tests",
|
||||
]
|
||||
|
||||
# Shared package metadata, inherited by each member via `license.workspace = true`.
|
||||
@@ -27,3 +28,7 @@ opt-level = 3
|
||||
|
||||
# Archipelago workspace - no StartOS dependencies
|
||||
# All patches removed - we use standard crates.io dependencies
|
||||
|
||||
# Small source-sharing validation harness; no optimized tests needed.
|
||||
[profile.test.package.archipelago-publishing-tests]
|
||||
opt-level = 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.13-alpha"
|
||||
version = "1.9.0-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
@@ -73,6 +73,7 @@ chrono = "0.4"
|
||||
|
||||
# BIP-39 mnemonic seed generation + BIP-32 HD key derivation
|
||||
bip39 = { version = "2.1", features = ["rand"] }
|
||||
lightning-invoice = "=0.34.1"
|
||||
bitcoin = { version = "=0.32.5", features = ["rand-std"] }
|
||||
|
||||
# Configuration
|
||||
@@ -106,6 +107,8 @@ flate2 = "1.0"
|
||||
# TOTP 2FA
|
||||
totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] }
|
||||
qrcode = "0.14"
|
||||
# Paid image previews must be degraded on the server, never by browser CSS.
|
||||
image = { version = "0.25.9", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
data-encoding = "2.6"
|
||||
zeroize = { version = "1.8.2", features = ["derive"] }
|
||||
|
||||
@@ -146,6 +149,7 @@ iroh-blobs = { version = "0.103", optional = true }
|
||||
lofty = "0.24.0"
|
||||
cashu = { version = "0.17.5", default-features = false, features = ["wallet"] }
|
||||
|
||||
tempfile = "3.10"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3.10"
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Permanent Cloud snapshot delivery. Current source path/share state cannot
|
||||
//! revoke a settled immutable snapshot; no rental clock is started here.
|
||||
use super::{build_response, ApiHandler};
|
||||
use crate::{
|
||||
content_purchase::{Journal, SellerPhase},
|
||||
content_server::ByteRange,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{Body, HeaderMap, Response, StatusCode};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_cloud_purchase(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Response<Body>> {
|
||||
let (content_id, purchase_id) = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|value| value.split_once("/purchase/"))
|
||||
.context("Invalid purchase delivery route")?;
|
||||
anyhow::ensure!(
|
||||
!content_id.contains('/')
|
||||
&& !content_id.starts_with("registered_")
|
||||
&& !purchase_id.contains('/'),
|
||||
"Invalid Cloud delivery route"
|
||||
);
|
||||
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
|
||||
let buyer = crate::content_auth::incoming(
|
||||
headers,
|
||||
&audience,
|
||||
path,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)?
|
||||
.context("Authenticated peer proof is required")?;
|
||||
let mut values = headers.get_all("x-content-capability").iter();
|
||||
let capability = values
|
||||
.next()
|
||||
.context("Delivery capability is required")?
|
||||
.to_str()?;
|
||||
anyhow::ensure!(values.next().is_none(), "Duplicate delivery capability");
|
||||
let (contract, mime) = {
|
||||
let journal = Journal::open(&self.config.data_dir).await?;
|
||||
let record = journal
|
||||
.seller(purchase_id)
|
||||
.await?
|
||||
.context("Purchase settlement not found")?;
|
||||
let receipt = match record.phase {
|
||||
SellerPhase::ReceiptSaved(receipt) => receipt,
|
||||
_ => anyhow::bail!("Purchase settlement is not durable"),
|
||||
};
|
||||
anyhow::ensure!(
|
||||
record.contract.buyer_did == buyer
|
||||
&& record.contract.seller_did == audience
|
||||
&& record.contract.content_id == content_id
|
||||
&& receipt.capability == capability,
|
||||
"Purchase delivery binding changed"
|
||||
);
|
||||
let envelope = journal
|
||||
.protocol_envelope("seller", purchase_id)
|
||||
.await?
|
||||
.context("Original delivery metadata is missing")?;
|
||||
anyhow::ensure!(
|
||||
envelope.contract()? == record.contract,
|
||||
"Delivery metadata binding changed"
|
||||
);
|
||||
(record.contract, envelope.offer.mime_type)
|
||||
};
|
||||
let range = headers
|
||||
.get("range")
|
||||
.map(|value| -> Result<_> {
|
||||
crate::content_server::parse_range_header(value.to_str()?).context("Invalid range")
|
||||
})
|
||||
.transpose()?;
|
||||
let total = contract.content_size;
|
||||
let (start, end, partial) = match range {
|
||||
None => (0, total - 1, false),
|
||||
Some(ByteRange::From { start, end }) => {
|
||||
(start, end.unwrap_or(total - 1).min(total - 1), true)
|
||||
}
|
||||
Some(ByteRange::Suffix(count)) if count > 0 => {
|
||||
(total.saturating_sub(count), total - 1, true)
|
||||
}
|
||||
_ => anyhow::bail!("Invalid range"),
|
||||
};
|
||||
if start > end || start >= total {
|
||||
let mut response = build_response(
|
||||
StatusCode::RANGE_NOT_SATISFIABLE,
|
||||
"text/plain",
|
||||
Body::empty(),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("content-range", format!("bytes */{total}").parse()?);
|
||||
return Ok(response);
|
||||
}
|
||||
let data = self.config.data_dir.clone();
|
||||
let file = tokio::task::spawn_blocking(move || {
|
||||
crate::content_snapshot::open_matching(
|
||||
&data,
|
||||
&contract.content_id,
|
||||
&contract.content_sha256,
|
||||
contract.content_size,
|
||||
)
|
||||
})
|
||||
.await??;
|
||||
let mut file = tokio::fs::File::from_std(file.file);
|
||||
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||
let length = end - start + 1;
|
||||
let chunks =
|
||||
futures_util::stream::try_unfold((file, length), |(mut file, left)| async move {
|
||||
if left == 0 {
|
||||
return Ok::<_, std::io::Error>(None);
|
||||
}
|
||||
let mut bytes = vec![0; left.min(65536) as usize];
|
||||
let count = file.read(&mut bytes).await?;
|
||||
if count == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
"Purchase snapshot ended early",
|
||||
));
|
||||
}
|
||||
bytes.truncate(count);
|
||||
Ok(Some((bytes, (file, left - count as u64))))
|
||||
});
|
||||
let mut response = Response::builder()
|
||||
.status(if partial {
|
||||
StatusCode::PARTIAL_CONTENT
|
||||
} else {
|
||||
StatusCode::OK
|
||||
})
|
||||
.header("content-type", mime)
|
||||
.header("content-length", length)
|
||||
.header("accept-ranges", "bytes")
|
||||
.header("cache-control", "private, no-store")
|
||||
.header("x-content-type-options", "nosniff")
|
||||
.header("content-security-policy", "sandbox; default-src 'none'");
|
||||
if partial {
|
||||
response = response.header("content-range", format!("bytes {start}-{end}/{total}"));
|
||||
}
|
||||
Ok(response.body(Body::wrap_stream(chunks))?)
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,48 @@ use hyper::{Response, StatusCode};
|
||||
use super::{is_valid_app_id, ApiHandler};
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_content_catalog(config: &Config) -> Result<Response<hyper::Body>> {
|
||||
match content_server::load_catalog(&config.data_dir).await {
|
||||
fn verified_content_peer(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<Option<String>> {
|
||||
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
|
||||
crate::content_auth::incoming(headers, &audience, path, chrono::Utc::now().timestamp())
|
||||
}
|
||||
|
||||
async fn content_access_context(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<(Option<String>, bool, bool)> {
|
||||
let peer = self.verified_content_peer(path, headers)?;
|
||||
let known = if let Some(did) = &peer {
|
||||
crate::federation::load_nodes(&self.config.data_dir)
|
||||
.await?
|
||||
.iter()
|
||||
.any(|node| &node.did == did)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let owner = match crate::session::extract_session_cookie(headers) {
|
||||
Some(token) => self.session_store.validate(&token).await,
|
||||
None => false,
|
||||
};
|
||||
Ok((peer, known, owner))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_content_catalog(
|
||||
&self,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let (peer, known, owner) = self.content_access_context("/content", headers).await?;
|
||||
match content_server::load_catalog(&self.config.data_dir).await {
|
||||
Ok(catalog) => {
|
||||
// Only expose public metadata for available items
|
||||
let items: Vec<serde_json::Value> = catalog
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| !matches!(i.availability, content_server::Availability::Nobody))
|
||||
.filter(|item| content_server::visible_to(item, peer.as_deref(), known, owner))
|
||||
.map(|i| {
|
||||
serde_json::json!({
|
||||
"id": i.id,
|
||||
@@ -74,7 +108,7 @@ impl ApiHandler {
|
||||
let invoice_hash = headers
|
||||
.get("x-invoice-hash")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
.map(|s| s.to_ascii_lowercase())
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("x-onchain-address")
|
||||
@@ -82,11 +116,18 @@ impl ApiHandler {
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
// Extract federation peer DID from X-Federation-DID header
|
||||
let peer_did = headers
|
||||
.get("x-federation-did")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
let peer_did = match self.verified_content_peer(path, headers) {
|
||||
Ok(peer) => peer,
|
||||
Err(_) => {
|
||||
return Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"Peer authentication failed. Check both nodes are updated and their clocks are correct."}"#,
|
||||
),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// The authenticated local operator never pays for their own node's
|
||||
// content: validate the session cookie (same discipline as the model
|
||||
@@ -98,11 +139,64 @@ impl ApiHandler {
|
||||
None => false,
|
||||
};
|
||||
|
||||
// Payment settlement is verified on the seller even when no status
|
||||
// poll preceded this download (e.g. direct payment from another node).
|
||||
let requires_payment = if !owner_session && headers.contains_key("x-invoice-hash") {
|
||||
content_server::load_catalog(&config.data_dir)
|
||||
.await?
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| {
|
||||
item.id == content_id
|
||||
&& matches!(item.access, content_server::AccessControl::Paid { .. })
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if requires_payment {
|
||||
if let Some(hash) = headers.get("x-invoice-hash").and_then(|v| v.to_str().ok()) {
|
||||
if hash.len() != 64 || !hash.bytes().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid payment hash"),
|
||||
));
|
||||
}
|
||||
if let Err(error) = self
|
||||
.rpc_handler
|
||||
.settle_content_invoice(hash, content_id)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Cannot verify peer-file invoice settlement: {error:#}");
|
||||
return Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"Payment verification is temporarily unavailable. Retry the download without paying again."}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse Range header for streaming support
|
||||
let range = headers
|
||||
.get("range")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(content_server::parse_range_header);
|
||||
let range = match headers.get("range") {
|
||||
None => None,
|
||||
Some(value) => match value
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(content_server::parse_range_header)
|
||||
{
|
||||
Some(range) => Some(range),
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid byte range"),
|
||||
))
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
match content_server::serve_content(
|
||||
&config.data_dir,
|
||||
@@ -115,6 +209,7 @@ impl ApiHandler {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(content_server::ServeResult::Stream(body)) => body.into_response(),
|
||||
Ok(content_server::ServeResult::Ok(bytes, mime_type)) => {
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
@@ -162,18 +257,44 @@ impl ApiHandler {
|
||||
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
|
||||
),
|
||||
)),
|
||||
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
|
||||
Ok(content_server::ServeResult::Unavailable) => Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller's node can't read this file right now. This request did not redeem an ecash payment."}"#,
|
||||
),
|
||||
)),
|
||||
Ok(content_server::ServeResult::RangeNotSatisfiable(total)) => Ok(Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header("Content-Range", format!("bytes */{total}"))
|
||||
.body(hyper::Body::empty())
|
||||
.unwrap()),
|
||||
Ok(content_server::ServeResult::NotFound) => Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
)),
|
||||
// Not a 404: a paid request may already have been charged by the
|
||||
// time this fails, and "not found" hid the real error entirely.
|
||||
Err(e) => {
|
||||
tracing::error!("Serving content {content_id} failed: {e:#}");
|
||||
Ok(build_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"text/plain",
|
||||
hyper::Body::from("Failed to serve content"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seller side (#46): mint a Lightning invoice for a paid catalog item so a
|
||||
/// buyer can pay from any external wallet. Path: GET /content/{id}/invoice.
|
||||
/// Records a pending entitlement keyed by the invoice's payment hash.
|
||||
pub(super) async fn handle_content_invoice(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
pub(super) async fn handle_content_invoice(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let content_id = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|s| s.strip_suffix("/invoice"))
|
||||
@@ -186,6 +307,7 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let (peer, known, owner) = self.content_access_context(path, headers).await?;
|
||||
let catalog = content_server::load_catalog(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
@@ -199,6 +321,13 @@ impl ApiHandler {
|
||||
))
|
||||
}
|
||||
};
|
||||
if !content_server::visible_to(item, peer.as_deref(), known, owner) {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
));
|
||||
}
|
||||
let price_sats = match &item.access {
|
||||
content_server::AccessControl::Paid { price_sats, .. } => *price_sats,
|
||||
_ => {
|
||||
@@ -220,6 +349,18 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(error) =
|
||||
content_server::ensure_payment_source_available(&self.config.data_dir, item).await
|
||||
{
|
||||
return Ok(build_response(
|
||||
StatusCode::CONFLICT,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(
|
||||
&serde_json::json!({ "error": error.to_string(), "payment_started": false }),
|
||||
)?),
|
||||
));
|
||||
}
|
||||
|
||||
let memo = format!("Archipelago peer file {content_id}");
|
||||
match self
|
||||
.rpc_handler
|
||||
@@ -227,7 +368,13 @@ impl ApiHandler {
|
||||
.await
|
||||
{
|
||||
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
|
||||
crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await;
|
||||
crate::content_invoice::record_pending(
|
||||
&self.config.data_dir,
|
||||
&payment_hash,
|
||||
content_id,
|
||||
price_sats,
|
||||
)
|
||||
.await?;
|
||||
let body = serde_json::json!({
|
||||
"bolt11": bolt11,
|
||||
"payment_hash": payment_hash,
|
||||
@@ -268,58 +415,20 @@ impl ApiHandler {
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let rest = path.strip_prefix("/content/").unwrap_or("");
|
||||
let (content_id, payment_hash) = match rest.split_once("/invoice-status/") {
|
||||
Some((id, hash)) => (id, hash),
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
))
|
||||
}
|
||||
};
|
||||
if content_id.is_empty() || !is_valid_app_id(content_id) || payment_hash.is_empty() {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
hyper::Body::from("Invalid request"),
|
||||
));
|
||||
}
|
||||
|
||||
// The hash must be one we issued for exactly this content item.
|
||||
match crate::content_invoice::lookup(payment_hash).await {
|
||||
Some((cid, _)) if cid == content_id => {}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Already paid? Otherwise ask our LND and persist the result.
|
||||
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
|
||||
if !paid {
|
||||
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
|
||||
crate::content_invoice::mark_paid(payment_hash).await;
|
||||
paid = true;
|
||||
}
|
||||
}
|
||||
|
||||
let body = serde_json::json!({ "paid": paid });
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
|
||||
))
|
||||
Ok(invoice_status_response(path, |hash, id| async move {
|
||||
self.rpc_handler.content_invoice_lifecycle(&hash, &id).await
|
||||
})
|
||||
.await)
|
||||
}
|
||||
|
||||
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item
|
||||
/// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a
|
||||
/// pending entitlement keyed by the address; price doubles as expected amount.
|
||||
pub(super) async fn handle_content_onchain(&self, path: &str) -> Result<Response<hyper::Body>> {
|
||||
pub(super) async fn handle_content_onchain(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &hyper::HeaderMap,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let content_id = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|s| s.strip_suffix("/onchain"))
|
||||
@@ -331,43 +440,80 @@ impl ApiHandler {
|
||||
hyper::Body::from("Invalid content ID"),
|
||||
));
|
||||
}
|
||||
let (peer, known, owner) = self.content_access_context(path, headers).await?;
|
||||
let catalog = content_server::load_catalog(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||
Some(i) => match &i.access {
|
||||
content_server::AccessControl::Paid { price_sats, .. } => {
|
||||
if !content_server::method_accepted(&i.access, "onchain") {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller does not accept on-chain payment for this item"}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
*price_sats
|
||||
}
|
||||
_ => {
|
||||
let Some(item) = catalog.items.iter().find(|item| item.id == content_id) else {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
));
|
||||
};
|
||||
if !content_server::visible_to(item, peer.as_deref(), known, owner) {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
));
|
||||
}
|
||||
let price_sats = match &item.access {
|
||||
content_server::AccessControl::Paid { price_sats, .. } => {
|
||||
if !content_server::method_accepted(&item.access, "onchain") {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
|
||||
))
|
||||
hyper::Body::from(
|
||||
r#"{"error":"The seller does not accept on-chain payment for this item"}"#,
|
||||
),
|
||||
));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
*price_sats
|
||||
}
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
hyper::Body::from("Content not found"),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
// Match the node wallet's existing sendcoins minimum before exposing a
|
||||
// payable address for an amount its own payment flow cannot broadcast.
|
||||
if let Err(error) = content_server::validate_onchain_payment_price(price_sats) {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(
|
||||
&serde_json::json!({ "error": error.to_string(), "payment_started": false }),
|
||||
)?),
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(error) =
|
||||
content_server::ensure_payment_source_available(&self.config.data_dir, item).await
|
||||
{
|
||||
return Ok(build_response(
|
||||
StatusCode::CONFLICT,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(
|
||||
&serde_json::json!({ "error": error.to_string(), "payment_started": false }),
|
||||
)?),
|
||||
));
|
||||
}
|
||||
|
||||
match self.rpc_handler.new_onchain_address().await {
|
||||
Ok(address) if !address.is_empty() => {
|
||||
crate::content_invoice::record_pending(&address, content_id, price_sats).await;
|
||||
crate::content_invoice::record_pending_method(
|
||||
&self.config.data_dir,
|
||||
&address,
|
||||
content_id,
|
||||
price_sats,
|
||||
crate::content_invoice::PaymentMethod::Onchain,
|
||||
)
|
||||
.await?;
|
||||
let body = serde_json::json!({
|
||||
"address": address,
|
||||
"amount_sats": price_sats,
|
||||
@@ -417,7 +563,7 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
// The address must be one we issued for exactly this content item.
|
||||
let price = match crate::content_invoice::lookup(address).await {
|
||||
let price = match crate::content_invoice::lookup(&self.config.data_dir, address).await? {
|
||||
Some((cid, price)) if cid == content_id => price,
|
||||
_ => {
|
||||
return Ok(build_response(
|
||||
@@ -428,11 +574,24 @@ impl ApiHandler {
|
||||
}
|
||||
};
|
||||
|
||||
let mut paid = crate::content_invoice::is_paid_for(address, content_id).await;
|
||||
let mut paid =
|
||||
crate::content_invoice::is_paid_for(&self.config.data_dir, address, content_id).await;
|
||||
if !paid {
|
||||
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
|
||||
crate::content_invoice::mark_paid(address).await;
|
||||
paid = true;
|
||||
match self.rpc_handler.onchain_received(address, price).await {
|
||||
Ok(true) => {
|
||||
crate::content_invoice::mark_paid(&self.config.data_dir, address).await?;
|
||||
paid = true;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(_) => return Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(serde_json::to_vec(&serde_json::json!({
|
||||
"paid": false,
|
||||
"status": "unknown",
|
||||
"error": "Exact on-chain outputs could not be verified. Keep the original payment address and do not pay again."
|
||||
}))?),
|
||||
)),
|
||||
}
|
||||
}
|
||||
let body = serde_json::json!({ "paid": paid });
|
||||
@@ -463,6 +622,7 @@ impl ApiHandler {
|
||||
}
|
||||
|
||||
match content_server::serve_content_preview(&config.data_dir, content_id).await {
|
||||
Ok(content_server::PreviewResult::Stream(body)) => body.into_response(),
|
||||
Ok(content_server::PreviewResult::FullContent(bytes, mime_type)) => {
|
||||
let len = bytes.len();
|
||||
Ok(Response::builder()
|
||||
@@ -509,3 +669,109 @@ impl ApiHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep invalid input and an unavailable wallet inside the HTTP protocol so
|
||||
/// buyers can retry delivery without treating a dropped socket as lost payment.
|
||||
async fn invoice_status_response<F, Fut>(path: &str, settle: F) -> Response<hyper::Body>
|
||||
where
|
||||
F: FnOnce(String, String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<serde_json::Value>>,
|
||||
{
|
||||
let parsed = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|rest| rest.split_once("/invoice-status/"))
|
||||
.filter(|(id, hash)| {
|
||||
!id.is_empty()
|
||||
&& is_valid_app_id(id)
|
||||
&& hash.len() == 64
|
||||
&& hash.bytes().all(|c| c.is_ascii_hexdigit())
|
||||
});
|
||||
let Some((id, hash)) = parsed else {
|
||||
return build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"Invalid content ID or payment hash"}"#),
|
||||
);
|
||||
};
|
||||
match settle(hash.to_ascii_lowercase(), id.to_owned()).await {
|
||||
Ok(body) => build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
hyper::Body::from(body.to_string()),
|
||||
),
|
||||
Err(_) => {
|
||||
tracing::warn!("Peer-file payment status verification is temporarily unavailable");
|
||||
let mut response = build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(
|
||||
r#"{"error":"Payment verification is temporarily unavailable. Retry without paying again."}"#,
|
||||
),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
hyper::header::RETRY_AFTER,
|
||||
hyper::header::HeaderValue::from_static("5"),
|
||||
);
|
||||
response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod invoice_status_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_requests_do_not_query_the_wallet() {
|
||||
for path in [
|
||||
"/bad",
|
||||
"/content//invoice-status/aa",
|
||||
"/content/file/invoice-status/aa",
|
||||
"/content/file/invoice-status/",
|
||||
"/content/file/invoice-status/not-a-hash",
|
||||
] {
|
||||
let response = invoice_status_response(path, |_, _| async {
|
||||
panic!("Invalid request reached wallet");
|
||||
#[allow(unreachable_code)]
|
||||
Ok(serde_json::json!({"paid":false}))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(response.headers()["content-type"], "application/json");
|
||||
let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
|
||||
assert!(
|
||||
serde_json::from_slice::<serde_json::Value>(&body).unwrap()["error"].is_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settlement_results_and_failures_have_explicit_http_responses() {
|
||||
let hash = "AB".repeat(32);
|
||||
let path = format!("/content/file/invoice-status/{hash}");
|
||||
for paid in [false, true] {
|
||||
let response = invoice_status_response(&path, |hash, id| async move {
|
||||
assert_eq!(hash, "ab".repeat(32));
|
||||
assert_eq!(id, "file");
|
||||
Ok(serde_json::json!({"paid":paid}))
|
||||
})
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&body).unwrap()["paid"],
|
||||
paid
|
||||
);
|
||||
}
|
||||
let response = invoice_status_response(&path, |_, _| async {
|
||||
anyhow::bail!("private wallet details must not escape")
|
||||
})
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(response.headers()["retry-after"], "5");
|
||||
let body = hyper::body::to_bytes(response.into_body()).await.unwrap();
|
||||
let text = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(text.contains("without paying again"));
|
||||
assert!(!text.contains("private wallet"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
use super::{build_response, ApiHandler};
|
||||
use crate::content_lightning::{Binding, Journal, Phase};
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{body::HttpBody, Body, Method, Request, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncReadExt;
|
||||
pub(crate) const ROUTE: &str = "/content/lightning/v1/operation";
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Operation {
|
||||
pub binding: Binding,
|
||||
pub action: String,
|
||||
}
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_lightning_purchase(
|
||||
&self,
|
||||
mut request: Request<Body>,
|
||||
) -> Result<Response<Body>> {
|
||||
anyhow::ensure!(
|
||||
request.method() == Method::POST && request.uri().path() == ROUTE,
|
||||
"Invalid invoice route"
|
||||
);
|
||||
let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = request.body_mut().data().await {
|
||||
let chunk = chunk?;
|
||||
anyhow::ensure!(
|
||||
bytes.len() + chunk.len() <= 16384,
|
||||
"Invoice request too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk)
|
||||
}
|
||||
Ok::<_, anyhow::Error>(bytes)
|
||||
})
|
||||
.await
|
||||
.context("Invoice request timed out")??;
|
||||
let seller = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
|
||||
let buyer = crate::content_auth::authenticate_request(
|
||||
request.headers(),
|
||||
&seller,
|
||||
&Method::POST,
|
||||
ROUTE,
|
||||
&bytes,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)?;
|
||||
let operation: Operation = serde_json::from_slice(&bytes)?;
|
||||
anyhow::ensure!(
|
||||
operation.binding.buyer_did == buyer && operation.binding.seller_did == seller,
|
||||
"Invoice peer identity mismatch"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
operation.action.as_str(),
|
||||
"create" | "status" | "cancel" | "download"
|
||||
),
|
||||
"Invalid invoice action"
|
||||
);
|
||||
let binding = &operation.binding;
|
||||
let journal = Journal::open(&self.config.data_dir).await?;
|
||||
let mut saved = journal.seller(binding)?;
|
||||
if saved.is_none() {
|
||||
anyhow::ensure!(
|
||||
operation.action == "create",
|
||||
"Unknown original invoice operation"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!binding.content_id.starts_with("registered_"),
|
||||
"Registered rentals use their native purchase contract"
|
||||
);
|
||||
let catalog = crate::content_server::load_catalog(&self.config.data_dir).await?;
|
||||
let item = catalog
|
||||
.items
|
||||
.iter()
|
||||
.find(|v| v.id == binding.content_id)
|
||||
.context("Shared item unavailable")?;
|
||||
let visible = match &item.availability {
|
||||
crate::content_server::Availability::Nobody => false,
|
||||
crate::content_server::Availability::AllPeers => true,
|
||||
crate::content_server::Availability::Specific { peers } => peers.contains(&buyer),
|
||||
};
|
||||
anyhow::ensure!(visible, "Item is not shared with this buyer");
|
||||
anyhow::ensure!(
|
||||
matches!(&item.access,crate::content_server::AccessControl::Paid{price_sats,..} if *price_sats==binding.price_sats)
|
||||
&& crate::content_server::method_accepted(&item.access, "lightning"),
|
||||
"Invoice price or accepted method changed"
|
||||
);
|
||||
crate::content_server::ensure_payment_source_available(&self.config.data_dir, item)
|
||||
.await?;
|
||||
let source = crate::content_server::content_file_path(&self.config.data_dir, item);
|
||||
let roots = [
|
||||
self.config.data_dir.join("content/files"),
|
||||
self.config.data_dir.join("filebrowser"),
|
||||
];
|
||||
let (root, relative) = roots
|
||||
.iter()
|
||||
.find_map(|root| {
|
||||
source
|
||||
.strip_prefix(root)
|
||||
.ok()
|
||||
.map(|p| (root.clone(), p.to_path_buf()))
|
||||
})
|
||||
.context("Unsupported invoice source root")?;
|
||||
let data = self.config.data_dir.clone();
|
||||
let id = binding.content_id.clone();
|
||||
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
|
||||
impl Drop for CancelCopy {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
|
||||
false,
|
||||
)));
|
||||
let cancelled = cancel_copy.0.clone();
|
||||
let snapshot = tokio::task::spawn_blocking(move || {
|
||||
crate::content_snapshot::prepare(
|
||||
&data,
|
||||
&root,
|
||||
&id,
|
||||
&relative,
|
||||
&crate::media_registration::Limits {
|
||||
max_bytes: 64 * 1024 * 1024 * 1024,
|
||||
cancelled: &cancelled,
|
||||
},
|
||||
64 * 1024 * 1024 * 1024,
|
||||
512 * 1024 * 1024,
|
||||
|_| Ok(()),
|
||||
)
|
||||
})
|
||||
.await??;
|
||||
anyhow::ensure!(
|
||||
snapshot.size == item.size_bytes,
|
||||
"Shared file changed before invoice"
|
||||
);
|
||||
// Source metadata is private and committed before AddInvoice dispatch.
|
||||
let record = crate::content_server::publish_snapshot_invoice(
|
||||
&self.config.data_dir,
|
||||
item,
|
||||
&journal,
|
||||
binding.clone(),
|
||||
crate::content_lightning::RetainedFile {
|
||||
sha256: snapshot.sha256,
|
||||
size: snapshot.size,
|
||||
filename: item.filename.clone(),
|
||||
mime_type: item.mime_type.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
saved = Some(record);
|
||||
}
|
||||
let mut saved = saved.context("Missing invoice operation")?;
|
||||
anyhow::ensure!(
|
||||
saved.source.is_some(),
|
||||
"Original invoice source is not prepared; no new invoice dispatched"
|
||||
);
|
||||
let status = if operation.action == "cancel" && saved.phase == Phase::Prepared {
|
||||
saved.phase = Phase::CanceledUnpaid;
|
||||
journal.save_seller(&saved)?;
|
||||
saved.status()
|
||||
} else if operation.action != "create"
|
||||
&& operation.action != "cancel"
|
||||
&& saved.phase == Phase::Prepared
|
||||
{
|
||||
saved.status()
|
||||
} else {
|
||||
self.rpc_handler
|
||||
.drive_external_invoice(&journal, binding, operation.action == "cancel")
|
||||
.await?
|
||||
};
|
||||
// The original legacy delivery mechanism remains usable by its hash.
|
||||
if status.bolt11.is_some() {
|
||||
crate::content_invoice::record_pending(
|
||||
&self.config.data_dir,
|
||||
&status.payment_hash,
|
||||
&binding.content_id,
|
||||
binding.price_sats,
|
||||
)
|
||||
.await?;
|
||||
if status.state == Phase::Settled {
|
||||
crate::content_invoice::mark_paid(&self.config.data_dir, &status.payment_hash)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
if operation.action == "download" {
|
||||
anyhow::ensure!(
|
||||
status.state == Phase::Settled,
|
||||
"Original invoice has not settled"
|
||||
);
|
||||
let source = status
|
||||
.source
|
||||
.as_ref()
|
||||
.context("Original invoice snapshot is missing")?;
|
||||
let data = self.config.data_dir.clone();
|
||||
let id = binding.content_id.clone();
|
||||
let retained = source.clone();
|
||||
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
|
||||
impl Drop for CancelCopy {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
|
||||
false,
|
||||
)));
|
||||
let cancelled = cancel_copy.0.clone();
|
||||
let snapshot = tokio::task::spawn_blocking(move || {
|
||||
crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
|
||||
})
|
||||
.await??;
|
||||
let stream = futures_util::stream::try_unfold(
|
||||
(tokio::fs::File::from_std(snapshot.file), source.size),
|
||||
|(mut file, left)| async move {
|
||||
if left == 0 {
|
||||
return Ok::<_, std::io::Error>(None);
|
||||
}
|
||||
let mut bytes = vec![0; left.min(65536) as usize];
|
||||
let count = file.read(&mut bytes).await?;
|
||||
if count == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
"Original invoice snapshot ended early",
|
||||
));
|
||||
}
|
||||
bytes.truncate(count);
|
||||
Ok(Some((bytes, (file, left - count as u64))))
|
||||
},
|
||||
);
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", &source.mime_type)
|
||||
.header("Content-Length", source.size)
|
||||
.header("Cache-Control", "private, no-store")
|
||||
.body(Body::wrap_stream(stream))?);
|
||||
}
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
Body::from(serde_json::to_vec(&status)?),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
mod blob;
|
||||
mod cdp;
|
||||
mod cloud_purchase;
|
||||
mod content;
|
||||
mod dwn;
|
||||
pub(crate) mod lightning_purchase;
|
||||
mod model_proxy;
|
||||
mod node_message;
|
||||
pub(crate) mod onchain_purchase;
|
||||
mod proxy;
|
||||
mod purchase;
|
||||
mod registered_media;
|
||||
mod remote_input;
|
||||
mod remote_relay;
|
||||
mod rental_playback;
|
||||
mod routstr_proxy;
|
||||
mod terminal;
|
||||
mod websocket;
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
@@ -384,6 +391,12 @@ impl ApiHandler {
|
||||
let path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
|
||||
if path.starts_with("/api/rental-playback/") {
|
||||
return self
|
||||
.handle_local_rental_request(&method, &path, req.headers())
|
||||
.await;
|
||||
}
|
||||
|
||||
// Handle CORS preflight for all routes
|
||||
if method == Method::OPTIONS {
|
||||
let mut builder = Response::builder()
|
||||
@@ -414,6 +427,16 @@ impl ApiHandler {
|
||||
.await;
|
||||
}
|
||||
|
||||
// Owner terminal attachment — the browser socket is disposable; the
|
||||
// authenticated tmux session survives reconnects and browser closes.
|
||||
if method == Method::GET && path == "/ws/terminal" {
|
||||
if !self.is_authenticated(req.headers()).await {
|
||||
tracing::warn!("401 WebSocket /ws/terminal — session invalid or missing");
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
return Self::handle_terminal_websocket(req).await;
|
||||
}
|
||||
|
||||
// Remote input WebSocket — companion app sends keyboard/mouse events
|
||||
if method == Method::GET && path == "/ws/remote-input" {
|
||||
if !self.is_authenticated(req.headers()).await {
|
||||
@@ -444,6 +467,32 @@ impl ApiHandler {
|
||||
.await;
|
||||
}
|
||||
|
||||
if method == Method::POST && path == lightning_purchase::ROUTE {
|
||||
return self.handle_lightning_purchase(req).await;
|
||||
}
|
||||
// Purchase routes bound the original body before the generic buffer.
|
||||
if method == Method::POST
|
||||
&& matches!(
|
||||
path.as_str(),
|
||||
crate::content_purchase_protocol::PREPARE_OFFER_ROUTE
|
||||
| crate::content_purchase_protocol::OFFER_ROUTE
|
||||
| crate::content_purchase_protocol::ACCEPT_ROUTE
|
||||
| crate::content_purchase_protocol::SETTLE_ROUTE
|
||||
| crate::content_purchase_protocol::STATUS_ROUTE
|
||||
| crate::content_purchase_protocol::CANCEL_ROUTE
|
||||
)
|
||||
{
|
||||
return self.handle_purchase_request(req).await;
|
||||
}
|
||||
|
||||
if method == Method::POST
|
||||
&& path.starts_with("/content/registered_")
|
||||
&& path.contains("/rental/")
|
||||
&& (path.ends_with("/prepare") || path.ends_with("/start"))
|
||||
{
|
||||
return self.handle_rental_control(req).await;
|
||||
}
|
||||
|
||||
// Convert body to bytes for non-WS routes
|
||||
let headers = req.headers().clone();
|
||||
let query_string = req.uri().query().map(|s| s.to_string()).unwrap_or_default();
|
||||
@@ -506,6 +555,15 @@ impl ApiHandler {
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
(Method::GET, "/api/terminal/sessions") => {
|
||||
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
|
||||
terminal::list_response().await
|
||||
}
|
||||
(Method::POST, "/api/terminal/sessions") => {
|
||||
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); }
|
||||
terminal::create(&body_bytes).await
|
||||
}
|
||||
|
||||
// Node message — P2P endpoint (authenticated by source validation, not cookie)
|
||||
(Method::POST, "/archipelago/node-message") => {
|
||||
Self::handle_node_message(body_bytes).await
|
||||
@@ -584,6 +642,15 @@ impl ApiHandler {
|
||||
Self::handle_blob_download(&self.blob_store, p, &query_string).await
|
||||
}
|
||||
|
||||
// Immutable registered rentals use durable seller receipts and their
|
||||
// first-open window, never legacy mutable filename shares.
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.contains("/purchase/") => {
|
||||
self.handle_cloud_purchase(p, &headers).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/content/registered_") && p.contains("/rental/") => {
|
||||
self.handle_registered_rental(p, &headers).await
|
||||
}
|
||||
|
||||
// Content preview — degraded previews for paid content (no auth, no payment)
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => {
|
||||
Self::handle_content_preview(p, &self.config).await
|
||||
@@ -591,7 +658,7 @@ impl ApiHandler {
|
||||
|
||||
// Lightning-invoice peer-file sale (#46): mint invoice / poll settlement
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/invoice") => {
|
||||
self.handle_content_invoice(p).await
|
||||
self.handle_content_invoice(p, &headers).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => {
|
||||
self.handle_content_invoice_status(p).await
|
||||
@@ -602,7 +669,7 @@ impl ApiHandler {
|
||||
self.handle_content_onchain_status(p).await
|
||||
}
|
||||
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/onchain") => {
|
||||
self.handle_content_onchain(p).await
|
||||
self.handle_content_onchain(p, &headers).await
|
||||
}
|
||||
|
||||
// Content serving — peers access shared content over Tor (no session auth);
|
||||
@@ -612,7 +679,7 @@ impl ApiHandler {
|
||||
}
|
||||
|
||||
// Content catalog — list available content (no session auth, for peers)
|
||||
(Method::GET, "/content") => Self::handle_content_catalog(&self.config).await,
|
||||
(Method::GET, "/content") => self.handle_content_catalog(&headers).await,
|
||||
|
||||
// Electrs status — unauthenticated (read-only sync status)
|
||||
(Method::GET, "/electrs-status") => Self::handle_electrs_status().await,
|
||||
@@ -623,6 +690,34 @@ impl ApiHandler {
|
||||
// (upstream Gitea has no ACAO header) or CSP (IP-port upstream
|
||||
// falls outside `connect-src`). Session-authenticated so only
|
||||
// the logged-in node owner can spin up fetches.
|
||||
(Method::GET, "/api/node-app-catalog") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
}
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
crate::container::node_catalog::verified_body(&data_dir)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| Err(anyhow::anyhow!(error)));
|
||||
let (status, body) = match result {
|
||||
Ok(Some(body)) => (StatusCode::OK, body),
|
||||
Ok(None) => (StatusCode::NOT_FOUND, "{}".to_owned()),
|
||||
Err(error) => {
|
||||
tracing::warn!("Node demo catalog rejected: {error}");
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
"{\"error\":\"Node demo catalog is unavailable\"}".to_owned(),
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "private, no-store")
|
||||
.body(hyper::Body::from(body))?)
|
||||
}
|
||||
|
||||
(Method::GET, "/api/app-catalog") => {
|
||||
if !self.is_authenticated(&headers).await {
|
||||
return Ok(Self::unauthorized());
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
use super::{build_response, ApiHandler};
|
||||
use crate::{content_lightning::Binding, content_onchain_seller::Journal};
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{body::HttpBody, Body, Method, Request, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncReadExt;
|
||||
pub(crate) const ROUTE: &str = "/content/onchain/v1/operation";
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Operation {
|
||||
pub binding: Binding,
|
||||
pub action: String,
|
||||
}
|
||||
// Load wallet credentials only after authenticated request validation reaches a
|
||||
// wallet operation. Tests inject the same typed boundary without live services.
|
||||
struct NativeSellerWallet<'a>(&'a crate::api::rpc::RpcHandler);
|
||||
impl crate::content_onchain_seller::Wallet for NativeSellerWallet<'_> {
|
||||
async fn network(&self) -> Result<crate::content_onchain::ChainNetwork> {
|
||||
self.0.onchain_purchase_wallet().await?.network().await
|
||||
}
|
||||
async fn preflight(&self, network: crate::content_onchain::ChainNetwork) -> Result<()> {
|
||||
self.0
|
||||
.onchain_purchase_wallet()
|
||||
.await?
|
||||
.preflight(network)
|
||||
.await
|
||||
}
|
||||
async fn allocate(&self) -> Result<String> {
|
||||
self.0.onchain_purchase_wallet().await?.allocate().await
|
||||
}
|
||||
async fn received(&self, address: &str, amount: u64) -> Result<bool> {
|
||||
self.0
|
||||
.onchain_purchase_wallet()
|
||||
.await?
|
||||
.received(address, amount)
|
||||
.await
|
||||
}
|
||||
}
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_onchain_purchase(
|
||||
&self,
|
||||
request: Request<Body>,
|
||||
) -> Result<Response<Body>> {
|
||||
self.handle_onchain_purchase_with_wallet(request, &NativeSellerWallet(&self.rpc_handler))
|
||||
.await
|
||||
}
|
||||
async fn handle_onchain_purchase_with_wallet<W: crate::content_onchain_seller::Wallet>(
|
||||
&self,
|
||||
mut request: Request<Body>,
|
||||
wallet: &W,
|
||||
) -> Result<Response<Body>> {
|
||||
anyhow::ensure!(
|
||||
request.method() == Method::POST && request.uri().path() == ROUTE,
|
||||
"Invalid on-chain purchase route"
|
||||
);
|
||||
let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = request.body_mut().data().await {
|
||||
let chunk = chunk?;
|
||||
anyhow::ensure!(
|
||||
bytes.len() + chunk.len() <= 16384,
|
||||
"On-chain purchase request too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk)
|
||||
}
|
||||
Ok::<_, anyhow::Error>(bytes)
|
||||
})
|
||||
.await
|
||||
.context("On-chain purchase request timed out")??;
|
||||
let seller = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
|
||||
let buyer = crate::content_auth::authenticate_request(
|
||||
request.headers(),
|
||||
&seller,
|
||||
&Method::POST,
|
||||
ROUTE,
|
||||
&bytes,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)?;
|
||||
let operation: Operation = serde_json::from_slice(&bytes)?;
|
||||
anyhow::ensure!(
|
||||
operation.binding.buyer_did == buyer && operation.binding.seller_did == seller,
|
||||
"On-chain purchase peer identity mismatch"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
matches!(
|
||||
operation.action.as_str(),
|
||||
"create" | "offer" | "allocate" | "status" | "download" | "cancel"
|
||||
),
|
||||
"Invalid on-chain purchase action"
|
||||
);
|
||||
let binding = &operation.binding;
|
||||
let journal = Journal::open(&self.config.data_dir).await?;
|
||||
let retired = if operation.action == "cancel" {
|
||||
Some(journal.retire_unallocated(binding)?)
|
||||
} else {
|
||||
journal.retirement(binding)?
|
||||
};
|
||||
if let Some(ack) = retired {
|
||||
return Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
Body::from(serde_json::to_vec(&ack)?),
|
||||
));
|
||||
}
|
||||
let mut saved = journal.load(binding)?;
|
||||
if saved.is_none() {
|
||||
anyhow::ensure!(
|
||||
matches!(operation.action.as_str(), "create" | "offer"),
|
||||
"Unknown original on-chain purchase operation"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
!binding.content_id.starts_with("registered_"),
|
||||
"Registered rentals use their native purchase contract"
|
||||
);
|
||||
let catalog = crate::content_server::load_catalog(&self.config.data_dir).await?;
|
||||
let item = catalog
|
||||
.items
|
||||
.iter()
|
||||
.find(|v| v.id == binding.content_id)
|
||||
.context("Shared item unavailable")?;
|
||||
let visible = match &item.availability {
|
||||
crate::content_server::Availability::Nobody => false,
|
||||
crate::content_server::Availability::AllPeers => true,
|
||||
crate::content_server::Availability::Specific { peers } => peers.contains(&buyer),
|
||||
};
|
||||
anyhow::ensure!(visible, "Item is not shared with this buyer");
|
||||
anyhow::ensure!(
|
||||
matches!(&item.access,crate::content_server::AccessControl::Paid{price_sats,..} if *price_sats==binding.price_sats)
|
||||
&& crate::content_server::method_accepted(&item.access, "onchain"),
|
||||
"On-chain purchase price or accepted method changed"
|
||||
);
|
||||
crate::content_server::ensure_payment_source_available(&self.config.data_dir, item)
|
||||
.await?;
|
||||
let source = crate::content_server::content_file_path(&self.config.data_dir, item);
|
||||
let roots = [
|
||||
self.config.data_dir.join("content/files"),
|
||||
self.config.data_dir.join("filebrowser"),
|
||||
];
|
||||
let (root, relative) = roots
|
||||
.iter()
|
||||
.find_map(|root| {
|
||||
source
|
||||
.strip_prefix(root)
|
||||
.ok()
|
||||
.map(|p| (root.clone(), p.to_path_buf()))
|
||||
})
|
||||
.context("Unsupported on-chain purchase source root")?;
|
||||
let data = self.config.data_dir.clone();
|
||||
let id = binding.content_id.clone();
|
||||
struct CancelCopy(std::sync::Arc<std::sync::atomic::AtomicBool>);
|
||||
impl Drop for CancelCopy {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let cancel_copy = CancelCopy(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
|
||||
false,
|
||||
)));
|
||||
let cancelled = cancel_copy.0.clone();
|
||||
let snapshot = tokio::task::spawn_blocking(move || {
|
||||
crate::content_snapshot::prepare(
|
||||
&data,
|
||||
&root,
|
||||
&id,
|
||||
&relative,
|
||||
&crate::media_registration::Limits {
|
||||
max_bytes: 64 * 1024 * 1024 * 1024,
|
||||
cancelled: &cancelled,
|
||||
},
|
||||
64 * 1024 * 1024 * 1024,
|
||||
512 * 1024 * 1024,
|
||||
|_| Ok(()),
|
||||
)
|
||||
})
|
||||
.await??;
|
||||
anyhow::ensure!(
|
||||
snapshot.size == item.size_bytes,
|
||||
"Shared file changed before on-chain purchase"
|
||||
);
|
||||
// Source metadata is private and committed before address allocation.
|
||||
let record = crate::content_server::publish_snapshot_onchain(
|
||||
&self.config.data_dir,
|
||||
item,
|
||||
&journal,
|
||||
binding.clone(),
|
||||
crate::content_lightning::RetainedFile {
|
||||
sha256: snapshot.sha256,
|
||||
size: snapshot.size,
|
||||
filename: item.filename.clone(),
|
||||
mime_type: item.mime_type.clone(),
|
||||
},
|
||||
wallet.network().await?,
|
||||
)
|
||||
.await?;
|
||||
saved = Some(record);
|
||||
}
|
||||
saved.context("Missing original on-chain operation")?;
|
||||
let status = if operation.action == "allocate" {
|
||||
crate::content_server::allocate_onchain_offer(
|
||||
&self.config.data_dir,
|
||||
&journal,
|
||||
binding,
|
||||
wallet,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
crate::content_onchain_seller::drive(&journal, binding, false, wallet).await?
|
||||
};
|
||||
if operation.action == "download" {
|
||||
anyhow::ensure!(status.paid, "Original on-chain purchase has not settled");
|
||||
let source = &status.source;
|
||||
let data = self.config.data_dir.clone();
|
||||
let id = binding.content_id.clone();
|
||||
let retained = source.clone();
|
||||
let snapshot = tokio::task::spawn_blocking(move || {
|
||||
crate::content_snapshot::open_matching(&data, &id, &retained.sha256, retained.size)
|
||||
})
|
||||
.await??;
|
||||
let stream = futures_util::stream::try_unfold(
|
||||
(tokio::fs::File::from_std(snapshot.file), source.size),
|
||||
|(mut file, left)| async move {
|
||||
if left == 0 {
|
||||
return Ok::<_, std::io::Error>(None);
|
||||
}
|
||||
let mut bytes = vec![0; left.min(65536) as usize];
|
||||
let count = file.read(&mut bytes).await?;
|
||||
if count == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
"Original on-chain purchase snapshot ended early",
|
||||
));
|
||||
}
|
||||
bytes.truncate(count);
|
||||
Ok(Some((bytes, (file, left - count as u64))))
|
||||
},
|
||||
);
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", &source.mime_type)
|
||||
.header("Content-Length", source.size)
|
||||
.header("Cache-Control", "private, no-store")
|
||||
.body(Body::wrap_stream(stream))?);
|
||||
}
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
Body::from(serde_json::to_vec(&status)?),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::content_onchain_seller::{Allocation, UnallocatedAck};
|
||||
use hyper::service::{make_service_fn, service_fn};
|
||||
use std::{convert::Infallible, sync::Arc};
|
||||
#[derive(Default)]
|
||||
struct MockWallet {
|
||||
allocations: std::sync::atomic::AtomicUsize,
|
||||
lose_reply: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
impl crate::content_onchain_seller::Wallet for MockWallet {
|
||||
async fn network(&self) -> Result<crate::content_onchain::ChainNetwork> {
|
||||
Ok(crate::content_onchain::ChainNetwork::Regtest)
|
||||
}
|
||||
async fn preflight(&self, _: crate::content_onchain::ChainNetwork) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn allocate(&self) -> Result<String> {
|
||||
self.allocations
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
anyhow::ensure!(
|
||||
!self
|
||||
.lose_reply
|
||||
.swap(false, std::sync::atomic::Ordering::SeqCst),
|
||||
"Simulated lost allocation response"
|
||||
);
|
||||
let mut bytes = vec![0, 20];
|
||||
bytes.extend([17u8; 20]);
|
||||
Ok(bitcoin::Address::from_script(
|
||||
&bitcoin::ScriptBuf::from_bytes(bytes),
|
||||
bitcoin::Network::Regtest,
|
||||
)?
|
||||
.to_string())
|
||||
}
|
||||
async fn received(&self, _: &str, _: u64) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
struct HttpFixture {
|
||||
wallet: Arc<MockWallet>,
|
||||
data: tempfile::TempDir,
|
||||
_buyer_data: tempfile::TempDir,
|
||||
buyer: crate::identity::NodeIdentity,
|
||||
seller: String,
|
||||
url: String,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Drop for HttpFixture {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
async fn fixture() -> HttpFixture {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let buyer_data = tempfile::tempdir().unwrap();
|
||||
let buyer = crate::identity::NodeIdentity::load_or_create(buyer_data.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut config = crate::config::Config::default();
|
||||
config.data_dir = data.path().to_path_buf();
|
||||
let handler = Arc::new(
|
||||
ApiHandler::new(
|
||||
config,
|
||||
Arc::new(crate::state::StateManager::new()),
|
||||
Arc::new(crate::monitoring::MetricsStore::new()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let seller = crate::identity::did_key_from_pubkey_hex(&handler.self_pubkey_hex).unwrap();
|
||||
let wallet = Arc::new(MockWallet::default());
|
||||
let server_wallet = wallet.clone();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = hyper::Server::from_tcp(listener)
|
||||
.unwrap()
|
||||
.serve(make_service_fn(move |_| {
|
||||
let handler = handler.clone();
|
||||
let wallet = server_wallet.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |request| {
|
||||
let handler = handler.clone();
|
||||
let wallet = wallet.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(
|
||||
handler
|
||||
.handle_onchain_purchase_with_wallet(request, wallet.as_ref())
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
Body::from("{\"error\":\"rejected\"}"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}));
|
||||
let task = tokio::spawn(async move {
|
||||
server.await.unwrap();
|
||||
});
|
||||
HttpFixture {
|
||||
wallet,
|
||||
data,
|
||||
_buyer_data: buyer_data,
|
||||
buyer,
|
||||
seller,
|
||||
url,
|
||||
task,
|
||||
}
|
||||
}
|
||||
impl HttpFixture {
|
||||
fn binding(&self) -> Binding {
|
||||
Binding {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
buyer_did: self.buyer.did_key().unwrap(),
|
||||
seller_did: self.seller.clone(),
|
||||
content_id: "file".into(),
|
||||
price_sats: 546,
|
||||
}
|
||||
}
|
||||
async fn send(
|
||||
&self,
|
||||
body: &[u8],
|
||||
signed_body: Option<&[u8]>,
|
||||
audience: Option<&str>,
|
||||
) -> reqwest::Response {
|
||||
let mut request = reqwest::Client::new()
|
||||
.post(format!("{}{}", self.url, ROUTE))
|
||||
.header("content-type", "application/json")
|
||||
.body(body.to_vec());
|
||||
if let Some(signed) = signed_body {
|
||||
let proof = crate::content_auth::sign_request(
|
||||
&self.buyer,
|
||||
audience.unwrap_or(&self.seller),
|
||||
&Method::POST,
|
||||
ROUTE,
|
||||
signed,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.unwrap();
|
||||
request = request.header(crate::content_auth::REQUEST_HEADER, proof);
|
||||
}
|
||||
request.send().await.unwrap()
|
||||
}
|
||||
async fn operation(&self, binding: &Binding, action: &str) -> reqwest::Response {
|
||||
let body = serde_json::to_vec(&Operation {
|
||||
binding: binding.clone(),
|
||||
action: action.into(),
|
||||
})
|
||||
.unwrap();
|
||||
self.send(&body, Some(&body), None).await
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn authenticated_cancel_roundtrip_lost_reply_and_delayed_create_return_same_retirement() {
|
||||
let server = fixture().await;
|
||||
let binding = server.binding();
|
||||
// Drop the original reply after headers: terminal state must already be durable.
|
||||
let first = server.operation(&binding, "cancel").await;
|
||||
assert_eq!(first.status(), reqwest::StatusCode::OK);
|
||||
drop(first);
|
||||
let replay = server.operation(&binding, "cancel").await;
|
||||
assert_eq!(replay.status(), reqwest::StatusCode::OK);
|
||||
let ack: UnallocatedAck = replay.json().await.unwrap();
|
||||
ack.validate(&binding).unwrap();
|
||||
let delayed = server.operation(&binding, "create").await;
|
||||
assert_eq!(delayed.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(delayed.json::<UnallocatedAck>().await.unwrap(), ack);
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert_eq!(journal.retirement(&binding).unwrap(), Some(ack));
|
||||
assert!(journal.load(&binding).unwrap().is_none());
|
||||
assert!(!server.data.path().join("content-snapshots").exists());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn cancellation_http_rejects_missing_proof_body_tamper_and_wrong_seller_without_tombstone(
|
||||
) {
|
||||
let server = fixture().await;
|
||||
let binding = server.binding();
|
||||
let body = serde_json::to_vec(&Operation {
|
||||
binding: binding.clone(),
|
||||
action: "cancel".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!server.send(&body, None, None).await.status().is_success());
|
||||
let mut changed = binding.clone();
|
||||
changed.price_sats += 1;
|
||||
let changed = serde_json::to_vec(&Operation {
|
||||
binding: changed,
|
||||
action: "cancel".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!server
|
||||
.send(&changed, Some(&body), None)
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let wrong = crate::identity::did_key_from_pubkey_hex(&hex::encode([8; 32])).unwrap();
|
||||
assert!(!server
|
||||
.send(&body, Some(&body), Some(&wrong))
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert!(journal.retirement(&binding).unwrap().is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn authenticated_cancel_cannot_retire_dispatched_or_issued_address() {
|
||||
let server = fixture().await;
|
||||
let mut script = vec![0, 20];
|
||||
script.extend([1; 20]);
|
||||
let address = bitcoin::Address::from_script(
|
||||
&bitcoin::ScriptBuf::from_bytes(script),
|
||||
bitcoin::Network::Regtest,
|
||||
)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
for allocation in [Allocation::Dispatched, Allocation::Ready { address }] {
|
||||
let binding = server.binding();
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
let mut record = journal
|
||||
.prepare(
|
||||
binding.clone(),
|
||||
crate::content_lightning::RetainedFile {
|
||||
sha256: "a".repeat(64),
|
||||
size: 4,
|
||||
filename: "original.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
},
|
||||
crate::content_onchain::ChainNetwork::Regtest,
|
||||
)
|
||||
.unwrap();
|
||||
record.allocation = allocation.clone();
|
||||
journal.save(&record).unwrap();
|
||||
drop(journal);
|
||||
assert!(!server
|
||||
.operation(&binding, "cancel")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert!(journal.retirement(&binding).unwrap().is_none());
|
||||
assert_eq!(
|
||||
journal.load(&binding).unwrap().unwrap().allocation,
|
||||
allocation
|
||||
);
|
||||
}
|
||||
}
|
||||
async fn seed_unallocated_offer(server: &HttpFixture) -> Binding {
|
||||
let binding = server.binding();
|
||||
crate::content_server::save_catalog(
|
||||
server.data.path(),
|
||||
&crate::content_server::ContentCatalog {
|
||||
items: vec![crate::content_server::ContentItem {
|
||||
id: binding.content_id.clone(),
|
||||
filename: "original.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
size_bytes: 4,
|
||||
description: String::new(),
|
||||
added_at: String::new(),
|
||||
availability: crate::content_server::Availability::AllPeers,
|
||||
access: crate::content_server::AccessControl::Paid {
|
||||
price_sats: 546,
|
||||
accepted: vec!["onchain".into()],
|
||||
},
|
||||
}],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let root = server.data.path().join("content/files");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("original.txt"), b"test").unwrap();
|
||||
let cancelled = std::sync::atomic::AtomicBool::new(false);
|
||||
let snapshot = crate::content_snapshot::prepare(
|
||||
server.data.path(),
|
||||
&root,
|
||||
&binding.content_id,
|
||||
std::path::Path::new("original.txt"),
|
||||
&crate::media_registration::Limits {
|
||||
max_bytes: 1024,
|
||||
cancelled: &cancelled,
|
||||
},
|
||||
1024 * 1024,
|
||||
0,
|
||||
|_| Ok(()),
|
||||
)
|
||||
.unwrap();
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
journal
|
||||
.prepare(
|
||||
binding.clone(),
|
||||
crate::content_lightning::RetainedFile {
|
||||
sha256: snapshot.sha256,
|
||||
size: 4,
|
||||
filename: "original.txt".into(),
|
||||
mime_type: "text/plain".into(),
|
||||
},
|
||||
crate::content_onchain::ChainNetwork::Regtest,
|
||||
)
|
||||
.unwrap();
|
||||
binding
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_offer_never_allocates_or_returns_a_receive_address() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
let result = server.operation(&binding, "offer").await;
|
||||
assert_eq!(result.status(), reqwest::StatusCode::OK);
|
||||
let body: serde_json::Value = result.json().await.unwrap();
|
||||
assert_eq!(body["allocation"]["state"], "prepared");
|
||||
assert!(body["allocation"].get("address").is_none());
|
||||
assert!(body.get("address").is_none());
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert_eq!(
|
||||
journal.load(&binding).unwrap().unwrap().allocation,
|
||||
Allocation::Prepared
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reviewed_offer_can_cancel_and_delayed_explicit_allocate_cannot_revive_it() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
assert_eq!(
|
||||
server.operation(&binding, "offer").await.status(),
|
||||
reqwest::StatusCode::OK
|
||||
);
|
||||
let retired: UnallocatedAck = server
|
||||
.operation(&binding, "cancel")
|
||||
.await
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
retired.validate(&binding).unwrap();
|
||||
// Represents a delayed Pay request from the old modal after cancellation.
|
||||
let late = server.operation(&binding, "allocate").await;
|
||||
assert_eq!(late.status(), reqwest::StatusCode::OK);
|
||||
assert_eq!(late.json::<UnallocatedAck>().await.unwrap(), retired);
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert_eq!(
|
||||
journal.load(&binding).unwrap().unwrap().allocation,
|
||||
Allocation::Prepared
|
||||
);
|
||||
assert_eq!(journal.retirement(&binding).unwrap(), Some(retired));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changing_authenticated_offer_body_to_allocate_cannot_dispatch_an_address() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
let reviewed = serde_json::to_vec(&Operation {
|
||||
binding: binding.clone(),
|
||||
action: "offer".into(),
|
||||
})
|
||||
.unwrap();
|
||||
let changed = serde_json::to_vec(&Operation {
|
||||
binding: binding.clone(),
|
||||
action: "allocate".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!server
|
||||
.send(&changed, Some(&reviewed), None)
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let journal = Journal::open(server.data.path()).await.unwrap();
|
||||
assert_eq!(
|
||||
journal.load(&binding).unwrap().unwrap().allocation,
|
||||
Allocation::Prepared
|
||||
);
|
||||
assert!(journal.retirement(&binding).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_allocation_reuses_original_address_after_lost_http_reply() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
assert!(server
|
||||
.operation(&binding, "offer")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
assert_eq!(
|
||||
server
|
||||
.wallet
|
||||
.allocations
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
0
|
||||
);
|
||||
// Caller loses the response after seller durability; recovery returns the same record.
|
||||
drop(server.operation(&binding, "allocate").await);
|
||||
let recovered: crate::content_onchain_seller::Record = server
|
||||
.operation(&binding, "allocate")
|
||||
.await
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(recovered.quote().unwrap().is_some());
|
||||
let repeated: crate::content_onchain_seller::Record = server
|
||||
.operation(&binding, "allocate")
|
||||
.await
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(recovered, repeated);
|
||||
assert_eq!(
|
||||
server
|
||||
.wallet
|
||||
.allocations
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1
|
||||
);
|
||||
assert!(!server
|
||||
.operation(&binding, "cancel")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn lost_wallet_allocation_reply_never_allocates_a_second_address() {
|
||||
let server = fixture().await;
|
||||
let binding = seed_unallocated_offer(&server).await;
|
||||
server
|
||||
.wallet
|
||||
.lose_reply
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
assert!(!server
|
||||
.operation(&binding, "allocate")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
let recovered: crate::content_onchain_seller::Record = server
|
||||
.operation(&binding, "allocate")
|
||||
.await
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(recovered.allocation, Allocation::Dispatched);
|
||||
assert!(recovered.quote().unwrap().is_none());
|
||||
assert_eq!(
|
||||
server
|
||||
.wallet
|
||||
.allocations
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1
|
||||
);
|
||||
assert!(!server
|
||||
.operation(&binding, "cancel")
|
||||
.await
|
||||
.status()
|
||||
.is_success());
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,19 @@ impl ApiHandler {
|
||||
cors_origin: &str,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||
if suffix == "/archy-status" {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Cache-Control", "no-store")
|
||||
.header("Access-Control-Allow-Origin", cors_origin)
|
||||
.header("Access-Control-Allow-Credentials", "true")
|
||||
.header("Vary", "Origin")
|
||||
.body(hyper::Body::from(
|
||||
rpc.handle_lnd_readiness().await.to_string(),
|
||||
))?);
|
||||
}
|
||||
|
||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||
// A bare reqwest::get() uses the default client, which rejects the
|
||||
@@ -225,54 +238,13 @@ impl ApiHandler {
|
||||
return bad("invalid onion or content id");
|
||||
}
|
||||
|
||||
// Already purchased? Serve the local cache — no network, no
|
||||
// re-payment. The seller's node charges every fetch by design; the
|
||||
// buyer-side store (content_owned) exists precisely so an owned item
|
||||
// never has to be bought twice, and the content surface's cards were
|
||||
// hitting the seller's 402 and rendering as permanent placeholders.
|
||||
// Range is honoured by slicing, so seek/playback works from cache.
|
||||
if crate::content_owned::is_owned(&self.config.data_dir, onion, content_id).await {
|
||||
if let Some((mime_type, bytes)) =
|
||||
crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await
|
||||
{
|
||||
let total = bytes.len();
|
||||
let range = headers
|
||||
.get("range")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(crate::content_server::parse_range_header);
|
||||
if let Some(r) = range {
|
||||
let start = (r.start as usize).min(total);
|
||||
let end = r
|
||||
.end
|
||||
.map(|e| e as usize)
|
||||
.unwrap_or(total.saturating_sub(1))
|
||||
.min(total.saturating_sub(1));
|
||||
if start <= end && total > 0 {
|
||||
let slice = &bytes[start..=end];
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", slice.len().to_string())
|
||||
.header(
|
||||
"Content-Range",
|
||||
format!("bytes {}-{}/{}", start, end, total),
|
||||
)
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.body(hyper::Body::from(slice.to_vec()))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::empty())));
|
||||
}
|
||||
}
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type)
|
||||
.header("Content-Length", total.to_string())
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.body(hyper::Body::from(bytes))
|
||||
.unwrap_or_else(|_| Response::new(hyper::Body::empty())));
|
||||
}
|
||||
// Indexed as owned but bytes missing — fall through to the peer
|
||||
// rather than erroring: the seller can still serve it (for the
|
||||
// price already paid, the operator can re-fetch and re-cache).
|
||||
// Ownership is checked before opening a bounded file stream. Corrupt
|
||||
// records or missing purchased bytes never trigger another purchase.
|
||||
match crate::content_owned::open_owned(&self.config.data_dir, onion, content_id).await {
|
||||
Ok(Some((mime, file))) => return crate::media_stream::file_response(file, &mime, headers).await,
|
||||
Ok(None) => {},
|
||||
Err(_) => return Ok(build_response(StatusCode::CONFLICT, "application/json",
|
||||
hyper::Body::from(serde_json::json!({"error": "Purchased file unavailable locally. Recover the existing purchase without paying again."}).to_string()))),
|
||||
}
|
||||
|
||||
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
|
||||
@@ -280,20 +252,31 @@ impl ApiHandler {
|
||||
// Generous overall timeout: this endpoint serves both seek/Range
|
||||
// playback (small, finishes fast) and full-file downloads of large
|
||||
// media (#38). 60s was too tight for a multi-hundred-MB transfer over
|
||||
// Tor and aborted the download mid-stream.
|
||||
// slow links and aborted the download mid-stream.
|
||||
let mut req = crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &peer_path)
|
||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||
.require_fips()
|
||||
.record_transport(&self.config.data_dir)
|
||||
.timeout(std::time::Duration::from_secs(900));
|
||||
if let Some(r) = headers.get("range").and_then(|v| v.to_str().ok()) {
|
||||
req = req.header("Range", r.to_string());
|
||||
}
|
||||
let req = req.authenticate_content(&self.config.data_dir).await?;
|
||||
match req.send_get().await {
|
||||
Ok((resp, _transport)) => {
|
||||
Ok((resp, transport)) => {
|
||||
if resp.status().is_redirection() {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"application/json",
|
||||
hyper::Body::from("{\"error\":\"Peer media redirects are not allowed\"}"),
|
||||
));
|
||||
}
|
||||
let status = resp.status().as_u16();
|
||||
let rh = resp.headers().clone();
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header("Accept-Ranges", "bytes");
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.header("X-Archipelago-Transport", transport.to_string());
|
||||
for h in ["content-type", "content-range", "content-length"] {
|
||||
if let Some(v) = rh.get(h).and_then(|v| v.to_str().ok()) {
|
||||
builder = builder.header(h, v);
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Add as api/handler/purchase.rs; dispatch only exact supported POST routes.
|
||||
use super::{build_response, ApiHandler};
|
||||
use crate::{
|
||||
content_purchase::Journal, content_purchase_protocol as protocol, identity::NodeIdentity,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{body::HttpBody, Body, Method, Request, Response, StatusCode};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct OfferRequest {
|
||||
id: String,
|
||||
content_id: String,
|
||||
}
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_purchase_request(
|
||||
&self,
|
||||
mut request: Request<Body>,
|
||||
) -> Result<Response<Body>> {
|
||||
let path = request.uri().path().to_owned();
|
||||
anyhow::ensure!(
|
||||
request.method() == Method::POST
|
||||
&& matches!(
|
||||
path.as_str(),
|
||||
protocol::PREPARE_OFFER_ROUTE
|
||||
| protocol::OFFER_ROUTE
|
||||
| protocol::ACCEPT_ROUTE
|
||||
| protocol::SETTLE_ROUTE
|
||||
| protocol::STATUS_ROUTE
|
||||
| protocol::CANCEL_ROUTE
|
||||
),
|
||||
"Unsupported purchase route"
|
||||
);
|
||||
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
|
||||
let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = request.body_mut().data().await {
|
||||
let chunk = chunk?;
|
||||
anyhow::ensure!(
|
||||
bytes
|
||||
.len()
|
||||
.checked_add(chunk.len())
|
||||
.is_some_and(|n| n <= 1024 * 1024),
|
||||
"Purchase body too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok::<_, anyhow::Error>(bytes)
|
||||
})
|
||||
.await
|
||||
.context("Purchase body timed out")??;
|
||||
let buyer = crate::content_auth::authenticate_request(
|
||||
request.headers(),
|
||||
&audience,
|
||||
&Method::POST,
|
||||
&path,
|
||||
&bytes,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)?;
|
||||
let data_dir = &self.config.data_dir;
|
||||
let result = match path.as_str() {
|
||||
protocol::PREPARE_OFFER_ROUTE => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Prepare {
|
||||
content_id: String,
|
||||
#[serde(default)]
|
||||
retry: bool,
|
||||
expected: Option<crate::content_purchase_caller::ExpectedRental>,
|
||||
}
|
||||
let input: Prepare = serde_json::from_slice(&bytes)?;
|
||||
let identity = std::sync::Arc::new(
|
||||
NodeIdentity::load_existing(&data_dir.join("identity")).await?,
|
||||
);
|
||||
anyhow::ensure!(identity.did_key()? == audience, "Node identity changed");
|
||||
let root = data_dir.clone();
|
||||
serde_json::to_value(
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Some(expected) = &input.expected {
|
||||
let (receipt, _) = crate::registered_media::registered_metadata(
|
||||
&root,
|
||||
&identity,
|
||||
&input.content_id,
|
||||
)?;
|
||||
expected.verify_metadata(&identity.did_key()?, &receipt)?;
|
||||
}
|
||||
crate::registered_media::prepare_registered(
|
||||
root,
|
||||
identity,
|
||||
&input.content_id,
|
||||
input.retry,
|
||||
)
|
||||
})
|
||||
.await??,
|
||||
)?
|
||||
}
|
||||
protocol::OFFER_ROUTE => {
|
||||
let body: OfferRequest = serde_json::from_slice(&bytes)?;
|
||||
anyhow::ensure!(
|
||||
uuid::Uuid::parse_str(&body.id)?.to_string() == body.id,
|
||||
"Invalid operation identifier"
|
||||
);
|
||||
let saved = {
|
||||
Journal::open(data_dir)
|
||||
.await?
|
||||
.protocol_offer(&body.id)
|
||||
.await?
|
||||
};
|
||||
let offer = if let Some(saved) = saved {
|
||||
anyhow::ensure!(
|
||||
saved.buyer_did == buyer && saved.content_id == body.content_id,
|
||||
"Original offer binding changed"
|
||||
);
|
||||
saved
|
||||
} else {
|
||||
// Registration pins and immutable snapshot are node-owned;
|
||||
// no content hash/price/path is accepted from the request.
|
||||
if !body.content_id.starts_with("registered_") {
|
||||
let wallet = crate::wallet::ecash::load_wallet(data_dir).await?;
|
||||
let offer = crate::content_cloud_offer::offer(
|
||||
data_dir,
|
||||
&body.id,
|
||||
&body.content_id,
|
||||
&buyer,
|
||||
&audience,
|
||||
crate::wallet::ecash::load_network(data_dir).await?,
|
||||
wallet.mint_url.trim_end_matches('/'),
|
||||
crate::content_cloud_offer::SnapshotPolicy {
|
||||
max_file_bytes: 64 * 1024 * 1024 * 1024,
|
||||
max_total_bytes: 64 * 1024 * 1024 * 1024,
|
||||
minimum_free_bytes: 512 * 1024 * 1024,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
return Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
Body::from(serde_json::to_vec(&offer)?),
|
||||
));
|
||||
}
|
||||
let identity = NodeIdentity::load_existing(&data_dir.join("identity")).await?;
|
||||
anyhow::ensure!(identity.did_key()? == audience, "Node identity changed");
|
||||
let selected = body.content_id.clone();
|
||||
let root = data_dir.clone();
|
||||
let (receipt, terms) = tokio::task::spawn_blocking(move || {
|
||||
crate::registered_media::registered_terms(&root, &identity, &selected)
|
||||
})
|
||||
.await??;
|
||||
anyhow::ensure!(
|
||||
receipt
|
||||
.payment_methods
|
||||
.iter()
|
||||
.any(|method| method == "cashu"),
|
||||
"Content does not accept Cashu"
|
||||
);
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let deadline = now.checked_add(120).context("Offer clock overflow")?;
|
||||
let wallet = crate::wallet::ecash::load_wallet(data_dir).await?;
|
||||
let offer = protocol::Offer {
|
||||
id: body.id,
|
||||
buyer_did: buyer.clone(),
|
||||
seller_did: audience.clone(),
|
||||
filename: receipt.content_id.clone(),
|
||||
mime_type: "application/octet-stream".into(),
|
||||
content_id: receipt.content_id,
|
||||
content_sha256: receipt.sha256,
|
||||
content_size: receipt.size_bytes.parse()?,
|
||||
viewing_seconds: Some(receipt.viewing_seconds),
|
||||
terms_sha256: terms,
|
||||
network: crate::wallet::ecash::load_network(data_dir).await?,
|
||||
mint_url: wallet.mint_url.trim_end_matches('/').to_owned(),
|
||||
seller_net_sats: receipt.price_sats,
|
||||
offered_at: now,
|
||||
expires_at: deadline,
|
||||
};
|
||||
protocol::save_offer(data_dir, &offer, &buyer, now).await?
|
||||
};
|
||||
protocol::ensure_seller_mint_policy(data_dir, offer.network, &offer.mint_url)
|
||||
.await?;
|
||||
serde_json::to_value(offer)?
|
||||
}
|
||||
protocol::ACCEPT_ROUTE => serde_json::to_value(
|
||||
protocol::accept(data_dir, &serde_json::from_slice(&bytes)?, &buyer, || {
|
||||
chrono::Utc::now().timestamp()
|
||||
})
|
||||
.await?,
|
||||
)?,
|
||||
protocol::SETTLE_ROUTE => serde_json::to_value(
|
||||
protocol::settle(data_dir, &serde_json::from_slice(&bytes)?, &buyer).await?,
|
||||
)?,
|
||||
protocol::CANCEL_ROUTE => serde_json::to_value(
|
||||
protocol::cancel(data_dir, &serde_json::from_slice(&bytes)?, &buyer).await?,
|
||||
)?,
|
||||
protocol::STATUS_ROUTE => serde_json::to_value(
|
||||
protocol::status(data_dir, &serde_json::from_slice(&bytes)?, &buyer).await?,
|
||||
)?,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
Body::from(serde_json::to_vec(&result)?),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
//! Authenticated immutable rental streaming, separate from legacy mutable shares.
|
||||
use super::{build_response, ApiHandler};
|
||||
use crate::{content_server::ByteRange, identity::NodeIdentity, registered_media::OpenedMedia};
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{Body, HeaderMap, Response, StatusCode};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn route(path: &str) -> Result<(&str, &str)> {
|
||||
let (content, purchase) = path
|
||||
.strip_prefix("/content/")
|
||||
.and_then(|value| value.split_once("/rental/"))
|
||||
.context("Invalid rental route")?;
|
||||
anyhow::ensure!(
|
||||
content.starts_with("registered_") && !content.contains('/') && !purchase.contains('/'),
|
||||
"Invalid rental identifiers"
|
||||
);
|
||||
let id = uuid::Uuid::parse_str(purchase)?;
|
||||
anyhow::ensure!(
|
||||
id.to_string() == purchase && id.get_version_num() == 4,
|
||||
"Invalid purchase identifier"
|
||||
);
|
||||
Ok((content, purchase))
|
||||
}
|
||||
fn bounds(range: Option<ByteRange>, total: u64) -> Result<Option<(u64, u64)>> {
|
||||
let Some(range) = range else {
|
||||
anyhow::ensure!(total > 0, "Registered media is empty");
|
||||
return Ok(None);
|
||||
};
|
||||
let last = total.checked_sub(1).context("Registered media is empty")?;
|
||||
let (start, end) = match range {
|
||||
ByteRange::From { start, end } => (start, end.unwrap_or(last).min(last)),
|
||||
ByteRange::Suffix(count) => {
|
||||
anyhow::ensure!(count > 0, "Invalid suffix range");
|
||||
(total.saturating_sub(count), last)
|
||||
}
|
||||
};
|
||||
anyhow::ensure!(start <= end && start < total, "Invalid rental byte range");
|
||||
Ok(Some((start, end)))
|
||||
}
|
||||
fn clock() -> u64 {
|
||||
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0)
|
||||
}
|
||||
fn denied(message: &'static str) -> Response<Body> {
|
||||
build_response(StatusCode::FORBIDDEN, "text/plain", Body::from(message))
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_rental_control(
|
||||
&self,
|
||||
mut request: hyper::Request<Body>,
|
||||
) -> Result<Response<Body>> {
|
||||
use hyper::body::HttpBody;
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Control {
|
||||
capability: String,
|
||||
ready_id: Option<String>,
|
||||
#[serde(default)]
|
||||
retry: bool,
|
||||
}
|
||||
let path = request.uri().path().to_owned();
|
||||
let (base, action) = path.rsplit_once('/').context("Invalid rental action")?;
|
||||
anyhow::ensure!(
|
||||
matches!(action, "prepare" | "start") && request.method() == hyper::Method::POST,
|
||||
"Invalid rental action"
|
||||
);
|
||||
let (content, purchase) = route(base)?;
|
||||
let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = request.body_mut().data().await {
|
||||
let chunk = chunk?;
|
||||
anyhow::ensure!(
|
||||
bytes
|
||||
.len()
|
||||
.checked_add(chunk.len())
|
||||
.is_some_and(|n| n <= 16 * 1024),
|
||||
"Rental request too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok::<_, anyhow::Error>(bytes)
|
||||
})
|
||||
.await
|
||||
.context("Rental request timed out")??;
|
||||
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
|
||||
let buyer = crate::content_auth::authenticate_request(
|
||||
request.headers(),
|
||||
&audience,
|
||||
&hyper::Method::POST,
|
||||
&path,
|
||||
&bytes,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)?;
|
||||
let input: Control = serde_json::from_slice(&bytes)?;
|
||||
let identity =
|
||||
Arc::new(NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?);
|
||||
anyhow::ensure!(identity.did_key()? == audience, "Node identity changed");
|
||||
let result = if action == "prepare" {
|
||||
anyhow::ensure!(input.ready_id.is_none(), "Prepare does not start a rental");
|
||||
let prior = crate::registered_media::paid_window(
|
||||
&self.config.data_dir,
|
||||
&identity,
|
||||
content,
|
||||
purchase,
|
||||
&buyer,
|
||||
&input.capability,
|
||||
)
|
||||
.await?;
|
||||
let metadata = crate::registered_media::registered_metadata(
|
||||
&self.config.data_dir,
|
||||
&identity,
|
||||
content,
|
||||
)?;
|
||||
anyhow::ensure!(
|
||||
prior
|
||||
.as_ref()
|
||||
.is_none_or(|window| clock() >= window.started_at),
|
||||
"Rental clock moved backwards"
|
||||
);
|
||||
if let Some(window) = prior.as_ref().filter(|window| clock() >= window.expires_at) {
|
||||
serde_json::json!({"state":"expired", "viewing_seconds":metadata.0.viewing_seconds,"started_at":window.started_at,"expires_at":window.expires_at})
|
||||
} else {
|
||||
let state = crate::registered_media::prepare_paid(
|
||||
self.config.data_dir.clone(),
|
||||
identity,
|
||||
content.into(),
|
||||
purchase.into(),
|
||||
buyer,
|
||||
input.capability,
|
||||
input.retry,
|
||||
)
|
||||
.await?;
|
||||
let mut result = serde_json::to_value(state)?;
|
||||
result["viewing_seconds"] = serde_json::json!(metadata.0.viewing_seconds);
|
||||
result["started_at"] =
|
||||
serde_json::json!(prior.as_ref().map(|window| window.started_at));
|
||||
result["expires_at"] =
|
||||
serde_json::json!(prior.as_ref().map(|window| window.expires_at));
|
||||
result
|
||||
}
|
||||
} else {
|
||||
anyhow::ensure!(!input.retry, "Start cannot retry verification");
|
||||
let ready_id = input
|
||||
.ready_id
|
||||
.context("Media must be ready before explicit Start")?;
|
||||
let window = crate::registered_media::start_paid(
|
||||
self.config.data_dir.clone(),
|
||||
identity,
|
||||
content.into(),
|
||||
purchase.into(),
|
||||
buyer,
|
||||
input.capability,
|
||||
ready_id,
|
||||
)
|
||||
.await?;
|
||||
anyhow::ensure!(clock() >= window.started_at, "Rental clock moved backwards");
|
||||
serde_json::json!({"state": if clock() >= window.expires_at {"expired"} else {"started"},
|
||||
"started_at":window.started_at,"expires_at":window.expires_at})
|
||||
};
|
||||
Ok(build_response(
|
||||
StatusCode::OK,
|
||||
"application/json",
|
||||
Body::from(serde_json::to_vec(&result)?),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn handle_registered_rental(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Response<Body>> {
|
||||
let (content, purchase) = match route(path) {
|
||||
Ok(ids) => ids,
|
||||
Err(_) => {
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"text/plain",
|
||||
Body::from("Invalid rental route"),
|
||||
))
|
||||
}
|
||||
};
|
||||
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
|
||||
let buyer = match crate::content_auth::incoming(
|
||||
headers,
|
||||
&audience,
|
||||
path,
|
||||
chrono::Utc::now().timestamp(),
|
||||
) {
|
||||
Ok(Some(buyer)) => buyer,
|
||||
_ => return Ok(denied("Authenticated node proof is required")),
|
||||
};
|
||||
let capability = match headers
|
||||
.get("x-content-capability")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
Some(value) if value.len() == 64 => value.to_owned(),
|
||||
_ => return Ok(denied("Original purchase capability is required")),
|
||||
};
|
||||
let requested_range = match headers.get("range") {
|
||||
None => None,
|
||||
Some(value) => match value
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(crate::content_server::parse_range_header)
|
||||
{
|
||||
Some(range) => Some(range),
|
||||
None => {
|
||||
return Ok(build_response(
|
||||
StatusCode::RANGE_NOT_SATISFIABLE,
|
||||
"text/plain",
|
||||
Body::from("Invalid byte range"),
|
||||
))
|
||||
}
|
||||
},
|
||||
};
|
||||
let identity =
|
||||
Arc::new(NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?);
|
||||
anyhow::ensure!(identity.did_key()? == audience, "Node identity changed");
|
||||
let data = self.config.data_dir.clone();
|
||||
let selected = content.to_owned();
|
||||
let key = identity.clone();
|
||||
let metadata = tokio::task::spawn_blocking(move || {
|
||||
crate::registered_media::registered_metadata(&data, &key, &selected)
|
||||
})
|
||||
.await?;
|
||||
let (receipt, _) = match metadata {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(build_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"text/plain",
|
||||
Body::from("Registered content is unavailable"),
|
||||
))
|
||||
}
|
||||
};
|
||||
let total = receipt.size_bytes.parse::<u64>()?;
|
||||
let range = match bounds(requested_range, total) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header("Content-Range", format!("bytes */{total}"))
|
||||
.body(Body::from("Invalid byte range"))?)
|
||||
}
|
||||
};
|
||||
// All malformed/out-of-bounds requests are rejected before first-open
|
||||
// rental creation. No payment or new receipt is attempted by this route.
|
||||
let opened = match crate::registered_media::open_paid(
|
||||
self.config.data_dir.clone(),
|
||||
identity,
|
||||
content.into(),
|
||||
purchase.into(),
|
||||
buyer,
|
||||
capability,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(opened) => opened,
|
||||
Err(_) => {
|
||||
return Ok(denied(
|
||||
"This purchase is not settled, does not match, or its rental has expired",
|
||||
))
|
||||
}
|
||||
};
|
||||
rental_response(opened, range, Arc::new(clock)).await
|
||||
}
|
||||
}
|
||||
async fn rental_response(
|
||||
opened: OpenedMedia,
|
||||
range: Option<(u64, u64)>,
|
||||
now: Arc<dyn Fn() -> u64 + Send + Sync>,
|
||||
) -> Result<Response<Body>> {
|
||||
anyhow::ensure!(
|
||||
opened.still_authorized(now()),
|
||||
"Rental expired before streaming"
|
||||
);
|
||||
let total = opened.size_bytes;
|
||||
let started = opened.started_at;
|
||||
let expires = opened.expires_at;
|
||||
let (start, length) = range.map_or((0, total), |(start, end)| (start, end - start + 1));
|
||||
let chunks = futures_util::stream::try_unfold(
|
||||
(opened.file, opened.verification, start, length, now),
|
||||
move |(mut file, verification, position, left, now)| async move {
|
||||
if left == 0 {
|
||||
return Ok::<_, std::io::Error>(None);
|
||||
}
|
||||
let instant = now();
|
||||
if instant < started || instant >= expires {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"Rental window ended",
|
||||
));
|
||||
}
|
||||
let read = tokio::task::spawn_blocking(move || {
|
||||
let bytes = verification
|
||||
.index
|
||||
.read_slice(&mut file, position, left.min(64 * 1024) as usize)
|
||||
.map_err(std::io::Error::other)?;
|
||||
Ok::<_, std::io::Error>((file, verification, bytes))
|
||||
});
|
||||
let (file, verification, bytes) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(expires - instant), read)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::TimedOut, "Rental window ended")
|
||||
})?
|
||||
.map_err(std::io::Error::other)??;
|
||||
let instant = now();
|
||||
if instant < started || instant >= expires {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"Rental window ended",
|
||||
));
|
||||
}
|
||||
let count = bytes.len() as u64;
|
||||
Ok(Some((
|
||||
bytes,
|
||||
(file, verification, position + count, left - count, now),
|
||||
)))
|
||||
},
|
||||
);
|
||||
let mut response = Response::builder()
|
||||
.status(if range.is_some() {
|
||||
StatusCode::PARTIAL_CONTENT
|
||||
} else {
|
||||
StatusCode::OK
|
||||
})
|
||||
.header("Content-Type", opened.mime_type)
|
||||
.header("Content-Length", length)
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header("Cache-Control", "private, no-store")
|
||||
.header("X-Rental-Expires-At", expires);
|
||||
if let Some((start, end)) = range {
|
||||
response = response.header("Content-Range", format!("bytes {start}-{end}/{total}"));
|
||||
}
|
||||
Ok(response.body(Body::wrap_stream(chunks))?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use hyper::body::HttpBody;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
#[test]
|
||||
fn invalid_routes_and_ranges_cannot_reach_rental_creation() {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
assert!(route(&format!("/content/registered_{id}/rental/{id}")).is_ok());
|
||||
for path in [
|
||||
format!("/content/registered_{id}/rental/{id}/extra"),
|
||||
format!("/content/../rental/{id}"),
|
||||
format!("/content/registered_{id}/rental/not-a-purchase"),
|
||||
] {
|
||||
assert!(route(&path).is_err());
|
||||
}
|
||||
assert!(bounds(
|
||||
Some(ByteRange::From {
|
||||
start: 20,
|
||||
end: None
|
||||
}),
|
||||
20
|
||||
)
|
||||
.is_err());
|
||||
assert!(bounds(
|
||||
Some(ByteRange::From {
|
||||
start: 9,
|
||||
end: Some(8)
|
||||
}),
|
||||
20
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
bounds(Some(ByteRange::Suffix(5)), 20).unwrap(),
|
||||
Some((15, 19))
|
||||
);
|
||||
}
|
||||
fn opened(size: u64) -> OpenedMedia {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut file = tempfile::tempfile().unwrap();
|
||||
file.set_len(size).unwrap();
|
||||
let binding = crate::rental_chunk_index::Binding {
|
||||
content_id: format!("registered_{}", uuid::Uuid::new_v4()),
|
||||
receipt_sha256: "ab".repeat(32),
|
||||
full_sha256: hex::encode(Sha256::digest(vec![0; size as usize])),
|
||||
size,
|
||||
};
|
||||
let index = crate::rental_chunk_index::Index::scan(&mut file, binding, |_| Ok(())).unwrap();
|
||||
OpenedMedia {
|
||||
file,
|
||||
verification: crate::rental_readiness::Ready::fixture(index),
|
||||
size_bytes: size,
|
||||
mime_type: "video/mp4".into(),
|
||||
started_at: 1000,
|
||||
expires_at: 1060,
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn bounded_stream_stops_at_persisted_deadline_without_restarting_window() {
|
||||
let clock = Arc::new(AtomicU64::new(1000));
|
||||
let read_clock = clock.clone();
|
||||
let mut response = rental_response(
|
||||
opened(200_000),
|
||||
None,
|
||||
Arc::new(move || read_clock.load(Ordering::SeqCst)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.headers()["x-rental-expires-at"], "1060");
|
||||
assert_eq!(
|
||||
response.body_mut().data().await.unwrap().unwrap().len(),
|
||||
64 * 1024
|
||||
);
|
||||
clock.store(1060, Ordering::SeqCst);
|
||||
assert!(response.body_mut().data().await.unwrap().is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn suffix_response_has_exact_length_and_expired_or_rollback_stream_denies() {
|
||||
let mut response = rental_response(opened(20), Some((15, 19)), Arc::new(|| 1000))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(response.headers()["content-range"], "bytes 15-19/20");
|
||||
assert_eq!(
|
||||
hyper::body::to_bytes(response.body_mut())
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
5
|
||||
);
|
||||
assert!(rental_response(opened(20), None, Arc::new(|| 1060))
|
||||
.await
|
||||
.is_err());
|
||||
assert!(rental_response(opened(20), None, Arc::new(|| 999))
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
//! Local browser playback. Only opaque local handles cross the browser boundary.
|
||||
use super::{build_response, ApiHandler};
|
||||
use crate::{content_purchase::Journal, content_server::ByteRange, identity::NodeIdentity};
|
||||
use anyhow::{Context, Result};
|
||||
use hyper::{Body, HeaderMap, Method, Response, StatusCode};
|
||||
use std::{
|
||||
io,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
fn requested_bounds(headers: &HeaderMap, total: u64) -> Result<Option<(u64, u64)>> {
|
||||
anyhow::ensure!(total > 0, "Empty purchased media");
|
||||
anyhow::ensure!(
|
||||
headers.get_all("range").iter().count() <= 1,
|
||||
"Ambiguous playback ranges"
|
||||
);
|
||||
let Some(header) = headers.get("range") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let range = crate::content_server::parse_range_header(header.to_str()?)
|
||||
.context("Invalid playback byte range")?;
|
||||
let last = total - 1;
|
||||
let (start, end) = match range {
|
||||
ByteRange::From { start, end } => (start, end.unwrap_or(last).min(last)),
|
||||
ByteRange::Suffix(count) => {
|
||||
anyhow::ensure!(count > 0, "Invalid byte range");
|
||||
(total.saturating_sub(count), last)
|
||||
}
|
||||
};
|
||||
anyhow::ensure!(start <= end && start < total, "Invalid playback byte range");
|
||||
Ok(Some((start, end)))
|
||||
}
|
||||
fn validate_upstream(
|
||||
status: u16,
|
||||
headers: &HeaderMap,
|
||||
total: u64,
|
||||
bounds: Option<(u64, u64)>,
|
||||
now: u64,
|
||||
) -> Result<(u16, u64, String, u64)> {
|
||||
let expected_status = if bounds.is_some() { 206 } else { 200 };
|
||||
anyhow::ensure!(
|
||||
status == expected_status,
|
||||
"Seller returned another range status"
|
||||
);
|
||||
let length = bounds.map_or(total, |(start, end)| end - start + 1);
|
||||
anyhow::ensure!(
|
||||
headers
|
||||
.get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
== Some(length),
|
||||
"Seller changed purchased byte length"
|
||||
);
|
||||
if let Some((start, end)) = bounds {
|
||||
let expected = format!("bytes {start}-{end}/{total}");
|
||||
anyhow::ensure!(
|
||||
headers.get("content-range").and_then(|v| v.to_str().ok()) == Some(expected.as_str()),
|
||||
"Seller changed purchased byte range"
|
||||
);
|
||||
}
|
||||
let mime = headers
|
||||
.get("content-type")
|
||||
.context("Missing media type")?
|
||||
.to_str()?
|
||||
.to_owned();
|
||||
anyhow::ensure!(
|
||||
mime.starts_with("video/") || mime.starts_with("audio/"),
|
||||
"Unsupported rental media type"
|
||||
);
|
||||
let expires = headers
|
||||
.get("x-rental-expires-at")
|
||||
.context("Missing rental expiry")?
|
||||
.to_str()?
|
||||
.parse::<u64>()?;
|
||||
anyhow::ensure!(expires > now, "Rental viewing window ended");
|
||||
Ok((expected_status, length, mime, expires))
|
||||
}
|
||||
|
||||
fn installed_playback_origin(
|
||||
origin: &str,
|
||||
expected: &str,
|
||||
host: &str,
|
||||
gated_tls_port: bool,
|
||||
) -> bool {
|
||||
let (Ok(actual), Ok(expected), Ok(request)) = (
|
||||
reqwest::Url::parse(origin),
|
||||
reqwest::Url::parse(expected),
|
||||
reqwest::Url::parse(&format!("http://{host}")),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(actual.scheme(), "http" | "https")
|
||||
|| actual.origin().ascii_serialization() != origin
|
||||
|| request.path() != "/"
|
||||
|| !request.username().is_empty()
|
||||
|| request.password().is_some()
|
||||
|| request.query().is_some()
|
||||
|| request.fragment().is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if actual.origin() == expected.origin() {
|
||||
return true;
|
||||
}
|
||||
let same_port = actual.port_or_known_default() == expected.port_or_known_default();
|
||||
let scheme = actual.scheme() == expected.scheme()
|
||||
|| (gated_tls_port && actual.scheme() == "https" && expected.scheme() == "http");
|
||||
let expected_loopback = matches!(
|
||||
expected.host_str(),
|
||||
Some("localhost" | "127.0.0.1" | "[::1]")
|
||||
);
|
||||
same_port
|
||||
&& scheme
|
||||
&& actual.host_str() == request.host_str()
|
||||
&& (expected_loopback || actual.host_str() == expected.host_str())
|
||||
}
|
||||
|
||||
fn unix_now() -> Result<u64> {
|
||||
Ok(std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_secs())
|
||||
}
|
||||
fn stream_error(message: &'static str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, message)
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_local_rental_request(
|
||||
&self,
|
||||
method: &Method,
|
||||
path: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Response<Body>> {
|
||||
// HEAD is deliberately not GET: a browser probe must never open a lease.
|
||||
if method != Method::GET && method != Method::OPTIONS {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::METHOD_NOT_ALLOWED)
|
||||
.header("Allow", "GET, OPTIONS")
|
||||
.header("Cache-Control", "no-store")
|
||||
.body(Body::empty())?);
|
||||
}
|
||||
let origin = headers.get("origin").map(|v| v.to_str()).transpose()?;
|
||||
if let Some(origin) = origin {
|
||||
let identity =
|
||||
NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?;
|
||||
let (state, _) = self.state_manager.get_snapshot().await;
|
||||
let root = self.config.data_dir.clone();
|
||||
let context = tokio::task::spawn_blocking(move || {
|
||||
crate::container::registration_pin::installed_context(&root, &identity, &state)
|
||||
})
|
||||
.await??;
|
||||
let host = headers
|
||||
.get("host")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let port = reqwest::Url::parse(origin)
|
||||
.ok()
|
||||
.and_then(|url| url.port_or_known_default());
|
||||
let ports = self.rpc_handler.app_gate.port_map().await;
|
||||
let gated_tls_port = port
|
||||
.and_then(|port| ports.gated(port))
|
||||
.is_some_and(|gate| gate.app_id == context.app_id && gate.declared);
|
||||
if !context
|
||||
.app_origins
|
||||
.iter()
|
||||
.any(|allowed| installed_playback_origin(origin, allowed, host, gated_tls_port))
|
||||
{
|
||||
return Ok(build_response(
|
||||
StatusCode::FORBIDDEN,
|
||||
"text/plain",
|
||||
Body::from("Playback origin is not the installed app"),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut response = if method == Method::OPTIONS {
|
||||
Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty())?
|
||||
} else {
|
||||
self.handle_local_rental(path, headers).await?
|
||||
};
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("Cache-Control", "private, no-store".parse()?);
|
||||
response.headers_mut().insert("Vary", "Origin".parse()?);
|
||||
if let Some(origin) = origin {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("Access-Control-Allow-Origin", origin.parse()?);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("Access-Control-Allow-Credentials", "true".parse()?);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("Access-Control-Allow-Methods", "GET, OPTIONS".parse()?);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("Access-Control-Allow-Headers", "Range".parse()?);
|
||||
response.headers_mut().insert(
|
||||
"Access-Control-Expose-Headers",
|
||||
"Content-Length, Content-Range, Accept-Ranges, X-Rental-Expires-At".parse()?,
|
||||
);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Dispatcher accepts GET only after normal session handling. HEAD and other
|
||||
/// methods never reach upstream, so metadata probes cannot start a lease.
|
||||
pub(super) async fn handle_local_rental(
|
||||
&self,
|
||||
path: &str,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Response<Body>> {
|
||||
let token = match crate::session::extract_session_cookie(headers) {
|
||||
Some(token) if self.session_store.validate(&token).await => token,
|
||||
_ => return Ok(Self::unauthorized()),
|
||||
};
|
||||
let handle = path
|
||||
.strip_prefix("/api/rental-playback/")
|
||||
.context("Invalid playback route")?;
|
||||
let identity =
|
||||
Arc::new(NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?);
|
||||
let (state, _) = self.state_manager.get_snapshot().await;
|
||||
let root = self.config.data_dir.clone();
|
||||
let key = identity.clone();
|
||||
let context = tokio::task::spawn_blocking(move || {
|
||||
crate::container::registration_pin::installed_context(&root, &key, &state)
|
||||
})
|
||||
.await??;
|
||||
let binding = self
|
||||
.rpc_handler
|
||||
.playback_handles()
|
||||
.lookup(handle, &token, &context)?;
|
||||
let capability = {
|
||||
let journal = Journal::open(&self.config.data_dir).await?;
|
||||
let record = journal
|
||||
.buyer(&binding.contract.id)
|
||||
.await?
|
||||
.context("Original purchase is missing")?;
|
||||
anyhow::ensure!(
|
||||
record.contract == binding.contract,
|
||||
"Original purchase changed"
|
||||
);
|
||||
record
|
||||
.receipt()
|
||||
.context("Original purchase is not settled")?
|
||||
.capability
|
||||
.clone()
|
||||
};
|
||||
let peer = crate::federation::load_unique_payment_peer(
|
||||
&self.config.data_dir,
|
||||
&binding.seller_onion,
|
||||
)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
peer.did == binding.contract.seller_did,
|
||||
"Purchased seller identity changed"
|
||||
);
|
||||
let mesh = peer
|
||||
.fips_npub
|
||||
.context("Seller mesh binding is unavailable")?;
|
||||
let total = binding.contract.content_size;
|
||||
let bounds = match requested_bounds(headers, total) {
|
||||
Ok(bounds) => bounds,
|
||||
Err(_) => {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header("Content-Range", format!("bytes */{total}"))
|
||||
.body(Body::empty())?)
|
||||
}
|
||||
};
|
||||
let remote_path = format!(
|
||||
"/content/{}/rental/{}",
|
||||
binding.contract.content_id, binding.contract.id
|
||||
);
|
||||
let mut request =
|
||||
crate::fips::dial::PeerRequest::new(Some(&mesh), &binding.seller_onion, &remote_path)
|
||||
.require_fips()
|
||||
.single_delivery()
|
||||
.timeout(Duration::from_secs(24 * 60 * 60))
|
||||
.header("X-Content-Capability", capability);
|
||||
if let Some((start, end)) = bounds {
|
||||
request = request.header("Range", format!("bytes={start}-{end}"));
|
||||
}
|
||||
let (response, transport) = tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
request.send_content_get(&self.config.data_dir),
|
||||
)
|
||||
.await
|
||||
.context("Seller did not begin the original rental stream")??;
|
||||
if !response.status().is_success() {
|
||||
// Never forward arbitrary upstream bodies, redirects, cookies or private headers.
|
||||
let status = if response.status().as_u16() == 403 {
|
||||
StatusCode::FORBIDDEN
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
return Ok(build_response(
|
||||
status,
|
||||
"text/plain",
|
||||
Body::from(
|
||||
"Original rental is unavailable; recover this purchase without paying again",
|
||||
),
|
||||
));
|
||||
}
|
||||
let (expected_status, length, mime, expires) = validate_upstream(
|
||||
response.status().as_u16(),
|
||||
response.headers(),
|
||||
total,
|
||||
bounds,
|
||||
unix_now()?,
|
||||
)?;
|
||||
self.rpc_handler
|
||||
.playback_handles()
|
||||
.note_expiry(handle, &binding, expires)?;
|
||||
let sessions = self.session_store.clone();
|
||||
let state_manager = self.state_manager.clone();
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let chunks = futures_util::stream::try_unfold(
|
||||
(response, length, None::<Instant>),
|
||||
move |(mut response, left, mut checked)| {
|
||||
let sessions = sessions.clone();
|
||||
let token = token.clone();
|
||||
let state_manager = state_manager.clone();
|
||||
let data_dir = data_dir.clone();
|
||||
let identity = identity.clone();
|
||||
let context = context.clone();
|
||||
async move {
|
||||
if unix_now().map_err(|_| stream_error("Playback clock unavailable"))?
|
||||
>= expires
|
||||
{
|
||||
return Err(stream_error("Rental viewing window ended"));
|
||||
}
|
||||
if left == 0 {
|
||||
return Ok::<_, io::Error>(None);
|
||||
}
|
||||
let waiting_since = Instant::now();
|
||||
loop {
|
||||
if waiting_since.elapsed() >= Duration::from_secs(30) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"Rental stream stalled; reopen the original purchase",
|
||||
));
|
||||
}
|
||||
if unix_now().map_err(|_| stream_error("Playback clock unavailable"))?
|
||||
>= expires
|
||||
{
|
||||
return Err(stream_error("Rental viewing window ended"));
|
||||
}
|
||||
if checked.is_none_or(|at| at.elapsed() >= Duration::from_secs(1)) {
|
||||
if !sessions.validate(&token).await {
|
||||
return Err(stream_error("Playback session ended"));
|
||||
}
|
||||
let (state, _) = state_manager.get_snapshot().await;
|
||||
let root = data_dir.clone();
|
||||
let key = identity.clone();
|
||||
let actual = tokio::task::spawn_blocking(move || {
|
||||
crate::container::registration_pin::installed_context(
|
||||
&root, &key, &state,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| stream_error("Playback app context unavailable"))?
|
||||
.map_err(|_| stream_error("Playback app context unavailable"))?;
|
||||
if actual != context {
|
||||
return Err(stream_error("Playback app context changed"));
|
||||
}
|
||||
checked = Some(Instant::now());
|
||||
}
|
||||
// Keep checking revocation while the peer stalls; no local media cache.
|
||||
let chunk = tokio::select! {
|
||||
chunk=response.chunk() => chunk.map_err(|_|io::Error::new(io::ErrorKind::ConnectionAborted,"Rental stream interrupted; reopen the original purchase"))?,
|
||||
_=tokio::time::sleep(Duration::from_secs(1)) => continue,
|
||||
};
|
||||
let bytes = chunk.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"Purchased media ended early",
|
||||
)
|
||||
})?;
|
||||
if bytes.len() as u64 > left {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Purchased media exceeded its declared length",
|
||||
));
|
||||
}
|
||||
let remaining = left - bytes.len() as u64;
|
||||
return Ok(Some((bytes, (response, remaining, checked))));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
let mut result = Response::builder()
|
||||
.status(expected_status)
|
||||
.header("Content-Type", mime)
|
||||
.header("Content-Length", length)
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.header("Cache-Control", "private, no-store")
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header("X-Rental-Expires-At", expires)
|
||||
.header("X-Archipelago-Transport", transport.to_string());
|
||||
if let Some((start, end)) = bounds {
|
||||
result = result.header("Content-Range", format!("bytes {start}-{end}/{total}"));
|
||||
}
|
||||
Ok(result.body(Body::wrap_stream(chunks))?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn installed_origin_maps_only_current_host_and_verified_app_port() {
|
||||
assert!(installed_playback_origin(
|
||||
"http://192.168.1.5:7778",
|
||||
"http://127.0.0.1:7778",
|
||||
"192.168.1.5",
|
||||
false
|
||||
));
|
||||
assert!(installed_playback_origin(
|
||||
"https://192.168.1.5:7778",
|
||||
"http://127.0.0.1:7778",
|
||||
"192.168.1.5",
|
||||
true
|
||||
));
|
||||
assert!(installed_playback_origin(
|
||||
"https://[fd00::5]:7778",
|
||||
"https://[::1]:7778",
|
||||
"[fd00::5]:443",
|
||||
false
|
||||
));
|
||||
for (actual, host, tls) in [
|
||||
("https://192.168.1.5:7778", "192.168.1.5", false),
|
||||
("http://evil.test:7778", "192.168.1.5", true),
|
||||
("http://192.168.1.5:7779", "192.168.1.5", true),
|
||||
("http://192.168.1.5:7778", "evil.test", true),
|
||||
("http://192.168.1.5:7778/path", "192.168.1.5", true),
|
||||
("http://192.168.1.5:7778", "user@192.168.1.5", true),
|
||||
] {
|
||||
assert!(
|
||||
!installed_playback_origin(actual, "http://127.0.0.1:7778", host, tls),
|
||||
"{actual} {host}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_bounds_follow_purchased_size_and_reject_ambiguous_ranges() {
|
||||
let check = |range: &str| {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("range", range.parse().unwrap());
|
||||
requested_bounds(&h, 100)
|
||||
};
|
||||
assert_eq!(check("bytes=20-39").unwrap(), Some((20, 39)));
|
||||
assert_eq!(check("bytes=90-").unwrap(), Some((90, 99)));
|
||||
assert_eq!(check("bytes=-10").unwrap(), Some((90, 99)));
|
||||
assert_eq!(check("bytes=-200").unwrap(), Some((0, 99)));
|
||||
assert_eq!(check("bytes=90-500").unwrap(), Some((90, 99)));
|
||||
for range in [
|
||||
"bytes=100-",
|
||||
"bytes=20-10",
|
||||
"bytes=-0",
|
||||
"bytes=0-1,4-6",
|
||||
"other=0-1",
|
||||
] {
|
||||
assert!(check(range).is_err(), "{range}");
|
||||
}
|
||||
assert_eq!(requested_bounds(&HeaderMap::new(), 100).unwrap(), None);
|
||||
assert!(requested_bounds(&HeaderMap::new(), 0).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn upstream_range_expiry_and_media_headers_are_bound_before_bytes_escape() {
|
||||
let mut headers = HeaderMap::new();
|
||||
for (name, value) in [
|
||||
("content-length", "20"),
|
||||
("content-range", "bytes 20-39/100"),
|
||||
("content-type", "video/mp4"),
|
||||
("x-rental-expires-at", "200"),
|
||||
] {
|
||||
headers.insert(name, value.parse().unwrap());
|
||||
}
|
||||
assert_eq!(
|
||||
validate_upstream(206, &headers, 100, Some((20, 39)), 100).unwrap(),
|
||||
(206, 20, "video/mp4".into(), 200)
|
||||
);
|
||||
assert!(validate_upstream(200, &headers, 100, Some((20, 39)), 100).is_err());
|
||||
assert!(validate_upstream(206, &headers, 100, Some((20, 39)), 200).is_err());
|
||||
for (name, bad) in [
|
||||
("content-length", "21"),
|
||||
("content-range", "bytes 21-40/100"),
|
||||
("content-type", "text/html"),
|
||||
("x-rental-expires-at", "0"),
|
||||
] {
|
||||
let mut changed = headers.clone();
|
||||
changed.insert(name, bad.parse().unwrap());
|
||||
assert!(
|
||||
validate_upstream(206, &changed, 100, Some((20, 39)), 100).is_err(),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
headers.remove("content-range");
|
||||
headers.insert("content-length", "100".parse().unwrap());
|
||||
assert!(validate_upstream(200, &headers, 100, None, 100).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_probes_and_unauthenticated_get_do_not_touch_purchase_or_identity() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut config = crate::config::Config::default();
|
||||
config.data_dir = root.path().to_path_buf();
|
||||
let handler = ApiHandler::new(
|
||||
config,
|
||||
Arc::new(crate::state::StateManager::new()),
|
||||
Arc::new(crate::monitoring::MetricsStore::new()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// ApiHandler initialization may establish its own node identity, but the
|
||||
// denied route must not need installed apps, saved receipts or any peer.
|
||||
for method in [Method::HEAD, Method::POST] {
|
||||
let result = handler
|
||||
.handle_request(
|
||||
hyper::Request::builder()
|
||||
.method(method)
|
||||
.uri("/api/rental-playback/invalid")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
let result = handler
|
||||
.handle_request(
|
||||
hyper::Request::builder()
|
||||
.uri("/api/rental-playback/invalid")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.status(), StatusCode::UNAUTHORIZED);
|
||||
assert!(!root.path().join("content-purchases").exists());
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,17 @@ async fn forward_models() -> Result<Response<Body>> {
|
||||
/// OpenAI-shaped completion. Order matters: screen (S3) → budget gate (D-05,
|
||||
/// offline) → price quote → pay → forward → redeem change → record net.
|
||||
async fn forward_chat(req: Request<Body>, data_dir: &Path) -> Result<Response<Body>> {
|
||||
// An already-open iframe may still show its previous selection. The node's
|
||||
// saved choice is authoritative before any pricing, token or network work.
|
||||
let settings = crate::settings::model_provider::ModelProvider::load(data_dir).await?;
|
||||
if settings.provider != crate::settings::model_provider::Provider::Routstr {
|
||||
return Ok(json_response(
|
||||
StatusCode::CONFLICT,
|
||||
json!({"error": {
|
||||
"code": "provider_changed", "message": "Your AI provider changed. Reopen AIUI before sending this request."
|
||||
}}),
|
||||
));
|
||||
}
|
||||
let payload = hyper::body::to_bytes(req.into_body())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("read request payload: {e}"))?;
|
||||
@@ -445,6 +456,41 @@ mod tests {
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_routstr_selection_cannot_pay_after_provider_change() {
|
||||
let store = test_store().await;
|
||||
let token = store.create().await;
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
crate::settings::model_provider::ModelProvider {
|
||||
provider: crate::settings::model_provider::Provider::Claude,
|
||||
openai_model: String::new(),
|
||||
}
|
||||
.save(data_dir.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let r = req(
|
||||
"POST",
|
||||
"/aiui/api/routstr/chat/completions",
|
||||
Some(&token),
|
||||
"{}",
|
||||
);
|
||||
let response = route_routstr_proxy(
|
||||
&store,
|
||||
data_dir.path(),
|
||||
r,
|
||||
"/aiui/api/routstr/chat/completions",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||
assert_eq!(
|
||||
crate::assistant::AssistantBudget::load(data_dir.path())
|
||||
.await
|
||||
.spent_sats,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// D-05: a fresh node (no budget file → zero allowance) refuses the paid
|
||||
/// path BEFORE any pricing/network I/O — this test runs fully offline.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Owner-authenticated terminal sessions backed by private tmux processes.
|
||||
//!
|
||||
//! Browser connections are disposable attachments. The tmux process and its
|
||||
//! metadata remain on the node so a reconnect resumes the same workspace.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::process::Command;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
fn tmux_key_for_input(data: &str) -> Option<&'static str> {
|
||||
match data {
|
||||
"\u{3}" => Some("C-c"),
|
||||
"\u{4}" => Some("C-d"),
|
||||
"\r" | "\n" => Some("Enter"),
|
||||
"\u{7f}" => Some("BSpace"),
|
||||
"\t" => Some("Tab"),
|
||||
"\u{1b}[A" => Some("Up"),
|
||||
"\u{1b}[B" => Some("Down"),
|
||||
"\u{1b}[C" => Some("Right"),
|
||||
"\u{1b}[D" => Some("Left"),
|
||||
"\u{1b}[H" => Some("Home"),
|
||||
"\u{1b}[F" => Some("End"),
|
||||
"\u{1b}[3~" => Some("DC"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{build_response, ApiHandler};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct SessionRecord {
|
||||
pub schema: u8,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub workspace: String,
|
||||
pub state: String,
|
||||
pub tmux: String,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateRequest {
|
||||
name: Option<String>,
|
||||
workspace: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ClientMessage {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
data: Option<String>,
|
||||
cols: Option<u16>,
|
||||
rows: Option<u16>,
|
||||
}
|
||||
|
||||
pub(crate) fn state_dir() -> PathBuf {
|
||||
std::env::var_os("ARCHY_SESSION_STATE_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
if let Some(xdg) = std::env::var_os("XDG_STATE_HOME") {
|
||||
PathBuf::from(xdg).join("archipelago/sessions")
|
||||
} else if let Some(home) = std::env::var_os("HOME") {
|
||||
let user_dir = PathBuf::from(home).join(".local/state/archipelago/sessions");
|
||||
if user_dir.exists() {
|
||||
user_dir
|
||||
} else {
|
||||
PathBuf::from("/var/lib/archipelago/sessions")
|
||||
}
|
||||
} else {
|
||||
PathBuf::from("/var/lib/archipelago/sessions")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
async fn read_record(dir: &Path, id: &str) -> Result<SessionRecord> {
|
||||
if !valid_id(id) {
|
||||
return Err(anyhow!("invalid session id"));
|
||||
}
|
||||
let bytes = tokio::fs::read(dir.join(format!("{id}.json"))).await?;
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
|
||||
async fn tmux_alive(name: &str) -> bool {
|
||||
Command::new("tmux")
|
||||
.args(["has-session", "-t", name])
|
||||
.output()
|
||||
.await
|
||||
.map(|out| out.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn write_record(dir: &Path, record: &SessionRecord) -> Result<()> {
|
||||
tokio::fs::create_dir_all(dir).await?;
|
||||
let tmp = dir.join(format!(".{}.tmp-{}", record.id, Uuid::new_v4()));
|
||||
let final_path = dir.join(format!("{}.json", record.id));
|
||||
tokio::fs::write(&tmp, serde_json::to_vec_pretty(record)?).await?;
|
||||
tokio::fs::rename(tmp, final_path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list(dir: &Path) -> Result<Vec<SessionRecord>> {
|
||||
let mut out = Vec::new();
|
||||
let mut entries = match tokio::fs::read_dir(dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
if entry.path().extension().and_then(|s| s.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = tokio::fs::read(entry.path()).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(mut record) = serde_json::from_slice::<SessionRecord>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
record.state = if tmux_alive(&record.tmux).await {
|
||||
"detached".into()
|
||||
} else if record.state == "running" {
|
||||
"interrupted".into()
|
||||
} else {
|
||||
record.state.clone()
|
||||
};
|
||||
out.push(record);
|
||||
}
|
||||
out.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_response() -> Result<Response<hyper::Body>> {
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(serde_json::to_vec(
|
||||
&list(&state_dir()).await?,
|
||||
)?))?)
|
||||
}
|
||||
|
||||
pub(crate) async fn create(body: &[u8]) -> Result<Response<hyper::Body>> {
|
||||
let request: CreateRequest = serde_json::from_slice(body).unwrap_or(CreateRequest {
|
||||
name: None,
|
||||
workspace: None,
|
||||
});
|
||||
let workspace_was_requested = request.workspace.is_some();
|
||||
let workspace = request.workspace.unwrap_or_else(|| {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("Work")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
});
|
||||
let workspace_path = PathBuf::from(&workspace);
|
||||
if !workspace_was_requested {
|
||||
tokio::fs::create_dir_all(&workspace_path).await?;
|
||||
}
|
||||
if !workspace_path.is_absolute() || !workspace_path.is_dir() || workspace_path == Path::new("/")
|
||||
{
|
||||
return Ok(build_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"workspace must be an existing non-root directory"}"#),
|
||||
));
|
||||
}
|
||||
let id = format!("s-{}", Uuid::new_v4().simple());
|
||||
let tmux_name = format!("archy-{id}");
|
||||
let output = Command::new("tmux")
|
||||
.args(["new-session", "-d", "-s", &tmux_name, "-c", &workspace])
|
||||
.output()
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
return Ok(build_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"tmux could not start the session"}"#),
|
||||
));
|
||||
}
|
||||
let name = request
|
||||
.name
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.unwrap_or_else(|| "Work".into());
|
||||
let record = SessionRecord {
|
||||
schema: 1,
|
||||
id,
|
||||
name,
|
||||
workspace,
|
||||
state: "running".into(),
|
||||
tmux: tmux_name,
|
||||
updated_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
write_record(&state_dir(), &record).await?;
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::CREATED)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(hyper::Body::from(serde_json::to_vec(&record)?))?)
|
||||
}
|
||||
|
||||
pub(crate) async fn websocket(req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
|
||||
let id = req
|
||||
.uri()
|
||||
.query()
|
||||
.and_then(|query| {
|
||||
query
|
||||
.split('&')
|
||||
.find_map(|part| part.strip_prefix("session="))
|
||||
})
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let record = read_record(&state_dir(), &id).await?;
|
||||
if !tmux_alive(&record.tmux).await {
|
||||
return Ok(build_response(
|
||||
StatusCode::CONFLICT,
|
||||
"application/json",
|
||||
hyper::Body::from(r#"{"error":"session is not running"}"#),
|
||||
));
|
||||
}
|
||||
let (response, ws_fut) =
|
||||
hyper_ws_listener::create_ws(req).map_err(|e| anyhow!("WebSocket upgrade failed: {e}"))?;
|
||||
if let Some(ws_fut) = ws_fut {
|
||||
tokio::spawn(async move {
|
||||
let Ok(Ok(stream)) = ws_fut.await else { return };
|
||||
let (mut tx, mut rx) = stream.split();
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(150));
|
||||
let mut last = String::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
// Capture the visible pane only. Asking tmux for a large
|
||||
// historical range injects hundreds of blank rows into
|
||||
// xterm on every reconnect, producing a misleading
|
||||
// giant initial scrollbar. xterm owns live scrollback.
|
||||
let output = Command::new("tmux").args(["capture-pane", "-p", "-e", "-t", &record.tmux]).output().await;
|
||||
if let Ok(output) = output {
|
||||
let text = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
if text != last { last = text.clone(); if tx.send(Message::Text(serde_json::json!({"type":"output", "data":text}).to_string())).await.is_err() { break; } }
|
||||
} else { break; }
|
||||
}
|
||||
message = rx.next() => match message {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let Ok(message) = serde_json::from_str::<ClientMessage>(&text) else { continue };
|
||||
match message.kind.as_str() {
|
||||
"input" => if let Some(data) = message.data { let mut command = Command::new("tmux"); command.args(["send-keys", "-t", &record.tmux]); if let Some(key) = tmux_key_for_input(&data) { command.arg(key); } else { command.args(["-l", "--", &data]); } let _ = command.output().await; },
|
||||
"resize" => if let (Some(cols), Some(rows)) = (message.cols, message.rows) { let _ = Command::new("tmux").args(["resize-window", "-t", &record.tmux, "-x", &cols.to_string(), "-y", &rows.to_string()]).output().await; },
|
||||
"ping" => { let _ = tx.send(Message::Text(r#"{"type":"pong"}"#.into())).await; },
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Err(_)) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
pub(super) async fn handle_terminal_websocket(
|
||||
req: Request<hyper::Body>,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
websocket(req).await
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,47 @@
|
||||
|
||||
use super::RpcHandler;
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashSet;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const ANALYTICS_FILE: &str = "analytics-config.json";
|
||||
|
||||
/// Collector reports are unsigned claims, including historical on-disk reports.
|
||||
/// Provenance is assigned here, never accepted from their JSON payload.
|
||||
fn collector_report(
|
||||
mut report: serde_json::Value,
|
||||
expected_id: Option<&str>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let id = report
|
||||
.get("node_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Missing collector identity")?;
|
||||
anyhow::ensure!(
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& !id.contains('/')
|
||||
&& !id.contains('\\')
|
||||
&& !id.contains("..")
|
||||
&& !id.ends_with("-history")
|
||||
&& !id.chars().any(char::is_control),
|
||||
"Invalid collector identity"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
expected_id.is_none_or(|expected| expected == id),
|
||||
"Collector identity differs from record"
|
||||
);
|
||||
let object = report.as_object_mut().context("Invalid collector report")?;
|
||||
object.insert("source".into(), serde_json::json!("collector"));
|
||||
object.insert("trust_level".into(), serde_json::json!("unverified"));
|
||||
object.insert("identity_authenticated".into(), serde_json::json!(false));
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
async fn federation_ids(data_dir: &std::path::Path) -> Result<HashSet<String>> {
|
||||
// A damaged authoritative store must not promote unsigned collector data.
|
||||
crate::federation::load_node_identities(data_dir).await
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// Check if analytics are enabled.
|
||||
pub(super) async fn handle_analytics_get_status(&self) -> Result<serde_json::Value> {
|
||||
@@ -262,7 +299,7 @@ impl RpcHandler {
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let report = params.context("Missing telemetry report payload")?;
|
||||
let report = collector_report(params.context("Missing telemetry report payload")?, None)?;
|
||||
|
||||
// Validate required fields
|
||||
let node_id = report
|
||||
@@ -285,6 +322,13 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_str())
|
||||
.context("Missing required field: reported_at")?;
|
||||
|
||||
anyhow::ensure!(
|
||||
!federation_ids(&self.config.data_dir)
|
||||
.await?
|
||||
.contains(node_id),
|
||||
"Unsigned collector report conflicts with a known node identity"
|
||||
);
|
||||
|
||||
let fleet_dir = self.config.data_dir.join("telemetry-fleet");
|
||||
tokio::fs::create_dir_all(&fleet_dir)
|
||||
.await
|
||||
@@ -339,9 +383,9 @@ impl RpcHandler {
|
||||
let mut nodes: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
// ── Trusted federation nodes ─────────────────────────────────────
|
||||
let fed_nodes = crate::federation::load_nodes(&self.config.data_dir)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let fed_nodes = crate::federation::load_nodes(&self.config.data_dir).await?;
|
||||
let mut authoritative = federation_ids(&self.config.data_dir).await?;
|
||||
authoritative.extend(fed_nodes.iter().map(|n| n.did.clone()));
|
||||
for n in fed_nodes
|
||||
.iter()
|
||||
.filter(|n| n.trust_level == crate::federation::TrustLevel::Trusted)
|
||||
@@ -352,20 +396,19 @@ impl RpcHandler {
|
||||
(Some(u), Some(t)) if t > 0 => {
|
||||
serde_json::json!((u as f64 / t as f64 * 100.0).round())
|
||||
}
|
||||
_ => serde_json::json!(0),
|
||||
_ => serde_json::Value::Null,
|
||||
}
|
||||
};
|
||||
let apps = state.map(|s| s.apps.as_slice()).unwrap_or(&[]);
|
||||
let reported_at = state
|
||||
.map(|s| s.timestamp.clone())
|
||||
.or_else(|| n.last_seen.clone())
|
||||
.unwrap_or_else(|| n.added_at.clone());
|
||||
.or_else(|| n.last_seen.clone());
|
||||
|
||||
let mut report = serde_json::json!({
|
||||
"node_id": n.did,
|
||||
"node_name": state.and_then(|s| s.node_name.clone()).or_else(|| n.name.clone()),
|
||||
"uptime_secs": state.and_then(|s| s.uptime_secs).unwrap_or(0),
|
||||
"cpu_pct": state.and_then(|s| s.cpu_usage_percent).map(|v| v.round()).unwrap_or(0.0),
|
||||
"uptime_secs": state.and_then(|s| s.uptime_secs),
|
||||
"cpu_pct": state.and_then(|s| s.cpu_usage_percent).filter(|v| v.is_finite() && (0.0..=100.0).contains(v)).map(|v| v.round()),
|
||||
"mem_pct": pct(state.and_then(|s| s.mem_used_bytes), state.and_then(|s| s.mem_total_bytes)),
|
||||
"disk_pct": pct(state.and_then(|s| s.disk_used_bytes), state.and_then(|s| s.disk_total_bytes)),
|
||||
"container_count": apps.len(),
|
||||
@@ -379,6 +422,7 @@ impl RpcHandler {
|
||||
"reported_at": reported_at,
|
||||
"trust_level": n.trust_level.to_string(),
|
||||
"source": "federation",
|
||||
"identity_authenticated": true,
|
||||
});
|
||||
annotate_fleet_report(&mut report);
|
||||
nodes.push(report);
|
||||
@@ -401,9 +445,20 @@ impl RpcHandler {
|
||||
|
||||
match tokio::fs::read_to_string(entry.path()).await {
|
||||
Ok(data) => match serde_json::from_str::<serde_json::Value>(&data) {
|
||||
Ok(mut report) => {
|
||||
annotate_fleet_report(&mut report);
|
||||
nodes.push(report);
|
||||
Ok(report) => {
|
||||
if let Ok(mut report) =
|
||||
collector_report(report, name.strip_suffix(".json"))
|
||||
{
|
||||
let id = report["node_id"]
|
||||
.as_str()
|
||||
.expect("validated collector identity");
|
||||
// Include every relationship in this exclusion, so an unsigned
|
||||
// claim cannot undo observer/untrusted Fleet exclusions either.
|
||||
if !authoritative.contains(id) {
|
||||
annotate_fleet_report(&mut report);
|
||||
nodes.push(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(file = %name, error = %e, "Skipping corrupt fleet report");
|
||||
@@ -450,6 +505,14 @@ impl RpcHandler {
|
||||
anyhow::bail!("Invalid node_id");
|
||||
}
|
||||
|
||||
if federation_ids(&self.config.data_dir)
|
||||
.await?
|
||||
.contains(node_id)
|
||||
{
|
||||
return Ok(serde_json::json!({"node_id":node_id,"entries":[],"count":0,
|
||||
"history_available":false,"reason":"collector_history_not_authoritative"}));
|
||||
}
|
||||
|
||||
let history_path = self
|
||||
.config
|
||||
.data_dir
|
||||
@@ -461,8 +524,15 @@ impl RpcHandler {
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
|
||||
let history: Vec<_> = history
|
||||
.into_iter()
|
||||
.filter_map(|report| collector_report(report, Some(node_id)).ok())
|
||||
.collect();
|
||||
Ok(serde_json::json!({
|
||||
"node_id": node_id,
|
||||
"history_available": true,
|
||||
"source": "collector",
|
||||
"identity_authenticated": false,
|
||||
"entries": history,
|
||||
"count": history.len(),
|
||||
}))
|
||||
@@ -476,6 +546,7 @@ impl RpcHandler {
|
||||
return Ok(serde_json::json!({ "alerts": [] }));
|
||||
}
|
||||
|
||||
let authoritative = federation_ids(&self.config.data_dir).await?;
|
||||
let mut all_alerts: Vec<serde_json::Value> = Vec::new();
|
||||
let mut entries = tokio::fs::read_dir(&fleet_dir)
|
||||
.await
|
||||
@@ -498,19 +569,30 @@ impl RpcHandler {
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let report = match collector_report(report, name.strip_suffix(".json")) {
|
||||
Ok(report) => report,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let node_id = report
|
||||
.get("node_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
if authoritative.contains(&node_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(alerts) = report.get("recent_alerts").and_then(|v| v.as_array()) {
|
||||
for alert in alerts {
|
||||
let mut enriched = alert.clone();
|
||||
if let Some(obj) = enriched.as_object_mut() {
|
||||
obj.insert("node_id".to_string(), serde_json::json!(node_id));
|
||||
obj.insert("source".into(), serde_json::json!("collector"));
|
||||
obj.insert("trust_level".into(), serde_json::json!("unverified"));
|
||||
obj.insert("identity_authenticated".into(), serde_json::json!(false));
|
||||
all_alerts.push(enriched);
|
||||
}
|
||||
all_alerts.push(enriched);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,7 +643,7 @@ fn annotate_fleet_report(report: &mut serde_json::Value) {
|
||||
let is_online = reported
|
||||
.map(|dt| {
|
||||
let age = chrono::Utc::now().signed_duration_since(dt);
|
||||
age.num_minutes() < 30
|
||||
age.num_seconds() >= -60 && age.num_seconds() < 1800
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -569,7 +651,9 @@ fn annotate_fleet_report(report: &mut serde_json::Value) {
|
||||
.map(|dt| {
|
||||
let age = chrono::Utc::now().signed_duration_since(dt);
|
||||
let mins = age.num_minutes();
|
||||
if mins < 1 {
|
||||
if age.num_seconds() < -60 {
|
||||
"unknown (clock ahead)".to_string()
|
||||
} else if mins < 1 {
|
||||
"just now".to_string()
|
||||
} else if mins < 60 {
|
||||
format!("{}m ago", mins)
|
||||
@@ -586,3 +670,181 @@ fn annotate_fleet_report(report: &mut serde_json::Value) {
|
||||
obj.insert("last_seen".to_string(), serde_json::json!(last_seen));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod collector_provenance_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn unsigned_and_legacy_reports_cannot_assign_their_own_trust() {
|
||||
let report = collector_report(
|
||||
json!({"node_id":"claimed-node", "source":"federation",
|
||||
"trust_level":"trusted", "identity_authenticated":true, "cpu_pct":0}),
|
||||
Some("claimed-node"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(report["source"], "collector");
|
||||
assert_eq!(report["trust_level"], "unverified");
|
||||
assert_eq!(report["identity_authenticated"], false);
|
||||
assert_eq!(report["cpu_pct"], 0);
|
||||
assert_eq!(
|
||||
collector_report(report.clone(), Some("claimed-node")).unwrap(),
|
||||
report
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collector_cannot_claim_another_record_identity_or_malformed_id() {
|
||||
for value in [
|
||||
json!(null),
|
||||
json!([]),
|
||||
json!({"node_id":"other"}),
|
||||
json!({"node_id":"../escape"}),
|
||||
json!({"node_id":"bad\nidentity"}),
|
||||
json!({"node_id":"claimed-node-history"}),
|
||||
] {
|
||||
assert!(collector_report(value, Some("claimed-node")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collector_history_suffix_cannot_overwrite_another_nodes_history() {
|
||||
assert!(collector_report(json!({"node_id":"node-history"}), Some("node-history")).is_err());
|
||||
assert!(collector_report(json!({"node_id":"node-history"}), None).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fleet_reads_do_not_promote_old_collector_spoofs_or_history() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let peer = serde_json::from_value(json!({"did":"known-node", "pubkey":"00".repeat(32),
|
||||
"onion":format!("{}.onion", "a".repeat(56)), "trust_level":"trusted", "added_at":"now"})).unwrap();
|
||||
let observer =
|
||||
serde_json::from_value(json!({"did":"observer-node", "pubkey":"11".repeat(32),
|
||||
"onion":format!("{}.onion", "a".repeat(56)), "trust_level":"observer", "added_at":"now"}))
|
||||
.unwrap();
|
||||
crate::federation::save_nodes(data.path(), &[peer, observer])
|
||||
.await
|
||||
.unwrap();
|
||||
let mut config = crate::config::Config::default();
|
||||
config.data_dir = data.path().to_path_buf();
|
||||
let handler = RpcHandler::new(
|
||||
config,
|
||||
std::sync::Arc::new(crate::state::StateManager::new()),
|
||||
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
|
||||
crate::session::SessionStore::new_for_tests(data.path().join("sessions.json")),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let root = data.path().join("telemetry-fleet");
|
||||
tokio::fs::create_dir_all(&root).await.unwrap();
|
||||
let spoof = json!({"node_id":"known-node", "source":"federation", "trust_level":"trusted",
|
||||
"identity_authenticated":true, "version":"spoof", "reported_at":"2026-10-07T00:00:00Z",
|
||||
"recent_alerts":[{"message":"spoofed alert", "source":"federation", "identity_authenticated":true}]});
|
||||
tokio::fs::write(root.join("known-node.json"), spoof.to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(
|
||||
root.join("known-node-history.json"),
|
||||
json!([spoof.clone()]).to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(handler
|
||||
.handle_telemetry_ingest(Some(spoof.clone()))
|
||||
.await
|
||||
.is_err());
|
||||
let status = handler.handle_telemetry_fleet_status().await.unwrap();
|
||||
let nodes = status["nodes"].as_array().unwrap();
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0]["source"], "federation");
|
||||
assert_eq!(nodes[0]["identity_authenticated"], true);
|
||||
assert_ne!(nodes[0]["version"], "spoof");
|
||||
let history = handler
|
||||
.handle_telemetry_fleet_node_history(Some(json!({"node_id":"known-node"})))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history["history_available"], false);
|
||||
assert_eq!(history["count"], 0);
|
||||
assert!(
|
||||
handler.handle_telemetry_fleet_alerts().await.unwrap()["alerts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
// Display dedup collapses the shared onion, but unsigned reports must
|
||||
// still be excluded for BOTH raw identities, including the observer.
|
||||
let ids = federation_ids(data.path()).await.unwrap();
|
||||
assert!(ids.contains("known-node") && ids.contains("observer-node"));
|
||||
let mut observer_spoof = spoof.clone();
|
||||
observer_spoof["node_id"] = json!("observer-node");
|
||||
tokio::fs::write(root.join("observer-node.json"), observer_spoof.to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(handler
|
||||
.handle_telemetry_ingest(Some(observer_spoof))
|
||||
.await
|
||||
.is_err());
|
||||
let status = handler.handle_telemetry_fleet_status().await.unwrap();
|
||||
assert!(status["nodes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|node| node["source"] == "federation"));
|
||||
assert!(
|
||||
handler.handle_telemetry_fleet_alerts().await.unwrap()["alerts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let observer_history = handler
|
||||
.handle_telemetry_fleet_node_history(Some(json!({"node_id":"observer-node"})))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observer_history["history_available"], false);
|
||||
let mut independent = spoof;
|
||||
independent["node_id"] = json!("independent");
|
||||
handler
|
||||
.handle_telemetry_ingest(Some(independent))
|
||||
.await
|
||||
.unwrap();
|
||||
let status = handler.handle_telemetry_fleet_status().await.unwrap();
|
||||
let collector = status["nodes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|v| v["node_id"] == "independent")
|
||||
.unwrap();
|
||||
assert_eq!(collector["source"], "collector");
|
||||
assert_eq!(collector["identity_authenticated"], false);
|
||||
let alerts = handler.handle_telemetry_fleet_alerts().await.unwrap();
|
||||
assert_eq!(alerts["alerts"][0]["source"], "collector");
|
||||
assert_eq!(alerts["alerts"][0]["identity_authenticated"], false);
|
||||
// Corrupt authoritative state must fail closed, not turn collector
|
||||
// claims into the fallback representation of known relationships.
|
||||
let nodes_path = data.path().join("federation").join("nodes.json");
|
||||
tokio::fs::write(&nodes_path, b"{broken-authoritative-store")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(federation_ids(data.path()).await.is_err());
|
||||
assert!(handler.handle_telemetry_fleet_status().await.is_err());
|
||||
assert!(handler.handle_telemetry_fleet_alerts().await.is_err());
|
||||
assert!(handler
|
||||
.handle_telemetry_fleet_node_history(Some(json!({"node_id":"independent"})))
|
||||
.await
|
||||
.is_err());
|
||||
assert!(handler
|
||||
.handle_telemetry_ingest(Some(
|
||||
json!({"node_id":"new-claim", "version":"spoof", "reported_at":"now"})
|
||||
))
|
||||
.await
|
||||
.is_err());
|
||||
assert!(!root.join("new-claim.json").exists());
|
||||
assert_eq!(
|
||||
tokio::fs::read(&nodes_path).await.unwrap(),
|
||||
b"{broken-authoritative-store"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ impl RpcHandler {
|
||||
/// ~30% of UI calls error out even though the node is perfectly healthy.
|
||||
/// With retry + backoff, the UI sees a uniform slow-but-successful
|
||||
/// response instead of intermittent failures.
|
||||
async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
|
||||
pub(in crate::api::rpc) async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
method: &str,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,435 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
||||
use base64::Engine;
|
||||
for paid in [0, 1] {
|
||||
let response = paid_content_response(&[0, 255, 123], "application/octet-stream", paid);
|
||||
assert_eq!(response["data"], response["data_base64"]);
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(response["data"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
[0, 255, 123]
|
||||
);
|
||||
assert_eq!(response["size"], 3);
|
||||
assert_eq!(response["size_bytes"], 3);
|
||||
assert_eq!(response["paid_sats"], paid);
|
||||
assert_eq!(response["owned"], true);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_routes_media_and_sanitizes_the_filename() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
tokio::fs::create_dir(dir.path().join("filebrowser"))
|
||||
.await
|
||||
.unwrap();
|
||||
for (mime, folder) in [
|
||||
("image/png", "Photos"),
|
||||
("video/mp4", "Photos"),
|
||||
("audio/mpeg", "Music"),
|
||||
("text/plain", "Documents"),
|
||||
] {
|
||||
crate::content_owned::record_purchase(
|
||||
dir.path(),
|
||||
"seller.onion",
|
||||
"id",
|
||||
"../name #?.bin",
|
||||
mime,
|
||||
b"paid",
|
||||
1,
|
||||
"cashu",
|
||||
"now",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let item = crate::content_owned::list_owned_checked(dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
let relative = file_cached_purchase_in_files(dir.path(), &item)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(relative.starts_with(&format!("{folder}/name #?")));
|
||||
assert_eq!(
|
||||
tokio::fs::read(dir.path().join("filebrowser").join(relative))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"paid"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_files_storage_is_reported_without_creating_a_fake_installation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
crate::content_owned::record_purchase(
|
||||
dir.path(),
|
||||
"seller.onion",
|
||||
"id",
|
||||
"name",
|
||||
"text/plain",
|
||||
b"bytes",
|
||||
1,
|
||||
"cashu",
|
||||
"now",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let item = crate::content_owned::list_owned_checked(dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
assert!(file_cached_purchase_in_files(dir.path(), &item)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(!dir.path().join("filebrowser").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seller_errors_are_bounded_printable_and_identified_as_peer_text() {
|
||||
let status = reqwest::StatusCode::SERVICE_UNAVAILABLE;
|
||||
let message = seller_error_message(status, r#"{"error":"Cannot read file\n\u0000"}"#);
|
||||
assert!(message.starts_with("Seller response (503"));
|
||||
assert!(message.ends_with("Cannot read file"));
|
||||
assert!(!message.contains('\n') && !message.contains('\0'));
|
||||
let body = serde_json::json!({"error": "é".repeat(1000)}).to_string();
|
||||
assert!(seller_error_message(status, &body).chars().count() < 300);
|
||||
for body in ["not JSON", r#"{"error": 7}"#, r#"{"error":" "}"#] {
|
||||
assert_eq!(
|
||||
seller_error_message(status, body),
|
||||
"Peer returned an error (503 Service Unavailable)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn known_purchase_never_becomes_a_new_spend_when_cache_or_index_is_unavailable() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "seller.onion", "id", None, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
crate::content_owned::record_purchase(
|
||||
dir.path(),
|
||||
"seller.onion",
|
||||
"id",
|
||||
"file.txt",
|
||||
"text/plain",
|
||||
b"paid",
|
||||
1,
|
||||
"cashu",
|
||||
"now",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for (id, filename) in [("id", None), ("duplicate-id", Some("/file.txt"))] {
|
||||
let cached = existing_paid_content(dir.path(), "seller.onion", id, filename, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cached["paid_sats"], 0);
|
||||
assert_eq!(cached["already_owned"], true);
|
||||
assert_eq!(cached["data"], "cGFpZA==");
|
||||
}
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "different.onion", "id", None, false)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
tokio::fs::remove_file(dir.path().join("purchased-content/seller.onion/id"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "seller.onion", "id", None, false)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No new payment")
|
||||
);
|
||||
tokio::fs::write(dir.path().join("purchased-content/owned.json"), b"damaged")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "seller.onion", "other-id", None, false)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("no new payment")
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(dir.path().join("purchased-content/owned.json"))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"damaged"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inline_download_limits_known_and_chunked_bodies_without_draining_them() {
|
||||
use futures_util::StreamExt;
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
};
|
||||
let response: reqwest::Response = hyper::Response::new("small").into();
|
||||
assert!(bounded_content_bytes(response, 4).await.is_err());
|
||||
let response: reqwest::Response = hyper::Response::new("small").into();
|
||||
assert_eq!(bounded_content_bytes(response, 5).await.unwrap(), b"small");
|
||||
let consumed = Arc::new(AtomicUsize::new(0));
|
||||
let counter = consumed.clone();
|
||||
let chunks = futures_util::stream::iter(0..1000).map(move |_| {
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
Ok::<_, std::io::Error>(bytes::Bytes::from_static(b"abc"))
|
||||
});
|
||||
let body = reqwest::Body::wrap_stream(chunks);
|
||||
let response: reqwest::Response = hyper::Response::new(body).into();
|
||||
assert!(bounded_content_bytes(response, 4).await.is_err());
|
||||
assert_eq!(consumed.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onchain_delivery_streams_to_owned_cache_and_preserves_incomplete_recovery() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let response: reqwest::Response = hyper::Response::builder()
|
||||
.header("content-length", "2097152")
|
||||
.body(hyper::Body::from(vec![71u8; 2 * 1024 * 1024]))
|
||||
.unwrap()
|
||||
.into();
|
||||
let item = cache_peer_response(
|
||||
dir.path(),
|
||||
"seller.onion",
|
||||
"film",
|
||||
"film.mp4",
|
||||
"video/mp4",
|
||||
1,
|
||||
"onchain",
|
||||
response,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(item.download_complete);
|
||||
assert_eq!(item.ecash_backend, "onchain");
|
||||
let (_, mut file) = crate::content_owned::open_owned(dir.path(), "seller.onion", "film")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes).await.unwrap();
|
||||
assert_eq!(bytes, vec![71u8; 2 * 1024 * 1024]);
|
||||
let reply = cached_purchase_response(dir.path(), "seller.onion", "film", true, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(reply["owned"], true);
|
||||
assert!(reply.get("data").is_none());
|
||||
|
||||
let truncated: reqwest::Response = hyper::Response::builder()
|
||||
.header("content-length", "100")
|
||||
.body(hyper::Body::wrap_stream(futures_util::stream::iter([
|
||||
Ok(bytes::Bytes::from_static(b"short")),
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::UnexpectedEof,
|
||||
"connection closed before delivery completed",
|
||||
)),
|
||||
])))
|
||||
.unwrap()
|
||||
.into();
|
||||
assert!(cache_peer_response(
|
||||
dir.path(),
|
||||
"seller.onion",
|
||||
"interrupted",
|
||||
"film.mp4",
|
||||
"video/mp4",
|
||||
1,
|
||||
"onchain",
|
||||
truncated
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
let entries = crate::content_owned::list_owned_checked(dir.path())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!entries
|
||||
.iter()
|
||||
.find(|item| item.content_id == "interrupted")
|
||||
.unwrap()
|
||||
.download_complete
|
||||
);
|
||||
assert!(
|
||||
existing_paid_content(dir.path(), "seller.onion", "interrupted", None, true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_creation_stays_hidden_without_explicit_visibility_and_rejects_invalid_policy() {
|
||||
let empty = serde_json::json!({});
|
||||
assert!(matches!(
|
||||
parse_content_availability(&empty, "nobody").unwrap(),
|
||||
Availability::Nobody
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_content_access(&empty).unwrap(),
|
||||
AccessControl::Free
|
||||
));
|
||||
for invalid in [
|
||||
serde_json::json!({"access":null}),
|
||||
serde_json::json!({"access":"paid","price_sats":0}),
|
||||
serde_json::json!({"access":"paid","price_sats":1,"accepted_methods":["unsupported"]}),
|
||||
serde_json::json!({"access":"paid","price_sats":1,"accepted_methods":"ecash"}),
|
||||
] {
|
||||
assert!(parse_content_access(&invalid).is_err());
|
||||
}
|
||||
let paid = serde_json::json!({"access":"paid","price_sats":1,"accepted_methods":["ecash"],"availability":"all_peers"});
|
||||
assert!(matches!(
|
||||
parse_content_access(&paid).unwrap(),
|
||||
AccessControl::Paid { price_sats: 1, .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_content_availability(&paid, "nobody").unwrap(),
|
||||
Availability::AllPeers
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_share_returns_stable_id_and_complete_policy() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut config = crate::config::Config::default();
|
||||
config.data_dir = dir.path().to_path_buf();
|
||||
config.dev_mode = false;
|
||||
let sessions = crate::session::SessionStore::new_for_tests(dir.path().join("sessions.json"));
|
||||
let handler = RpcHandler::new(
|
||||
config,
|
||||
std::sync::Arc::new(crate::state::StateManager::new()),
|
||||
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
|
||||
sessions,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let first = handler
|
||||
.handle_content_publish(Some(serde_json::json!({
|
||||
"filename":"film.mp4", "access":"paid", "price_sats":1,
|
||||
"accepted_methods":["ecash"], "availability":"nobody"
|
||||
})))
|
||||
.await
|
||||
.unwrap();
|
||||
let second = handler
|
||||
.handle_content_publish(Some(serde_json::json!({
|
||||
"filename":"film.mp4", "access":"paid", "price_sats":2,
|
||||
"accepted_methods":["lightning"], "availability":"nobody"
|
||||
})))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first["item"]["id"], second["item"]["id"]);
|
||||
let catalog = content_server::load_catalog(dir.path()).await.unwrap();
|
||||
assert_eq!(catalog.items.len(), 1);
|
||||
let item = &catalog.items[0];
|
||||
assert_eq!(second["item"]["id"].as_str(), Some(item.id.as_str()));
|
||||
assert!(
|
||||
matches!(&item.access, AccessControl::Paid { price_sats: 2, accepted } if accepted == &["lightning"])
|
||||
);
|
||||
assert!(matches!(item.availability, Availability::Nobody));
|
||||
assert!(handler
|
||||
.handle_content_configure(Some(serde_json::json!({
|
||||
"id":item.id, "access":"free"
|
||||
})))
|
||||
.await
|
||||
.is_err());
|
||||
assert!(matches!(
|
||||
content_server::load_catalog(dir.path())
|
||||
.await
|
||||
.unwrap()
|
||||
.items[0]
|
||||
.access,
|
||||
AccessControl::Paid { price_sats: 2, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_payment_routes_refuse_fresh_spending_but_preserve_paid_cache() {
|
||||
let data = tempfile::tempdir().unwrap();
|
||||
let mut config = crate::config::Config::default();
|
||||
config.data_dir = data.path().to_path_buf();
|
||||
let handler = RpcHandler::new(
|
||||
config,
|
||||
std::sync::Arc::new(crate::state::StateManager::new()),
|
||||
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
|
||||
crate::session::SessionStore::new_for_tests(data.path().join("sessions.json")),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let onion = format!("{}.onion", "a".repeat(56));
|
||||
for method in ["cashu", "fedimint", "auto"] {
|
||||
let error = handler
|
||||
.handle_content_download_peer_paid(Some(serde_json::json!({
|
||||
"onion": onion, "content_id": "file", "price_sats": 1,
|
||||
"method": method, "cache_only": true
|
||||
})))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("No payment was sent"),
|
||||
"{error:#}"
|
||||
);
|
||||
}
|
||||
assert!(handler
|
||||
.handle_content_request_invoice(None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("saved Lightning purchase flow"));
|
||||
assert!(handler
|
||||
.handle_content_request_onchain(None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("saved Bitcoin purchase flow"));
|
||||
assert!(!data.path().join("wallet").exists());
|
||||
crate::content_owned::record_purchase(
|
||||
data.path(),
|
||||
&onion,
|
||||
"file",
|
||||
"paid.txt",
|
||||
"text/plain",
|
||||
b"previously paid",
|
||||
1,
|
||||
"fedimint",
|
||||
"2026-10-01T00:00:00Z",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for cache_only in [true, false] {
|
||||
let result = handler
|
||||
.handle_content_download_peer_paid(Some(serde_json::json!({
|
||||
"onion": onion, "content_id": "file", "price_sats": 1,
|
||||
"method": "fedimint", "cache_only": cache_only
|
||||
})))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result["paid_sats"], 0);
|
||||
assert_eq!(result["already_owned"], true);
|
||||
if cache_only {
|
||||
assert_eq!(result["owned"], true);
|
||||
} else {
|
||||
use base64::Engine;
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(result["data"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
b"previously paid"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(!data.path().join("wallet").exists());
|
||||
}
|
||||
@@ -11,6 +11,26 @@ impl RpcHandler {
|
||||
session_token: &Option<String>,
|
||||
) -> Result<serde_json::Value> {
|
||||
match method {
|
||||
"publishing.gateway-app-route" => {
|
||||
self.handle_publishing_gateway_app_route(params).await
|
||||
}
|
||||
"publishing.gateway-configure" => {
|
||||
self.handle_publishing_gateway_configure(params).await
|
||||
}
|
||||
"publishing.gateway-route" => self.handle_publishing_gateway_route(params).await,
|
||||
"publishing.gateway-disconnect" => {
|
||||
crate::publishing::gateway::disconnect(&self.config.data_dir).await
|
||||
}
|
||||
"publishing.status" => self.handle_publishing_status().await,
|
||||
"publishing.verify-https" => self.handle_publishing_verify_https(params).await,
|
||||
"publishing.update" => self.handle_publishing_update(params).await,
|
||||
"publishing.dns" => self.handle_publishing_dns(params).await,
|
||||
"publishing.generate" => self.handle_publishing_generate(params).await,
|
||||
"publishing.nsite-prepare" => self.handle_publishing_nsite_prepare(params).await,
|
||||
"publishing.blossom-prepare" => self.handle_publishing_blossom_prepare(params).await,
|
||||
"publishing.blossom-store" => self.handle_publishing_blossom_store(params).await,
|
||||
"publishing.access-create" => self.handle_publishing_access_create(params).await,
|
||||
"publishing.access-revoke" => self.handle_publishing_access_revoke(params).await,
|
||||
"echo" => self.handle_echo(params).await,
|
||||
"server.echo" => self.handle_echo(params).await,
|
||||
"server.get-state" => self.handle_server_get_state().await,
|
||||
@@ -132,6 +152,9 @@ impl RpcHandler {
|
||||
"lnd.newaddress" => self.handle_lnd_newaddress().await,
|
||||
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
||||
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
|
||||
"lnd.bump-quote" => self.handle_lnd_bump_quote(params).await,
|
||||
"lnd.bump-submit" => self.handle_lnd_bump_submit(params).await,
|
||||
"lnd.bump-status" => self.handle_lnd_bump_status(params).await,
|
||||
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||
"lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
@@ -156,6 +179,8 @@ impl RpcHandler {
|
||||
|
||||
// Multi-identity management
|
||||
"identity.list" => self.handle_identity_list(params).await,
|
||||
"identity.capabilities" => Ok(serde_json::json!({"import_nostr":true})),
|
||||
"identity.import-nostr" => self.handle_identity_import_nostr(params).await,
|
||||
"identity.create" => self.handle_identity_create(params).await,
|
||||
"identity.get" => self.handle_identity_get(params).await,
|
||||
"identity.delete" => self.handle_identity_delete(params).await,
|
||||
@@ -319,6 +344,8 @@ impl RpcHandler {
|
||||
// Content catalog management
|
||||
"content.list-mine" => self.handle_content_list_mine().await,
|
||||
"content.add" => self.handle_content_add(params).await,
|
||||
"content.publish" => self.handle_content_publish(params).await,
|
||||
"content.configure" => self.handle_content_configure(params).await,
|
||||
"content.remove" => self.handle_content_remove(params).await,
|
||||
"content.set-pricing" => self.handle_content_set_pricing(params).await,
|
||||
"content.set-availability" => self.handle_content_set_availability(params).await,
|
||||
@@ -327,6 +354,43 @@ impl RpcHandler {
|
||||
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
|
||||
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
|
||||
"content.browse-all-peers" => self.handle_content_browse_all_peers().await,
|
||||
"content.playback-handle" => self.handle_playback_handle(params, session_token).await,
|
||||
"content.playback-prepare" => self.handle_playback_prepare(params, session_token).await,
|
||||
"content.playback-start" => self.handle_playback_start(params, session_token).await,
|
||||
"content.playback-status" => self.handle_playback_status(params, session_token).await,
|
||||
"content.rental-purchase" => self.handle_content_rental_purchase(params).await,
|
||||
"content.onchain-cancel" => self.handle_onchain_operation(params, "cancel").await,
|
||||
"content.onchain-attempt" => self.handle_onchain_operation(params, "lookup").await,
|
||||
"content.onchain-create" => self.handle_onchain_operation(params, "create").await,
|
||||
"content.onchain-expose" => self.handle_onchain_operation(params, "expose").await,
|
||||
"content.onchain-prepare" => self.handle_onchain_operation(params, "prepare").await,
|
||||
"content.onchain-pay" => self.handle_onchain_operation(params, "pay").await,
|
||||
"content.onchain-recover" => self.handle_onchain_operation(params, "status").await,
|
||||
"content.onchain-download" => self.handle_onchain_operation(params, "download").await,
|
||||
"content.invoice-pay" => self.handle_lightning_operation(params, "pay").await,
|
||||
"content.invoice-download" => self.handle_lightning_operation(params, "download").await,
|
||||
"content.invoice-attempt" => self.handle_lightning_operation(params, "lookup").await,
|
||||
"content.invoice-retry-native" => {
|
||||
self.handle_lightning_operation(params, "retry").await
|
||||
}
|
||||
"content.invoice-create" => self.handle_lightning_operation(params, "create").await,
|
||||
"content.invoice-recover" => self.handle_lightning_operation(params, "status").await,
|
||||
"content.invoice-cancel" => self.handle_lightning_operation(params, "cancel").await,
|
||||
"content.purchase" => self.handle_content_purchase(params).await,
|
||||
"content.cancel-purchase" => self.handle_content_cancel_purchase(params).await,
|
||||
"content.payment-status" => self.handle_content_payment_status(params).await,
|
||||
"media.registration.context" => {
|
||||
self.handle_media_registration_context(params.unwrap_or_default())
|
||||
.await
|
||||
}
|
||||
"media.registration.prepare" => {
|
||||
self.handle_media_registration_prepare(params.unwrap_or_default())
|
||||
.await
|
||||
}
|
||||
"media.registration.resolve" => {
|
||||
self.handle_media_registration_resolve(params.unwrap_or_default())
|
||||
.await
|
||||
}
|
||||
"content.owned-list" => self.handle_content_owned_list().await,
|
||||
"content.owned-get" => self.handle_content_owned_get(params).await,
|
||||
"content.request-invoice" => self.handle_content_request_invoice(params).await,
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::mesh;
|
||||
use crate::network::dwn_store::DwnStore;
|
||||
use crate::nostr_handshake;
|
||||
use anyhow::Result;
|
||||
use futures_util::stream::{FuturesUnordered, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1";
|
||||
@@ -460,37 +462,65 @@ impl RpcHandler {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?;
|
||||
|
||||
let mut synced = 0u32;
|
||||
let mut failed = 0u32;
|
||||
// A dead Tor peer can take the bounded transport timeout. Serialising
|
||||
// those waits made one bad peer block every healthy peer behind it.
|
||||
// Keep concurrency bounded so a large federation cannot exhaust the
|
||||
// node's sockets or overwhelm a peer, while allowing healthy peers to
|
||||
// finish independently. Results are tagged and sorted below so the
|
||||
// response remains stable for callers and tests.
|
||||
const MAX_CONCURRENT_SYNCS: usize = 4;
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_SYNCS));
|
||||
let identity = Arc::new(node_identity);
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let mut pending = FuturesUnordered::new();
|
||||
|
||||
for (index, node) in nodes
|
||||
.into_iter()
|
||||
.filter(|node| node.trust_level != TrustLevel::Untrusted)
|
||||
.enumerate()
|
||||
{
|
||||
let data_dir = data_dir.clone();
|
||||
let local_did = local_did.clone();
|
||||
let identity = identity.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
pending.push(async move {
|
||||
let permit = semaphore
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("sync semaphore lives for all pending syncs");
|
||||
let result = federation::sync_with_peer(&data_dir, &node, &local_did, |bytes| {
|
||||
identity.sign(bytes)
|
||||
})
|
||||
.await;
|
||||
drop(permit);
|
||||
(index, node.did, result)
|
||||
});
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for node in &nodes {
|
||||
if node.trust_level == TrustLevel::Untrusted {
|
||||
continue;
|
||||
}
|
||||
|
||||
let did_clone = local_did.clone();
|
||||
match federation::sync_with_peer(&self.config.data_dir, node, &did_clone, |bytes| {
|
||||
node_identity.sign(bytes)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(state) => {
|
||||
synced += 1;
|
||||
results.push(serde_json::json!({
|
||||
"did": node.did,
|
||||
"status": "ok",
|
||||
"apps": state.apps.len(),
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
results.push(serde_json::json!({
|
||||
"did": node.did,
|
||||
"status": "error",
|
||||
"error": e.to_string(),
|
||||
}));
|
||||
}
|
||||
while let Some((index, did, result)) = pending.next().await {
|
||||
let row = match result {
|
||||
Ok(state) => serde_json::json!({
|
||||
"index": index,
|
||||
"did": did,
|
||||
"status": "ok",
|
||||
"apps": state.apps.len(),
|
||||
}),
|
||||
Err(e) => serde_json::json!({
|
||||
"index": index,
|
||||
"did": did,
|
||||
"status": "error",
|
||||
"error": e.to_string(),
|
||||
}),
|
||||
};
|
||||
results.push(row);
|
||||
}
|
||||
results.sort_by_key(|row| row["index"].as_u64().unwrap_or(u64::MAX));
|
||||
let synced = results.iter().filter(|row| row["status"] == "ok").count() as u32;
|
||||
let failed = results.len() as u32 - synced;
|
||||
for row in &mut results {
|
||||
if let Some(object) = row.as_object_mut() {
|
||||
object.remove("index");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,14 +602,39 @@ impl RpcHandler {
|
||||
None
|
||||
};
|
||||
|
||||
// Reuse the minute collector instead of running expensive probes for
|
||||
// every peer. An absent/stalled collector is unknown, never zero load.
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let latest = self
|
||||
.metrics_store
|
||||
.latest()
|
||||
.await
|
||||
.filter(|sample| (0..=180).contains(&now.saturating_sub(sample.timestamp)));
|
||||
let metrics = latest.as_ref().map(|sample| &sample.system);
|
||||
let uptime = tokio::fs::read_to_string("/proc/uptime")
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
|
||||
.filter(|v| v.is_finite() && *v >= 0.0)
|
||||
.map(|v| v as u64);
|
||||
let state = federation::build_local_state(
|
||||
apps,
|
||||
0.0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
metrics
|
||||
.map(|m| m.cpu_percent)
|
||||
.filter(|v| v.is_finite() && (0.0..=100.0).contains(v)),
|
||||
metrics
|
||||
.filter(|m| m.mem_total_bytes > 0)
|
||||
.map(|m| m.mem_used_bytes),
|
||||
metrics
|
||||
.filter(|m| m.mem_total_bytes > 0)
|
||||
.map(|m| m.mem_total_bytes),
|
||||
metrics
|
||||
.filter(|m| m.disk_total_bytes > 0)
|
||||
.map(|m| m.disk_used_bytes),
|
||||
metrics
|
||||
.filter(|m| m.disk_total_bytes > 0)
|
||||
.map(|m| m.disk_total_bytes),
|
||||
uptime,
|
||||
tor_active,
|
||||
server_name,
|
||||
nostr_npub,
|
||||
@@ -1224,76 +1279,120 @@ impl RpcHandler {
|
||||
);
|
||||
}
|
||||
|
||||
let reply = self.prepare_peer_approval_reply(&req).await?;
|
||||
// Persist the operator decision before transport. A relay outage must
|
||||
// not require another approval or lose the already-authorized reply.
|
||||
pending::decide(&self.config.data_dir, id, pending::PendingState::Approved).await?;
|
||||
let delivered = self.deliver_peer_approval_reply(&reply).await?;
|
||||
Ok(serde_json::json!({ "approved": true, "id": id, "delivery_pending": !delivered }))
|
||||
}
|
||||
|
||||
async fn prepare_peer_approval_reply(
|
||||
&self,
|
||||
req: &pending::PendingPeerRequest,
|
||||
) -> Result<federation::handshake_delivery::ApprovalReply> {
|
||||
use federation::handshake_delivery::{self, ApprovalReply};
|
||||
if let Some(reply) = handshake_delivery::find(&self.config.data_dir, &req.id).await? {
|
||||
anyhow::ensure!(
|
||||
reply.recipient == req.from_nostr_pubkey && reply.expected_did == req.from_did,
|
||||
"Approval recipient changed"
|
||||
);
|
||||
return Ok(reply);
|
||||
}
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let local_did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let local_onion = data
|
||||
.server_info
|
||||
.tor_address
|
||||
.clone()
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Tor address not available"))?;
|
||||
let local_pubkey = data.server_info.pubkey.clone();
|
||||
|
||||
// Generate a one-shot federation invite. The code embeds OUR onion
|
||||
// and OUR pubkey, but it leaves this box only inside the NIP-44
|
||||
// ciphertext below.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let local_fips_npub = identity::fips_npub(&identity_dir).await.unwrap_or(None);
|
||||
// Discovery/connection-request approvals admit the requester as
|
||||
// Observer — the invite itself now carries that level, so both
|
||||
// sides converge on Observer without post-hoc demotion.
|
||||
let local_fips_npub = identity::fips_npub(&self.config.data_dir.join("identity"))
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
let invite_code = federation::create_invite(
|
||||
&self.config.data_dir,
|
||||
&local_did,
|
||||
&local_onion,
|
||||
&local_pubkey,
|
||||
local_onion,
|
||||
&data.server_info.pubkey,
|
||||
local_fips_npub.as_deref(),
|
||||
TrustLevel::Observer,
|
||||
)
|
||||
.await?;
|
||||
handshake_delivery::stage(
|
||||
&self.config.data_dir,
|
||||
ApprovalReply {
|
||||
request_id: req.id.clone(),
|
||||
recipient: req.from_nostr_pubkey.clone(),
|
||||
expected_did: req.from_did.clone(),
|
||||
invite_code,
|
||||
attempts: 0,
|
||||
next_attempt: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Pre-add the requester to OUR federation list as Observer so that
|
||||
// when their `federation.peer-joined` callback arrives over Tor we
|
||||
// already trust their pubkey enough to accept the join. Their DID
|
||||
// and pubkey come from the request — we'll cross-check the pubkey
|
||||
// against the eventual peer-joined signature in the existing
|
||||
// verification path (handlers.rs line ~365).
|
||||
if !req.from_did.is_empty() {
|
||||
// We don't know the requester's onion or ed25519 pubkey yet —
|
||||
// they'll send those in the federation.peer-joined callback
|
||||
// after they apply our invite. Until then we can't add a real
|
||||
// FederatedNode entry. We just store the pending row as
|
||||
// Approved so the UI shows progress, and trust the existing
|
||||
// peer-joined handler to admit them as Observer when they call.
|
||||
//
|
||||
// Caveat: peer-joined currently hardcodes TrustLevel::Trusted.
|
||||
// We override that below by demoting on success.
|
||||
debug!(
|
||||
requester_did = %req.from_did,
|
||||
"Approval pending — waiting for federation.peer-joined callback over Tor"
|
||||
);
|
||||
}
|
||||
|
||||
// Encrypt + send the invite over NIP-44 to the requester.
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
nostr_handshake::send_peer_invite(
|
||||
&identity_dir,
|
||||
&req.from_nostr_pubkey,
|
||||
&invite_code,
|
||||
&self.config.nostr_relays,
|
||||
async fn deliver_peer_approval_reply(
|
||||
&self,
|
||||
reply: &federation::handshake_delivery::ApprovalReply,
|
||||
) -> Result<bool> {
|
||||
let Some(claimed) = federation::handshake_delivery::claim(
|
||||
&self.config.data_dir,
|
||||
&reply.request_id,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let result = nostr_handshake::send_peer_invite(
|
||||
&self.config.data_dir.join("identity"),
|
||||
&claimed.recipient,
|
||||
&claimed.invite_code,
|
||||
&self.handshake_relays().await,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
if result.is_err() {
|
||||
warn!(request_id = %reply.request_id, "Peer approval delivery deferred; durable retry scheduled");
|
||||
}
|
||||
Ok(result.is_ok())
|
||||
}
|
||||
|
||||
pending::set_state(&self.config.data_dir, id, pending::PendingState::Approved).await?;
|
||||
info!(
|
||||
id = %id,
|
||||
from = %req.from_nostr_pubkey,
|
||||
"Approved peer request and shipped invite over NIP-44"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"approved": true,
|
||||
"id": id,
|
||||
}))
|
||||
/// Recover relay loss and legacy approvals without changing trust or
|
||||
/// resurrecting a node that the operator explicitly removed.
|
||||
pub(in crate::api::rpc) async fn retry_peer_approval_replies(&self) -> Result<()> {
|
||||
let requests = pending::load_pending(&self.config.data_dir).await?;
|
||||
let nodes = federation::load_nodes(&self.config.data_dir).await?;
|
||||
let removed = federation::load_removed_dids(&self.config.data_dir).await?;
|
||||
let cutoff = chrono::Utc::now() - chrono::Duration::days(30);
|
||||
let mut sent = 0;
|
||||
for req in requests {
|
||||
if req.outbound || req.state != pending::PendingState::Approved {
|
||||
federation::handshake_delivery::remove(&self.config.data_dir, &req.id).await?;
|
||||
continue;
|
||||
}
|
||||
let expired = chrono::DateTime::parse_from_rfc3339(&req.received_at)
|
||||
.map(|time| time < cutoff)
|
||||
.unwrap_or(true);
|
||||
if expired
|
||||
|| removed.contains(&req.from_did)
|
||||
|| nodes.iter().any(|node| node.did == req.from_did)
|
||||
{
|
||||
federation::handshake_delivery::remove(&self.config.data_dir, &req.id).await?;
|
||||
continue;
|
||||
}
|
||||
if req.from_did.is_empty() || sent >= 4 {
|
||||
continue;
|
||||
}
|
||||
let reply = self.prepare_peer_approval_reply(&req).await?;
|
||||
if reply.next_attempt > chrono::Utc::now().timestamp() {
|
||||
continue;
|
||||
}
|
||||
self.deliver_peer_approval_reply(&reply).await?;
|
||||
sent += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// federation.reject-request — drop a pending request and, if requested,
|
||||
@@ -1323,19 +1422,19 @@ impl RpcHandler {
|
||||
);
|
||||
}
|
||||
|
||||
pending::decide(&self.config.data_dir, id, pending::PendingState::Rejected).await?;
|
||||
if notify {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
let _ = nostr_handshake::send_peer_reject(
|
||||
&identity_dir,
|
||||
&req.from_nostr_pubkey,
|
||||
reason,
|
||||
&self.config.nostr_relays,
|
||||
&self.handshake_relays().await,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pending::set_state(&self.config.data_dir, id, pending::PendingState::Rejected).await?;
|
||||
info!(id = %id, from = %req.from_nostr_pubkey, "Rejected peer request");
|
||||
Ok(serde_json::json!({ "rejected": true, "id": id }))
|
||||
}
|
||||
@@ -1361,16 +1460,7 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
|
||||
let req = pending::find_by_id(&self.config.data_dir, id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Pending request not found: {}", id))?;
|
||||
if !req.outbound || !matches!(req.state, pending::PendingState::Sent) {
|
||||
anyhow::bail!(
|
||||
"Can only cancel outbound requests in Sent state (outbound={}, state={:?})",
|
||||
req.outbound,
|
||||
req.state
|
||||
);
|
||||
}
|
||||
let req = pending::cancel_outbound(&self.config.data_dir, id).await?;
|
||||
|
||||
if notify {
|
||||
let identity_dir = self.config.data_dir.join("identity");
|
||||
@@ -1380,7 +1470,7 @@ impl RpcHandler {
|
||||
&identity_dir,
|
||||
&req.from_nostr_pubkey,
|
||||
reason,
|
||||
&self.config.nostr_relays,
|
||||
&self.handshake_relays().await,
|
||||
self.config.nostr_tor_proxy.as_deref(),
|
||||
)
|
||||
.await
|
||||
@@ -1393,7 +1483,6 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
pending::delete(&self.config.data_dir, id).await?;
|
||||
info!(id = %id, to = %req.from_nostr_pubkey, notified = notify, "Cancelled outbound peer request");
|
||||
Ok(serde_json::json!({ "cancelled": true, "id": id, "notified": notify }))
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user