Compare commits
550
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94899e69d3 | ||
|
|
8d17597202 | ||
|
|
40c3e2cd8b | ||
|
|
6297c3733b | ||
|
|
23f73519d3 | ||
|
|
b0395ce9cf | ||
|
|
eef17eb79b | ||
|
|
5828df5658 | ||
|
|
7f03994e97 | ||
|
|
fe398df8f6 | ||
|
|
0df423a84f | ||
|
|
cea4fa1a5a | ||
|
|
92d2855bff | ||
|
|
5e737fac6c | ||
|
|
e959d0eda2 | ||
|
|
799cbe1bc9 | ||
|
|
6e3fea0abb | ||
|
|
fde5a5c907 | ||
|
|
dd097b952c | ||
|
|
58ff04f782 | ||
|
|
6afb6abccf | ||
|
|
8d70d0312c | ||
|
|
daea20bcb2 | ||
|
|
dca32b078b | ||
|
|
cf3c38bed0 | ||
|
|
a28c61af69 | ||
|
|
a1bc642615 | ||
|
|
214cebfac2 | ||
|
|
83632c2439 | ||
|
|
8a70232f18 | ||
|
|
ccfa91aa57 | ||
|
|
b29d58213f | ||
|
|
bd9f00ecbf | ||
|
|
c83037c04e | ||
|
|
0008f48988 | ||
|
|
63e21bb102 | ||
|
|
2ca50ae36c | ||
|
|
b2ecd940de | ||
|
|
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 | ||
|
|
69cd4021f2 | ||
|
|
a67cffe88d | ||
|
|
2b04f9a79c | ||
|
|
2fad10c8de | ||
|
|
3fc37642cd | ||
|
|
b2ada09b7c | ||
|
|
883c5a7c76 | ||
|
|
7946ef8636 | ||
|
|
631c2bc73a | ||
|
|
57923b0a5f | ||
|
|
e5b52b84a5 | ||
|
|
5c0b6402ed | ||
|
|
e110dd1c0c | ||
|
|
67a24d6a65 | ||
|
|
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 | ||
|
|
00682e6420 | ||
|
|
95cdc3daea | ||
|
|
1d05f2c27a | ||
|
|
b3f16d07a6 | ||
|
|
14d2b37e99 | ||
|
|
f5b255ee68 | ||
|
|
6e8d90fb5f | ||
|
|
66c4b0d375 | ||
|
|
0f74ebfbbe | ||
|
|
ee11863ada | ||
|
|
86052d9552 | ||
|
|
047ef98987 | ||
|
|
c681472e15 | ||
|
|
7c0ba14a00 | ||
|
|
eacd74e1db | ||
|
|
34b68001d1 | ||
|
|
0fac51b9c5 | ||
|
|
4f0d123f27 | ||
|
|
13b1329c21 | ||
|
|
c4aa72dccc | ||
|
|
d35474f774 | ||
|
|
a03f340bd1 | ||
|
|
caaa2e729e | ||
|
|
fbb3ada87d | ||
|
|
72e84439ee | ||
|
|
5081a4fe7d | ||
|
|
39727dacbc | ||
|
|
1e409007d4 | ||
|
|
8f144c3038 | ||
|
|
8258705df7 | ||
|
|
d13002e022 | ||
|
|
e625b29d9e | ||
|
|
c4ed9fb1fa | ||
|
|
2bc5e98edb | ||
|
|
c1e14f7c7a | ||
|
|
564ffe1c47 | ||
|
|
c34d6ef76f | ||
|
|
dac29baf97 | ||
|
|
ef8c3a76be | ||
|
|
dc7b598558 | ||
|
|
69f3a355c7 | ||
|
|
f5c0ba85cd | ||
|
|
973356df16 | ||
|
|
e5a0d95459 | ||
|
|
b9862c7643 | ||
|
|
6fe9c5f81b | ||
|
|
28454264ac | ||
|
|
84b04d1634 | ||
|
|
ce5c04d49d | ||
|
|
ce9fca1c38 | ||
|
|
e661f237f1 | ||
|
|
f9af30b08a | ||
|
|
87a5025341 | ||
|
|
2947277205 | ||
|
|
db52c06a72 | ||
|
|
4b14b62e74 | ||
|
|
5da91e4099 | ||
|
|
62731cc729 | ||
|
|
5e17ace690 | ||
|
|
b010471a4a | ||
|
|
c4ede96517 | ||
|
|
be06e1a502 | ||
|
|
094f42312c | ||
|
|
da8c3ec193 | ||
|
|
4fdf8e8c58 | ||
|
|
61b5d93b11 | ||
|
|
be06b3ce2b | ||
|
|
f3d96ae2ee | ||
|
|
a4ae375617 | ||
|
|
0646bc4e85 | ||
|
|
0faaf4577f | ||
|
|
f9a1ef031c | ||
|
|
cf240df4b6 | ||
|
|
d8320896c4 | ||
|
|
b87f1f0612 | ||
|
|
1ca002661b | ||
|
|
0d0e2e243a | ||
|
|
9c49b502e3 | ||
|
|
d68a013e35 | ||
|
|
1464b1b24d | ||
|
|
82001403b4 | ||
|
|
81ede159ac | ||
|
|
8e988be853 | ||
|
|
210f7f1b12 | ||
|
|
ed49cc974f | ||
|
|
4849186ab9 | ||
|
|
3347b8b8b9 | ||
|
|
e382e679ae | ||
|
|
77d0768a21 | ||
|
|
f133d5555a | ||
|
|
cbd5314dd9 | ||
|
|
9fb2e1ed9e | ||
|
|
7125dea05d | ||
|
|
bcdf2c75be | ||
|
|
e77f60085d | ||
|
|
6c31eb9d4a | ||
|
|
63e6c64c63 | ||
|
|
4d8bb1fd44 | ||
|
|
2b4b60013c | ||
|
|
f0ef410948 | ||
|
|
19467e9b7c | ||
|
|
628ed252b4 | ||
|
|
bc94445ca0 | ||
|
|
04cf0f663a | ||
|
|
576c642da4 | ||
|
|
12866db84a | ||
|
|
a184254706 | ||
|
|
192e045426 | ||
|
|
9ac46a69f8 | ||
|
|
bf6ef9644c | ||
|
|
c32910809e |
+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 }}"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Blocking incident: Framework LND startup and false zero balance
|
||||
|
||||
Before starting or resuming work in this repository, read
|
||||
`docs/incident-framework-lnd-startup.md` and check its status.
|
||||
|
||||
The user explicitly required this incident to take priority over later work.
|
||||
While its status is OPEN:
|
||||
|
||||
- Surface this blocker at session start, including when the user asks for unrelated work.
|
||||
- Prioritize investigation and repair on the actual Framework node. If access is
|
||||
unavailable, ask for it and continue useful offline investigation; defer unrelated
|
||||
work unless the user explicitly overrides this priority.
|
||||
- A manual LND restart, a source patch, passing local tests, or publishing an OTA
|
||||
does not resolve the incident. Do not mark it fixed until the Framework's startup,
|
||||
Receive flow, and balance behavior are verified on the node, including a controlled
|
||||
reboot with access and recovery arrangements in place.
|
||||
- Preserve wallet identity, wallet/channel databases, credentials, and backups.
|
||||
Never run wallet wipe/recreation as an automatic investigation or recovery step.
|
||||
- Record evidence, changes, validation, and remaining work in the incident document.
|
||||
|
||||
This priority comes from the user's explicit instruction on 2026-09-15. It remains
|
||||
in effect across sessions until the documented acceptance criteria are met or the
|
||||
user explicitly changes it.
|
||||
|
||||
## 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 = 48
|
||||
versionName = "0.5.28"
|
||||
versionCode = 57
|
||||
versionName = "0.5.37"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
@@ -41,6 +41,17 @@ android {
|
||||
enableV1Signing = true
|
||||
enableV2Signing = true
|
||||
}
|
||||
// Local-only UAT builds install beside both the production companion
|
||||
// and its shared-key debug package. The ignored uat.keystore is made
|
||||
// on the validation box; it must never be used for a public artifact.
|
||||
create("uat") {
|
||||
storeFile = file("uat.keystore")
|
||||
storePassword = "android"
|
||||
keyAlias = "androiduatkey"
|
||||
keyPassword = "android"
|
||||
enableV1Signing = true
|
||||
enableV2Signing = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -51,6 +62,13 @@ android {
|
||||
versionNameSuffix = "-debug"
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
create("uat") {
|
||||
initWith(getByName("debug"))
|
||||
applicationIdSuffix = ".uat"
|
||||
versionNameSuffix = "-uat"
|
||||
signingConfig = signingConfigs.getByName("uat")
|
||||
matchingFallbacks += listOf("debug")
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
@@ -118,12 +136,15 @@ tasks.register<Exec>("buildRustArm64") {
|
||||
|
||||
tasks.matching {
|
||||
it.name in listOf(
|
||||
"mergeDebugNativeLibs", "mergeReleaseNativeLibs",
|
||||
"mergeDebugJniLibFolders", "mergeReleaseJniLibFolders",
|
||||
"mergeDebugNativeLibs", "mergeUatNativeLibs", "mergeReleaseNativeLibs",
|
||||
"mergeDebugJniLibFolders", "mergeUatJniLibFolders", "mergeReleaseJniLibFolders",
|
||||
)
|
||||
}.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,8 +356,9 @@ private object KioskWebView {
|
||||
private fun injectSafeAreaVars(view: WebView) {
|
||||
val insets = view.rootWindowInsets ?: return // listener re-fires when real
|
||||
val density = view.resources.displayMetrics.density
|
||||
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
|
||||
val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt()
|
||||
val compatibleInsets = androidx.core.view.WindowInsetsCompat.toWindowInsetsCompat(insets, view)
|
||||
val sat = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.statusBars()).top / density).toInt()
|
||||
val sab = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom / density).toInt()
|
||||
// The insets listener fires on every pass (every IME show/hide); skip the
|
||||
// JS round-trip — and the Vue event it dispatches — when nothing changed.
|
||||
val stamp = "sa:$sat,$sab"
|
||||
@@ -377,7 +408,8 @@ private fun injectSafeAreaVars(view: WebView) {
|
||||
private fun injectTopInset(view: WebView) {
|
||||
val insets = view.rootWindowInsets ?: return
|
||||
val density = view.resources.displayMetrics.density
|
||||
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
|
||||
val compatibleInsets = androidx.core.view.WindowInsetsCompat.toWindowInsetsCompat(insets, view)
|
||||
val sat = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.statusBars()).top / density).toInt()
|
||||
if (sat <= 0) return
|
||||
view.evaluateJavascript(
|
||||
"""
|
||||
@@ -609,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.
|
||||
@@ -896,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,
|
||||
@@ -911,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
|
||||
@@ -991,6 +1031,51 @@ fun WebViewScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** HTML downloads are not handled by WebView.
|
||||
* Fetch only this connected node's public CA
|
||||
* over its always-available HTTP listener,
|
||||
* verify it is an actual CA certificate, then
|
||||
* hand it to Android's trusted system prompt.
|
||||
* No caller-controlled certificate bytes are
|
||||
* accepted by this bridge. */
|
||||
@android.webkit.JavascriptInterface
|
||||
fun installNodeCertificate() {
|
||||
scope.launch {
|
||||
try {
|
||||
val der = withContext(Dispatchers.IO) {
|
||||
val host = android.net.Uri.parse(serverUrl).host
|
||||
?: error("node URL has no host")
|
||||
val caUrl = java.net.URI(
|
||||
"http", null, host, 80, "/ca.crt", null, null,
|
||||
).toASCIIString()
|
||||
val request = okhttp3.Request.Builder().url(caUrl).build()
|
||||
okhttp3.OkHttpClient().newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) error("CA download failed")
|
||||
val bytes = response.body?.bytes() ?: error("empty CA")
|
||||
if (bytes.size > 64 * 1024) error("CA is too large")
|
||||
val cert = java.security.cert.CertificateFactory
|
||||
.getInstance("X.509")
|
||||
.generateCertificate(java.io.ByteArrayInputStream(bytes))
|
||||
as java.security.cert.X509Certificate
|
||||
if (cert.basicConstraints < 0) error("certificate is not a CA")
|
||||
cert.encoded
|
||||
}
|
||||
}
|
||||
val intent = android.security.KeyChain.createInstallIntent().apply {
|
||||
putExtra(android.security.KeyChain.EXTRA_CERTIFICATE, der)
|
||||
putExtra(
|
||||
android.security.KeyChain.EXTRA_NAME,
|
||||
"Archipelago node CA",
|
||||
)
|
||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
// Network failure, invalid CA, or no credential installer.
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ArchipelagoNative",
|
||||
)
|
||||
@@ -1039,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
|
||||
@@ -1134,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
|
||||
}
|
||||
@@ -1499,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.
|
||||
@@ -1523,6 +1618,11 @@ private fun InAppBrowser(
|
||||
var loaderIcon by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var progress by remember { mutableIntStateOf(0) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
// Once this WebView has painted an app, keep that surface visible during
|
||||
// same-app reloads/navigation. Covering every navigation with an opaque
|
||||
// Compose loader caused GitWorkshop to flash, and an IndeeHub auth reload
|
||||
// could remain covered when WebView omitted the final callback.
|
||||
var hasCommittedPage by remember { mutableStateOf(false) }
|
||||
var canGoBack by remember { mutableStateOf(false) }
|
||||
var canGoForward by remember { mutableStateOf(false) }
|
||||
// Main-frame load failure — the branded offline screen renders instead of
|
||||
@@ -1591,11 +1691,32 @@ 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()
|
||||
val appBrowserView = this
|
||||
addJavascriptInterface(
|
||||
object {
|
||||
@android.webkit.JavascriptInterface
|
||||
fun expectPageTransition() {
|
||||
appBrowserView.post {
|
||||
hasCommittedPage = false
|
||||
loading = true
|
||||
appBrowserView.invalidate()
|
||||
}
|
||||
}
|
||||
},
|
||||
"ArchipelagoSurface",
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1623,7 +1744,7 @@ private fun InAppBrowser(
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||
loading = true
|
||||
loading = !hasCommittedPage
|
||||
loadError = false
|
||||
view?.let {
|
||||
injectTopInset(it)
|
||||
@@ -1632,6 +1753,7 @@ private fun InAppBrowser(
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, u: String?) {
|
||||
hasCommittedPage = true
|
||||
loading = false
|
||||
canGoBack = view?.canGoBack() == true
|
||||
canGoForward = view?.canGoForward() == true
|
||||
@@ -1641,6 +1763,14 @@ private fun InAppBrowser(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageCommitVisible(view: WebView?, url: String?) {
|
||||
// Fires when the new main-frame pixels are ready,
|
||||
// earlier and more reliably than onPageFinished
|
||||
// for service-worker-controlled SPAs.
|
||||
hasCommittedPage = true
|
||||
loading = false
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
@@ -1732,6 +1862,7 @@ private fun InAppBrowser(
|
||||
text = stringResource(R.string.retry),
|
||||
onClick = {
|
||||
loadError = false
|
||||
hasCommittedPage = false
|
||||
loading = true
|
||||
browser?.reload()
|
||||
},
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
+213
-1
@@ -1,5 +1,217 @@
|
||||
# Changelog
|
||||
|
||||
## 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)
|
||||
|
||||
- **GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.
|
||||
- **Fresh GitWorkshop installs build the correct image.** The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.
|
||||
- **Curated app classification is regression-tested.** Every user-facing app remains in My Apps during installation, while headless services stay in Services.
|
||||
|
||||
## v1.8.12-alpha (2026-09-11)
|
||||
|
||||
- **Fresh IndeedHub installs no longer share a fleet-wide encryption root.** The API now generates a persistent per-node AES master secret and shares it with the media worker through the platform's protected secret environment. Existing nodes migrate the exact legacy value they are already using before any container can be recreated, preserving access to encrypted data; an unreadable or empty existing root fails safely instead of being silently replaced. The manifest path, retired fallback installer, and container repair script follow the same rule.
|
||||
|
||||
- **The Companion download advertises and re-announces the APK it actually serves.** The Discover banner and its install prompt now share the no-cache APK metadata, visibly report Companion 0.5.32 build 52, and remember dismissal per Android build rather than forever, so an existing browser gets one useful update prompt when the APK changes. The ISO gate reads the expected version from the Android build itself instead of accepting the stale 0.5.28 payload.
|
||||
|
||||
- **GitWorkshop's dependency audit is clean.** The pinned upstream client keeps its separately reviewable Archipelago integration patch and now applies a deterministic dependency patch: safe lock refreshes plus targeted `fflate`, React Router, and Vitest upgrades remove all ten production advisories and all eight development advisories. A clean install reports zero vulnerabilities; type-check, all 152 upstream unit tests, and the exact Archipelago subpath build pass.
|
||||
|
||||
- **Every completed payment now gets the full Lightning-style receipt screen.** Cashu and Fedimint sends no longer leave the payment form open behind a token; wallet, QR-scan, Web5, and app-requested sends all replace their forms with the animated success state. Payment hashes, transaction IDs, ecash tokens/notes, mint details, and other useful references remain copyable in the receipt, and receive completions open the same distinct payment-success modal. Minibits claims retain a short-lived durable receipt so the visible modal still reports success when another dashboard or Companion context wins the claim-poll race, while concurrent watchers now share one bounded relay fetch instead of queueing several long polls.
|
||||
|
||||
- **TollGate provisioning closes the free-access path without taking over an admin network.** Confirmed upstream `TollGate-*` access points are moved from LAN onto the paid network, mint URLs are normalized consistently, and operators can set a validated Lightning payout address without replacing merchant keys or other revenue-share identities. Malformed existing identity data now stops provisioning safely instead of being overwritten.
|
||||
|
||||
- **Cashu receive gains a human-readable Minibits Lightning address.** The node derives the profile from the existing ecash recovery phrase, collects payments from the Minibits Nostr delivery relays, and redeems them into the Cashu wallet. Claim polling is single-flight, state and already-consumed tokens are written atomically with private permissions, same-second events are deduplicated without being skipped, restored seeds cannot reuse another wallet's profile, and pending claims retain the service key that encrypted them across key rotations. The UI identifies Minibits as a third-party beta service and recommends small balances.
|
||||
|
||||
- **Nostr sign-in returns directly to the app instead of a black or grey frame.** The top-level signer broker now stays loaded as a 1px non-interactive surface parked physically off-screen; removing or display-hiding its full-screen cross-origin iframe could leave stale compositor pixels above IndeeHub or GitWorkshop in Android WebView and mobile Chromium until refresh. One retained broker also keeps identity selection and its immediately following signing request in a continuous UI, while Companion no longer adds a separate 180ms cover that made GitWorkshop visibly flicker.
|
||||
|
||||
- **Gitea is sized for source and release hosting, not an empty demo.** Its manifest storage allowance is now 50GiB, release attachments accept individual files up to 10GiB, container-package owner storage remains unlimited, and HTTP/HTTPS proxy uploads share a streamed 10GiB ceiling. Existing repository, package, LFS and release data is unchanged.
|
||||
|
||||
- **Companion browser-tab signing now accepts the app gate's complete session.** A fresh external browser no longer needs a prior dashboard login/localStorage marker before the dashboard-origin signer can load. The app gate now issues both the shared HttpOnly node session and its matching readable CSRF token, so identity discovery and signing RPCs work after that one login instead of rendering a misleading “No identities found” state. Normal dashboard logout/session checks keep their existing behavior.
|
||||
|
||||
- **Fast Nostr identity choices now survive app startup and Companion tabs.** The tab/WebView broker waits for the application load event before opening its first-run picker, queues every NIP-07 call until the signer is initialized, and hands the just-selected public key directly to the immediate login request. GitWorkshop now turns that first-run choice into its normal extension account automatically, eliminating the startup race that surfaced as IndeedHub's “Could not get public key from extension.”
|
||||
|
||||
- **GitWorkshop makes network projects and Archipelago login explicit.** Its signed-in dashboard now includes recent repositories from the Nostr git index, the NIP-07 action reads “Extension / Archipelago,” and explicit Archipelago logins reopen the node identity chooser instead of silently reusing the first identity. Direct, user-triggered NIP-07 logins receive the same account-switch behavior for upstream apps such as IndeedHub.
|
||||
|
||||
- **IndeedHub tab signing now tracks the dashboard signer.** The injected provider supports the contained signer broker in direct tabs, is cache-busted, and is reconciled after dashboard-only updates as well as app installs and starts.
|
||||
|
||||
- **App launches now honor credentials everywhere.** Home, Spotlight, Discover, My Apps, and app-detail launches all pass through one platform-owned credential handoff, so Portainer's first-run token and the File Browser/PhotoPrism login details can no longer be skipped by launching from the Home grid.
|
||||
|
||||
- **Manage Updates returns to Download immediately after cancellation.** Canceling a stalled OTA now clears both the local staged state and progress state instead of leaving an incorrect Install button visible until the page is refreshed.
|
||||
|
||||
- **GitWorkshop no longer probes a desktop-only localhost relay or unauthenticated manifest.** The packaged upstream client disables its default `localhost:4869` nostrdb probe, uses credentialed manifest loading, drops dead lookup relays, and permits the dashboard's contained signer broker in its frame policy.
|
||||
|
||||
- **Rootless app ports self-heal when `pasta` drops a listener.** The five-minute container doctor compares every running container's declared Podman port bindings with actual host listeners and restarts only a container whose listener vanished. TCP and UDP are checked separately, avoiding false restarts of services such as NetBird's UDP port 3478. This covers the intermittent Nginx Proxy Manager port 8081 rebind failure without requiring a node reboot.
|
||||
|
||||
- **Nostr identity actions now use one contained, companion-safe signing experience.** The old full-screen signer has been replaced by the same in-app consent surface used by embedded apps, with the animated identity circle as a brief signing indicator and an explicit completion state. Editing an identity now ends on a dedicated success screen that reports relay coverage and the event ID instead of disappearing back into the form. The app developer guide defines this platform-owned NIP-07 flow and its browser/Companion test matrix so apps do not add a second signer UI.
|
||||
|
||||
- **Discovery merchandising is now owned by the signed app registry.** The catalog declares the Popular Apps set and contribution promotion; Discover renders two desktop rows of popular apps, then the “Your node. Your source.” banner, then the remaining apps. GitWorkshop uses a cache-busted copy of its current upstream mark, and its catalog entry identifies the canonical Archipelago maintainer npub.
|
||||
|
||||
- **Companion opens Source in its native WebView and installs the node certificate.** GitWorkshop is a top-level page in the Companion in-app browser—not a dashboard iframe—and its injected provider uses the contained, consent-gated signer broker. The generic native launcher turns relative app paths into complete URLs before handing them to Android. The Node certificate button uses Android's system credential installer in the companion instead of an unsupported WebView download.
|
||||
|
||||
- **Node certificate guidance now covers installation and the failures people actually see.** Settings includes the complete macOS, iOS/iPadOS, Windows, Android, Linux, Firefox, and Arch/Manjaro steps; reminds users to restart browsers that cache trust decisions; separates certificate trust from DNS; and maps common browser symptoms to their likely cause.
|
||||
|
||||
- **Tab and Companion Nostr sign-in no longer loses the broker or an early identity choice.** The signer route validates the shared app-gate session with the implemented, authenticated `system.get-hostname` RPC instead of the nonexistent `system.get-version`. The provider also exposes a sticky identity subscription so a GitWorkshop React listener that mounts just after selection still completes the normal NIP-07 login. The dashboard service worker no longer precaches the signer route or provider, preventing an old bridge from surviving an update. This repairs GitWorkshop automatic login and IndeeHub's external mobile-browser flow.
|
||||
|
||||
- **The App Store now makes Archipelago's source an invitation to contribute.** GitWorkshop has its real upstream icon and source-focused description, plus a dedicated “Your node. Your source.” banner explaining that users can browse the code, clone with ngit, and send issues, patches, and reviews over Nostr.
|
||||
|
||||
- **Source now packages GitWorkshop instead of maintaining a separate Nostr Git interface.** The pinned upstream client runs read-only behind the authenticated app gate, launches at the dashboard's same origin under `/app/archipelago-source/`, and uses the node's consent-gated NIP-07 bridge. The upstream revision declares no license; Archipelago's owner accepted that redistribution risk without representing the client as licensed. Production publication still requires a tested canonical Archipelago NIP-34/GRASP announcement.
|
||||
|
||||
- **Changing the node password now reports a wrong current password directly.** The backend was already rejecting the request before changing either the web or SSH password, but its error sanitizer replaced that safe, actionable explanation with “check server logs.” The real validation error now reaches the password dialog.
|
||||
|
||||
- **The periodic container doctor runs from the same canonical path used by OTA updates.** Its systemd unit and embedded bootstrap still pointed at the retired source-checkout path while release updates installed the script under `/opt/archipelago/scripts`, leaving the doctor failed on nodes without that checkout. ISO, OTA bootstrap, and the deployment smoke test now agree on the `/opt` path.
|
||||
|
||||
## v1.8.11-alpha (2026-09-07)
|
||||
|
||||
- **Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.
|
||||
|
||||
- **OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.
|
||||
|
||||
- **Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links.
|
||||
|
||||
## v1.8.10-alpha (2026-09-02)
|
||||
|
||||
- **Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered "No route to the recipient" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.
|
||||
|
||||
- **A channel that drops its peer link now heals itself — on every node.** Restarting LND (an app update, a reboot, container churn) can leave a channel's peer connection down for hours while both endpoints keep the channel flagged disabled in the routing graph: the node looks perfectly healthy, the wallet shows balance, and every payment in either direction fails "no route to the recipient". Observed live: a node's only channel sat unroutable for ~17 hours after the LND 0.21.2 update, with no sign of it in any dashboard. The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't, using the peer's advertised addresses. Nodes without LND are untouched; an unreachable peer is retried gently, not hammered.
|
||||
|
||||
- **The Lightning wallet states the node's real funding state instead of "you have no channel."** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had NO channel at all (the outbound sum is legitimately zero in both states), pointed the user at opening a second channel, and — for payment routing failures — even showed the *receiving* copy. The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of claiming channel problems, and only a genuinely channel-less node keeps the open-one guidance.
|
||||
|
||||
## v1.8.9-alpha (2026-09-01)
|
||||
|
||||
- **Lightning sends work again after the LND 0.21.2 update.** LND 0.21 removed the old synchronous payment route the node's backend paid through (`/v1/channels/transactions`) — every Lightning send answered the literal "Not Found" and the wallet showed "Payment failed: Not Found". The backend now pays through the supported Router.SendPaymentV2 route, keeps the same settle-then-report behaviour (a slow multi-hop payment is still tracked to completion, never falsely declared failed), and translates LND's failure reasons into plain advice. A new gate test speaks the payment route directly against the running LND, so an image/backend skew like this can never ship silently again.
|
||||
|
||||
- **The node no longer pins HSTS — HTTP access is a supported mode, and it stays working.** The HTTPS listener used to send `Strict-Transport-Security: max-age=31536000; includeSubDomains`; browsers that visited HTTPS once cached that and then silently upgraded the still-open HTTP dashboard's calls to HTTPS, which is a scheme change — cross-origin — so every request died as "CORS blocked / Failed to fetch" while the node was perfectly healthy. The HTTPS listener now actively clears the cached policy (`max-age=0`) and port 80 sends no HSTS at all, which is deliberate: the node's certificate is optional and self-signed, and devices that haven't installed the CA must keep plain-HTTP access (that's what Settings → Node certificate is for). If your browser already cached the old policy, visiting the dashboard over HTTPS once after this update clears it; a gate test now refuses any config that reintroduces the pin.
|
||||
|
||||
- **App frames open over HTTPS again — including the ones that "did not connect."** The launcher asked the signed catalog for each app's port policy under the name you click ("Mempool Web", "Bitcoin Knots"), but the catalog declares those ports under the manifest that owns them (the Mempool web container, Bitcoin UI). The lookup missed, the launcher handed the iframe an `http://` address, and the browser blocked it as mixed content — the app tile went blank or spun forever. Port resolution now follows launch aliases (mempool-web, bitcoin-knots/bitcoin-core, lnd, electrs and friends), falls back to a port-wide catalog scan when the id is unknown, and the catalog is warmed as soon as the dashboard loads rather than only in the App Store, so the very first app you open already knows which ports serve TLS.
|
||||
|
||||
- **Signing in to IndeeHub with Nostr works over HTTPS.** The NIP-07 bridge compared the app frame's origin for exact equality with the recorded `http://` app URL — a frame the browser upgraded to HTTPS (or any scheme change) was silently ignored, and replies addressed to the stale origin were refused outright, so Nostr sign-in quietly did nothing. The bridge now matches host and port (scheme intentionally ignored) and always replies to the frame's real origin.
|
||||
|
||||
- **Nginx Proxy Manager starts again.** Converting it to a platform manifest dropped two things its image needs: the `/etc/letsencrypt` mount its boot script hard-requires, and the `NET_BIND_SERVICE` capability its internal nginx needs to bind ports 80/443/81 under the orchestrator's `--cap-drop=ALL`. The result was an endless start/die loop (a node watched it restart 3,176 times). Both are declared in its manifest now, its certs live on unchanged under the same persistent app directory, and the signed catalog carries the fix so installed nodes heal on the next update.
|
||||
|
||||
- **Portainer's first-run token is in the app page, not buried in "server logs."** New Portainer versions mint a one-time setup token on a fresh install and print it only to the container logs — on an appliance that meant telling the user to go read a server log to get into their own app. The token now appears in the same launch interstitial as app login credentials (with a copy button), only while first-run setup is actually pending; once the admin account exists the card disappears on its own.
|
||||
|
||||
- **The Lightning wallet states the node's real funding state instead of "you have no channel."** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had no channel at all (the outbound sum is legitimately zero in both states). The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of pointing at channel setup, and only a genuinely channel-less node is sent to open one.
|
||||
|
||||
## v1.8.8-alpha (2026-09-01)
|
||||
|
||||
- **SSH over the mesh is now a first-class setting.** Settings gains an "SSH over mesh" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit "I understand" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the `90-ssh.nft` drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, `fipssh <user>@<node-npub>` connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.
|
||||
|
||||
- **The App Store now lists apps — not parts of apps.** The signed catalog carries every manifest because the node's update layer needs their pins, and the store briefly listed them all: Mempool API, LND UI, Bitcoin UI, the Pine voice engines, the IndeeHub and Immich backends, the mesh router and friends. Components are hidden from the store listing (they still appear where they belong — the Services tab of My Apps, once installed), and four entries that never earned a tile are gone outright: MorphOS server (old), the Web5 DID wallet, Lightning Stack (an untracked upstream bundle — LND covers the need), and CryptPad (never tested).
|
||||
|
||||
- **App icons now persist everywhere, in the proper container style.** Two fixes: installed apps render the icon from their own manifest — Cuprate no longer falls back to the generic A-mark on its Services tile — and the store grids (the Discover page) apply the same icon container treatment (backdrop, border, shadow) as My Apps, the detail pages, and Home. Manifest-declared UI apps also classify correctly again: Alby Hub installs into My Apps with a working tile, not into Services, because a probe miss no longer buries an app the manifest itself says has a frontend.
|
||||
|
||||
- **Installing from the store keeps you on the store page.** The install progress lives on the tile itself and the app appears in My Apps when it lands — no more being yanked to My Apps mid-browse.
|
||||
|
||||
## v1.8.7-alpha (2026-08-31)
|
||||
|
||||
- **What's New really does stop at v1.8.0 now.** The first correction removed old generated release blocks but missed six much older hand-written v1.2 sections at the bottom of the modal. Those sections are gone, and the release check now recognizes and rejects that legacy format too, so the history floor cannot falsely pass again.
|
||||
@@ -10,7 +222,7 @@
|
||||
|
||||
- **Apps open over HTTPS when your node does.** Connect to your node over HTTPS and the apps you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now open on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out `http://` addresses regardless of how you reached it. Ports the gate does not front (plain-HTTP publishes, and the API ports like Cuprate's RPC) deliberately stay on `http` — `https` there would simply fail to connect. Plain-HTTP access (the kiosk, LAN browsing) is unchanged.
|
||||
|
||||
- **Every app in the store is now a first-class platform app.** The last stragglers — Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home — now carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. The four apps retired earlier (FIPS, Nostr VPN, Routstr, Penpot) are finally dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.
|
||||
- **Every app in the store is now a first-class platform app.** The remaining platform apps carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. Retired apps are dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.
|
||||
|
||||
- **Newly signed apps appear in the App Store immediately.** The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. The same signed catalog now also decides which ports serve TLS, so nothing is upgraded to `https` that can't answer it.
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -57,6 +57,13 @@ ElevenLabs TTS under a commercial-use plan.
|
||||
|
||||
## Redistributed software (ISO and container registry)
|
||||
|
||||
- **GitWorkshop** — https://github.com/DanConwayDev/gitworkshop — pinned at
|
||||
`dc36db64f6a2cca29d109829eabaf0a49d4bf4da`. The upstream revision declares
|
||||
no software license. Archipelago applies a documented integration patch and
|
||||
redistributes the resulting static application under an explicit owner risk
|
||||
acceptance dated 2026-09-11; this notice does not claim or grant upstream
|
||||
copyright permission. See `docker/archipelago-source/UPSTREAM.md`.
|
||||
|
||||
The Archipelago OS image is based on Debian and redistributes Debian packages
|
||||
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs
|
||||
required for hardware support); per-package license texts are preserved at
|
||||
@@ -65,7 +72,7 @@ is available via Debian (https://snapshot.debian.org) as referenced in each
|
||||
release's notes. Container images offered through the app catalog and mirror
|
||||
registry remain under their upstream licenses (including GPL/AGPL software
|
||||
such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich,
|
||||
Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in
|
||||
Jellyfin, MariaDB, and strfry); source links are provided in
|
||||
the app catalog. The modified mempool-frontend image is built from
|
||||
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Archipelago
|
||||
|
||||
> **Alpha testing:** Archipelago is experimental software. Any funds you put on it are at your own risk.
|
||||
|
||||
> Self-sovereign Bitcoin node OS and manifest-driven app platform.
|
||||
|
||||
Archipelago is a bootable personal server OS for Bitcoin infrastructure,
|
||||
@@ -11,7 +13,21 @@ Podman containers managed by the Rust backend.
|
||||
[](LICENSE)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://vuejs.org/)
|
||||
[]()
|
||||
[](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:
|
||||
|
||||
```
|
||||
nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
|
||||
```
|
||||
|
||||
Clone with ngit, or use the Gitea mirror when you need a conventional Git
|
||||
remote. Contributions should follow [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## What is here
|
||||
|
||||
|
||||
Submodule aiui/.claude/worktrees/agitated-hofstadter deleted from 10e12a329f
Submodule aiui/.claude/worktrees/funny-hofstadter deleted from 1c5185a15c
Submodule aiui/.claude/worktrees/happy-colden deleted from 666e1232f4
Submodule aiui/.claude/worktrees/hardcore-beaver deleted from a817fa199f
Submodule aiui/.claude/worktrees/heuristic-raman deleted from e8e002debc
Submodule aiui/.claude/worktrees/priceless-colden deleted from aaaef7d710
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -249,6 +249,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>()
|
||||
|
||||
|
||||
@@ -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>(archyBridge.isInArchy() ? 'auto' : 'claude')
|
||||
|
||||
const activeModel = ref('claude-haiku-4.5')
|
||||
|
||||
// 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: 'local' as Provider, name: 'Local AI', models: [{ id: 'node', name: 'Node configuration' }] },
|
||||
{ id: 'auto' as Provider, name: 'Node AI', models: [{ id: 'node', name: 'Node configuration' }] },
|
||||
{ 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: 'routstr' as Provider, name: 'Routstr (sats)', models: routstrModels.value.length ? routstrModels.value : [{ id: 'routstr-unavailable', name: 'Models unavailable — retry' }] },
|
||||
]
|
||||
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 {
|
||||
|
||||
@@ -34,6 +34,40 @@ Add an entry to `catalog.json`:
|
||||
For apps with hardcoded backend configs (Bitcoin, LND, etc.), `containerConfig` is optional.
|
||||
For new apps, include `containerConfig` so the backend knows how to create the container.
|
||||
|
||||
## Storefront layout
|
||||
|
||||
Discovery merchandising is app-registry data, not node-OS layout. The optional
|
||||
top-level `storefront` block defines the ordered Popular Apps rows and the
|
||||
promotional banners placed before the remaining `All Apps` grid:
|
||||
|
||||
```json
|
||||
{
|
||||
"storefront": {
|
||||
"popular": ["bitcoin-knots", "lnd", "btcpay-server"],
|
||||
"promotions": [{
|
||||
"id": "my-app",
|
||||
"banner": "/assets/img/featured/my-app.webp",
|
||||
"eyebrow": "open source",
|
||||
"headline": "Build together.",
|
||||
"description": "Catalog-controlled promotional copy.",
|
||||
"tag": "NOSTR // SOURCE",
|
||||
"path": "/npub1maintainer/project",
|
||||
"launchLabel": "Open",
|
||||
"installLabel": "Install",
|
||||
"detailsLabel": "Learn more →"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only IDs present in `apps` render. An optional promotion `path` deep-links into
|
||||
the installed app; Archipelago uses this to open the canonical signed Nostr
|
||||
repository rather than GitWorkshop's generic dashboard. New dashboards prefer `storefront` from the
|
||||
daemon-verified signed catalog and use the bundled community copy as a local
|
||||
fallback. `scripts/generate-app-catalog.sh` carries this block into the signed
|
||||
release artifact; changing it does not require a node OS release once that
|
||||
artifact is published.
|
||||
|
||||
## Categories
|
||||
|
||||
money, commerce, data, home, nostr, networking, community, development, l484
|
||||
|
||||
+100
-46
@@ -9,24 +9,36 @@
|
||||
"description": "Bitcoin documentaries with Nostr identity.",
|
||||
"tag": "NOSTR IDENTITY // YOUR NODE"
|
||||
},
|
||||
"storefront": {
|
||||
"popular": [
|
||||
"bitcoin-knots",
|
||||
"lnd",
|
||||
"btcpay-server",
|
||||
"mempool",
|
||||
"filebrowser",
|
||||
"homeassistant"
|
||||
],
|
||||
"promotions": [
|
||||
{
|
||||
"id": "archipelago-source",
|
||||
"banner": "/assets/img/featured/archipelago-source-banner.webp",
|
||||
"eyebrow": "open source",
|
||||
"headline": "Your node. Your source.",
|
||||
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
|
||||
"tag": "NGIT // NOSTR // NO SILO",
|
||||
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
|
||||
"launchLabel": "Open GitWorkshop",
|
||||
"installLabel": "Install GitWorkshop",
|
||||
"detailsLabel": "How contribution works →"
|
||||
}
|
||||
]
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"id": "adguardhome",
|
||||
"title": "AdGuard Home",
|
||||
"version": "v0.107.55",
|
||||
"description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.",
|
||||
"icon": "",
|
||||
"author": "AdGuard",
|
||||
"category": "networking",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55",
|
||||
"repoUrl": "https://github.com/AdguardTeam/AdGuardHome"
|
||||
},
|
||||
{
|
||||
"id": "alby-hub",
|
||||
"title": "Alby Hub",
|
||||
"version": "1.23.0",
|
||||
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect \u2014 one hub, every app pays through it.",
|
||||
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
|
||||
"icon": "/assets/img/app-icons/alby-hub.svg",
|
||||
"author": "Alby",
|
||||
"category": "money",
|
||||
@@ -117,18 +129,6 @@
|
||||
"bitcoin-knots"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "cryptpad",
|
||||
"title": "CryptPad",
|
||||
"version": "2024.12.0",
|
||||
"description": "End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.",
|
||||
"icon": "/assets/icon/favico-black-v2.svg",
|
||||
"author": "XWiki SAS",
|
||||
"category": "data",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/cryptpad:2024.12.0",
|
||||
"repoUrl": "https://github.com/cryptpad/cryptpad"
|
||||
},
|
||||
{
|
||||
"id": "cuprate",
|
||||
"title": "Cuprate",
|
||||
@@ -203,13 +203,13 @@
|
||||
{
|
||||
"id": "filebrowser",
|
||||
"title": "File Browser",
|
||||
"version": "2.27.0",
|
||||
"version": "2.63.23",
|
||||
"description": "Baseline Archipelago file manager service.",
|
||||
"icon": "/assets/img/app-icons/file-browser.webp",
|
||||
"author": "File Browser",
|
||||
"category": "data",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.63.23",
|
||||
"repoUrl": "https://github.com/filebrowser/filebrowser",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -230,12 +230,12 @@
|
||||
{
|
||||
"id": "gitea",
|
||||
"title": "Gitea",
|
||||
"version": "1.23",
|
||||
"version": "1.27.3",
|
||||
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
|
||||
"icon": "/assets/img/app-icons/gitea.svg",
|
||||
"author": "Gitea",
|
||||
"category": "development",
|
||||
"dockerImage": "docker.io/gitea/gitea:1.23",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/gitea:1.27.3",
|
||||
"repoUrl": "https://gitea.com",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -259,6 +259,19 @@
|
||||
},
|
||||
"tier": "optional"
|
||||
},
|
||||
{
|
||||
"id": "archipelago-source",
|
||||
"title": "GitWorkshop",
|
||||
"version": "0.4.0",
|
||||
"description": "Get Archipelago's source, clone it with ngit, and contribute issues, patches, and reviews over Nostr using the upstream GitWorkshop client.",
|
||||
"icon": "/assets/img/app-icons/gitworkshop-dc36db6.svg",
|
||||
"author": "GitWorkshop contributors",
|
||||
"maintainerNpub": "npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg",
|
||||
"category": "development",
|
||||
"tier": "optional",
|
||||
"repoUrl": "https://github.com/DanConwayDev/gitworkshop",
|
||||
"dockerImage": "localhost/archipelago-source:local"
|
||||
},
|
||||
{
|
||||
"id": "grafana",
|
||||
"title": "Grafana",
|
||||
@@ -286,12 +299,12 @@
|
||||
{
|
||||
"id": "homeassistant",
|
||||
"title": "Home Assistant",
|
||||
"version": "2026.7.3",
|
||||
"version": "2026.8.3",
|
||||
"description": "Open source home automation platform. Control and monitor your smart home devices.",
|
||||
"icon": "/assets/img/app-icons/homeassistant.png",
|
||||
"author": "Home Assistant",
|
||||
"category": "home",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.3",
|
||||
"repoUrl": "https://github.com/home-assistant/core",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -350,13 +363,13 @@
|
||||
{
|
||||
"id": "lnd",
|
||||
"title": "LND",
|
||||
"version": "0.18.4",
|
||||
"version": "0.21.2",
|
||||
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
|
||||
"icon": "/assets/img/app-icons/lnd.png",
|
||||
"author": "Lightning Labs",
|
||||
"category": "money",
|
||||
"tier": "core",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.21.2-beta",
|
||||
"repoUrl": "https://github.com/lightningnetwork/lnd",
|
||||
"requires": [
|
||||
"bitcoin-knots"
|
||||
@@ -365,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",
|
||||
@@ -382,7 +395,7 @@
|
||||
"id": "netbird",
|
||||
"title": "NetBird",
|
||||
"version": "2.38.0",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point \u2014 a TLS proxy in front of the dashboard + server.",
|
||||
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
|
||||
"icon": "/assets/img/app-icons/netbird.svg",
|
||||
"author": "NetBird",
|
||||
"category": "networking",
|
||||
@@ -423,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 \u2014 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"
|
||||
},
|
||||
{
|
||||
@@ -460,7 +473,7 @@
|
||||
"id": "ollama",
|
||||
"title": "Ollama",
|
||||
"version": "0.5.4",
|
||||
"description": "Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware \u2014 served on the node's loopback for the AI assistant (Settings \u2192 Claude Auth \u2192 model backend), never exposed to the network.",
|
||||
"description": "Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware — served on the node's loopback for the AI assistant (Settings → Claude Auth → model backend), never exposed to the network.",
|
||||
"icon": "/assets/img/app-icons/ollama.png",
|
||||
"author": "Ollama",
|
||||
"category": "community",
|
||||
@@ -472,7 +485,7 @@
|
||||
"id": "phoenixd",
|
||||
"title": "phoenixd",
|
||||
"version": "0.9.0",
|
||||
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own \u2014 it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
|
||||
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
|
||||
"icon": "/assets/img/app-icons/phoenixd.svg",
|
||||
"author": "ACINQ",
|
||||
"category": "money",
|
||||
@@ -507,7 +520,7 @@
|
||||
"id": "pine",
|
||||
"title": "Pine",
|
||||
"version": "1.3.0",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.",
|
||||
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
|
||||
"icon": "/assets/img/app-icons/pine.svg",
|
||||
"author": "Archipelago",
|
||||
"category": "home",
|
||||
@@ -517,13 +530,13 @@
|
||||
{
|
||||
"id": "portainer",
|
||||
"title": "Portainer",
|
||||
"version": "2.19.4",
|
||||
"version": "2.45.0",
|
||||
"description": "Container management web UI for the local Podman socket.",
|
||||
"icon": "/assets/img/app-icons/portainer.webp",
|
||||
"author": "Portainer",
|
||||
"category": "development",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.45.0",
|
||||
"repoUrl": "https://github.com/portainer/portainer",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -615,13 +628,13 @@
|
||||
{
|
||||
"id": "vaultwarden",
|
||||
"title": "Vaultwarden",
|
||||
"version": "1.30.0",
|
||||
"version": "1.37.2",
|
||||
"description": "Self-hosted password vault with zero-knowledge encryption.",
|
||||
"icon": "/assets/img/app-icons/vaultwarden.webp",
|
||||
"author": "Vaultwarden",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.2-alpine",
|
||||
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
@@ -631,6 +644,47 @@
|
||||
"/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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -25,6 +25,7 @@ This document lists all port assignments for Archipelago apps.
|
||||
| did-wallet | 8083 | TCP | Web UI | 18083 |
|
||||
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
|
||||
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
|
||||
| archipelago-source | 8337 | TCP | Authenticated source UI | 18337 |
|
||||
|
||||
## Development Ports (Offset: +10000)
|
||||
|
||||
@@ -53,6 +54,7 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
|
||||
| DID Wallet | http://localhost:18083 |
|
||||
| Router | http://localhost:18084 |
|
||||
| Meshtastic | http://localhost:14403 |
|
||||
| GitWorkshop | http://localhost:18337 |
|
||||
|
||||
## Port Conflict Resolution
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
app:
|
||||
id: adguardhome
|
||||
name: AdGuard Home
|
||||
version: v0.107.55
|
||||
upstream:
|
||||
kind: github
|
||||
repo: AdguardTeam/AdGuardHome
|
||||
description: >-
|
||||
Network-wide ad and tracker blocking: a DNS server that filters every
|
||||
device on your LAN, with a web console for rules and client management.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.55
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: false
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 3000
|
||||
container: 3000
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
# open: the setup wizard and admin console carry AdGuard Home's own
|
||||
# login; the gate fronts the port (TLS, header fixes) without a
|
||||
# second cookie challenge.
|
||||
auth: open
|
||||
auth_rationale: >-
|
||||
AdGuard Home enforces its own admin login on the console, and the
|
||||
first-run wizard must answer before any account exists.
|
||||
- host: 53
|
||||
container: 53
|
||||
protocol: udp
|
||||
# none: plain DNS must answer every unauthenticated query from LAN
|
||||
# devices — a login page in front of :53 breaks every client on the
|
||||
# network by design.
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Plain DNS answers unauthenticated by protocol: resolvers and clients
|
||||
send queries directly; a login challenge would make DNS unreachable.
|
||||
- host: 53
|
||||
container: 53
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
DNS-over-TCP fallback (truncated responses, zone transfers); same
|
||||
protocol-level requirement as the UDP port.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/adguardhome
|
||||
target: /opt/adguardhome
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:3000
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: Admin console
|
||||
description: AdGuard Home web console
|
||||
type: ui
|
||||
port: 3000
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
author: AdGuard
|
||||
category: networking
|
||||
repo: https://github.com/AdguardTeam/AdGuardHome
|
||||
tier: optional
|
||||
@@ -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
|
||||
@@ -0,0 +1,80 @@
|
||||
app:
|
||||
id: archipelago-source
|
||||
name: GitWorkshop
|
||||
version: 0.4.0
|
||||
upstream:
|
||||
kind: github
|
||||
repo: DanConwayDev/gitworkshop
|
||||
description: >-
|
||||
Get Archipelago's source, clone it with ngit, and contribute issues,
|
||||
patches, and reviews over Nostr using the upstream GitWorkshop client.
|
||||
category: development
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/archipelago-source
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/archipelago-source:local
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 64Mi
|
||||
disk_limit: 64Mi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
network_policy: host
|
||||
|
||||
ports:
|
||||
- host: 8337
|
||||
container: 8337
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
session_passthrough: true
|
||||
|
||||
volumes:
|
||||
- type: tmpfs
|
||||
target: /tmp
|
||||
tmpfs_options: rw,noexec,nosuid,size=16m,mode=1777
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8337
|
||||
path: /healthz
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: GitWorkshop
|
||||
description: NIP-34 repository browser, issues, pull requests, and review
|
||||
type: ui
|
||||
port: 8337
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
# Versioned filename deliberately invalidates dashboard/browser icon caches
|
||||
# when the Source prototype is replaced by the upstream GitWorkshop mark.
|
||||
icon: /assets/img/app-icons/gitworkshop-dc36db6.svg
|
||||
author: GitWorkshop contributors
|
||||
repo: https://github.com/DanConwayDev/gitworkshop
|
||||
maintainer_npub: npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg
|
||||
tier: optional
|
||||
launch:
|
||||
# GitWorkshop is top-level in Companion's native in-app WebView. Its
|
||||
# injected NIP-07 provider creates the authenticated dashboard-origin
|
||||
# signer broker itself, so no dashboard parent frame is required.
|
||||
requires_host_frame: false
|
||||
features:
|
||||
- NIP-34 repository discovery and browsing
|
||||
- Bandwidth-efficient Git explorer over GRASP
|
||||
- Nostr issues, pull requests, and code review
|
||||
- NIP-07 extension and NIP-46 remote-signer support
|
||||
- Archipelago node identity through explicit signing consent
|
||||
@@ -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;
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
app:
|
||||
id: cryptpad
|
||||
name: CryptPad
|
||||
version: 2024.12.0
|
||||
upstream:
|
||||
kind: github
|
||||
repo: cryptpad/cryptpad
|
||||
description: End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/cryptpad:2024.12.0
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
dependencies:
|
||||
- storage: 5Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 1Gi
|
||||
disk_limit: 5Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: false
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
|
||||
ports:
|
||||
- host: 3000
|
||||
container: 3000
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
# gated: CryptPad is browser-only (its own per-user accounts sit on top
|
||||
# of the node login, exactly like Vaultwarden), so the gate's session
|
||||
# challenge costs nothing and keeps the pads behind the node login.
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/cryptpad
|
||||
target: /cryptpad/data
|
||||
options: [rw]
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: tcp
|
||||
endpoint: localhost:3000
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
interfaces:
|
||||
main:
|
||||
name: CryptPad
|
||||
description: Encrypted collaboration suite
|
||||
type: ui
|
||||
port: 3000
|
||||
protocol: http
|
||||
path: /
|
||||
|
||||
metadata:
|
||||
author: XWiki SAS
|
||||
category: data
|
||||
icon: /assets/icon/favico-black-v2.svg
|
||||
repo: https://github.com/cryptpad/cryptpad
|
||||
tier: optional
|
||||
@@ -0,0 +1,67 @@
|
||||
app:
|
||||
id: cuprate-ui
|
||||
name: Cuprate UI
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: |
|
||||
Archipelago-native HTTP frontend for the Cuprate Monero node. Runs nginx
|
||||
inside a container, serves a static status dashboard, and proxies
|
||||
/cuprate-rpc/ to the cuprate restricted RPC on 127.0.0.1:18090 (the
|
||||
published host port for the container's 18089). No credentials are
|
||||
injected — the restricted RPC is Monero's own safe-for-public subset — so
|
||||
the nginx.conf is baked into the image and there is no rendered-config
|
||||
bind-mount like bitcoin-ui's.
|
||||
|
||||
container:
|
||||
build:
|
||||
context: /opt/archipelago/docker/cuprate-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/cuprate-ui:local
|
||||
|
||||
dependencies:
|
||||
- app_id: cuprate
|
||||
|
||||
resources:
|
||||
memory_limit: 64Mi
|
||||
|
||||
security:
|
||||
readonly_root: false
|
||||
network_policy: host
|
||||
|
||||
# Host networking: nginx listens on 18091 directly on the host IP.
|
||||
# Declared so the APP GATE can see this port. Host networking means Podman
|
||||
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
|
||||
# is a statement of where the container's own nginx listens — 127.0.0.1 —
|
||||
# not a publish instruction. Without this declaration the gate would have no
|
||||
# idea the port existed: neither protected nor listed as unprotected.
|
||||
ports:
|
||||
- host: 18091
|
||||
container: 18091
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
# First-party companion UI: its nginx forwards the node session cookie
|
||||
# to the daemon's authenticated endpoints; without passthrough the gate
|
||||
# strips it and every data call 401s while the page shell renders.
|
||||
session_passthrough: true
|
||||
|
||||
volumes: []
|
||||
|
||||
environment: []
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:18091
|
||||
path: /
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
metadata:
|
||||
icon: /assets/img/app-icons/cuprate.svg
|
||||
category: money
|
||||
tier: optional
|
||||
author: Archipelago
|
||||
repo: https://github.com/Cuprate/cuprate
|
||||
+74
-17
@@ -36,17 +36,38 @@ app:
|
||||
data_uid: "1000:1000"
|
||||
|
||||
dependencies:
|
||||
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
|
||||
# month; cuprated's pruning support is not confirmed stable yet (the
|
||||
# `pruning` crate exists in the workspace but nothing in this config
|
||||
# surface toggles it), so this sizes for a full unpruned chain plus
|
||||
# headroom rather than assuming pruning is available.
|
||||
- storage: 300Gi
|
||||
# Monero mainnet is ~250GiB unpruned as of 2026 and growing ~60GiB/year.
|
||||
# Verified against upstream main (binaries/cuprated/src/config.rs, 2026-09):
|
||||
# cuprated has NO on-disk pruning setting of any kind — the `pruning`
|
||||
# crate in its workspace is Monero's p2p *protocol* pruning, not a
|
||||
# smaller chain — so unlike bitcoin-knots this app CANNOT self-prune
|
||||
# when disk is scarce (see the DISK_GB branch in
|
||||
# apps/bitcoin-knots/manifest.yml). Left running on a too-small disk it
|
||||
# syncs until the filesystem fills and takes Archipelago down. The
|
||||
# disk-scarce equivalent is enforced in Rust instead: install, start,
|
||||
# restart and update refuse, and boot reconcile skips, on any node under
|
||||
# CUPRATE_MIN_DISK_GB (450GB — chain + headroom; refuses the 250GB VPS
|
||||
# class, allows 500GB-class disks). If upstream ever ships a prune flag,
|
||||
# replace that gate with the bitcoin-style entrypoint branch.
|
||||
#
|
||||
# 450Gi, not the chain size (~250GiB): every manifest-driven surface
|
||||
# (store size display, install pre-checks, docs) must show the number the
|
||||
# Rust gate actually enforces, or a user provisioned to the displayed
|
||||
# value gets refused at a different, unexplained one. Single source of
|
||||
# truth is crate::constants::CUPRATE_MIN_DISK_GB — keep in lockstep.
|
||||
- storage: 450Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 0
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 300Gi
|
||||
# Raised from 4Gi alongside target_max_memory below (see files[] comment)
|
||||
# — 2026-09-03 incident: a 4Gi/3GB-cache config starved
|
||||
# cuprated's DB cache into constant eviction/flush, driving 45% sustained
|
||||
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
|
||||
# headroom above the 8GiB cache for the process itself.
|
||||
memory_limit: 10Gi
|
||||
# Matches the storage dependency above (= the enforced disk floor),
|
||||
# not the raw chain size — see the CUPRATE_MIN_DISK_GB note.
|
||||
disk_limit: 450Gi
|
||||
|
||||
security:
|
||||
# FROM scratch, no package manager/shell, ownership fixed at build time
|
||||
@@ -82,17 +103,21 @@ app:
|
||||
# bind without an explicit i_know_what_im_doing override.
|
||||
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
|
||||
# what wallets use when connecting to a "remote node". Disabled by
|
||||
# cuprated's own default; enabled via files[] below. A dashboard login
|
||||
# would break wallet clients connecting programmatically, same
|
||||
# reasoning as electrumx's port. The daemon still uses its canonical
|
||||
# container port 18089, but Penpot already owns host port 18089, so this
|
||||
# maps the public host port to the free 18090 instead.
|
||||
# cuprated's own default; enabled via files[] below. `open`, not `gated`:
|
||||
# the gate still takes the port over (loopback pin, external binds,
|
||||
# fronts the Tor onion) but skips the dashboard login challenge, same
|
||||
# reasoning as electrumx's port — wallet clients (Feather,
|
||||
# monero-wallet-rpc, GUI) speak plain HTTP JSON-RPC programmatically and
|
||||
# cannot complete a browser login or hold a session cookie. The daemon
|
||||
# still uses its canonical container port 18089, but Penpot already owns
|
||||
# host port 18089, so this maps the public host port to the free 18090
|
||||
# instead.
|
||||
- host: 18090
|
||||
container: 18089
|
||||
protocol: tcp
|
||||
auth: none
|
||||
auth: open
|
||||
auth_rationale: >-
|
||||
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.
|
||||
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot complete a browser login or hold a dashboard session cookie.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
@@ -103,11 +128,23 @@ app:
|
||||
# Settings that need to differ from cuprated's own documented defaults
|
||||
# (verified against `cuprated --generate-config` and `--dry-run` locally,
|
||||
# 2026-08-21):
|
||||
# - fast_sync: cuprated's own default is false, which performs full
|
||||
# cryptographic verification (ring signatures + RandomX PoW) on every
|
||||
# incoming block instead of trusting checkpointed history. Root-caused
|
||||
# 2026-09-03 as the dominant cause of a sustained 45% CPU node,
|
||||
# vs. 2.8% on a reference node with fast_sync = true — same chain height, same
|
||||
# block rate. Set explicitly rather than relying on the binary
|
||||
# default so fresh deploys don't silently regress into full-verify.
|
||||
# - target_max_memory: cuprated's own default auto-detects total *host*
|
||||
# RAM via sysinfo, which inside a memory-limited container would let
|
||||
# it size caches far past what resources.memory_limit above actually
|
||||
# grants — same class of problem bitcoin-knots' -dbcache sizing
|
||||
# comment addresses. Set explicitly, comfortably under the 4Gi limit.
|
||||
# comment addresses. Set explicitly, comfortably under the 10Gi limit.
|
||||
# Previously 3000000000 (~2.8GiB); that starved the DB cache and
|
||||
# forced constant eviction/flush (595GB/24h block I/O on a node just
|
||||
# appending ~2MB blocks every 2 minutes) — raised to 8GiB, matching
|
||||
# the healthy reference node, and
|
||||
# resources.memory_limit above raised in step to keep headroom above it.
|
||||
# - rpc.restricted.enable: cuprated ships this off by default; flip on
|
||||
# so the auth:none host port above actually serves something instead
|
||||
# of refusing every connection. port stays at its documented default
|
||||
@@ -125,14 +162,34 @@ app:
|
||||
# uses for its own RPC port (-rpcbind=0.0.0.0:8332 internally, gate
|
||||
# restricts it externally) — not a new risk, the same one already
|
||||
# reviewed and accepted for Bitcoin's RPC.
|
||||
# - tracing.stdout.level / tracing.file.{level,max_log_files}: an
|
||||
# operator reading Cuprated.toml on disk should be able to see and
|
||||
# tune the log level directly instead of the file silently omitting
|
||||
# the whole [tracing] table (verified live on the affected node
|
||||
# 2026-09-01: the deployed file had no [tracing] section at all, and
|
||||
# the level was only discoverable by running `cuprated
|
||||
# --generate-config` and diffing). file.level is set to "info", NOT
|
||||
# cuprated's own raw default of "debug" — matches the reference dev
|
||||
# config this app was built and tested against (verified 2026-09-01),
|
||||
# which deliberately runs file logging quieter
|
||||
# than the binary default. max_log_files similarly follows that
|
||||
# reference (14, not the binary default of 7).
|
||||
files:
|
||||
- path: /var/lib/archipelago/cuprate/Cuprated.toml
|
||||
content: |
|
||||
network = "Mainnet"
|
||||
target_max_memory = 3000000000
|
||||
fast_sync = true
|
||||
target_max_memory = 8589934592
|
||||
|
||||
[rpc.restricted]
|
||||
enable = true
|
||||
|
||||
[tracing.stdout]
|
||||
level = "info"
|
||||
|
||||
[tracing.file]
|
||||
level = "info"
|
||||
max_log_files = 14
|
||||
overwrite: false
|
||||
|
||||
health_check:
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
@@ -1,39 +0,0 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1000 appuser && \
|
||||
adduser -D -u 1000 -G appuser appuser && \
|
||||
mkdir -p /app/wallet && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV WALLET_STORAGE=/app/wallet
|
||||
ENV DWN_ENDPOINT=http://web5-dwn:3000
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -1,35 +0,0 @@
|
||||
# DID Wallet
|
||||
|
||||
Web5 wallet with Decentralized Identifier (DID) support.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# From the apps directory
|
||||
./build.sh did-wallet
|
||||
|
||||
# Or manually
|
||||
cd did-wallet
|
||||
docker build -t archipelago/did-wallet:latest .
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd did-wallet
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Ports
|
||||
|
||||
- **8083**: Web UI (dev: 18083)
|
||||
|
||||
## Running Locally
|
||||
|
||||
```bash
|
||||
docker run -p 8083:8080 \
|
||||
-v /tmp/archipelago-dev/did-wallet:/app/wallet \
|
||||
-e DWN_ENDPOINT=http://localhost:13000 \
|
||||
archipelago/did-wallet:latest
|
||||
```
|
||||
@@ -1,59 +0,0 @@
|
||||
app:
|
||||
id: did-wallet
|
||||
name: Web5 DID Wallet
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
|
||||
|
||||
container:
|
||||
image: archipelago/did-wallet:1.0.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 2Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 2Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: did-wallet
|
||||
|
||||
ports:
|
||||
- host: 8088
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/did-wallet
|
||||
target: /app/wallet
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- WALLET_STORAGE=/app/wallet
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
web5_integration:
|
||||
did_support: true
|
||||
wallet_functionality: true
|
||||
bitcoin_integration: true
|
||||
Generated
-2747
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "did-wallet",
|
||||
"version": "1.0.0",
|
||||
"description": "Web5 DID Wallet for Archipelago",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"@web5/api": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.3",
|
||||
"ts-node": "^10.9.2"
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DID Wallet</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Web5 DID Wallet</h1>
|
||||
<p>Decentralized Identity Wallet for Archipelago</p>
|
||||
<div id="app">
|
||||
<p>Wallet interface coming soon...</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,37 +0,0 @@
|
||||
import express from 'express';
|
||||
|
||||
const app = express();
|
||||
const port = 8080;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', service: 'did-wallet' });
|
||||
});
|
||||
|
||||
// Wallet API endpoints
|
||||
app.get('/api/wallet/info', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
wallet: {
|
||||
dids: [],
|
||||
balance: 0
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/wallet/did/create', async (req, res) => {
|
||||
// Placeholder for DID creation
|
||||
res.json({
|
||||
status: 'ok',
|
||||
did: 'did:key:placeholder'
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`DID Wallet listening on port ${port}`);
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: filebrowser
|
||||
name: File Browser
|
||||
version: 2.27.0
|
||||
version: 2.63.23
|
||||
# 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: Baseline Archipelago file manager service.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0
|
||||
image: source.archipelago-foundation.org/lfg2025/filebrowser:v2.63.23
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
custom_args: ["--config", "/data/.filebrowser.json"]
|
||||
|
||||
+27
-12
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: gitea
|
||||
name: Gitea
|
||||
version: "1.23"
|
||||
version: "1.27.3"
|
||||
# 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,18 +12,23 @@ app:
|
||||
category: development
|
||||
|
||||
container:
|
||||
image: docker.io/gitea/gitea:1.23
|
||||
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:
|
||||
- storage: 500Mi
|
||||
# Source history, LFS objects, release artifacts and OCI layers all share
|
||||
# this persistent store. 500Mi was only suitable for an empty demo node.
|
||||
- storage: 50Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 256Mi
|
||||
disk_limit: 500Mi
|
||||
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
|
||||
@@ -60,12 +65,29 @@ 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
|
||||
- GITEA__server__SSH_LISTEN_PORT=22
|
||||
- GITEA__server__LFS_START_SERVER=true
|
||||
- GITEA__packages__ENABLED=true
|
||||
# Package/LFS storage remains bounded by the node's disk, not an arbitrary
|
||||
# per-owner quota. Release artifacts allow installer/OTA images up to 10GiB.
|
||||
- GITEA__packages__LIMIT_TOTAL_OWNER_SIZE=-1
|
||||
- GITEA__packages__LIMIT_SIZE_CONTAINER=-1
|
||||
- GITEA__repository_0x2Erelease__FILE_MAX_SIZE=10240
|
||||
- GITEA__repository_0x2Erelease__MAX_FILES=20
|
||||
- GITEA__repository__ENABLE_PUSH_CREATE_USER=true
|
||||
- GITEA__repository__ENABLE_PUSH_CREATE_ORG=true
|
||||
|
||||
@@ -98,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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: homeassistant
|
||||
name: Home Assistant
|
||||
version: 2026.7.3
|
||||
version: 2026.8.3
|
||||
# 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: Open source home automation platform. Control and monitor your smart home devices.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2
|
||||
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.3
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
|
||||
@@ -15,18 +15,21 @@ 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
|
||||
network_aliases: [api]
|
||||
# The JWT signing secret is owned here (no backend container owns it); the
|
||||
# db + minio passwords are owned by indeedhub-postgres / indeedhub-minio and
|
||||
# only consumed here. ensure_generated_secrets no-ops when a file already
|
||||
# exists, so live values on .228 are preserved (postgres pw is fixed at
|
||||
# PGDATA init — regenerating would lock the API out).
|
||||
# The JWT signing secret and stable envelope-encryption root are owned here;
|
||||
# the db + minio passwords are owned by indeedhub-postgres / indeedhub-minio
|
||||
# and only consumed here. Existing nodes migrate the legacy AES value into
|
||||
# the secret file once, while fresh nodes receive a unique per-node value.
|
||||
generated_secrets:
|
||||
- name: indeedhub-jwt
|
||||
kind: hex32
|
||||
- name: indeedhub-aes-master
|
||||
kind: hex16
|
||||
secret_env:
|
||||
- key: DATABASE_PASSWORD
|
||||
secret_file: indeedhub-db-password
|
||||
@@ -34,6 +37,8 @@ app:
|
||||
secret_file: indeedhub-minio-password
|
||||
- key: NOSTR_JWT_SECRET
|
||||
secret_file: indeedhub-jwt
|
||||
- key: AES_MASTER_SECRET
|
||||
secret_file: indeedhub-aes-master
|
||||
|
||||
dependencies:
|
||||
- app_id: indeedhub-postgres
|
||||
@@ -67,9 +72,6 @@ app:
|
||||
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
|
||||
- S3_PUBLIC_BUCKET_URL=/storage
|
||||
- NOSTR_JWT_EXPIRES_IN=7d
|
||||
# Fixed across the fleet (envelope-encryption master key baked by the legacy
|
||||
# installer); not node-specific, so a plain env literal, not a secret.
|
||||
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
|
||||
- ENVIRONMENT=production
|
||||
|
||||
health_check:
|
||||
|
||||
@@ -22,6 +22,8 @@ app:
|
||||
secret_file: indeedhub-db-password
|
||||
- key: AWS_SECRET_KEY
|
||||
secret_file: indeedhub-minio-password
|
||||
- key: AES_MASTER_SECRET
|
||||
secret_file: indeedhub-aes-master
|
||||
|
||||
dependencies:
|
||||
- app_id: indeedhub-api
|
||||
@@ -51,4 +53,3 @@ app:
|
||||
- S3_PUBLIC_BUCKET_NAME=indeedhub-public
|
||||
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
|
||||
- ENVIRONMENT=production
|
||||
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
|
||||
|
||||
@@ -69,7 +69,12 @@ app:
|
||||
- copy_from_host:
|
||||
src: "web-ui/nostr-provider.js"
|
||||
dest: "/usr/share/nginx/html/nostr-provider.js"
|
||||
- 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: ["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
|
||||
|
||||
@@ -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: 256Mi
|
||||
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,5 +0,0 @@
|
||||
# Lightning Stack - uses official image
|
||||
FROM lightninglabs/lightning-stack:v0.12.0
|
||||
|
||||
# Default configuration is in the image
|
||||
# No additional setup needed
|
||||
@@ -1,85 +0,0 @@
|
||||
app:
|
||||
id: lightning-stack
|
||||
name: Lightning Stack
|
||||
version: 0.12.0
|
||||
# No public listing exists for lightninglabs/lightning-stack (checked
|
||||
# docker.io, ghcr.io and github.com) — nothing can be queried automatically,
|
||||
# so this one is tracked by hand.
|
||||
upstream:
|
||||
kind: manual
|
||||
url: no public listing for lightninglabs/lightning-stack — verify by hand
|
||||
description: Complete Lightning Network implementation. Includes LND, CLN, and management tools.
|
||||
|
||||
container:
|
||||
image: lightninglabs/lightning-stack:v0.12.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
version: ">=24.0"
|
||||
- storage: 50Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 4
|
||||
memory_limit: 4Gi
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
capabilities: [NET_BIND_SERVICE]
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: lightning-stack
|
||||
|
||||
ports:
|
||||
- host: 9738
|
||||
container: 9735
|
||||
protocol: tcp # P2P
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.
|
||||
- host: 10010
|
||||
container: 10009
|
||||
protocol: tcp # gRPC
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.
|
||||
# Mirrors lnd's 18080 exemption — same LND REST API, same macaroon auth.
|
||||
- host: 8091
|
||||
container: 8080
|
||||
protocol: tcp # REST/Web UI
|
||||
auth: none
|
||||
auth_rationale: >-
|
||||
LND REST, authenticated by macaroon over TLS. A browser login page would break
|
||||
Zeus and every non-browser wallet client, exactly as for lnd's 18080.
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/lightning-stack
|
||||
target: /root/.lightning
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- BITCOIND_HOST=bitcoin-core
|
||||
- BITCOIND_RPCUSER=${BITCOIN_RPC_USER}
|
||||
- BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}
|
||||
- NETWORK=mainnet
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /v1/getinfo
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
bitcoin_integration:
|
||||
rpc_access: admin
|
||||
sync_required: true
|
||||
|
||||
lightning_integration:
|
||||
channel_management: true
|
||||
payment_routing: true
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: lnd
|
||||
name: LND
|
||||
version: 0.18.4
|
||||
version: 0.21.2
|
||||
# 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: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta
|
||||
image: source.archipelago-foundation.org/lfg2025/lnd:v0.21.2-beta
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
# BITCOIND_HOST must follow the node's actual Bitcoin container — Knots or
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
@@ -1,37 +0,0 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1000 appuser && \
|
||||
adduser -D -u 1000 -G appuser appuser && \
|
||||
mkdir -p /app/data && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV MORPHOS_DATA_DIR=/app/data
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -1,55 +0,0 @@
|
||||
app:
|
||||
id: morphos-server
|
||||
name: MorphOS Server
|
||||
version: 1.0.0
|
||||
# Built by this project — there is no upstream release feed to watch.
|
||||
upstream:
|
||||
kind: internal
|
||||
description: MorphOS server platform. Decentralized application server.
|
||||
|
||||
container:
|
||||
image: archipelago/morphos-server:1.0.0
|
||||
image_signature: cosign://...
|
||||
pull_policy: if-not-present
|
||||
|
||||
dependencies:
|
||||
- storage: 5Gi
|
||||
|
||||
resources:
|
||||
cpu_limit: 2
|
||||
memory_limit: 2Gi
|
||||
disk_limit: 5Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
no_new_privileges: true
|
||||
user: 1000
|
||||
seccomp_profile: default
|
||||
network_policy: isolated
|
||||
apparmor_profile: morphos-server
|
||||
|
||||
ports:
|
||||
- host: 8089
|
||||
container: 8080
|
||||
protocol: tcp # Web UI
|
||||
bind: 127.0.0.1
|
||||
auth: gated
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/morphos-server
|
||||
target: /app/data
|
||||
options: [rw]
|
||||
|
||||
environment:
|
||||
- MORPHOS_ENV=production
|
||||
- MORPHOS_DATA_DIR=/app/data
|
||||
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8080
|
||||
path: /health
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
Generated
-1161
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "morphos-server",
|
||||
"version": "1.0.0",
|
||||
"description": "MorphOS server platform",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.3",
|
||||
"ts-node": "^10.9.2"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import express from 'express';
|
||||
|
||||
const app = express();
|
||||
const port = 8080;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', service: 'morphos-server', version: '1.0.0' });
|
||||
});
|
||||
|
||||
// API endpoints
|
||||
app.get('/api/info', (req, res) => {
|
||||
res.json({
|
||||
name: 'MorphOS Server',
|
||||
version: '1.0.0',
|
||||
status: 'running'
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
console.log(`MorphOS Server listening on port ${port}`);
|
||||
console.log(`Data directory: ${process.env.MORPHOS_DATA_DIR || '/app/data'}`);
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -24,7 +27,14 @@ app:
|
||||
disk_limit: 1Gi
|
||||
|
||||
security:
|
||||
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE]
|
||||
# NET_BIND_SERVICE is load-bearing, not decoration: NPM's internal nginx
|
||||
# listens on 80, 443 AND 81, and the orchestrator runs --cap-drop=ALL —
|
||||
# without this cap every start dies with "bind() to 0.0.0.0:80 failed
|
||||
# (13: Permission denied)" and s6 restart-loops forever (shorty-s,
|
||||
# 2026-09-01, restart counter 3176 within hours of the manifest
|
||||
# conversion). The legacy podman-run path defaulted to the full cap set,
|
||||
# which is why it never showed there.
|
||||
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
|
||||
readonly_root: false
|
||||
no_new_privileges: true
|
||||
network_policy: isolated
|
||||
@@ -42,17 +52,37 @@ 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
|
||||
target: /data
|
||||
options: [rw]
|
||||
# Current NPM images refuse to start unless /etc/letsencrypt is a mount in
|
||||
# its own right. Keeping the files below the same persistent app directory
|
||||
# preserves existing certificates while satisfying that startup contract.
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/nginx-proxy-manager/letsencrypt
|
||||
target: /etc/letsencrypt
|
||||
options: [rw]
|
||||
|
||||
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
|
||||
|
||||
@@ -20,7 +20,8 @@ app:
|
||||
- storage: 50Gi
|
||||
|
||||
resources:
|
||||
memory_limit: 0
|
||||
# No memory limit: models are sized by the disk allowance below, and a
|
||||
# RAM ceiling would just OOM-kill long inferences.
|
||||
disk_limit: 50Gi
|
||||
|
||||
security:
|
||||
|
||||
@@ -5,7 +5,7 @@ app:
|
||||
# (--beam-size 1). Bumped past the image version so catalog-driven nodes
|
||||
# pick up the args change; the pre-release form "3.4.1-1" would compare
|
||||
# LOWER than 3.4.1 under semver and never roll out.
|
||||
version: "3.4.2"
|
||||
version: "3.6.0"
|
||||
# Tracks the rhasspy/wyoming-whisper image we pin (Docker Hub — the
|
||||
# project's GitHub tags are not the image tags). NOTE: this manifest
|
||||
# deliberately ships an args-tuned revision AHEAD of the image tag (see
|
||||
@@ -24,7 +24,7 @@ app:
|
||||
container_name: pine-whisper
|
||||
|
||||
container:
|
||||
image: docker.io/rhasspy/wyoming-whisper:3.4.1
|
||||
image: docker.io/rhasspy/wyoming-whisper:3.6.0
|
||||
pull_policy: if-not-present
|
||||
network: archy-net
|
||||
network_aliases: [pine-whisper]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: portainer
|
||||
name: Portainer
|
||||
version: 2.19.4
|
||||
version: 2.45.0
|
||||
# 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,10 +12,18 @@ app:
|
||||
category: development
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.6
|
||||
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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
app:
|
||||
id: vaultwarden
|
||||
name: Vaultwarden
|
||||
version: 1.30.0
|
||||
version: 1.37.2
|
||||
# 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: Self-hosted password vault with zero-knowledge encryption.
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine
|
||||
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.2-alpine
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
|
||||
|
||||
+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
+64
-5
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.7-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",
|
||||
@@ -1567,6 +1569,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 +2514,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 +3661,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 +3706,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 +4167,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 +4404,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 +7366,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",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.7-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
|
||||
@@ -90,8 +91,9 @@ rustls-pemfile = "1.0"
|
||||
webpki = { package = "rustls-webpki", version = "0.101" }
|
||||
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
|
||||
|
||||
# Nostr (node discovery + NIP-44 encrypted peer handshake)
|
||||
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
|
||||
# Nostr (node discovery + NIP-44 encrypted peer handshake).
|
||||
# nip06: NIP-06 key derivation for the Minibits @minibits.cash profile flow.
|
||||
nostr-sdk = { version = "0.44", features = ["nip04", "nip06", "nip44"] }
|
||||
|
||||
# Backup encryption (DID identity export) + TOTP 2FA encryption
|
||||
argon2 = "0.5.3"
|
||||
@@ -105,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"] }
|
||||
|
||||
@@ -145,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,12 +1,18 @@
|
||||
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 websocket;
|
||||
|
||||
@@ -384,6 +390,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()
|
||||
@@ -444,6 +456,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();
|
||||
@@ -584,6 +622,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 +638,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 +649,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 +659,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 +670,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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user