Compare commits
467
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6360c253f | ||
|
|
eaacfa697c | ||
|
|
3b99be7fcf | ||
|
|
07d0280307 | ||
|
|
4fe2b2b7ab | ||
|
|
e5412bfe5a | ||
|
|
916c57b42e | ||
|
|
2e6794dd9c | ||
|
|
6c67977ad6 | ||
|
|
03c6f6a311 | ||
|
|
1f710263c5 | ||
|
|
cab03ba544 | ||
|
|
2d128fdf4b | ||
|
|
dc1455ccf3 | ||
|
|
976b6e5b7d | ||
|
|
709cd07eab | ||
|
|
75dcce89dd | ||
|
|
ae82f09d1a | ||
|
|
8270556ccb | ||
|
|
3502cf4312 | ||
|
|
bd11f9541f | ||
|
|
7f326aa57d | ||
|
|
1ed926bcd8 | ||
|
|
f4c30f5b74 | ||
|
|
8362d35549 | ||
|
|
a786e79fcd | ||
|
|
29fbc9c4df | ||
|
|
d8e77d5097 | ||
|
|
4b301730cd | ||
|
|
97061fc849 | ||
|
|
1ea9315492 | ||
|
|
22c30a4f31 | ||
|
|
da0a6d2221 | ||
|
|
9315887f1d | ||
|
|
d4faa4a034 | ||
|
|
dc4d75ff2f | ||
|
|
00de9bbcbb | ||
|
|
5b25b39f7a | ||
|
|
5e069e6e1f | ||
|
|
acabe3fbd6 | ||
|
|
d0eb7c60e5 | ||
|
|
7f0298e1f4 | ||
|
|
4ee4613627 | ||
|
|
489f0a1b64 | ||
|
|
1045a0e7e8 | ||
|
|
93b70398e3 | ||
|
|
5992353dca | ||
|
|
fa3af93cf0 | ||
|
|
05a43c2c9d | ||
|
|
33797e3d6a | ||
|
|
cc6e208171 | ||
|
|
b6cfe542a5 | ||
|
|
ac6ce915b1 | ||
|
|
c7a766fb98 | ||
|
|
cdb0a3b582 | ||
|
|
9a85d8dfa2 | ||
|
|
34e4211383 | ||
|
|
43edc66452 | ||
|
|
5f44ca29a9 | ||
|
|
048fd4113a | ||
|
|
e7da938f0b | ||
|
|
f4123e80c9 | ||
|
|
b035561b85 | ||
|
|
86c30d955c | ||
|
|
f9a0cab649 | ||
|
|
813a93109b | ||
|
|
19c3239bad | ||
|
|
1b795c6c07 | ||
|
|
b9bad0abae | ||
|
|
1c0335c131 | ||
|
|
861eb0f607 | ||
|
|
5f8bd00a69 | ||
|
|
c20b875736 | ||
|
|
5e8bea8d6a | ||
|
|
b167d889b5 | ||
|
|
5a6d78566d | ||
|
|
25cb823a43 | ||
|
|
c7402195e4 | ||
|
|
29a941bd9f | ||
|
|
224da46da5 | ||
|
|
396ba92672 | ||
|
|
4ff3aa3061 | ||
|
|
62b6cf80da | ||
|
|
5fe08ce366 | ||
|
|
b2d6c5a99a | ||
|
|
bfb36aee49 | ||
|
|
04a9c98e6a | ||
|
|
815bf05e07 | ||
|
|
1e3623fee3 | ||
|
|
6c83d7e23c | ||
|
|
c1af5599a5 | ||
|
|
c771cc692b | ||
|
|
8248b786f0 | ||
|
|
2b970f6538 | ||
|
|
81f1666cea | ||
|
|
f428401e80 | ||
|
|
e848dc9e20 | ||
|
|
4b10536869 | ||
|
|
9f0c584518 | ||
|
|
8819b41aed | ||
|
|
34c411344b | ||
|
|
4cffbaea46 | ||
|
|
55dede4cd8 | ||
|
|
f27cf9d5a5 | ||
|
|
c50d974be5 | ||
|
|
3f169fe098 | ||
|
|
3204eb45ae | ||
|
|
a48b6e955a | ||
|
|
502d11a519 | ||
|
|
afa3f7f5a1 | ||
|
|
d1338cc421 | ||
|
|
6cea29d124 | ||
|
|
090dc01d3c | ||
|
|
1c11fe234f | ||
|
|
fdc77a1329 | ||
|
|
869986834b | ||
|
|
ac0f61b372 | ||
|
|
555e0b42d3 | ||
|
|
148713fb19 | ||
|
|
6750823d4d | ||
|
|
c17fe1db06 | ||
|
|
8bbe464028 | ||
|
|
a67071af2b | ||
|
|
b39ce2a315 | ||
|
|
b25c6fa093 | ||
|
|
c965f790f9 | ||
|
|
d276887f00 | ||
|
|
976156141a | ||
|
|
6443f33d03 | ||
|
|
ef2745122a | ||
|
|
5e6adafc0e | ||
|
|
bf22d15de2 | ||
|
|
2bb48cbef0 | ||
|
|
28c210a829 | ||
|
|
2bd63b91af | ||
|
|
4198ca64f6 | ||
|
|
a44e2fa36d | ||
|
|
89c58d4bbb | ||
|
|
3c02fa9b13 | ||
|
|
0e21ec21bc | ||
|
|
ce7b437ee8 | ||
|
|
77b9db47b3 | ||
|
|
dd83efa085 | ||
|
|
ebf7ff428a | ||
|
|
04af3ad85d | ||
|
|
ba50085fa0 | ||
|
|
9a8490aee8 | ||
|
|
6480f9685c | ||
|
|
6b34cc2af4 | ||
|
|
acfa68dc47 | ||
|
|
3bb8a07606 | ||
|
|
98e881f748 | ||
|
|
da36e63449 | ||
|
|
b1144006f6 | ||
|
|
ade28c1ae4 | ||
|
|
dd75dd928a | ||
|
|
89a85e5780 | ||
|
|
9fcc433e2e | ||
|
|
20a100637b | ||
|
|
33504e7256 | ||
|
|
d26fc3f7f1 | ||
|
|
faed46986b | ||
|
|
138ae70667 | ||
|
|
6f83a86bcc | ||
|
|
5f43ec34c8 | ||
|
|
17d1caaff4 | ||
|
|
fa146980ed | ||
|
|
2f9f56fc63 | ||
|
|
35813466d2 | ||
|
|
205f632f84 | ||
|
|
676c76d521 | ||
|
|
43d2aac510 | ||
|
|
4363ae2e3f | ||
|
|
dcca7f9146 | ||
|
|
acf005a292 | ||
|
|
02a45660f6 | ||
|
|
5826e4df87 | ||
|
|
8cf19c829e | ||
|
|
2b327ab2ff | ||
|
|
cdf7ac9dee | ||
|
|
9298b8908c | ||
|
|
ab5f3ba097 | ||
|
|
9f94967660 | ||
|
|
2e0157a466 | ||
|
|
5c459b7ebd | ||
|
|
e236549917 | ||
|
|
9b45afd288 | ||
|
|
aaf31d3634 | ||
|
|
8ccae998af | ||
|
|
7de9d1c83c | ||
|
|
6b3e87f19a | ||
|
|
7816dc357b | ||
|
|
b83ea21a04 | ||
|
|
b5246ccb49 | ||
|
|
64255dab8b | ||
|
|
26fa964dd7 | ||
|
|
f67f5bb254 | ||
|
|
dbdd782d22 | ||
|
|
e3f8ade165 | ||
|
|
e31c51df16 | ||
|
|
9fe980b540 | ||
|
|
32fc0a894c | ||
|
|
326b134bc1 | ||
|
|
03a124fbd1 | ||
|
|
52256c6f42 | ||
|
|
0ec0634843 | ||
|
|
f9919c1777 | ||
|
|
85c5c7695a | ||
|
|
ff0b1276a5 | ||
|
|
36c485bcf4 | ||
|
|
f3f192f34e | ||
|
|
15b112b6fa | ||
|
|
5631c7c296 | ||
|
|
51244c54ad | ||
|
|
0b9dd005e3 | ||
|
|
807356a4fd | ||
|
|
925c197396 | ||
|
|
d96cab7f99 | ||
|
|
1c2187a696 | ||
|
|
5582961895 | ||
|
|
a6b67c0b68 | ||
|
|
6364ee6583 | ||
|
|
89aa93a50d | ||
|
|
3f2c6141a2 | ||
|
|
f2657b2e03 | ||
|
|
3cd6639fd9 | ||
|
|
f835fab44e | ||
|
|
ef426fc80f | ||
|
|
bf8f2358a8 | ||
|
|
3666efd1b3 | ||
|
|
4f66494626 | ||
|
|
0dc192e9bc | ||
|
|
45288b8269 | ||
|
|
ef978e96dd | ||
|
|
4fd10488b8 | ||
|
|
ad9404c6c6 | ||
|
|
5de1d8c329 | ||
|
|
aed7badfa3 | ||
|
|
9846670179 | ||
|
|
aab13f4bc7 | ||
|
|
0f3f4eaa44 | ||
|
|
768aaaa6e3 | ||
|
|
e2c6ffa114 | ||
|
|
d8d8f0416c | ||
|
|
7de25e92ba | ||
|
|
5256c9eee7 | ||
|
|
efef024d05 | ||
|
|
68ba348d61 | ||
|
|
d04474b6c8 | ||
|
|
716f66a0c0 | ||
|
|
0be949796c | ||
|
|
e69c719869 | ||
|
|
9bfee57a40 | ||
|
|
6050d80f1d | ||
|
|
a88a700052 | ||
|
|
2dd935bad4 | ||
|
|
bf24f513d3 | ||
|
|
9cff842298 | ||
|
|
8793a1505b | ||
|
|
4489a67852 | ||
|
|
1151d61cd4 | ||
|
|
b61c0d5f62 | ||
|
|
b3fc391ab7 | ||
|
|
ce8f295690 | ||
|
|
367666b338 | ||
|
|
f48e67b2e6 | ||
|
|
6a59490370 | ||
|
|
a1684b0267 | ||
|
|
5feab49350 | ||
|
|
2963fa9939 | ||
|
|
cb50f9d848 | ||
|
|
98458630c3 | ||
|
|
8473aea817 | ||
|
|
6b9829a62a | ||
|
|
dcda70e697 | ||
|
|
cc92820629 | ||
|
|
12aae53385 | ||
|
|
e5cfa17b3f | ||
|
|
1d39291f48 | ||
|
|
9d84018687 | ||
|
|
602b966f83 | ||
|
|
e8a3202bb2 | ||
|
|
2081895606 | ||
|
|
da539992f7 | ||
|
|
6927dc86e3 | ||
|
|
b0a329dfed | ||
|
|
0d10852fb8 | ||
|
|
dcac00d1a9 | ||
|
|
25e49637e3 | ||
|
|
c6faa4cc2f | ||
|
|
b67d71707d | ||
|
|
03a7cdc4f3 | ||
|
|
544bc01506 | ||
|
|
b6c67ed1bd | ||
|
|
184ba064cd | ||
|
|
ece444796c | ||
|
|
684bbdaffa | ||
|
|
3a912fee44 | ||
|
|
ad141780c2 | ||
|
|
45ee3f291a | ||
|
|
2f8d991bbd | ||
|
|
c814fba6d5 | ||
|
|
a50356862c | ||
|
|
43c5c2ce5b | ||
|
|
e6164ca52a | ||
|
|
637c3ec039 | ||
|
|
f455d75e60 | ||
|
|
5a0fb68cf1 | ||
|
|
4c0dc81030 | ||
|
|
f7ab65e7a7 | ||
|
|
0eb82d3e03 | ||
|
|
80def3957c | ||
|
|
6346392f49 | ||
|
|
04ccd9420d | ||
|
|
d0cf7507a7 | ||
|
|
27965e2762 | ||
|
|
1082e21535 | ||
|
|
aabae0f56b | ||
|
|
df9e47f987 | ||
|
|
d29cfa7291 | ||
|
|
5c298b8ec0 | ||
|
|
ca207173a1 | ||
|
|
e0f0548dc8 | ||
|
|
3ab9ae5d43 | ||
|
|
1519aa4b0a | ||
|
|
41f8788600 | ||
|
|
eb7c08eff2 | ||
|
|
0b2367106c | ||
|
|
b5646cc6e8 | ||
|
|
b6ac5d232e | ||
|
|
1f142fcdde | ||
|
|
daf964ecf2 | ||
|
|
dd7b3008e4 | ||
|
|
6438403b15 | ||
|
|
3c7f247cb9 | ||
|
|
bbeb15e3ad | ||
|
|
cb6ad96ddd | ||
|
|
fe1a3d8a32 | ||
|
|
06b822a0e1 | ||
|
|
9ee55416c8 | ||
|
|
ddd6c49f3a | ||
|
|
6d05c2ef0a | ||
|
|
d8e38bec32 | ||
|
|
77f5221644 | ||
|
|
aa1183662a | ||
|
|
413919ae2b | ||
|
|
34d68554d7 | ||
|
|
ffb48bc0ca | ||
|
|
cf4b37ae3d | ||
|
|
64248a7084 | ||
|
|
2224f1579b | ||
|
|
168e3ebdc4 | ||
|
|
fe26a4bd73 | ||
|
|
125682a71f | ||
|
|
0dd9042033 | ||
|
|
ba52d12301 | ||
|
|
e0bfb9dc7c | ||
|
|
067ca0d711 | ||
|
|
ca84a102c8 | ||
|
|
543f96510d | ||
|
|
0f20d8cb8f | ||
|
|
16eacfafc6 | ||
|
|
bcac032bf1 | ||
|
|
82b48f1c82 | ||
|
|
ee8682bd6b | ||
|
|
da754487dc | ||
|
|
56309da4e2 | ||
|
|
70d56cfa7f | ||
|
|
29a76b89a3 | ||
|
|
0ad98c8f02 | ||
|
|
c902d9d1de | ||
|
|
6dc33dd5e9 | ||
|
|
d621cae682 | ||
|
|
19d3dc9daa | ||
|
|
a75bb7fea0 | ||
|
|
4fdf0e147b | ||
|
|
1f279e7080 | ||
|
|
1fac3a45ab | ||
|
|
448ecda16e | ||
|
|
5abfa46c35 | ||
|
|
71e308ed7b | ||
|
|
5eaa6f6998 | ||
|
|
efb2a042f3 | ||
|
|
bc057afc17 | ||
|
|
1d1086f105 | ||
|
|
79614ed4de | ||
|
|
34eb14bee5 | ||
|
|
5ad350a0f3 | ||
|
|
12e8f23ee4 | ||
|
|
7e0a52a33c | ||
|
|
31938144e1 | ||
|
|
2d4ac1c2ac | ||
|
|
31972e2543 | ||
|
|
d59bab6143 | ||
|
|
b63671158d | ||
|
|
716e1914d9 | ||
|
|
73e66ac7c3 | ||
|
|
805b5fcba9 | ||
|
|
f19b97295a | ||
|
|
fba6b0ba00 | ||
|
|
56be00b47c | ||
|
|
8bea7f63c8 | ||
|
|
d50211e951 | ||
|
|
d9281ca3e0 | ||
|
|
3a2414e8d6 | ||
|
|
18ed3c3baa | ||
|
|
c5e66f0754 | ||
|
|
6460625491 | ||
|
|
9288276bae | ||
|
|
cfb233d6d1 | ||
|
|
7afc41ab9e | ||
|
|
e5be511741 | ||
|
|
d94590f652 | ||
|
|
78847a1ad5 | ||
|
|
32ea249b12 | ||
|
|
0fba2c4d38 | ||
|
|
6bc4f414b3 | ||
|
|
0ec9bb32a8 | ||
|
|
6396d6a387 | ||
|
|
04e084f911 | ||
|
|
49391ac78e | ||
|
|
c54ce1fd73 | ||
|
|
a0ec6c8055 | ||
|
|
f9d75fb0f4 | ||
|
|
f96971536f | ||
|
|
7eae2fd5c3 | ||
|
|
ecad318b80 | ||
|
|
4bc28cdb54 | ||
|
|
61dc8ebd74 | ||
|
|
6a8d6057af | ||
|
|
f7ce1be391 | ||
|
|
d829c3f804 | ||
|
|
9aeb0400f1 | ||
|
|
4f9e89a390 | ||
|
|
7a808bf149 | ||
|
|
3af2ee222c | ||
|
|
3f252f7573 | ||
|
|
a33217ccea | ||
|
|
4504bab7e3 | ||
|
|
83439dfe17 | ||
|
|
761455008c | ||
|
|
7cfdd4c18f | ||
|
|
5fa08d6c14 | ||
|
|
a093ebb929 | ||
|
|
000455f0fe | ||
|
|
0948bd3498 | ||
|
|
82d82a3227 | ||
|
|
5b06e9178c | ||
|
|
4228d5e77a | ||
|
|
4a89e73c7d | ||
|
|
6168cecdb3 | ||
|
|
b8b2c34137 | ||
|
|
23192028df | ||
|
|
2002dcc1e0 | ||
|
|
f8e94649bf | ||
|
|
480c97a560 | ||
|
|
d395de326a | ||
|
|
e0645f96f4 | ||
|
|
b51f9240e8 | ||
|
|
54ae9b3a2f | ||
|
|
3830544e13 | ||
|
|
913542c19f | ||
|
|
f603a0dfff | ||
|
|
5e47e055a2 | ||
|
|
d1136d74cb | ||
|
|
54f598789e | ||
|
|
5171eaf31e |
No files matched your search
@@ -0,0 +1,35 @@
|
||||
# Stable releases require live dev acceptance
|
||||
|
||||
The order is **dev CI → dev image publication → automatic Cabbage deployment →
|
||||
real capture and browser acceptance → stable publication**. Never
|
||||
substitute a published PyPI wheel, healthy container, or local build for this
|
||||
acceptance. The release workflow blocks *before* a stable upload unless Cabbage
|
||||
has successful evidence from `staging-acceptance.yml` matching all tracked
|
||||
source and dependency pins. Only ArchiveBox's own version fields may change
|
||||
between dev and stable.
|
||||
|
||||
The staging workflow observes the existing image watcher; it cannot deploy or
|
||||
restart the application. On Cabbage, install `bin/staging-acceptance-host.sh`
|
||||
as root-owned mode 0755 `/usr/local/sbin/archivebox-staging-acceptance`. Its hash
|
||||
is checked against the candidate. A dedicated SSH key must use
|
||||
`restrict,command="/usr/local/sbin/archivebox-staging-acceptance"` in authorized
|
||||
keys. It accepts only `inspect <source-sha>` and `capture <source-sha>`; it cannot
|
||||
run caller-provided commands or archive arbitrary URLs.
|
||||
|
||||
The `staging` GitHub environment is restricted to dev/main branches. It contains
|
||||
`STAGING_SSH_KEY` and `STAGING_KNOWN_HOSTS`, with independently verified host keys.
|
||||
Only trusted release events use the private ugNAS runner route. Never expose
|
||||
these credentials to PR jobs or copy an operator's existing root key.
|
||||
|
||||
Acceptance creates one tagged background crawl per source through the normal
|
||||
add/runner path, enabling every snapshot extractor, including TLSNotary. It
|
||||
checks installed pins, completed outputs and their files, unexpected/missing
|
||||
plugin results, host OOM kills, and real rendered snapshot pages. For this
|
||||
release, the explicitly accepted TLSNotary failures and exact missing-Claude-auth
|
||||
diagnostics are retained in the report's `limitations` list; they do not disable
|
||||
those plugins or excuse missing/truncated successful outputs. Remove the
|
||||
TLSNotary exception when its follow-up fix ships. Logs, result inventories and
|
||||
screenshots are retained as Actions artifacts. Other failures remain
|
||||
failures; do not publish a success deployment record to bypass them. No code
|
||||
mounts are allowed. The Cabbage job must pass before its record authorizes a
|
||||
stable upload. Queued capture progress is polled; failed tests are not retried.
|
||||
@@ -0,0 +1,3 @@
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- ugnas-linux
|
||||
@@ -108,3 +108,12 @@ manifests and provenance are never rewritten by this presentation step.
|
||||
|
||||
Standalone one- or two-image illustrations remain in their authored source.
|
||||
Gallery strips with three or more images use the latest restored captures.
|
||||
|
||||
## Keeping the common layout in sync
|
||||
|
||||
The comments in `base/header.html`, `base/footer-start.html`,
|
||||
`base/footer-end.html`, `base/chrome.css`, and `base/chrome.js` list the peer
|
||||
repositories. Update those committed copies together when shared branding,
|
||||
Apps links, keyboard behavior, or footer resources change. Preserve each site's
|
||||
local navigation and content. The former monorepo `site-chrome` generator is
|
||||
retired; builds must use only this repository's files.
|
||||
@@ -1,3 +1,13 @@
|
||||
/*
|
||||
Shared ArchiveBox website navigation styles and behavior.
|
||||
Keep this file in sync with .github/pages/base/chrome.css in:
|
||||
ArchiveBox/ArchiveBox, abx-plugins, abxpkg, android-archivebox,
|
||||
electron-archivebox, ios-archivebox, and archivebox-browser-extension (all repositories are in the ArchiveBox GitHub organization).
|
||||
Each site builds its committed copy independently; there is no central generator.
|
||||
Keep site-specific navigation, CTAs, and extra footer links outside this base.
|
||||
Also review the inline navbar/footer in abx-dl/website/index.html and
|
||||
debian-archivebox/website/index.html when shared links or branding change.
|
||||
*/
|
||||
/* Common brand tokens. Native templates own product layout and content. */
|
||||
:root {
|
||||
--abx-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
/*
|
||||
Shared ArchiveBox website navigation styles and behavior.
|
||||
Keep this file in sync with .github/pages/base/chrome.js in:
|
||||
ArchiveBox/ArchiveBox, abx-plugins, abxpkg, android-archivebox,
|
||||
electron-archivebox, ios-archivebox, and archivebox-browser-extension (all repositories are in the ArchiveBox GitHub organization).
|
||||
Each site builds its committed copy independently; there is no central generator.
|
||||
Keep site-specific navigation, CTAs, and extra footer links outside this base.
|
||||
Also review the inline navbar/footer in abx-dl/website/index.html and
|
||||
debian-archivebox/website/index.html when shared links or branding change.
|
||||
*/
|
||||
(() => {
|
||||
const menu = document.querySelector('.abx-apps');
|
||||
document.addEventListener('keydown', event => {
|
||||
|
||||
@@ -1,2 +1,12 @@
|
||||
<!--
|
||||
Shared ArchiveBox website footer.
|
||||
Keep this file in sync with .github/pages/base/footer-end.html in:
|
||||
ArchiveBox/ArchiveBox, abx-plugins, abxpkg, android-archivebox,
|
||||
electron-archivebox, ios-archivebox, and archivebox-browser-extension (all repositories are in the ArchiveBox GitHub organization).
|
||||
Each site builds its committed copy independently; there is no central generator.
|
||||
Keep site-specific navigation, CTAs, and extra footer links outside this base.
|
||||
Also review the inline navbar/footer in abx-dl/website/index.html and
|
||||
debian-archivebox/website/index.html when shared links or branding change.
|
||||
-->
|
||||
<div class="abx-footer-bottom"><p>Built in the open. Supported by our community.</p><p><a href="https://github.com/ArchiveBox">GitHub organization</a> · <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview">Security & privacy</a> · <a href="https://archivebox.io">ArchiveBox.io ↗</a></p></div>
|
||||
</footer>
|
||||
@@ -1,3 +1,13 @@
|
||||
<!--
|
||||
Shared ArchiveBox website footer.
|
||||
Keep this file in sync with .github/pages/base/footer-start.html in:
|
||||
ArchiveBox/ArchiveBox, abx-plugins, abxpkg, android-archivebox,
|
||||
electron-archivebox, ios-archivebox, and archivebox-browser-extension (all repositories are in the ArchiveBox GitHub organization).
|
||||
Each site builds its committed copy independently; there is no central generator.
|
||||
Keep site-specific navigation, CTAs, and extra footer links outside this base.
|
||||
Also review the inline navbar/footer in abx-dl/website/index.html and
|
||||
debian-archivebox/website/index.html when shared links or branding change.
|
||||
-->
|
||||
<footer class="abx-footer" aria-label="ArchiveBox ecosystem">
|
||||
<div class="abx-footer-intro"><div><a class="abx-brand" href="https://archivebox.io/"><svg class="abx-logo" viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#9b2854"/><path d="M10 14h20v17H10z" fill="#fff4ed"/><path d="M8 9h24v6H8z" fill="#fff4ed"/><path d="M16 19h8v3h-8z" fill="#9b2854"/><path d="M12 31V17" stroke="#9b2854" stroke-width="2"/></svg><span>ArchiveBox</span></a><p class="abx-tagline">Open-source tools for keeping a lasting copy of the web.<br>Your data. Your devices. Your archive.</p></div><p class="abx-footer-motto">A home for the web<br>you want to keep.</p></div>
|
||||
<div class="abx-footer-grid"><section class="abx-footer-column"><h2>Apps & tools</h2><ul><li><a href="https://archivebox.io/">ArchiveBox Server</a><a class="abx-source" href="https://github.com/ArchiveBox/ArchiveBox" aria-label="ArchiveBox Server source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://app.archivebox.io/#get-started">ArchiveBox for macOS & iOS</a><a class="abx-source" href="https://github.com/ArchiveBox/ios-archivebox" aria-label="ArchiveBox for macOS & iOS source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://android.archivebox.io/">ArchiveBox for Android</a><a class="abx-source" href="https://github.com/ArchiveBox/android-archivebox" aria-label="ArchiveBox for Android source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://electron.archivebox.io/">ArchiveBox Desktop</a><a class="abx-source" href="https://github.com/ArchiveBox/electron-archivebox" aria-label="ArchiveBox Desktop source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://extension.archivebox.io/">ArchiveBox Browser Extension</a><a class="abx-source" href="https://github.com/ArchiveBox/archivebox-browser-extension" aria-label="ArchiveBox Browser Extension source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://plugins.archivebox.io/">ArchiveBox Plugin Library</a><a class="abx-source" href="https://github.com/ArchiveBox/abx-plugins" aria-label="ArchiveBox Plugin Library source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://abx-dl.archivebox.io/">One-shot abx-Line truncated
|
||||
<div class="abx-footer-grid"><section class="abx-footer-column"><h2>Apps & tools</h2><ul><li><a href="https://archivebox.io/">ArchiveBox Server</a><a class="abx-source" href="https://github.com/ArchiveBox/ArchiveBox" aria-label="ArchiveBox Server source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://app.archivebox.io/#get-started">ArchiveBox for macOS & iOS</a><a class="abx-source" href="https://github.com/ArchiveBox/ios-archivebox" aria-label="ArchiveBox for macOS & iOS source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://android.archivebox.io/">ArchiveBox for Android</a><a class="abx-source" href="https://github.com/ArchiveBox/android-archivebox" aria-label="ArchiveBox for Android source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://electron.archivebox.io/">ArchiveBox Desktop</a><a class="abx-source" href="https://github.com/ArchiveBox/electron-archivebox" aria-label="ArchiveBox Desktop source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://extension.archivebox.io/">ArchiveBox Browser Extension</a><a class="abx-source" href="https://github.com/ArchiveBox/archivebox-browser-extension" aria-label="ArchiveBox Browser Extension source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://plugins.archivebox.io/">ArchiveBox Plugin Library</a><a class="abx-source" href="https://github.com/ArchiveBox/abx-plugins" aria-label="ArchiveBox Plugin Library source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://abx-dl.archivebox.io/">One-shot abx-Line truncated
|
||||
@@ -1,3 +1,13 @@
|
||||
<!--
|
||||
Shared ArchiveBox website navbar and Apps menu.
|
||||
Keep this file in sync with .github/pages/base/header.html in:
|
||||
ArchiveBox/ArchiveBox, abx-plugins, abxpkg, android-archivebox,
|
||||
electron-archivebox, ios-archivebox, and archivebox-browser-extension (all repositories are in the ArchiveBox GitHub organization).
|
||||
Each site builds its committed copy independently; there is no central generator.
|
||||
Keep site-specific navigation, CTAs, and extra footer links outside this base.
|
||||
Also review the inline navbar/footer in abx-dl/website/index.html and
|
||||
debian-archivebox/website/index.html when shared links or branding change.
|
||||
-->
|
||||
<header class="abx-header">
|
||||
<a class="abx-brand" href="https://archivebox.io/"><svg class="abx-logo" viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#9b2854"/><path d="M10 14h20v17H10z" fill="#fff4ed"/><path d="M8 9h24v6H8z" fill="#fff4ed"/><path d="M16 19h8v3h-8z" fill="#9b2854"/><path d="M12 31V17" stroke="#9b2854" stroke-width="2"/></svg><span>ArchiveBox</span><span class="abx-subsite">__SITE_NAME__</span></a>
|
||||
<nav class="abx-nav" aria-label="Main navigation">
|
||||
|
||||
+42
-1
@@ -2,11 +2,13 @@
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
from urllib.parse import unquote, urljoin, urlsplit
|
||||
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
@@ -25,8 +27,47 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
pass
|
||||
|
||||
|
||||
class ScreenshotReferences(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.references = []
|
||||
self.ids = set()
|
||||
|
||||
def handle_starttag(self, tag, attributes):
|
||||
attrs = dict(attributes)
|
||||
if attrs.get("id"):
|
||||
self.ids.add(attrs["id"])
|
||||
for attribute in ("src", "href"):
|
||||
if attrs.get(attribute):
|
||||
self.references.append(attrs[attribute])
|
||||
|
||||
|
||||
def verify_screenshot_references(output, canonical):
|
||||
"""Check gallery embeds on every static page, including optional translations."""
|
||||
pages = {}
|
||||
for path in output.rglob("*.html"):
|
||||
page = ScreenshotReferences()
|
||||
page.feed(path.read_text())
|
||||
pages[path.resolve()] = page
|
||||
for path, page in pages.items():
|
||||
page_url = urljoin(canonical, path.relative_to(output.resolve()).as_posix())
|
||||
for reference in page.references:
|
||||
url = urlsplit(urljoin(page_url, reference))
|
||||
if url.netloc != urlsplit(canonical).netloc or "/screenshots/" not in url.path:
|
||||
continue
|
||||
target = output / unquote(url.path).lstrip("/")
|
||||
if target.is_dir():
|
||||
target /= "index.html"
|
||||
target = target.resolve()
|
||||
assert target.is_relative_to(output.resolve()) and target.is_file(), f"{path}: missing screenshot target {reference}"
|
||||
assert not re.match(r"^\d+-", target.name), f"{path}: screenshot filename depends on capture order: {reference}"
|
||||
if url.fragment and target in pages:
|
||||
assert unquote(url.fragment) in pages[target].ids, f"{path}: missing screenshot anchor {reference}"
|
||||
|
||||
|
||||
def verify(output, evidence):
|
||||
config = json.loads((HERE / "site.json").read_text())
|
||||
verify_screenshot_references(output, config["url"])
|
||||
evidence.mkdir(parents=True, exist_ok=True)
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), partial(Handler, directory=str(output.resolve())))
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
|
||||
@@ -1,30 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discover every ArchiveBox test file and expose it as one CI job."""
|
||||
"""Discover every test once, batching measured short files."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
root = Path.cwd().resolve()
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
archivebox_tests = sorted((root / "archivebox/tests").glob("test_*.py"))
|
||||
|
||||
if not archivebox_tests:
|
||||
raise SystemExit("No ArchiveBox tests discovered")
|
||||
|
||||
matrix: list[dict[str, object]] = []
|
||||
# These are observed whole-job seconds, including setup. Unknown/long files,
|
||||
# optional dependencies and hosted-only requirements keep independent jobs.
|
||||
durations = json.loads((root / ".github/test-durations.json").read_text())["seconds"]
|
||||
hosted = {path for path in archivebox_tests if "# ci-runner: hosted" in path.read_text().splitlines()[:5]}
|
||||
requested = os.environ.get("TEST_FILE", "")
|
||||
batches: list[list[str]] = []
|
||||
batch: list[str] = []
|
||||
batch_seconds = 0
|
||||
for path in archivebox_tests:
|
||||
test_path = path.relative_to(root).as_posix()
|
||||
duration = durations.get(test_path, 60)
|
||||
if duration >= 60 or path in hosted or path.stem == "test_auth_ldap" or test_path == requested:
|
||||
batches.append([test_path])
|
||||
continue
|
||||
if batch and (len(batch) == 8 or batch_seconds + duration > 180):
|
||||
batches.append(batch)
|
||||
batch = []
|
||||
batch_seconds = 0
|
||||
batch.append(test_path)
|
||||
batch_seconds += duration
|
||||
if batch:
|
||||
batches.append(batch)
|
||||
|
||||
matrix: list[dict[str, object]] = []
|
||||
for paths in batches:
|
||||
matrix.append(
|
||||
{
|
||||
"name": f"main/{path.stem}",
|
||||
"paths": [test_path],
|
||||
"paths_arg": test_path,
|
||||
"extra": "ldap" if test_path.endswith("/test_auth_ldap.py") else "",
|
||||
"count": 1,
|
||||
"name": f"main/{Path(paths[0]).stem}" if len(paths) == 1 else f"batch/{Path(paths[0]).stem}",
|
||||
"paths": paths,
|
||||
"paths_arg": " ".join(paths),
|
||||
"extra": "ldap" if paths == ["archivebox/tests/test_auth_ldap.py"] else "",
|
||||
"count": len(paths),
|
||||
"ugnas": False,
|
||||
},
|
||||
)
|
||||
|
||||
# Ordinary Linux tests can use either pool. Only genuine hosted-only
|
||||
# requirements need a file header; filenames never enter runner policy.
|
||||
# Keep a bounded NAS share so hosted capacity continues working in parallel.
|
||||
capacity = int(os.environ.get("UGNAS_CI_MAX_JOBS", "3"))
|
||||
# Start long jobs first in both pools instead of leaving several minutes of
|
||||
# work behind every short job in an alphabetically ordered hosted queue.
|
||||
matrix.sort(key=lambda item: sum(durations.get(path, 60) for path in item["paths"]), reverse=True)
|
||||
eligible = [item for item in matrix if not any(root / path in hosted for path in item["paths"])]
|
||||
for item in eligible[:capacity]:
|
||||
item["ugnas"] = True
|
||||
|
||||
discovered_paths = [path for entry in matrix for path in entry["paths"]]
|
||||
if len(discovered_paths) != len(set(discovered_paths)):
|
||||
raise SystemExit("Tests were not discovered exactly once")
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"source_runs": [
|
||||
"https://github.com/ArchiveBox/ArchiveBox/actions/runs/36302511923",
|
||||
"https://github.com/ArchiveBox/ArchiveBox/actions/runs/36353196552"
|
||||
],
|
||||
"measurement": "Whole job seconds, including setup and excluding queue time. Unbatched files use the latest complete successful run; batched files retain their individual baseline measurement. Unknown files stay independent.",
|
||||
"seconds": {
|
||||
"archivebox/tests/test_api_v1_auth_browser_session.py": 29,
|
||||
"archivebox/tests/test_api_v1_auth_check_api_token.py": 27,
|
||||
"archivebox/tests/test_api_v1_auth_get_api_token.py": 24,
|
||||
"archivebox/tests/test_api_v1_cli_add.py": 96,
|
||||
"archivebox/tests/test_api_v1_cli_remove.py": 50,
|
||||
"archivebox/tests/test_api_v1_cli_schedule.py": 41,
|
||||
"archivebox/tests/test_api_v1_cli_search.py": 26,
|
||||
"archivebox/tests/test_api_v1_cli_update.py": 100,
|
||||
"archivebox/tests/test_api_v1_cli_workflow_add_search_update_remove.py": 64,
|
||||
"archivebox/tests/test_api_v1_core_any_id.py": 21,
|
||||
"archivebox/tests/test_api_v1_core_archiveresult_archiveresult_id.py": 55,
|
||||
"archivebox/tests/test_api_v1_core_archiveresults.py": 32,
|
||||
"archivebox/tests/test_api_v1_core_snapshot_snapshot_id.py": 203,
|
||||
"archivebox/tests/test_api_v1_core_snapshots_rss.py": 27,
|
||||
"archivebox/tests/test_api_v1_core_snapshots.py": 28,
|
||||
"archivebox/tests/test_api_v1_core_tag_tag_id_rename.py": 23,
|
||||
"archivebox/tests/test_api_v1_core_tag_tag_id_snapshots_jsonl.py": 27,
|
||||
"archivebox/tests/test_api_v1_core_tag_tag_id_urls_txt.py": 28,
|
||||
"archivebox/tests/test_api_v1_core_tag_tag_id.py": 27,
|
||||
"archivebox/tests/test_api_v1_core_tags_add_to_snapshot.py": 27,
|
||||
"archivebox/tests/test_api_v1_core_tags_autocomplete.py": 26,
|
||||
"archivebox/tests/test_api_v1_core_tags_create.py": 21,
|
||||
"archivebox/tests/test_api_v1_core_tags_remove_from_snapshot.py": 25,
|
||||
"archivebox/tests/test_api_v1_core_tags_search.py": 26,
|
||||
"archivebox/tests/test_api_v1_core_tags.py": 25,
|
||||
"archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_filename.py": 22,
|
||||
"archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_folder_filename.py": 25,
|
||||
"archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_folder_subfolder_filename.py": 24,
|
||||
"archivebox/tests/test_api_v1_crawls_crawl_crawl_id.py": 86,
|
||||
"archivebox/tests/test_api_v1_crawls_crawls.py": 21,
|
||||
"archivebox/tests/test_api_v1_machine_binaries.py": 23,
|
||||
"archivebox/tests/test_api_v1_machine_binary_binary_id.py": 23,
|
||||
"archivebox/tests/test_api_v1_machine_binary_by_name_name.py": 26,
|
||||
"archivebox/tests/test_api_v1_machine_machine_current.py": 23,
|
||||
"archivebox/tests/test_api_v1_machine_machine_machine_id.py": 23,
|
||||
"archivebox/tests/test_api_v1_machine_machines.py": 27,
|
||||
"archivebox/tests/test_api_v1_personas_personas.py": 23,
|
||||
"archivebox/tests/test_api_v1_personas_sync.py": 29,
|
||||
"archivebox/tests/test_api_v1_workflow_core_token_auth_side_effects.py": 36,
|
||||
"archivebox/tests/test_api_v1_workflow_frozen_crawl_config_sources.py": 33,
|
||||
"archivebox/tests/test_archive_result_service.py": 122,
|
||||
"archivebox/tests/test_auth_ldap.py": 44,
|
||||
"archivebox/tests/test_binary_service.py": 83,
|
||||
"archivebox/tests/test_cli_add_1.py": 179,
|
||||
"archivebox/tests/test_cli_add_2.py": 167,
|
||||
"archivebox/tests/test_cli_add_3.py": 194,
|
||||
"archivebox/tests/test_cli_add_4.py": 168,
|
||||
"archivebox/tests/test_cli_archiveresult.py": 145,
|
||||
"archivebox/tests/test_cli_binary.py": 20,
|
||||
"archivebox/tests/test_cli_config.py": 141,
|
||||
"archivebox/tests/test_cli_crawl.py": 127,
|
||||
"archivebox/tests/test_cli_extract_input.py": 205,
|
||||
"archivebox/tests/test_cli_extract.py": 113,
|
||||
"archivebox/tests/test_cli_help.py": 30,
|
||||
"archivebox/tests/test_cli_init.py": 133,
|
||||
"archivebox/tests/test_cli_install.py": 58,
|
||||
"archivebox/tests/test_cli_list.py": 177,
|
||||
"archivebox/tests/test_cli_machine.py": 50,
|
||||
"archivebox/tests/test_cli_manage.py": 48,
|
||||
"archivebox/tests/test_cli_mcp.py": 51,
|
||||
"archivebox/tests/test_cli_oneshot.py": 23,
|
||||
"archivebox/tests/test_cli_persona.py": 82,
|
||||
"archivebox/tests/test_cli_piping.py": 97,
|
||||
"archivebox/tests/test_cli_pluginmap.py": 21,
|
||||
"archivebox/tests/test_cli_process.py": 20,
|
||||
"archivebox/tests/test_cli_remove.py": 150,
|
||||
"archivebox/tests/test_cli_run_1.py": 165,
|
||||
"archivebox/tests/test_cli_run_2.py": 241,
|
||||
"archivebox/tests/test_cli_run_3.py": 162,
|
||||
"archivebox/tests/test_cli_run_4.py": 200,
|
||||
"archivebox/tests/test_cli_schedule.py": 87,
|
||||
"archivebox/tests/test_cli_search.py": 35,
|
||||
"archivebox/tests/test_cli_server.py": 251,
|
||||
"archivebox/tests/test_cli_shell.py": 32,
|
||||
"archivebox/tests/test_cli_snapshot.py": 138,
|
||||
"archivebox/tests/test_cli_status.py": 105,
|
||||
"archivebox/tests/test_cli_tag.py": 21,
|
||||
"archivebox/tests/test_cli_update_reindex_snapshots.py": 144,
|
||||
"archivebox/tests/test_cli_update.py": 148,
|
||||
"archivebox/tests/test_cli_version.py": 181,
|
||||
"archivebox/tests/test_config_DELETE_AFTER.py": 192,
|
||||
"archivebox/tests/test_config_MAX_limits.py": 34,
|
||||
"archivebox/tests/test_config_ONLY_NEW.py": 24,
|
||||
"archivebox/tests/test_config_SAVE_TITLE.py": 99,
|
||||
"archivebox/tests/test_config_URL_filters.py": 26,
|
||||
"archivebox/tests/test_core_config.py": 20,
|
||||
"archivebox/tests/test_core_forms.py": 28,
|
||||
"archivebox/tests/test_crawl_pause.py": 20,
|
||||
"archivebox/tests/test_crawl_runner.py": 45,
|
||||
"archivebox/tests/test_crawl_service.py": 34,
|
||||
"archivebox/tests/test_frozen_crawl_config.py": 47,
|
||||
"archivebox/tests/test_hook_result_lifecycle.py": 78,
|
||||
"archivebox/tests/test_hooks.py": 90,
|
||||
"archivebox/tests/test_machine_models.py": 52,
|
||||
"archivebox/tests/test_machine_service.py": 104,
|
||||
"archivebox/tests/test_migrations_04_to_09.py": 36,
|
||||
"archivebox/tests/test_migrations_07_to_09.py": 140,
|
||||
"archivebox/tests/test_migrations_08_to_09.py": 257,
|
||||
"archivebox/tests/test_migrations_fresh.py": 99,
|
||||
"archivebox/tests/test_misc_checks.py": 22,
|
||||
"archivebox/tests/test_misc_monkey_patches.py": 20,
|
||||
"archivebox/tests/test_opencode_agent_1.py": 135,
|
||||
"archivebox/tests/test_opencode_agent_2.py": 138,
|
||||
"archivebox/tests/test_opencode_agent_3.py": 140,
|
||||
"archivebox/tests/test_opencode_browser_1.py": 290,
|
||||
"archivebox/tests/test_opencode_browser_2.py": 157,
|
||||
"archivebox/tests/test_opencode_browser_3.py": 129,
|
||||
"archivebox/tests/test_orm_helpers.py": 22,
|
||||
"archivebox/tests/test_permissions.py": 21,
|
||||
"archivebox/tests/test_persona_runtime.py": 56,
|
||||
"archivebox/tests/test_postgres_backend.py": 85,
|
||||
"archivebox/tests/test_process_runtime_paths.py": 28,
|
||||
"archivebox/tests/test_process_service.py": 49,
|
||||
"archivebox/tests/test_proof_previews.py": 54,
|
||||
"archivebox/tests/test_recursive_crawl_1.py": 396,
|
||||
"archivebox/tests/test_recursive_crawl_2.py": 193,
|
||||
"archivebox/tests/test_recursive_crawl_3.py": 144,
|
||||
"archivebox/tests/test_release_notes.py": 14,
|
||||
"archivebox/tests/test_release_source_fingerprint.py": 22,
|
||||
"archivebox/tests/test_release_workflow.py": 21,
|
||||
"archivebox/tests/test_resource_admission.py": 21,
|
||||
"archivebox/tests/test_runner_watch.py": 182,
|
||||
"archivebox/tests/test_search.py": 203,
|
||||
"archivebox/tests/test_serve_static.py": 31,
|
||||
"archivebox/tests/test_server_automatic_url.py": 36,
|
||||
"archivebox/tests/test_server_security_browser.py": 241,
|
||||
"archivebox/tests/test_settings_signal_webhooks.py": 21,
|
||||
"archivebox/tests/test_setup_script.py": 21,
|
||||
"archivebox/tests/test_shutdown_util.py": 22,
|
||||
"archivebox/tests/test_snapshot_filesystem_migration.py": 29,
|
||||
"archivebox/tests/test_snapshot_output_stacks.py": 62,
|
||||
"archivebox/tests/test_snapshot_ref_lookup.py": 23,
|
||||
"archivebox/tests/test_snapshot_service.py": 41,
|
||||
"archivebox/tests/test_snapshot_url_length.py": 26,
|
||||
"archivebox/tests/test_tag_service.py": 24,
|
||||
"archivebox/tests/test_takeover_util_1.py": 122,
|
||||
"archivebox/tests/test_takeover_util_2.py": 171,
|
||||
"archivebox/tests/test_takeover_util_3.py": 165,
|
||||
"archivebox/tests/test_takeover_util_4.py": 160,
|
||||
"archivebox/tests/test_test_harness.py": 22,
|
||||
"archivebox/tests/test_ui_add_view_runtime.py": 239,
|
||||
"archivebox/tests/test_ui_add_view.py": 46,
|
||||
"archivebox/tests/test_ui_admin_apitoken.py": 23,
|
||||
"archivebox/tests/test_ui_admin_archiveresult.py": 30,
|
||||
"archivebox/tests/test_ui_admin_config_widget.py": 25,
|
||||
"archivebox/tests/test_ui_admin_crawl.py": 44,
|
||||
"archivebox/tests/test_ui_admin_links.py": 35,
|
||||
"archivebox/tests/test_ui_admin_machine.py": 29,
|
||||
"archivebox/tests/test_ui_admin_persona.py": 28,
|
||||
"archivebox/tests/test_ui_admin_snapshot.py": 288,
|
||||
"archivebox/tests/test_ui_admin_tag.py": 26,
|
||||
"archivebox/tests/test_ui_admin_user.py": 28,
|
||||
"archivebox/tests/test_ui_config_views.py": 43,
|
||||
"archivebox/tests/test_ui_live_progress.py": 303,
|
||||
"archivebox/tests/test_ui_public_snapshot.py": 138,
|
||||
"archivebox/tests/test_ui_savepagenow.py": 48,
|
||||
"archivebox/tests/test_urls.py": 179,
|
||||
"archivebox/tests/test_util.py": 20,
|
||||
"archivebox/tests/test_version_metadata.py": 24
|
||||
}
|
||||
}
|
||||
@@ -18,47 +18,68 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/prepare-release.yml
|
||||
secrets: inherit
|
||||
|
||||
lint:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
name: Linters
|
||||
uses: ./.github/workflows/lint.yml
|
||||
secrets: inherit
|
||||
|
||||
install-cli-platform:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
name: Install and CLI platform compatibility
|
||||
uses: ./.github/workflows/test.yml
|
||||
secrets: inherit
|
||||
|
||||
tests:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
name: Discovered test matrix
|
||||
uses: ./.github/workflows/test-parallel.yml
|
||||
secrets: inherit
|
||||
|
||||
documentation:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
name: Documentation and root tests
|
||||
uses: ./.github/workflows/docs.yml
|
||||
secrets: inherit
|
||||
|
||||
codeql:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
name: CodeQL
|
||||
uses: ./.github/workflows/codeql.yml
|
||||
secrets: inherit
|
||||
|
||||
python-artifacts:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
name: Tested Python artifacts
|
||||
uses: ./.github/workflows/pip.yml
|
||||
secrets: inherit
|
||||
|
||||
docker-digests:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
name: Tested Docker images
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
push_digests: false
|
||||
push_digests: ${{ github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'main') }}
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
name: All required CI lanes
|
||||
if: ${{ always() }}
|
||||
if: ${{ always() && needs.prepare.outputs.run_jobs == 'true' }}
|
||||
needs:
|
||||
- prepare
|
||||
- lint
|
||||
- install-cli-platform
|
||||
- tests
|
||||
|
||||
@@ -13,6 +13,22 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_ref:
|
||||
description: Exact 40-character commit SHA to build
|
||||
required: true
|
||||
type: string
|
||||
push_digests:
|
||||
description: Push validated per-platform images by digest for the release workflow
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
full_tests:
|
||||
description: Run storage, CLI, and size validation in addition to release smoke checks
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
DOCKERHUB_IMAGE: archivebox/archivebox
|
||||
@@ -42,6 +58,18 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
ref: ${{ inputs.source_ref || github.sha }}
|
||||
|
||||
- name: Verify exact source commit
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
shell: bash
|
||||
env:
|
||||
SOURCE_REF: ${{ inputs.source_ref || github.sha }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
[[ "$SOURCE_REF" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$(git rev-parse HEAD)" == "$SOURCE_REF" ]]
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
@@ -183,7 +211,7 @@ jobs:
|
||||
{
|
||||
echo 'labels<<EOF'
|
||||
echo "org.opencontainers.image.version=${VERSION}"
|
||||
echo "org.opencontainers.image.revision=${GITHUB_SHA}"
|
||||
echo "org.opencontainers.image.revision=${{ inputs.source_ref || github.sha }}"
|
||||
echo 'org.opencontainers.image.source=https://github.com/ArchiveBox/ArchiveBox'
|
||||
echo "io.archivebox.abx-dl.image=${{ steps.abx_dl_image.outputs.image }}"
|
||||
echo 'EOF'
|
||||
@@ -203,7 +231,7 @@ jobs:
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
build-args: |
|
||||
ABX_DL_IMAGE=${{ steps.abx_dl_image.outputs.image }}
|
||||
ARCHIVEBOX_COMMIT_HASH=${{ github.sha }}
|
||||
ARCHIVEBOX_COMMIT_HASH=${{ inputs.source_ref || github.sha }}
|
||||
pull: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
# Keep the released image as the one cache authority. A second GHA
|
||||
@@ -232,7 +260,7 @@ jobs:
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
build-args: |
|
||||
ABX_DL_IMAGE=${{ steps.abx_dl_image.outputs.image }}
|
||||
ARCHIVEBOX_COMMIT_HASH=${{ github.sha }}
|
||||
ARCHIVEBOX_COMMIT_HASH=${{ inputs.source_ref || github.sha }}
|
||||
pull: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
# Test the same layer lineage users receive. Reusing :dev avoids a
|
||||
@@ -267,12 +295,13 @@ jobs:
|
||||
'
|
||||
|
||||
- name: Validate exact built image version and commit
|
||||
if: ${{ !inputs.push_digests }}
|
||||
if: inputs.full_tests
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
IMAGE="${{ matrix.local_tag }}"
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
IMAGE="${{ inputs.push_digests && format('{0}@{1}', env.DOCKERHUB_IMAGE, steps.docker_build.outputs.digest) || matrix.local_tag }}"
|
||||
SHORT_SHA="${{ (inputs.source_ref || github.sha) }}"
|
||||
SHORT_SHA="${SHORT_SHA::7}"
|
||||
|
||||
DATA_DIR="$(mktemp -d)"
|
||||
"$DOCKER_BINARY" run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox init
|
||||
@@ -395,7 +424,7 @@ jobs:
|
||||
|
||||
NFS_TEST_DIR="$NFS_MOUNT" \
|
||||
SMB_TEST_DIR="$SMB_MOUNT" \
|
||||
IMAGE="${{ matrix.local_tag }}" \
|
||||
IMAGE="${{ inputs.push_digests && format('{0}@{1}', env.DOCKERHUB_IMAGE, steps.docker_build.outputs.digest) || matrix.local_tag }}" \
|
||||
"$BASH_BINARY" bin/validate_docker_uid_gid.sh --local-only
|
||||
|
||||
- name: Validate Docker port compatibility
|
||||
@@ -408,7 +437,7 @@ jobs:
|
||||
if: inputs.full_tests && matrix.platform == 'linux/amd64'
|
||||
shell: bash
|
||||
env:
|
||||
ARCHIVEBOX_IMAGE: ${{ matrix.local_tag }}
|
||||
ARCHIVEBOX_IMAGE: ${{ inputs.push_digests && format('{0}@{1}', env.DOCKERHUB_IMAGE, steps.docker_build.outputs.digest) || matrix.local_tag }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
"$MKDIR_BINARY" "${{ github.workspace }}/data"
|
||||
@@ -523,12 +552,17 @@ jobs:
|
||||
|
||||
- name: Export digest
|
||||
if: inputs.push_digests
|
||||
env:
|
||||
ABX_DL_IMAGE_REF: ${{ steps.abx_dl_image.outputs.image }}
|
||||
ARTIFACT_NAME: ${{ matrix.artifact_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.docker_build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
printf '%s\n' '${{ inputs.source_ref || github.sha }}' > /tmp/digests/source-ref
|
||||
printf '%s\n' "$ABX_DL_IMAGE_REF" > "/tmp/digests/abx-dl-image-$ARTIFACT_NAME"
|
||||
|
||||
- name: Upload digest
|
||||
if: inputs.push_digests
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 4
|
||||
max-parallel: 8
|
||||
matrix: ${{ fromJSON(needs.discover.outputs.matrix) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
@@ -146,7 +146,9 @@ jobs:
|
||||
fi
|
||||
- name: Build the exact checkout's documentation image
|
||||
if: ${{ matrix.environment == 'docker' }}
|
||||
run: '"$DOCKER_BINARY" build --tag archivebox/archivebox:dev .'
|
||||
run: |
|
||||
ABX_DL_VERSION="$(uv run --no-cache --no-project python -c 'import tomllib; print(next(dep.removeprefix("abx-dl==") for dep in tomllib.load(open("pyproject.toml", "rb"))["project"]["dependencies"] if dep.startswith("abx-dl==")))')"
|
||||
"$DOCKER_BINARY" build --build-arg "ABX_DL_IMAGE=archivebox/abx-dl:${ABX_DL_VERSION}" --tag archivebox/archivebox:dev .
|
||||
- name: Run the Ubuntu, Docker, or macOS documentation lane
|
||||
if: ${{ !contains(fromJSON('["root", "freebsd", "openbsd"]'), matrix.environment) }}
|
||||
run: uv run --no-cache --no-sync python docs/test_codeblocks_manifest.py run-group '${{ matrix.snippet_ids }}'
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Prepare release version
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
run_jobs:
|
||||
description: Whether this revision owns its release version and should run expensive jobs
|
||||
value: ${{ jobs.prepare.outputs.run_jobs }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Prepare the version before starting expensive jobs
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
run_jobs: ${{ github.event_name != 'push' || steps.channel.outputs.stable == 'true' || steps.version.outputs.action == 'ready' || steps.version.outputs.action == 'reserved' }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
token: ${{ github.event_name == 'push' && secrets.RELEASE_GH_TOKEN || github.token }}
|
||||
|
||||
- name: Keep synchronized stable branches on the released version
|
||||
id: channel
|
||||
if: github.event_name == 'push'
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
VERSION="$(sed -nE 's/^version = "([^"]+)".*/\1/p' pyproject.toml | head -n 1)"
|
||||
git fetch --no-tags origin main
|
||||
MAIN_SHA="$(git rev-parse origin/main)"
|
||||
# Stable reconciliation may preserve dev history with a merge commit.
|
||||
# Its tree still matches main; bumping it would undo synchronization
|
||||
# before the stable publisher can advance the Docker :dev tag.
|
||||
if [[ "$GITHUB_REF_NAME" == dev && "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] &&
|
||||
git merge-base --is-ancestor "$MAIN_SHA" "$GITHUB_SHA" &&
|
||||
git diff --quiet "$MAIN_SHA" "$GITHUB_SHA"; then
|
||||
echo 'stable=true' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'stable=false' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- id: version
|
||||
if: github.event_name == 'push' && steps.channel.outputs.stable != 'true'
|
||||
uses: ArchiveBox/monorepo/.github/actions/prepare-release-version@main
|
||||
with:
|
||||
package: archivebox
|
||||
branch: ${{ github.ref_name }}
|
||||
tag-prefix: v
|
||||
version-scheme: ${{ github.ref_name == 'main' && 'patch' || 'rc' }}
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-candidate-dev
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
action: ${{ steps.version.outputs.action }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
token: ${{ secrets.RELEASE_GH_TOKEN }}
|
||||
|
||||
- id: version
|
||||
uses: ArchiveBox/monorepo/.github/actions/prepare-release-version@main
|
||||
with:
|
||||
package: archivebox
|
||||
branch: dev
|
||||
tag-prefix: v
|
||||
version-scheme: rc
|
||||
|
||||
python-artifacts:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved'
|
||||
uses: ./.github/workflows/pip.yml
|
||||
with:
|
||||
full_tests: false
|
||||
secrets: inherit
|
||||
|
||||
docker-digests:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved'
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
push_digests: true
|
||||
full_tests: false
|
||||
secrets: inherit
|
||||
|
||||
opencode:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
test_file:
|
||||
- archivebox/tests/test_opencode_agent.py
|
||||
- archivebox/tests/test_opencode_browser.py
|
||||
uses: ./.github/workflows/test-parallel.yml
|
||||
with:
|
||||
test_file: ${{ matrix.test_file }}
|
||||
secrets: inherit
|
||||
+260
-36
@@ -2,36 +2,56 @@ name: Release
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI, Release candidate]
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
branches: [dev, main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_branch:
|
||||
description: Branch being promoted
|
||||
required: true
|
||||
type: choice
|
||||
options: [main, dev]
|
||||
release_sha:
|
||||
description: Exact tested commit to release
|
||||
required: true
|
||||
type: string
|
||||
ci_run_id:
|
||||
description: Successful CI run containing the tested Python artifacts
|
||||
required: true
|
||||
type: string
|
||||
docker_digest_run_id:
|
||||
description: Manual Docker digest build run containing the tested image digests
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.repository }}-${{ github.event.workflow_run.head_branch }}
|
||||
group: release-${{ github.repository }}-${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
DOCKERHUB_IMAGE: archivebox/archivebox
|
||||
GHCR_IMAGE: ghcr.io/archivebox/archivebox
|
||||
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
RELEASE_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
CI_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
RELEASE_BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.release_branch || github.event.workflow_run.head_branch }}
|
||||
RELEASE_SHA: ${{ github.event_name == 'workflow_dispatch' && inputs.release_sha || github.event.workflow_run.head_sha }}
|
||||
|
||||
jobs:
|
||||
candidate:
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
((github.event.workflow_run.head_branch == 'dev' && github.event.workflow_run.name == 'Release candidate') ||
|
||||
(github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.name == 'CI'))
|
||||
(github.event.workflow_run.head_branch == 'dev' || github.event.workflow_run.head_branch == 'main') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.release_branch == 'main')
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
outputs:
|
||||
ready: ${{ steps.verified.outputs.ready }}
|
||||
artifact_run_id: ${{ steps.verified.outputs.artifact_run_id }}
|
||||
digest_run_id: ${{ steps.verified.outputs.digest_run_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
@@ -40,49 +60,84 @@ jobs:
|
||||
- id: owner
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$RELEASE_BRANCH" == main ]]; then
|
||||
echo 'ready=true' >> "$GITHUB_OUTPUT"
|
||||
set -Eeuo pipefail
|
||||
BRANCH_SHA="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | cut -f1)"
|
||||
if [[ "$GITHUB_EVENT_NAME" != workflow_dispatch && "$BRANCH_SHA" != "$RELEASE_SHA" ]]; then
|
||||
echo 'ready=false' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
VERSION="$(sed -nE 's/^version = "([^"]+)".*/\1/p' pyproject.toml | head -n 1)"
|
||||
if [[ "$RELEASE_BRANCH" == main ]]; then
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "Refusing to publish non-stable version $VERSION as a main release" >&2
|
||||
exit 1
|
||||
}
|
||||
git fetch --no-tags origin main
|
||||
git merge-base --is-ancestor "$RELEASE_SHA" origin/main || {
|
||||
echo "Refusing to publish $RELEASE_SHA: it is not on main" >&2
|
||||
exit 1
|
||||
}
|
||||
TAG_TARGET="$(git ls-remote origin "refs/tags/v$VERSION" | cut -f1)"
|
||||
if [[ "$GITHUB_EVENT_NAME" != workflow_dispatch && -n "$TAG_TARGET" && "$TAG_TARGET" != "$RELEASE_SHA" ]]; then
|
||||
echo 'ready=false' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo 'ready=true' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# A stable release fast-forwards dev to main. Only main publishes it;
|
||||
# the resulting dev workflow is not a new release candidate.
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+rc[0-9]+$ ]]; then
|
||||
echo 'ready=false' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
TARGET="$(git ls-remote origin "refs/tags/release-candidate/$VERSION" | awk '{print $1}')"
|
||||
[[ "$TARGET" == "$RELEASE_SHA" ]] && echo 'ready=true' >> "$GITHUB_OUTPUT" || echo 'ready=false' >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Require successful full CI for this commit
|
||||
id: verified
|
||||
if: steps.owner.outputs.ready == 'true'
|
||||
timeout-minutes: 40
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MANUAL_CI_RUN_ID: ${{ inputs.ci_run_id }}
|
||||
MANUAL_DIGEST_RUN_ID: ${{ inputs.docker_digest_run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
CI_RUN_ID=''
|
||||
for _ in {1..12}; do
|
||||
CI_RUN_ID="$(gh run list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--workflow ci.yml \
|
||||
--commit "$RELEASE_SHA" \
|
||||
--event push \
|
||||
--limit 1 \
|
||||
--json databaseId \
|
||||
--jq '.[0].databaseId // empty')"
|
||||
[[ -n "$CI_RUN_ID" ]] && break
|
||||
sleep 5
|
||||
done
|
||||
[[ -n "$CI_RUN_ID" ]]
|
||||
gh run watch "$CI_RUN_ID" --repo "$GITHUB_REPOSITORY" --exit-status
|
||||
echo 'ready=true' >> "$GITHUB_OUTPUT"
|
||||
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
|
||||
CI_RUN_ID="$MANUAL_CI_RUN_ID"
|
||||
DIGEST_RUN_ID="$MANUAL_DIGEST_RUN_ID"
|
||||
CI_STATUS="$(gh run view "$CI_RUN_ID" --repo "$GITHUB_REPOSITORY" --json status,conclusion,headSha,event,name \
|
||||
--jq '[.status, .conclusion, .headSha, .event, .name] | @tsv')"
|
||||
[[ "$CI_STATUS" == "$(printf 'completed\tsuccess\t%s\tpush\tCI' "$RELEASE_SHA")" ]]
|
||||
else
|
||||
CI_RUN_ID="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow ci.yml \
|
||||
--commit "$RELEASE_SHA" --branch "$RELEASE_BRANCH" --event push --limit 1 \
|
||||
--json databaseId,status,conclusion \
|
||||
--jq '.[0] | select(.status == "completed" and .conclusion == "success") | .databaseId // empty')"
|
||||
[[ -n "$CI_RUN_ID" ]] || exit 0
|
||||
DIGEST_RUN_ID="$CI_RUN_ID"
|
||||
fi
|
||||
ARTIFACT_RUN_ID="$CI_RUN_ID"
|
||||
{
|
||||
echo 'ready=true'
|
||||
echo "artifact_run_id=$ARTIFACT_RUN_ID"
|
||||
echo "digest_run_id=$DIGEST_RUN_ID"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
python-release:
|
||||
name: Publish tested Python artifacts
|
||||
needs: candidate
|
||||
if: needs.candidate.outputs.ready == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
CI_RUN_ID: ${{ needs.candidate.outputs.artifact_run_id }}
|
||||
DOCKER_DIGEST_RUN_ID: ${{ needs.candidate.outputs.digest_run_id }}
|
||||
environment: pypi
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
deployments: read
|
||||
id-token: write
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
@@ -106,6 +161,26 @@ jobs:
|
||||
version: "0.11.3"
|
||||
enable-cache: false
|
||||
|
||||
# This precedes every stable upload, including workflow_dispatch. A green
|
||||
# build is not evidence that our automatically updated test servers ran it.
|
||||
- name: Download tested Docker base-image metadata
|
||||
if: env.RELEASE_BRANCH == 'main'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/tested-docker-artifacts
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ env.DOCKER_DIGEST_RUN_ID }}
|
||||
|
||||
- name: Require matching acceptance on Cabbage
|
||||
if: env.RELEASE_BRANCH == 'main'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
STAGING_DOCKER_ARTIFACT_DIR: ${{ runner.temp }}/tested-docker-artifacts
|
||||
run: uv run --no-project python bin/verify-staging-release.py
|
||||
|
||||
- name: Resolve release binaries through abxpkg
|
||||
env:
|
||||
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
|
||||
@@ -154,6 +229,10 @@ jobs:
|
||||
VERSION='${{ steps.version.outputs.version }}'
|
||||
case "$RELEASE_BRANCH" in
|
||||
main)
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "Refusing to publish non-stable version $VERSION from main" >&2
|
||||
exit 1
|
||||
}
|
||||
;;
|
||||
dev)
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+rc[0-9]+$ ]] || {
|
||||
@@ -191,20 +270,92 @@ jobs:
|
||||
docker-release:
|
||||
name: Publish tested multiarch image
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
needs: python-release
|
||||
(github.event_name == 'workflow_dispatch') ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push')
|
||||
needs: [candidate, python-release]
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
DOCKER_DIGEST_RUN_ID: ${{ needs.candidate.outputs.digest_run_id }}
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout the tested commit
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
ref: ${{ env.RELEASE_SHA }}
|
||||
token: ${{ secrets.RELEASE_GH_TOKEN || github.token }}
|
||||
|
||||
- name: Reconcile dev to the tested stable release
|
||||
if: env.RELEASE_BRANCH == 'main' && needs.python-release.outputs.release_ready == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
git fetch --no-tags origin main dev
|
||||
MAIN_SHA="$(git rev-parse origin/main)"
|
||||
DEV_SHA="$(git rev-parse origin/dev)"
|
||||
if [[ "$MAIN_SHA" != "$RELEASE_SHA" || "$DEV_SHA" == "$RELEASE_SHA" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
push_dev() {
|
||||
if ! git push origin "$1:refs/heads/dev"; then
|
||||
CURRENT_DEV="$(git ls-remote origin refs/heads/dev | cut -f1)"
|
||||
if [[ "$CURRENT_DEV" == "$DEV_SHA" ]]; then
|
||||
echo "Failed to push dev although it still points to $DEV_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Leaving dev at $CURRENT_DEV: it advanced during release reconciliation"
|
||||
fi
|
||||
}
|
||||
if git merge-base --is-ancestor "$DEV_SHA" "$RELEASE_SHA"; then
|
||||
push_dev "$RELEASE_SHA"
|
||||
exit 0
|
||||
else
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -eq 1 ]] || exit "$STATUS"
|
||||
fi
|
||||
BASE_SHA="$(git merge-base "$MAIN_SHA" "$DEV_SHA")"
|
||||
if git diff --quiet "$BASE_SHA" "$DEV_SHA" -- . ':!pyproject.toml' ':!uv.lock' ':!etc/package.json'; then
|
||||
:
|
||||
else
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -eq 1 ]] || exit "$STATUS"
|
||||
echo "Leaving dev at $DEV_SHA: it contains changes beyond release metadata"
|
||||
exit 0
|
||||
fi
|
||||
normalized_metadata() {
|
||||
case "$2" in
|
||||
pyproject.toml)
|
||||
git show "$1:$2" | awk '
|
||||
/^\[/ {section = $0}
|
||||
section == "[project]" && /^version = "[^"]+"$/ {print "version = \"__VERSION__\""; next}
|
||||
section == "[tool.bumpver]" && /^current_version = "[^"]+"$/ {print "current_version = \"__VERSION__\""; next}
|
||||
{print}
|
||||
' ;;
|
||||
uv.lock)
|
||||
git show "$1:$2" | awk 'previous == "name = \"archivebox\"" && /^version = / {print "version = \"__VERSION__\""; previous = $0; next} {print; previous = $0}' ;;
|
||||
etc/package.json)
|
||||
git show "$1:$2" | sed -E 's/^( "version": )"[^"]+"(,?)$/\1"__VERSION__"\2/' ;;
|
||||
esac
|
||||
}
|
||||
for path in pyproject.toml uv.lock etc/package.json; do
|
||||
BASE_METADATA="$(normalized_metadata "$BASE_SHA" "$path")"
|
||||
DEV_METADATA="$(normalized_metadata "$DEV_SHA" "$path")"
|
||||
if [[ "$BASE_METADATA" != "$DEV_METADATA" ]]; then
|
||||
echo "Leaving dev at $DEV_SHA: $path contains changes beyond release version metadata"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
# A concurrent release bot may bump only dev's own version metadata.
|
||||
# Keep that commit as a parent while preserving the exact tested stable tree.
|
||||
git switch --detach "$MAIN_SHA"
|
||||
git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
|
||||
merge -s ours --no-ff -m "Merge tested stable release into dev" "$DEV_SHA"
|
||||
MERGE_SHA="$(git rev-parse HEAD)"
|
||||
push_dev "$MERGE_SHA"
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
@@ -227,13 +378,14 @@ jobs:
|
||||
--lib="$ABXPKG_LIB_DIR" \
|
||||
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:release_binaries" \
|
||||
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docker_binaries" >/dev/null
|
||||
for name in uv git jq docker; do
|
||||
for name in uv gh git jq docker; do
|
||||
test -L "$ABXPKG_LIB_DIR/env/bin/$name"
|
||||
test -x "$ABXPKG_LIB_DIR/env/bin/$name"
|
||||
done
|
||||
{
|
||||
echo "UV_BINARY=$ABXPKG_LIB_DIR/env/bin/uv"
|
||||
echo "GIT_BINARY=$ABXPKG_LIB_DIR/env/bin/git"
|
||||
echo "GH_BINARY=$ABXPKG_LIB_DIR/env/bin/gh"
|
||||
echo "JQ_BINARY=$ABXPKG_LIB_DIR/env/bin/jq"
|
||||
echo "DOCKER_BINARY=$ABXPKG_LIB_DIR/env/bin/docker"
|
||||
} >> "$GITHUB_ENV"
|
||||
@@ -261,16 +413,31 @@ jobs:
|
||||
merge-multiple: true
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ env.CI_RUN_ID }}
|
||||
run-id: ${{ env.DOCKER_DIGEST_RUN_ID }}
|
||||
|
||||
- name: Verify exact successful digest build run
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
RUN_STATUS="$($GH_BINARY run view "$DOCKER_DIGEST_RUN_ID" --repo "$GITHUB_REPOSITORY" --json status --jq '.status')"
|
||||
RUN_CONCLUSION="$($GH_BINARY run view "$DOCKER_DIGEST_RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion --jq '.conclusion')"
|
||||
[[ "$RUN_STATUS" == completed && "$RUN_CONCLUSION" == success ]]
|
||||
if [[ "$GITHUB_EVENT_NAME" != workflow_dispatch ]]; then
|
||||
RUN_SHA="$($GH_BINARY run view "$DOCKER_DIGEST_RUN_ID" --repo "$GITHUB_REPOSITORY" --json headSha --jq '.headSha')"
|
||||
[[ "$RUN_SHA" == "$RELEASE_SHA" ]]
|
||||
fi
|
||||
|
||||
- name: Verify digest set and collect tags
|
||||
id: docker_meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))')
|
||||
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'import re; from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file() and re.fullmatch(r"[0-9a-f]{64}", path.name))))')
|
||||
[[ "${#DIGESTS[@]}" -eq 2 ]]
|
||||
for digest in "${DIGESTS[@]}"; do [[ "$digest" =~ ^[0-9a-f]{64}$ ]]; done
|
||||
[[ "$(< /tmp/digests/source-ref)" == "$RELEASE_SHA" ]]
|
||||
|
||||
VERSION='${{ needs.python-release.outputs.version }}'
|
||||
SHORT_SHA="${RELEASE_SHA::12}"
|
||||
@@ -280,12 +447,46 @@ jobs:
|
||||
TAG_TARGET="$($GIT_BINARY ls-remote origin "refs/tags/v${VERSION}")"
|
||||
TAG_TARGET="${TAG_TARGET%%[[:space:]]*}"
|
||||
fi
|
||||
if [[ "$RELEASE_BRANCH" == main ]]; then
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "Refusing to tag :latest with prerelease $VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$TAG_TARGET" == "$RELEASE_SHA" ]] || {
|
||||
echo "Refusing to tag :latest: v$VERSION does not point to $RELEASE_SHA" >&2
|
||||
exit 1
|
||||
}
|
||||
$GIT_BINARY fetch --no-tags origin main
|
||||
$GIT_BINARY merge-base --is-ancestor "$RELEASE_SHA" origin/main || {
|
||||
echo "Refusing to tag :latest: $RELEASE_SHA is not on main" >&2
|
||||
exit 1
|
||||
}
|
||||
MAIN_VERSION="$($GIT_BINARY show origin/main:pyproject.toml | sed -nE 's/^version = "([^"]+)".*/\1/p' | head -n 1)"
|
||||
[[ "$MAIN_VERSION" == "$VERSION" ]] || {
|
||||
echo "Refusing to tag :latest: main is now $MAIN_VERSION, not $VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
SYNC_DEV=false
|
||||
if [[ "$RELEASE_BRANCH" == main ]]; then
|
||||
$GIT_BINARY fetch --no-tags origin main dev
|
||||
MAIN_TARGET="$($GIT_BINARY rev-parse origin/main)"
|
||||
DEV_TARGET="$($GIT_BINARY rev-parse origin/dev)"
|
||||
# Recovery can add CI/docs fixes after the immutable release tag.
|
||||
# Both branches and every shipped application file must agree.
|
||||
if $GIT_BINARY merge-base --is-ancestor "$MAIN_TARGET" "$DEV_TARGET" &&
|
||||
$GIT_BINARY diff --quiet "$MAIN_TARGET" "$DEV_TARGET" &&
|
||||
$GIT_BINARY diff --quiet "$RELEASE_SHA" "$MAIN_TARGET" -- . ':!.github' ':!README.md'; then
|
||||
SYNC_DEV=true
|
||||
fi
|
||||
fi
|
||||
{
|
||||
echo 'dockerhub_tags<<EOF'
|
||||
if [[ "$RELEASE_BRANCH" == dev ]]; then
|
||||
echo "${DOCKERHUB_IMAGE}:dev"
|
||||
elif [[ "$RELEASE_BRANCH" == main ]]; then
|
||||
echo "${DOCKERHUB_IMAGE}:latest"
|
||||
[[ "$SYNC_DEV" == true ]] && echo "${DOCKERHUB_IMAGE}:dev"
|
||||
fi
|
||||
echo "${DOCKERHUB_IMAGE}:sha-${SHORT_SHA}"
|
||||
[[ "$TAG_TARGET" == "$RELEASE_SHA" ]] && echo "${DOCKERHUB_IMAGE}:${VERSION}"
|
||||
@@ -295,6 +496,7 @@ jobs:
|
||||
echo "${GHCR_IMAGE}:dev"
|
||||
elif [[ "$RELEASE_BRANCH" == main ]]; then
|
||||
echo "${GHCR_IMAGE}:latest"
|
||||
[[ "$SYNC_DEV" == true ]] && echo "${GHCR_IMAGE}:dev"
|
||||
fi
|
||||
echo "${GHCR_IMAGE}:sha-${SHORT_SHA}"
|
||||
[[ "$TAG_TARGET" == "$RELEASE_SHA" ]] && echo "${GHCR_IMAGE}:${VERSION}"
|
||||
@@ -305,7 +507,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))')
|
||||
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'import re; from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file() and re.fullmatch(r"[0-9a-f]{64}", path.name))))')
|
||||
TAG_ARGS=()
|
||||
while IFS= read -r tag; do [[ -n "$tag" ]] && TAG_ARGS+=(--tag "$tag"); done <<< '${{ steps.docker_meta.outputs.dockerhub_tags }}'
|
||||
REFS=()
|
||||
@@ -316,7 +518,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))')
|
||||
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'import re; from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file() and re.fullmatch(r"[0-9a-f]{64}", path.name))))')
|
||||
TAG_ARGS=()
|
||||
while IFS= read -r tag; do [[ -n "$tag" ]] && TAG_ARGS+=(--tag "$tag"); done <<< '${{ steps.docker_meta.outputs.ghcr_tags }}'
|
||||
REFS=()
|
||||
@@ -335,6 +537,26 @@ jobs:
|
||||
test "$INSTALLED_VERSION" = "$VERSION"
|
||||
done
|
||||
|
||||
- name: Record published image source
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
# workflow_run can report the default branch's head even when this
|
||||
# run published a different branch/SHA. Preserve the verified target.
|
||||
[[ "$RELEASE_BRANCH" == dev || "$RELEASE_BRANCH" == main ]]
|
||||
[[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
mkdir -p "$RUNNER_TEMP/published-image-source"
|
||||
# shellcheck disable=SC2016
|
||||
"$JQ_BINARY" -n --arg branch "$RELEASE_BRANCH" --arg sha "$RELEASE_SHA" \
|
||||
'{branch: $branch, sha: $sha}' > "$RUNNER_TEMP/published-image-source/source.json"
|
||||
|
||||
- name: Upload published image source
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: published-image-source
|
||||
path: ${{ runner.temp }}/published-image-source/source.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Update Docker Hub README
|
||||
uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4
|
||||
with:
|
||||
@@ -346,6 +568,7 @@ jobs:
|
||||
name: Cascade verified release
|
||||
if: needs.python-release.outputs.release_ready == 'true'
|
||||
needs:
|
||||
- candidate
|
||||
- python-release
|
||||
- docker-release
|
||||
permissions:
|
||||
@@ -356,5 +579,6 @@ jobs:
|
||||
upstream_package: archivebox
|
||||
upstream_version: ${{ needs.python-release.outputs.version }}
|
||||
upstream_artifact_name: python-distributions
|
||||
upstream_run_id: ${{ needs.candidate.outputs.artifact_run_id }}
|
||||
secrets:
|
||||
release_token: ${{ secrets.RELEASE_GH_TOKEN }}
|
||||
@@ -13,6 +13,7 @@ on:
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- .github/workflows/screenshots.yml
|
||||
- .github/workflows/prepare-release.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -23,7 +24,15 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
permissions:
|
||||
contents: write
|
||||
uses: ./.github/workflows/prepare-release.yml
|
||||
secrets: inherit
|
||||
|
||||
deploy:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.run_jobs == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -49,6 +58,7 @@ jobs:
|
||||
uv run --no-cache --no-sync python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
@@ -58,6 +68,7 @@ jobs:
|
||||
assert provenance["version"] == version, provenance
|
||||
assert provenance["capture_count"] >= 3 * 35, provenance
|
||||
assert len(provenance["files"]) == provenance["capture_count"], provenance
|
||||
assert all(not re.match(r"^\d+-", name) for name in provenance["files"]), provenance
|
||||
PY
|
||||
|
||||
- name: Save validated screenshots
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
name: Staging acceptance
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Release]
|
||||
types: [completed]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: staging-acceptance
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
published:
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
actions: read
|
||||
outputs:
|
||||
ready: ${{ steps.source.outputs.ready }}
|
||||
sha: ${{ steps.source.outputs.sha }}
|
||||
steps:
|
||||
- id: image
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
published=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RELEASE_RUN_ID/jobs?per_page=100" --jq 'any(.jobs[]; .name == "Publish tested multiarch image" and .conclusion == "success")')
|
||||
echo "published=$published" >> "$GITHUB_OUTPUT"
|
||||
- name: Download published image source
|
||||
if: steps.image.outputs.published == 'true'
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: published-image-source
|
||||
path: ${{ runner.temp }}/published-image-source
|
||||
github-token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
- name: Select published dev image
|
||||
id: source
|
||||
if: steps.image.outputs.published == 'true'
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
# The triggering run may report the default branch as its head;
|
||||
# this artifact names the image source verified by the publisher.
|
||||
source="$RUNNER_TEMP/published-image-source/source.json"
|
||||
jq -e 'type == "object" and (keys | sort) == ["branch", "sha"]' "$source" >/dev/null
|
||||
branch=$(jq -er '.branch | select(type == "string")' "$source")
|
||||
sha=$(jq -er '.sha | select(type == "string")' "$source")
|
||||
[[ "$branch" == dev || "$branch" == main ]]
|
||||
[[ "$sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$branch" == dev ]]; then
|
||||
echo 'ready=true' >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo 'ready=false' >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
acceptance:
|
||||
# The privileged key is never exposed to PRs or arbitrary dispatch refs.
|
||||
needs: published
|
||||
if: needs.published.outputs.ready == 'true'
|
||||
runs-on: [self-hosted, Linux, X64, ugnas-linux]
|
||||
environment: staging
|
||||
timeout-minutes: 110
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [cabbage]
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
ref: ${{ needs.published.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e
|
||||
with:
|
||||
version: '0.11.3'
|
||||
- name: Prepare browser and restricted staging identity
|
||||
env:
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
STAGING_KNOWN_HOSTS: ${{ secrets.STAGING_KNOWN_HOSTS }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
printf '%s\n' "$STAGING_SSH_KEY" > "$RUNNER_TEMP/staging-key"
|
||||
printf '%s\n' "$STAGING_KNOWN_HOSTS" > "$RUNNER_TEMP/staging-known-hosts"
|
||||
uv run --no-project --with playwright==1.63.0 playwright install chromium
|
||||
- name: Verify actual deployment, full captures and rendered snapshots
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
STAGING_TARGET: ${{ matrix.target }}
|
||||
STAGING_SHA: ${{ needs.published.outputs.sha }}
|
||||
STAGING_KEY_FILE: ${{ runner.temp }}/staging-key
|
||||
STAGING_KNOWN_HOSTS_FILE: ${{ runner.temp }}/staging-known-hosts
|
||||
run: uv run --no-project --with playwright==1.63.0 python bin/check-staging-deployment.py
|
||||
- name: Remove staging identity
|
||||
if: always()
|
||||
run: rm -f "$RUNNER_TEMP/staging-key" "$RUNNER_TEMP/staging-known-hosts"
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
if: always()
|
||||
with:
|
||||
name: staging-${{ matrix.target }}-${{ needs.published.outputs.sha }}
|
||||
path: staging-evidence/
|
||||
if-no-files-found: error
|
||||
@@ -1,6 +1,13 @@
|
||||
name: Parallel Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
test_file:
|
||||
description: Run one discovered test file
|
||||
required: true
|
||||
default: archivebox/tests/test_cli_list.py
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
test_file:
|
||||
@@ -19,11 +26,27 @@ env:
|
||||
jobs:
|
||||
discover-tests:
|
||||
name: Discover every test
|
||||
# Selection must run on GitHub so an offline NAS cannot block the fallback.
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
tests: ${{ steps.discover.outputs.tests }}
|
||||
runner: ${{ steps.runner.outputs.use-runner || '["ubuntu-24.04"]' }}
|
||||
|
||||
steps:
|
||||
- name: Select available CI capacity
|
||||
id: runner
|
||||
if: vars.UGNAS_CI_ENABLED == 'true' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
|
||||
uses: mikehardy/runner-fallback-action@dc7732f9e532c451b2527b288ba8d58fd4b0a4ca # v1.1.0
|
||||
with:
|
||||
github-token: ${{ secrets.RUNNER_STATUS_TOKEN }}
|
||||
organization: ArchiveBox
|
||||
primary-runner: self-hosted,ugnas-linux
|
||||
fallback-runner: ubuntu-24.04
|
||||
# Jobs queued for this pool must not be mistaken for idle runner slots:
|
||||
# requiring MAX_JOBS idle runners prevents assigning a second wave.
|
||||
primaries-required: ${{ vars.UGNAS_CI_MIN_IDLE || 1 }}
|
||||
fallback-on-error: true
|
||||
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
submodules: true
|
||||
@@ -66,6 +89,7 @@ jobs:
|
||||
id: discover
|
||||
env:
|
||||
TEST_FILE: ${{ inputs.test_file }}
|
||||
UGNAS_CI_MAX_JOBS: ${{ vars.UGNAS_CI_MAX_JOBS || 3 }}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
mapfile -t output < <("$PYTHON_BINARY" .github/scripts/discover_test_matrix.py)
|
||||
@@ -80,13 +104,13 @@ jobs:
|
||||
tests:
|
||||
name: ${{ matrix.test.name }}
|
||||
needs: discover-tests
|
||||
runs-on: ubuntu-24.04
|
||||
# Opt in trusted branches only; PRs always use GitHub-hosted runners.
|
||||
# Ordinary Linux tests are eligible; # ci-runner: hosted declares exceptions.
|
||||
# The hosted discovery job falls back when workers are offline/busy or the
|
||||
# availability API fails. UGNAS_CI_ENABLED=false disables the pool entirely.
|
||||
runs-on: ${{ fromJSON(matrix.test.ugnas && needs.discover-tests.outputs.runner || '["ubuntu-24.04"]') }}
|
||||
env:
|
||||
ABXPKG_LIB_DIR: /tmp/archivebox-lib
|
||||
CHROME_HEADLESS: "true"
|
||||
DATA_DIR: /tmp/archivebox-test-data
|
||||
PERSONAS_DIR: /tmp/abx-personas
|
||||
CHROME_USER_DATA_DIR: /tmp/abx-personas/Default/chrome_profile
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -94,6 +118,15 @@ jobs:
|
||||
test: ${{ fromJson(needs.discover-tests.outputs.tests) }}
|
||||
|
||||
steps:
|
||||
- name: Set job-local runtime paths
|
||||
run: |
|
||||
{
|
||||
echo "ABXPKG_LIB_DIR=$RUNNER_TEMP/archivebox-lib"
|
||||
echo "DATA_DIR=$RUNNER_TEMP/archivebox-test-data"
|
||||
echo "PERSONAS_DIR=$RUNNER_TEMP/abx-personas"
|
||||
echo "CHROME_USER_DATA_DIR=$RUNNER_TEMP/abx-personas/Default/chrome_profile"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
submodules: true
|
||||
@@ -143,6 +176,9 @@ jobs:
|
||||
extra_args=(--extra "$TEST_EXTRA")
|
||||
fi
|
||||
uv sync --no-cache --locked --dev "${extra_args[@]}"
|
||||
- name: Install Playwright Chromium for browser tests
|
||||
if: contains(matrix.test.paths_arg, 'test_proof_previews') || contains(matrix.test.paths_arg, 'test_snapshot_output_stacks') || contains(matrix.test.paths_arg, 'test_ui_admin_snapshot') || contains(matrix.test.paths_arg, 'test_cli_extract')
|
||||
run: uv run --no-cache --no-sync --no-sources playwright install chromium
|
||||
|
||||
- name: Run ${{ matrix.test.name }}
|
||||
env:
|
||||
@@ -153,13 +189,35 @@ jobs:
|
||||
for test_path in "${test_paths[@]}"; do
|
||||
test -f "$test_path"
|
||||
done
|
||||
test_status=0
|
||||
for test_path in "${test_paths[@]}"; do
|
||||
test_name="$(basename "$test_path" .py)"
|
||||
test_slug="${test_name//[^A-Za-z0-9_.-]/_}"
|
||||
export DATA_DIR="$RUNNER_TEMP/archivebox-test-data/$test_slug"
|
||||
export PERSONAS_DIR="$RUNNER_TEMP/abx-personas/$test_slug"
|
||||
export CHROME_USER_DATA_DIR="$PERSONAS_DIR/Default/chrome_profile"
|
||||
if (( ${#test_paths[@]} > 1 )); then
|
||||
export ABXPKG_LIB_DIR="$RUNNER_TEMP/archivebox-lib/$test_slug"
|
||||
fi
|
||||
mkdir -p "$DATA_DIR" "$PERSONAS_DIR" "$RUNNER_TEMP/pytest/$test_slug"
|
||||
uv run --no-cache --no-sync --no-sources pytest -vs "$test_path" \
|
||||
--basetemp="$RUNNER_TEMP/pytest/$test_slug"
|
||||
uv run --no-cache --no-sync --no-sources pytest -vs "$test_path" --durations=20 \
|
||||
--basetemp="$RUNNER_TEMP/pytest/$test_slug" \
|
||||
--junitxml="$RUNNER_TEMP/test-results/$test_slug.xml" || test_status=1
|
||||
done
|
||||
exit "$test_status"
|
||||
- name: Save test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: test-results-${{ strategy.job-index }}
|
||||
path: ${{ runner.temp }}/test-results/*.xml
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
- name: Save failed runtime diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: runtime-logs-${{ strategy.job-index }}
|
||||
path: ${{ runner.temp }}/pytest/**/*.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
@@ -56,6 +56,8 @@ repos:
|
||||
- id: mixed-line-ending
|
||||
- id: fix-byte-order-marker
|
||||
- id: end-of-file-fixer
|
||||
# Preserve the exact bytes of captured cryptographic proof fixtures.
|
||||
exclude: ^archivebox/tests/fixtures/legacy_proofs/.*\.json$
|
||||
- id: detect-private-key
|
||||
- id: debug-statements
|
||||
- id: forbid-submodules
|
||||
|
||||
@@ -77,11 +77,7 @@ archivebox run
|
||||
|
||||
## Verification
|
||||
|
||||
Use targeted tests for focused work:
|
||||
Run the test suite with `uv run pytest archivebox/tests -x` and repository checks
|
||||
with `uv run prek run --all-files`. For focused work, select the relevant tests.
|
||||
|
||||
```bash
|
||||
uv run pytest archivebox/tests/test_cli_add.py::test_add_help_shows_depth_and_tag_options -q
|
||||
uv run prek run --all-files
|
||||
```
|
||||
|
||||
Releases are published only by `.github/workflows/release.yml` after the complete `dev` CI workflow succeeds. Local development and deployment commands must not publish packages, images, tags, or GitHub releases.
|
||||
Releases are published only by `.github/workflows/release.yml` after complete CI succeeds for the exact commit. Pushes to `main` prepare stable patch releases automatically; `dev` prepares release candidates. Version selection and dependency repinning are owned by CI and the monorepo coordinator. Local development and deployment commands must not publish packages, images, tags, or GitHub releases.
|
||||
+2
-2
@@ -40,10 +40,10 @@
|
||||
# --build-context abx-plugins=../abx-plugins \
|
||||
# -t archivebox/abx-dl:dev
|
||||
# docker buildx build . -f Dockerfile \
|
||||
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:1.12.379 \
|
||||
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:dev \
|
||||
# -t archivebox:multistage
|
||||
|
||||
ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.379
|
||||
ARG ABX_DL_IMAGE=archivebox/abx-dl:main
|
||||
|
||||
FROM archivebox/sonic:1.4.9 AS sonic
|
||||
FROM ${ABX_DL_IMAGE} AS archivebox-runtime-base
|
||||
|
||||
@@ -105,12 +105,11 @@ curl -fsSL 'https://get.archivebox.io' | bash
|
||||
</details>
|
||||
<br/>
|
||||
|
||||
|
||||
<div align="center" style="text-align: center">
|
||||
<br/><br/>
|
||||
<img src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/5a7d95f2-6977-4de6-9f08-42851a1fe1d2" height="70px" alt="bookshelf graphic"> <img src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/b2765a33-0d1e-4019-a1db-920c7e00e20e" height="75px" alt="logo" align="top"/> <img src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/5a7d95f2-6977-4de6-9f08-42851a1fe1d2" height="70px" alt="bookshelf graphic">
|
||||
<br/><br/>
|
||||
<small><a href="https://demo.archivebox.io">Demo</a> | <a href="#screenshots">Screenshots</a> | <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage">Usage</a></small>
|
||||
<small><a href="https://demo.archivebox.io">Demo</a> | <a href="https://archivebox.io/screenshots">Screenshots</a> | <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage">Usage</a></small>
|
||||
<br/>
|
||||
<sub>. . . . . . . . . . . . . . . . . . . . . . . . . . . .</sub>
|
||||
<br/><br/>
|
||||
@@ -381,7 +380,7 @@ See <a href="#%EF%B8%8F-cli-usage">below</a> for usage examples using the CLI, W
|
||||
> *Warning: These are contributed by external volunteers and may lag behind the official Docker and `uv` channels.*
|
||||
|
||||
<ul>
|
||||
<li><s>TrueNAS: <a href="https://truecharts.org/charts/stable/archivebox/">Official ArchiveBox TrueChart</a> / <a href="https://dev.to/finloop/setting-up-archivebox-on-truenas-scale-1788">Custom App Guide</a></s> (<a href="https://truecharts.org/news/scale-deprecation/">TrueCharts is discontinued</a>, wait for <a href="https://forums.truenas.com/t/the-future-of-electric-eel-and-apps/5409/">Electric Eel</a>)</li>
|
||||
<li><a href="https://apps.truenas.com/catalog/archivebox_community/">TrueNAS</a></li>
|
||||
<li><a href="https://unraid.net/community/apps?q=archivebox#r">UnRaid</a></li>
|
||||
<li><a href="https://community-scripts.github.io/ProxmoxVE/scripts?id=archivebox">Proxmox</a></li>
|
||||
<li><a href="https://github.com/YunoHost-Apps/archivebox_ynh">Yunohost</a></li>
|
||||
@@ -682,8 +681,6 @@ It also includes a built-in scheduled import feature with `archivebox schedule`,
|
||||
|
||||
## Output Formats: What ArchiveBox saves for each URL
|
||||
|
||||
Snapshot detail pages group output cards into expandable stacks: HTML, Raster, Article text, Embedded media, OCR, and Metadata. HTML, Raster, Article text, and OCR use the preference order defined in [`archivebox/plugins/output_groups.py`](archivebox/plugins/output_groups.py); media and metadata put the largest output directory first. Unclassified plugins and auxiliary files remain available under Other files. The same layout is included in portable static exports.
|
||||
|
||||
<img src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/ace0954a-ddac-4520-9d18-1c77b1ec50b2" width="330px" align="right" style="float: right"/>
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ class APIConfig(AppConfig):
|
||||
name = "archivebox.api"
|
||||
label = "api"
|
||||
|
||||
def ready(self):
|
||||
from archivebox.api.webhooks import register_delete_webhook_field_loader
|
||||
|
||||
register_delete_webhook_field_loader()
|
||||
|
||||
|
||||
def register_admin(admin_site):
|
||||
from archivebox.api.admin import register_admin
|
||||
|
||||
@@ -32,7 +32,7 @@ from archivebox.core.models import Snapshot, ArchiveResult, Tag
|
||||
from archivebox.core.permissions import public_snapshots_queryset
|
||||
from archivebox.api.auth import authenticated_user_from_request
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.core.routes_util import build_web_url
|
||||
from archivebox.core.routes_util import build_snapshot_detail_url, build_web_url
|
||||
from archivebox.misc.util import filter_queryset_by_uuid_substring, validate_url_length
|
||||
from archivebox.core.tag_util import (
|
||||
add_snapshot_counts,
|
||||
@@ -800,7 +800,7 @@ def _snapshots_rss_response(
|
||||
)
|
||||
|
||||
for snapshot in snapshots:
|
||||
archived_url = build_web_url(f"/{snapshot.archive_path_from_db}", request=request)
|
||||
archived_url = build_snapshot_detail_url(snapshot.archive_path_from_db, request=request)
|
||||
tags = [tag.name for tag in snapshot.tags.all()]
|
||||
crawl_user = snapshot.crawl.created_by if snapshot.crawl_id else None
|
||||
description = f"Original URL: {snapshot.url}\nArchived snapshot: {archived_url}"
|
||||
|
||||
@@ -182,6 +182,14 @@ def crawl_file(request: HttpRequest, crawl_id: str, path: str):
|
||||
if not file_path.is_file() or crawl_root not in file_path.parents:
|
||||
raise HttpError(404, "Crawl file not found")
|
||||
|
||||
if path == "chrome_screencast/latest.jpg" and request.GET.get("after"):
|
||||
try:
|
||||
started_at = float(request.GET["after"])
|
||||
except ValueError:
|
||||
raise HttpError(400, "Invalid screencast start time")
|
||||
if file_path.stat().st_mtime < started_at:
|
||||
raise HttpError(404, "No frame for this capture yet")
|
||||
|
||||
response = FileResponse(file_path.open("rb"))
|
||||
response["Cache-Control"] = "no-store, no-cache, max-age=0, must-revalidate"
|
||||
response["Pragma"] = "no-cache"
|
||||
|
||||
@@ -2,7 +2,7 @@ __package__ = "archivebox.api"
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from django.db.models import Q
|
||||
@@ -23,6 +23,7 @@ class PersonaBrowserSettingsSchema(Schema):
|
||||
user_agent: str = ""
|
||||
viewport_size: str = ""
|
||||
viewport_device_scale_factor: float | None = None
|
||||
color_scheme: Literal["", "light", "dark"] = ""
|
||||
language: str = ""
|
||||
timezone: str = ""
|
||||
geolocation: dict[str, Any] | None = None
|
||||
@@ -94,6 +95,8 @@ def browser_settings_to_config(extension_persona_id: str, settings: PersonaBrows
|
||||
)
|
||||
if settings.viewport_device_scale_factor is not None:
|
||||
config["BROWSER_DEVICE_SCALE_FACTOR"] = settings.viewport_device_scale_factor
|
||||
if "color_scheme" in settings.model_fields_set:
|
||||
config["BROWSER_COLOR_SCHEME"] = settings.color_scheme
|
||||
if settings.language:
|
||||
config["BROWSER_LANGUAGE"] = settings.language
|
||||
if settings.timezone:
|
||||
@@ -150,12 +153,12 @@ def sync_persona(request: HttpRequest, payload: PersonaSyncSchema):
|
||||
persona.ensure_dirs()
|
||||
|
||||
cookies_written = False
|
||||
if payload.cookies_txt.strip():
|
||||
if "cookies_txt" in payload.model_fields_set:
|
||||
(persona.path / "cookies.txt").write_text(payload.cookies_txt)
|
||||
cookies_written = True
|
||||
|
||||
auth_written = False
|
||||
if payload.auth_json:
|
||||
if "auth_json" in payload.model_fields_set:
|
||||
(persona.path / "auth.json").write_text(json.dumps(payload.auth_json, indent=2, sort_keys=True) + "\n")
|
||||
auth_written = True
|
||||
|
||||
|
||||
@@ -5,12 +5,40 @@ from collections.abc import Callable
|
||||
import logging
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import pre_delete
|
||||
from signal_webhooks.handlers import sync_task_handler
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_deferred_fields_for_delete_webhook(sender, instance, using, **kwargs) -> None:
|
||||
"""Materialize deferred fields while DELETE webhook rows still exist.
|
||||
|
||||
Admin lists defer large fields, but signal_webhooks serializes all concrete
|
||||
fields in post_delete. Loading one then would query an already-deleted row
|
||||
and drop the event. Hydrate only deferred fields on webhook-enabled models
|
||||
here, preserving the normal list-query optimization.
|
||||
"""
|
||||
from signal_webhooks.handlers import find_hook_handler
|
||||
from signal_webhooks.utils import reference_for_model
|
||||
|
||||
ref = reference_for_model(type(instance))
|
||||
deferred_fields = instance.get_deferred_fields()
|
||||
if not deferred_fields or find_hook_handler(ref, "DELETE") is None:
|
||||
return
|
||||
|
||||
instance.refresh_from_db(using=using, fields=deferred_fields)
|
||||
|
||||
|
||||
def register_delete_webhook_field_loader() -> None:
|
||||
pre_delete.connect(
|
||||
load_deferred_fields_for_delete_webhook,
|
||||
dispatch_uid="archivebox.load_deferred_fields_for_delete_webhook",
|
||||
weak=False,
|
||||
)
|
||||
|
||||
|
||||
def warning_error_handler(hook: Any, error: Exception | None) -> None:
|
||||
if error is not None:
|
||||
logger.warning("Outbound webhook %r failed: %s", hook.name, error)
|
||||
|
||||
@@ -155,13 +155,10 @@ def cli(ctx, help=False):
|
||||
wants_help = any(arg in ("-h", "--help", "--version") for arg in sys.argv[1:])
|
||||
if not wants_help and (subcommand in ArchiveBoxGroup.archive_commands or subcommand in ArchiveBoxGroup.model_commands):
|
||||
try:
|
||||
if subcommand == "server":
|
||||
run_in_debug = "--reload" in sys.argv or os.environ.get("DEBUG") in ("1", "true", "True", "TRUE", "yes")
|
||||
if run_in_debug:
|
||||
os.environ["ARCHIVEBOX_RUNSERVER"] = "1"
|
||||
if "--reload" in sys.argv:
|
||||
os.environ["ARCHIVEBOX_AUTORELOAD"] = "1"
|
||||
|
||||
# RUNSERVER_WORKER sets the web-worker markers on its own child.
|
||||
# Setting them on the server parent leaks them through supervisord
|
||||
# into runners waiting for takeover, which then register themselves
|
||||
# as web workers and trigger spurious runner_watch reloads.
|
||||
from archivebox.config.django import setup_django
|
||||
from archivebox.misc.checks import check_data_folder, check_migrations
|
||||
|
||||
@@ -177,8 +174,12 @@ def cli(ctx, help=False):
|
||||
archivebox_init(quick=True)
|
||||
os.environ.pop("ARCHIVEBOX_WANTS_INIT", None)
|
||||
check_data_folder()
|
||||
if subcommand != "update":
|
||||
check_migrations(auto_apply=True)
|
||||
if subcommand != "update" and not check_migrations(auto_apply=True):
|
||||
from archivebox.machine.models import Machine
|
||||
|
||||
# Host registration and config reconciliation belong to CLI
|
||||
# startup; get_config() is also used by read-only HTTP views.
|
||||
Machine.current()
|
||||
except Exception as e:
|
||||
STDERR.print(f"[red][X] Error setting up Django or checking data folder: {e}[/red]")
|
||||
if subcommand not in ("manage", "shell"): # not all management commands need django to be setup beforehand
|
||||
|
||||
@@ -6,6 +6,7 @@ __command__ = "archivebox add"
|
||||
|
||||
import sys
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
from typing import Any, TYPE_CHECKING
|
||||
@@ -227,6 +228,10 @@ def add(
|
||||
|
||||
assert command is not None
|
||||
exit_code = 0
|
||||
interactive_interrupts = sys.stdin.isatty() and (sys.stdout.isatty() or sys.stderr.isatty())
|
||||
|
||||
def interrupt_hook() -> None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
def crawl_is_complete() -> bool:
|
||||
crawl.refresh_from_db(fields=["status"])
|
||||
@@ -234,7 +239,13 @@ def add(
|
||||
|
||||
try:
|
||||
try:
|
||||
with foreground_shutdown_signals(first_signal_message=None), foreground_parent_watchdog():
|
||||
with (
|
||||
foreground_shutdown_signals(
|
||||
first_signal_message=None,
|
||||
interrupt_handlers={signal.SIGINT: interrupt_hook},
|
||||
),
|
||||
foreground_parent_watchdog(),
|
||||
):
|
||||
while True:
|
||||
standby = standby_until_foreground_runner_needed(
|
||||
command,
|
||||
@@ -246,7 +257,7 @@ def add(
|
||||
exit_code = run_runner_worker(
|
||||
["--crawl-id", str(crawl.id)],
|
||||
name=f"worker_runner_add_{os.getpid()}",
|
||||
interactive_interrupts=True,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
config=get_config(crawl=crawl),
|
||||
)
|
||||
crawl.refresh_from_db(fields=["status", "retry_at"])
|
||||
@@ -311,9 +322,13 @@ def add(
|
||||
|
||||
admin_url = build_admin_url(f"/admin/crawls/crawl/{crawl.id}/change/", config=config)
|
||||
|
||||
print("\n[bold]crawl output saved to:[/bold]")
|
||||
print(f" {rel_output_str}")
|
||||
print(f" {admin_url}")
|
||||
if snapshots_count:
|
||||
print("\n[bold]snapshot output saved to:[/bold]")
|
||||
for snapshot in crawl.snapshot_set.order_by("created_at").iterator():
|
||||
click.echo(f" {snapshot.output_dir}")
|
||||
print("\n[bold]crawl logs and setup:[/bold]")
|
||||
click.echo(f" {rel_output_str}")
|
||||
click.echo(f" {admin_url}")
|
||||
print(f"\n[bold]total urls snapshotted:[/bold] {snapshots_count}")
|
||||
print(f"[bold]total size:[/bold] {total_size}")
|
||||
print(f"[bold]total time:[/bold] {duration_str}")
|
||||
|
||||
@@ -111,6 +111,11 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
|
||||
else:
|
||||
os.environ["ARCHIVEBOX_WANTS_INIT"] = previous_wants_init
|
||||
|
||||
from archivebox.machine.models import Machine
|
||||
|
||||
# Registration requires the final schema and reconciles legacy file config.
|
||||
Machine.current()
|
||||
|
||||
from archivebox.misc.db import database_display_location, is_postgres
|
||||
|
||||
assert database_exists()
|
||||
|
||||
@@ -54,12 +54,9 @@ RUNNER_DAEMON_ENV = "ARCHIVEBOX_RUNNER_DAEMON"
|
||||
|
||||
|
||||
def _exit_daemon_runner_on_signal(sig: signal.Signals) -> None:
|
||||
# A supervised `archivebox run --daemon` is intentionally a disposable
|
||||
# child. If it receives SIGINT/SIGTERM directly, exit with the conventional
|
||||
# signal status so supervisord treats it as an unexpected worker death and
|
||||
# restarts only the runner. The parent `archivebox server` owns supervisord
|
||||
# shutdown and must not be pulled down by a killed daemon worker.
|
||||
os._exit(128 + int(sig))
|
||||
# A supervised daemon must exit non-zero so supervisord restarts it, while
|
||||
# still unwinding the active crawl's hooks and persisted process state.
|
||||
raise SystemExit(128 + sig.value)
|
||||
|
||||
|
||||
def process_stdin_records() -> int:
|
||||
@@ -285,19 +282,26 @@ def run_runner(
|
||||
# future ownership lease cannot hide it from the unified scheduler.
|
||||
if crawl is not None:
|
||||
crawl.update_and_requeue(retry_at=now, refresh=False)
|
||||
# Only a foreground `archivebox add` gets the interactive "abort current
|
||||
# hook, continue/retry, second Ctrl+C exits" flow. Server/update/run owned
|
||||
# orchestrators should shut down immediately and cleanly on the first signal.
|
||||
interactive_interrupts = current.root.process_type == Process.TypeChoices.ADD
|
||||
# Interactivity follows terminal ownership, not the spelling of the command.
|
||||
# A supervised add worker has pipes and receives terminal intent/answers from
|
||||
# its parent; direct run owns the terminal itself. Daemons and redirected
|
||||
# commands must never wait for a person who cannot answer their prompt.
|
||||
# Trust the explicit terminal-owner flag, not Process.root: when add borrows
|
||||
# a server-owned supervisord, its worker's OS ancestry still leads to server.
|
||||
# Gating on root=ADD silently made the first Ctrl+C abort after takeover.
|
||||
interactive_interrupts = not daemon and (
|
||||
(sys.stdin.isatty() and (sys.stdout.isatty() or sys.stderr.isatty())) or os.environ.get("ARCHIVEBOX_INTERACTIVE_INTERRUPTS") == "1"
|
||||
)
|
||||
if daemon:
|
||||
os.environ[RUNNER_DAEMON_ENV] = "1"
|
||||
|
||||
shutdown_state = None
|
||||
try:
|
||||
with (
|
||||
foreground_shutdown_signals(
|
||||
on_signal=_exit_daemon_runner_on_signal if daemon else None,
|
||||
raise_on_first_signal=not daemon,
|
||||
),
|
||||
) as shutdown_state,
|
||||
foreground_parent_watchdog(enabled=not daemon),
|
||||
):
|
||||
run_pending_crawls(
|
||||
@@ -306,10 +310,16 @@ def run_runner(
|
||||
maintenance_only=maintenance_only,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
)
|
||||
if daemon and shutdown_state.signal_name:
|
||||
return 128 + signal.Signals[shutdown_state.signal_name].value
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
if daemon and shutdown_state and shutdown_state.signal_name:
|
||||
return 128 + signal.Signals[shutdown_state.signal_name].value
|
||||
return 130
|
||||
except asyncio.CancelledError as e:
|
||||
if daemon and shutdown_state and shutdown_state.signal_name:
|
||||
return 128 + signal.Signals[shutdown_state.signal_name].value
|
||||
if daemon:
|
||||
rprint(f"[red]Runner cancelled unexpectedly: {type(e).__name__}: {e}[/red]", file=sys.stderr)
|
||||
return 1
|
||||
@@ -362,11 +372,11 @@ def main(
|
||||
):
|
||||
sys.exit(run_runner(daemon=True, maintenance_only=maintenance_only))
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
sys.exit(130)
|
||||
|
||||
with foreground_shutdown_signals(), foreground_parent_watchdog(enabled=not daemon):
|
||||
if snapshot_id:
|
||||
sys.exit(run_snapshot_worker(snapshot_id))
|
||||
sys.exit(run_snapshot_worker(snapshot_id, daemon=daemon))
|
||||
|
||||
if binary_id:
|
||||
try:
|
||||
@@ -386,7 +396,7 @@ def main(
|
||||
if crawl_id:
|
||||
sys.exit(
|
||||
run_runner(
|
||||
daemon=False,
|
||||
daemon=daemon,
|
||||
crawl_id=crawl_id,
|
||||
maintenance_only=maintenance_only,
|
||||
),
|
||||
@@ -401,7 +411,7 @@ def main(
|
||||
sys.exit(run_runner(daemon=daemon, maintenance_only=maintenance_only))
|
||||
|
||||
|
||||
def run_snapshot_worker(snapshot_id: str) -> int:
|
||||
def run_snapshot_worker(snapshot_id: str, *, daemon: bool = False) -> int:
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.core.takeover_util import enter_single_runner_gate
|
||||
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
|
||||
@@ -416,6 +426,7 @@ def run_snapshot_worker(snapshot_id: str) -> int:
|
||||
return 0
|
||||
|
||||
snapshot = None
|
||||
interactive_interrupts = not daemon and sys.stdin.isatty() and (sys.stdout.isatty() or sys.stderr.isatty())
|
||||
try:
|
||||
with foreground_shutdown_signals(), foreground_parent_watchdog():
|
||||
for _ in range(10):
|
||||
@@ -424,7 +435,7 @@ def run_snapshot_worker(snapshot_id: str) -> int:
|
||||
snapshot.update_and_requeue(retry_at=timezone.now())
|
||||
elif snapshot.retry_at > timezone.now():
|
||||
break
|
||||
if not run_due_snapshot(snapshot, lock_seconds=60):
|
||||
if not run_due_snapshot(snapshot, lock_seconds=60, interactive_interrupts=interactive_interrupts):
|
||||
break
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
@@ -437,7 +448,7 @@ def run_snapshot_worker(snapshot_id: str) -> int:
|
||||
snapshot.update_and_requeue(retry_at=timezone.now())
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
return 130
|
||||
except Exception as e:
|
||||
rprint(f"[red]Runner error: {type(e).__name__}: {e}[/red]", file=sys.stderr)
|
||||
import traceback
|
||||
|
||||
@@ -80,20 +80,6 @@ def _render_binary_abspath(abspath: str):
|
||||
return Text(abspath, style="green")
|
||||
|
||||
|
||||
def _binary_record_matches_runtime(installed, lib_dir: Path) -> bool:
|
||||
if not installed or not installed.is_valid or not installed.version:
|
||||
return False
|
||||
try:
|
||||
abspath = Path(installed.abspath).expanduser().resolve(strict=False)
|
||||
if not abspath.exists():
|
||||
return False
|
||||
if installed.binprovider not in {"", "env", "apt", "brew"}:
|
||||
abspath.relative_to(lib_dir)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _binary_row_dedupe_key(
|
||||
*,
|
||||
display_name: str,
|
||||
@@ -127,7 +113,6 @@ def version(
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
from abx_dl.tables import binary_dependency_status, binary_dependency_table
|
||||
|
||||
from archivebox.config import CONSTANTS
|
||||
@@ -263,7 +248,6 @@ def version(
|
||||
prnt("[pale_green1][i] Binary Dependencies:[/pale_green1]")
|
||||
failures = []
|
||||
seen_failures: set[str] = set()
|
||||
seen_rows: set[tuple[str, str, str, str]] = set()
|
||||
|
||||
from archivebox.plugins.discovery import get_enabled_plugins, get_plugin_catalog
|
||||
from abx_dl.config import get_required_binary_requests
|
||||
@@ -272,37 +256,29 @@ def version(
|
||||
from abxpkg.binary_service import BinaryEvent, BinaryService
|
||||
|
||||
plugins = get_plugin_catalog()
|
||||
enabled_plugins = plugins.select(get_enabled_plugins(config=config))
|
||||
enabled_plugin_names = set(enabled_plugins)
|
||||
runtime_config = normalize_runtime_config(config.for_crawl(), json_safe=False)
|
||||
enabled_plugin_names = set(get_enabled_plugins(config=config))
|
||||
runtime_config = normalize_runtime_config(dict(config.items()), json_safe=False)
|
||||
derived_config: dict[str, object] = {}
|
||||
db_binaries = {}
|
||||
db_available = False
|
||||
if in_data_dir:
|
||||
try:
|
||||
from archivebox.config.django import setup_django
|
||||
|
||||
setup_django()
|
||||
|
||||
from archivebox.machine.models import Machine, Binary
|
||||
from archivebox.machine.models import Machine
|
||||
|
||||
machine = Machine.current()
|
||||
derived_config = normalize_runtime_config(machine.config, json_safe=False)
|
||||
for binary in Binary.objects.filter(machine=machine).order_by("name", "-modified_at"):
|
||||
if _binary_record_matches_runtime(binary, config.ABXPKG_LIB_DIR):
|
||||
db_binaries.setdefault(binary.name, binary)
|
||||
db_available = True
|
||||
|
||||
except Exception as e:
|
||||
prnt()
|
||||
prnt("", f"[yellow]Warning: Could not query collection binary records; resolving through abxpkg: {e}[/yellow]")
|
||||
prnt("", f"[yellow]Warning: Could not query collection machine config; resolving through abxpkg: {e}[/yellow]")
|
||||
|
||||
declared_binary_specs: dict[str, dict[str, object]] = {}
|
||||
for plugin_name, plugin in plugins.items():
|
||||
if not plugin_may_have_requested_binary(plugin):
|
||||
continue
|
||||
plugin_requested = bool(requested_names)
|
||||
plugin_enabled = plugin_name in enabled_plugin_names
|
||||
plugin_requested = plugin_name.lower() in {name.lower() for name in requested_names}
|
||||
binary_records = get_required_binary_requests(
|
||||
plugin,
|
||||
plugin.config.required_binaries,
|
||||
@@ -320,10 +296,9 @@ def version(
|
||||
display_name = logical_name
|
||||
if not plugin_requested and not binary_is_requested(logical_name, actual_name, display_name):
|
||||
continue
|
||||
if not plugin_enabled and not requested_names:
|
||||
continue
|
||||
if _binary_record_matches_runtime(db_binaries.get(logical_name), config.ABXPKG_LIB_DIR):
|
||||
continue
|
||||
# This is a diagnostic: recheck the provider instead of trusting an
|
||||
# installation record or a cached version, including disabled plugins.
|
||||
binary_record["no_cache"] = True
|
||||
signature = json.dumps(binary_record, sort_keys=True, default=str)
|
||||
declared_binary_specs.setdefault(signature, binary_record)
|
||||
|
||||
@@ -341,126 +316,47 @@ def version(
|
||||
|
||||
loaded_binaries = asyncio.run(resolve_declared_binaries())
|
||||
|
||||
rows: list[dict[str, object]] = []
|
||||
rows_by_binary: dict[str, dict] = {}
|
||||
any_rows = False
|
||||
any_available = False
|
||||
compact_paths = console.is_terminal
|
||||
live_enabled = console.is_terminal
|
||||
live_cm = Live(binary_dependency_table(rows), console=console, refresh_per_second=8) if live_enabled else None
|
||||
|
||||
def emit_row(row: dict[str, object]) -> None:
|
||||
rows.append(row)
|
||||
if live_cm is not None:
|
||||
live_cm.update(binary_dependency_table(rows), refresh=True)
|
||||
|
||||
if live_cm is not None:
|
||||
live_cm.start()
|
||||
try:
|
||||
for plugin_name, plugin in plugins.items():
|
||||
if not plugin_may_have_requested_binary(plugin):
|
||||
continue
|
||||
plugin_requested = bool(requested_names)
|
||||
plugin_enabled = plugin_name in enabled_plugin_names
|
||||
binary_records = get_required_binary_requests(
|
||||
plugin,
|
||||
plugin.config.required_binaries,
|
||||
overrides=runtime_config,
|
||||
derived_overrides=derived_config,
|
||||
run_output_dir=CONSTANTS.DATA_DIR,
|
||||
for plugin_name, plugin in plugins.items():
|
||||
if not plugin_may_have_requested_binary(plugin):
|
||||
continue
|
||||
plugin_requested = plugin_name.lower() in {name.lower() for name in requested_names}
|
||||
plugin_enabled = plugin_name in enabled_plugin_names
|
||||
disabled_by = ", ".join(
|
||||
f"{dependency.enabled_key}=False"
|
||||
for dependency in plugins.select([plugin_name]).values()
|
||||
if not config.get(dependency.enabled_key, True)
|
||||
)
|
||||
binary_records = get_required_binary_requests(
|
||||
plugin,
|
||||
plugin.config.required_binaries,
|
||||
overrides=runtime_config,
|
||||
derived_overrides=derived_config,
|
||||
run_output_dir=CONSTANTS.DATA_DIR,
|
||||
)
|
||||
for binary_record in binary_records:
|
||||
binary_record["no_cache"] = True
|
||||
actual_name = str(binary_record["name"])
|
||||
logical_name = (
|
||||
Path(actual_name).expanduser().name
|
||||
if ("/" in actual_name or "\\" in actual_name or actual_name.startswith("~"))
|
||||
else actual_name
|
||||
)
|
||||
for binary_record in binary_records:
|
||||
actual_name = str(binary_record["name"])
|
||||
logical_name = (
|
||||
Path(actual_name).expanduser().name
|
||||
if ("/" in actual_name or "\\" in actual_name or actual_name.startswith("~"))
|
||||
else actual_name
|
||||
)
|
||||
display_name = logical_name
|
||||
if not plugin_requested and not binary_is_requested(logical_name, actual_name, display_name):
|
||||
continue
|
||||
display_name = logical_name
|
||||
if not plugin_requested and not binary_is_requested(logical_name, actual_name, display_name):
|
||||
continue
|
||||
|
||||
installed = db_binaries.get(logical_name) if db_available else None
|
||||
if _binary_record_matches_runtime(installed, config.ABXPKG_LIB_DIR):
|
||||
abspath = installed.abspath
|
||||
version_str = (installed.version or "unknown")[:15]
|
||||
provider = (installed.binprovider or "env")[:8]
|
||||
valid = True
|
||||
elif not plugin_enabled and not requested_names:
|
||||
# `archivebox version` is expected to verify the active runtime, not
|
||||
# cold-load every optional plugin provider. Migration and status
|
||||
# checks often run with PLUGINS narrowed to a tiny set; resolving
|
||||
# disabled plugin binaries there can spend most of the command on
|
||||
# providers the current collection will never execute.
|
||||
continue
|
||||
else:
|
||||
loaded = loaded_binaries[json.dumps(binary_record, sort_keys=True, default=str)]
|
||||
abspath = loaded.abspath if loaded is not None else ""
|
||||
version_str = str(loaded.version or "unknown")[:15] if loaded is not None else "unknown"
|
||||
provider = str(loaded.binprovider or "env")[:8] if loaded is not None else "env"
|
||||
valid = loaded is not None
|
||||
|
||||
any_rows = True
|
||||
if valid:
|
||||
display_path = (
|
||||
_format_binary_abspath(
|
||||
abspath,
|
||||
pwd=Path.cwd(),
|
||||
lib_dir=config.ABXPKG_LIB_DIR,
|
||||
personas_dir=CONSTANTS.PERSONAS_DIR,
|
||||
home=Path.home(),
|
||||
)
|
||||
if compact_paths
|
||||
else abspath
|
||||
)
|
||||
rendered_path = _render_binary_abspath(display_path) if compact_paths else display_path
|
||||
any_available = True
|
||||
else:
|
||||
rendered_path = "[grey53]not installed[/grey53]"
|
||||
if plugin_enabled and display_name not in seen_failures:
|
||||
failures.append(display_name)
|
||||
seen_failures.add(display_name)
|
||||
|
||||
row_key = _binary_row_dedupe_key(
|
||||
display_name=display_name,
|
||||
valid=valid,
|
||||
version=version_str if valid else "-",
|
||||
provider=provider if valid else "-",
|
||||
abspath=abspath,
|
||||
)
|
||||
if row_key in seen_rows:
|
||||
continue
|
||||
seen_rows.add(row_key)
|
||||
|
||||
emit_row(
|
||||
{
|
||||
"plugin": plugin_name,
|
||||
"status": binary_dependency_status(enabled=plugin_enabled, valid=valid),
|
||||
"binary": display_name,
|
||||
"version": version_str if valid else "-",
|
||||
"provider": provider if valid else "-",
|
||||
"path": rendered_path,
|
||||
"style": "" if plugin_enabled else "dim",
|
||||
},
|
||||
)
|
||||
|
||||
if db_available:
|
||||
for binary_name, installed in db_binaries.items():
|
||||
if requested_names and binary_name not in requested_names:
|
||||
continue
|
||||
abspath = installed.abspath
|
||||
version_str = (installed.version or "unknown")[:15]
|
||||
provider = (installed.binprovider or "env")[:8]
|
||||
row_key = _binary_row_dedupe_key(
|
||||
display_name=binary_name,
|
||||
valid=True,
|
||||
version=version_str,
|
||||
provider=provider,
|
||||
abspath=abspath,
|
||||
)
|
||||
if row_key in seen_rows:
|
||||
continue
|
||||
seen_rows.add(row_key)
|
||||
loaded = loaded_binaries[json.dumps(binary_record, sort_keys=True, default=str)]
|
||||
abspath = loaded.abspath if loaded is not None else ""
|
||||
version_str = str(loaded.version or "unknown") if loaded is not None else "unknown"
|
||||
provider = str(loaded.binprovider or "env") if loaded is not None else "env"
|
||||
valid = loaded is not None and Path(abspath).is_file()
|
||||
|
||||
any_rows = True
|
||||
if valid:
|
||||
display_path = (
|
||||
_format_binary_abspath(
|
||||
abspath,
|
||||
@@ -472,24 +368,67 @@ def version(
|
||||
if compact_paths
|
||||
else abspath
|
||||
)
|
||||
emit_row(
|
||||
{
|
||||
"plugin": "(database)",
|
||||
"status": binary_dependency_status(enabled=True, valid=True),
|
||||
"binary": binary_name,
|
||||
"version": version_str,
|
||||
"provider": provider,
|
||||
"path": _render_binary_abspath(display_path) if compact_paths else display_path,
|
||||
"style": "dim",
|
||||
},
|
||||
)
|
||||
any_rows = True
|
||||
rendered_path = _render_binary_abspath(display_path) if compact_paths else display_path
|
||||
any_available = True
|
||||
finally:
|
||||
if live_cm is not None:
|
||||
live_cm.stop()
|
||||
else:
|
||||
prnt(binary_dependency_table(rows))
|
||||
else:
|
||||
rendered_path = "[grey53]not installed[/grey53]"
|
||||
if plugin_enabled and display_name not in seen_failures:
|
||||
failures.append(display_name)
|
||||
seen_failures.add(display_name)
|
||||
|
||||
if not plugin_enabled:
|
||||
rendered_path = f"[grey53]disabled by {disabled_by or 'PLUGINS configuration'}[/grey53]"
|
||||
|
||||
row = {
|
||||
"plugin": plugin_name,
|
||||
"status": binary_dependency_status(enabled=plugin_enabled, valid=valid),
|
||||
"binary": display_name,
|
||||
"version": version_str if valid else "-",
|
||||
"provider": provider if valid else "-",
|
||||
"path": rendered_path,
|
||||
"style": "" if plugin_enabled else "dim",
|
||||
"enabled": plugin_enabled,
|
||||
"valid": valid,
|
||||
"plugins": [plugin_name],
|
||||
"disabled_by": [disabled_by or "PLUGINS configuration"] if not plugin_enabled else [],
|
||||
}
|
||||
existing = rows_by_binary.get(display_name)
|
||||
if existing is None:
|
||||
rows_by_binary[display_name] = row
|
||||
else:
|
||||
consumers = list(dict.fromkeys([*existing["plugins"], plugin_name]))
|
||||
disabled_reasons = list(dict.fromkeys([*existing["disabled_by"], *row["disabled_by"]]))
|
||||
# Disabled consumers cannot mask an active requirement. If any
|
||||
# active request fails (e.g. a version floor), keep that failure.
|
||||
if plugin_enabled and (not existing["enabled"] or not valid):
|
||||
existing.update(row)
|
||||
existing["plugins"] = consumers
|
||||
existing["disabled_by"] = disabled_reasons
|
||||
existing["plugin"] = ", ".join(consumers)
|
||||
if not existing["enabled"]:
|
||||
existing["path"] = f"[grey53]disabled by {', '.join(disabled_reasons)}[/grey53]"
|
||||
|
||||
rows = list(rows_by_binary.values())
|
||||
|
||||
if console.is_terminal and console.width < 120:
|
||||
from rich.text import Text
|
||||
|
||||
for row in rows:
|
||||
heading = Text.from_markup(str(row["status"]))
|
||||
heading.append(f" {row['binary']} {row['version']} ({row['provider']}) · {row['plugin']}")
|
||||
prnt(heading, style=str(row["style"]))
|
||||
path = row["path"]
|
||||
prnt(Text.assemble(" ", path if isinstance(path, Text) else Text.from_markup(str(path))), style=str(row["style"]))
|
||||
else:
|
||||
table = binary_dependency_table(rows)
|
||||
table.expand = False
|
||||
table.title = None
|
||||
table.box = None
|
||||
for column in table.columns:
|
||||
column.width = None
|
||||
column.max_width = None
|
||||
column.ratio = None
|
||||
prnt(table)
|
||||
|
||||
if not any_rows:
|
||||
prnt("", "[grey53]No required binaries declared for discovered plugins.[/grey53]")
|
||||
|
||||
@@ -468,7 +468,13 @@ class ArchivingConfig(BaseConfigSet):
|
||||
COOKIES_FILE: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
AUTH_STORAGE_FILE: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
|
||||
URL_DENYLIST: str = Field(default=r"\.(css|js|otf|ttf|woff|woff2|gstatic\.com|googleapis\.com/css)(\?.*)?$", alias="URL_BLACKLIST")
|
||||
URL_DENYLIST: str = Field(
|
||||
default=(
|
||||
r"\.(css|js|otf|ttf|woff|woff2|gstatic\.com|googleapis\.com/css)(\?.*)?$"
|
||||
r"|^https?://news\.ycombinator\.com/(?:vote|hide)/?(?:[?#]|$)"
|
||||
),
|
||||
alias="URL_BLACKLIST",
|
||||
)
|
||||
URL_ALLOWLIST: str | None = Field(default=None, alias="URL_WHITELIST")
|
||||
|
||||
DEFAULT_PERSONA: str = Field(default="Default", json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
|
||||
@@ -1107,7 +1113,7 @@ def get_config(
|
||||
if apps.ready:
|
||||
from archivebox.machine.models import Machine
|
||||
|
||||
machine = Machine.current()
|
||||
machine = Machine.current_readonly()
|
||||
except (DatabaseError, ImportError, RuntimeError, TypeError, ValueError):
|
||||
machine = None
|
||||
|
||||
|
||||
@@ -63,7 +63,14 @@ def setup_django(check_db=False) -> None:
|
||||
os.chown(CONSTANTS.DATA_DIR, ARCHIVEBOX_USER, ARCHIVEBOX_GROUP, follow_symlinks=False)
|
||||
if CONSTANTS.DATA_DIR.exists():
|
||||
for child in CONSTANTS.DATA_DIR.iterdir():
|
||||
os.chown(child, ARCHIVEBOX_USER, ARCHIVEBOX_GROUP, follow_symlinks=False)
|
||||
try:
|
||||
os.chown(child, ARCHIVEBOX_USER, ARCHIVEBOX_GROUP, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
# A concurrent SQLite transaction can remove its journal
|
||||
# between directory enumeration and chown. There is no
|
||||
# ownership left to fix; other permission errors must
|
||||
# still fail startup rather than being hidden.
|
||||
continue
|
||||
|
||||
# Suppress the "database access during app initialization" warning
|
||||
# This warning can be triggered during django.setup() but is safe to ignore
|
||||
|
||||
+21
-13
@@ -85,23 +85,31 @@ def get_collection_id(DATA_DIR=DATA_DIR) -> str:
|
||||
|
||||
|
||||
@cache
|
||||
def get_machine_id() -> str:
|
||||
"""Get a short, stable, unique ID for the current machine (e.g. abc45678)"""
|
||||
def get_machine_id(length: int = 8) -> str:
|
||||
"""Get a machine ID, using the network identity when no OS ID is available."""
|
||||
import machineid
|
||||
|
||||
MACHINE_ID = "unknown"
|
||||
try:
|
||||
import machineid
|
||||
return machineid.hashed_id("archivebox")[:length]
|
||||
except machineid.MachineIdNotFound:
|
||||
import uuid
|
||||
|
||||
MACHINE_ID = machineid.hashed_id("archivebox")[:8]
|
||||
except Exception:
|
||||
try:
|
||||
import uuid
|
||||
import hashlib
|
||||
import psutil
|
||||
|
||||
MACHINE_ID = hashlib.sha256(str(uuid.getnode()).encode()).hexdigest()[:8]
|
||||
except Exception:
|
||||
pass
|
||||
return MACHINE_ID
|
||||
node = uuid.getnode()
|
||||
identity = str(node)
|
||||
if node & (1 << 40):
|
||||
# uuid.getnode() silently generates a per-process random value when
|
||||
# tools such as ip/ifconfig are absent. Read interfaces directly so
|
||||
# separate CLI processes agree even in a minimal container.
|
||||
addresses = sorted(
|
||||
address.address
|
||||
for interface in psutil.net_if_addrs().values()
|
||||
for address in interface
|
||||
if address.family == psutil.AF_LINK and address.address not in ("", "00:00:00:00:00:00")
|
||||
)
|
||||
identity = str(int(addresses[0].replace(":", "").replace("-", ""), 16)) if addresses else f"hostname:{socket.gethostname()}"
|
||||
return hashlib.sha256(identity.encode()).hexdigest()[:length]
|
||||
|
||||
|
||||
@cache
|
||||
|
||||
@@ -2,11 +2,9 @@ __package__ = "archivebox.core"
|
||||
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
from functools import reduce
|
||||
from operator import and_
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.contrib import admin
|
||||
@@ -24,7 +22,13 @@ from django.utils.text import smart_split
|
||||
|
||||
from archivebox.base_models.admin import BaseModelAdmin
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
from archivebox.core.routes_util import build_snapshot_url
|
||||
from archivebox.core.routes_util import (
|
||||
build_snapshot_detail_url,
|
||||
build_snapshot_files_url,
|
||||
build_snapshot_url,
|
||||
build_snapshot_zip_url,
|
||||
get_snapshot_output_anchor,
|
||||
)
|
||||
from archivebox.core.widgets import InlineTagEditorWidget
|
||||
from archivebox.machine.env_util import env_to_shell_exports
|
||||
from archivebox.misc.logging_util import printable_filesize
|
||||
@@ -186,7 +190,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None, can_del
|
||||
if embed_path and result.status == "succeeded":
|
||||
output_link = build_snapshot_url(snapshot_id, embed_path, config=config)
|
||||
else:
|
||||
output_link = build_snapshot_url(snapshot_id, "", config=config)
|
||||
output_link = build_snapshot_detail_url(result.snapshot.archive_path_from_db, config=config)
|
||||
output_link_attr = html.escape(output_link, quote=True)
|
||||
|
||||
# Get version - try cmd_version field
|
||||
@@ -555,8 +559,8 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
try:
|
||||
queryset = self.get_queryset(request).filter(pk__in=selected)
|
||||
if not queryset.exists():
|
||||
snapshot = Snapshot.objects.only("id").filter(pk=request.GET.get("snapshot")).first()
|
||||
return redirect(build_snapshot_url(str(snapshot.id), "index.html", request=request) if snapshot else request.path)
|
||||
snapshot = Snapshot.objects.filter(pk=request.GET.get("snapshot")).first()
|
||||
return redirect(build_snapshot_detail_url(snapshot.archive_path_from_db, request=request) if snapshot else request.path)
|
||||
except (ValidationError, ValueError):
|
||||
return redirect(request.path)
|
||||
return delete_selected(self, request, queryset)
|
||||
@@ -568,7 +572,7 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
request.GET.clear()
|
||||
request.META["QUERY_STRING"] = ""
|
||||
try:
|
||||
handoff_snapshot = Snapshot.objects.only("id").filter(pk=handoff_snapshot).first()
|
||||
handoff_snapshot = Snapshot.objects.filter(pk=handoff_snapshot).first()
|
||||
except (ValidationError, ValueError):
|
||||
handoff_snapshot = None
|
||||
saved_list_per_page = self.list_per_page
|
||||
@@ -582,7 +586,7 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
pk__in=request.POST.getlist(ACTION_CHECKBOX_NAME),
|
||||
).exists()
|
||||
):
|
||||
return redirect(build_snapshot_url(str(handoff_snapshot.id), "index.html", request=request))
|
||||
return redirect(build_snapshot_detail_url(handoff_snapshot.archive_path_from_db, request=request))
|
||||
return response
|
||||
finally:
|
||||
self.list_per_page = saved_list_per_page
|
||||
@@ -656,22 +660,40 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
|
||||
def get_snapshot_view_url(self, result: ArchiveResult) -> str:
|
||||
request = self.request
|
||||
return build_snapshot_url(str(result.snapshot_id), request=request, config=request.archivebox_config)
|
||||
return build_snapshot_detail_url(
|
||||
result.snapshot.archive_path_from_db,
|
||||
request=request,
|
||||
config=request.archivebox_config,
|
||||
)
|
||||
|
||||
def get_output_view_url(self, result: ArchiveResult) -> str:
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
output_path = result.embed_path()
|
||||
if not output_path:
|
||||
output_path = result.plugin or ""
|
||||
return build_snapshot_url(str(result.snapshot_id), output_path, request=request, config=config)
|
||||
output_path = get_snapshot_output_anchor(result.plugin, result.embed_path() or "")
|
||||
return build_snapshot_detail_url(
|
||||
result.snapshot.archive_path_from_db,
|
||||
output_path=output_path,
|
||||
request=request,
|
||||
config=config,
|
||||
)
|
||||
|
||||
def get_output_files_url(self, result: ArchiveResult) -> str:
|
||||
request = self.request
|
||||
return f"{build_snapshot_url(str(result.snapshot_id), result.plugin, request=request, config=request.archivebox_config)}/?files=1"
|
||||
return build_snapshot_files_url(
|
||||
str(result.snapshot_id),
|
||||
result.output_folder_path(),
|
||||
request=request,
|
||||
config=request.archivebox_config,
|
||||
)
|
||||
|
||||
def get_output_zip_url(self, result: ArchiveResult) -> str:
|
||||
return f"{self.get_output_files_url(result)}&download=zip"
|
||||
request = self.request
|
||||
return build_snapshot_zip_url(
|
||||
str(result.snapshot_id),
|
||||
result.output_folder_path(),
|
||||
request=request,
|
||||
config=request.archivebox_config,
|
||||
)
|
||||
|
||||
@admin.display(description="Details", ordering="id")
|
||||
def details_link(self, result):
|
||||
@@ -729,7 +751,11 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
request = self.request
|
||||
return format_html(
|
||||
'<a href="{}"><b><code>[{}]</code></b> {} {}</a><br/>',
|
||||
build_snapshot_url(snapshot_id, "index.html", request=request, config=request.archivebox_config),
|
||||
build_snapshot_detail_url(
|
||||
result.snapshot.archive_path_from_db,
|
||||
request=request,
|
||||
config=request.archivebox_config,
|
||||
),
|
||||
snapshot_id[:8],
|
||||
result.snapshot.bookmarked_at.strftime("%Y-%m-%d %H:%M"),
|
||||
result.snapshot.url[:128],
|
||||
@@ -827,11 +853,14 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
config = request.archivebox_config
|
||||
# Determine output link path - use embed_path() which checks output_files
|
||||
embed_path = result.embed_path()
|
||||
output_path = embed_path if (result.status == "succeeded" and embed_path) else "index.html"
|
||||
snapshot_id = str(result.snapshot_id)
|
||||
output_url = (
|
||||
build_snapshot_url(str(result.snapshot_id), embed_path, request=request, config=config)
|
||||
if result.status == "succeeded" and embed_path
|
||||
else build_snapshot_detail_url(result.snapshot.archive_path_from_db, request=request, config=config)
|
||||
)
|
||||
return format_html(
|
||||
'<a href="{}" class="output-link">↗️</a><pre>{}</pre>',
|
||||
build_snapshot_url(snapshot_id, output_path, request=request, config=config),
|
||||
output_url,
|
||||
result.output_str_for_display(),
|
||||
)
|
||||
|
||||
@@ -858,46 +887,16 @@ class ArchiveResultAdmin(BaseModelAdmin):
|
||||
)
|
||||
|
||||
def output_summary(self, result):
|
||||
snapshot_dir = Path(result.snapshot.output_dir)
|
||||
output_html = format_html(
|
||||
'<pre style="display: inline-block">{}</pre><br/>',
|
||||
result.output_str_for_display(),
|
||||
)
|
||||
snapshot_id = str(result.snapshot_id)
|
||||
request = self.request
|
||||
output_html += format_html(
|
||||
'<a href="{}#all">See result files ...</a><br/><pre><code>',
|
||||
build_snapshot_url(snapshot_id, "index.html", request=request, config=request.archivebox_config),
|
||||
)
|
||||
embed_path = result.embed_path() or ""
|
||||
path_from_embed = snapshot_dir / (embed_path or "")
|
||||
output_html += format_html(
|
||||
'<i style="padding: 1px">{}</i><b style="padding-right: 20px">/</b><i>{}</i><br/><hr/>',
|
||||
str(snapshot_dir),
|
||||
str(embed_path),
|
||||
)
|
||||
if os.access(path_from_embed, os.R_OK):
|
||||
root_dir = str(path_from_embed)
|
||||
else:
|
||||
root_dir = str(snapshot_dir)
|
||||
|
||||
for root, dirs, files in os.walk(root_dir):
|
||||
depth = root.replace(root_dir, "").count(os.sep) + 1
|
||||
if depth > 2:
|
||||
continue
|
||||
indent = " " * 4 * (depth)
|
||||
output_html += format_html('<b style="padding: 1px">{}{}/</b><br/>', indent, os.path.basename(root))
|
||||
indentation_str = " " * 4 * (depth + 1)
|
||||
for filename in sorted(files):
|
||||
is_hidden = filename.startswith(".")
|
||||
output_html += format_html(
|
||||
'<span style="opacity: {}.2">{}{}</span><br/>',
|
||||
int(not is_hidden),
|
||||
indentation_str,
|
||||
filename.strip(),
|
||||
)
|
||||
|
||||
return output_html + mark_safe("</code></pre>")
|
||||
output_html = format_html('<pre style="display: inline-block">{}</pre>', result.output_str_for_display())
|
||||
file_map = result.output_file_map()
|
||||
paths = sorted({path for key in file_map if (path := result.output_file_path(key, output_file_map=file_map))})
|
||||
if paths:
|
||||
output_html += format_html(
|
||||
'<br/><a href="{}">See result files ...</a><pre><code>{}</code></pre>',
|
||||
self.get_output_files_url(result),
|
||||
"\n".join(paths),
|
||||
)
|
||||
return output_html
|
||||
|
||||
|
||||
def register_admin(admin_site):
|
||||
|
||||
+227
-191
@@ -1,8 +1,8 @@
|
||||
__package__ = "archivebox.core"
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from types import SimpleNamespace
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from django.contrib import admin, messages
|
||||
from django.core.exceptions import PermissionDenied
|
||||
@@ -10,11 +10,12 @@ from django.urls import path, reverse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotAllowed, Http404
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import format_html, format_html_join
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.db.models import Q, Count, Exists, F, OuterRef, Prefetch
|
||||
from django import forms
|
||||
from django.template import Template, RequestContext
|
||||
from django.template import Context, Template, RequestContext
|
||||
from django.contrib.admin.helpers import ActionForm
|
||||
|
||||
from archivebox.config.common import get_config
|
||||
@@ -24,17 +25,32 @@ from archivebox.misc.logging_util import printable_filesize
|
||||
from archivebox.misc.serve_static import serve_static_with_byterange_support
|
||||
from archivebox.search.admin import SearchResultsAdminMixin, SearchResultsChangeList
|
||||
from archivebox.search.views import admin_snapshot_search_stream_view
|
||||
from archivebox.core.routes_util import build_snapshot_url, build_web_url
|
||||
from archivebox.core.routes_util import (
|
||||
build_snapshot_detail_url,
|
||||
build_snapshot_files_url,
|
||||
build_snapshot_role_output_url,
|
||||
build_snapshot_url,
|
||||
build_snapshot_zip_url,
|
||||
get_snapshot_output_anchor,
|
||||
)
|
||||
from archivebox.core.tag_util import get_or_create_tag
|
||||
from archivebox.core.templatetags.core_tags import snapshot_thumbnail, snapshot_url_text
|
||||
from archivebox.plugins.hooks import discover_hooks
|
||||
from archivebox.plugins.discovery import get_plugin_icon, get_plugin_name, get_plugins
|
||||
from archivebox.plugins.discovery import get_plugin_icon, get_snapshot_role_names
|
||||
from archivebox.plugins.output_groups import (
|
||||
OUTPUT_GROUPS,
|
||||
display_plugin_name,
|
||||
output_group_for_plugin,
|
||||
order_output_plugins,
|
||||
plugin_icon_is_hidden,
|
||||
plugin_output_sizes,
|
||||
)
|
||||
|
||||
from archivebox.base_models.admin import BaseModelAdmin, ConfigEditorMixin
|
||||
|
||||
from archivebox.core.models import Tag, Snapshot, ArchiveResult
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.core.admin_archiveresults import render_archiveresults_list
|
||||
from archivebox.core.preview_util import EXTENSION_SCREENSHOT_PLUGIN
|
||||
from archivebox.progressmonitor.views import progress_endpoint
|
||||
from archivebox.core.permissions import (
|
||||
PERMISSIONS_CHOICES,
|
||||
@@ -48,17 +64,14 @@ from archivebox.core.widgets import TagEditorWidget, InlineTagEditorWidget
|
||||
GLOBAL_CONTEXT = {}
|
||||
|
||||
SNAPSHOT_PERMISSION_META = PERMISSIONS_META
|
||||
GRID_PREVIEW_OUTPUTS = {
|
||||
("screenshot", "screenshot.png"),
|
||||
("chrome_extension_screenshot", "screenshot-1.png"),
|
||||
("chrome_extension_screenshot", "screenshot.png"),
|
||||
("favicon", "favicon.ico"),
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _plugin_sort_order() -> dict[str, int]:
|
||||
return {get_plugin_name(plugin): idx for idx, plugin in enumerate(get_plugins())}
|
||||
def _format_size_column(num_bytes: int) -> str:
|
||||
size = num_bytes
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if size < 1024 or unit == "TB":
|
||||
return f"{size:.1f} {unit}" if unit == "GB" else f"{ceil(size)} {unit}"
|
||||
size /= 1024
|
||||
|
||||
|
||||
class SnapshotActionForm(ActionForm):
|
||||
@@ -270,15 +283,28 @@ class SnapshotChangeList(SearchResultsChangeList):
|
||||
rows = (
|
||||
ArchiveResult.objects.filter(snapshot_id__in=snapshot_ids, status=ArchiveResult.StatusChoices.SUCCEEDED, output_size__gt=0)
|
||||
.order_by("snapshot_id", "plugin")
|
||||
.values_list("snapshot_id", "plugin", "status", "output_size", "output_files")
|
||||
.only("snapshot_id", "plugin", "status", "output_str", "output_size", "output_files")
|
||||
)
|
||||
for snapshot_id, plugin, status, output_size, output_files in rows.iterator(chunk_size=1000):
|
||||
if plugin in seen_plugins[snapshot_id]:
|
||||
for result in rows.iterator(chunk_size=1000):
|
||||
if result.plugin in seen_plugins[result.snapshot_id]:
|
||||
continue
|
||||
seen_plugins[snapshot_id].add(plugin)
|
||||
output_results_by_snapshot[snapshot_id].append(
|
||||
SimpleNamespace(plugin=plugin, status=status, output_size=output_size, output_files=output_files),
|
||||
seen_plugins[result.snapshot_id].add(result.plugin)
|
||||
output_results_by_snapshot[result.snapshot_id].append(result)
|
||||
|
||||
card_results_by_snapshot = {snapshot_id: [] for snapshot_id in snapshot_ids}
|
||||
for result in (
|
||||
ArchiveResult.objects.filter(snapshot_id__in=snapshot_ids)
|
||||
.exclude(plugin="")
|
||||
.only(
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
"status",
|
||||
"output_str",
|
||||
"output_size",
|
||||
"output_files",
|
||||
)
|
||||
):
|
||||
card_results_by_snapshot[result.snapshot_id].append(result)
|
||||
|
||||
for obj in self.result_list:
|
||||
counts = status_counts_by_snapshot.get(obj.pk, {})
|
||||
@@ -302,6 +328,7 @@ class SnapshotChangeList(SearchResultsChangeList):
|
||||
"is_sealed": obj.status not in (obj.StatusChoices.QUEUED, obj.StatusChoices.STARTED, obj.StatusChoices.PAUSED),
|
||||
}
|
||||
obj.__dict__["_admin_output_results"] = output_results_by_snapshot[obj.pk]
|
||||
obj.__dict__["_snapshot_card_results"] = card_results_by_snapshot[obj.pk]
|
||||
|
||||
def get_results(self, request):
|
||||
super().get_results(request)
|
||||
@@ -314,6 +341,7 @@ class SnapshotChangeList(SearchResultsChangeList):
|
||||
class SnapshotAdminForm(forms.ModelForm):
|
||||
"""Custom form for Snapshot admin with tag editor widget."""
|
||||
|
||||
url = forms.CharField(widget=forms.TextInput)
|
||||
tags_editor = forms.CharField(
|
||||
label="Tags",
|
||||
required=False,
|
||||
@@ -376,7 +404,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
list_display = (
|
||||
"permissions_badge",
|
||||
"created_at",
|
||||
"created_at_display",
|
||||
"preview_icon",
|
||||
"title_str",
|
||||
"tags_inline",
|
||||
@@ -384,7 +412,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
"files",
|
||||
"size_with_stats",
|
||||
)
|
||||
list_display_links = ("created_at",)
|
||||
list_display_links = ("created_at_display",)
|
||||
sort_fields = ("title_str", "created_at", "status", "crawl")
|
||||
readonly_fields = (
|
||||
"admin_actions",
|
||||
@@ -498,6 +526,15 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
inlines = [] # Removed TagInline, using TagEditorWidget instead
|
||||
list_per_page = 50
|
||||
|
||||
@admin.display(description="Date", ordering="created_at")
|
||||
def created_at_display(self, obj):
|
||||
created_at = timezone.localtime(obj.created_at)
|
||||
return format_html(
|
||||
'<span class="snapshot-created-at"><span class="snapshot-created-at__date">{}</span><span class="snapshot-created-at__time">{}</span></span>',
|
||||
date_format(created_at, "Y-m-d"),
|
||||
date_format(created_at, "g:i A"),
|
||||
)
|
||||
|
||||
action_form = SnapshotActionForm
|
||||
paginator = AcceleratedPaginator
|
||||
|
||||
@@ -563,14 +600,15 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
def get_snapshot_view_url(self, obj: Snapshot) -> str:
|
||||
request = self.request
|
||||
return build_snapshot_url(str(obj.id), request=request, config=request.archivebox_config)
|
||||
return build_snapshot_detail_url(obj.archive_path_from_db, request=request, config=request.archivebox_config)
|
||||
|
||||
def get_snapshot_files_url(self, obj: Snapshot) -> str:
|
||||
request = self.request
|
||||
return f"{build_snapshot_url(str(obj.id), request=request, config=request.archivebox_config)}/?files=1"
|
||||
return build_snapshot_files_url(str(obj.id), request=request, config=request.archivebox_config)
|
||||
|
||||
def get_snapshot_zip_url(self, obj: Snapshot) -> str:
|
||||
return f"{self.get_snapshot_files_url(obj)}&download=zip"
|
||||
request = self.request
|
||||
return build_snapshot_zip_url(str(obj.id), request=request, config=request.archivebox_config)
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
@@ -592,17 +630,17 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
return custom_urls + urls
|
||||
|
||||
def preview_view(self, request, object_id, plugin, filename):
|
||||
"""Serve grid thumbnails from the authenticated admin origin."""
|
||||
if (plugin, filename) not in GRID_PREVIEW_OUTPUTS:
|
||||
raise Http404("Unsupported snapshot preview")
|
||||
"""Serve a saved image output from the authenticated admin origin."""
|
||||
snapshot = get_object_or_404(Snapshot, pk=object_id)
|
||||
if not self.has_view_or_change_permission(request, snapshot):
|
||||
raise PermissionDenied
|
||||
result = snapshot.archiveresult_set.filter(plugin=plugin, status=ArchiveResult.StatusChoices.SUCCEEDED).first()
|
||||
file_info = (result.output_files or {}).get(filename) if result else None
|
||||
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
|
||||
raise Http404("Snapshot preview does not exist")
|
||||
output_path = filename if file_info.get("root_relative") else f"{plugin}/{filename}"
|
||||
try:
|
||||
output_path = result.output_file_path(filename) if result else None
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
output_path = None
|
||||
if not output_path:
|
||||
raise Http404("Snapshot image does not exist")
|
||||
request.archivebox_cache_policy = "private"
|
||||
response = serve_static_with_byterange_support(request, output_path, document_root=snapshot.output_dir)
|
||||
if response.status_code != 304 and not response.headers.get("Content-Type", "").lower().startswith("image/"):
|
||||
@@ -742,7 +780,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
return qs
|
||||
|
||||
@admin.display(description="👁", ordering="permissions")
|
||||
@admin.display(description="🔒", ordering="permissions")
|
||||
def permissions_badge(self, obj):
|
||||
permissions = obj.__dict__.get("snapshot_permissions")
|
||||
if permissions is None:
|
||||
@@ -888,7 +926,13 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
def status_info(self, obj):
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
favicon_url = build_snapshot_url(str(obj.id), "favicon.ico", request=request, config=config)
|
||||
favicon_url = build_snapshot_role_output_url(
|
||||
obj,
|
||||
"list_icon",
|
||||
request=request,
|
||||
config=config,
|
||||
fallback_to_default=True,
|
||||
)
|
||||
return format_html(
|
||||
"""
|
||||
Archived: {} ({} files {})
|
||||
@@ -926,7 +970,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
show_title = bool(title_raw) and title_normalized != "pending..." and title_normalized != url_normalized
|
||||
css_class = "fetched" if show_title else "pending"
|
||||
|
||||
detail_url = build_web_url(f"/{obj.archive_path_from_db}/index.html", request=request, config=config)
|
||||
detail_url = build_snapshot_detail_url(obj.archive_path_from_db, request=request, config=config)
|
||||
title_html = ""
|
||||
if show_title:
|
||||
title_html = format_html(
|
||||
@@ -936,14 +980,26 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
urldecode(htmldecode(title_raw))[:128],
|
||||
)
|
||||
|
||||
favicon = self._get_favicon_data(obj) or {}
|
||||
favicon_html = (
|
||||
format_html(
|
||||
'<img class="snapshot-url-favicon" src="{}" alt="" loading="lazy" decoding="async" onerror="this.hidden=true">',
|
||||
favicon["url"],
|
||||
)
|
||||
if favicon.get("url")
|
||||
else ""
|
||||
)
|
||||
return format_html(
|
||||
'<a class="snapshot-title-detail-hitbox" href="{}" aria-label="Open snapshot details"></a>'
|
||||
"{}"
|
||||
'<div style="font-size: 11px; color: #64748b; margin-top: 2px;">'
|
||||
'<a href="{}"><code style="user-select: all;">{}</code></a>'
|
||||
'<div class="snapshot-title-url">'
|
||||
'<a class="snapshot-original-url" href="{}">{}<code style="user-select: all;">{}</code></a>'
|
||||
"</div>",
|
||||
detail_url,
|
||||
title_html,
|
||||
url_raw or obj.url,
|
||||
(url_raw or obj.url)[:128],
|
||||
favicon_html,
|
||||
snapshot_url_text(url_raw or obj.url),
|
||||
)
|
||||
|
||||
@admin.display(description="Tags", ordering="tag_count")
|
||||
@@ -970,90 +1026,47 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
)
|
||||
return mark_safe(f'<span class="tags-inline-editor">{tags_html}</span>')
|
||||
|
||||
def _get_preview_data(self, obj):
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
def _get_favicon_data(self, obj):
|
||||
list_icon_plugins = set(get_snapshot_role_names("list_icon"))
|
||||
results = self._get_prefetched_results(obj)
|
||||
if results is not None:
|
||||
results = [r for r in results if r.plugin in ("screenshot", EXTENSION_SCREENSHOT_PLUGIN, "favicon")]
|
||||
else:
|
||||
results = list(
|
||||
obj.archiveresult_set.filter(plugin__in=("screenshot", EXTENSION_SCREENSHOT_PLUGIN, "favicon")).only(
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
"status",
|
||||
"output_files",
|
||||
"output_str",
|
||||
),
|
||||
if results is None:
|
||||
results = obj.archiveresult_set.filter(
|
||||
plugin__in=list_icon_plugins,
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
)
|
||||
urls = []
|
||||
for result in results:
|
||||
if result.plugin not in list_icon_plugins or result.status != ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
continue
|
||||
try:
|
||||
output_path = result.embed_path()
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
continue
|
||||
if output_path:
|
||||
urls.append(
|
||||
build_snapshot_url(str(obj.id), output_path, request=self.request, config=self.request.archivebox_config),
|
||||
)
|
||||
return {"url": urls[0], "fallbacks": ",".join(urls[1:])} if urls else None
|
||||
|
||||
def result_output_path(result, filename: str) -> str | None:
|
||||
output_files = result.output_files or {}
|
||||
file_info = output_files.get(filename)
|
||||
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
|
||||
return None
|
||||
if file_info.get("root_relative"):
|
||||
return filename
|
||||
return f"{result.plugin}/{filename}"
|
||||
|
||||
def result_urls(plugin: str, filenames: tuple[str, ...]) -> list[str]:
|
||||
urls: list[str] = []
|
||||
for result in results:
|
||||
if result.plugin != plugin or result.status != ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
continue
|
||||
for filename in filenames:
|
||||
output_path = result_output_path(result, filename)
|
||||
if output_path:
|
||||
urls.append(build_snapshot_url(str(obj.id), output_path, request=request, config=config))
|
||||
return urls
|
||||
|
||||
screenshot_urls = result_urls("screenshot", ("screenshot.png",))
|
||||
extension_screenshot_urls = result_urls(EXTENSION_SCREENSHOT_PLUGIN, ("screenshot-1.png", "screenshot.png"))
|
||||
favicon_urls = result_urls("favicon", ("favicon.ico",))
|
||||
|
||||
if not screenshot_urls and not extension_screenshot_urls and not favicon_urls:
|
||||
return None
|
||||
|
||||
all_screenshot_urls = [*screenshot_urls, *extension_screenshot_urls]
|
||||
if all_screenshot_urls:
|
||||
img_url = all_screenshot_urls[0]
|
||||
fallbacks = [*all_screenshot_urls[1:], *favicon_urls]
|
||||
img_alt = "Screenshot"
|
||||
preview_class = "screenshot"
|
||||
else:
|
||||
img_url = favicon_urls[0]
|
||||
fallbacks = favicon_urls[1:]
|
||||
img_alt = "Favicon"
|
||||
preview_class = "favicon"
|
||||
|
||||
fallback_list = ",".join(fallbacks)
|
||||
onerror_js = (
|
||||
"this.dataset.fallbacks && this.dataset.fallbacks.length ? "
|
||||
"(this.src=this.dataset.fallbacks.split(',').shift(), "
|
||||
"this.dataset.fallbacks=this.dataset.fallbacks.split(',').slice(1).join(',')) : "
|
||||
"this.remove()"
|
||||
def _render_thumbnail(self, obj, width="100px", height="100px"):
|
||||
return snapshot_thumbnail(
|
||||
Context({"request": self.request, "CONFIG": self.request.archivebox_config}),
|
||||
obj,
|
||||
admin=True,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
||||
return {
|
||||
"img_url": img_url,
|
||||
"img_alt": img_alt,
|
||||
"preview_class": preview_class,
|
||||
"onerror_js": onerror_js,
|
||||
"fallback_list": fallback_list,
|
||||
"favicon_url": favicon_urls[0] if favicon_urls else "",
|
||||
"favicon_fallback_list": ",".join(favicon_urls[1:]),
|
||||
}
|
||||
|
||||
@admin.display(description="", empty_value="")
|
||||
def url_favicon(self, obj):
|
||||
preview = self._get_preview_data(obj)
|
||||
if not preview:
|
||||
favicon = self._get_favicon_data(obj)
|
||||
if not favicon:
|
||||
return ""
|
||||
|
||||
favicon_url = preview.get("favicon_url") or ""
|
||||
favicon_url = favicon.get("url") or ""
|
||||
if not favicon_url:
|
||||
return ""
|
||||
fallback_list = preview.get("favicon_fallback_list") or ""
|
||||
fallback_list = favicon.get("fallbacks") or ""
|
||||
onerror_js = (
|
||||
"this.dataset.fallbacks && this.dataset.fallbacks.length ? "
|
||||
"(this.src=this.dataset.fallbacks.split(',').shift(), "
|
||||
@@ -1074,45 +1087,24 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
|
||||
@admin.display(description="Preview", empty_value="")
|
||||
def preview_icon(self, obj):
|
||||
preview = self._get_preview_data(obj)
|
||||
if not preview:
|
||||
return None
|
||||
|
||||
return format_html(
|
||||
'<a href="{}" title="Open snapshot details">'
|
||||
'<img src="{}" alt="{}" class="snapshot-preview {}" decoding="async" loading="lazy" onerror="{}" data-fallbacks="{}">'
|
||||
"</a>",
|
||||
build_web_url(f"/{obj.archive_path_from_db}/index.html", request=self.request, config=self.request.archivebox_config),
|
||||
preview["img_url"],
|
||||
preview["img_alt"],
|
||||
preview["preview_class"],
|
||||
preview["onerror_js"],
|
||||
preview["fallback_list"],
|
||||
'<a href="{}" title="Open snapshot details">{}</a>',
|
||||
build_snapshot_detail_url(obj.archive_path_from_db, request=self.request, config=self.request.archivebox_config),
|
||||
self._render_thumbnail(obj, height="38px"),
|
||||
)
|
||||
|
||||
@admin.display(description=" ", empty_value="")
|
||||
def snapshot_summary(self, obj):
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
preview = self._get_preview_data(obj)
|
||||
stats = self._get_progress_stats(obj)
|
||||
archive_size = stats["output_size"] or 0
|
||||
size_txt = printable_filesize(archive_size) if archive_size else "pending"
|
||||
screenshot_html = ""
|
||||
|
||||
if preview:
|
||||
screenshot_html = format_html(
|
||||
'<a href="{href}" title="Open snapshot live view" style="display:block; flex:0 0 220px; width:220px;">'
|
||||
'<img src="{src}" alt="{alt}" decoding="async" loading="lazy" onerror="{onerror}" data-fallbacks="{fallbacks}" '
|
||||
'style="display:block; width:100%; max-width:220px; aspect-ratio: 16 / 10; object-fit: cover; object-position: top; '
|
||||
'border-radius: 10px; border: 1px solid #e2e8f0; background: #f8fafc;">'
|
||||
"</a>",
|
||||
href=build_web_url(f"/{obj.archive_path}", request=request, config=config),
|
||||
src=preview["img_url"],
|
||||
alt=preview["img_alt"],
|
||||
onerror=preview["onerror_js"],
|
||||
fallbacks=preview["fallback_list"],
|
||||
)
|
||||
screenshot_html = format_html(
|
||||
'<a href="{}" title="Open snapshot live view" style="display:block;flex:0 0 220px;">{}</a>',
|
||||
build_snapshot_detail_url(obj.archive_path_from_db, request=request, config=config),
|
||||
self._render_thumbnail(obj, width="220px", height="138px"),
|
||||
)
|
||||
|
||||
return format_html(
|
||||
'<div style="display:flex; gap:16px; align-items:flex-start;">'
|
||||
@@ -1127,18 +1119,19 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
"</div>",
|
||||
screenshot_html,
|
||||
size_txt,
|
||||
build_web_url(f"/{obj.archive_path}", request=request, config=config),
|
||||
obj.archive_path,
|
||||
self.get_snapshot_files_url(obj),
|
||||
obj.archive_path_from_db,
|
||||
)
|
||||
|
||||
@admin.display(
|
||||
description="Files Saved",
|
||||
ordering="ar_succeeded_count",
|
||||
empty_value="",
|
||||
)
|
||||
def files(self, obj):
|
||||
results = self._get_prefetched_results(obj)
|
||||
if results is None:
|
||||
results = obj.archiveresult_set.only("plugin", "status", "output_size")
|
||||
results = obj.archiveresult_set.only("plugin", "status", "output_size", "output_files")
|
||||
|
||||
plugins_with_output: dict[str, ArchiveResult] = {}
|
||||
for result in results:
|
||||
@@ -1149,56 +1142,111 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
plugins_with_output.setdefault(result.plugin, result)
|
||||
|
||||
if not plugins_with_output:
|
||||
return mark_safe('<span style="opacity: 0.35;">...</span>')
|
||||
return ""
|
||||
|
||||
sorted_results = sorted(
|
||||
plugins_with_output.values(),
|
||||
key=lambda result: (_plugin_sort_order().get(result.plugin, 9999), result.plugin),
|
||||
)
|
||||
visible_results = sorted_results[:14]
|
||||
output = []
|
||||
sorted_results = [plugins_with_output[plugin] for plugin in order_output_plugins(plugins_with_output, plugin_output_sizes(results))]
|
||||
icons_by_group: dict[str, list[dict[str, Any]]] = {group_id: [] for group_id, _, _ in OUTPUT_GROUPS}
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
for result in visible_results:
|
||||
for result in sorted_results:
|
||||
if plugin_icon_is_hidden(result.plugin):
|
||||
continue
|
||||
icon = mark_safe(get_plugin_icon(result.plugin))
|
||||
if not icon.strip():
|
||||
continue
|
||||
output.append(
|
||||
format_html(
|
||||
'<a href="{}" class="exists-True" title="{}">{}</a>',
|
||||
build_web_url(f"/{obj.archive_path_from_db}/{result.plugin}/", request=request, config=config),
|
||||
result.plugin,
|
||||
icon,
|
||||
),
|
||||
)
|
||||
if len(sorted_results) > len(visible_results):
|
||||
output.append(
|
||||
format_html(
|
||||
'<span title="{} more outputs">+{}</span>',
|
||||
len(sorted_results) - len(visible_results),
|
||||
len(sorted_results) - len(visible_results),
|
||||
),
|
||||
embed_path = result.embed_path()
|
||||
if not embed_path:
|
||||
continue
|
||||
group_id = output_group_for_plugin(result.plugin)
|
||||
anchor_path = get_snapshot_output_anchor(result.plugin, embed_path)
|
||||
icons_by_group[group_id].append(
|
||||
{
|
||||
"plugin": result.plugin,
|
||||
"icon": icon,
|
||||
"href": build_snapshot_detail_url(
|
||||
obj.archive_path_from_db,
|
||||
output_path=anchor_path,
|
||||
request=request,
|
||||
config=config,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
output = []
|
||||
for group_id, label, _ in OUTPUT_GROUPS:
|
||||
icons = icons_by_group[group_id]
|
||||
if icons:
|
||||
top = icons[0]
|
||||
front_index = 0
|
||||
pile_column = len(output)
|
||||
top_html = format_html(
|
||||
'<a href="{}" class="exists-True files-icon-pile-top files-icon-pile-cover files-icon-plugin--{}" aria-label="{}" data-tooltip="{}" style="--files-icon-front-index:{};--files-icon-front-offset:{}px">{}</a>',
|
||||
top["href"],
|
||||
top["plugin"],
|
||||
label,
|
||||
label,
|
||||
front_index,
|
||||
front_index * 2,
|
||||
top["icon"],
|
||||
)
|
||||
members = []
|
||||
for icon_index, item in enumerate(icons):
|
||||
row = icon_index // 5
|
||||
row_count = min(5, len(icons) - row * 5)
|
||||
member_top = 8 + row * 18
|
||||
row_offset = (5 - row_count) * 11 + 3
|
||||
member_left = 3 + row_offset + (icon_index % 5) * 22
|
||||
members.append(
|
||||
format_html(
|
||||
'<a href="{}" class="exists-True files-icon-plugin--{}" aria-label="{}" data-tooltip="{}" style="--files-icon-index:{};--files-icon-row:{};--files-icon-column:{};--files-icon-row-offset:{}px;--files-icon-member-left:{}px;--files-icon-member-top:{}px">{}</a>',
|
||||
item["href"],
|
||||
item["plugin"],
|
||||
display_plugin_name(item["plugin"]),
|
||||
item["plugin"],
|
||||
icon_index,
|
||||
row,
|
||||
icon_index % 5,
|
||||
row_offset,
|
||||
member_left,
|
||||
member_top,
|
||||
item["icon"],
|
||||
),
|
||||
)
|
||||
popup = format_html(
|
||||
'<span class="files-icon-pile-popup" popover="manual">{}<span class="files-icon-pile-label">{}</span></span>',
|
||||
mark_safe("".join(str(icon) for icon in members)),
|
||||
label,
|
||||
)
|
||||
pile_contents = format_html("{}{}", top_html, popup)
|
||||
output.append(
|
||||
format_html(
|
||||
'<span class="files-icon-pile files-icon-pile--{}" role="group" aria-label="{} saved outputs" style="--files-icon-popup-left:{}px;--files-icon-rows:{};--files-icon-card-width:{}px">{}</span>',
|
||||
group_id,
|
||||
label,
|
||||
pile_column * -22 - front_index * 2,
|
||||
(len(icons) + 4) // 5,
|
||||
116,
|
||||
pile_contents,
|
||||
),
|
||||
)
|
||||
|
||||
return format_html(
|
||||
'<span class="files-icons files-icons--compact" style="font-size: 1em; opacity: 0.8;">{}</span>',
|
||||
'<span class="files-icons files-icons--compact" style="font-size: 1em;">{}</span>',
|
||||
mark_safe("".join(output)),
|
||||
)
|
||||
|
||||
@admin.display()
|
||||
@admin.display(empty_value="")
|
||||
def size(self, obj):
|
||||
request = self.request
|
||||
config = request.archivebox_config
|
||||
archive_size = self._get_progress_stats(obj)["output_size"] or 0
|
||||
if archive_size:
|
||||
size_txt = printable_filesize(archive_size)
|
||||
size_txt = _format_size_column(archive_size)
|
||||
if archive_size > 52428800:
|
||||
size_txt = mark_safe(f"<b>{size_txt}</b>")
|
||||
else:
|
||||
size_txt = mark_safe('<span style="opacity: 0.3">...</span>')
|
||||
return ""
|
||||
return format_html(
|
||||
'<a href="{}" title="View all files">{}</a>',
|
||||
build_web_url(f"/{obj.archive_path}", request=request, config=config),
|
||||
self.get_snapshot_files_url(obj),
|
||||
size_txt,
|
||||
)
|
||||
|
||||
@@ -1268,6 +1316,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
@admin.display(
|
||||
description="Size",
|
||||
ordering="output_size",
|
||||
empty_value="",
|
||||
)
|
||||
def size_with_stats(self, obj):
|
||||
"""Show archive size with output size from archive results."""
|
||||
@@ -1276,24 +1325,11 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
size_bytes = output_size or 0
|
||||
|
||||
if size_bytes:
|
||||
size_txt = printable_filesize(size_bytes)
|
||||
size_txt = _format_size_column(size_bytes)
|
||||
if size_bytes > 52428800: # 50MB
|
||||
size_txt = mark_safe(f"<b>{size_txt}</b>")
|
||||
else:
|
||||
size_txt = mark_safe('<span style="opacity: 0.3">...</span>')
|
||||
|
||||
# Show hook statistics
|
||||
if stats["total"] > 0:
|
||||
return format_html(
|
||||
'<a href="{}" title="View all files" style="white-space: nowrap;">'
|
||||
"{}</a>"
|
||||
'<div style="font-size: 10px; color: #94a3b8; margin-top: 2px;">'
|
||||
"{}/{} hooks</div>",
|
||||
self.get_snapshot_files_url(obj),
|
||||
size_txt,
|
||||
stats["succeeded"],
|
||||
stats["total"],
|
||||
)
|
||||
return ""
|
||||
|
||||
return format_html(
|
||||
'<a href="{}" title="View all files">{}</a>',
|
||||
@@ -1421,9 +1457,9 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
|
||||
)
|
||||
def url_str(self, obj):
|
||||
return format_html(
|
||||
'<a href="{}"><code style="user-select: all;">{}</code></a>',
|
||||
'<a class="snapshot-original-url" href="{}"><code style="user-select: all;">{}</code></a>',
|
||||
obj.url,
|
||||
obj.url[:128],
|
||||
snapshot_url_text(obj.url),
|
||||
)
|
||||
|
||||
@admin.display(description="Health", ordering="health")
|
||||
|
||||
@@ -7,12 +7,13 @@ from django.contrib import admin, messages
|
||||
from django.contrib.admin.options import IS_POPUP_VAR
|
||||
from django.http import HttpRequest, HttpResponseRedirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.db.models import Prefetch
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from archivebox.base_models.admin import BaseModelAdmin
|
||||
from archivebox.core.models import SnapshotTag, Tag
|
||||
from archivebox.core.models import ArchiveResult, SnapshotTag, Tag
|
||||
from archivebox.core.tag_util import (
|
||||
TAG_HAS_SNAPSHOTS_CHOICES,
|
||||
TAG_SORT_CHOICES,
|
||||
@@ -24,7 +25,8 @@ from archivebox.core.tag_util import (
|
||||
normalize_has_snapshots_filter,
|
||||
normalize_tag_sort,
|
||||
)
|
||||
from archivebox.core.routes_util import build_snapshot_url
|
||||
from archivebox.core.routes_util import build_snapshot_role_output_url
|
||||
from archivebox.plugins.discovery import get_snapshot_role_names
|
||||
|
||||
|
||||
class TagInline(admin.TabularInline):
|
||||
@@ -191,7 +193,26 @@ class TagAdmin(BaseModelAdmin):
|
||||
|
||||
@admin.display(description="Snapshots")
|
||||
def snapshots(self, tag: Tag):
|
||||
snapshots = tag.snapshot_set.select_related("crawl__created_by").order_by("-downloaded_at", "-created_at", "-pk")[:10]
|
||||
snapshots = (
|
||||
tag.snapshot_set.select_related("crawl__created_by")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"archiveresult_set",
|
||||
queryset=ArchiveResult.objects.filter(
|
||||
plugin__in=get_snapshot_role_names("list_icon"),
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
).only(
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
"status",
|
||||
"output_str",
|
||||
"output_files",
|
||||
),
|
||||
to_attr="_list_icon_results",
|
||||
),
|
||||
)
|
||||
.order_by("-downloaded_at", "-created_at", "-pk")[:10]
|
||||
)
|
||||
total_count = tag.snapshot_set.count()
|
||||
if not snapshots:
|
||||
return mark_safe(
|
||||
@@ -214,7 +235,12 @@ class TagAdmin(BaseModelAdmin):
|
||||
</a>
|
||||
""",
|
||||
reverse("admin:core_snapshot_change", args=[snapshot.pk]),
|
||||
build_snapshot_url(str(snapshot.pk), "favicon.ico"),
|
||||
build_snapshot_role_output_url(
|
||||
snapshot,
|
||||
"list_icon",
|
||||
archive_results=snapshot._list_icon_results,
|
||||
fallback_to_default=True,
|
||||
),
|
||||
title[:120],
|
||||
snapshot.url[:120],
|
||||
),
|
||||
|
||||
@@ -33,6 +33,7 @@ from archivebox.core.routes_util import (
|
||||
split_host_port,
|
||||
)
|
||||
from archivebox.core.views import OriginalDomainHostView, SnapshotHostView
|
||||
from archivebox.misc.serve_static import FAVICON_CACHE_CONTROL
|
||||
|
||||
ADMIN_LOGIN_HINT_COOKIE = "archivebox_admin_logged_in"
|
||||
|
||||
@@ -154,8 +155,21 @@ def CacheControlMiddleware(get_response):
|
||||
response.headers["Last-Modified"] = http_date(mtime)
|
||||
return response
|
||||
|
||||
# Keep artifact freshness/validators, but never let a shared cache store
|
||||
# private or unlisted snapshots. Auth redirects and errors stay unstored.
|
||||
if response.status_code in (200, 206, 304) and response.get("Cache-Control") == FAVICON_CACHE_CONTROL:
|
||||
# The same raw favicon bytes are shared across authenticated viewers.
|
||||
# Session middleware may have added Cookie to Vary during authorization.
|
||||
vary = [
|
||||
value.strip()
|
||||
for value in response.get("Vary", "").split(",")
|
||||
if value.strip().lower() not in {"", "cookie", "authorization"}
|
||||
]
|
||||
if vary:
|
||||
response["Vary"] = ", ".join(vary)
|
||||
else:
|
||||
response.headers.pop("Vary", None)
|
||||
return response
|
||||
|
||||
# Other private/unlisted artifacts remain private; auth errors stay unstored.
|
||||
snapshot_policy = getattr(request, "archivebox_cache_policy", None)
|
||||
if snapshot_policy and response.status_code in (200, 206, 304):
|
||||
if not response.get("Cache-Control"):
|
||||
|
||||
@@ -100,11 +100,12 @@ def create_default_crawl_and_assign_snapshots(apps, schema_editor):
|
||||
if schema_editor.connection.vendor != "sqlite":
|
||||
return
|
||||
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
import uuid as uuid_lib
|
||||
from datetime import datetime
|
||||
|
||||
connection = schema_editor.connection
|
||||
cursor = connection.cursor()
|
||||
now = connection.ops.adapt_datetimefield_value(timezone.now())
|
||||
|
||||
# Check if there are any snapshots without a crawl
|
||||
cursor.execute("SELECT COUNT(*) FROM core_snapshot WHERE crawl_id IS NULL")
|
||||
@@ -125,14 +126,13 @@ def create_default_crawl_and_assign_snapshots(apps, schema_editor):
|
||||
INSERT INTO auth_user (id, password, is_superuser, username, first_name, last_name, email, is_staff, is_active, date_joined)
|
||||
VALUES (1, '!', 1, 'system', '', '', '', 1, 1, %s)
|
||||
""",
|
||||
[datetime.now().isoformat()],
|
||||
[now],
|
||||
)
|
||||
|
||||
# Create a default crawl for migrated snapshots.
|
||||
# Depending on migration graph order, later crawls migrations may already
|
||||
# have removed output_dir by the time this data migration runs.
|
||||
crawl_id = uuid_lib.uuid4().hex
|
||||
now = datetime.now().isoformat()
|
||||
cursor.execute("PRAGMA table_info(crawls_crawl)")
|
||||
crawl_columns = {row[1] for row in cursor.fetchall()}
|
||||
default_columns: list[str] = []
|
||||
@@ -155,9 +155,9 @@ def create_default_crawl_and_assign_snapshots(apps, schema_editor):
|
||||
status, retry_at, created_by_id, schedule_id, config, persona_id
|
||||
) VALUES (%s, %s, %s, 0, 0, %s, 0, '', 'Migrated from v0.7.2/v0.8.6',
|
||||
'Auto-created crawl for migrated snapshots', {default_values_sql}
|
||||
'sealed', %s, 1, NULL, '{{}}', NULL)
|
||||
'sealed', NULL, 1, NULL, '{{}}', NULL)
|
||||
""",
|
||||
[crawl_id, now, now, crawl_urls, now],
|
||||
[crawl_id, now, now, crawl_urls],
|
||||
)
|
||||
|
||||
# Assign all snapshots without a crawl to the default crawl
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Repair SQLite queue timestamps written as raw ISO strings by old migrations."""
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
|
||||
def repair_legacy_retry_timestamps(apps, schema_editor):
|
||||
connection = schema_editor.connection
|
||||
if connection.vendor != "sqlite":
|
||||
return
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# 0024 created this Crawl already sealed, but left a due retry marker.
|
||||
# Its orphaned snapshots have their own scheduler rows for maintenance.
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE crawls_crawl SET retry_at = NULL
|
||||
WHERE status = 'sealed'
|
||||
AND label = 'Migrated from v0.7.2/v0.8.6'
|
||||
AND notes = 'Auto-created crawl for migrated snapshots'
|
||||
AND substr(retry_at, 11, 1) = 'T'
|
||||
""",
|
||||
)
|
||||
|
||||
# SQLite compares stored date strings lexically. A raw `T` timestamp
|
||||
# from a legacy migration sorts differently from Django's space-separated
|
||||
# parameter, so a due row can be selected but never claimed. Preserve
|
||||
# the datetime Django already reads from each row while rewriting its
|
||||
# storage representation through the active database adapter.
|
||||
for table in ("crawls_crawl", "core_snapshot", "machine_binary", "machine_process"):
|
||||
last_id = ""
|
||||
while True:
|
||||
cursor.execute(
|
||||
f"SELECT id, CAST(retry_at AS TEXT) FROM {table} WHERE id > %s AND substr(retry_at, 11, 1) = 'T' ORDER BY id LIMIT 500",
|
||||
[last_id],
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
if not rows:
|
||||
break
|
||||
for row_id, raw_retry_at in rows:
|
||||
try:
|
||||
parsed = parse_datetime(raw_retry_at)
|
||||
except ValueError:
|
||||
continue
|
||||
if parsed is None:
|
||||
continue
|
||||
normalized = connection.ops.adapt_datetimefield_value(parsed)
|
||||
cursor.execute(
|
||||
f"UPDATE {table} SET retry_at = %s WHERE id = %s AND retry_at = %s",
|
||||
[normalized, row_id, raw_retry_at],
|
||||
)
|
||||
last_id = rows[-1][0]
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0054_snapshot_fs_version_index"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(repair_legacy_retry_timestamps, migrations.RunPython.noop)]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Repair process links after both core's legacy copy and machine migrations."""
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
repair_process_binary_iface_links = import_module(
|
||||
"archivebox.machine.migrations.0020_repair_process_binary_iface_links",
|
||||
).repair_process_binary_iface_links
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
("core", "0055_repair_legacy_retry_timestamps"),
|
||||
("machine", "0022_networkinterface_identity_without_mac"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# The original repair can run before core.0027 creates legacy Process
|
||||
# rows. Repair again after the copy, including already upgraded DBs.
|
||||
migrations.RunPython(repair_process_binary_iface_links, migrations.RunPython.noop),
|
||||
]
|
||||
+404
-162
@@ -466,21 +466,23 @@ class SnapshotQuerySet(models.QuerySet):
|
||||
|
||||
from archivebox.config import VERSION
|
||||
from archivebox.config.version import get_COMMIT_HASH
|
||||
from archivebox.plugins.discovery import get_snapshot_role_names
|
||||
|
||||
config = get_config()
|
||||
|
||||
template = "static_index.html" if with_headers else "minimal_index.html"
|
||||
snapshot_list = list(self.iterator(chunk_size=500))
|
||||
list_icon_plugins = set(get_snapshot_role_names("list_icon"))
|
||||
manifest_records = []
|
||||
for snapshot in snapshot_list:
|
||||
# Unarchived snapshots still need an export destination before discovery.
|
||||
snapshot.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
outputs = snapshot.discover_outputs(include_filesystem_fallback=True)
|
||||
output_paths = [str(output.get("path") or "") for output in outputs]
|
||||
snapshot._public_preview_paths = [
|
||||
path for preferred in ("screenshot/screenshot.png", "screenshot.png") for path in output_paths if path == preferred
|
||||
# The list icon is part of the portable export too; an incomplete
|
||||
# hash manifest must not hide a file that is still on disk.
|
||||
outputs = snapshot.discover_outputs(include_filesystem_fallback=True, filesystem_index={})
|
||||
snapshot._public_list_icon_paths = [
|
||||
str(output.get("path") or "") for output in outputs if output.get("name") in list_icon_plugins
|
||||
]
|
||||
snapshot._public_favicon_paths = [path for path in output_paths if path in ("favicon/favicon.ico", "favicon.ico")]
|
||||
snapshot.write_html_details()
|
||||
if with_headers:
|
||||
# Use the same portable schema as the JSON export. Rendering
|
||||
@@ -2374,15 +2376,77 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
return calc_tags_str()
|
||||
return calc_tags_str()
|
||||
|
||||
def plugin_output_path(self, plugin: str, archive_results=None) -> str | None:
|
||||
"""Return the first declared successful output path for one plugin."""
|
||||
from archivebox.plugins.discovery import get_archive_result_names
|
||||
|
||||
result_names = get_archive_result_names(plugin)
|
||||
if archive_results is None:
|
||||
for cache_name in ("_snapshot_card_results", "_admin_output_results", "_admin_archiveresults"):
|
||||
if cache_name in self.__dict__:
|
||||
archive_results = self.__dict__[cache_name]
|
||||
break
|
||||
if archive_results is None:
|
||||
archive_results = self.__dict__.get("_prefetched_objects_cache", {}).get("archiveresult_set")
|
||||
if archive_results is None:
|
||||
archive_results = self.archiveresult_set.filter(plugin__in=result_names, status=ArchiveResult.StatusChoices.SUCCEEDED).only(
|
||||
"plugin",
|
||||
"status",
|
||||
"output_str",
|
||||
"output_files",
|
||||
)
|
||||
|
||||
for result in archive_results:
|
||||
if result.plugin not in result_names or result.status != ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
continue
|
||||
try:
|
||||
output_path = result.embed_path()
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
continue
|
||||
if output_path:
|
||||
return output_path
|
||||
return None
|
||||
|
||||
def icons(self, path: str | None = None, prefix: str = "/", quote_paths: bool = False) -> str:
|
||||
"""Generate HTML icons showing which extractor plugins have succeeded for this snapshot"""
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.utils.html import format_html
|
||||
|
||||
from archivebox.core.routes_util import build_snapshot_detail_path, build_snapshot_index_path, get_snapshot_output_anchor
|
||||
|
||||
compact_icons = self.__dict__.get("_icons_compact", False)
|
||||
|
||||
def calc_icons():
|
||||
from archivebox.plugins.output_groups import (
|
||||
OUTPUT_GROUPS,
|
||||
display_plugin_name,
|
||||
output_group_for_plugin,
|
||||
order_output_plugins,
|
||||
plugin_icon_is_hidden,
|
||||
plugin_output_sizes,
|
||||
)
|
||||
|
||||
precomputed_archive_results = self.__dict__.get("_icons_archive_results")
|
||||
if precomputed_archive_results is not None and compact_icons:
|
||||
archive_results = {
|
||||
plugin: result if isinstance(result, ArchiveResult) else True
|
||||
for plugin, result in (
|
||||
precomputed_archive_results.items()
|
||||
if isinstance(precomputed_archive_results, dict)
|
||||
else ((plugin, True) for plugin in precomputed_archive_results)
|
||||
)
|
||||
}
|
||||
output_sizes = self.__dict__.get("_icons_output_sizes")
|
||||
if output_sizes is None:
|
||||
output_sizes = plugin_output_sizes(result for result in archive_results.values() if isinstance(result, ArchiveResult))
|
||||
else:
|
||||
results = list(self.archiveresult_set.all())
|
||||
output_sizes = plugin_output_sizes(results)
|
||||
archive_results = {
|
||||
result.plugin: result
|
||||
for result in results
|
||||
if result.status == "succeeded" and (compact_icons or result.output_files or result.output_str)
|
||||
}
|
||||
ordered_plugins = order_output_plugins(archive_results, output_sizes)
|
||||
if compact_icons and self.status == self.StatusChoices.STARTED:
|
||||
progress_stats = self.__dict__.get("_icons_progress_stats") or self.get_progress_stats()
|
||||
total = int(progress_stats.get("total") or 0)
|
||||
@@ -2393,29 +2457,76 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
running = int(progress_stats.get("running") or 0)
|
||||
completed = succeeded + failed + skipped + noresults
|
||||
percent = int((completed / total * 100) if total > 0 else 0)
|
||||
successful_plugins = sorted(self.__dict__.get("_icons_archive_results") or ())
|
||||
visible_plugins = successful_plugins[:8]
|
||||
plugin_icon_spans = []
|
||||
for plugin in visible_plugins:
|
||||
icons_by_group: dict[str, list[dict[str, object]]] = {group_id: [] for group_id, _, _ in OUTPUT_GROUPS}
|
||||
for plugin in ordered_plugins:
|
||||
if plugin_icon_is_hidden(plugin):
|
||||
continue
|
||||
icon = get_plugin_icon(plugin)
|
||||
if str(icon).strip():
|
||||
plugin_icon_spans.append(str(format_html('<span title="{}">{}</span>', plugin, mark_safe(icon))))
|
||||
successful_icons = format_html(
|
||||
'<div class="snapshot-successful-plugin-icons" style="display:flex; flex-wrap:wrap; gap:2px; margin-top:4px;">{}</div>',
|
||||
mark_safe(
|
||||
"".join(plugin_icon_spans)
|
||||
+ (
|
||||
str(
|
||||
group_id = output_group_for_plugin(plugin)
|
||||
icons_by_group[group_id].append({"plugin": plugin, "icon": mark_safe(icon)})
|
||||
plugin_icon_piles = []
|
||||
for group_id, label, _ in OUTPUT_GROUPS:
|
||||
icons = icons_by_group[group_id]
|
||||
if icons:
|
||||
top = icons[0]
|
||||
front_index = 0
|
||||
pile_column = len(plugin_icon_piles)
|
||||
top_html = format_html(
|
||||
'<span class="files-icon-pile-top files-icon-pile-cover files-icon-plugin--{}" aria-label="{}" data-tooltip="{}" style="--files-icon-front-index:{}">{}</span>',
|
||||
top["plugin"],
|
||||
label,
|
||||
label,
|
||||
front_index,
|
||||
top["icon"],
|
||||
)
|
||||
members = []
|
||||
for index, item in enumerate(icons):
|
||||
row = index // 5
|
||||
member_top = 26 + row * 18
|
||||
row_offset = (5 - min(5, len(icons) - row * 5)) * 11 + 3
|
||||
member_left = 3 + row_offset + (index % 5) * 22 - pile_column * 22
|
||||
members.append(
|
||||
format_html(
|
||||
'<span title="{} more successful plugins">+{}</span>',
|
||||
len(successful_plugins) - 8,
|
||||
len(successful_plugins) - 8,
|
||||
'<span class="files-icon-plugin--{}" aria-label="{}" data-tooltip="{}" style="--files-icon-index:{};--files-icon-row:{};--files-icon-column:{};--files-icon-row-offset:{}px;--files-icon-member-left:{}px;--files-icon-member-top:{}px">{}</span>',
|
||||
item["plugin"],
|
||||
display_plugin_name(item["plugin"]),
|
||||
item["plugin"],
|
||||
index,
|
||||
row,
|
||||
index % 5,
|
||||
row_offset,
|
||||
member_left,
|
||||
member_top,
|
||||
item["icon"],
|
||||
),
|
||||
)
|
||||
if len(successful_plugins) > 8
|
||||
else ""
|
||||
),
|
||||
),
|
||||
pile_contents = format_html(
|
||||
'<span class="files-icon-pile-backplate" aria-hidden="true"></span><span class="files-icon-pile-hover-bridge" aria-hidden="true"></span>{}{}',
|
||||
top_html,
|
||||
mark_safe("".join(str(icon) for icon in members)),
|
||||
)
|
||||
plugin_icon_piles.append(
|
||||
format_html(
|
||||
'<span class="files-icon-pile files-icon-pile--{}" role="group" aria-label="{} saved outputs" style="--files-icon-count:{};--files-icon-pile-column:{};--files-icon-front-index:{};--files-icon-popup-left:{}px;--files-icon-front-offset:{}px;--files-icon-bridge-start:{}px;--files-icon-bridge-end:{}px;--files-icon-rows:{};--files-icon-card-height:{}px;--files-icon-card-width:{}px">{}</span>',
|
||||
group_id,
|
||||
label,
|
||||
len(icons),
|
||||
pile_column,
|
||||
front_index,
|
||||
pile_column * -22,
|
||||
front_index * 2,
|
||||
pile_column * 22 + front_index * 2,
|
||||
pile_column * 22 + front_index * 2 + 16,
|
||||
(len(icons) + 4) // 5,
|
||||
((len(icons) + 4) // 5) * 18 + 8,
|
||||
116,
|
||||
pile_contents,
|
||||
),
|
||||
)
|
||||
successful_icons = format_html(
|
||||
'<div class="snapshot-successful-plugin-icons files-icons">{}</div>',
|
||||
mark_safe("".join(str(pile) for pile in plugin_icon_piles)),
|
||||
)
|
||||
return format_html(
|
||||
'<div class="snapshot-files-progress" title="{} of {} hooks complete" style="min-width: 96px;">'
|
||||
@@ -2442,39 +2553,12 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
successful_icons,
|
||||
)
|
||||
|
||||
precomputed_archive_results = self.__dict__.get("_icons_archive_results")
|
||||
prefetched_cache = self.__dict__.get("_prefetched_objects_cache", {})
|
||||
if precomputed_archive_results is not None and compact_icons:
|
||||
archive_results = {plugin: True for plugin in precomputed_archive_results}
|
||||
elif "archiveresult_set" in prefetched_cache:
|
||||
archive_results = {
|
||||
r.plugin: r
|
||||
for r in self.archiveresult_set.all()
|
||||
if r.status == "succeeded" and (compact_icons or r.output_files or r.output_str)
|
||||
}
|
||||
else:
|
||||
# Filter for results that have either output_files or output_str
|
||||
from django.db.models import Q
|
||||
|
||||
archive_results_qs = self.archiveresult_set.filter(status="succeeded")
|
||||
if not compact_icons:
|
||||
archive_results_qs = archive_results_qs.filter(Q(output_files__isnull=False) | ~Q(output_str=""))
|
||||
archive_results = {r.plugin: r for r in archive_results_qs}
|
||||
|
||||
archive_path = path or self.archive_path
|
||||
output = ""
|
||||
output_template = '<a href="{}{}/{}" class="exists-{}" title="{}">{}</a>'
|
||||
|
||||
# Get all plugins from hooks system (sorted by numeric prefix)
|
||||
all_plugins = self.__dict__.get("_icons_plugin_names")
|
||||
if all_plugins is None and not compact_icons:
|
||||
all_plugins = [get_plugin_name(e) for e in get_plugins()]
|
||||
elif all_plugins is None:
|
||||
all_plugins = []
|
||||
ordered_plugins = [plugin for plugin in all_plugins if plugin in archive_results]
|
||||
ordered_plugins.extend(sorted(set(archive_results) - set(ordered_plugins)))
|
||||
archive_path = path or self.archive_path_from_db
|
||||
icons_by_group: dict[str, list[dict[str, object]]] = {group_id: [] for group_id, _, _ in OUTPUT_GROUPS}
|
||||
|
||||
for plugin in ordered_plugins:
|
||||
if plugin_icon_is_hidden(plugin):
|
||||
continue
|
||||
result = archive_results.get(plugin)
|
||||
existing = result is True or bool(
|
||||
result and result.status == "succeeded" and (compact_icons or result.output_files or result.output_str),
|
||||
@@ -2488,7 +2572,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
if not icon.strip():
|
||||
continue
|
||||
|
||||
embed_path = f"{plugin}/" if compact_icons else result.embed_path()
|
||||
embed_path = result.embed_path() if isinstance(result, ArchiveResult) else f"{plugin}/"
|
||||
if not embed_path or str(embed_path).strip() in (".", "/", "./"):
|
||||
continue
|
||||
output_path = Path(str(embed_path))
|
||||
@@ -2498,21 +2582,88 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
and (output_path.is_absolute() or ".." in output_path.parts or not (Path(self.output_dir) / output_path).exists())
|
||||
):
|
||||
continue
|
||||
if quote_paths:
|
||||
embed_path = quote(str(embed_path), safe="/@-._~!$&'()*+,;=")
|
||||
output += format_html(
|
||||
output_template,
|
||||
prefix,
|
||||
archive_path,
|
||||
embed_path,
|
||||
str(bool(existing)),
|
||||
plugin,
|
||||
icon,
|
||||
group_id = output_group_for_plugin(plugin)
|
||||
anchor_path = get_snapshot_output_anchor(plugin, embed_path)
|
||||
icons_by_group[group_id].append(
|
||||
{
|
||||
"plugin": plugin,
|
||||
"icon": icon,
|
||||
"href": (
|
||||
build_snapshot_index_path(archive_path, output_path=anchor_path, prefix=prefix)
|
||||
if quote_paths
|
||||
else build_snapshot_detail_path(archive_path, output_path=anchor_path, prefix=prefix)
|
||||
),
|
||||
"existing": bool(existing),
|
||||
},
|
||||
)
|
||||
|
||||
output = []
|
||||
for group_id, label, _ in OUTPUT_GROUPS:
|
||||
icons = icons_by_group[group_id]
|
||||
if icons:
|
||||
top = icons[0]
|
||||
front_index = 0
|
||||
pile_column = len(output)
|
||||
top_html = format_html(
|
||||
'<a href="{}" class="exists-{} files-icon-pile-top files-icon-pile-cover files-icon-plugin--{}" aria-label="{}" data-tooltip="{}" style="--files-icon-front-index:{}">{}</a>',
|
||||
top["href"],
|
||||
str(top["existing"]),
|
||||
top["plugin"],
|
||||
label,
|
||||
label,
|
||||
front_index,
|
||||
top["icon"],
|
||||
)
|
||||
members = []
|
||||
for index, item in enumerate(icons):
|
||||
row = index // 5
|
||||
member_top = 26 + row * 18
|
||||
row_offset = (5 - min(5, len(icons) - row * 5)) * 11 + 3
|
||||
member_left = 3 + row_offset + (index % 5) * 22 - pile_column * 22
|
||||
members.append(
|
||||
format_html(
|
||||
'<a href="{}" class="exists-{} files-icon-plugin--{}" aria-label="{}" data-tooltip="{}" style="--files-icon-index:{};--files-icon-row:{};--files-icon-column:{};--files-icon-row-offset:{}px;--files-icon-member-left:{}px;--files-icon-member-top:{}px">{}</a>',
|
||||
item["href"],
|
||||
str(item["existing"]),
|
||||
item["plugin"],
|
||||
display_plugin_name(item["plugin"]),
|
||||
item["plugin"],
|
||||
index,
|
||||
row,
|
||||
index % 5,
|
||||
row_offset,
|
||||
member_left,
|
||||
member_top,
|
||||
item["icon"],
|
||||
),
|
||||
)
|
||||
pile_contents = format_html(
|
||||
'<span class="files-icon-pile-backplate" aria-hidden="true"></span><span class="files-icon-pile-hover-bridge" aria-hidden="true"></span>{}{}',
|
||||
top_html,
|
||||
mark_safe("".join(str(icon) for icon in members)),
|
||||
)
|
||||
output.append(
|
||||
format_html(
|
||||
'<span class="files-icon-pile files-icon-pile--{}" role="group" aria-label="{} saved outputs" style="--files-icon-count:{};--files-icon-pile-column:{};--files-icon-front-index:{};--files-icon-popup-left:{}px;--files-icon-front-offset:{}px;--files-icon-bridge-start:{}px;--files-icon-bridge-end:{}px;--files-icon-rows:{};--files-icon-card-height:{}px;--files-icon-card-width:{}px">{}</span>',
|
||||
group_id,
|
||||
label,
|
||||
len(icons),
|
||||
pile_column,
|
||||
front_index,
|
||||
pile_column * -22,
|
||||
front_index * 2,
|
||||
pile_column * 22 + front_index * 2,
|
||||
pile_column * 22 + front_index * 2 + 16,
|
||||
(len(icons) + 4) // 5,
|
||||
((len(icons) + 4) // 5) * 18 + 8,
|
||||
116,
|
||||
pile_contents,
|
||||
),
|
||||
)
|
||||
|
||||
return format_html(
|
||||
'<span class="files-icons" style="font-size: 1em; opacity: 0.8; display: inline-grid; grid-auto-flow: column; grid-auto-columns: auto; grid-template-rows: repeat(4, auto); gap: 0 0; justify-content: start; align-content: start;">{}</span>',
|
||||
mark_safe(output),
|
||||
'<span class="files-icons" style="font-size: 1em;">{}</span>',
|
||||
mark_safe("".join(str(pile) for pile in output)),
|
||||
)
|
||||
|
||||
return calc_icons()
|
||||
@@ -2522,7 +2673,9 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
return str(reverse_lazy("api-1:get_snapshot", args=[self.id]))
|
||||
|
||||
def get_absolute_url(self):
|
||||
return f"/{self.archive_path}"
|
||||
from archivebox.core.routes_util import build_snapshot_detail_path
|
||||
|
||||
return build_snapshot_detail_path(self.archive_path_from_db)
|
||||
|
||||
@cached_property
|
||||
def domain(self) -> str:
|
||||
@@ -2716,30 +2869,49 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
def legacy_archive_path(self) -> str:
|
||||
return f"{CONSTANTS.ARCHIVE_DIR_NAME}/{self.timestamp}"
|
||||
|
||||
@classmethod
|
||||
def archive_path_for_values(
|
||||
cls,
|
||||
*,
|
||||
fs_version: str,
|
||||
timestamp: str,
|
||||
snapshot_id,
|
||||
url: str,
|
||||
username: str = "web",
|
||||
bookmarked_at=None,
|
||||
created_at=None,
|
||||
) -> str:
|
||||
"""Build the public detail path without consulting the filesystem."""
|
||||
legacy_path = f"{CONSTANTS.ARCHIVE_DIR_NAME}/{timestamp}"
|
||||
if fs_version not in ("0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "1.0.0"):
|
||||
return legacy_path
|
||||
|
||||
date_base = bookmarked_at or created_at
|
||||
if not date_base:
|
||||
return legacy_path
|
||||
|
||||
username = username if username and username != "system" else "web"
|
||||
date_str = date_base.strftime("%Y%m%d")
|
||||
domain = cls.extract_domain_from_url(url)
|
||||
return f"{username}/{date_str}/{domain}/{snapshot_id}"
|
||||
|
||||
@cached_property
|
||||
def archive_path_from_db(self) -> str:
|
||||
"""Best-effort public URL path derived from DB fields only."""
|
||||
if self.fs_version in ("0.7.0", "0.8.0"):
|
||||
return self.legacy_archive_path
|
||||
|
||||
username = "web"
|
||||
if self.fs_version in ("0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "1.0.0"):
|
||||
username = "web"
|
||||
crawl = self.crawl if self.crawl_id else None
|
||||
if crawl and crawl.created_by_id:
|
||||
username = crawl.created_by.username
|
||||
if username == "system":
|
||||
username = "web"
|
||||
|
||||
date_base = self.bookmarked_at or self.created_at
|
||||
if date_base:
|
||||
date_str = date_base.strftime("%Y%m%d")
|
||||
else:
|
||||
return self.legacy_archive_path
|
||||
|
||||
domain = self.extract_domain_from_url(self.url)
|
||||
return f"{username}/{date_str}/{domain}/{self.id}"
|
||||
|
||||
return self.legacy_archive_path
|
||||
return self.archive_path_for_values(
|
||||
fs_version=self.fs_version,
|
||||
timestamp=self.timestamp,
|
||||
snapshot_id=self.id,
|
||||
url=self.url,
|
||||
username=username,
|
||||
bookmarked_at=self.bookmarked_at,
|
||||
created_at=self.created_at,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def url_path(self) -> str:
|
||||
@@ -3282,7 +3454,17 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
archive_results: list["ArchiveResult"] | None = None,
|
||||
filesystem_index: dict | None = None,
|
||||
) -> list[dict]:
|
||||
"""Discover output files from ArchiveResults and filesystem."""
|
||||
"""Discover output paths; file availability is not result success.
|
||||
|
||||
Live cards must take succeeded/failed state from ArchiveResult.status.
|
||||
A failed hook can leave useful partial files, and hooks/retries share
|
||||
plugin directories, so an embeddable path or nonzero size is not proof
|
||||
of success. Filesystem discovery also supports portable static exports;
|
||||
it must not overwrite or replace the live database's lifecycle facts.
|
||||
|
||||
This includes partial files for browsing. get_html_details_context
|
||||
separately admits successful DB results to the output cards.
|
||||
"""
|
||||
from archivebox.misc.util import ts_to_date_str
|
||||
|
||||
ArchiveResult = self.archiveresult_set.model
|
||||
@@ -3403,8 +3585,6 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
entry = Path(path)
|
||||
if len(entry.parts) != 1 or entry.name in ("index.html", "index.json", "favicon.ico"):
|
||||
continue
|
||||
if filesystem_index is not None and entry.name == "index.jsonl":
|
||||
continue
|
||||
if entry.suffix.lstrip(".").lower() not in embeddable_exts or entry.stem in seen:
|
||||
continue
|
||||
outputs.append(
|
||||
@@ -3436,7 +3616,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
|
||||
def to_dict(self, extended: bool = False, static_export: bool = False) -> dict[str, Any]:
|
||||
"""Convert Snapshot to a dictionary (replacement for Link._asdict())"""
|
||||
from archivebox.core.routes_util import build_snapshot_url
|
||||
from archivebox.core.routes_util import build_snapshot_detail_url, build_snapshot_index_path
|
||||
|
||||
archive_size = self.archive_size
|
||||
|
||||
@@ -3465,8 +3645,12 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
"extension": self.extension,
|
||||
"is_static": self.is_static,
|
||||
"is_archived": self.is_archived,
|
||||
"archive_path": self.static_archive_path if static_export else self.archive_path,
|
||||
"archive_url": f"./{self.static_archive_path}/index.html" if static_export else build_snapshot_url(str(self.id), "index.html"),
|
||||
"archive_path": self.static_archive_path if static_export else self.archive_path_from_db,
|
||||
"archive_url": (
|
||||
build_snapshot_index_path(self.static_archive_path, prefix="./")
|
||||
if static_export
|
||||
else build_snapshot_detail_url(self.archive_path_from_db)
|
||||
),
|
||||
"output_dir": self.output_dir,
|
||||
"link_dir": self.output_dir, # backwards compatibility alias
|
||||
"archive_size": archive_size,
|
||||
@@ -3511,7 +3695,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
from archivebox.core.widgets import TagEditorWidget
|
||||
from archivebox.misc.logging_util import printable_filesize
|
||||
from archivebox.progressmonitor.views import progress_endpoint
|
||||
from archivebox.plugins.output_groups import OUTPUT_GROUPS, order_snapshot_outputs
|
||||
from archivebox.plugins.output_groups import OUTPUT_GROUPS, order_snapshot_outputs, plugin_card_is_hidden, plugin_output_sizes
|
||||
|
||||
runtime_config = get_request_config(request) if request is not None else get_config()
|
||||
self._runtime_config = runtime_config
|
||||
@@ -3523,8 +3707,9 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
self.__dict__["num_outputs_cached"] = sum(result.status == ArchiveResult.StatusChoices.SUCCEEDED for result in archive_results)
|
||||
self.__dict__["num_failures_cached"] = sum(result.status == ArchiveResult.StatusChoices.FAILED for result in archive_results)
|
||||
|
||||
filesystem_index = {} if discover_files else None
|
||||
hidden_card_plugins = {"archivedotorg", "favicon"}
|
||||
# Static exports must reflect files that still exist even when the
|
||||
# saved hash manifest is incomplete or stale.
|
||||
filesystem_index = {} if discover_files or static_export_dir is not None else None
|
||||
outputs = [
|
||||
output
|
||||
for output in self.discover_outputs(
|
||||
@@ -3532,7 +3717,15 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
archive_results=archive_results,
|
||||
filesystem_index=filesystem_index,
|
||||
)
|
||||
if (output.get("size") or 0) > 0 and output.get("name") not in hidden_card_plugins
|
||||
if (output.get("size") or 0) > 0
|
||||
and not plugin_card_is_hidden(str(output.get("name") or ""))
|
||||
# Files left by failed/started/noresults hooks remain browsable,
|
||||
# but only the DB can authorize a successful live output card.
|
||||
and (
|
||||
output["result"].status == ArchiveResult.StatusChoices.SUCCEEDED
|
||||
if output.get("result") is not None
|
||||
else static_export_dir is not None
|
||||
)
|
||||
]
|
||||
if static_export_dir is not None:
|
||||
|
||||
@@ -3544,6 +3737,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
return not output_path.is_absolute() and ".." not in output_path.parts and (static_export_dir / output_path).exists()
|
||||
|
||||
outputs = [output for output in outputs if static_output_exists(output)]
|
||||
# A snapshot's index.jsonl is a manifest, not another captured
|
||||
# output card. Preserve it as the preview fallback only when the
|
||||
# manifest is the snapshot's sole available output.
|
||||
if any(output.get("path") != "index.jsonl" for output in outputs):
|
||||
outputs = [output for output in outputs if output.get("path") != "index.jsonl"]
|
||||
outputs_by_name: dict[str, dict[str, Any]] = {}
|
||||
result_ids_by_name: dict[str, list[str]] = {}
|
||||
for output in outputs:
|
||||
@@ -3554,22 +3752,20 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
outputs_by_name[output["name"]] = output
|
||||
# Several hooks can report the same plugin directory. Count overlapping
|
||||
# files once, while retaining every result ID for the existing delete action.
|
||||
files_by_name: dict[str, dict[str, int]] = {}
|
||||
for result in archive_results:
|
||||
if result.plugin not in outputs_by_name:
|
||||
continue
|
||||
files = files_by_name.setdefault(result.plugin, {})
|
||||
for path, metadata in result.output_file_map().items():
|
||||
files[path] = result._coerce_output_file_size(metadata.get("size"))
|
||||
output_sizes = plugin_output_sizes(archive_results)
|
||||
for name, output in outputs_by_name.items():
|
||||
output["result_ids"] = ",".join(result_ids_by_name.get(name, ()))
|
||||
if files_by_name.get(name):
|
||||
output["size"] = sum(files_by_name[name].values())
|
||||
output["folder_path"] = (
|
||||
output["result"].output_folder_path() if output.get("result") is not None else str(Path(str(output["path"])).parent)
|
||||
)
|
||||
if output["folder_path"] == ".":
|
||||
output["folder_path"] = ""
|
||||
if name in output_sizes:
|
||||
output["size"] = output_sizes[name]
|
||||
|
||||
hash_index = filesystem_index if filesystem_index is not None else (self.hashes_index if static_export_dir is not None else {})
|
||||
loose_items, failed_items = self.get_detail_page_auxiliary_items(
|
||||
outputs,
|
||||
hidden_card_plugins=hidden_card_plugins,
|
||||
archive_results=archive_results,
|
||||
hashes_index=hash_index,
|
||||
)
|
||||
@@ -3596,11 +3792,27 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
type(self)
|
||||
.objects.filter(url=self.url)
|
||||
.exclude(id=self.id)
|
||||
.only("id", "url", "bookmarked_at", "created_at", "downloaded_at", "output_size")
|
||||
.order_by("-bookmarked_at", "-created_at", "-timestamp")[:25],
|
||||
.select_related("crawl__created_by")
|
||||
.only(
|
||||
"id",
|
||||
"url",
|
||||
"timestamp",
|
||||
"fs_version",
|
||||
"bookmarked_at",
|
||||
"created_at",
|
||||
"downloaded_at",
|
||||
"output_size",
|
||||
"crawl__created_by__username",
|
||||
)
|
||||
.order_by("-bookmarked_at", "-created_at", "-timestamp"),
|
||||
)
|
||||
url_snapshots = [*related_snapshots, self]
|
||||
url_snapshots.sort(
|
||||
key=lambda snapshot: snapshot.bookmarked_at or snapshot.created_at or snapshot.downloaded_at or timezone.now(),
|
||||
reverse=True,
|
||||
)
|
||||
related_years_map: dict[int, list[Snapshot]] = {}
|
||||
for snapshot in [self, *related_snapshots]:
|
||||
for snapshot in url_snapshots:
|
||||
snapshot_date = snapshot.bookmarked_at or snapshot.created_at or snapshot.downloaded_at
|
||||
if snapshot_date:
|
||||
related_years_map.setdefault(snapshot_date.year, []).append(snapshot)
|
||||
@@ -3610,14 +3822,24 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
key=lambda snapshot: snapshot.bookmarked_at or snapshot.created_at or snapshot.downloaded_at or timezone.now(),
|
||||
reverse=True,
|
||||
)
|
||||
related_years.append({"year": year, "latest": snapshots[0], "snapshots": snapshots})
|
||||
related_years.sort(key=lambda item: item["year"], reverse=True)
|
||||
related_years.append(
|
||||
{
|
||||
"year": year,
|
||||
"latest": snapshots[0],
|
||||
"snapshots": snapshots,
|
||||
"selected_date": (self.bookmarked_at or self.created_at or self.downloaded_at) if self in snapshots else None,
|
||||
},
|
||||
)
|
||||
related_years.sort(key=lambda item: item["year"])
|
||||
|
||||
warc_path = next(
|
||||
(rel_path for rel_path in hash_index if rel_path.startswith("warc/") and ".warc" in Path(rel_path).name),
|
||||
"warc/",
|
||||
)
|
||||
user = getattr(request, "user", None)
|
||||
# The parent-domain hint is only a presentation bit for web.*/snap-*;
|
||||
# it never authorizes deletion. The X button navigates to admin.*,
|
||||
# where the host-only session and CSRF checks guard the real action.
|
||||
can_delete_outputs = bool(
|
||||
static_export_dir is None
|
||||
and request is not None
|
||||
@@ -3655,7 +3877,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
"best_result": best_result,
|
||||
"snapshot": self,
|
||||
"CONFIG": runtime_config,
|
||||
"related_snapshots": related_snapshots,
|
||||
"url_snapshots": url_snapshots,
|
||||
"related_years": related_years,
|
||||
"loose_items": loose_items,
|
||||
"failed_items": failed_items,
|
||||
@@ -3682,13 +3904,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
|
||||
def get_detail_page_auxiliary_items(
|
||||
self,
|
||||
outputs: list[dict] | None = None,
|
||||
hidden_card_plugins: set[str] | None = None,
|
||||
archive_results: list["ArchiveResult"] | None = None,
|
||||
hashes_index: dict[str, dict[str, Any]] | None = None,
|
||||
) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
|
||||
if outputs is None:
|
||||
outputs = self.discover_outputs(include_filesystem_fallback=True)
|
||||
hidden_card_plugins = hidden_card_plugins or set()
|
||||
accounted_entries: set[str] = set()
|
||||
for output in outputs:
|
||||
output_name = str(output.get("name") or "")
|
||||
@@ -4194,7 +4414,9 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
|
||||
return str(reverse_lazy("api-1:get_archiveresult", args=[self.id]))
|
||||
|
||||
def get_absolute_url(self):
|
||||
return f"/{self.snapshot.archive_path}/{self.plugin}"
|
||||
from archivebox.core.routes_util import build_snapshot_detail_path
|
||||
|
||||
return build_snapshot_detail_path(self.snapshot.archive_path_from_db, output_path=self.plugin)
|
||||
|
||||
@property
|
||||
def is_paused(self) -> bool:
|
||||
@@ -4214,11 +4436,37 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
|
||||
return 0
|
||||
|
||||
def output_file_map(self) -> dict[str, dict[str, Any]]:
|
||||
# List views opt into this cache on their read-only, request-local rows.
|
||||
# Runner-owned results must still reflect metadata changed during a hook.
|
||||
if "_render_output_file_map" in self.__dict__:
|
||||
return self._render_output_file_map
|
||||
return self._normalize_output_files(self.output_files)
|
||||
|
||||
def output_file_paths(self) -> list[str]:
|
||||
return list(self.output_file_map().keys())
|
||||
|
||||
def output_file_path(
|
||||
self,
|
||||
path: str,
|
||||
output_file_map: dict[str, dict[str, Any]] | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve an output_files key to its snapshot-relative path."""
|
||||
requested_path = str(path or "")
|
||||
if not requested_path or requested_path.startswith("/") or ".." in Path(requested_path).parts:
|
||||
return None
|
||||
|
||||
output_file_map = output_file_map if output_file_map is not None else self.output_file_map()
|
||||
plugin_relative_path = requested_path.removeprefix(f"{self.plugin}/")
|
||||
plugin_path = f"{self.plugin}/{plugin_relative_path}"
|
||||
for candidate in dict.fromkeys((requested_path, plugin_relative_path, plugin_path)):
|
||||
file_info = output_file_map.get(candidate)
|
||||
if not isinstance(file_info, dict):
|
||||
continue
|
||||
if file_info.get("root_relative"):
|
||||
return candidate.removeprefix(f"{self.plugin}/")
|
||||
return candidate if candidate.startswith(f"{self.plugin}/") else f"{self.plugin}/{candidate}"
|
||||
return None
|
||||
|
||||
def update_output_metadata_from_filesystem(self, snapshot_dir: Path | None = None, save: bool = True, full_scan: bool = False) -> bool:
|
||||
from abx_dl.output_files import OutputManifest, output_file_from_path
|
||||
|
||||
@@ -4346,11 +4594,20 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
|
||||
output_file_map: dict[str, dict[str, Any]] | None = None,
|
||||
) -> str | None:
|
||||
ignored = {"stdout.log", "stderr.log", "hook.pid", "listener.pid"}
|
||||
candidates = [
|
||||
path
|
||||
for path in output_file_paths
|
||||
if Path(path).name not in ignored and Path(path).suffix.lower() not in (".pid", ".log", ".sh")
|
||||
]
|
||||
# Responses/wget manifests can contain thousands of paths. Parse each
|
||||
# once instead of rebuilding Path objects for every preferred filename
|
||||
# and extension group, each time a list row selects a preview.
|
||||
candidates = []
|
||||
names = {}
|
||||
extensions = {}
|
||||
for path in output_file_paths:
|
||||
parsed_path = Path(path)
|
||||
name, extension = parsed_path.name, parsed_path.suffix.lower()
|
||||
if name in ignored or extension in (".pid", ".log", ".sh"):
|
||||
continue
|
||||
candidates.append(path)
|
||||
names.setdefault(name.lower(), path)
|
||||
extensions[path] = extension
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
@@ -4373,32 +4630,19 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
|
||||
"article.json",
|
||||
]
|
||||
for preferred_name in preferred_names:
|
||||
for candidate in candidates:
|
||||
if Path(candidate).name.lower() == preferred_name:
|
||||
return candidate
|
||||
if preferred_name in names:
|
||||
return names[preferred_name]
|
||||
|
||||
plugin_lower = (plugin_name or "").lower()
|
||||
if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl"):
|
||||
# yt-dlp commonly emits a thumbnail plus several media formats.
|
||||
# Prefer something browsers can play directly; otherwise cards can
|
||||
# select a large MKV or thumbnail that the plugin player cannot use.
|
||||
ext_groups = (
|
||||
(".mp4", ".webm", ".m4v", ".ogv"),
|
||||
(".mp3", ".m4a", ".aac", ".opus", ".ogg", ".wav", ".flac"),
|
||||
(".mkv", ".mov", ".avi", ".flv", ".wmv", ".mpg", ".mpeg", ".ts", ".m2ts", ".mts", ".3gp", ".3g2"),
|
||||
(".html", ".htm", ".mhtml", ".mht", ".pdf"),
|
||||
(".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"),
|
||||
(".json", ".jsonl", ".txt", ".md", ".csv", ".tsv", ".srt", ".vtt"),
|
||||
)
|
||||
else:
|
||||
ext_groups = (
|
||||
(".html", ".htm", ".mhtml", ".mht", ".pdf"),
|
||||
(".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"),
|
||||
(".json", ".jsonl", ".txt", ".md", ".csv", ".tsv"),
|
||||
(".mp4", ".webm", ".mp3", ".opus", ".ogg", ".wav"),
|
||||
)
|
||||
from archivebox.plugins.discovery import get_plugin_output_extension_preference
|
||||
|
||||
ext_groups = get_plugin_output_extension_preference(plugin_name or "") or (
|
||||
(".html", ".htm", ".mhtml", ".mht", ".pdf"),
|
||||
(".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"),
|
||||
(".json", ".jsonl", ".txt", ".md", ".csv", ".tsv"),
|
||||
(".mp4", ".webm", ".mp3", ".opus", ".ogg", ".wav"),
|
||||
)
|
||||
for ext_group in ext_groups:
|
||||
group_candidates = [candidate for candidate in candidates if Path(candidate).suffix.lower() in ext_group]
|
||||
group_candidates = [candidate for candidate in candidates if extensions[candidate] in ext_group]
|
||||
if group_candidates:
|
||||
return max(
|
||||
group_candidates,
|
||||
@@ -4441,12 +4685,9 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
|
||||
def embed_path_db(self, output_file_map: dict[str, dict[str, Any]] | None = None) -> str | None:
|
||||
output_file_map = output_file_map if output_file_map is not None else self.output_file_map()
|
||||
|
||||
def is_root_relative(path: str) -> bool:
|
||||
metadata = output_file_map.get(path) or {}
|
||||
return bool(isinstance(metadata, dict) and metadata.get("root_relative"))
|
||||
|
||||
if self.output_str:
|
||||
raw_output = str(self.output_str).strip()
|
||||
output_str = "" if "output_str" in self.get_deferred_fields() else self.output_str
|
||||
if output_str:
|
||||
raw_output = str(output_str).strip()
|
||||
if self._looks_like_output_path(raw_output, self.plugin):
|
||||
output_path = Path(raw_output)
|
||||
if output_path.is_absolute():
|
||||
@@ -4474,23 +4715,16 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
|
||||
# payload storage while rendering a live snapshot page.
|
||||
return candidates[0] if ".." not in output_path.parts else None
|
||||
|
||||
if raw_output in output_file_map and is_root_relative(raw_output):
|
||||
return raw_output
|
||||
|
||||
for relative_path in candidates:
|
||||
plugin_relative = relative_path.removeprefix(f"{self.plugin}/")
|
||||
if relative_path in output_file_map:
|
||||
return f"{self.plugin}/{relative_path}" if not relative_path.startswith(f"{self.plugin}/") else relative_path
|
||||
if plugin_relative in output_file_map:
|
||||
return f"{self.plugin}/{plugin_relative}"
|
||||
resolved_path = self.output_file_path(relative_path, output_file_map=output_file_map)
|
||||
if resolved_path:
|
||||
return resolved_path
|
||||
|
||||
output_file_paths = list(output_file_map.keys())
|
||||
if output_file_paths:
|
||||
fallback_path = self._fallback_output_file_path(output_file_paths, self.plugin, output_file_map)
|
||||
if fallback_path:
|
||||
if is_root_relative(fallback_path):
|
||||
return fallback_path
|
||||
return f"{self.plugin}/{fallback_path}"
|
||||
return self.output_file_path(fallback_path, output_file_map=output_file_map)
|
||||
|
||||
return None
|
||||
|
||||
@@ -4503,6 +4737,14 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
|
||||
"""
|
||||
return self.embed_path_db()
|
||||
|
||||
def output_folder_path(self) -> str:
|
||||
"""Return the physical folder for this result's files, using saved metadata."""
|
||||
output_path = self.embed_path_db()
|
||||
if output_path and not output_path.startswith(f"{self.plugin}/"):
|
||||
parent = Path(output_path).parent.as_posix()
|
||||
return "" if parent == "." else parent
|
||||
return self.plugin
|
||||
|
||||
@property
|
||||
def output_dir_name(self) -> str:
|
||||
return self.plugin
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
__package__ = "archivebox.core"
|
||||
|
||||
|
||||
EXTENSION_SCREENSHOT_PLUGIN = "chrome_extension_screenshot"
|
||||
@@ -74,8 +74,9 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
|
||||
**snapshot_filter,
|
||||
).update(retry_at=now, modified_at=now)
|
||||
|
||||
# Find rare uploads through hook_name's index before checking their parents;
|
||||
# a correlated upload probe otherwise visits every sealed snapshot.
|
||||
extension_upload_results = ArchiveResult.objects.filter(
|
||||
snapshot_id=OuterRef("pk"),
|
||||
hook_name=Snapshot.BROWSER_EXTENSION_UPLOAD_HOOK_NAME,
|
||||
)
|
||||
server_results = ArchiveResult.objects.filter(snapshot_id=OuterRef("pk")).exclude(
|
||||
@@ -83,15 +84,15 @@ def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str |
|
||||
)
|
||||
extension_only_snapshots = (
|
||||
Snapshot.objects.filter(
|
||||
id__in=extension_upload_results.values("snapshot_id"),
|
||||
status=Snapshot.StatusChoices.SEALED,
|
||||
crawl__status__in=[Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED, Crawl.StatusChoices.SEALED],
|
||||
**snapshot_filter,
|
||||
)
|
||||
.annotate(
|
||||
has_extension_upload=Exists(extension_upload_results),
|
||||
has_server_result=Exists(server_results),
|
||||
)
|
||||
.filter(has_extension_upload=True, has_server_result=False)
|
||||
.filter(has_server_result=False)
|
||||
)
|
||||
# Older browser-extension uploads could win a race with runner startup:
|
||||
# their successful external rows made the fresh Snapshot look finished
|
||||
|
||||
+112
-21
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import parse_qsl, quote, urlencode, urlparse, urlsplit, urlunsplit
|
||||
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
@@ -11,6 +11,8 @@ from archivebox.config.common import get_config, get_request_config
|
||||
_SNAPSHOT_ID_RE = re.compile(r"^[0-9a-fA-F-]{8,36}$")
|
||||
_SNAPSHOT_SUBDOMAIN_RE = re.compile(r"^snap-(?P<suffix>[0-9a-fA-F]{12})$")
|
||||
_ROLE_SUBDOMAIN_LABELS = ("admin", "web", "api")
|
||||
_URL_PATH_SAFE = "/@-._~!$&'()*+,;="
|
||||
_URL_FRAGMENT_SAFE = "/@-._~!$&'()*+,;="
|
||||
|
||||
|
||||
def split_host_port(host: str) -> tuple[str, str | None]:
|
||||
@@ -60,13 +62,6 @@ def _csrf_trusted_origins(config) -> list[str]:
|
||||
return seen
|
||||
|
||||
|
||||
def _allowed_hosts(config) -> set[str]:
|
||||
raw = (config.ALLOWED_HOSTS or "").strip()
|
||||
if not raw:
|
||||
return set()
|
||||
return {entry.strip().lower() for entry in raw.split(",") if entry.strip() and entry.strip() != "*"}
|
||||
|
||||
|
||||
def derive_base_url_from_csrf(config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
"""Pick a single CSRF_TRUSTED_ORIGINS entry to act as the implicit BASE_URL.
|
||||
|
||||
@@ -93,11 +88,6 @@ def get_listen_host(config: dict[str, Any] | None = None, **config_kwargs: Any)
|
||||
return (config.BIND_ADDR or "").strip()
|
||||
|
||||
|
||||
def get_listen_parts(config: dict[str, Any] | None = None, **config_kwargs: Any) -> tuple[str, str | None]:
|
||||
config = config or get_config(**config_kwargs)
|
||||
return split_host_port(get_listen_host(config=config))
|
||||
|
||||
|
||||
def _with_port(host: str, port: str | None) -> str:
|
||||
return f"{host}:{port}" if port else host
|
||||
|
||||
@@ -146,7 +136,7 @@ def canonical_base_host_for_request(request_host: str) -> str:
|
||||
|
||||
def _root_host_from_listen(config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
config = config or get_config(**config_kwargs)
|
||||
listen_host, listen_port = get_listen_parts(config=config)
|
||||
listen_host, listen_port = split_host_port(get_listen_host(config=config))
|
||||
root_host = "archivebox.localhost" if _is_local_bind_host(listen_host) else listen_host
|
||||
return _with_port(root_host, listen_port) if root_host else ""
|
||||
|
||||
@@ -380,11 +370,6 @@ def get_snapshot_base_url(snapshot_id: str, request=None, config: dict[str, Any]
|
||||
)
|
||||
|
||||
|
||||
def get_original_base_url(domain: str, request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
config = config or (get_request_config(request) if request is not None else get_config(**config_kwargs))
|
||||
return _build_url(get_web_base_url(request=request, config=config), f"/original/{domain}")
|
||||
|
||||
|
||||
def build_admin_url(path: str = "", request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
return _build_url(get_admin_base_url(request, config=config, **config_kwargs), path)
|
||||
|
||||
@@ -394,11 +379,109 @@ def build_web_url(path: str = "", request=None, config: dict[str, Any] | None =
|
||||
|
||||
|
||||
def build_snapshot_url(snapshot_id: str, path: str = "", request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
return _build_url(get_snapshot_base_url(snapshot_id, request=request, config=config, **config_kwargs), path)
|
||||
# `path` is a filesystem-relative output path, not an already-encoded URL.
|
||||
# In particular, wget can save literal `%3A` in filenames; emitting that
|
||||
# unchanged makes the browser request `:`, which cannot match the file.
|
||||
output_path = quote(str(path).lstrip("/"), safe=_URL_PATH_SAFE)
|
||||
return _build_url(get_snapshot_base_url(snapshot_id, request=request, config=config, **config_kwargs), output_path)
|
||||
|
||||
|
||||
def build_snapshot_detail_path(archive_path: str, output_path: str = "", prefix: str = "/") -> str:
|
||||
"""Build the live Django snapshot-detail path and optional output anchor."""
|
||||
normalized_archive_path = quote(str(archive_path or "").strip("/"), safe=_URL_PATH_SAFE)
|
||||
detail_path = f"{prefix}{normalized_archive_path}"
|
||||
normalized_output_path = str(output_path or "").lstrip("#/")
|
||||
if not normalized_output_path:
|
||||
return detail_path
|
||||
fragment = quote(normalized_output_path, safe=_URL_FRAGMENT_SAFE)
|
||||
return f"{detail_path}#{fragment}"
|
||||
|
||||
|
||||
def build_snapshot_index_path(archive_path: str, output_path: str = "", prefix: str = "/") -> str:
|
||||
"""Build a path to the literal exported ``index.html`` snapshot file."""
|
||||
normalized_archive_path = quote(str(archive_path or "").strip("/"), safe=_URL_PATH_SAFE)
|
||||
index_path = f"{prefix}{normalized_archive_path}/index.html"
|
||||
normalized_output_path = str(output_path or "").lstrip("#/")
|
||||
if not normalized_output_path:
|
||||
return index_path
|
||||
fragment = quote(normalized_output_path, safe=_URL_FRAGMENT_SAFE)
|
||||
return f"{index_path}#{fragment}"
|
||||
|
||||
|
||||
def get_snapshot_output_anchor(plugin: str, output_path: str) -> str:
|
||||
"""Map root-relative legacy outputs to the plugin card that displays them."""
|
||||
normalized_output_path = str(output_path or "").lstrip("/")
|
||||
return normalized_output_path if normalized_output_path.startswith(f"{plugin}/") else plugin
|
||||
|
||||
|
||||
def build_snapshot_detail_url(
|
||||
archive_path: str,
|
||||
output_path: str = "",
|
||||
request=None,
|
||||
config: dict[str, Any] | None = None,
|
||||
**config_kwargs: Any,
|
||||
) -> str:
|
||||
"""Build a request-aware URL for trusted snapshot UI on the web origin."""
|
||||
return build_web_url(
|
||||
build_snapshot_detail_path(archive_path, output_path=output_path),
|
||||
request=request,
|
||||
config=config,
|
||||
**config_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def build_snapshot_role_output_url(
|
||||
snapshot,
|
||||
role: str,
|
||||
request=None,
|
||||
config: dict[str, Any] | None = None,
|
||||
archive_results=None,
|
||||
fallback_to_default: bool = False,
|
||||
**config_kwargs: Any,
|
||||
) -> str:
|
||||
"""Build the first available output URL for a plugin-owned snapshot role."""
|
||||
from archivebox.plugins.discovery import get_plugin_default_output_path, get_snapshot_role_names
|
||||
|
||||
for plugin in get_snapshot_role_names(role):
|
||||
output_path = snapshot.plugin_output_path(plugin, archive_results=archive_results)
|
||||
if not output_path and fallback_to_default:
|
||||
output_path = get_plugin_default_output_path(plugin)
|
||||
if output_path:
|
||||
return build_snapshot_url(str(snapshot.id), output_path, request=request, config=config, **config_kwargs)
|
||||
return ""
|
||||
|
||||
|
||||
def build_snapshot_files_url(
|
||||
snapshot_id: str,
|
||||
path: str = "",
|
||||
request=None,
|
||||
config: dict[str, Any] | None = None,
|
||||
**config_kwargs: Any,
|
||||
) -> str:
|
||||
"""Build a request-aware directory-browser URL on the isolated replay origin."""
|
||||
directory_url = build_snapshot_url(snapshot_id, path, request=request, config=config, **config_kwargs)
|
||||
parts = urlsplit(directory_url)
|
||||
normalized_directory_path = f"{parts.path.rstrip('/')}/"
|
||||
directory_url = urlunsplit((parts.scheme, parts.netloc, normalized_directory_path, parts.query, parts.fragment))
|
||||
return _replace_url_query_params(directory_url, files="1")
|
||||
|
||||
|
||||
def build_snapshot_zip_url(
|
||||
snapshot_id: str,
|
||||
path: str = "",
|
||||
request=None,
|
||||
config: dict[str, Any] | None = None,
|
||||
**config_kwargs: Any,
|
||||
) -> str:
|
||||
"""Build a request-aware ZIP download URL for a snapshot directory."""
|
||||
files_url = build_snapshot_files_url(snapshot_id, path, request=request, config=config, **config_kwargs)
|
||||
return _replace_url_query_params(files_url, download="zip")
|
||||
|
||||
|
||||
def build_original_url(domain: str, path: str = "", request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
|
||||
return _build_url(get_original_base_url(domain, request=request, config=config, **config_kwargs), path)
|
||||
config = config or (get_request_config(request) if request is not None else get_config(**config_kwargs))
|
||||
base_url = _build_url(get_web_base_url(request=request, config=config), f"/original/{domain}")
|
||||
return _build_url(base_url, path)
|
||||
|
||||
|
||||
def _build_url(base_url: str, path: str) -> str:
|
||||
@@ -409,3 +492,11 @@ def _build_url(base_url: str, path: str) -> str:
|
||||
if not path:
|
||||
return base_url
|
||||
return f"{base_url}{path if path.startswith('/') else f'/{path}'}"
|
||||
|
||||
|
||||
def _replace_url_query_params(url: str, **params: str) -> str:
|
||||
parts = urlsplit(url)
|
||||
replacement_keys = set(params)
|
||||
query = [(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True) if key not in replacement_keys]
|
||||
query.extend((key, value) for key, value in params.items())
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||
@@ -1,3 +1,28 @@
|
||||
"""Shared foreground signal and child-process cleanup contracts.
|
||||
|
||||
Shutdown intent and process exit are separate: takeover must preserve the
|
||||
replacement owner's work, while a user abort must not later resume the crawl.
|
||||
Callers own that policy; this module preserves the received signal and stops
|
||||
only the process objects they explicitly supply.
|
||||
|
||||
Cooperative boundaries are essential, not just quieter exception handling.
|
||||
A real Linux CI regression interrupted Popen.poll() with SIGINT and stranded
|
||||
its wait lock. SIGKILL stopped the child (it became a zombie), but Popen.wait()
|
||||
still timed out. Raising a different exception or waiting longer cannot repair
|
||||
that interrupted critical section. Polling loops must record the signal first
|
||||
and honor it between operations; async owners may cancel their own task while
|
||||
their loop is running. Once exiting, the last-resort process cleanup must stay
|
||||
synchronous: the original loop may be closed, and creating a replacement loop
|
||||
cannot recover tasks or resources owned by it.
|
||||
|
||||
Keep immediate signal notices and repeated-signal force exit independent of
|
||||
the event bus: it may already be draining or closed during shutdown. Resumable
|
||||
pause/retry/skip belongs to the existing crawl controller and must not set
|
||||
sticky command-shutdown state. See foreground_shutdown_signals below for both
|
||||
cooperative and legacy blocking callers; do not change all callers' policy at
|
||||
once without supplying their interruption boundaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -23,7 +48,7 @@ _active_shutdown_state: ShutdownSignalState | None = None
|
||||
|
||||
|
||||
def raise_if_shutdown_requested() -> None:
|
||||
"""Let long foreground loops honor a signal even if Python ignored it once."""
|
||||
"""Honor sticky shutdown intent at a caller-owned interruption boundary."""
|
||||
|
||||
if _active_shutdown_state and _active_shutdown_state.signal_name:
|
||||
raise KeyboardInterrupt
|
||||
@@ -48,14 +73,37 @@ def wait_popen_and_kill_children(
|
||||
timeout: float,
|
||||
kill_timeout: float = 2.0,
|
||||
) -> None:
|
||||
"""Wait for a Popen parent and then hard-kill any surviving descendants."""
|
||||
"""Reap our own Popen child and stop its explicitly captured descendants.
|
||||
|
||||
A dead child still needs its parent to reap it. Keep wait failures visible;
|
||||
bypassing Popen's lock would hide the interrupted-owner bug described above.
|
||||
Descendants must still be cleaned up when that wait fails.
|
||||
"""
|
||||
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=kill_timeout)
|
||||
kill_remaining_processes(children, timeout=kill_timeout)
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=kill_timeout)
|
||||
except subprocess.TimeoutExpired as err:
|
||||
try:
|
||||
state = psutil.Process(proc.pid).as_dict(attrs=["pid", "ppid", "status", "num_threads", "cpu_times"])
|
||||
except psutil.NoSuchProcess:
|
||||
state = {"pid": proc.pid, "status": "exited"}
|
||||
err.add_note(f"Process state after SIGKILL: {state}")
|
||||
# A locked Popen wait can time out even after the OS process exits.
|
||||
# Inspect CPython's lock only for diagnostics; never release it here.
|
||||
waitpid_lock = getattr(proc, "_waitpid_lock", None)
|
||||
err.add_note(
|
||||
f"Popen state: returncode={proc.returncode}, "
|
||||
f"waitpid_lock_locked={waitpid_lock.locked() if waitpid_lock is not None else None}, "
|
||||
f"threads={[thread.name for thread in threading.enumerate()]}",
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
# A parent that cannot be reaped must not leave its descendants running.
|
||||
kill_remaining_processes(children, timeout=kill_timeout)
|
||||
|
||||
|
||||
def wait_psutil_and_kill_children(
|
||||
@@ -101,13 +149,17 @@ def foreground_shutdown_signals(
|
||||
*,
|
||||
first_signal_message: str | None = "\n[🛑] Got {signal_name}, stopping gracefully...\n",
|
||||
on_signal: Callable[[signal.Signals], None] | None = None,
|
||||
interrupt_handlers: dict[signal.Signals, Callable[[], None]] | None = None,
|
||||
raise_on_first_signal: bool = True,
|
||||
) -> Iterator[ShutdownSignalState]:
|
||||
"""Install foreground signal handlers that print an immediate exit notice.
|
||||
"""Keep shutdown intent visible until the owning foreground command unwinds.
|
||||
|
||||
Some log-tail loops intentionally swallow KeyboardInterrupt so that callers
|
||||
can centralize cleanup in finally blocks. The handler writes the signal name
|
||||
immediately, then raises KeyboardInterrupt to break out of the blocking read.
|
||||
Signal state was added because inner loops can swallow KeyboardInterrupt
|
||||
while their caller still needs to stop its children. Cooperative callers use
|
||||
raise_on_first_signal=False and check raise_if_shutdown_requested() between
|
||||
operations, or cancel their owned async task through on_signal. Raising from
|
||||
the handler can otherwise interrupt library locks or partially sent I/O.
|
||||
Blocking callers without a cooperative boundary retain immediate exceptions.
|
||||
"""
|
||||
|
||||
global _active_shutdown_state
|
||||
@@ -118,10 +170,20 @@ def foreground_shutdown_signals(
|
||||
|
||||
state = ShutdownSignalState()
|
||||
previous_active_state = _active_shutdown_state
|
||||
previous_handlers = {sig: signal.getsignal(sig) for sig in handled_signals}
|
||||
previous_handlers = {sig: signal.getsignal(sig) for sig in (*handled_signals, *(interrupt_handlers or {}))}
|
||||
|
||||
def raise_keyboard_interrupt(signum, _frame):
|
||||
sig = signal.Signals(signum)
|
||||
if interrupt_handlers and sig in interrupt_handlers:
|
||||
# Shield resumable terminal intent from sticky shutdown state.
|
||||
# Ctrl+C is delegated to abx-dl's pause/abort controller; SIGUSR1/2
|
||||
# carry prompt answers. Recording any of these as shutdown here
|
||||
# makes a later skip/retry fail raise_if_shutdown_requested(), or
|
||||
# makes a second Ctrl+C hard-exit before hooks can flush output.
|
||||
# SIGTERM/SIGHUP deliberately bypass this shield: ownership takeover
|
||||
# and parent loss must cancel the old worker without asking for input.
|
||||
interrupt_handlers[sig]()
|
||||
return
|
||||
already_requested = state.signal_name is not None
|
||||
if not already_requested:
|
||||
state.signal_name = sig.name
|
||||
@@ -129,11 +191,9 @@ def foreground_shutdown_signals(
|
||||
os.write(sys.stdout.fileno(), first_signal_message.format(signal_name=state.signal_name).encode())
|
||||
if on_signal is not None:
|
||||
on_signal(sig)
|
||||
# Foreground `archivebox add` uses the first signal to abort the active
|
||||
# hook through the bus-facing runner code, then reserves the second
|
||||
# signal for hard foreground-command shutdown. Server/update/run and
|
||||
# other non-interactive commands raise immediately so their finally
|
||||
# blocks can stop owned children without prompting.
|
||||
# Actual shutdown requests remain sticky and a second signal forces
|
||||
# exit. Resumable interactive actions were handled above without
|
||||
# marking this context as shutting down.
|
||||
if already_requested:
|
||||
os.write(sys.stdout.fileno(), f"\n[🛑] Got {sig.name} again, exiting immediately.\n".encode())
|
||||
os._exit(130)
|
||||
@@ -142,7 +202,7 @@ def foreground_shutdown_signals(
|
||||
|
||||
try:
|
||||
_active_shutdown_state = state
|
||||
for sig in handled_signals:
|
||||
for sig in previous_handlers:
|
||||
signal.signal(sig, raise_keyboard_interrupt)
|
||||
yield state
|
||||
finally:
|
||||
|
||||
@@ -6,15 +6,16 @@ from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Count, Exists, F, OuterRef, QuerySet
|
||||
from django.db.models import Count, Exists, F, OuterRef, Prefetch, QuerySet
|
||||
from django.db.models.functions import Lower
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.core.models import ArchiveResult, Snapshot, SnapshotTag, Tag
|
||||
from archivebox.core.routes_util import build_snapshot_detail_url, build_snapshot_role_output_url
|
||||
from archivebox.misc.util import sanitize_html_text
|
||||
from archivebox.core.routes_util import build_snapshot_url, build_web_url
|
||||
from archivebox.core.models import Snapshot, SnapshotTag, Tag
|
||||
from archivebox.plugins.discovery import get_snapshot_role_names
|
||||
|
||||
|
||||
TAG_SNAPSHOT_PREVIEW_LIMIT = 10
|
||||
@@ -202,9 +203,16 @@ def _build_snapshot_preview(snapshot: Snapshot, request: HttpRequest | None = No
|
||||
"id": str(snapshot.pk),
|
||||
"title": _display_snapshot_title(snapshot),
|
||||
"url": snapshot.url,
|
||||
"favicon_url": build_snapshot_url(str(snapshot.pk), "favicon.ico", request=request, config=config),
|
||||
"favicon_url": build_snapshot_role_output_url(
|
||||
snapshot,
|
||||
"list_icon",
|
||||
request=request,
|
||||
config=config,
|
||||
archive_results=snapshot._list_icon_results,
|
||||
fallback_to_default=True,
|
||||
),
|
||||
"admin_url": reverse("admin:core_snapshot_change", args=[snapshot.pk]),
|
||||
"archive_url": build_web_url(f"/{snapshot.archive_path_from_db}/index.html", request=request, config=config),
|
||||
"archive_url": build_snapshot_detail_url(snapshot.archive_path_from_db, request=request, config=config),
|
||||
"downloaded_at": snapshot.downloaded_at.isoformat() if snapshot.downloaded_at else None,
|
||||
}
|
||||
|
||||
@@ -221,6 +229,22 @@ def _build_snapshot_preview_map(
|
||||
snapshot_tags = (
|
||||
SnapshotTag.objects.filter(tag_id__in=tag_ids)
|
||||
.select_related("snapshot__crawl__created_by")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"snapshot__archiveresult_set",
|
||||
queryset=ArchiveResult.objects.filter(
|
||||
plugin__in=get_snapshot_role_names("list_icon"),
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
).only(
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
"status",
|
||||
"output_str",
|
||||
"output_files",
|
||||
),
|
||||
to_attr="_list_icon_results",
|
||||
),
|
||||
)
|
||||
.order_by(
|
||||
"tag_id",
|
||||
F("snapshot__downloaded_at").desc(nulls_last=True),
|
||||
|
||||
@@ -76,6 +76,8 @@ def command_owns_runtime_stack(command, *, data_dir: str | Path) -> bool:
|
||||
|
||||
def foreground_runner_owner(*, data_dir: str | Path, exclude_id=None):
|
||||
"""Return the newest live local parent allowed to borrow runner/sonic."""
|
||||
from django.db.models import Q
|
||||
|
||||
from archivebox.machine.models import Machine, Process
|
||||
|
||||
machine = Machine.current()
|
||||
@@ -83,7 +85,13 @@ def foreground_runner_owner(*, data_dir: str | Path, exclude_id=None):
|
||||
machine=machine,
|
||||
status=Process.StatusChoices.RUNNING,
|
||||
pwd=str(data_dir),
|
||||
process_type__in=(Process.TypeChoices.SERVER, Process.TypeChoices.ADD, Process.TypeChoices.UPDATE),
|
||||
).filter(
|
||||
Q(process_type__in=(Process.TypeChoices.SERVER, Process.TypeChoices.ADD, Process.TypeChoices.UPDATE))
|
||||
# A direct `run` owns its own hooks, just as add/update own theirs via
|
||||
# a supervised one-shot worker. Respawning the server daemon must not
|
||||
# steal the gate back from that newer foreground command. Supervised
|
||||
# runner children cannot elect themselves above their parent command.
|
||||
| (Q(process_type=Process.TypeChoices.ORCHESTRATOR) & ~Q(parent__process_type=Process.TypeChoices.SUPERVISORD)),
|
||||
)
|
||||
if exclude_id is not None:
|
||||
qs = qs.exclude(id=exclude_id)
|
||||
|
||||
@@ -1,34 +1,76 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from html import unescape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
from abx_plugins.plugins.archivewebpage.replay_preview import is_replay_target as is_archivewebpage_replay_target
|
||||
from django import template
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.html import escape
|
||||
from django.utils.html import escape, format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.text import Truncator
|
||||
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.core.routes_util import (
|
||||
build_snapshot_detail_url,
|
||||
build_snapshot_files_url,
|
||||
build_snapshot_role_output_url,
|
||||
build_snapshot_url,
|
||||
build_snapshot_zip_url,
|
||||
get_admin_base_url,
|
||||
get_snapshot_base_url,
|
||||
get_web_base_url,
|
||||
)
|
||||
from archivebox.core.setup_wizard import get_base_url_mismatch_context, get_setup_wizard_context
|
||||
from archivebox.plugins.discovery import (
|
||||
get_plugin_catalog,
|
||||
get_plugin_icon,
|
||||
get_plugin_name,
|
||||
get_plugin_template,
|
||||
plugin_card_is_interactive,
|
||||
)
|
||||
|
||||
register = template.Library()
|
||||
register.filter("plugin_card_is_interactive", plugin_card_is_interactive)
|
||||
|
||||
# Preview cards are clipped windows onto their content, never scroll areas.
|
||||
# Apply inside the document too: parent overflow cannot constrain an iframe.
|
||||
CARD_OVERFLOW_STYLE = (
|
||||
"<style>html,body{height:100%!important;min-height:0!important}"
|
||||
"html,body,body *{overflow:clip!important;scrollbar-width:none!important}</style>"
|
||||
)
|
||||
|
||||
|
||||
@register.filter
|
||||
def snapshot_url_text(url):
|
||||
"""Emphasize the hostname while preserving the exact selectable URL."""
|
||||
url = str(url or "")
|
||||
match = re.match(r"^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?//([^/?#]*)", url)
|
||||
if not match:
|
||||
return escape(url)
|
||||
netloc = match[1]
|
||||
authority = netloc.rsplit("@", 1)[-1]
|
||||
start = match.start(1) + len(netloc) - len(authority)
|
||||
host_end = authority.find("]") + 1 if authority.startswith("[") else authority.find(":")
|
||||
hostname = authority[:host_end] if host_end > 0 else authority
|
||||
if hostname.lower().startswith("www."):
|
||||
start += 4
|
||||
hostname = hostname[4:]
|
||||
remainder = url[start + len(hostname) :]
|
||||
suffix = re.search(r"[?&#]", remainder)
|
||||
suffix_start = suffix.start() if suffix else len(remainder)
|
||||
return format_html(
|
||||
'<span class="snapshot-url-prefix">{}</span><span class="snapshot-url-domain">{}</span>'
|
||||
'<span class="snapshot-url-path">{}</span><span class="snapshot-url-suffix">{}</span>',
|
||||
url[:start],
|
||||
hostname,
|
||||
remainder[:suffix_start],
|
||||
remainder[suffix_start:],
|
||||
)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
@@ -191,7 +233,13 @@ def _list_media_files(result, *, include_filesystem_fallback=True) -> list[dict]
|
||||
candidates.append((rel_path, size))
|
||||
|
||||
for rel_path, size in candidates:
|
||||
href = str(Path(result.plugin) / rel_path)
|
||||
if callable(getattr(result, "output_file_path", None)):
|
||||
href = result.output_file_path(str(rel_path), output_file_map=output_files)
|
||||
else:
|
||||
metadata = output_files.get(str(rel_path)) or {}
|
||||
href = str(rel_path) if metadata.get("root_relative") else str(Path(result.plugin) / rel_path)
|
||||
if not href:
|
||||
continue
|
||||
suffix = rel_path.suffix.lower()
|
||||
media_type = "video" if suffix in _VIDEO_FILE_EXTS else "audio"
|
||||
media_files.append(
|
||||
@@ -270,12 +318,14 @@ def _snapshot_base_url_for_context(context, snapshot) -> str:
|
||||
|
||||
def _snapshot_url_for_context(context, snapshot, path: str = "") -> str:
|
||||
if not context.get("STATIC_EXPORT"):
|
||||
return build_snapshot_url(
|
||||
path_part, separator, query = str(path or "").partition("?")
|
||||
url = build_snapshot_url(
|
||||
str(_snapshot_id(snapshot)),
|
||||
path,
|
||||
path_part,
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
)
|
||||
return f"{url}?{query}" if separator else url
|
||||
|
||||
base_url = _static_snapshot_base_url(context, snapshot)
|
||||
raw_path = str(path or "")
|
||||
@@ -287,14 +337,6 @@ def _snapshot_url_for_context(context, snapshot, path: str = "") -> str:
|
||||
return f"{base_url.rstrip('/')}/{quoted_path}{suffix}"
|
||||
|
||||
|
||||
def _build_snapshot_files_url(snapshot_id: str, request=None, config=None, base_url: str | None = None) -> str:
|
||||
return (
|
||||
f"{base_url.rstrip('/')}/index.jsonl"
|
||||
if base_url
|
||||
else build_snapshot_url(str(snapshot_id), "/?files=1", request=request, config=config)
|
||||
)
|
||||
|
||||
|
||||
def _build_snapshot_preview_url(
|
||||
snapshot_id: str,
|
||||
path: str = "",
|
||||
@@ -306,20 +348,25 @@ def _build_snapshot_preview_url(
|
||||
if path == "about:blank":
|
||||
return path
|
||||
if _is_root_snapshot_output_path(path):
|
||||
return _build_snapshot_files_url(snapshot_id, request=request, config=config, base_url=base_url)
|
||||
return (
|
||||
f"{base_url.rstrip('/')}/index.jsonl"
|
||||
if base_url
|
||||
else build_snapshot_files_url(str(snapshot_id), request=request, config=config)
|
||||
)
|
||||
if base_url:
|
||||
path_part, separator, query = str(path).lstrip("/").partition("?")
|
||||
url = f"{base_url.rstrip('/')}/{quote(path_part, safe=_STATIC_URL_SAFE)}"
|
||||
if separator:
|
||||
url = f"{url}?{query}"
|
||||
else:
|
||||
url = build_snapshot_url(str(snapshot_id), path, request=request, config=config)
|
||||
path_part, separator, query = str(path).partition("?")
|
||||
url = build_snapshot_url(str(snapshot_id), path_part, request=request, config=config)
|
||||
if separator:
|
||||
url = f"{url}?{query}"
|
||||
path_parts = Path(path).parts
|
||||
plugin = get_plugin_name(plugin) if plugin else (path_parts[0] if len(path_parts) > 1 else "")
|
||||
has_plugin_preview = bool(plugin and get_plugin_template(plugin, "full", fallback=False))
|
||||
if not (
|
||||
_is_text_preview_path(path) or _is_image_preview_path(path) or has_plugin_preview or is_archivewebpage_replay_target(path or "")
|
||||
):
|
||||
if not (_is_text_preview_path(path) or _is_image_preview_path(path) or has_plugin_preview):
|
||||
return url
|
||||
separator = "&" if "?" in url else "?"
|
||||
return f"{url}{separator}preview=1"
|
||||
@@ -330,7 +377,7 @@ def _render_text_preview(plugin: str, icon_html: str, snippet: str) -> str:
|
||||
plugin_label = escape(plugin or "")
|
||||
escaped = escape(snippet)
|
||||
return (
|
||||
f'<div class="thumbnail-text" data-plugin="{plugin_attr}" data-compact="1">'
|
||||
f'<div class="thumbnail-text" data-plugin="{plugin_attr}">'
|
||||
f'<div class="thumbnail-text-header">'
|
||||
f'<span class="thumbnail-compact-icon">{icon_html}</span>'
|
||||
f'<span class="thumbnail-text-title">{plugin_label}</span>'
|
||||
@@ -345,7 +392,7 @@ def _render_fallback_card(plugin: str, icon_html: str, fallback_label: str) -> s
|
||||
plugin_label = escape(plugin or "")
|
||||
fallback_attr = escape(fallback_label)
|
||||
return (
|
||||
f'<div class="thumbnail-compact" data-plugin="{plugin_attr}" data-compact="1">'
|
||||
f'<div class="thumbnail-compact" data-plugin="{plugin_attr}">'
|
||||
f'<span class="thumbnail-compact-icon">{icon_html}</span>'
|
||||
f'<span class="thumbnail-compact-label">{plugin_label}</span>'
|
||||
f'<span class="thumbnail-compact-meta">{fallback_attr}</span>'
|
||||
@@ -385,12 +432,29 @@ def index(value, position):
|
||||
|
||||
|
||||
@register.filter
|
||||
def file_size(num_bytes: float) -> str:
|
||||
def file_size(num_bytes: float, decimal_places: int = 1) -> str:
|
||||
for count in ["Bytes", "KB", "MB", "GB"]:
|
||||
if num_bytes > -1024.0 and num_bytes < 1024.0:
|
||||
return f"{num_bytes:3.1f} {count}"
|
||||
if num_bytes >= 0:
|
||||
precision = 10 ** max(0, int(decimal_places))
|
||||
num_bytes = math.ceil(num_bytes * precision) / precision
|
||||
return f"{num_bytes:.{decimal_places}f} {count}"
|
||||
num_bytes /= 1024.0
|
||||
return "{:3.1f} {}".format(num_bytes, "TB")
|
||||
if num_bytes >= 0:
|
||||
precision = 10 ** max(0, int(decimal_places))
|
||||
num_bytes = math.ceil(num_bytes * precision) / precision
|
||||
return f"{num_bytes:.{decimal_places}f} TB"
|
||||
|
||||
|
||||
@register.filter
|
||||
def grid_file_size(num_bytes: float | None) -> str:
|
||||
if not num_bytes or num_bytes <= 0:
|
||||
return ""
|
||||
if num_bytes < 1024**2:
|
||||
return f"{max(1, num_bytes / 1024):.0f}kb"
|
||||
if num_bytes < 1024**3:
|
||||
return f"{num_bytes / 1024**2:.0f}mb"
|
||||
return f"{num_bytes / 1024**3:.1f}gb"
|
||||
|
||||
|
||||
@register.filter
|
||||
@@ -557,73 +621,140 @@ def snapshot_base_url(context, snapshot) -> str:
|
||||
return _snapshot_base_url_for_context(context, snapshot)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_detail_url(context, snapshot) -> str:
|
||||
"""Keep the trusted snapshot detail page on web.*, not the replay host."""
|
||||
if context.get("STATIC_EXPORT"):
|
||||
return _snapshot_url_for_context(context, snapshot, "index.html")
|
||||
return build_snapshot_detail_url(
|
||||
snapshot.archive_path_from_db,
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_url(context, snapshot, path: str = "") -> str:
|
||||
return _snapshot_url_for_context(context, snapshot, path)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_archiveresult_url(context, snapshot, plugin: str, filename: str) -> str:
|
||||
snapshot_id = str(_snapshot_id(snapshot))
|
||||
url_cache = snapshot.__dict__.setdefault("_snapshot_archiveresult_url_cache", {})
|
||||
def snapshot_files_url(context, snapshot, path: str = "") -> str:
|
||||
if context.get("STATIC_EXPORT"):
|
||||
return _snapshot_url_for_context(context, snapshot, "index.jsonl")
|
||||
return build_snapshot_files_url(
|
||||
str(_snapshot_id(snapshot)),
|
||||
path,
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_zip_url(context, snapshot, path: str = "") -> str:
|
||||
if context.get("STATIC_EXPORT"):
|
||||
return ""
|
||||
return build_snapshot_zip_url(
|
||||
str(_snapshot_id(snapshot)),
|
||||
path,
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_role_output_url(context, snapshot, role: str, fallback_to_default: bool = False) -> str:
|
||||
url_cache = snapshot.__dict__.setdefault("_snapshot_role_output_url_cache", {})
|
||||
cache_key = (
|
||||
plugin,
|
||||
filename,
|
||||
role,
|
||||
bool(context.get("STATIC_EXPORT")),
|
||||
str(context.get("STATIC_EXPORT_DIR") or ""),
|
||||
fallback_to_default,
|
||||
)
|
||||
if cache_key in url_cache:
|
||||
return url_cache[cache_key]
|
||||
|
||||
results = None
|
||||
if "_admin_archiveresults" in snapshot.__dict__:
|
||||
results = snapshot.__dict__["_admin_archiveresults"]
|
||||
elif "archiveresult_set" in snapshot.__dict__.get("_prefetched_objects_cache", {}):
|
||||
results = snapshot.archiveresult_set.all()
|
||||
elif "_snapshot_archiveresult_url_results" in snapshot.__dict__:
|
||||
results = snapshot.__dict__["_snapshot_archiveresult_url_results"]
|
||||
from archivebox.plugins.discovery import get_snapshot_role_names
|
||||
|
||||
if results is None:
|
||||
from archivebox.core.models import ArchiveResult
|
||||
|
||||
results = list(
|
||||
ArchiveResult.objects.filter(
|
||||
snapshot_id=snapshot_id,
|
||||
plugin__in=("screenshot", "chrome_extension_screenshot", "favicon"),
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
).only(
|
||||
"plugin",
|
||||
"status",
|
||||
"output_files",
|
||||
),
|
||||
if context.get("STATIC_EXPORT"):
|
||||
output_path = None
|
||||
for plugin in get_snapshot_role_names(role):
|
||||
output_path = snapshot.plugin_output_path(plugin)
|
||||
if output_path:
|
||||
break
|
||||
url_cache[cache_key] = _snapshot_url_for_context(context, snapshot, output_path) if output_path else ""
|
||||
else:
|
||||
url_cache[cache_key] = build_snapshot_role_output_url(
|
||||
snapshot,
|
||||
role,
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
fallback_to_default=fallback_to_default,
|
||||
)
|
||||
snapshot.__dict__["_snapshot_archiveresult_url_results"] = results
|
||||
|
||||
for result in results:
|
||||
if result.plugin != plugin or result.status != "succeeded":
|
||||
continue
|
||||
output_files = result.output_files or {}
|
||||
file_info = output_files.get(filename)
|
||||
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
|
||||
continue
|
||||
output_path = filename if file_info.get("root_relative") else f"{plugin}/{filename}"
|
||||
url_cache[cache_key] = _snapshot_url_for_context(context, snapshot, output_path)
|
||||
return url_cache[cache_key]
|
||||
|
||||
url_cache[cache_key] = ""
|
||||
return ""
|
||||
return url_cache[cache_key]
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def admin_snapshot_archiveresult_url(context, snapshot, plugin: str, filename: str) -> str:
|
||||
if not snapshot_archiveresult_url(context, snapshot, plugin, filename):
|
||||
def snapshot_result_file_url(context, snapshot, result, path: str) -> str:
|
||||
"""Build a saved-file URL from one ArchiveResult declaration."""
|
||||
try:
|
||||
output_path = result.output_file_path(path)
|
||||
except (AttributeError, TypeError, ValueError, OverflowError):
|
||||
return ""
|
||||
return reverse("admin:core_snapshot_preview", args=(snapshot.pk, plugin, filename))
|
||||
if not output_path:
|
||||
return ""
|
||||
return _snapshot_url_for_context(context, snapshot, output_path)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_thumbnail(context, snapshot, admin=False, width="100px", height="100px"):
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from archivebox.plugins.output_groups import snapshot_thumbnail_result
|
||||
|
||||
results = snapshot.__dict__.get("_snapshot_card_results")
|
||||
if results is None:
|
||||
results = snapshot.__dict__.get("_admin_output_results")
|
||||
if results is None:
|
||||
results = snapshot.__dict__.get("_admin_archiveresults")
|
||||
if results is None:
|
||||
results = snapshot.__dict__.get("_prefetched_objects_cache", {}).get("archiveresult_set")
|
||||
if results is None:
|
||||
results = snapshot.archiveresult_set.exclude(plugin="").only(
|
||||
"id",
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
"status",
|
||||
"output_str",
|
||||
"output_files",
|
||||
"output_size",
|
||||
)
|
||||
|
||||
try:
|
||||
host = urlparse(snapshot.url).hostname or "Archive"
|
||||
except ValueError:
|
||||
host = "Archive"
|
||||
host = host.removeprefix("www.")
|
||||
hue = int(hashlib.sha256(host.encode()).hexdigest()[:6], 16) % 360
|
||||
return mark_safe(
|
||||
render_to_string(
|
||||
"core/snapshot_thumbnail.html",
|
||||
{
|
||||
**context.flatten(),
|
||||
"card_result": snapshot_thumbnail_result(results),
|
||||
"host": host,
|
||||
"initial": host[:1].upper(),
|
||||
"hue": hue,
|
||||
"width": width,
|
||||
"height": height,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def snapshot_index_row(context, link) -> str:
|
||||
snapshot_base = _snapshot_base_url_for_context(context, link)
|
||||
detail_url = snapshot_detail_url(context, link)
|
||||
|
||||
status = getattr(link, "status", None) or "unknown"
|
||||
bookmarked_at = getattr(link, "bookmarked_at", None)
|
||||
@@ -642,10 +773,11 @@ def snapshot_index_row(context, link) -> str:
|
||||
|
||||
url = getattr(link, "url", "") or ""
|
||||
title = unescape(getattr(link, "title", "") or "")
|
||||
is_pending = status in {"queued", "started", "backoff"}
|
||||
title_text = title or url
|
||||
tags_str = link.tags_str() if callable(getattr(link, "tags_str", None)) else getattr(link, "tags_str", "")
|
||||
tag_html = "".join(f'<span class="snapshot-tag">{escape(tag)}</span>' for tag in (tags_str or "").split(",") if tag)
|
||||
tag_html = "".join(
|
||||
f'<span class="snapshot-tag" title="{escape(tag)}">{escape(tag)}</span>' for tag in (tags_str or "").split(",") if tag
|
||||
)
|
||||
if tag_html:
|
||||
tag_cell = f'<span class="snapshot-tags">{tag_html}</span>'
|
||||
else:
|
||||
@@ -660,50 +792,14 @@ def snapshot_index_row(context, link) -> str:
|
||||
archive_size = int(getattr(link, "archive_size", 0) or 0)
|
||||
size_cell = file_size(archive_size) if archive_size else '<span class="empty-value">...</span>'
|
||||
output_plural = "" if num_outputs == 1 else "s"
|
||||
files_url = _snapshot_url_for_context(context, link, "index.jsonl") if context.get("STATIC_EXPORT") else f"{snapshot_base}/?files=1"
|
||||
files_url = snapshot_files_url(context, link)
|
||||
|
||||
if is_pending:
|
||||
preview_html = '<span class="snapshot-preview snapshot-preview-spinner" aria-label="Archiving in progress"></span>'
|
||||
if not context.get("STATIC_EXPORT"):
|
||||
preview_html = (
|
||||
'<span class="snapshot-preview snapshot-preview-spinner" aria-label="Archiving in progress">'
|
||||
f'<img src="{escape(static("spinner.gif"))}" alt="" decoding="async" loading="lazy">'
|
||||
"</span>"
|
||||
)
|
||||
elif "_public_preview_paths" in link.__dict__:
|
||||
preview_paths = list(getattr(link, "_public_preview_paths", []) or [])
|
||||
if preview_paths:
|
||||
preview_urls = [_snapshot_url_for_context(context, link, path) for path in preview_paths]
|
||||
preview_html = (
|
||||
f'<img src="{escape(preview_urls[0])}" '
|
||||
f'data-fallbacks="{escape(",".join(preview_urls[1:]))}" '
|
||||
'onerror="nextPublicSnapshotPreview(this)" class="snapshot-preview screenshot" alt="" decoding="async" loading="lazy">'
|
||||
)
|
||||
else:
|
||||
preview_html = '<span class="snapshot-preview snapshot-preview-empty" aria-label="No preview available"></span>'
|
||||
else:
|
||||
preview_urls = [
|
||||
url
|
||||
for url in (
|
||||
snapshot_archiveresult_url(context, link, "screenshot", "screenshot.png"),
|
||||
snapshot_archiveresult_url(context, link, "chrome_extension_screenshot", "screenshot-1.png"),
|
||||
snapshot_archiveresult_url(context, link, "chrome_extension_screenshot", "screenshot.png"),
|
||||
)
|
||||
if url
|
||||
]
|
||||
if preview_urls:
|
||||
preview_html = (
|
||||
f'<img src="{escape(preview_urls[0])}" '
|
||||
f'data-fallbacks="{escape(",".join(preview_urls[1:]))}" '
|
||||
'onerror="nextPublicSnapshotPreview(this)" class="snapshot-preview screenshot" alt="" decoding="async" loading="lazy">'
|
||||
)
|
||||
else:
|
||||
preview_html = '<span class="snapshot-preview snapshot-preview-empty" aria-label="No preview available"></span>'
|
||||
preview_html = snapshot_thumbnail(context, link)
|
||||
|
||||
if "_public_favicon_paths" in link.__dict__:
|
||||
favicon_paths = list(getattr(link, "_public_favicon_paths", []) or [])
|
||||
if favicon_paths:
|
||||
favicon_urls = [_snapshot_url_for_context(context, link, path) for path in favicon_paths]
|
||||
if "_public_list_icon_paths" in link.__dict__:
|
||||
list_icon_paths = list(getattr(link, "_public_list_icon_paths", []) or [])
|
||||
if list_icon_paths:
|
||||
favicon_urls = [_snapshot_url_for_context(context, link, path) for path in list_icon_paths]
|
||||
favicon_html = (
|
||||
f'<img src="{escape(favicon_urls[0])}" '
|
||||
f'data-fallbacks="{escape(",".join(favicon_urls[1:]))}" '
|
||||
@@ -713,7 +809,7 @@ def snapshot_index_row(context, link) -> str:
|
||||
else:
|
||||
favicon_html = '<span class="link-favicon link-favicon-empty" aria-hidden="true"></span>'
|
||||
else:
|
||||
favicon_url = snapshot_archiveresult_url(context, link, "favicon", "favicon.ico")
|
||||
favicon_url = snapshot_role_output_url(context, link, "list_icon")
|
||||
favicon_html = (
|
||||
(
|
||||
f'<img src="{escape(favicon_url)}" '
|
||||
@@ -728,27 +824,24 @@ def snapshot_index_row(context, link) -> str:
|
||||
html = f"""
|
||||
<tr class="snapshot-row status-{escape(status)}">
|
||||
<td class="snapshot-time" title="{escape(title_time)}" data-sort="{escape(sort_value)}">
|
||||
<a href="{escape(snapshot_base)}/index.html">
|
||||
<a href="{escape(detail_url)}">
|
||||
<span>{escape(date_text)}</span>
|
||||
<small>{escape(time_text)}</small>
|
||||
</a>
|
||||
</td>
|
||||
<td class="snapshot-preview-cell">
|
||||
<a href="{escape(snapshot_base)}/index.html" title="Open archived snapshot">
|
||||
<a href="{escape(detail_url)}" title="Open archived snapshot">
|
||||
{preview_html}
|
||||
</a>
|
||||
</td>
|
||||
<td class="snapshot-title-cell" title="{escape(title or url)}">
|
||||
<div class="snapshot-title-line">
|
||||
<a href="{escape(snapshot_base)}/index.html" class="snapshot-favicon-link" title="Open archived snapshot">
|
||||
{favicon_html}
|
||||
</a>
|
||||
<a href="{escape(snapshot_base)}/index.html" class="snapshot-title">
|
||||
{escape(Truncator(title_text).chars(110))}
|
||||
<a href="{escape(detail_url)}" class="snapshot-title">
|
||||
{escape(Truncator(title_text).chars(110) if title_text != url else url)}
|
||||
</a>
|
||||
</div>
|
||||
<a href="{escape(url)}" class="snapshot-url" title="{escape(url)}" target="_blank" rel="noopener noreferrer">
|
||||
{escape(url)}
|
||||
{favicon_html}<span class="snapshot-url-text">{snapshot_url_text(url)}</span>
|
||||
</a>
|
||||
<span class="snapshot-mobile-saved">Saved {escape(date_text)} at {escape(time_text)}</span>
|
||||
</td>
|
||||
@@ -819,6 +912,27 @@ def plugin_card(context, result) -> str:
|
||||
if result is None or not isinstance(result, ArchiveResult):
|
||||
return ""
|
||||
|
||||
plugin = get_plugin_name(result.plugin)
|
||||
if not context.get("STATIC_EXPORT"):
|
||||
# The cover and expanded stack both embed this one card document. Run
|
||||
# plugin code on the snapshot origin so it can read saved files without
|
||||
# exposing raw archives or the admin session to the collection page.
|
||||
card_url = build_snapshot_url(
|
||||
str(_snapshot_id(result.snapshot)),
|
||||
f"_card/{result.id}",
|
||||
request=context.get("request"),
|
||||
config=context.get("CONFIG"),
|
||||
)
|
||||
return mark_safe(
|
||||
f'<iframe src="{escape(card_url)}" title="{escape(plugin)} card" '
|
||||
'loading="lazy" fetchpriority="low" sandbox="allow-scripts allow-same-origin" '
|
||||
'style="width:100% !important;height:100% !important;transform:none !important;border:0"></iframe>',
|
||||
)
|
||||
return render_plugin_card_document(context, result)
|
||||
|
||||
|
||||
def render_plugin_card_document(context, result) -> str:
|
||||
"""Render one plugin card template for the trusted snapshot-origin frame."""
|
||||
plugin = get_plugin_name(result.plugin)
|
||||
has_card_template = bool(get_plugin_template(plugin, "card", fallback=False))
|
||||
template_str = get_plugin_template(plugin, "card")
|
||||
@@ -828,12 +942,7 @@ def plugin_card(context, result) -> str:
|
||||
output_url = _snapshot_url_for_context(context, result.snapshot, raw_output_path or "")
|
||||
|
||||
icon_html = get_plugin_icon(plugin)
|
||||
plugin_lower = (plugin or "").lower()
|
||||
media_files = (
|
||||
_list_media_files(result, include_filesystem_fallback=bool(context.get("STATIC_EXPORT")))
|
||||
if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl")
|
||||
else []
|
||||
)
|
||||
media_files = _list_media_files(result, include_filesystem_fallback=bool(context.get("STATIC_EXPORT")))
|
||||
media_file_count = len(media_files)
|
||||
if context.get("STATIC_EXPORT") and media_files:
|
||||
media_files = [
|
||||
@@ -868,6 +977,7 @@ def plugin_card(context, result) -> str:
|
||||
tpl = template.Template(template_str)
|
||||
ctx = template.Context(
|
||||
{
|
||||
**context.flatten(),
|
||||
"result": result,
|
||||
"snapshot": result.snapshot,
|
||||
"output_path": output_url,
|
||||
@@ -881,6 +991,18 @@ def plugin_card(context, result) -> str:
|
||||
rendered = tpl.render(ctx)
|
||||
# Only return non-empty content (strip whitespace to check)
|
||||
if rendered.strip():
|
||||
if rendered.lstrip().lower().startswith("<!doctype html"):
|
||||
rendered = rendered.replace("</head>", CARD_OVERFLOW_STYLE + "</head>", 1)
|
||||
if context.get("STATIC_EXPORT") and rendered.lstrip().lower().startswith("<!doctype html"):
|
||||
# Static exports have no _card route. Keep the same card
|
||||
# document in both stack positions, with relative saved-file
|
||||
# URLs resolved against the exported index page.
|
||||
return mark_safe(
|
||||
f'<iframe srcdoc="{escape(rendered)}" title="{escape(plugin)} card" '
|
||||
'loading="lazy" sandbox="allow-scripts allow-same-origin" '
|
||||
'style="width:100% !important;height:100% !important;max-width:100% !important;'
|
||||
'transform:none !important;border:0"></iframe>',
|
||||
)
|
||||
return mark_safe(rendered)
|
||||
except (template.TemplateSyntaxError, AttributeError, TypeError, ValueError):
|
||||
rendered = ""
|
||||
@@ -954,7 +1076,11 @@ def plugin_full(context, result) -> str:
|
||||
output_url = _snapshot_url_for_context(context, result.snapshot, raw_output_path)
|
||||
|
||||
try:
|
||||
tpl = template.Template(template_str)
|
||||
catalog = get_plugin_catalog()
|
||||
tpl = template.Engine(
|
||||
dirs=[catalog[plugin].path] if plugin in catalog else [],
|
||||
libraries=template.Engine.get_default().libraries,
|
||||
).from_string(template_str)
|
||||
ctx = template.Context(
|
||||
{
|
||||
"result": result,
|
||||
@@ -962,6 +1088,7 @@ def plugin_full(context, result) -> str:
|
||||
"output_path": output_url,
|
||||
"output_path_raw": raw_output_path,
|
||||
"plugin": plugin,
|
||||
"plugin_icon": get_plugin_icon(plugin),
|
||||
},
|
||||
)
|
||||
rendered = tpl.render(ctx)
|
||||
|
||||
+262
-144
@@ -6,8 +6,8 @@ import posixpath
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, cast
|
||||
from urllib.parse import quote, urlparse
|
||||
from uuid import UUID
|
||||
|
||||
from abx_plugins.plugins.archivewebpage import replay_preview as archivewebpage_replay
|
||||
from admin_data_views.typing import ItemContext, SectionData, TableContext
|
||||
from admin_data_views.utils import ItemLink, render_with_item_view, render_with_table_view
|
||||
from django import template
|
||||
@@ -17,7 +17,6 @@ from django.contrib.auth import HASH_SESSION_KEY, SESSION_KEY, get_user_model
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.core import signing
|
||||
from django.core.paginator import InvalidPage
|
||||
from django.db.models import Case, IntegerField, Q, Value, When
|
||||
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseForbidden, QueryDict
|
||||
from django.shortcuts import redirect, render
|
||||
@@ -48,7 +47,6 @@ from archivebox.config.configset import BaseConfigSet
|
||||
from archivebox.core.forms import AddLinkForm
|
||||
from archivebox.core.models import ArchiveResult, Snapshot, SnapshotTag
|
||||
from archivebox.core.permissions import (
|
||||
PERMISSIONS_PRIVATE,
|
||||
PERMISSIONS_PUBLIC,
|
||||
PERMISSIONS_UNLISTED,
|
||||
can_view_snapshot,
|
||||
@@ -59,6 +57,8 @@ from archivebox.core.permissions import (
|
||||
)
|
||||
from archivebox.core.routes_util import (
|
||||
build_admin_url,
|
||||
build_snapshot_detail_url,
|
||||
build_snapshot_files_url,
|
||||
build_snapshot_url,
|
||||
build_web_url,
|
||||
get_admin_host,
|
||||
@@ -69,7 +69,7 @@ from archivebox.core.routes_util import (
|
||||
)
|
||||
from archivebox.crawls.models import Crawl
|
||||
from archivebox.misc.paginators import AcceleratedPaginator
|
||||
from archivebox.misc.serve_static import serve_static_with_byterange_support
|
||||
from archivebox.misc.serve_static import FAVICON_CACHE_CONTROL, serve_static_with_byterange_support
|
||||
from archivebox.misc.util import (
|
||||
base_url,
|
||||
filter_queryset_by_uuid_substring,
|
||||
@@ -78,10 +78,19 @@ from archivebox.misc.util import (
|
||||
validate_url,
|
||||
without_fragment,
|
||||
)
|
||||
from archivebox.plugins.discovery import get_plugin_name, get_plugin_template
|
||||
from archivebox.plugins.discovery import (
|
||||
get_plugin_catalog,
|
||||
get_plugin_icon,
|
||||
get_plugin_name,
|
||||
get_plugin_template,
|
||||
get_snapshot_role_names,
|
||||
plugin_has_custom_full_response,
|
||||
serve_plugin_replay_asset,
|
||||
)
|
||||
from archivebox.plugins.forms import get_plugin_config_binary_urls
|
||||
from archivebox.plugins.views import get_config_definition_link
|
||||
from archivebox.progressmonitor.views import live_progress_view
|
||||
from archivebox.progressmonitor.collection import collection_summary
|
||||
from archivebox.search.config import (
|
||||
get_search_mode,
|
||||
get_search_mode_backend,
|
||||
@@ -361,10 +370,6 @@ class SnapshotView(View):
|
||||
|
||||
# slug is a timestamp
|
||||
if slug.replace(".", "").isdigit():
|
||||
# missing trailing slash -> redirect to index
|
||||
if "/" not in path:
|
||||
return redirect(f"{path}/index.html")
|
||||
|
||||
try:
|
||||
try:
|
||||
snapshot = Snapshot.objects.get(Q(timestamp=slug) | Q(id__startswith=slug))
|
||||
@@ -373,7 +378,11 @@ class SnapshotView(View):
|
||||
return _private_snapshot_auth_redirect(request, snapshot, archivefile or "index.html")
|
||||
canonical_base = snapshot.url_path
|
||||
if canonical_base != snapshot.legacy_archive_path:
|
||||
target_path = f"/{canonical_base}/{archivefile or 'index.html'}"
|
||||
target_path = (
|
||||
build_snapshot_detail_url(snapshot.archive_path_from_db, request=request)
|
||||
if not archivefile or archivefile == "index.html"
|
||||
else build_snapshot_url(str(snapshot.id), archivefile, request=request)
|
||||
)
|
||||
query = request.META.get("QUERY_STRING")
|
||||
if query:
|
||||
target_path = f"{target_path}?{query}"
|
||||
@@ -388,8 +397,9 @@ class SnapshotView(View):
|
||||
show_indexes=True,
|
||||
is_archive_replay=True,
|
||||
)
|
||||
elif archivefile == "index.html":
|
||||
# if they requested snapshot index, serve live rendered template instead of static html
|
||||
elif archivefile in ("", "index.html"):
|
||||
# Both the canonical extensionless URL and the legacy
|
||||
# index.html URL render the live Django snapshot page.
|
||||
response = self.render_live_index(request, snapshot)
|
||||
else:
|
||||
target = build_snapshot_url(str(snapshot.id), archivefile, request=request)
|
||||
@@ -423,15 +433,15 @@ class SnapshotView(View):
|
||||
except Snapshot.MultipleObjectsReturned:
|
||||
snapshot_hrefs = mark_safe("<br/>").join(
|
||||
format_html(
|
||||
'{} <a href="/{}/index.html"><b><code>{}</code></b></a> {} <b>{}</b>',
|
||||
'{} <a href="{}"><b><code>{}</code></b></a> {} <b>{}</b>',
|
||||
snap.bookmarked_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
snap.archive_path,
|
||||
build_snapshot_detail_url(snap.archive_path_from_db, request=request),
|
||||
snap.timestamp,
|
||||
snap.url,
|
||||
snap.title_stripped[:64] or "",
|
||||
)
|
||||
for snap in direct_snapshots_queryset(request, Snapshot.objects.filter(timestamp__startswith=slug))
|
||||
.only("url", "timestamp", "title", "bookmarked_at")
|
||||
.select_related("crawl__created_by")
|
||||
.order_by("-bookmarked_at")
|
||||
)
|
||||
return HttpResponse(
|
||||
@@ -455,27 +465,27 @@ class SnapshotView(View):
|
||||
<title>Snapshot Not Found</title>
|
||||
</head><body>
|
||||
<center><br/><br/><br/>
|
||||
Snapshot <a href="/{}/index.html" target="_top"><b><code>[{}]</code></b></a>: <a href="{}" target="_blank" rel="noreferrer">{}</a><br/>
|
||||
was queued on {}, but no files have been saved yet in:<br/><b><a href="/{}/" target="_top"><code>{}</code></a><code>/{}</code></b><br/><br/>
|
||||
Snapshot <a href="{}" target="_top"><b><code>[{}]</code></b></a>: <a href="{}" target="_blank" rel="noreferrer">{}</a><br/>
|
||||
was queued on {}, but no files have been saved yet in:<br/><b><a href="{}" target="_top"><code>{}</code></a><code>/{}</code></b><br/><br/>
|
||||
It's possible {} during the last capture on {},<br/>or that the archiving process has not completed yet.<br/>
|
||||
<pre><code># run this cmd to finish/retry archiving this Snapshot</code><br/>
|
||||
<code style="user-select: all; color: #333">archivebox update -t timestamp {}</code></pre><br/><br/>
|
||||
<div class="text-align: left; width: 100%; max-width: 400px">
|
||||
<i><b>Next steps:</i></b><br/>
|
||||
- list all the <a href="/{}/" target="_top">Snapshot files <code>.*</code></a><br/>
|
||||
- view the <a href="/{}/index.html" target="_top">Snapshot <code>./index.html</code></a><br/>
|
||||
- list all the <a href="{}" target="_top">Snapshot files <code>.*</code></a><br/>
|
||||
- view the <a href="{}" target="_top">Snapshot <code>./index.html</code></a><br/>
|
||||
- go to the <a href="/admin/core/snapshot/{}/change/" target="_top">Snapshot admin</a> to edit<br/>
|
||||
- go to the <a href="/admin/core/snapshot/?id__exact={}" target="_top">Snapshot actions</a> to re-archive<br/>
|
||||
- or return to <a href="/" target="_top">the main index...</a></div>
|
||||
</center>
|
||||
</body></html>
|
||||
""",
|
||||
snapshot.archive_path,
|
||||
build_snapshot_detail_url(snapshot.archive_path_from_db, request=request),
|
||||
snapshot.timestamp,
|
||||
snapshot.url,
|
||||
snapshot.url,
|
||||
str(snapshot.bookmarked_at).split(".")[0],
|
||||
snapshot.archive_path,
|
||||
build_snapshot_files_url(str(snapshot.id), request=request),
|
||||
snapshot.timestamp,
|
||||
archivefile if str(archivefile) != "None" else "",
|
||||
f"the {archivefile} resource could not be fetched"
|
||||
@@ -483,8 +493,8 @@ class SnapshotView(View):
|
||||
else "the original site was not available",
|
||||
str(snapshot.bookmarked_at).split(".")[0],
|
||||
snapshot.timestamp,
|
||||
snapshot.archive_path,
|
||||
snapshot.archive_path,
|
||||
build_snapshot_files_url(str(snapshot.id), request=request),
|
||||
build_snapshot_detail_url(snapshot.archive_path_from_db, request=request),
|
||||
snapshot.pk,
|
||||
snapshot.id,
|
||||
),
|
||||
@@ -503,7 +513,7 @@ class SnapshotView(View):
|
||||
return id_qs
|
||||
return SnapshotView.find_snapshots_for_url(slug)
|
||||
|
||||
snapshots = direct_snapshots_queryset(request, _resolve_snapshots_for_slug(path))
|
||||
snapshots = direct_snapshots_queryset(request, _resolve_snapshots_for_slug(path)).select_related("crawl__created_by")
|
||||
try:
|
||||
if "://" in path:
|
||||
snapshot = snapshots.order_by("-bookmarked_at").first()
|
||||
@@ -531,15 +541,15 @@ class SnapshotView(View):
|
||||
except Snapshot.MultipleObjectsReturned:
|
||||
snapshot_hrefs = mark_safe("<br/>").join(
|
||||
format_html(
|
||||
'{} <code style="font-size: 0.8em">{}</code> <a href="/{}/index.html"><b><code>{}</code></b></a> {} <b>{}</b>',
|
||||
'{} <code style="font-size: 0.8em">{}</code> <a href="{}"><b><code>{}</code></b></a> {} <b>{}</b>',
|
||||
snap.bookmarked_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
str(snap.id)[:8],
|
||||
snap.archive_path,
|
||||
build_snapshot_detail_url(snap.archive_path_from_db, request=request),
|
||||
snap.timestamp,
|
||||
snap.url,
|
||||
snap.title_stripped[:64] or "",
|
||||
)
|
||||
for snap in snapshots.only("url", "timestamp", "title", "bookmarked_at").order_by("-bookmarked_at")
|
||||
for snap in snapshots.select_related("crawl__created_by").order_by("-bookmarked_at")
|
||||
)
|
||||
return HttpResponse(
|
||||
format_html(
|
||||
@@ -552,7 +562,7 @@ class SnapshotView(View):
|
||||
status=404,
|
||||
)
|
||||
|
||||
target_path = build_snapshot_url(str(snapshot.id), "index.html", request=request)
|
||||
target_path = build_snapshot_detail_url(snapshot.archive_path_from_db, request=request)
|
||||
query = request.META.get("QUERY_STRING")
|
||||
if query:
|
||||
target_path = f"{target_path}?{query}"
|
||||
@@ -657,7 +667,9 @@ class SnapshotPathView(View):
|
||||
if snapshot_id:
|
||||
requested_base = f"{requested_base}/{snapshot_id}"
|
||||
if canonical_base != requested_base:
|
||||
target = f"/{canonical_base}/{path or 'index.html'}"
|
||||
target = f"/{canonical_base}"
|
||||
if path:
|
||||
target = f"{target}/{path}"
|
||||
query = request.META.get("QUERY_STRING")
|
||||
if query:
|
||||
target = f"{target}?{query}"
|
||||
@@ -703,11 +715,14 @@ def _safe_archive_relpath(path: str) -> str | None:
|
||||
return cleaned
|
||||
|
||||
|
||||
def _resolve_archiveresult_relpath(snapshot: Snapshot, rel_path: str) -> tuple[str, ArchiveResult | None]:
|
||||
"""Resolve plugin-relative output paths through ArchiveResult.output_files."""
|
||||
def _resolve_archiveresult_relpath(
|
||||
snapshot: Snapshot,
|
||||
rel_path: str,
|
||||
) -> tuple[str, ArchiveResult | None, tuple[str, ...]]:
|
||||
"""Resolve a declared output path and its exact historical fallbacks."""
|
||||
parts = Path(rel_path).parts
|
||||
if len(parts) < 2:
|
||||
return rel_path, None
|
||||
return rel_path, None, ()
|
||||
|
||||
plugin = parts[0]
|
||||
plugin_relpath = posixpath.join(*parts[1:])
|
||||
@@ -719,19 +734,38 @@ def _resolve_archiveresult_relpath(snapshot: Snapshot, rel_path: str) -> tuple[s
|
||||
).only("plugin", "output_files"),
|
||||
)
|
||||
if not results:
|
||||
return rel_path, None
|
||||
from archivebox.plugins.discovery import get_plugin_default_output_path
|
||||
|
||||
fallbacks = (plugin_relpath,) if get_plugin_default_output_path(plugin) == rel_path else ()
|
||||
return rel_path, None, fallbacks
|
||||
|
||||
declared_paths: list[tuple[str, ArchiveResult]] = []
|
||||
historical_fallbacks: list[str] = []
|
||||
for result in results:
|
||||
output_files = result.output_files or {}
|
||||
output_files = result.output_file_map()
|
||||
for candidate in (plugin_relpath, rel_path):
|
||||
file_info = output_files.get(candidate)
|
||||
if not isinstance(file_info, dict):
|
||||
continue
|
||||
if file_info.get("root_relative"):
|
||||
return candidate, result
|
||||
return rel_path, result
|
||||
output_path = result.output_file_path(candidate, output_file_map=output_files)
|
||||
if output_path and output_path not in {path for path, _result in declared_paths}:
|
||||
declared_paths.append((output_path, result))
|
||||
|
||||
return rel_path, results[0]
|
||||
# Partially migrated 0.7 rows can declare ``singlefile.html`` while
|
||||
# the old file still lives at SNAP_DIR/singlefile.html, without the
|
||||
# later root_relative metadata bit. Keep that one exact declared key as
|
||||
# a fallback after the normal path returns 404. Do not preflight either
|
||||
# path: the static response performs the only happy-path filesystem IO.
|
||||
if plugin_relpath in output_files and plugin_relpath not in historical_fallbacks:
|
||||
historical_fallbacks.append(plugin_relpath)
|
||||
|
||||
if declared_paths:
|
||||
primary_path, primary_result = declared_paths[0]
|
||||
fallbacks = tuple(
|
||||
dict.fromkeys(
|
||||
path for path in (*[path for path, _result in declared_paths[1:]], *historical_fallbacks) if path != primary_path
|
||||
),
|
||||
)
|
||||
return primary_path, primary_result, fallbacks
|
||||
|
||||
return rel_path, results[0], ()
|
||||
|
||||
|
||||
def _plugin_full_preview_response(
|
||||
@@ -749,23 +783,25 @@ def _plugin_full_preview_response(
|
||||
if not plugin:
|
||||
return None
|
||||
|
||||
# ReplayWeb.page needs plugin-owned WACZ inspection and service-worker
|
||||
# context, so it remains the one narrow preview exception below.
|
||||
if plugin == "archivewebpage" and archivewebpage_replay.is_replay_target(rel_path):
|
||||
return None
|
||||
|
||||
template_str = get_plugin_template(plugin, "full", fallback=False)
|
||||
if not template_str:
|
||||
return None
|
||||
if plugin_has_custom_full_response(plugin):
|
||||
# The plugin owns path selection and rendering for this full template.
|
||||
# Defer to the file-serving response hook so unrelated outputs from the
|
||||
# same plugin continue through their normal MIME-specific renderer.
|
||||
return None
|
||||
|
||||
raw_query = request.GET.copy()
|
||||
raw_query.pop("preview", None)
|
||||
raw_query.pop("titlebar", None)
|
||||
output_url = request.path
|
||||
if raw_query:
|
||||
output_url = f"{output_url}?{raw_query.urlencode()}"
|
||||
|
||||
catalog = get_plugin_catalog()
|
||||
rendered = (
|
||||
template.Engine(debug=False)
|
||||
template.Engine(debug=False, dirs=[catalog[plugin].path] if plugin in catalog else [])
|
||||
.from_string(template_str)
|
||||
.render(
|
||||
template.Context(
|
||||
@@ -774,23 +810,40 @@ def _plugin_full_preview_response(
|
||||
"snapshot": snapshot,
|
||||
"output_path": output_url,
|
||||
"output_path_raw": rel_path,
|
||||
"snapshot_details_url": build_snapshot_detail_url(snapshot.archive_path_from_db, request=request),
|
||||
"plugin": plugin,
|
||||
"plugin_icon": get_plugin_icon(plugin),
|
||||
"preview_base": f"{request.path.rsplit('/', 1)[0]}/",
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
if request.GET.get("titlebar") == "0":
|
||||
from archivebox.core.templatetags.core_tags import CARD_OVERFLOW_STYLE
|
||||
|
||||
# The card already has a result header and actions. Only its embedded
|
||||
# trusted full viewer drops the duplicate top bar; opening the same
|
||||
# output directly keeps the complete viewer and controls.
|
||||
rendered = rendered.replace("</head>", CARD_OVERFLOW_STYLE + "<style>body > header { display: none !important; }</style></head>", 1)
|
||||
response = HttpResponse(rendered, content_type="text/html; charset=utf-8")
|
||||
response.headers["Content-Disposition"] = f'inline; filename="{Path(rel_path).stem}.html"'
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-ArchiveBox-Security-Mode"] = request.archivebox_config.SERVER_SECURITY_MODE
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
# Trusted viewers live on the snapshot origin, but the collection UI lives
|
||||
# on web.* (and can be embedded by admin.*). Permit only those configured
|
||||
# origins, not arbitrary sites or other snapshots. This does not grant the
|
||||
# archived document access to the parent frame or its admin session cookies.
|
||||
# Full plugin viewers are trusted code, but must fetch saved data from the
|
||||
# isolated snapshot origin. The outer collection UI stays on web.*.
|
||||
response.headers["Content-Security-Policy"] = _trusted_snapshot_frame_csp(request)
|
||||
return response
|
||||
|
||||
|
||||
def _trusted_snapshot_frame_csp(request: HttpRequest) -> str:
|
||||
# A trusted plugin frame on snap-* may be embedded by web.* or admin.*.
|
||||
# Archived HTML/JS/CSS is still served only inside that snapshot origin;
|
||||
# neither this CSP nor the frame grants it admin cookies or cross-snapshot
|
||||
# reads. In one-domain no-JS mode the hosts coincide, but raw replay scripts
|
||||
# remain blocked by the separate replay response policy.
|
||||
viewer_origins = " ".join(dict.fromkeys((build_web_url(request=request), build_admin_url(request=request))))
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
return (
|
||||
"default-src 'self' data: blob:; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; "
|
||||
"style-src 'unsafe-inline' data: blob: 'self'; "
|
||||
@@ -805,6 +858,47 @@ def _plugin_full_preview_response(
|
||||
"form-action 'none'; "
|
||||
f"frame-ancestors 'self' {viewer_origins};"
|
||||
)
|
||||
|
||||
|
||||
def _plugin_card_document_response(request: HttpRequest, snapshot: Snapshot, result_id: str) -> HttpResponse:
|
||||
"""Serve the existing card template from the snapshot origin, not web.*.
|
||||
|
||||
srcdoc and inline card scripts inherit the containing page's origin. This
|
||||
frame route gives them same-origin access to this snapshot's saved files
|
||||
without opening CORS on raw archives or sharing the admin session cookie.
|
||||
The parent web page receives only the one-bit login hint; delete buttons
|
||||
navigate to admin.* for a real authenticated confirmation.
|
||||
"""
|
||||
try:
|
||||
result_pk = UUID(result_id)
|
||||
except (TypeError, ValueError):
|
||||
raise Http404 from None
|
||||
result = snapshot.archiveresult_set.filter(pk=result_pk).first()
|
||||
if result is None:
|
||||
raise Http404
|
||||
|
||||
# There is exactly one plugin card template. The stack cover and expanded
|
||||
# tray point at this same document; only their containing card width differs.
|
||||
from archivebox.core.templatetags.core_tags import CARD_OVERFLOW_STYLE, render_plugin_card_document
|
||||
|
||||
fragment = render_plugin_card_document(
|
||||
template.Context({"request": request, "CONFIG": get_request_config(request, resolve_plugins=False)}),
|
||||
result,
|
||||
)
|
||||
if str(fragment).lstrip().lower().startswith("<!doctype html"):
|
||||
card_html = str(fragment)
|
||||
else:
|
||||
card_html = (
|
||||
'<!doctype html><html><head><meta charset="utf-8"><style>'
|
||||
"html,body{margin:0;width:100%;height:100%;overflow:hidden}"
|
||||
"body>iframe{display:block;width:100%;height:100%;border:0}"
|
||||
f"</style>{CARD_OVERFLOW_STYLE}</head><body>"
|
||||
f"{fragment}</body></html>"
|
||||
)
|
||||
response = HttpResponse(card_html, content_type="text/html; charset=utf-8")
|
||||
response.headers["Content-Security-Policy"] = _trusted_snapshot_frame_csp(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
return response
|
||||
|
||||
|
||||
@@ -923,7 +1017,7 @@ def _build_snapshot_replay_response(request: HttpRequest, snapshot: Snapshot, pa
|
||||
snapshot._runtime_config = request_config
|
||||
|
||||
if rel_path.startswith("replay/") or rel_path == "replay":
|
||||
response = archivewebpage_replay.serve_replay_asset_response(rel_path, request_config, HttpResponse)
|
||||
response = serve_plugin_replay_asset(rel_path, request_config, HttpResponse)
|
||||
if response is not None:
|
||||
return response
|
||||
|
||||
@@ -934,9 +1028,24 @@ def _build_snapshot_replay_response(request: HttpRequest, snapshot: Snapshot, pa
|
||||
# subdomain (this keeps the endpoint identical across all security modes).
|
||||
return live_progress_view(request, authorized_snapshot=snapshot)
|
||||
|
||||
if rel_path.startswith("_card/"):
|
||||
# SnapshotHostView/SnapshotReplayView have already authorized this
|
||||
# snapshot. Look up the result through its relation so a card ID from
|
||||
# another isolated snapshot can never render here.
|
||||
return _plugin_card_document_response(request, snapshot, rel_path.removeprefix("_card/"))
|
||||
|
||||
is_directory_request = bool(path) and path.endswith("/")
|
||||
show_indexes = bool(request.GET.get("files")) or (request_config.USES_SUBDOMAIN_ROUTING and is_directory_request)
|
||||
if not show_indexes and (not rel_path or rel_path == "index.html"):
|
||||
if request_config.CONTROL_PLANE_ENABLED and snapshot.permissions in (PERMISSIONS_PUBLIC, PERMISSIONS_UNLISTED):
|
||||
# Snapshot detail is trusted application UI and belongs on web.*.
|
||||
# snap-* is for isolated saved outputs and plugin frames. Keep the
|
||||
# private replay-cookie path below: its grant is host-only and must
|
||||
# never be mistaken for an admin session on web.*.
|
||||
target = build_snapshot_detail_url(snapshot.archive_path_from_db, request=request, config=request_config)
|
||||
if request.META.get("QUERY_STRING"):
|
||||
target = f"{target}?{request.META['QUERY_STRING']}"
|
||||
return redirect(target)
|
||||
return SnapshotView.render_live_index(request, snapshot)
|
||||
|
||||
if not rel_path or rel_path.endswith("/"):
|
||||
@@ -948,22 +1057,23 @@ def _build_snapshot_replay_response(request: HttpRequest, snapshot: Snapshot, pa
|
||||
if rel_path is None:
|
||||
raise Http404
|
||||
|
||||
rel_path, archive_result = _resolve_archiveresult_relpath(snapshot, rel_path)
|
||||
rel_path, archive_result, fallback_relpaths = _resolve_archiveresult_relpath(snapshot, rel_path)
|
||||
|
||||
plugin_preview = _plugin_full_preview_response(request, snapshot, rel_path, archive_result)
|
||||
if plugin_preview is not None:
|
||||
return plugin_preview
|
||||
|
||||
try:
|
||||
return serve_static_with_byterange_support(
|
||||
request,
|
||||
rel_path,
|
||||
document_root=snapshot.output_dir,
|
||||
show_indexes=show_indexes,
|
||||
is_archive_replay=True,
|
||||
)
|
||||
except Http404:
|
||||
pass
|
||||
for candidate_path in (rel_path, *fallback_relpaths):
|
||||
try:
|
||||
return serve_static_with_byterange_support(
|
||||
request,
|
||||
candidate_path,
|
||||
document_root=snapshot.output_dir,
|
||||
show_indexes=show_indexes,
|
||||
is_archive_replay=True,
|
||||
)
|
||||
except Http404:
|
||||
continue
|
||||
|
||||
host = urlparse(snapshot.url).hostname or snapshot.domain
|
||||
responses_root = Path(snapshot.output_dir) / "responses" / host
|
||||
@@ -977,6 +1087,8 @@ def _build_snapshot_replay_response(request: HttpRequest, snapshot: Snapshot, pa
|
||||
|
||||
def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str = ""):
|
||||
response = _build_snapshot_replay_response(request, snapshot, path)
|
||||
if response.status_code in (200, 206, 304) and response.get("Cache-Control") == FAVICON_CACHE_CONTROL:
|
||||
return response
|
||||
if path != "progress.json" and response.status_code in (200, 206, 304):
|
||||
if not response.get("Cache-Control"):
|
||||
policy = "public" if snapshot.permissions == PERMISSIONS_PUBLIC else "private"
|
||||
@@ -1125,14 +1237,23 @@ class PublicIndexView(ListView):
|
||||
"status",
|
||||
"output_size",
|
||||
"permissions",
|
||||
# Modern replay URLs need the filesystem version and owner's name.
|
||||
# Deferring these caused four extra SELECTs per displayed snapshot.
|
||||
"fs_version",
|
||||
"crawl__created_by__username",
|
||||
)
|
||||
|
||||
def _ordered_public_page_from_order_index(self, *, page_number: int, page_size: int) -> list[Snapshot] | None:
|
||||
target_count = page_number * page_size
|
||||
def _ordered_public_page_from_order_index(self, *, page_number: int, page_size: int) -> list[Snapshot]:
|
||||
# One extra row determines Next without a full collection count. Cached
|
||||
# totals can be stale while the runner adds/removes snapshots, so they
|
||||
# must never decide whether the requested page exists.
|
||||
target_count = page_number * page_size + 1
|
||||
public_snapshots: list[Snapshot] = []
|
||||
scanned = 0
|
||||
chunk_size = max(self.public_page_scan_chunk_size, page_size)
|
||||
ordered_snapshots = Snapshot.objects.order_by(*self.ordering).only(*self._base_public_snapshot_fields())
|
||||
chunk_size = max(self.public_page_scan_chunk_size, page_size + 1)
|
||||
ordered_snapshots = (
|
||||
Snapshot.objects.select_related("crawl__created_by").order_by(*self.ordering).only(*self._base_public_snapshot_fields())
|
||||
)
|
||||
|
||||
while len(public_snapshots) < target_count:
|
||||
chunk = list(ordered_snapshots[scanned : scanned + chunk_size])
|
||||
@@ -1148,20 +1269,31 @@ class PublicIndexView(ListView):
|
||||
if self.request.GET.get("q", default="").strip():
|
||||
return super().paginate_queryset(queryset, page_size)
|
||||
|
||||
public_count = self.get_exact_public_snapshot_count()
|
||||
paginator = self.get_paginator(range(public_count), page_size)
|
||||
page_kwarg = self.kwargs.get(self.page_kwarg)
|
||||
page_query = self.request.GET.get(self.page_kwarg)
|
||||
page_number = page_kwarg or page_query or 1
|
||||
|
||||
try:
|
||||
page = paginator.page(page_number)
|
||||
except InvalidPage as err:
|
||||
page_number = int(page_number)
|
||||
if page_number < 1:
|
||||
raise ValueError("Page must be positive")
|
||||
except (ValueError, TypeError) as err:
|
||||
raise Http404(f"Invalid page ({page_number}): {err}") from err
|
||||
|
||||
object_list = self._ordered_public_page_from_order_index(page_number=page.number, page_size=page_size)
|
||||
page.object_list = object_list
|
||||
return paginator, page, object_list, page.has_other_pages()
|
||||
snapshots = self._ordered_public_page_from_order_index(page_number=page_number, page_size=page_size)
|
||||
if not snapshots and page_number > 1:
|
||||
raise Http404(f"Invalid page ({page_number}): No snapshots")
|
||||
observed_count = (page_number - 1) * page_size + len(snapshots)
|
||||
self.public_snapshot_count = observed_count
|
||||
if len(snapshots) > page_size:
|
||||
summary = collection_summary(self.request.user, public=True)
|
||||
cached_count = summary["snapshots"] if summary is not None else None
|
||||
self.public_snapshot_count = cached_count if cached_count is not None and cached_count >= observed_count else None
|
||||
|
||||
paginator = self.get_paginator(range(observed_count), page_size)
|
||||
page = paginator.page(page_number)
|
||||
page.object_list = snapshots[:page_size]
|
||||
return paginator, page, page.object_list, page.has_other_pages()
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
runtime_config = self.__dict__.get("runtime_config")
|
||||
@@ -1190,11 +1322,20 @@ class PublicIndexView(ListView):
|
||||
and search_mode_backend
|
||||
and context["paginator"].count == 0,
|
||||
)
|
||||
context["public_snapshot_count"] = context["paginator"].count if query else self.public_snapshot_count
|
||||
count = context["public_snapshot_count"]
|
||||
context["public_page_count"] = (
|
||||
max(1, (count + context["paginator"].per_page - 1) // context["paginator"].per_page) if count is not None else None
|
||||
)
|
||||
snapshots = list(context.get("object_list") or ())
|
||||
icons_by_snapshot: dict[str, set[str]] = {str(snapshot.id): set() for snapshot in snapshots}
|
||||
from archivebox.plugins.output_groups import plugin_output_sizes
|
||||
|
||||
all_results_by_snapshot = {str(snapshot.id): [] for snapshot in snapshots}
|
||||
snapshots_by_id = {str(snapshot.id): snapshot for snapshot in snapshots}
|
||||
icons_by_snapshot: dict[str, dict[str, ArchiveResult]] = {str(snapshot.id): {} for snapshot in snapshots}
|
||||
tag_names_by_snapshot: dict[str, list[str]] = {str(snapshot.id): [] for snapshot in snapshots}
|
||||
preview_paths_by_snapshot: dict[str, list[tuple[int, int, str]]] = {str(snapshot.id): [] for snapshot in snapshots}
|
||||
favicon_paths_by_snapshot: dict[str, list[str]] = {str(snapshot.id): [] for snapshot in snapshots}
|
||||
list_icon_paths_by_snapshot: dict[str, list[str]] = {str(snapshot.id): [] for snapshot in snapshots}
|
||||
list_icon_plugins = set(get_snapshot_role_names("list_icon"))
|
||||
progress_by_snapshot: dict[str, dict[str, int]] = {
|
||||
str(snapshot.id): {
|
||||
"total": 0,
|
||||
@@ -1215,89 +1356,66 @@ class PublicIndexView(ListView):
|
||||
):
|
||||
tag_names_by_snapshot[str(snapshot_id)].append(tag_name)
|
||||
|
||||
preview_plugin_order = {
|
||||
"screenshot": 0,
|
||||
"chrome_extension_screenshot": 1,
|
||||
}
|
||||
preview_candidates = {
|
||||
"screenshot": ("screenshot.png",),
|
||||
"chrome_extension_screenshot": ("screenshot-1.png", "screenshot.png"),
|
||||
}
|
||||
|
||||
def result_output_path(result: ArchiveResult, filename: str) -> str | None:
|
||||
output_files = result.output_files or {}
|
||||
file_info = output_files.get(filename)
|
||||
if not isinstance(file_info, dict) or int(file_info.get("size") or 0) <= 0:
|
||||
return None
|
||||
if file_info.get("root_relative"):
|
||||
return filename
|
||||
return f"{result.plugin}/{filename}"
|
||||
|
||||
for snapshot_id, plugin, status in (
|
||||
ArchiveResult.objects.filter(
|
||||
snapshot_id__in=icons_by_snapshot.keys(),
|
||||
)
|
||||
results = (
|
||||
ArchiveResult.objects.filter(snapshot_id__in=icons_by_snapshot.keys())
|
||||
.exclude(plugin="")
|
||||
.values_list("snapshot_id", "plugin", "status")
|
||||
.iterator(chunk_size=1000)
|
||||
):
|
||||
snapshot_key = str(snapshot_id)
|
||||
.only(
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
"status",
|
||||
"output_str",
|
||||
"output_files",
|
||||
"output_size",
|
||||
)
|
||||
)
|
||||
# This is already bounded by the displayed snapshot page. Finish
|
||||
# the SELECT before parsing output manifests: a streaming cursor
|
||||
# otherwise keeps SQLite's read lock throughout rendering.
|
||||
for result in list(results):
|
||||
snapshot_key = str(result.snapshot_id)
|
||||
# Thumbnail URLs traverse result.snapshot too; share the page's
|
||||
# already-loaded parent rather than fetching it once per result.
|
||||
result.snapshot = snapshots_by_id[snapshot_key]
|
||||
# These read-only page objects are shared by icons, sizes, and
|
||||
# thumbnails. Normalize each manifest once for this render.
|
||||
result._render_output_file_map = result.output_file_map()
|
||||
all_results_by_snapshot[snapshot_key].append(result)
|
||||
progress = progress_by_snapshot[snapshot_key]
|
||||
progress["total"] += 1
|
||||
if status == ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
icons_by_snapshot[snapshot_key].add(plugin)
|
||||
if result.status == ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
icons_by_snapshot[snapshot_key].setdefault(result.plugin, result)
|
||||
progress["succeeded"] += 1
|
||||
elif status == ArchiveResult.StatusChoices.FAILED:
|
||||
elif result.status == ArchiveResult.StatusChoices.FAILED:
|
||||
progress["failed"] += 1
|
||||
elif status == ArchiveResult.StatusChoices.STARTED:
|
||||
elif result.status == ArchiveResult.StatusChoices.STARTED:
|
||||
progress["running"] += 1
|
||||
elif status == ArchiveResult.StatusChoices.SKIPPED:
|
||||
elif result.status == ArchiveResult.StatusChoices.SKIPPED:
|
||||
progress["skipped"] += 1
|
||||
elif status == ArchiveResult.StatusChoices.NORESULTS:
|
||||
elif result.status == ArchiveResult.StatusChoices.NORESULTS:
|
||||
progress["noresults"] += 1
|
||||
|
||||
for result in (
|
||||
ArchiveResult.objects.filter(
|
||||
snapshot_id__in=icons_by_snapshot.keys(),
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
plugin__in=(*preview_candidates, "favicon"),
|
||||
)
|
||||
.only("snapshot_id", "plugin", "output_files")
|
||||
.iterator(chunk_size=1000)
|
||||
):
|
||||
snapshot_key = str(result.snapshot_id)
|
||||
if result.plugin in preview_candidates:
|
||||
plugin_rank = preview_plugin_order[result.plugin]
|
||||
for filename_rank, filename in enumerate(preview_candidates[result.plugin]):
|
||||
output_path = result_output_path(result, filename)
|
||||
if output_path:
|
||||
preview_paths_by_snapshot[snapshot_key].append((plugin_rank, filename_rank, output_path))
|
||||
elif result.plugin == "favicon":
|
||||
output_path = result_output_path(result, "favicon.ico")
|
||||
if output_path:
|
||||
favicon_paths_by_snapshot[snapshot_key].append(output_path)
|
||||
if result.plugin in list_icon_plugins and result.status == ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
if output_path := result.embed_path():
|
||||
list_icon_paths_by_snapshot[snapshot_key].append(output_path)
|
||||
|
||||
for snapshot in snapshots:
|
||||
snapshot._icons_compact = True
|
||||
snapshot._icons_output_sizes = plugin_output_sizes(all_results_by_snapshot[str(snapshot.id)])
|
||||
snapshot._icons_archive_results = icons_by_snapshot.get(str(snapshot.id), set())
|
||||
snapshot._icons_progress_stats = progress_by_snapshot.get(str(snapshot.id), {})
|
||||
snapshot.num_outputs_cached = snapshot._icons_progress_stats.get("succeeded", 0)
|
||||
snapshot._tags_str_cached = ",".join(tag_names_by_snapshot.get(str(snapshot.id), []))
|
||||
snapshot._public_preview_paths = [
|
||||
output_path for _plugin_rank, _filename_rank, output_path in sorted(preview_paths_by_snapshot.get(str(snapshot.id), []))
|
||||
]
|
||||
snapshot._public_favicon_paths = favicon_paths_by_snapshot.get(str(snapshot.id), [])
|
||||
snapshot._snapshot_card_results = all_results_by_snapshot[str(snapshot.id)]
|
||||
snapshot._public_list_icon_paths = list_icon_paths_by_snapshot.get(str(snapshot.id), [])
|
||||
snapshot._is_archived_cached = bool(snapshot.downloaded_at or snapshot.status == Snapshot.StatusChoices.SEALED)
|
||||
context["object_list"] = snapshots
|
||||
return context
|
||||
|
||||
def get_exact_public_snapshot_count(self) -> int:
|
||||
hidden_count = Snapshot.objects.filter(permissions=PERMISSIONS_PRIVATE).count()
|
||||
hidden_count += Snapshot.objects.filter(permissions=PERMISSIONS_UNLISTED).count()
|
||||
return Snapshot.objects.count() - hidden_count
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
qs = public_snapshots_queryset(super().get_queryset(**kwargs)).only(*self._base_public_snapshot_fields())
|
||||
qs = (
|
||||
public_snapshots_queryset(super().get_queryset(**kwargs))
|
||||
.select_related("crawl__created_by")
|
||||
.only(*self._base_public_snapshot_fields())
|
||||
)
|
||||
query = self.request.GET.get("q", default="").strip()
|
||||
|
||||
if not query:
|
||||
@@ -1576,7 +1694,7 @@ class WebAddView(AddView):
|
||||
if requested_url:
|
||||
snapshot = self._latest_snapshot_for_url(requested_url)
|
||||
if snapshot:
|
||||
return redirect(f"/{snapshot.url_path}")
|
||||
return redirect(build_snapshot_detail_url(snapshot.archive_path_from_db, request=request))
|
||||
|
||||
request_host = (request.get_host() or "").lower()
|
||||
request_config = get_request_config(request)
|
||||
@@ -1616,7 +1734,7 @@ class WebAddView(AddView):
|
||||
|
||||
snapshot = self._latest_snapshot_for_url(requested_url)
|
||||
if snapshot:
|
||||
return redirect(f"/{snapshot.url_path}")
|
||||
return redirect(build_snapshot_detail_url(snapshot.archive_path_from_db, request=request))
|
||||
|
||||
add_url = self._normalize_add_url(requested_url)
|
||||
assert self.form_class is not None
|
||||
@@ -1648,7 +1766,7 @@ class WebAddView(AddView):
|
||||
crawl = self._create_crawl_from_form(form)
|
||||
snapshot = Snapshot.from_json({"url": add_url, "tags": form.cleaned_data.get("tag", "")}, overrides={"crawl": crawl})
|
||||
assert snapshot is not None
|
||||
return redirect(f"/{snapshot.url_path}")
|
||||
return redirect(build_snapshot_detail_url(snapshot.archive_path_from_db, request=request))
|
||||
|
||||
|
||||
class HealthCheckView(View):
|
||||
|
||||
@@ -717,7 +717,7 @@ class InlineTagEditorWidget(TagEditorWidget):
|
||||
)
|
||||
pills_html += f'''
|
||||
<span class="tag-pill" data-tag="{self._escape(td["name"])}" data-tag-id="{td["id"]}" style="{self._tag_style(td["name"])}">
|
||||
<a href="/admin/core/snapshot/?tags__id__exact={td["id"]}" class="tag-link">{self._escape(td["name"])}</a>
|
||||
<a href="/admin/core/snapshot/?tags__id__exact={td["id"]}" class="tag-link" title="{self._escape(td["name"])}">{self._escape(td["name"])}</a>
|
||||
{remove_button}
|
||||
</span>
|
||||
'''
|
||||
|
||||
+39
-10
@@ -8,7 +8,7 @@ from urllib.parse import urlencode, urlparse
|
||||
from django import forms
|
||||
from django.contrib import admin, messages
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Count, F, Q
|
||||
from django.db.models import Count, F, Prefetch, Q
|
||||
from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.loader import render_to_string
|
||||
@@ -30,9 +30,11 @@ from archivebox.core.permissions import (
|
||||
PERMISSIONS_VALUES,
|
||||
normalize_permissions,
|
||||
)
|
||||
from archivebox.core.routes_util import build_snapshot_detail_url, build_snapshot_role_output_url
|
||||
from archivebox.core.widgets import TagEditorWidget, URLFiltersWidget
|
||||
from archivebox.crawls.models import Crawl, CrawlSchedule
|
||||
from archivebox.misc.paginators import AcceleratedPaginator
|
||||
from archivebox.plugins.discovery import get_snapshot_role_names
|
||||
from archivebox.progressmonitor.views import progress_endpoint
|
||||
from archivebox.workers.models import RETRY_AT_MAX
|
||||
|
||||
@@ -72,13 +74,32 @@ def render_snapshots_list(snapshots_qs, request=None, crawl=None, page_size=50,
|
||||
|
||||
# Keep ArchiveResult counters as scalar subqueries so the paginated
|
||||
# Snapshot queryset does not become a join+GROUP BY over every result row.
|
||||
snapshots_qs = filtered_qs.order_by("-created_at").annotate(
|
||||
total_results=ArchiveResult.snapshot_count_expr(),
|
||||
succeeded_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SUCCEEDED),
|
||||
failed_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.FAILED),
|
||||
started_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.STARTED),
|
||||
skipped_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SKIPPED),
|
||||
snapshot_permissions=F("permissions"),
|
||||
snapshots_qs = (
|
||||
filtered_qs.order_by("-created_at")
|
||||
.annotate(
|
||||
total_results=ArchiveResult.snapshot_count_expr(),
|
||||
succeeded_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SUCCEEDED),
|
||||
failed_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.FAILED),
|
||||
started_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.STARTED),
|
||||
skipped_results=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SKIPPED),
|
||||
snapshot_permissions=F("permissions"),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"archiveresult_set",
|
||||
queryset=ArchiveResult.objects.filter(
|
||||
plugin__in=get_snapshot_role_names("list_icon"),
|
||||
status=ArchiveResult.StatusChoices.SUCCEEDED,
|
||||
).only(
|
||||
"snapshot_id",
|
||||
"plugin",
|
||||
"status",
|
||||
"output_str",
|
||||
"output_files",
|
||||
),
|
||||
to_attr="_list_icon_results",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
page_number = request.GET.get(page_param, 1) if request is not None else 1
|
||||
@@ -219,6 +240,14 @@ def render_snapshots_list(snapshots_qs, request=None, crawl=None, page_size=50,
|
||||
|
||||
# Format date
|
||||
date_str = snapshot.created_at.strftime("%Y-%m-%d %H:%M") if snapshot.created_at else "-"
|
||||
detail_url = build_snapshot_detail_url(snapshot.archive_path_from_db, request=request)
|
||||
favicon_url = build_snapshot_role_output_url(
|
||||
snapshot,
|
||||
"list_icon",
|
||||
request=request,
|
||||
archive_results=snapshot._list_icon_results,
|
||||
fallback_to_default=True,
|
||||
)
|
||||
|
||||
rows.append(f'''
|
||||
<tr style="border-bottom: 1px solid #eee;">
|
||||
@@ -231,8 +260,8 @@ def render_snapshots_list(snapshots_qs, request=None, crawl=None, page_size=50,
|
||||
<span title="{permissions}" style="display:inline-flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius:999px; font-size:12px; color:{permission_fg}; background:{permission_bg};">{permission_icon}</span>
|
||||
</td>
|
||||
<td style="padding: 6px 8px; white-space: nowrap;">
|
||||
<a href="/{snapshot.archive_path}/" style="text-decoration: none;">
|
||||
<img src="/{snapshot.archive_path}/favicon.ico"
|
||||
<a href="{escape(detail_url)}" style="text-decoration: none;">
|
||||
<img src="{escape(favicon_url)}"
|
||||
style="width: 16px; height: 16px; vertical-align: middle; margin-right: 4px;"
|
||||
onerror="this.style.display='none'"/>
|
||||
</a>
|
||||
|
||||
+20
-17
@@ -846,24 +846,31 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
output_dir: Path | None = None,
|
||||
num_snapshots: int | None = None,
|
||||
) -> str:
|
||||
from abx_dl.limits import CrawlLimitState
|
||||
from abx_dl.limits import parse_filesize_to_bytes
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = self.output_dir
|
||||
if config is None:
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
config = get_config(crawl=self, include_machine=False).for_crawl_runtime(
|
||||
crawl=self,
|
||||
persona=self.resolve_persona(),
|
||||
crawl_output_dir=output_dir,
|
||||
)
|
||||
config = get_config(crawl=self, include_machine=False)
|
||||
|
||||
limits_path = output_dir / ".abx-dl" / "limits.json"
|
||||
if limits_path.exists():
|
||||
stop_reason = CrawlLimitState.from_config(config).get_stop_reason()
|
||||
if stop_reason:
|
||||
return stop_reason
|
||||
# The DB is the shared authority for workers and the UI. A second
|
||||
# ledger beside the artifacts can disagree after cancellation/retry and
|
||||
# would require filesystem access just to render a crawl's status.
|
||||
# Recompute against the current limits so increasing a cap permits work.
|
||||
timeout = int(self._config_value(config, "CRAWL_TIMEOUT", 0) or 0)
|
||||
if timeout:
|
||||
# Snapshot creation already supplies a durable crawl clock. Using
|
||||
# that existing fact avoids another timestamp to keep in sync and
|
||||
# survives cancellation, which deliberately removes unfinished
|
||||
# ArchiveResult rows while preserving the Snapshot for retry.
|
||||
first_snapshot_time = self.snapshot_set.aggregate(first=models.Min("created_at"))["first"]
|
||||
if first_snapshot_time and (timezone.now() - first_snapshot_time).total_seconds() >= timeout:
|
||||
return "crawl_timeout"
|
||||
max_size = parse_filesize_to_bytes(self._config_value(config, "CRAWL_MAX_SIZE", 0) or 0)
|
||||
if max_size:
|
||||
total_size = self.snapshot_set.aggregate(total=models.Sum("output_size"))["total"] or 0
|
||||
if total_size >= max_size:
|
||||
return "crawl_max_size"
|
||||
|
||||
max_urls = int(self._config_value(config, "CRAWL_MAX_URLS", 0) or 0)
|
||||
if num_snapshots is None:
|
||||
@@ -1250,10 +1257,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
|
||||
}
|
||||
if tag_names:
|
||||
tag_names_by_url[snapshot.url] = tag_names
|
||||
# Snapshot.save() handles model-level validation. The crawl symlink
|
||||
# can still wait until after commit so SQLite does not hold a write
|
||||
# lock while touching the filesystem.
|
||||
transaction.on_commit(lambda snapshot=snapshot: snapshot.ensure_crawl_symlink())
|
||||
|
||||
tag_names = {tag for tags in tag_names_by_url.values() for tag in tags}
|
||||
if tag_names:
|
||||
|
||||
@@ -57,7 +57,10 @@ def get_vm_info():
|
||||
|
||||
hw_manufacturer = "Docker" if hw_in_docker else "Unknown"
|
||||
hw_product = "Container" if hw_in_docker else "Unknown"
|
||||
hw_uuid = machineid.id()
|
||||
try:
|
||||
hw_uuid = machineid.id()
|
||||
except machineid.MachineIdNotFound:
|
||||
hw_uuid = "Unknown"
|
||||
|
||||
if platform.system().lower() == "darwin":
|
||||
# Get macOS machine info
|
||||
@@ -301,7 +304,9 @@ def get_host_stats() -> dict[str, Any]:
|
||||
|
||||
|
||||
def get_host_guid() -> str:
|
||||
return machineid.hashed_id("archivebox")
|
||||
from archivebox.config.paths import get_machine_id
|
||||
|
||||
return get_machine_id(length=64)
|
||||
|
||||
|
||||
# Example usage
|
||||
|
||||
@@ -221,35 +221,21 @@ class Machine(ModelWithHealthStats):
|
||||
app_label = "machine"
|
||||
|
||||
@classmethod
|
||||
def current(cls, refresh: bool = False) -> Machine:
|
||||
global _CURRENT_MACHINE
|
||||
if refresh:
|
||||
_CURRENT_MACHINE = None
|
||||
if _CURRENT_MACHINE:
|
||||
if timezone.now() < _CURRENT_MACHINE.modified_at + timedelta(seconds=MACHINE_RECHECK_INTERVAL):
|
||||
# Reconciliation can save() and clear the shared cache; keep
|
||||
# this call's instance so we never return None after that save.
|
||||
machine = _CURRENT_MACHINE
|
||||
# One-time-per-process reconciliation between ArchiveBox.conf
|
||||
# and Machine.config. Fast-path: bool check + early-return when
|
||||
# the sync has already run, so the cached-machine return path
|
||||
# stays sub-microsecond.
|
||||
try:
|
||||
from archivebox.config.collection import sync_machine_and_file
|
||||
def current_readonly(cls) -> Machine | None:
|
||||
"""Read the current machine without registration, refresh, or config writes."""
|
||||
if _CURRENT_MACHINE is not None:
|
||||
return _CURRENT_MACHINE
|
||||
return cls.objects.filter(guid=get_host_guid()).first()
|
||||
|
||||
sync_machine_and_file(machine)
|
||||
except Exception:
|
||||
pass
|
||||
return machine
|
||||
else:
|
||||
_CURRENT_MACHINE = None
|
||||
@classmethod
|
||||
def current(cls) -> Machine:
|
||||
global _CURRENT_MACHINE
|
||||
if _CURRENT_MACHINE is not None:
|
||||
return _CURRENT_MACHINE
|
||||
|
||||
host_guid = get_host_guid()
|
||||
# save() intentionally invalidates _CURRENT_MACHINE so config edits
|
||||
# made through other instances become visible in this process. Host
|
||||
# refresh (after seven days), sanitation, and reconciliation can all
|
||||
# save here, so the working instance must not live only in that cache.
|
||||
# Otherwise a routine refresh leaves startup dereferencing None.
|
||||
# Host detection, sanitation, and config reconciliation run only on
|
||||
# initial registration, never on cached reads.
|
||||
try:
|
||||
machine = cls.objects.get(guid=host_guid)
|
||||
except cls.DoesNotExist:
|
||||
@@ -296,17 +282,14 @@ class Machine(ModelWithHealthStats):
|
||||
],
|
||||
)
|
||||
machine = cls._sanitize_config(machine)
|
||||
# Same one-time sync as the cached-return path. Triggers here on the
|
||||
# very first ``Machine.current()`` call in a process before any
|
||||
# cached return can occur.
|
||||
# Reconcile once, on the first Machine.current() call in this process.
|
||||
try:
|
||||
from archivebox.config.collection import sync_machine_and_file
|
||||
|
||||
sync_machine_and_file(machine)
|
||||
except Exception:
|
||||
pass
|
||||
# Publish only after all save-capable steps; publishing earlier would
|
||||
# let those steps invalidate the cache we are in the middle of filling.
|
||||
# Publish only after initialization is complete.
|
||||
_CURRENT_MACHINE = machine
|
||||
return machine
|
||||
|
||||
@@ -377,18 +360,15 @@ class Machine(ModelWithHealthStats):
|
||||
if update_fields is not None:
|
||||
kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "config"]))
|
||||
|
||||
# Drop the ``Machine.current()`` module-level cache on every save so
|
||||
# config edits (admin form, from_json, etc.) become live in the same
|
||||
# process without waiting out the 7-day ``MACHINE_RECHECK_INTERVAL``.
|
||||
# The cache reads ``_CURRENT_MACHINE.modified_at`` and that value
|
||||
# never moves forward on the cached object even when the row is
|
||||
# updated in the DB, so without this we'd keep serving stale
|
||||
# ``machine.config`` (incl. ``BASE_URL``) until the worker restarts.
|
||||
# Apply explicit edits to the cached instance without making the next
|
||||
# read repeat host detection, sanitation, or config reconciliation.
|
||||
update_fields = kwargs.get("update_fields")
|
||||
super().save(*args, **kwargs)
|
||||
global _CURRENT_MACHINE
|
||||
if _CURRENT_MACHINE is not None and _CURRENT_MACHINE.pk == self.pk:
|
||||
_CURRENT_MACHINE = None
|
||||
for field in self._meta.concrete_fields:
|
||||
if field.attname in self.__dict__ and (update_fields is None or field.name in update_fields):
|
||||
setattr(_CURRENT_MACHINE, field.attname, self.__dict__[field.attname])
|
||||
|
||||
# Mirror Machine.config into ArchiveBox.conf so the two stores stay
|
||||
# 1:1. Skipped when ``update_fields`` is set and doesn't touch
|
||||
@@ -443,7 +423,7 @@ class NetworkInterface(ModelWithHealthStats):
|
||||
@classmethod
|
||||
def current(cls, refresh: bool = False) -> NetworkInterface:
|
||||
global _CURRENT_INTERFACE
|
||||
machine = Machine.current(refresh=refresh)
|
||||
machine = Machine.current()
|
||||
if _CURRENT_INTERFACE and _CURRENT_INTERFACE.machine_id == machine.id:
|
||||
if not refresh:
|
||||
# Callers that pass refresh=False are asking for attribution to
|
||||
@@ -1778,7 +1758,8 @@ class Process(ModelWithDeleteAfter, models.Model):
|
||||
return None
|
||||
|
||||
# Can't validate processes on other machines
|
||||
if self.machine_id != Machine.current().id:
|
||||
machine = Machine.current_readonly()
|
||||
if machine is None or self.machine_id != machine.id:
|
||||
return None
|
||||
|
||||
try:
|
||||
|
||||
+13
-3
@@ -445,7 +445,10 @@ def migration_lock(stdout: TextIO | None = None):
|
||||
|
||||
|
||||
# Migration names that previously existed in ArchiveBox's source tree but have
|
||||
# since been deleted (squashed away, renamed, moved between apps, etc.). DBs
|
||||
# since been deleted (squashed away, renamed, moved between apps, etc.), plus
|
||||
# exact older migrations generated in the installed package by v0.6.2's
|
||||
# startup makemigrations. Those runtime-generated names were never in Git, so
|
||||
# the deleted-file history alone cannot cover them. DBs
|
||||
# upgraded incrementally through 0.8.x → 0.9.x dev rcs accumulate rows for
|
||||
# these in ``django_migrations``; the newer-DB guard added in 65dc2521 would
|
||||
# otherwise refuse to start with "applied migrations missing from this build"
|
||||
@@ -454,13 +457,18 @@ def migration_lock(stdout: TextIO | None = None):
|
||||
# splits the migration graph when only a *subset* of the replaces list is
|
||||
# applied (which is exactly what happens for users at different intermediate
|
||||
# dev branch states). This set is the authoritative compat list — extend it
|
||||
# when squashing more migrations away. Generated from
|
||||
# ``git log --diff-filter=D --name-only`` over each app's migrations/ tree.
|
||||
# when squashing more migrations away. The source-tracked names were generated
|
||||
# from ``git log --diff-filter=D --name-only`` over each app's migrations/ tree.
|
||||
HISTORICAL_GHOST_MIGRATIONS: frozenset[tuple[str, str]] = frozenset(
|
||||
{
|
||||
# core: 0023→0075 sequence plus every transient dev rename
|
||||
("core", "0002_auto_20190417_0739"),
|
||||
("core", "0006_auto_20200915_2006"),
|
||||
# Reported v0.6.2 runtime-generated 0021 migrations; the checked-in
|
||||
# replacement only changes ArchiveResult.extractor choices.
|
||||
("core", "0021_auto_20220510_0644"),
|
||||
("core", "0021_auto_20220724_1254"),
|
||||
("core", "0021_auto_20221128_1116"),
|
||||
("core", "0023_alter_archiveresult_options_archiveresult_abid_and_more"),
|
||||
("core", "0023_new_schema"),
|
||||
("core", "0024_auto_20240513_1143"),
|
||||
@@ -518,6 +526,8 @@ HISTORICAL_GHOST_MIGRATIONS: frozenset[tuple[str, str]] = frozenset(
|
||||
("core", "0053_remove_snapshottag_snapshot_old"),
|
||||
("core", "0054_alter_snapshot_timestamp"),
|
||||
("core", "0055_alter_tag_slug"),
|
||||
# Dev-only progress indexes removed in favor of existing-index queries.
|
||||
("core", "0056_progress_covering_indexes"),
|
||||
("core", "0056_remove_tag_uuid"),
|
||||
("core", "0057_rename_id_tag_old_id"),
|
||||
("core", "0058_alter_tag_old_id"),
|
||||
|
||||
@@ -14,10 +14,10 @@ import time
|
||||
import zipfile
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlencode, urljoin
|
||||
|
||||
from abx_plugins.plugins.archivewebpage import replay_preview as archivewebpage_replay
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.handlers.asgi import ASGIRequest
|
||||
from django.http import Http404, HttpResponse, HttpResponseNotModified, StreamingHttpResponse
|
||||
@@ -29,8 +29,10 @@ from django.views import static
|
||||
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.misc.logging_util import printable_filesize
|
||||
from archivebox.plugins.discovery import render_plugin_full_response
|
||||
|
||||
_HASHES_CACHE: dict[Path, tuple[float, dict[str, str]]] = {}
|
||||
FAVICON_CACHE_CONTROL = "public, max-age=31536000, s-maxage=31536000, immutable"
|
||||
IMG_SRC_ATTR_RE = re.compile(r'(<img\b[^>]*?\s(?:src|data-src)=["\'])([^"\']+)(["\'])', re.IGNORECASE)
|
||||
TRANSFORMED_HTML_PREVIEW_STYLE = """<style id="archivebox-static-html-preview-style">
|
||||
html {
|
||||
@@ -527,6 +529,26 @@ def _index_responses_paths_for_html_images(
|
||||
|
||||
best_matches: dict[str, str] = {}
|
||||
image_matches: set[str] = set()
|
||||
# Optional responses plugin file contract: index.jsonl records contain method,
|
||||
# url, status, and a path relative to responses/. No plugin code is imported.
|
||||
# Legacy captures without an index still use the filename lookup below.
|
||||
# The index retains complete URLs even when response filenames are shortened.
|
||||
index = responses_root / "index.jsonl"
|
||||
if index.is_file():
|
||||
with index.open(errors="replace") as records:
|
||||
for line in records:
|
||||
try:
|
||||
record = json.loads(line)
|
||||
encoded = _encoded_responses_image_url(record.get("url", ""), None)
|
||||
if encoded not in encoded_urls or record.get("method") != "GET" or record.get("status") != 200:
|
||||
continue
|
||||
candidate = (responses_root / record.get("path", "")).resolve()
|
||||
if not candidate.is_relative_to(responses_root.resolve()) or not candidate.is_file():
|
||||
continue
|
||||
best_matches[encoded] = posixpath.relpath(candidate, start=snapshot_root / posixpath.dirname(html_rel_path))
|
||||
image_matches.add(encoded)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
continue
|
||||
image_suffixes = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico"}
|
||||
for candidate in responses_root.rglob("*"):
|
||||
if not candidate.is_file():
|
||||
@@ -561,10 +583,30 @@ def _rewrite_html_image_sources_to_responses(
|
||||
if image_url not in encoded_urls_by_src:
|
||||
encoded_urls_by_src[image_url] = _encoded_responses_image_url(image_url, page_url)
|
||||
|
||||
# Optional dom plugin artifact: dom/output.html. Only image src and
|
||||
# data-canonical-src attributes are read; no scripts or plugin code execute.
|
||||
# Absence of this artifact simply disables proxy-to-original aliases.
|
||||
aliases: dict[str, str] = {}
|
||||
|
||||
class ImageAliases(HTMLParser):
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attrs = dict(attrs)
|
||||
original = attrs.get("data-canonical-src")
|
||||
proxy = attrs.get("src")
|
||||
if tag == "img" and original and proxy:
|
||||
original_key = _encoded_responses_image_url(original, page_url)
|
||||
proxy_key = _encoded_responses_image_url(proxy, page_url)
|
||||
if original_key and proxy_key:
|
||||
aliases[original_key] = proxy_key
|
||||
|
||||
dom = snapshot_root / "dom" / "output.html"
|
||||
if dom.is_file() and dom.stat().st_size <= 10 * 1024 * 1024:
|
||||
ImageAliases().feed(dom.read_text(errors="replace"))
|
||||
wanted = {encoded for encoded in encoded_urls_by_src.values() if encoded}
|
||||
response_paths = _index_responses_paths_for_html_images(
|
||||
snapshot_root,
|
||||
html_rel_path,
|
||||
{encoded_url for encoded_url in encoded_urls_by_src.values() if encoded_url},
|
||||
wanted | {aliases[key] for key in wanted if key in aliases},
|
||||
)
|
||||
rewrites = 0
|
||||
|
||||
@@ -572,7 +614,7 @@ def _rewrite_html_image_sources_to_responses(
|
||||
nonlocal rewrites
|
||||
image_url = html.unescape(match.group(2))
|
||||
encoded_url = encoded_urls_by_src.get(image_url)
|
||||
local_path = response_paths.get(encoded_url or "")
|
||||
local_path = response_paths.get(encoded_url or "") or response_paths.get(aliases.get(encoded_url or "", ""))
|
||||
if not local_path:
|
||||
return match.group(0)
|
||||
rewrites += 1
|
||||
@@ -589,12 +631,17 @@ def _rewrite_html_image_sources_for_request(
|
||||
) -> tuple[str, int]:
|
||||
if not document_root:
|
||||
return html_text, 0
|
||||
return _rewrite_html_image_sources_to_responses(
|
||||
html_text,
|
||||
document_root,
|
||||
html_rel_path,
|
||||
request.__dict__.get("archivebox_snapshot_url"),
|
||||
)
|
||||
try:
|
||||
return _rewrite_html_image_sources_to_responses(
|
||||
html_text,
|
||||
document_root,
|
||||
html_rel_path,
|
||||
request.__dict__.get("archivebox_snapshot_url"),
|
||||
)
|
||||
except (OSError, ValueError, TypeError, RecursionError):
|
||||
# Saved plugin artifacts are optional and may be incomplete, malformed,
|
||||
# or removed concurrently. Image enrichment must not break file replay.
|
||||
return html_text, 0
|
||||
|
||||
|
||||
def _apply_transformed_html_preview_style(html_text: str) -> str:
|
||||
@@ -620,7 +667,7 @@ def _set_transformed_response_headers(
|
||||
|
||||
|
||||
def _render_markdown_fallback(text: str) -> str:
|
||||
if _markdown is not None and not HTML_TAG_RE.search(text):
|
||||
if _markdown is not None:
|
||||
try:
|
||||
return _markdown(
|
||||
text,
|
||||
@@ -893,6 +940,15 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
raise Http404(_("“%(path)s” does not exist") % {"path": fullpath})
|
||||
|
||||
statobj = fullpath.stat()
|
||||
# Captured site icons are intentionally shareable even for private snapshots.
|
||||
# Only raw icon responses qualify, not generated previews or directory pages.
|
||||
favicon_cache_control = (
|
||||
FAVICON_CACHE_CONTROL
|
||||
if fullpath.stem.lower() == "favicon"
|
||||
and fullpath.suffix.lower() in {".ico", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif"}
|
||||
and not request.GET.get("preview")
|
||||
else None
|
||||
)
|
||||
document_root = Path(document_root) if document_root else None
|
||||
rel_path = path
|
||||
etag = None
|
||||
@@ -908,7 +964,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
if etag in inm_list or etag.strip('"') in [i.strip('"') for i in inm_list]:
|
||||
not_modified = HttpResponseNotModified()
|
||||
not_modified.headers["ETag"] = etag
|
||||
not_modified.headers["Cache-Control"] = f"{cache_policy}, max-age=31536000, immutable"
|
||||
not_modified.headers["Cache-Control"] = favicon_cache_control or f"{cache_policy}, max-age=31536000, immutable"
|
||||
not_modified.headers["Last-Modified"] = http_date(statobj.st_mtime)
|
||||
return _apply_archive_replay_headers(
|
||||
not_modified,
|
||||
@@ -919,6 +975,13 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
)
|
||||
|
||||
content_type, encoding = mimetypes.guess_type(str(fullpath))
|
||||
# Captured favicons retain the conventional .ico filename even when a site
|
||||
# returns SVG. Browsers cannot decode SVG served as image/x-icon.
|
||||
if fullpath.suffix.lower() == ".ico":
|
||||
with fullpath.open("rb") as icon_file:
|
||||
icon_head = icon_file.read(4096)
|
||||
if re.search(rb"<svg(?:\s|>)", icon_head):
|
||||
content_type = "image/svg+xml"
|
||||
preserve_plain_text = fullpath.suffix.lower() in {".log", ".sh", ".jsonl"}
|
||||
if preserve_plain_text:
|
||||
content_type = "text/plain"
|
||||
@@ -942,15 +1005,13 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
preview_as_image_html = (
|
||||
bool(request.GET.get("preview")) and content_type.startswith("image/") and not content_type.startswith("image/svg+xml")
|
||||
)
|
||||
preview_as_archivewebpage_html = bool(request.GET.get("preview")) and archivewebpage_replay.is_replay_target(fullpath.name)
|
||||
|
||||
# Respect the If-Modified-Since header for non-markdown responses.
|
||||
if not content_type.startswith(("text/plain", "text/html")) and not static.was_modified_since(
|
||||
request.META.get("HTTP_IF_MODIFIED_SINCE"),
|
||||
statobj.st_mtime,
|
||||
):
|
||||
not_modified = HttpResponseNotModified()
|
||||
not_modified.headers["Cache-Control"] = f"{cache_policy}, max-age=60, stale-while-revalidate=300"
|
||||
not_modified.headers["Cache-Control"] = favicon_cache_control or f"{cache_policy}, max-age=60, stale-while-revalidate=300"
|
||||
return _apply_archive_replay_headers(
|
||||
not_modified,
|
||||
fullpath=fullpath,
|
||||
@@ -976,7 +1037,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
config=config,
|
||||
)
|
||||
except (OSError, UnicodeDecodeError, ValueError):
|
||||
preview_as_text_html = False
|
||||
pass
|
||||
|
||||
if preview_as_image_html:
|
||||
try:
|
||||
@@ -996,16 +1057,16 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
config=config,
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
preview_as_image_html = False
|
||||
pass
|
||||
|
||||
if preview_as_archivewebpage_html:
|
||||
if request.GET.get("preview"):
|
||||
try:
|
||||
raw_query = request.GET.copy()
|
||||
raw_query.pop("preview", None)
|
||||
raw_output_path = request.path
|
||||
if raw_query:
|
||||
raw_output_path = f"{raw_output_path}?{raw_query.urlencode()}"
|
||||
body, preview_content_type, headers = archivewebpage_replay.render_preview_response(
|
||||
plugin_replay = render_plugin_full_response(
|
||||
fullpath.name,
|
||||
raw_output_path,
|
||||
wacz_path=fullpath,
|
||||
@@ -1017,22 +1078,24 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
),
|
||||
content_encoding=encoding or "",
|
||||
)
|
||||
response = HttpResponse(body, content_type=preview_content_type)
|
||||
for key, value in headers.items():
|
||||
response.headers[key] = value
|
||||
return _apply_archive_replay_headers(
|
||||
response,
|
||||
fullpath=fullpath,
|
||||
content_type=preview_content_type,
|
||||
is_archive_replay=is_archive_replay,
|
||||
config=config,
|
||||
)
|
||||
if plugin_replay is not None:
|
||||
body, replay_content_type, headers = plugin_replay
|
||||
response = HttpResponse(body, content_type=replay_content_type)
|
||||
for key, value in headers.items():
|
||||
response.headers[key] = value
|
||||
return _apply_archive_replay_headers(
|
||||
response,
|
||||
fullpath=fullpath,
|
||||
content_type=replay_content_type,
|
||||
is_archive_replay=is_archive_replay,
|
||||
config=config,
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
preview_as_archivewebpage_html = False
|
||||
pass
|
||||
|
||||
# Heuristic fix: some archived HTML outputs are stored with HTML-escaped markup
|
||||
# or markdown sources. If so, render sensibly.
|
||||
if not request.GET.get("raw") and not preserve_plain_text and content_type.startswith(("text/plain", "text/html")):
|
||||
if not request.GET.get("raw") and not preserve_plain_text and content_type.startswith(("text/plain", "text/html", "text/markdown")):
|
||||
try:
|
||||
max_unescape_size = 10 * 1024 * 1024 # 10MB cap to avoid heavy memory use
|
||||
if statobj.st_size <= max_unescape_size:
|
||||
@@ -1046,7 +1109,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
if content_type.startswith("text/html") and document_root:
|
||||
rewritten_html, rewritten_count = _rewrite_html_image_sources_for_request(request, decoded, document_root, rel_path)
|
||||
markdown_candidate = _extract_markdown_candidate(decoded)
|
||||
if _looks_like_markdown(markdown_candidate):
|
||||
if content_type.startswith("text/markdown") or _looks_like_markdown(markdown_candidate):
|
||||
wrapped = _render_markdown_document(markdown_candidate)
|
||||
wrapped, _rewrite_count = _rewrite_html_image_sources_for_request(request, wrapped, document_root, rel_path)
|
||||
wrapped = _apply_transformed_html_preview_style(wrapped)
|
||||
@@ -1100,6 +1163,8 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
|
||||
response.headers["Content-Disposition"] = f'inline; filename="{fullpath.name}"'
|
||||
if content_type.startswith("image/"):
|
||||
response.headers["Cache-Control"] = f"{cache_policy}, max-age=604800, immutable"
|
||||
if favicon_cache_control:
|
||||
response.headers["Cache-Control"] = favicon_cache_control
|
||||
|
||||
# handle byte-range requests by serving chunk of file
|
||||
if stat.S_ISREG(statobj.st_mode):
|
||||
|
||||
+15
-103
@@ -3,7 +3,7 @@ __package__ = "archivebox.misc"
|
||||
# Bootable utility functions (URL parsing, date parsing, JSON encoding, decorators).
|
||||
# MUST NOT import archivebox.config, archivebox.core, or Django — this module is
|
||||
# loaded by hooks.py and other early code paths. Only depends on stdlib and the
|
||||
# bootable .logging module.
|
||||
# bootable .logging module and standalone abx_plugins URL helpers.
|
||||
|
||||
import re
|
||||
import json as pyjson
|
||||
@@ -19,6 +19,9 @@ from urllib.parse import urlparse, quote, unquote
|
||||
from html import escape, unescape
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from abx_plugins.plugins.base.url_cleaning import URL_REGEX, fix_url_from_markdown
|
||||
from abx_plugins.plugins.base.url_cleaning import sanitize_extracted_url as sanitize_plugin_url
|
||||
|
||||
from .logging import COLOR_DICT
|
||||
|
||||
|
||||
@@ -90,18 +93,6 @@ def ts_to_date_str(ts: Any) -> str | None:
|
||||
COLOR_REGEX = re.compile(r"\[(?P<arg_1>\d+)(;(?P<arg_2>\d+)(;(?P<arg_3>\d+))?)?m")
|
||||
|
||||
|
||||
# https://mathiasbynens.be/demo/url-regex
|
||||
URL_REGEX = re.compile(
|
||||
r"(?=("
|
||||
r"http[s]?://" # start matching from allowed schemes
|
||||
r"(?:[a-zA-Z]|[0-9]" # followed by allowed alphanum characters
|
||||
r"|[-_$@.&+!*\(\),]" # or allowed symbols (keep hyphen first to match literal hyphen)
|
||||
r"|[^\u0000-\u007F])+" # or allowed unicode bytes
|
||||
r'[^\]\[<>"\'\s]+' # stop parsing at these symbols
|
||||
r"))",
|
||||
re.IGNORECASE | re.UNICODE,
|
||||
)
|
||||
|
||||
# Maximum supported URL length. Very long URLs are rare but must be supported correctly
|
||||
# (e.g. data: URLs, deeply nested query strings). The Snapshot.url column is stored as a
|
||||
# variable-length TextField (so short URLs don't reserve space and very long URLs still
|
||||
@@ -109,29 +100,6 @@ URL_REGEX = re.compile(
|
||||
# all keep working.
|
||||
MAX_URL_LENGTH = 65535
|
||||
|
||||
QUOTE_DELIMITERS = (
|
||||
'"',
|
||||
"'",
|
||||
"`",
|
||||
"“",
|
||||
"”",
|
||||
"‘",
|
||||
"’",
|
||||
)
|
||||
QUOTE_ENTITY_DELIMITERS = (
|
||||
""",
|
||||
""",
|
||||
""",
|
||||
"'",
|
||||
"'",
|
||||
"'",
|
||||
)
|
||||
URL_ENTITY_REPLACEMENTS = (
|
||||
("&", "&"),
|
||||
("&", "&"),
|
||||
("&", "&"),
|
||||
)
|
||||
|
||||
FILESIZE_UNITS: dict[str, int] = {
|
||||
"": 1,
|
||||
"b": 1,
|
||||
@@ -153,36 +121,9 @@ FILESIZE_UNITS: dict[str, int] = {
|
||||
|
||||
|
||||
def sanitize_extracted_url(url: str) -> str:
|
||||
"""Trim quote garbage and dangling prose punctuation from an extracted URL candidate."""
|
||||
cleaned = (url or "").strip()
|
||||
if not cleaned:
|
||||
return cleaned
|
||||
|
||||
lower_cleaned = cleaned.lower()
|
||||
cut_index = len(cleaned)
|
||||
|
||||
for delimiter in QUOTE_DELIMITERS:
|
||||
found_index = cleaned.find(delimiter)
|
||||
if found_index != -1:
|
||||
cut_index = min(cut_index, found_index)
|
||||
|
||||
for delimiter in QUOTE_ENTITY_DELIMITERS:
|
||||
found_index = lower_cleaned.find(delimiter)
|
||||
if found_index != -1:
|
||||
cut_index = min(cut_index, found_index)
|
||||
|
||||
cleaned = cleaned[:cut_index].strip()
|
||||
lower_cleaned = cleaned.lower()
|
||||
for entity, replacement in URL_ENTITY_REPLACEMENTS:
|
||||
while entity in lower_cleaned:
|
||||
entity_index = lower_cleaned.find(entity)
|
||||
cleaned = cleaned[:entity_index] + replacement + cleaned[entity_index + len(entity) :]
|
||||
lower_cleaned = cleaned.lower()
|
||||
|
||||
cleaned = cleaned.rstrip(".,;:!?\\'\"")
|
||||
cleaned = cleaned.rstrip('"')
|
||||
|
||||
return cleaned
|
||||
"""Use the standalone parser's quote rules, then trim dangling prose punctuation."""
|
||||
cleaned = sanitize_plugin_url(url)
|
||||
return cleaned.rstrip('.,;:!?\\"')
|
||||
|
||||
|
||||
def validate_url_length(url: str) -> str:
|
||||
@@ -215,39 +156,6 @@ def parens_are_matched(string: str, open_char="(", close_char=")"):
|
||||
return count == 0
|
||||
|
||||
|
||||
def fix_url_from_markdown(url_str: str) -> str:
|
||||
"""
|
||||
cleanup a regex-parsed url that may contain dangling trailing parens from markdown link syntax
|
||||
helpful to fix URLs parsed from markdown e.g.
|
||||
input: https://wikipedia.org/en/some_article_(Disambiguation).html?abc=def).somemoretext
|
||||
result: https://wikipedia.org/en/some_article_(Disambiguation).html?abc=def
|
||||
|
||||
IMPORTANT ASSUMPTION: valid urls wont have unbalanced or incorrectly nested parentheses
|
||||
e.g. this will fail the user actually wants to ingest a url like 'https://example.com/some_wei)(rd_url'
|
||||
in that case it will return https://example.com/some_wei (truncated up to the first unbalanced paren)
|
||||
This assumption is true 99.9999% of the time, and for the rare edge case the user can use url_list parser.
|
||||
"""
|
||||
trimmed_url = url_str
|
||||
if len(trimmed_url) > 2048:
|
||||
return trimmed_url
|
||||
|
||||
# cut off one trailing character at a time
|
||||
# until parens are balanced e.g. /a(b)c).x(y)z -> /a(b)c
|
||||
trim_attempts = 0
|
||||
while trimmed_url and not parens_are_matched(trimmed_url) and trim_attempts < 256:
|
||||
trimmed_url = trimmed_url[:-1]
|
||||
trim_attempts += 1
|
||||
|
||||
if not trimmed_url or not parens_are_matched(trimmed_url):
|
||||
return url_str
|
||||
|
||||
# make sure trimmed url is still valid
|
||||
if any(match == trimmed_url for match in re.findall(URL_REGEX, trimmed_url)):
|
||||
return trimmed_url
|
||||
|
||||
return url_str
|
||||
|
||||
|
||||
def split_comma_separated_urls(url: str):
|
||||
offset = 0
|
||||
while True:
|
||||
@@ -274,7 +182,8 @@ def find_all_urls(urls_str: str):
|
||||
if match.start() in skipped_starts:
|
||||
continue
|
||||
|
||||
cleaned_match = sanitize_extracted_url(fix_url_from_markdown(match.group(1)))
|
||||
cleaned_match = fix_url_from_markdown(match.group(1), preceding_text=urls_str[max(0, match.start(1) - 6) : match.start(1)])
|
||||
cleaned_match = sanitize_extracted_url(cleaned_match)
|
||||
for offset, url in split_comma_separated_urls(cleaned_match):
|
||||
if offset:
|
||||
skipped_starts.add(match.start() + offset)
|
||||
@@ -388,9 +297,12 @@ def parse_date(date: Any) -> datetime | None:
|
||||
if date.tzinfo is None:
|
||||
return date.replace(tzinfo=timezone.utc)
|
||||
|
||||
offset = date.utcoffset()
|
||||
assert offset == datetime.now(timezone.utc).utcoffset(), "Refusing to load a non-UTC date!"
|
||||
return date
|
||||
# Legacy SQLite ArchiveResult rows can retain explicit timezone offsets
|
||||
# through migration. Django loads those as aware datetimes, whereas JSON
|
||||
# imports reach the string branch below. Normalize both representations
|
||||
# consistently so opening a migrated snapshot does not raise (#1889).
|
||||
# Convert the instant; replacing tzinfo here would shift the capture time.
|
||||
return date.astimezone(timezone.utc)
|
||||
|
||||
if isinstance(date, (float, int)):
|
||||
date = str(date)
|
||||
|
||||
@@ -63,7 +63,6 @@ class PersonaAdmin(ConfigEditorMixin, BaseModelAdmin):
|
||||
"classes": ("card", "persona-card-timestamps"),
|
||||
},
|
||||
),
|
||||
add_fieldsets[1],
|
||||
add_fieldsets[2],
|
||||
(
|
||||
"Artifacts",
|
||||
@@ -136,7 +135,7 @@ class PersonaAdmin(ConfigEditorMixin, BaseModelAdmin):
|
||||
return super().get_form(request, obj=obj, change=change, **kwargs)
|
||||
|
||||
def render_change_form(self, request, context, add=False, change=False, form_url="", obj=None):
|
||||
context["detected_profile_count"] = len(discover_local_browser_profiles())
|
||||
context["detected_profile_count"] = len(discover_local_browser_profiles()) if add else 0
|
||||
return super().render_change_form(request, context, add=add, change=change, form_url=form_url, obj=obj)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
|
||||
@@ -2,7 +2,9 @@ __package__ = "archivebox.plugins"
|
||||
|
||||
from collections.abc import Iterable
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from importlib import import_module
|
||||
from pathlib import Path, PurePosixPath
|
||||
from types import ModuleType
|
||||
from typing import Any, Protocol, TypedDict
|
||||
|
||||
from abx_plugins import get_plugins_dir
|
||||
@@ -66,8 +68,141 @@ def get_plugin_name(plugin: str) -> str:
|
||||
"""
|
||||
parts = plugin.split("_", 1)
|
||||
if len(parts) == 2 and parts[0].isdigit():
|
||||
return parts[1]
|
||||
return plugin
|
||||
plugin = parts[1]
|
||||
return get_archive_result_aliases().get(plugin, plugin)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_archive_result_aliases() -> dict[str, str]:
|
||||
aliases: dict[str, str] = {}
|
||||
for plugin in get_plugin_catalog().values():
|
||||
for alias in plugin.manifest.get("archive_result_aliases") or []:
|
||||
alias = str(alias or "").strip()
|
||||
if alias:
|
||||
aliases[alias] = plugin.name
|
||||
return aliases
|
||||
|
||||
|
||||
def get_archive_result_names(plugin_name: str) -> tuple[str, ...]:
|
||||
"""Return the canonical ArchiveResult name and its declared historic aliases."""
|
||||
canonical = get_plugin_name(plugin_name)
|
||||
aliases = get_archive_result_aliases()
|
||||
return tuple(dict.fromkeys((canonical, plugin_name, *(alias for alias, target in aliases.items() if target == canonical))))
|
||||
|
||||
|
||||
def plugin_card_is_interactive(plugin_name: str) -> bool:
|
||||
plugin = get_plugin_catalog().get(get_plugin_name(plugin_name))
|
||||
return bool(plugin and plugin.manifest.get("card_interactive"))
|
||||
|
||||
|
||||
def get_plugin_output_extension_preference(plugin_name: str) -> tuple[tuple[str, ...], ...] | None:
|
||||
plugin = get_plugin_catalog().get(get_plugin_name(plugin_name))
|
||||
raw_groups = plugin.manifest.get("output_extension_preference") if plugin else None
|
||||
if not isinstance(raw_groups, list):
|
||||
return None
|
||||
groups = []
|
||||
for raw_group in raw_groups:
|
||||
if not isinstance(raw_group, list):
|
||||
continue
|
||||
group = tuple(str(extension).lower() for extension in raw_group if str(extension).startswith("."))
|
||||
if group:
|
||||
groups.append(group)
|
||||
return tuple(groups) or None
|
||||
|
||||
|
||||
def get_plugin_default_output_path(plugin_name: str) -> str | None:
|
||||
prefix, separator, base_name = plugin_name.partition("_")
|
||||
archive_result_name = base_name if separator and prefix.isdigit() else plugin_name
|
||||
plugin = get_plugin_catalog().get(get_plugin_name(plugin_name))
|
||||
raw_path = str(plugin.manifest.get("default_output_path") or "").strip() if plugin else ""
|
||||
path = PurePosixPath(raw_path)
|
||||
if not raw_path or raw_path.startswith("/") or ".." in path.parts or len(path.parts) != 1:
|
||||
return None
|
||||
return f"{archive_result_name}/{raw_path}"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_snapshot_thumbnail_card_order() -> dict[str, int]:
|
||||
"""Return plugin-owned ArchiveResult names eligible for snapshot thumbnails."""
|
||||
ordering: dict[str, int] = {}
|
||||
for plugin in get_plugin_catalog().values():
|
||||
declarations = plugin.manifest.get("snapshot_thumbnail_cards") or []
|
||||
if not isinstance(declarations, list):
|
||||
continue
|
||||
for declaration in declarations:
|
||||
if not isinstance(declaration, dict):
|
||||
continue
|
||||
result_name = str(declaration.get("archive_result_name") or "").strip()
|
||||
order = declaration.get("order")
|
||||
if not result_name or isinstance(order, bool) or not isinstance(order, int):
|
||||
continue
|
||||
ordering[result_name] = order
|
||||
return ordering
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_snapshot_role_names(role: str) -> tuple[str, ...]:
|
||||
"""Return ArchiveResult names for a plugin-owned snapshot presentation role."""
|
||||
manifest_key = f"snapshot_{role}"
|
||||
names = []
|
||||
for plugin in get_plugin_catalog().values():
|
||||
if plugin.manifest.get(manifest_key):
|
||||
names.extend(get_archive_result_names(plugin.name))
|
||||
return tuple(dict.fromkeys(names))
|
||||
|
||||
|
||||
def get_snapshot_role_result(results, role: str):
|
||||
"""Select the first successful result declared for a presentation role."""
|
||||
results_by_name = {result.plugin: result for result in results if result.status == "succeeded"}
|
||||
return next((results_by_name[name] for name in get_snapshot_role_names(role) if name in results_by_name), None)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_plugin_presentation_modules() -> tuple[ModuleType, ...]:
|
||||
"""Load optional plugin-owned presentation hooks declared in manifests."""
|
||||
return tuple(module for plugin in get_plugin_catalog().values() if (module := get_plugin_presentation_module(plugin.name)) is not None)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_plugin_presentation_module(plugin_name: str) -> ModuleType | None:
|
||||
"""Load one plugin's optional presentation hooks module."""
|
||||
plugin = get_plugin_catalog().get(get_plugin_name(plugin_name))
|
||||
module_name = str(plugin.manifest.get("presentation_module") or "").strip() if plugin else ""
|
||||
return import_module(module_name) if module_name else None
|
||||
|
||||
|
||||
def plugin_has_custom_full_response(plugin_name: str) -> bool:
|
||||
"""Return whether a plugin renders its full template through a response hook."""
|
||||
module = get_plugin_presentation_module(plugin_name)
|
||||
return bool(module and getattr(module, "render_full_response", None))
|
||||
|
||||
|
||||
def get_extra_snapshot_output_cards(outputs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Collect additional cards from plugin-owned presentation hooks."""
|
||||
cards = []
|
||||
for module in get_plugin_presentation_modules():
|
||||
hook = getattr(module, "extra_snapshot_output_cards", None)
|
||||
if hook is not None:
|
||||
cards.extend(hook(outputs) or ())
|
||||
return cards
|
||||
|
||||
|
||||
def serve_plugin_replay_asset(path: str, config, response_class):
|
||||
"""Give plugin presentation hooks the first chance to serve replay assets."""
|
||||
for module in get_plugin_presentation_modules():
|
||||
hook = getattr(module, "serve_replay_asset_response", None)
|
||||
if hook is not None and (response := hook(path, config, response_class)) is not None:
|
||||
return response
|
||||
return None
|
||||
|
||||
|
||||
def render_plugin_full_response(path: str, output_url: str, **kwargs):
|
||||
"""Let plugin presentation hooks render a saved output's full template."""
|
||||
for module in get_plugin_presentation_modules():
|
||||
render = getattr(module, "render_full_response", None)
|
||||
if render is not None and (response := render(path, output_url, **kwargs)) is not None:
|
||||
return response
|
||||
return None
|
||||
|
||||
|
||||
def get_enabled_plugins(config: ConfigLookup | None = None, **config_kwargs: Any) -> list[str]:
|
||||
@@ -151,12 +286,10 @@ def get_plugin_template(plugin: str, template_name: str, fallback: bool = True)
|
||||
|
||||
Args:
|
||||
plugin: Plugin name (e.g., 'screenshot', '15_singlefile')
|
||||
template_name: One of 'icon', 'card', 'full'
|
||||
template_name: A template slot such as 'icon', 'card', or 'full'.
|
||||
fallback: If True, return default template if plugin template not found
|
||||
"""
|
||||
base_name = get_plugin_name(plugin)
|
||||
if base_name in ("yt-dlp", "youtube-dl"):
|
||||
base_name = "ytdlp"
|
||||
|
||||
catalog = get_plugin_catalog()
|
||||
if base_name in catalog:
|
||||
|
||||
@@ -1,75 +1,115 @@
|
||||
"""Snapshot output taxonomy. Display preference is independent of hook order."""
|
||||
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from archivebox.plugins.discovery import get_plugin_name
|
||||
from archivebox.plugins.discovery import (
|
||||
get_extra_snapshot_output_cards,
|
||||
get_plugin_catalog,
|
||||
get_plugin_name,
|
||||
get_snapshot_thumbnail_card_order,
|
||||
)
|
||||
|
||||
# Ordered tuples are preference lists; unordered groups sort by total output bytes.
|
||||
OUTPUT_GROUPS = (
|
||||
("html", "HTML", ("archivewebpage", "singlefile", "chrome_mhtml", "wget", "dom")),
|
||||
("raster", "Raster", ("screenshot", "pdf")),
|
||||
("article_text", "Article text", ("defuddle", "readability", "mercury", "htmltotext")),
|
||||
("embedded_media", "Embedded media", ()),
|
||||
("ocr", "OCR", ("trafilatura", "liteparse")),
|
||||
("metadata", "Metadata", ()),
|
||||
("other", "Other files", ()),
|
||||
_GROUP_LABELS = (
|
||||
("html", "HTML"),
|
||||
("raster", "Raster"),
|
||||
("article_text", "Article text"),
|
||||
("embedded_media", "Embedded media"),
|
||||
("metadata", "Metadata"),
|
||||
("other", "Other files"),
|
||||
)
|
||||
|
||||
|
||||
def _plugin_presentation() -> dict[str, dict[str, Any]]:
|
||||
presentation = {}
|
||||
for plugin in get_plugin_catalog().values():
|
||||
manifest = plugin.manifest
|
||||
order = manifest.get("snapshot_output_order")
|
||||
presentation[plugin.name] = {
|
||||
"group": manifest.get("snapshot_output_group") or "other",
|
||||
"order": order if isinstance(order, int) and not isinstance(order, bool) else None,
|
||||
"display_name": manifest.get("snapshot_display_name"),
|
||||
"icon_hidden": bool(manifest.get("icon_hidden")),
|
||||
"card_hidden": bool(manifest.get("card_hidden")),
|
||||
}
|
||||
return presentation
|
||||
|
||||
|
||||
PLUGIN_PRESENTATION = _plugin_presentation()
|
||||
OUTPUT_GROUPS = tuple(
|
||||
(
|
||||
group_id,
|
||||
label,
|
||||
tuple(
|
||||
name
|
||||
for name, metadata in sorted(
|
||||
PLUGIN_PRESENTATION.items(),
|
||||
key=lambda item: (item[1]["order"] is None, item[1]["order"] or 0, item[0]),
|
||||
)
|
||||
if metadata["group"] == group_id and metadata["order"] is not None
|
||||
),
|
||||
)
|
||||
for group_id, label in _GROUP_LABELS
|
||||
)
|
||||
UNORDERED_OUTPUTS = {
|
||||
"embedded_media": {"responses", "ytdlp", "yt-dlp", "youtube-dl", "gallerydl", "forumdl", "git", "media"},
|
||||
"metadata": {
|
||||
"dns",
|
||||
"headers",
|
||||
"seo",
|
||||
"accessibility",
|
||||
"redirects",
|
||||
"consolelog",
|
||||
"sslcerts",
|
||||
"ssl",
|
||||
"title",
|
||||
"favicon",
|
||||
"hashes",
|
||||
"chrome",
|
||||
"parse_html_urls",
|
||||
"parse_txt_urls",
|
||||
"parse_dom_outlinks",
|
||||
"parse_jsonl_urls",
|
||||
"parse_netscape_urls",
|
||||
"parse_rss_urls",
|
||||
"tlsnotary",
|
||||
"opentimestamps",
|
||||
},
|
||||
group_id: {name for name, metadata in PLUGIN_PRESENTATION.items() if metadata["group"] == group_id and metadata["order"] is None}
|
||||
for group_id, _label in _GROUP_LABELS
|
||||
}
|
||||
HIDDEN_ICON_PLUGINS = frozenset(name for name, metadata in PLUGIN_PRESENTATION.items() if metadata["icon_hidden"])
|
||||
HIDDEN_CARD_PLUGINS = frozenset(name for name, metadata in PLUGIN_PRESENTATION.items() if metadata["card_hidden"])
|
||||
|
||||
|
||||
def _responses_html_card(outputs: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
# Presentation-only exception to the usual 1:1 plugin:card rule. Responses
|
||||
# owns both this HTML view and its media gallery; responses_html is only a
|
||||
# unique card/anchor ID, never a plugin or a synthetic ArchiveResult.
|
||||
# Refactor this exception if plugins gain support for multiple cards.
|
||||
for output in outputs:
|
||||
if output["name"] != "responses" or not (result := output.get("result")):
|
||||
def output_group_for_plugin(plugin: str) -> str:
|
||||
"""Return the snapshot-detail stack that contains a plugin output."""
|
||||
plugin = get_plugin_name(plugin)
|
||||
for group_id, _, plugins in OUTPUT_GROUPS:
|
||||
if plugin in plugins or plugin in UNORDERED_OUTPUTS.get(group_id, set()):
|
||||
return group_id
|
||||
return "other"
|
||||
|
||||
|
||||
def plugin_icon_is_hidden(plugin: str) -> bool:
|
||||
return get_plugin_name(plugin) in HIDDEN_ICON_PLUGINS
|
||||
|
||||
|
||||
def plugin_card_is_hidden(plugin: str) -> bool:
|
||||
return get_plugin_name(plugin) in HIDDEN_CARD_PLUGINS
|
||||
|
||||
|
||||
def display_plugin_name(plugin: str) -> str:
|
||||
canonical = get_plugin_name(plugin)
|
||||
return PLUGIN_PRESENTATION.get(canonical, {}).get("display_name") or plugin.replace("_", " ").replace("-", " ").title()
|
||||
|
||||
|
||||
def plugin_output_sizes(results) -> dict[str, int]:
|
||||
"""Count each plugin's files once, even when several hooks report them."""
|
||||
files_by_plugin: dict[str, dict[str, int]] = {}
|
||||
for result in results:
|
||||
files = files_by_plugin.setdefault(result.plugin, {})
|
||||
for path, metadata in result.output_file_map().items():
|
||||
files[path] = result._coerce_output_file_size(metadata.get("size"))
|
||||
return {plugin: sum(files.values()) for plugin, files in files_by_plugin.items() if files}
|
||||
|
||||
|
||||
def order_output_plugins(plugins, sizes: dict[str, int]) -> list[str]:
|
||||
"""Use the detail-card ordering for icon piles, without synthetic cards."""
|
||||
return [output["name"] for output in order_snapshot_outputs([{"name": plugin, "size": sizes.get(plugin, 0)} for plugin in plugins])]
|
||||
|
||||
|
||||
def snapshot_thumbnail_result(results):
|
||||
"""Select the first available card using plugin-owned thumbnail priority."""
|
||||
card_order = get_snapshot_thumbnail_card_order()
|
||||
candidates = []
|
||||
for result in results:
|
||||
if result.plugin not in card_order:
|
||||
continue
|
||||
path = str(output.get("path") or "")
|
||||
if not path.startswith("responses/") or ".." in PurePosixPath(path).parts:
|
||||
try:
|
||||
path = result.embed_path()
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
continue
|
||||
files = result.output_file_map()
|
||||
info = files.get(path) or files.get(path.removeprefix("responses/")) or {}
|
||||
mime = str(info.get("mimetype") or "").split(";")[0].lower()
|
||||
if not info or result._coerce_output_file_size(info.get("size")) <= 0:
|
||||
if not path:
|
||||
continue
|
||||
if mime not in {"text/html", "application/xhtml+xml"} and PurePosixPath(path).suffix.lower() not in {".html", ".htm"}:
|
||||
continue
|
||||
return {
|
||||
"name": "responses_html",
|
||||
"path": path,
|
||||
"folder_path": "responses",
|
||||
"ts": output.get("ts"),
|
||||
"size": 0, # Bytes are already counted by the real Responses card.
|
||||
"result": None,
|
||||
"direct_preview_path": f"{path}?card=responses_html",
|
||||
}
|
||||
return None
|
||||
candidates.append((card_order[result.plugin], str(result.id), result))
|
||||
return min(candidates, default=(None, None, None))[2]
|
||||
|
||||
|
||||
def order_snapshot_outputs(outputs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
@@ -83,14 +123,18 @@ def order_snapshot_outputs(outputs: list[dict[str, Any]]) -> list[dict[str, Any]
|
||||
for plugin in plugins:
|
||||
preferences[plugin] = (group_id, None)
|
||||
|
||||
if card := _responses_html_card(outputs):
|
||||
outputs = [*outputs, card]
|
||||
preferences["responses_html"] = ("html", len(OUTPUT_GROUPS[0][2]))
|
||||
if cards := get_extra_snapshot_output_cards(outputs):
|
||||
outputs = [*outputs, *cards]
|
||||
|
||||
def sort_key(output):
|
||||
plugin = get_plugin_name(output["name"])
|
||||
group_id, rank = preferences.get(plugin, ("other", None))
|
||||
declared_group = output.get("output_group")
|
||||
declared_order = output.get("output_order")
|
||||
if declared_group in group_order and isinstance(declared_order, int) and not isinstance(declared_order, bool):
|
||||
group_id, rank = declared_group, declared_order
|
||||
else:
|
||||
group_id, rank = preferences.get(plugin, ("other", None))
|
||||
output["output_group"] = group_id
|
||||
return (group_order[group_id], rank if rank is not None else -int(output.get("size") or 0), plugin)
|
||||
return (group_order[group_id], rank is None, rank if rank is not None else -int(output.get("size") or 0), plugin)
|
||||
|
||||
return sorted(outputs, key=sort_key)
|
||||
@@ -12,9 +12,62 @@ from django.db.models import Count, Sum
|
||||
_refresh_lock = Lock()
|
||||
|
||||
|
||||
def collection_summary(user):
|
||||
def crawl_summary(crawl_id, *, snapshot_id=None):
|
||||
"""Sample sealed snapshot metadata without adding table scans to polls."""
|
||||
key = f"progress-crawl:{crawl_id}:{snapshot_id or 'all'}"
|
||||
summary = cache.get(key)
|
||||
if (summary is None or time() - summary["sampled_at"] >= 30) and _refresh_lock.acquire(blocking=False):
|
||||
if not cache.add(f"{key}:refresh", True, timeout=30):
|
||||
_refresh_lock.release()
|
||||
return summary
|
||||
|
||||
def refresh():
|
||||
try:
|
||||
close_old_connections()
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
snapshots = Snapshot.objects.filter(crawl_id=crawl_id, status=Snapshot.StatusChoices.SEALED)
|
||||
if snapshot_id is not None:
|
||||
snapshots = snapshots.filter(pk=snapshot_id)
|
||||
snapshots = snapshots.order_by("modified_at").values_list("modified_at", "downloaded_at", "output_size")
|
||||
totals = {"snapshots": 0, "cancelled": 0, "bytes": 0}
|
||||
modified_at, offset = None, 0
|
||||
while True:
|
||||
batch_scope = snapshots.filter(modified_at__gte=modified_at) if modified_at is not None else snapshots
|
||||
# Use (crawl, status, modified_at), releasing the SQLite read
|
||||
# cursor after each small batch. Never hold a cursor over the
|
||||
# whole crawl, even in this background worker.
|
||||
batch = list(batch_scope[offset : offset + 100])
|
||||
for _, downloaded_at, size in batch:
|
||||
totals["snapshots"] += 1
|
||||
totals["cancelled"] += downloaded_at is None
|
||||
totals["bytes"] += size or 0
|
||||
if len(batch) < 100:
|
||||
break
|
||||
last_modified_at = batch[-1][0]
|
||||
# Only offset within ties; otherwise advance the indexed
|
||||
# timestamp range instead of rescanning previous pages.
|
||||
offset = (offset if last_modified_at == modified_at else 0) + sum(row[0] == last_modified_at for row in batch)
|
||||
modified_at = last_modified_at
|
||||
totals["sampled_at"] = time()
|
||||
cache.set(key, totals, timeout=3600)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception("Could not refresh crawl totals")
|
||||
finally:
|
||||
connections.close_all()
|
||||
_refresh_lock.release()
|
||||
|
||||
try:
|
||||
Thread(target=refresh, name="crawl-summary", daemon=True).start()
|
||||
except RuntimeError:
|
||||
_refresh_lock.release()
|
||||
logging.getLogger(__name__).exception("Could not start crawl totals refresh")
|
||||
return summary
|
||||
|
||||
|
||||
def collection_summary(user, *, public=False):
|
||||
user_id = None if user.is_superuser else user.pk
|
||||
key = f"progress-collection:{'all' if user_id is None else user_id}"
|
||||
key = "progress-collection:public" if public else f"progress-collection:{'all' if user_id is None else user_id}"
|
||||
summary = cache.get(key)
|
||||
# Even an indexed SUM scans the index. Never make an HTTP request wait for
|
||||
# that scan on a large collection. Serve stale totals while one worker refreshes;
|
||||
@@ -32,12 +85,23 @@ def collection_summary(user):
|
||||
from archivebox.core.models import Snapshot
|
||||
|
||||
snapshots = Snapshot.objects.all()
|
||||
if user_id is not None:
|
||||
snapshots = snapshots.filter(crawl__created_by_id=user_id)
|
||||
# COUNT(*) + SUM(output_size) can use Snapshot's covering size index.
|
||||
# Snapshot.output_size is maintained when ArchiveResult sizes change.
|
||||
totals = snapshots.aggregate(snapshots=Count("*"), bytes=Sum("output_size"))
|
||||
totals["bytes"] = totals["bytes"] or 0
|
||||
if public:
|
||||
from archivebox.core.permissions import PERMISSIONS_PUBLIC
|
||||
|
||||
# SQLite can choose a table scan for equality after ANALYZE
|
||||
# on an all-public archive. Equal range bounds keep the count
|
||||
# on the existing permissions index. It is still O(n), so
|
||||
# public pages share this background cache, never await it.
|
||||
totals = snapshots.filter(permissions__gte=PERMISSIONS_PUBLIC, permissions__lte=PERMISSIONS_PUBLIC).aggregate(
|
||||
snapshots=Count("permissions"),
|
||||
)
|
||||
else:
|
||||
if user_id is not None:
|
||||
snapshots = snapshots.filter(crawl__created_by_id=user_id)
|
||||
# COUNT(*) + SUM(output_size) can use Snapshot's covering size index.
|
||||
# Snapshot.output_size is maintained when ArchiveResult sizes change.
|
||||
totals = snapshots.aggregate(snapshots=Count("*"), bytes=Sum("output_size"))
|
||||
totals["bytes"] = totals.get("bytes") or 0
|
||||
totals["sampled_at"] = time()
|
||||
cache.set(key, totals, timeout=3600)
|
||||
except Exception:
|
||||
|
||||
@@ -549,6 +549,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 84px;
|
||||
height: 52px;
|
||||
flex: 0 0 84px;
|
||||
@@ -570,6 +571,17 @@
|
||||
object-position: top center;
|
||||
display: block;
|
||||
}
|
||||
#progress-monitor .snapshot-preview img:not([data-loaded]),
|
||||
#progress-monitor .snapshot-favicon:not([data-loaded]) {
|
||||
visibility: hidden;
|
||||
}
|
||||
#progress-monitor .snapshot-preview-loading {
|
||||
position: absolute;
|
||||
font-size: 20px;
|
||||
}
|
||||
#progress-monitor .snapshot-preview img[data-loaded] + .snapshot-preview-loading {
|
||||
display: none;
|
||||
}
|
||||
#progress-monitor .snapshot-preview.placeholder {
|
||||
font-size: 20px;
|
||||
}
|
||||
@@ -580,12 +592,17 @@
|
||||
#progress-monitor .snapshot-title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
position: relative;
|
||||
padding-left: 23px;
|
||||
min-width: 0;
|
||||
}
|
||||
#progress-monitor .snapshot-favicon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-radius: 3px;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
@@ -1061,54 +1078,133 @@
|
||||
}
|
||||
function stableSnapshotMedia(snapshot) {
|
||||
// The progress endpoint can briefly omit media URLs while ArchiveResult
|
||||
// rows settle. Once this page has seen a snapshot image URL, keep using
|
||||
// it so the preview/favicon branch does not flicker back to placeholder.
|
||||
// rows settle. Keep displaying the last *loaded* image while probing
|
||||
// new URLs offscreen: an output URL may 404 until its capture finishes.
|
||||
const snapshotId = String(snapshot.id || '');
|
||||
const media = snapshotMedia.get(snapshotId) || {};
|
||||
if (snapshot.favicon_url) media.favicon_url ||= snapshot.favicon_url;
|
||||
if (snapshot.favicon_url && snapshot.favicon_url !== media.favicon_url) {
|
||||
media.favicon_url = snapshot.favicon_url;
|
||||
media.favicon_next_probe = 0;
|
||||
media.favicon_retry_delay = 5000;
|
||||
}
|
||||
if (snapshot.preview_url) {
|
||||
media.preview_url ||= snapshot.preview_url;
|
||||
media.preview_link ||= snapshot.preview_link;
|
||||
media.preview_fallbacks ||= snapshot.preview_fallbacks || [];
|
||||
if (snapshot.preview_url !== media.preview_url) {
|
||||
media.preview_url = snapshot.preview_url;
|
||||
media.preview_next_probe = 0;
|
||||
media.preview_retry_delay = 5000;
|
||||
}
|
||||
media.preview_link = snapshot.preview_link || media.preview_link;
|
||||
}
|
||||
if (snapshotId) snapshotMedia.set(snapshotId, media);
|
||||
probeSnapshotImage(media, 'favicon');
|
||||
probeSnapshotImage(media, 'preview');
|
||||
return media;
|
||||
}
|
||||
function preserveStableMediaNodes(nextRoot) {
|
||||
// Snapshot preview/favicon files are immutable once visible. Keep the
|
||||
// loaded DOM nodes across poll renders so the browser never reloads
|
||||
// them while progress text/badges continue updating around them.
|
||||
const currentCards = new Map(
|
||||
Array.from(crawlTree.querySelectorAll('.snapshot-item[data-snapshot-id]')).map(card => [card.dataset.snapshotId, card])
|
||||
);
|
||||
nextRoot.querySelectorAll('.snapshot-item[data-snapshot-id]').forEach(nextCard => {
|
||||
const currentCard = currentCards.get(nextCard.dataset.snapshotId);
|
||||
if (!currentCard) return;
|
||||
currentCard.querySelectorAll('[data-stable-media]').forEach(currentNode => {
|
||||
const nextNode = nextCard.querySelector(`[data-stable-media="${currentNode.dataset.stableMedia}"]`);
|
||||
if (nextNode) nextNode.replaceWith(currentNode);
|
||||
});
|
||||
});
|
||||
function probeSnapshotImage(media, kind) {
|
||||
const url = media[`${kind}_url`];
|
||||
if (!url || media[`${kind}_ready_url`] === url || media[`${kind}_probing`] === url || Date.now() < (media[`${kind}_next_probe`] || 0)) return;
|
||||
media[`${kind}_probing`] = url;
|
||||
const probe = new Image();
|
||||
probe.onload = function() {
|
||||
if (media[`${kind}_url`] !== url) return;
|
||||
media[`${kind}_ready_url`] = url;
|
||||
media[`${kind}_ready_src`] = probe.src;
|
||||
media[`${kind}_probing`] = '';
|
||||
media[`${kind}_retry_delay`] = 5000;
|
||||
};
|
||||
probe.onerror = function() {
|
||||
if (media[`${kind}_url`] !== url) return;
|
||||
media[`${kind}_probing`] = '';
|
||||
const delay = media[`${kind}_retry_delay`] || 5000;
|
||||
media[`${kind}_next_probe`] = Date.now() + delay;
|
||||
media[`${kind}_retry_delay`] = Math.min(delay * 2, 30000);
|
||||
};
|
||||
// A previous 404 may be cached. Bust only the background probe URL;
|
||||
// the visible card keeps its loaded image or fixed-size placeholder.
|
||||
const probeUrl = new URL(url, window.location.href);
|
||||
probeUrl.searchParams.set('progress_retry', String(Date.now()));
|
||||
probe.src = probeUrl.href;
|
||||
}
|
||||
function sameProgressNode(current, next) {
|
||||
if (current.nodeType !== next.nodeType) return false;
|
||||
if (current.nodeType !== Node.ELEMENT_NODE) return true;
|
||||
if (current.tagName !== next.tagName) return false;
|
||||
for (const key of ['crawlId', 'snapshotId', 'stableMedia']) {
|
||||
if ((current.dataset[key] || next.dataset[key]) && current.dataset[key] !== next.dataset[key]) return false;
|
||||
}
|
||||
return (current.className || '').split(' ')[0] === (next.className || '').split(' ')[0];
|
||||
}
|
||||
function reconcileProgressNode(current, next) {
|
||||
if (current.nodeType === Node.TEXT_NODE) {
|
||||
if (current.textContent !== next.textContent) current.textContent = next.textContent;
|
||||
return;
|
||||
}
|
||||
if (current.nodeType !== Node.ELEMENT_NODE) return;
|
||||
// Never detach a loaded image on a poll. Its URL only changes when the
|
||||
// progress API reports a new output; keep a successful fallback too.
|
||||
if (current.dataset.stableMedia && current.dataset.mediaUrl === next.dataset.mediaUrl) {
|
||||
if (current.tagName === 'A' && current.getAttribute('href') !== next.getAttribute('href')) {
|
||||
current.setAttribute('href', next.getAttribute('href'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const attr of Array.from(current.attributes)) {
|
||||
if (!next.hasAttribute(attr.name)) current.removeAttribute(attr.name);
|
||||
}
|
||||
for (const attr of Array.from(next.attributes)) {
|
||||
if (current.getAttribute(attr.name) !== attr.value) current.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
reconcileProgressChildren(current, next);
|
||||
}
|
||||
function reconcileProgressChildren(current, next) {
|
||||
let position = 0;
|
||||
const nextNodes = Array.from(next.childNodes);
|
||||
for (const nextChild of nextNodes) {
|
||||
let child = current.childNodes[position];
|
||||
if (!child) {
|
||||
current.appendChild(nextChild);
|
||||
} else if (sameProgressNode(child, nextChild)) {
|
||||
reconcileProgressNode(child, nextChild);
|
||||
} else {
|
||||
const laterNext = nextNodes.slice(position + 1);
|
||||
if (laterNext.some(node => sameProgressNode(child, node))) {
|
||||
// A new optional node (such as a favicon) precedes the old
|
||||
// one. Insert it without detaching the old loaded image.
|
||||
current.insertBefore(nextChild, child);
|
||||
} else if (Array.from(current.childNodes).slice(position + 1).some(node => sameProgressNode(node, nextChild))) {
|
||||
// The old optional node disappeared; remove only that node.
|
||||
child.remove();
|
||||
reconcileProgressNode(current.childNodes[position], nextChild);
|
||||
} else {
|
||||
child.replaceWith(nextChild);
|
||||
}
|
||||
}
|
||||
position += 1;
|
||||
}
|
||||
while (current.childNodes.length > position) current.lastChild.remove();
|
||||
}
|
||||
function replaceCrawlTree(html) {
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = html;
|
||||
preserveStableMediaNodes(template.content);
|
||||
crawlTree.replaceChildren(...template.content.childNodes);
|
||||
reconcileProgressChildren(crawlTree, template.content);
|
||||
}
|
||||
window.nextPreviewFallback = function(img) {
|
||||
const fallbacks = (img.dataset.fallbacks || '').split(',').filter(Boolean);
|
||||
if (fallbacks.length > 0) {
|
||||
img.src = fallbacks.shift();
|
||||
img.dataset.fallbacks = fallbacks.join(',');
|
||||
} else {
|
||||
const preview = img.closest('.snapshot-preview');
|
||||
if (preview) {
|
||||
preview.removeAttribute('data-stable-media');
|
||||
preview.classList.add('placeholder');
|
||||
preview.innerHTML = '<span>▤</span>';
|
||||
window.failedProgressImage = function(img) {
|
||||
const snapshotId = img.closest('.snapshot-item')?.dataset.snapshotId;
|
||||
const media = snapshotMedia.get(snapshotId);
|
||||
if (media) {
|
||||
for (const kind of ['favicon', 'preview']) {
|
||||
if (media[`${kind}_ready_src`] === img.src) {
|
||||
media[`${kind}_ready_url`] = '';
|
||||
media[`${kind}_ready_src`] = '';
|
||||
media[`${kind}_next_probe`] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
const preview = img.closest('.snapshot-preview');
|
||||
if (preview) {
|
||||
preview.classList.add('placeholder');
|
||||
preview.innerHTML = '<span>▤</span>';
|
||||
} else img.remove();
|
||||
};
|
||||
function formatUrl(url) {
|
||||
if (!url) return '(no URL)';
|
||||
@@ -1466,11 +1562,13 @@
|
||||
const titleText = snapshot.title || formatUrl(snapshot.full_url || snapshot.url);
|
||||
const urlText = snapshot.full_url || snapshot.url || '';
|
||||
const media = stableSnapshotMedia(snapshot);
|
||||
const faviconHtml = media.favicon_url
|
||||
? `<img class="snapshot-favicon" data-stable-media="favicon" src="${escapeAttr(media.favicon_url)}" alt="" decoding="async" loading="lazy" onerror="this.remove()">`
|
||||
const faviconHtml = media.favicon_ready_src
|
||||
? `<img class="snapshot-favicon" data-stable-media="favicon" data-media-url="${escapeAttr(media.favicon_ready_url)}" src="${escapeAttr(media.favicon_ready_src)}" alt="" decoding="async" loading="lazy" onload="this.dataset.loaded='1'" onerror="failedProgressImage(this)">`
|
||||
: '';
|
||||
const previewHtml = media.preview_url
|
||||
? `<a class="snapshot-preview" data-stable-media="preview" href="${escapeAttr(media.preview_link || snapshot.view_url || adminUrl)}" title="Open snapshot output"><img src="${escapeAttr(media.preview_url)}" alt="" decoding="async" loading="lazy" data-fallbacks="${escapeAttr((media.preview_fallbacks || []).join(','))}" onerror="nextPreviewFallback(this)"></a>`
|
||||
const previewSrc = media.preview_ready_src || media.favicon_ready_src;
|
||||
const previewMediaUrl = media.preview_ready_src ? media.preview_ready_url : media.favicon_ready_url;
|
||||
const previewHtml = previewSrc
|
||||
? `<a class="snapshot-preview" data-stable-media="preview" data-media-url="${escapeAttr(previewMediaUrl)}" href="${escapeAttr(media.preview_link || snapshot.view_url || adminUrl)}" title="Open snapshot output"><img src="${escapeAttr(previewSrc)}" alt="" decoding="async" loading="lazy" onload="this.dataset.loaded='1'" onerror="failedProgressImage(this)"><span class="snapshot-preview-loading">▤</span></a>`
|
||||
: `<a class="snapshot-preview placeholder" href="${escapeAttr(snapshot.view_url || adminUrl)}" title="Open snapshot"><span>${statusIcon}</span></a>`;
|
||||
|
||||
let extractorHtml = '';
|
||||
@@ -1806,12 +1904,22 @@
|
||||
const progressEndpoint = monitor.dataset.progressEndpoint || '/progress.json';
|
||||
|
||||
function fetchProgress() {
|
||||
// Opening a replay replaces the queued snapshot's progress panel.
|
||||
// Its interval and already-running fetch must stop using that old DOM.
|
||||
if (!monitor.isConnected) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
fetch(progressEndpoint, { credentials: 'same-origin' })
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (!monitor.isConnected) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
if (data.error) {
|
||||
console.error('Progress API error:', data.error, data.traceback);
|
||||
setOrchestratorState('error', 'Backend not responding');
|
||||
@@ -1823,6 +1931,10 @@
|
||||
updateProgress(data);
|
||||
})
|
||||
.catch(error => {
|
||||
if (!monitor.isConnected) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
console.error('Progress fetch error:', error);
|
||||
setOrchestratorState('error', 'Backend not responding');
|
||||
idleMessage.textContent = 'Fetch Error: ' + error.message;
|
||||
@@ -1831,7 +1943,7 @@
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollInterval) return;
|
||||
if (pollInterval || !monitor.isConnected) return;
|
||||
fetchProgress();
|
||||
pollInterval = setInterval(fetchProgress, pollDelayMs);
|
||||
}
|
||||
@@ -1935,7 +2047,13 @@
|
||||
|
||||
// Start polling when page loads
|
||||
startPolling();
|
||||
setInterval(updateDurationBadges, 1000);
|
||||
const durationInterval = setInterval(() => {
|
||||
if (!monitor.isConnected) {
|
||||
clearInterval(durationInterval);
|
||||
return;
|
||||
}
|
||||
updateDurationBadges();
|
||||
}, 1000);
|
||||
|
||||
// Pause polling when tab is hidden
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
|
||||
+100
-161
@@ -7,17 +7,15 @@ from typing import Literal
|
||||
from abx_dl.events import PROCESS_EXIT_SKIPPED
|
||||
from django.conf import settings
|
||||
from django.db import DatabaseError
|
||||
from django.db.models import CharField, Count, Q, Sum
|
||||
from django.db.models import CharField, Count, Q
|
||||
from django.db.models.functions import Cast
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.utils import timezone
|
||||
|
||||
from archivebox.config import CONSTANTS
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.core.permissions import can_view_snapshot, is_admin_user
|
||||
from archivebox.core.routes_util import build_snapshot_url, build_web_url, get_api_base_url
|
||||
from archivebox.core.routes_util import build_snapshot_detail_url, build_snapshot_url, get_api_base_url
|
||||
from archivebox.misc.logging_util import printable_filesize
|
||||
from archivebox.plugins.discovery import discover_plugin_configs
|
||||
from archivebox.plugins.discovery import discover_plugin_configs, get_plugin_default_output_path, get_snapshot_role_result
|
||||
|
||||
|
||||
def progress_endpoint(scope: Literal["crawl", "snapshot"] | None = None, object_id: object | None = None) -> str:
|
||||
@@ -152,68 +150,25 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
|
||||
return hook_details(Path(hook_path).name, plugin=Path(hook_path).parent.name or "setup")
|
||||
|
||||
def archiveresult_output_path(ar) -> str | None:
|
||||
output_file_map = ar.output_files if isinstance(ar.output_files, dict) else {}
|
||||
|
||||
def is_root_relative(path: str) -> bool:
|
||||
metadata = output_file_map.get(path) or {}
|
||||
return bool(isinstance(metadata, dict) and metadata.get("root_relative"))
|
||||
|
||||
if ar.output_str:
|
||||
raw_output = str(ar.output_str).strip()
|
||||
if ar._looks_like_output_path(raw_output, ar.plugin):
|
||||
output_path = Path(raw_output)
|
||||
if output_path.is_absolute():
|
||||
return None
|
||||
|
||||
if raw_output.startswith(f"{ar.plugin}/"):
|
||||
candidates = [raw_output]
|
||||
elif len(output_path.parts) == 1:
|
||||
candidates = [f"{ar.plugin}/{raw_output}", raw_output]
|
||||
else:
|
||||
candidates = [raw_output]
|
||||
|
||||
if raw_output in output_file_map and is_root_relative(raw_output):
|
||||
return raw_output
|
||||
|
||||
for relative_path in candidates:
|
||||
plugin_relative = relative_path.removeprefix(f"{ar.plugin}/")
|
||||
if relative_path in output_file_map:
|
||||
return f"{ar.plugin}/{relative_path}" if not relative_path.startswith(f"{ar.plugin}/") else relative_path
|
||||
if plugin_relative in output_file_map:
|
||||
return f"{ar.plugin}/{plugin_relative}"
|
||||
|
||||
output_file_paths = list(output_file_map.keys())
|
||||
if output_file_paths:
|
||||
fallback_path = ArchiveResult._fallback_output_file_path(output_file_paths, ar.plugin, output_file_map)
|
||||
if fallback_path:
|
||||
if is_root_relative(fallback_path):
|
||||
return fallback_path
|
||||
return f"{ar.plugin}/{fallback_path}"
|
||||
|
||||
return None
|
||||
|
||||
def snapshot_output_url(snapshot, output_path: str) -> str:
|
||||
return build_snapshot_url(str(snapshot["id"]), output_path, request=request, config=request_config)
|
||||
|
||||
def snapshot_archive_path(snapshot) -> str:
|
||||
if snapshot["fs_version"] in ("0.7.0", "0.8.0"):
|
||||
return f"{CONSTANTS.ARCHIVE_DIR_NAME}/{snapshot['timestamp']}"
|
||||
crawl = crawls_by_id.get(str(snapshot["crawl_id"]))
|
||||
username = "web"
|
||||
if crawl is not None and crawl["created_by_id"]:
|
||||
username = crawl["created_by__username"]
|
||||
if username == "system":
|
||||
username = "web"
|
||||
date_base = snapshot["bookmarked_at"] or snapshot["created_at"]
|
||||
date_str = date_base.strftime("%Y%m%d") if date_base else "unknown"
|
||||
domain = Snapshot.extract_domain_from_url(snapshot["url"])
|
||||
return f"{username}/{date_str}/{domain}/{snapshot['id']}"
|
||||
return Snapshot.archive_path_for_values(
|
||||
fs_version=snapshot["fs_version"],
|
||||
timestamp=snapshot["timestamp"],
|
||||
snapshot_id=snapshot["id"],
|
||||
url=snapshot["url"],
|
||||
username=crawl["created_by__username"] if crawl is not None and crawl["created_by_id"] else "web",
|
||||
bookmarked_at=snapshot["bookmarked_at"],
|
||||
created_at=snapshot["created_at"],
|
||||
)
|
||||
|
||||
def snapshot_view_url(snapshot, output_path: str = "") -> str:
|
||||
anchor = f"#{output_path}" if output_path else ""
|
||||
return build_web_url(
|
||||
f"/{snapshot_archive_path(snapshot)}/index.html{anchor}",
|
||||
return build_snapshot_detail_url(
|
||||
snapshot_archive_path(snapshot),
|
||||
output_path=output_path,
|
||||
request=request,
|
||||
config=request_config,
|
||||
)
|
||||
@@ -226,53 +181,37 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
|
||||
running_screencasts = {}
|
||||
|
||||
def screencast_frame_url(crawl_id: str, crawl_dir: Path) -> str:
|
||||
frame_path = crawl_dir / "chrome_screencast" / "latest.jpg"
|
||||
try:
|
||||
frame_stat = frame_path.stat()
|
||||
except OSError:
|
||||
return ""
|
||||
if frame_stat.st_size <= 0:
|
||||
return ""
|
||||
def screencast_frame_url(crawl_id: str) -> str:
|
||||
started_at = running_screencasts.get(crawl_id)
|
||||
if started_at is None or frame_stat.st_mtime < started_at.timestamp():
|
||||
if started_at is None:
|
||||
return ""
|
||||
rel = f"/api/v1/crawls/crawl/{crawl_id}/files/chrome_screencast/latest.jpg?v={frame_stat.st_mtime_ns}"
|
||||
# File existence/freshness belongs to the image request, never to
|
||||
# the one-second JSON poll (archive storage may be remote).
|
||||
rel = f"/api/v1/crawls/crawl/{crawl_id}/files/chrome_screencast/latest.jpg?v={int(now.timestamp())}&after={started_at.timestamp()}"
|
||||
return f"{api_base}{rel}" if api_base else rel
|
||||
|
||||
machine_id = Machine.current().id
|
||||
machine = Machine.current_readonly()
|
||||
machine_id = machine.id if machine is not None else None
|
||||
orchestrator_proc = (
|
||||
Process.objects.filter(
|
||||
machine_id=machine_id,
|
||||
process_type=Process.TypeChoices.ORCHESTRATOR,
|
||||
status=Process.StatusChoices.RUNNING,
|
||||
)
|
||||
.only("id", "pid", "started_at", "machine_id", "process_type", "status")
|
||||
.only("id", "pid", "started_at", "machine_id", "process_type", "status", "env", "cmd")
|
||||
.order_by("-started_at")
|
||||
.first()
|
||||
if machine_id is not None
|
||||
else None
|
||||
)
|
||||
runner_worker = None
|
||||
orchestrator_proc_running = bool(orchestrator_proc and orchestrator_proc.is_running)
|
||||
if not orchestrator_proc_running:
|
||||
try:
|
||||
from archivebox.workers.supervisord_util import get_existing_supervisord_process, get_worker
|
||||
|
||||
supervisor = get_existing_supervisord_process(quiet=True)
|
||||
runner_worker = get_worker(supervisor, "worker_runner") if supervisor else None
|
||||
except (OSError, RuntimeError, TimeoutError):
|
||||
runner_worker = None
|
||||
|
||||
runner_worker_running = bool(runner_worker and runner_worker.get("statename") in ("STARTING", "RUNNING"))
|
||||
runner_worker_pid = runner_worker.get("pid") if runner_worker else None
|
||||
orchestrator_running = orchestrator_proc_running or runner_worker_running
|
||||
orchestrator_pid = orchestrator_proc.pid if orchestrator_proc_running and orchestrator_proc else runner_worker_pid
|
||||
orchestrator_running = orchestrator_proc_running
|
||||
orchestrator_pid = orchestrator_proc.pid if orchestrator_proc_running and orchestrator_proc else None
|
||||
|
||||
# Get model counts by status
|
||||
crawl_status_counts = Crawl.status_counts(
|
||||
crawl_scope,
|
||||
(Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED, Crawl.StatusChoices.PAUSED),
|
||||
(Crawl.StatusChoices.QUEUED, Crawl.StatusChoices.STARTED),
|
||||
)
|
||||
crawls_queued = crawl_status_counts.get(Crawl.StatusChoices.QUEUED, 0)
|
||||
crawls_active = crawl_status_counts.get(Crawl.StatusChoices.STARTED, 0)
|
||||
@@ -286,7 +225,7 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
|
||||
snapshot_status_counts = Snapshot.status_counts(
|
||||
snapshot_scope,
|
||||
Snapshot.OPEN_STATES,
|
||||
Snapshot.RUNNABLE_STATES,
|
||||
)
|
||||
snapshots_queued = snapshot_status_counts.get(Snapshot.StatusChoices.QUEUED, 0)
|
||||
snapshots_active = snapshot_status_counts.get(Snapshot.StatusChoices.STARTED, 0)
|
||||
@@ -296,15 +235,24 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
ArchiveResult.StatusChoices.QUEUED,
|
||||
ArchiveResult.StatusChoices.STARTED,
|
||||
)
|
||||
archiveresult_status_counts = ArchiveResult.status_counts(archiveresult_scope, result_statuses)
|
||||
# Start from open results using the existing status index. Filtering by
|
||||
# plugin first scans historical runs, including all completed results.
|
||||
result_counts = list(
|
||||
archiveresult_scope.filter(status__in=result_statuses).order_by().values("status", "plugin").annotate(count=Count("*")),
|
||||
)
|
||||
archiveresult_status_counts = {
|
||||
status: sum(row["count"] for row in result_counts if row["status"] == status) for status in result_statuses
|
||||
}
|
||||
download_scope = archiveresult_scope.filter(
|
||||
plugin__in=download_plugin_names,
|
||||
snapshot__status__in=Snapshot.RUNNABLE_STATES,
|
||||
snapshot__crawl__status__in=Crawl.RUNNABLE_STATES,
|
||||
)
|
||||
indexing_scope = archiveresult_scope.filter(plugin__in=indexing_plugin_names)
|
||||
download_status_counts = ArchiveResult.status_counts(download_scope, result_statuses)
|
||||
indexing_status_counts = ArchiveResult.status_counts(indexing_scope, result_statuses)
|
||||
indexing_status_counts = {
|
||||
status: sum(row["count"] for row in result_counts if row["status"] == status and row["plugin"] in indexing_plugin_names)
|
||||
for status in result_statuses
|
||||
}
|
||||
archiveresults_queued = archiveresult_status_counts.get(ArchiveResult.StatusChoices.QUEUED, 0)
|
||||
archiveresults_active = archiveresult_status_counts.get(ArchiveResult.StatusChoices.STARTED, 0)
|
||||
|
||||
@@ -340,21 +288,27 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
.values(*active_crawl_fields)
|
||||
.order_by("-modified_at")[:max_active_crawls],
|
||||
)
|
||||
paused_crawls = list(
|
||||
crawl_scope.filter(
|
||||
Q(status=Crawl.StatusChoices.PAUSED, created_at__gte=paused_crawl_cutoff)
|
||||
| Q(
|
||||
status=Crawl.StatusChoices.PAUSED,
|
||||
snapshot_set__status__in=Snapshot.RUNNABLE_STATES,
|
||||
snapshot_set__retry_at__lte=now,
|
||||
)
|
||||
| Q(
|
||||
status=Crawl.StatusChoices.PAUSED,
|
||||
snapshot_set__archiveresult__status=ArchiveResult.StatusChoices.QUEUED,
|
||||
),
|
||||
# Start with runnable children, not every historical snapshot belonging
|
||||
# to a paused crawl. These uncorrelated subqueries run once per poll.
|
||||
due_paused_snapshots = (
|
||||
Snapshot.objects.filter(
|
||||
status__in=Snapshot.RUNNABLE_STATES,
|
||||
retry_at__lte=now,
|
||||
)
|
||||
.order_by()
|
||||
.values("crawl_id")
|
||||
)
|
||||
queued_paused_results = (
|
||||
ArchiveResult.objects.filter(
|
||||
status=ArchiveResult.StatusChoices.QUEUED,
|
||||
)
|
||||
.order_by()
|
||||
.values("snapshot__crawl_id")
|
||||
)
|
||||
paused_crawls = list(
|
||||
crawl_scope.filter(status=Crawl.StatusChoices.PAUSED)
|
||||
.filter(Q(created_at__gte=paused_crawl_cutoff) | Q(id__in=due_paused_snapshots) | Q(id__in=queued_paused_results))
|
||||
.values(*active_crawl_fields)
|
||||
.distinct()
|
||||
.order_by("-modified_at")[:max_active_crawls],
|
||||
)
|
||||
queued_crawls = list(
|
||||
@@ -368,52 +322,29 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
crawl["persona_id"] = str(crawl["persona_id"])
|
||||
persona_details_by_id: dict[str, dict[str, str]] = {}
|
||||
persona_details_by_name: dict[str, dict[str, str]] = {}
|
||||
persona_objects_by_id = {}
|
||||
persona_objects_by_name = {}
|
||||
persona_ids = {crawl["persona_id"] for crawl in active_crawls_list if crawl["persona_id"]}
|
||||
persona_names = {"Default"} if any(not crawl["persona_id"] for crawl in active_crawls_list) else set()
|
||||
if persona_ids or persona_names:
|
||||
from archivebox.personas.models import Persona
|
||||
|
||||
for persona in Persona.objects.filter(Q(id__in=persona_ids) | Q(name__in=persona_names)).only("id", "name", "config"):
|
||||
for persona in Persona.objects.filter(Q(id__in=persona_ids) | Q(name__in=persona_names)).only("id", "name"):
|
||||
persona_details = {
|
||||
"name": persona.name,
|
||||
"admin_url": f"/admin/personas/persona/{persona.pk}/change/",
|
||||
}
|
||||
persona_details_by_id[str(persona.id)] = persona_details
|
||||
persona_details_by_name[persona.name] = persona_details
|
||||
persona_objects_by_id[str(persona.id)] = persona
|
||||
persona_objects_by_name[persona.name] = persona
|
||||
active_crawl_ids = [crawl["id"] for crawl in active_crawls_list]
|
||||
active_crawl_objects = {}
|
||||
if active_crawl_ids:
|
||||
for crawl_obj in Crawl.objects.filter(id__in=active_crawl_ids).select_related("created_by", "persona"):
|
||||
crawl_obj._runtime_config = request_config
|
||||
active_crawl_objects[str(crawl_obj.id)] = crawl_obj
|
||||
snapshot_counts_by_crawl: dict[str, dict[str, int]] = {str(crawl_id): {} for crawl_id in active_crawl_ids}
|
||||
cancelled_snapshot_counts_by_crawl: dict[str, int] = {str(crawl_id): 0 for crawl_id in active_crawl_ids}
|
||||
crawl_output_sizes_by_crawl: dict[str, int] = {str(crawl_id): 0 for crawl_id in active_crawl_ids}
|
||||
queued_snapshot_overflow_by_crawl: dict[str, int] = {str(crawl_id): 0 for crawl_id in active_crawl_ids}
|
||||
active_snapshot_scope = snapshot_scope.filter(crawl_id__in=active_crawl_ids)
|
||||
if active_crawl_ids:
|
||||
for row in active_snapshot_scope.values("crawl_id", "status").annotate(count=Count("id")):
|
||||
snapshot_counts_by_crawl.setdefault(str(row["crawl_id"]), {})[row["status"]] = row["count"]
|
||||
|
||||
for row in (
|
||||
active_snapshot_scope.filter(status=Snapshot.StatusChoices.SEALED, downloaded_at__isnull=True)
|
||||
.values("crawl_id")
|
||||
.annotate(count=Count("id"))
|
||||
):
|
||||
cancelled_snapshot_counts_by_crawl[str(row["crawl_id"])] = row["count"]
|
||||
|
||||
for row in (
|
||||
active_snapshot_scope.filter(
|
||||
status=Snapshot.StatusChoices.SEALED,
|
||||
)
|
||||
.values("crawl_id")
|
||||
.annotate(size=Sum("output_size"))
|
||||
):
|
||||
crawl_output_sizes_by_crawl[str(row["crawl_id"])] = int(row["size"] or 0)
|
||||
# The existing (crawl, status, modified_at) index covers these counts.
|
||||
# Byte totals and cancellation details require table reads, so sample
|
||||
# those separately and share cached values between viewer tabs.
|
||||
for row in active_snapshot_scope.order_by().values("crawl_id", "status").annotate(count=Count("*")):
|
||||
crawl_id = str(row["crawl_id"])
|
||||
snapshot_counts_by_crawl[crawl_id][row["status"]] = row["count"]
|
||||
|
||||
crawl_process_pids: dict[str, int] = {}
|
||||
snapshot_process_pids: dict[str, int] = {}
|
||||
@@ -632,7 +563,14 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
completed_snapshots = crawl_snapshot_counts.get(Snapshot.StatusChoices.SEALED, 0)
|
||||
started_snapshots = crawl_snapshot_counts.get(Snapshot.StatusChoices.STARTED, 0)
|
||||
pending_snapshots = crawl_snapshot_counts.get(Snapshot.StatusChoices.QUEUED, 0)
|
||||
cancelled_snapshots = cancelled_snapshot_counts_by_crawl.get(crawl_id, 0)
|
||||
from archivebox.progressmonitor.collection import crawl_summary
|
||||
|
||||
totals = (
|
||||
crawl_summary(crawl_id, snapshot_id=scoped_snapshot.pk if scoped_snapshot is not None else None)
|
||||
if completed_snapshots
|
||||
else {"snapshots": 0, "cancelled": 0, "bytes": 0}
|
||||
)
|
||||
cancelled_snapshots = totals["cancelled"] if totals is not None else None
|
||||
|
||||
# Count URLs in the crawl (for when snapshots haven't been created yet)
|
||||
urls_count = 0
|
||||
@@ -651,7 +589,7 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
crawl_setup_completed = sum(1 for item in crawl_setup_plugins if item.get("status") == "succeeded")
|
||||
crawl_setup_failed = sum(1 for item in crawl_setup_plugins if item.get("status") == "failed")
|
||||
crawl_setup_pending = sum(1 for item in crawl_setup_plugins if item.get("status") == "queued")
|
||||
crawl_screencast_url = screencast_frame_url(crawl_id, active_crawl_objects[crawl_id].output_dir)
|
||||
crawl_screencast_url = screencast_frame_url(crawl_id)
|
||||
crawl_screencast_link = f"/admin/crawls/crawl/{crawl_id.replace('-', '')}/change/" if crawl_screencast_url else ""
|
||||
|
||||
# Get active snapshots for this crawl (already prefetched)
|
||||
@@ -684,27 +622,28 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
snapshot_screencast_url = ""
|
||||
snapshot_screencast_link = ""
|
||||
snapshot_fallback_urls: list[str] = []
|
||||
result_by_plugin = {result.plugin: result for result in snapshot_results}
|
||||
title_result = result_by_plugin.get("title")
|
||||
if not snapshot_title and title_result is not None and title_result.status == ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
title_result = get_snapshot_role_result(snapshot_results, "title_source")
|
||||
if not snapshot_title and title_result is not None:
|
||||
snapshot_title = Snapshot._normalize_title_candidate(title_result.output_str, snapshot_url=snapshot["url"])
|
||||
favicon_result = result_by_plugin.get("favicon")
|
||||
if favicon_result is not None and favicon_result.status == ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
favicon_path = archiveresult_output_path(favicon_result) or "favicon/favicon.ico"
|
||||
snapshot_favicon_url = snapshot_output_url(snapshot, favicon_path)
|
||||
screenshot_result = result_by_plugin.get("screenshot")
|
||||
if screenshot_result is not None and screenshot_result.status == ArchiveResult.StatusChoices.SUCCEEDED:
|
||||
favicon_result = get_snapshot_role_result(snapshot_results, "list_icon")
|
||||
if favicon_result is not None:
|
||||
favicon_path = favicon_result.embed_path_db() or get_plugin_default_output_path(favicon_result.plugin)
|
||||
if favicon_path:
|
||||
snapshot_favicon_url = snapshot_output_url(snapshot, favicon_path)
|
||||
screenshot_result = get_snapshot_role_result(snapshot_results, "primary_preview")
|
||||
if screenshot_result is not None:
|
||||
snapshot_preview_link = snapshot_view_url(snapshot)
|
||||
screenshot_path = archiveresult_output_path(screenshot_result) or "screenshot/screenshot.png"
|
||||
snapshot_preview_url = snapshot_output_url(snapshot, screenshot_path)
|
||||
snapshot_preview_link = snapshot_view_url(snapshot, screenshot_path)
|
||||
screenshot_path = screenshot_result.embed_path_db() or get_plugin_default_output_path(screenshot_result.plugin)
|
||||
if screenshot_path:
|
||||
snapshot_preview_url = snapshot_output_url(snapshot, screenshot_path)
|
||||
snapshot_preview_link = snapshot_view_url(snapshot, screenshot_path)
|
||||
if snapshot_favicon_url:
|
||||
snapshot_fallback_urls.append(snapshot_favicon_url)
|
||||
elif snapshot_favicon_url:
|
||||
snapshot_preview_url = snapshot_favicon_url
|
||||
|
||||
if snapshot["status"] == Snapshot.StatusChoices.STARTED:
|
||||
snapshot_screencast_url = screencast_frame_url(crawl_id, active_crawl_objects[crawl_id].output_dir)
|
||||
snapshot_screencast_url = screencast_frame_url(crawl_id)
|
||||
snapshot_screencast_link = snapshot_view_url(snapshot) if snapshot_screencast_url else ""
|
||||
|
||||
def plugin_sort_key(ar):
|
||||
@@ -752,7 +691,7 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
"process_id": str(process.id) if process else None,
|
||||
"admin_url": f"/admin/core/archiveresult/{ar.id}/change/",
|
||||
}
|
||||
output_path = archiveresult_output_path(ar)
|
||||
output_path = ar.embed_path_db()
|
||||
if output_path:
|
||||
plugin_payload["output_path"] = output_path
|
||||
plugin_payload["output_url"] = snapshot_view_url(snapshot, output_path)
|
||||
@@ -852,18 +791,18 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
persona_details = persona_details_by_id.get(str(crawl["persona_id"])) if crawl["persona_id"] else None
|
||||
persona_name = persona_details["name"] if persona_details else "Default"
|
||||
persona_details = persona_details or persona_details_by_name.get(persona_name)
|
||||
crawl_output_size = crawl_output_sizes_by_crawl.get(crawl_id, 0)
|
||||
avg_snapshot_size = int(crawl_output_size / completed_snapshots) if completed_snapshots else 0
|
||||
crawl_obj = active_crawl_objects[crawl_id]
|
||||
effective_crawl_config = get_config(crawl=crawl_obj, resolve_plugins=False)
|
||||
max_urls = int(effective_crawl_config.CRAWL_MAX_URLS or 0)
|
||||
crawl_max_size = int(effective_crawl_config.CRAWL_MAX_SIZE or 0)
|
||||
crawl_timeout = int(effective_crawl_config.CRAWL_TIMEOUT or 0)
|
||||
snapshot_max_size = int(effective_crawl_config.SNAPSHOT_MAX_SIZE or 0)
|
||||
crawl_output_size = totals["bytes"] if totals is not None else None
|
||||
avg_snapshot_size = int(totals["bytes"] / max(totals["snapshots"], 1)) if totals is not None else None
|
||||
# These crawl-scope limits are frozen in Crawl.config. Rendering
|
||||
# them must not resolve personas, probe binaries, or reread config.
|
||||
max_urls = int(crawl["config"].get("CRAWL_MAX_URLS", request_config.CRAWL_MAX_URLS) or 0)
|
||||
crawl_max_size = int(crawl["config"].get("CRAWL_MAX_SIZE", request_config.CRAWL_MAX_SIZE) or 0)
|
||||
crawl_timeout = int(crawl["config"].get("CRAWL_TIMEOUT", request_config.CRAWL_TIMEOUT) or 0)
|
||||
snapshot_max_size = int(crawl["config"].get("SNAPSHOT_MAX_SIZE", request_config.SNAPSHOT_MAX_SIZE) or 0)
|
||||
|
||||
# Check if retry_at is in the future (would prevent worker from claiming)
|
||||
retry_at_future = crawl["retry_at"] > now if crawl["retry_at"] else False
|
||||
is_paused = crawl_obj.is_paused
|
||||
is_paused = crawl["status"] == Crawl.StatusChoices.PAUSED
|
||||
seconds_until_retry = (
|
||||
0 if is_paused else int((crawl["retry_at"] - now).total_seconds()) if crawl["retry_at"] and retry_at_future else 0
|
||||
)
|
||||
@@ -903,8 +842,8 @@ def live_progress_view(request, *, authorized_snapshot=None):
|
||||
"max_snapshot_size_display": printable_filesize(snapshot_max_size) if snapshot_max_size else "unlimited",
|
||||
"crawl_output_size": crawl_output_size,
|
||||
"avg_snapshot_size": avg_snapshot_size,
|
||||
"crawl_output_size_display": printable_filesize(crawl_output_size) if crawl_output_size else "0 B",
|
||||
"avg_snapshot_size_display": printable_filesize(avg_snapshot_size) if avg_snapshot_size else "0 B",
|
||||
"crawl_output_size_display": printable_filesize(crawl_output_size or 0) if totals is not None else "…",
|
||||
"avg_snapshot_size_display": printable_filesize(avg_snapshot_size or 0) if totals is not None else "…",
|
||||
"tags": crawl_tags,
|
||||
"urls_count": urls_count,
|
||||
"total_snapshots": total_snapshots,
|
||||
|
||||
@@ -12,8 +12,11 @@ _search_backends_cache: dict | None = None
|
||||
|
||||
def search_backend_command_env(config: dict[str, Any] | None = None, **config_kwargs: Any) -> dict[str, str]:
|
||||
"""Serialize resolved application config for a standalone plugin command."""
|
||||
from abx_dl.models import PluginEnv
|
||||
from abxpkg import BinProvider
|
||||
|
||||
config = config or get_config(**config_kwargs)
|
||||
env = os.environ.copy()
|
||||
env: dict[str, str] = {}
|
||||
for key, value in config.items():
|
||||
key = str(key)
|
||||
if value is None:
|
||||
@@ -24,7 +27,9 @@ def search_backend_command_env(config: dict[str, Any] | None = None, **config_kw
|
||||
env[key] = json.dumps(value)
|
||||
elif isinstance(value, (str, int, float, os.PathLike)):
|
||||
env[key] = str(value)
|
||||
return env
|
||||
# Use the same runtime bootstrap as hooks so the installation's abxpkg
|
||||
# entrypoint remains available even without an activated virtualenv.
|
||||
return BinProvider.build_exec_env(base_env=PluginEnv(**os.environ).to_env(), extra_env=env)
|
||||
|
||||
|
||||
def normalize_search_backend_name(backend_name: str | None) -> str:
|
||||
|
||||
@@ -141,6 +141,23 @@ def _save_archiveresult_event_to_db(
|
||||
if snapshot is None:
|
||||
return
|
||||
|
||||
if event.status == "cancelled":
|
||||
# Stopping a capture tells the user nothing about whether it would have
|
||||
# succeeded. Remove this attempt instead of showing a failure card or
|
||||
# adding it to "retry failed". The next scheduled run creates a new row.
|
||||
results = ArchiveResult.objects.filter(snapshot=snapshot, plugin=event.plugin, hook_name=event.hook_name)
|
||||
if process_started is not None:
|
||||
# A late completion must not erase a newer retry of the same hook.
|
||||
results = results.filter(start_ts=parse_event_datetime(process_started.start_ts))
|
||||
# Ordinary deletion invokes output-directory cleanup. Cancellation is
|
||||
# only withdrawing the DB fact: retain partial files and sibling hooks'
|
||||
# shared output: they may be the only copy we ever capture. A rerun's
|
||||
# hook owns replacing files without deleting the old copy first (see
|
||||
# abx-plugins README, Rules). Retain Process/logs to explain the stop.
|
||||
results._raw_delete(results.db)
|
||||
ArchiveResult.refresh_snapshot_output_sizes([snapshot.id])
|
||||
return
|
||||
|
||||
with _perf_span("archivebox.ArchiveResultService.on_ArchiveResultEvent.plugin_dir"):
|
||||
plugin_dir = (
|
||||
Path(process_started.output_dir)
|
||||
@@ -224,7 +241,15 @@ def _save_archiveresult_event_to_db(
|
||||
|
||||
|
||||
def mark_archiveresult_started(event: ProcessStartedEvent, *, snapshot_id: str, process_id: str) -> None:
|
||||
"""Project a running abx-dl hook after its OS process is persisted."""
|
||||
"""Project a running abx-dl hook after its OS process is persisted.
|
||||
|
||||
This DB state records process execution; the scheduler separately waits for
|
||||
stdout readiness before advancing past a background hook. Neither boundary
|
||||
proves that an archive was produced. Final status comes from the owning
|
||||
hook's ArchiveResultEvent, including corrections after an abnormal exit.
|
||||
Rows are per snapshot/plugin/hook: a recorder's start hook cannot declare
|
||||
that its separate export hook succeeded.
|
||||
"""
|
||||
from archivebox.core.models import ArchiveResult, Snapshot
|
||||
|
||||
started_at = parse_event_datetime(event.start_ts)
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
RESOURCE_RECHECK_SECONDS = 2.0
|
||||
# Scheduling policy, not a minimum-RAM claim: stop increasing extraction
|
||||
@@ -12,6 +17,65 @@ RESOURCE_RECHECK_SECONDS = 2.0
|
||||
# stalls. Require both recent and rolling pressure so transient reclaim does
|
||||
# not indefinitely starve otherwise viable work on small hosts.
|
||||
MEMORY_FULL_STALL_PERCENT = 10.0
|
||||
ZRAM_DEVICE_NAME = re.compile(r"zram\d+")
|
||||
|
||||
|
||||
def zram_swap_identifiers(sys_block: Path = Path("/sys/block")) -> set[str]:
|
||||
"""Return zram block names and major:minor IDs known to sysfs."""
|
||||
identifiers = set()
|
||||
for device in sys_block.glob("zram*"):
|
||||
if ZRAM_DEVICE_NAME.fullmatch(device.name):
|
||||
identifiers.add(device.name)
|
||||
try:
|
||||
identifiers.add((device / "dev").read_text().strip())
|
||||
except OSError:
|
||||
pass
|
||||
return identifiers
|
||||
|
||||
|
||||
def swap_source_identifiers(filename: str) -> set[str]:
|
||||
"""Identify a swap source by its visible name and, for block devices, ID."""
|
||||
identifiers = {Path(filename).name}
|
||||
try:
|
||||
source = os.stat(filename)
|
||||
if stat.S_ISBLK(source.st_mode):
|
||||
identifiers.add(f"{os.major(source.st_rdev)}:{os.minor(source.st_rdev)}")
|
||||
except OSError:
|
||||
pass
|
||||
return identifiers
|
||||
|
||||
|
||||
def disk_backed_swap_free_bytes(swaps: str, zram_identifiers: set[str]) -> int:
|
||||
"""Sum free active swap except zram, whose capacity is already backed by RAM."""
|
||||
lines = swaps.splitlines()
|
||||
free_kib = 0
|
||||
for line in lines[1:]: # /proc/swaps starts with a column header
|
||||
fields = line.split()
|
||||
if len(fields) < 5:
|
||||
continue
|
||||
filename, _, size_kib, used_kib, _ = fields[:5]
|
||||
source_ids = swap_source_identifiers(filename)
|
||||
if source_ids & zram_identifiers or any(ZRAM_DEVICE_NAME.fullmatch(name) for name in source_ids):
|
||||
continue
|
||||
try:
|
||||
free_kib += max(0, int(size_kib) - int(used_kib))
|
||||
except ValueError:
|
||||
continue
|
||||
return free_kib * 1024
|
||||
|
||||
|
||||
def host_swap_free_bytes(meminfo_swap_free: int) -> int:
|
||||
"""Use only independently backed swap as extra capacity on Linux."""
|
||||
if not sys.platform.startswith("linux"):
|
||||
return psutil.swap_memory().free
|
||||
try:
|
||||
swaps = Path("/proc/swaps").read_text()
|
||||
except OSError:
|
||||
# If Linux swap sources cannot be classified, fail closed rather than
|
||||
# treating potentially RAM-backed zram capacity as extra headroom.
|
||||
return 0
|
||||
disk_free = disk_backed_swap_free_bytes(swaps, zram_swap_identifiers())
|
||||
return min(max(0, meminfo_swap_free), disk_free)
|
||||
|
||||
|
||||
class ResourceAdmission:
|
||||
@@ -44,10 +108,73 @@ class ResourceAdmission:
|
||||
cpus = min(cpus, max(1, math.ceil(int(quota) / int(period))))
|
||||
except (OSError, ValueError, ZeroDivisionError):
|
||||
continue
|
||||
# CPU quota limits parallel admission, not forward progress. A host
|
||||
# with a fractional CPU must still be able to run one snapshot.
|
||||
# CPU quota bounds parallel work; measured memory admission is done
|
||||
# only after the first complete snapshot establishes its actual cost.
|
||||
return max(1, min(configured, cpus))
|
||||
|
||||
def additional_snapshot_slots(self, configured: int, active: int, observed_cost: int | None) -> int:
|
||||
if not observed_cost:
|
||||
# Stage one complete real capture before spending memory on parallel
|
||||
# hook trees. A zero-cost/failed probe cannot justify fanout.
|
||||
return 1 if active == 0 and configured > 0 else 0
|
||||
headroom = self.memory_headroom()
|
||||
return self.slots_for_headroom(configured, active, observed_cost, headroom[1] if headroom else 0)
|
||||
|
||||
@staticmethod
|
||||
def slots_for_headroom(configured: int, active: int, observed_cost: int, available: int) -> int:
|
||||
slots = max(0, configured - active)
|
||||
if observed_cost > 0:
|
||||
# Active captures can still grow to the measured peak. Reserve for
|
||||
# that growth before promising the remaining headroom to new work.
|
||||
unpromised = max(0, available - active * observed_cost)
|
||||
slots = min(slots, unpromised // observed_cost)
|
||||
return max(1, slots) if active == 0 and configured else slots
|
||||
|
||||
def memory_headroom(self) -> tuple[int, int] | None:
|
||||
"""Return workload RAM+swap usage and effective available headroom."""
|
||||
try:
|
||||
meminfo = {
|
||||
name: int(value.split()[0]) * 1024
|
||||
for name, value in (line.split(":", 1) for line in Path("/proc/meminfo").read_text().splitlines() if ":" in line)
|
||||
}
|
||||
host_swap_free = host_swap_free_bytes(meminfo.get("SwapFree", 0))
|
||||
host_available = meminfo["MemAvailable"] + host_swap_free
|
||||
except (OSError, ValueError, KeyError):
|
||||
host_swap_free = 0 if sys.platform.startswith("linux") else psutil.swap_memory().free
|
||||
host_available = psutil.virtual_memory().available + host_swap_free
|
||||
|
||||
available = host_available
|
||||
used_bytes = None
|
||||
for path in self.cgroup_paths():
|
||||
try:
|
||||
memory_max = (path / "memory.max").read_text().strip()
|
||||
if memory_max == "max":
|
||||
continue
|
||||
current = int((path / "memory.current").read_text())
|
||||
swap_max = (path / "memory.swap.max").read_text().strip()
|
||||
swap_current = int((path / "memory.swap.current").read_text())
|
||||
swap_room = host_swap_free if swap_max == "max" else min(host_swap_free, max(0, int(swap_max) - swap_current))
|
||||
available = min(available, max(0, int(memory_max) - current) + swap_room)
|
||||
if used_bytes is None:
|
||||
used_bytes = current + swap_current
|
||||
except (OSError, ValueError, KeyError):
|
||||
continue
|
||||
if used_bytes is None:
|
||||
# Bare-metal and non-Linux installs have no finite cgroup. Count
|
||||
# the real runner process tree rather than the entire host.
|
||||
try:
|
||||
process = psutil.Process()
|
||||
processes = [process, *process.children(recursive=True)]
|
||||
except (psutil.Error, OSError):
|
||||
return None
|
||||
used_bytes = 0
|
||||
for child in processes:
|
||||
try:
|
||||
used_bytes += child.memory_info().rss
|
||||
except (psutil.Error, OSError):
|
||||
continue
|
||||
return used_bytes, available
|
||||
|
||||
def observe_memory_stalls(self, source: str, total: int, avg10: float, sampled_at: float) -> bool:
|
||||
previous = self._memory_stalls.get(source)
|
||||
self._memory_stalls[source] = (total, sampled_at)
|
||||
|
||||
+292
-66
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import shutil
|
||||
@@ -12,7 +14,7 @@ from contextlib import nullcontext
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -26,6 +28,7 @@ from abx_dl.events import (
|
||||
CrawlCleanupEvent,
|
||||
CrawlCompletedEvent,
|
||||
CrawlEvent,
|
||||
CrawlPauseEvent,
|
||||
CrawlSetupEvent,
|
||||
CrawlStartEvent,
|
||||
InstallEvent,
|
||||
@@ -36,7 +39,6 @@ from abx_dl.events import (
|
||||
SnapshotEvent,
|
||||
slow_warning_timeout,
|
||||
)
|
||||
from abx_dl.limits import CrawlLimitState
|
||||
from abx_dl.catalog import PluginCatalog
|
||||
from abx_dl.config import GlobalConfig, RuntimeConfig
|
||||
from abx_dl.models import Snapshot as AbxSnapshot
|
||||
@@ -198,7 +200,12 @@ class CrawlRunner:
|
||||
self.crawl = crawl
|
||||
self.bus = create_bus(name=_bus_name("ArchiveBox", str(crawl.id)), total_timeout=3600.0)
|
||||
self.catalog = get_plugin_catalog()
|
||||
HookProcessService(self.bus, emit_jsonl=False, interactive_tty=interactive_interrupts)
|
||||
self.process_service = HookProcessService(
|
||||
self.bus,
|
||||
emit_jsonl=False,
|
||||
interactive_tty=interactive_interrupts,
|
||||
interrupted_hook_prompt=self._prompt_in_foreground if interactive_interrupts else None,
|
||||
)
|
||||
register_sonic_daemon_event_handler(self.bus)
|
||||
PersistedProcessService(self.bus)
|
||||
ArchiveBoxBinaryService(self.bus)
|
||||
@@ -209,6 +216,9 @@ class CrawlRunner:
|
||||
self.process_discovered_snapshots_inline = process_discovered_snapshots_inline
|
||||
self.show_progress = show_progress
|
||||
self.interactive_interrupts = interactive_interrupts
|
||||
self._interrupt_choice: asyncio.Future[Literal["abort", "retry", "skip"]] | None = None
|
||||
self._user_aborted = False
|
||||
self.bus.on(CrawlAbortEvent, self.on_CrawlAbortEvent)
|
||||
self.config_overrides = dict(config_overrides or {})
|
||||
|
||||
self.snapshot_service = SnapshotService(
|
||||
@@ -223,6 +233,10 @@ class CrawlRunner:
|
||||
self.snapshot_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self.snapshot_semaphore = asyncio.Semaphore(1)
|
||||
self.max_concurrent_snapshots = 1
|
||||
self._warmup_snapshot_id: str | None = None
|
||||
self._memory_baseline: int | None = None
|
||||
self._memory_peak_cost = 0
|
||||
self._observed_snapshot_cost: int | None = None
|
||||
self._resource_deferred = False
|
||||
self._initial_snapshot_admitted = False
|
||||
self.persona = None
|
||||
@@ -231,9 +245,11 @@ class CrawlRunner:
|
||||
self.primary_url = ""
|
||||
self.crawl_output_dir = ""
|
||||
self._live_stream = None
|
||||
self._live_ui: LiveBusUI | None = None
|
||||
self.root_crawl_event_id: str | None = None
|
||||
self.root_crawl_start_event_id: str | None = None
|
||||
self._run_task: asyncio.Task[None] | None = None
|
||||
self._run_started = False
|
||||
self._skip_wait_until_idle = False
|
||||
# This is intentionally a synchronous OS-signal side channel, not bus
|
||||
# state. During SIGINT/SIGTERM/SIGHUP, asyncio.run() may already be
|
||||
@@ -242,21 +258,115 @@ class CrawlRunner:
|
||||
self._signal_abort_requested = False
|
||||
self._last_lease_heartbeat_at = 0.0
|
||||
|
||||
def _request_abort_from_signal(self, _sig: signal.Signals) -> None:
|
||||
if os.environ.get("ARCHIVEBOX_RUNNER_DAEMON") == "1":
|
||||
# The daemon runner is owned by supervisord, not by the interactive
|
||||
# CLI foreground flow. A direct signal to this child should be short
|
||||
# and unambiguous: exit non-zero immediately so supervisord restarts
|
||||
# the runner, while the parent server and supervisord stay alive.
|
||||
os._exit(128 + int(_sig))
|
||||
already_requested = self._signal_abort_requested
|
||||
async def on_CrawlAbortEvent(self, event: CrawlAbortEvent) -> None:
|
||||
self._signal_abort_requested = True
|
||||
# An abort can arrive while the prompt transport is being established,
|
||||
# before another SIGINT sees _interrupt_choice. Release that waiter too;
|
||||
# shutdown must never depend on a subsequent keypress or IPC response.
|
||||
if self._interrupt_choice is not None and not self._interrupt_choice.done():
|
||||
self._interrupt_choice.set_result("abort")
|
||||
if event.user_initiated:
|
||||
self._user_aborted = True
|
||||
# Exit 130 alone cannot distinguish a user's abort from a worker
|
||||
# displaced by takeover. Tell the owning add command explicitly
|
||||
# so it exits instead of reclaiming and restarting this crawl.
|
||||
if os.environ.get("SUPERVISOR_PROCESS_NAME"):
|
||||
sys.__stderr__.write("ARCHIVEBOX_CRAWL_ABORTED\n")
|
||||
sys.__stderr__.flush()
|
||||
|
||||
def _interrupt_hook(self) -> None:
|
||||
if self._user_aborted:
|
||||
# An explicit abort is already draining hook groups with normal
|
||||
# grace periods. A further Ctrl+C requests immediate force exit.
|
||||
# This service owns only this runner's hook groups and children;
|
||||
# a server's supervisor or another foreground command is outside
|
||||
# the boundary. Tell a supervising add parent this was user intent
|
||||
# before bypassing the worker's normal finalization path.
|
||||
if os.environ.get("SUPERVISOR_PROCESS_NAME"):
|
||||
os.write(sys.__stderr__.fileno(), b"ARCHIVEBOX_CRAWL_ABORTED\n")
|
||||
os.write(sys.__stderr__.fileno(), b"\n[!] Forcing aborted crawl to exit now.\n")
|
||||
self.process_service.force_kill_owned_processes()
|
||||
os._exit(130)
|
||||
if self._run_task is None:
|
||||
# The crawl is already draining/finalizing; there is no live bus on
|
||||
# which to reopen a prompt. Remember the user's abort and finish the
|
||||
# cleanup that is already in progress. In particular do not hand a
|
||||
# second signal to Python's default handler in this teardown window.
|
||||
self._user_aborted = True
|
||||
if os.environ.get("SUPERVISOR_PROCESS_NAME"):
|
||||
os.write(sys.__stderr__.fileno(), b"ARCHIVEBOX_CRAWL_ABORTED\n")
|
||||
stream = self._live_stream if self._live_stream is not None and not self._live_stream.closed else sys.__stderr__
|
||||
os.write(stream.fileno(), b"\nAborting crawl - finishing shutdown... Ctrl+C again to force exit.\n")
|
||||
return
|
||||
if self._interrupt_choice is not None:
|
||||
self._choose_interrupt_action("abort")
|
||||
return
|
||||
|
||||
async def dispatch() -> None:
|
||||
# ArchiveBox transports terminal intent; abx-dl alone decides pause
|
||||
# versus abort. No local counter may compete with its controller.
|
||||
await self.bus.emit(CrawlPauseEvent()).now()
|
||||
|
||||
asyncio.get_running_loop().create_task(dispatch())
|
||||
|
||||
async def _prompt_in_foreground(self, hook_name: str) -> Literal["abort", "retry", "skip"]:
|
||||
rendered = self._live_ui.show_interrupt_prompt(hook_name) if self._live_ui is not None else False
|
||||
if sys.stdin.isatty():
|
||||
# Direct run is itself the outer terminal owner. Keep its asyncio
|
||||
# loop alive so background completions render above the prompt. No
|
||||
# supervisor parent exists here to consume an IPC prompt request.
|
||||
choice = (
|
||||
await HookProcessService.read_interrupt_choice(
|
||||
hook_name,
|
||||
render=not rendered,
|
||||
is_active=lambda: not self._signal_abort_requested,
|
||||
on_abort=lambda: setattr(self, "_user_aborted", True),
|
||||
)
|
||||
or "abort"
|
||||
)
|
||||
if choice == "abort":
|
||||
# Also cover an inactive prompt that defaulted to abort. A
|
||||
# typed answer is recorded at the keypress by on_abort above.
|
||||
self._user_aborted = True
|
||||
return choice
|
||||
# A supervised worker has log pipes instead of terminal input. Its add
|
||||
# parent owns the same shared reader and returns the answer via signals.
|
||||
self._interrupt_choice = asyncio.get_running_loop().create_future()
|
||||
sys.__stderr__.write("ARCHIVEBOX_HOOK_INTERRUPTED " + json.dumps({"hook_name": hook_name, "rendered": rendered}) + "\n")
|
||||
sys.__stderr__.flush()
|
||||
try:
|
||||
return await self._interrupt_choice
|
||||
finally:
|
||||
self._interrupt_choice = None
|
||||
|
||||
def _choose_interrupt_action(self, choice: Literal["abort", "retry", "skip"]) -> None:
|
||||
if self._interrupt_choice is not None and not self._interrupt_choice.done():
|
||||
if choice == "abort":
|
||||
self._user_aborted = True
|
||||
self._interrupt_choice.set_result(choice)
|
||||
|
||||
def _request_abort_from_signal(self, sig: signal.Signals) -> None:
|
||||
# Daemons also need cooperative cleanup. os._exit used to leave hook
|
||||
# process groups orphaned and Process rows running forever. A daemon's
|
||||
# nonzero exit still lets supervisord restart it, AFTER finally blocks
|
||||
# stop descendants and finalize the ledger. SIGINT uses the same shared
|
||||
# controller as other runners, with interactive_tty=False (whole abort).
|
||||
# Takeover/parent loss stops this execution owner, not the user's crawl
|
||||
# intent. Do not set _user_aborted or emit ARCHIVEBOX_CRAWL_ABORTED here:
|
||||
# the outer add may need to wait for the new owner and reclaim afterward.
|
||||
# Cancellation intentionally bypasses the interactive pause gate; a
|
||||
# waiting prompt must never keep a displaced worker or its hooks alive.
|
||||
self._signal_abort_requested = True
|
||||
self._skip_wait_until_idle = True
|
||||
if not self._run_started and os.environ.get("ARCHIVEBOX_RUNNER_DAEMON") == "1":
|
||||
# A signal before run() starts has no async task or hooks to drain.
|
||||
# It must still be an unexpected supervisor exit, not a clean idle exit.
|
||||
raise SystemExit(128 + sig.value)
|
||||
# The foreground signal handler runs while the event loop may be in the
|
||||
# middle of shutdown. Flip cheap in-memory flags here and let normal
|
||||
# finally blocks do cleanup; only cancel the runner task immediately for
|
||||
# non-interactive commands or for a second interrupt escalation.
|
||||
if (not self.interactive_interrupts or already_requested) and self._run_task is not None and not self._run_task.done():
|
||||
# finally blocks do cleanup. Interactive SIGINT uses _interrupt_hook;
|
||||
# actual shutdown signals must cancel promptly in either mode.
|
||||
if self._run_task is not None and not self._run_task.done():
|
||||
self._run_task.cancel()
|
||||
|
||||
async def crawl_is_cancelled(self) -> bool:
|
||||
@@ -284,7 +394,7 @@ class CrawlRunner:
|
||||
await asyncio.sleep(poll_interval)
|
||||
if not await self.crawl_is_cancelled():
|
||||
continue
|
||||
abort_event = parent_event.emit(CrawlAbortEvent())
|
||||
abort_event = parent_event.emit(CrawlAbortEvent(user_initiated=False))
|
||||
await _run_event_now(abort_event, abort_event.event_timeout)
|
||||
return
|
||||
|
||||
@@ -299,25 +409,36 @@ class CrawlRunner:
|
||||
root_snapshot_id: str | None = None
|
||||
bus_destroyed = False
|
||||
run_state_loaded = False
|
||||
try:
|
||||
first_signal_message = (
|
||||
"\n[🛑] Got {signal_name}, aborting the active hook...\n"
|
||||
if self.interactive_interrupts
|
||||
else "\n[🛑] Got {signal_name}, stopping gracefully...\n"
|
||||
)
|
||||
self._run_task = asyncio.current_task()
|
||||
# Do not raise KeyboardInterrupt directly from an OS signal while
|
||||
# the asyncio loop is active. Python can inject it into whichever
|
||||
# task is currently running, which produces noisy "Task exception
|
||||
# was never retrieved" logs from unrelated abxbus housekeeping
|
||||
# tasks. _request_abort_from_signal() cancels the runner task
|
||||
# cooperatively instead; repeated signals still hard-exit in the
|
||||
# shared foreground signal handler.
|
||||
with foreground_shutdown_signals(
|
||||
first_signal_message=first_signal_message,
|
||||
on_signal=self._request_abort_from_signal,
|
||||
raise_on_first_signal=False,
|
||||
):
|
||||
self._run_task = asyncio.current_task()
|
||||
self._run_started = True
|
||||
# Do not raise KeyboardInterrupt directly from an OS signal while
|
||||
# the asyncio loop is active. Python can inject it into whichever
|
||||
# task is currently running, which produces noisy "Task exception
|
||||
# was never retrieved" logs from unrelated abxbus housekeeping
|
||||
# tasks. _request_abort_from_signal() cancels the runner task
|
||||
# cooperatively instead; repeated signals still hard-exit in the
|
||||
# shared foreground signal handler.
|
||||
# Keep signal ownership through bus destruction AND durable finalization.
|
||||
# Restoring the outer/default handler before those awaits leaves a real
|
||||
# race: a rapid second Ctrl+C can kill Python during teardown, producing
|
||||
# supervisor exit -1 / shell 255 instead of an acknowledged user abort.
|
||||
with foreground_shutdown_signals(
|
||||
first_signal_message="\n[🛑] Got {signal_name}, stopping crawl worker...\n",
|
||||
on_signal=self._request_abort_from_signal,
|
||||
interrupt_handlers={
|
||||
signal.SIGINT: self._interrupt_hook,
|
||||
**(
|
||||
{
|
||||
signal.SIGUSR1: lambda: self._choose_interrupt_action("skip"),
|
||||
signal.SIGUSR2: lambda: self._choose_interrupt_action("retry"),
|
||||
}
|
||||
if self.interactive_interrupts
|
||||
else {}
|
||||
),
|
||||
},
|
||||
raise_on_first_signal=False,
|
||||
):
|
||||
try:
|
||||
if await sync_to_async(defer_crawl_for_resources, thread_sensitive=True)(self.crawl):
|
||||
return
|
||||
# Startup and its first snapshot are one admission. Browser
|
||||
@@ -330,6 +451,7 @@ class CrawlRunner:
|
||||
self.max_concurrent_snapshots = max_concurrent_snapshots
|
||||
self.snapshot_semaphore = asyncio.Semaphore(max_concurrent_snapshots)
|
||||
live_ui = self._create_live_ui()
|
||||
self._live_ui = live_ui
|
||||
with live_ui if live_ui is not None else nullcontext():
|
||||
try:
|
||||
if snapshot_ids:
|
||||
@@ -339,26 +461,30 @@ class CrawlRunner:
|
||||
self._run_task = None
|
||||
await self.stop_snapshot_tasks()
|
||||
try:
|
||||
await self.process_service.stop_background_hooks()
|
||||
await self.bus.wait_until_idle(timeout=1.0 if self._skip_wait_until_idle else 30.0)
|
||||
except TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
await self.bus.destroy(clear=False)
|
||||
bus_destroyed = True
|
||||
finally:
|
||||
if not bus_destroyed:
|
||||
self._run_task = None
|
||||
await self.stop_snapshot_tasks()
|
||||
await self.bus.destroy(clear=False)
|
||||
if self._live_stream is not None:
|
||||
try:
|
||||
self._live_stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._live_stream = None
|
||||
if run_state_loaded:
|
||||
await sync_to_async(project_abxpkg_derived_cache_to_db, thread_sensitive=True)(self.base_config.get("ABXPKG_LIB_DIR"))
|
||||
await sync_to_async(self.finalize_run_state, thread_sensitive=True)()
|
||||
finally:
|
||||
if not bus_destroyed:
|
||||
self._run_task = None
|
||||
await self.stop_snapshot_tasks()
|
||||
await self.process_service.stop_background_hooks()
|
||||
await self.bus.destroy(clear=False)
|
||||
if self._live_stream is not None:
|
||||
try:
|
||||
self._live_stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._live_stream = None
|
||||
if run_state_loaded:
|
||||
await sync_to_async(project_abxpkg_derived_cache_to_db, thread_sensitive=True)(self.base_config.get("ABXPKG_LIB_DIR"))
|
||||
await sync_to_async(self.finalize_run_state, thread_sensitive=True)()
|
||||
if self._user_aborted:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
async def enqueue_snapshot(self, snapshot_id: str, crawl_start_event: CrawlStartEvent | None = None) -> None:
|
||||
if self._resource_deferred:
|
||||
@@ -377,10 +503,15 @@ class CrawlRunner:
|
||||
if task is not None and not task.done():
|
||||
return
|
||||
current_event = crawl_start_event or get_current_event()
|
||||
if not isinstance(current_event, CrawlStartEvent) and in_handler_context():
|
||||
return
|
||||
if self._observed_snapshot_cost is None and self._warmup_snapshot_id is None:
|
||||
observation = resource_admission.memory_headroom()
|
||||
self._memory_baseline = observation[0] if observation is not None else None
|
||||
self._memory_peak_cost = 0
|
||||
self._warmup_snapshot_id = snapshot_id
|
||||
if isinstance(current_event, CrawlStartEvent):
|
||||
task = asyncio.create_task(self.run_snapshot(snapshot_id, current_event), context=_runner_task_context())
|
||||
elif in_handler_context():
|
||||
return
|
||||
else:
|
||||
task = asyncio.create_task(self.run_snapshot(snapshot_id), context=_runner_task_context())
|
||||
self._initial_snapshot_admitted = False
|
||||
@@ -409,6 +540,7 @@ class CrawlRunner:
|
||||
if task.done():
|
||||
if self.snapshot_tasks.get(snapshot_id) is task:
|
||||
self.snapshot_tasks.pop(snapshot_id, None)
|
||||
self._finish_warmup_snapshot(snapshot_id)
|
||||
try:
|
||||
task.result()
|
||||
except asyncio.CancelledError as err:
|
||||
@@ -433,13 +565,16 @@ class CrawlRunner:
|
||||
return
|
||||
continue
|
||||
await self.heartbeat_active_leases()
|
||||
done, _pending = await asyncio.wait(pending_tasks, timeout=10.0, return_when=asyncio.FIRST_COMPLETED)
|
||||
self._observe_snapshot_memory()
|
||||
done, _pending = await asyncio.wait(pending_tasks, timeout=RESOURCE_RECHECK_SECONDS, return_when=asyncio.FIRST_COMPLETED)
|
||||
self._observe_snapshot_memory()
|
||||
if not done:
|
||||
continue
|
||||
for task in done:
|
||||
for snapshot_id, tracked_task in list(self.snapshot_tasks.items()):
|
||||
if tracked_task is task:
|
||||
self.snapshot_tasks.pop(snapshot_id, None)
|
||||
self._finish_warmup_snapshot(snapshot_id)
|
||||
break
|
||||
try:
|
||||
task.result()
|
||||
@@ -457,6 +592,41 @@ class CrawlRunner:
|
||||
if not stop_scheduling and enqueue_projected:
|
||||
await self.enqueue_pending_snapshots_from_projection()
|
||||
|
||||
def _observe_snapshot_memory(self) -> None:
|
||||
if self._memory_baseline is None:
|
||||
return
|
||||
observation = resource_admission.memory_headroom()
|
||||
if observation is not None:
|
||||
used, _available = observation
|
||||
# Only attribute this workload's growth to a capture. Other jobs on
|
||||
# the host reduce available headroom, but are not a per-capture cost
|
||||
# to multiply again for every snapshot we want to admit.
|
||||
self._memory_peak_cost = max(
|
||||
self._memory_peak_cost,
|
||||
used - self._memory_baseline,
|
||||
)
|
||||
if self._observed_snapshot_cost is not None:
|
||||
self._observed_snapshot_cost = max(self._observed_snapshot_cost, self._memory_peak_cost)
|
||||
|
||||
def _finish_warmup_snapshot(self, snapshot_id: str) -> None:
|
||||
if snapshot_id != self._warmup_snapshot_id or self._observed_snapshot_cost is not None:
|
||||
return
|
||||
self._observe_snapshot_memory()
|
||||
if self._memory_peak_cost > 0:
|
||||
self._observed_snapshot_cost = self._memory_peak_cost
|
||||
headroom = resource_admission.memory_headroom()
|
||||
logging.getLogger(__name__).info(
|
||||
"Snapshot admission: concurrency limit=%s, observed memory growth=%s bytes, available=%s bytes",
|
||||
self.max_concurrent_snapshots,
|
||||
self._observed_snapshot_cost,
|
||||
headroom[1] if headroom else None,
|
||||
)
|
||||
else:
|
||||
# No observed cost cannot justify parallel admission. Probe the
|
||||
# next real snapshot while the queue keeps moving one at a time.
|
||||
self._memory_baseline = None
|
||||
self._warmup_snapshot_id = None
|
||||
|
||||
async def heartbeat_active_leases(self) -> None:
|
||||
# These are resumable work-item leases, not orchestrator-election
|
||||
# heartbeats. Each update is a short autocommit statement; network and
|
||||
@@ -524,7 +694,11 @@ class CrawlRunner:
|
||||
self.max_concurrent_snapshots = resource_admission.snapshot_limit(int(config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"]))
|
||||
|
||||
active_snapshot_ids = [snapshot_id for snapshot_id, task in self.snapshot_tasks.items() if not task.done()]
|
||||
available_slots = max(0, self.max_concurrent_snapshots - len(active_snapshot_ids))
|
||||
available_slots = resource_admission.additional_snapshot_slots(
|
||||
self.max_concurrent_snapshots,
|
||||
len(active_snapshot_ids),
|
||||
self._observed_snapshot_cost,
|
||||
)
|
||||
if available_slots <= 0:
|
||||
return
|
||||
pending_snapshot_ids = await sync_to_async(
|
||||
@@ -548,6 +722,9 @@ class CrawlRunner:
|
||||
from archivebox.machine.models import Machine, NetworkInterface, Process
|
||||
|
||||
self.primary_url = self.crawl.get_urls_list()[0] if self.crawl.get_urls_list() else ""
|
||||
# Validate saved binary paths at the execution boundary, including
|
||||
# config edited since this process first registered its Machine.
|
||||
machine = Machine._sanitize_config(Machine.current())
|
||||
current_iface = NetworkInterface.current(refresh=not self.allow_maintenance_on_inactive_crawl)
|
||||
current_process = Process.current()
|
||||
if current_process.iface_id != current_iface.id or current_process.machine_id != current_iface.machine_id:
|
||||
@@ -556,7 +733,7 @@ class CrawlRunner:
|
||||
current_process.save(update_fields=["iface", "machine", "modified_at"])
|
||||
self.persona = self.crawl.resolve_persona()
|
||||
self.base_config = get_config(crawl=self.crawl, overrides=self.config_overrides)
|
||||
self.derived_config = dict(Machine.current().config or {})
|
||||
self.derived_config = dict(machine.config or {})
|
||||
self.crawl_output_dir = str(self.crawl.output_dir)
|
||||
if self.persona:
|
||||
self.base_config.update(
|
||||
@@ -697,10 +874,12 @@ class CrawlRunner:
|
||||
stderr_is_tty = sys.stderr.isatty()
|
||||
interactive_tty = stdout_is_tty or stderr_is_tty
|
||||
stream = sys.stderr if stderr_is_tty or not stdout_is_tty else sys.stdout
|
||||
if interactive_tty and os.path.exists("/dev/tty"):
|
||||
progress_tty = os.environ.get("ARCHIVEBOX_PROGRESS_TTY")
|
||||
if progress_tty or (interactive_tty and os.path.exists("/dev/tty")):
|
||||
try:
|
||||
self._live_stream = open("/dev/tty", "w", buffering=1, encoding=stream.encoding or "utf-8")
|
||||
self._live_stream = open(progress_tty or "/dev/tty", "w", buffering=1, encoding=stream.encoding or "utf-8")
|
||||
stream = self._live_stream
|
||||
interactive_tty = stream.isatty()
|
||||
except OSError:
|
||||
self._live_stream = None
|
||||
try:
|
||||
@@ -874,7 +1053,8 @@ class CrawlRunner:
|
||||
if self.initial_snapshot_ids is not None:
|
||||
remaining_snapshot_ids = iter(snapshot_ids)
|
||||
while True:
|
||||
batch = list(zip(range(self.max_concurrent_snapshots), remaining_snapshot_ids, strict=False))
|
||||
slots = resource_admission.additional_snapshot_slots(self.max_concurrent_snapshots, 0, self._observed_snapshot_cost)
|
||||
batch = list(zip(range(slots), remaining_snapshot_ids, strict=False))
|
||||
if not batch:
|
||||
return
|
||||
for _slot, snapshot_id in batch:
|
||||
@@ -885,7 +1065,8 @@ class CrawlRunner:
|
||||
await self.enqueue_snapshot(snapshot_id)
|
||||
await self.wait_for_snapshot_tasks(enqueue_projected=False)
|
||||
else:
|
||||
for snapshot_id in snapshot_ids[: self.max_concurrent_snapshots]:
|
||||
slots = resource_admission.additional_snapshot_slots(self.max_concurrent_snapshots, 0, self._observed_snapshot_cost)
|
||||
for snapshot_id in snapshot_ids[:slots]:
|
||||
if await self.crawl_is_cancelled():
|
||||
break
|
||||
if await self.crawl_is_paused():
|
||||
@@ -1033,7 +1214,9 @@ class CrawlRunner:
|
||||
snapshot_selected_plugins = (
|
||||
self.requested_plugins or snapshot["retry_plugins"] or snapshot["selected_plugins"] or self.selected_plugins
|
||||
)
|
||||
if snapshot["depth"] > 0 and CrawlLimitState.from_config(snapshot["config"]).get_stop_reason() in (
|
||||
# All remaining URLs share the crawl budget, including explicitly
|
||||
# submitted roots. abx-dl only enforces the current snapshot's size.
|
||||
if await sync_to_async(snapshot["_snapshot"].crawl.limit_stop_reason, thread_sensitive=True)(config=config) in (
|
||||
"crawl_max_size",
|
||||
"crawl_timeout",
|
||||
):
|
||||
@@ -1346,7 +1529,9 @@ def _run_due_crawl_locked(crawl, *, lock_seconds: int, interactive_interrupts: b
|
||||
# avoids hot-looping on the parent while child work is ready without
|
||||
# resurrecting a user cancellation that sealed the crawl after
|
||||
# selection.
|
||||
crawl.safe_update(
|
||||
if crawl.status != crawl.StatusChoices.STARTED:
|
||||
return False
|
||||
return crawl.safe_update(
|
||||
{
|
||||
"status": crawl.StatusChoices.STARTED,
|
||||
"retry_at": now + timedelta(seconds=ACTIVE_STATE_LEASE_SECONDS),
|
||||
@@ -1355,7 +1540,6 @@ def _run_due_crawl_locked(crawl, *, lock_seconds: int, interactive_interrupts: b
|
||||
refresh=False,
|
||||
extra_filter={"status": crawl.StatusChoices.STARTED},
|
||||
)
|
||||
return True
|
||||
if snapshot_count and not due_active_snapshots:
|
||||
if crawl.is_finished():
|
||||
if not crawl.claim_processing_lock(lock_seconds=lock_seconds):
|
||||
@@ -1468,6 +1652,49 @@ def _run_due_snapshot_locked(snapshot, *, lock_seconds: int, interactive_interru
|
||||
return True
|
||||
return run_snapshot_maintenance(str(snapshot.id))
|
||||
|
||||
from archivebox.config.common import get_config
|
||||
from archivebox.plugins.discovery import get_enabled_plugins
|
||||
|
||||
config = get_config(crawl=snapshot.crawl, persona=snapshot.crawl.resolve_persona())
|
||||
configured_plugins = {name.strip().lower() for name in str(config.get("PLUGINS") or "").split(",") if name.strip()}
|
||||
chrome_selected = "chrome" in (configured_plugins or set(get_enabled_plugins(config=config)))
|
||||
if chrome_selected and str(config.get("CHROME_ISOLATION") or "crawl").lower() == "crawl":
|
||||
# The due Snapshot is only the scheduler's admission signal. Running
|
||||
# it as an explicit one-snapshot CrawlRunner tears down CrawlSetup after
|
||||
# every URL, even though crawl isolation owns one browser for the whole
|
||||
# crawl. The crawl lifecycle lock already serializes all of its child
|
||||
# work, so let one runner drain the due siblings and discoveries.
|
||||
if any(process.is_running for process in snapshot.process_set.filter(status="running").iterator()):
|
||||
snapshot.update_and_requeue(retry_at=timezone.now() + timedelta(seconds=lock_seconds))
|
||||
return True
|
||||
if snapshot.fs_migration_needed:
|
||||
# Crawl isolation bypasses the per-Snapshot path below, so migrate
|
||||
# this admitted Snapshot before the CrawlRunner derives its output
|
||||
# directory. Claim the Snapshot as well as holding the lifecycle
|
||||
# lock: maintenance-only workers use the Snapshot lease directly.
|
||||
if not snapshot.claim_processing_lock(lock_seconds=lock_seconds):
|
||||
return False
|
||||
snapshot.refresh_from_db()
|
||||
claimed_status = snapshot.status
|
||||
claimed_retry_at = snapshot.retry_at
|
||||
if snapshot.fs_migration_needed:
|
||||
snapshot.migrate_filesystem_to_current_version()
|
||||
if not snapshot.safe_update(
|
||||
{"retry_at": timezone.now()},
|
||||
refresh=True,
|
||||
extra_filter={"status": claimed_status, "retry_at": claimed_retry_at},
|
||||
):
|
||||
return False
|
||||
if not snapshot.crawl.claim_processing_lock(lock_seconds=lock_seconds):
|
||||
return False
|
||||
_runner_console_line(crawl=snapshot.crawl)
|
||||
_run_crawl_locked(
|
||||
str(snapshot.crawl_id),
|
||||
process_discovered_snapshots_inline=True,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
)
|
||||
return True
|
||||
|
||||
if not snapshot.claim_processing_lock(lock_seconds=lock_seconds):
|
||||
return False
|
||||
snapshot.refresh_from_db()
|
||||
@@ -1670,12 +1897,11 @@ def _run_due_crawl_status(
|
||||
due_crawl = Crawl.objects.filter(id=due_crawl_id).first()
|
||||
if due_crawl is None:
|
||||
return True
|
||||
run_due_crawl(
|
||||
return run_due_crawl(
|
||||
due_crawl,
|
||||
lock_seconds=lock_seconds,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _run_due_snapshot_query(
|
||||
@@ -1704,13 +1930,14 @@ def _run_due_snapshot_id(snapshot_id, *, lock_seconds: int, interactive_interrup
|
||||
due_snapshot = Snapshot.objects.filter(id=due_snapshot_id).first()
|
||||
if due_snapshot is None:
|
||||
return True
|
||||
run_due_snapshot(
|
||||
# A lost/busy claim is not progress. Let the scheduler try other work and
|
||||
# reach its idle wait instead of spinning on the same due child forever.
|
||||
return run_due_snapshot(
|
||||
due_snapshot,
|
||||
lock_seconds=lock_seconds,
|
||||
interactive_interrupts=interactive_interrupts,
|
||||
runtime_config=runtime_config,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _run_due_binary() -> bool:
|
||||
@@ -1728,8 +1955,7 @@ def _run_due_binary() -> bool:
|
||||
due_binary = Binary.objects.filter(id=due_binary_id).first()
|
||||
if due_binary is None:
|
||||
return True
|
||||
run_due_binary(due_binary, lock_seconds=60)
|
||||
return True
|
||||
return run_due_binary(due_binary, lock_seconds=60)
|
||||
|
||||
|
||||
def run_pending_crawls(
|
||||
|
||||
@@ -6,7 +6,6 @@ from pathlib import Path
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.utils import timezone
|
||||
from abx_dl.events import SnapshotCompletedEvent, SnapshotEvent
|
||||
from abx_dl.limits import CrawlLimitState
|
||||
from abx_dl.services.base import BaseService
|
||||
|
||||
|
||||
@@ -34,7 +33,7 @@ def project_discovered_snapshots(snapshot_id: str) -> list:
|
||||
crawl_output_dir=crawl.output_dir,
|
||||
snapshot_output_dir=snapshot.output_dir,
|
||||
)
|
||||
if CrawlLimitState.from_config(config).get_stop_reason() in ("crawl_max_size", "crawl_timeout"):
|
||||
if crawl.limit_stop_reason(config=config) in ("crawl_max_size", "crawl_timeout"):
|
||||
return []
|
||||
return crawl.create_discovered_snapshots(snapshot, discovered_urls, depth=snapshot.depth + 1)
|
||||
|
||||
@@ -114,12 +113,7 @@ def finalize_completed_snapshot(
|
||||
def _crawl_limit_stop_reason(crawl) -> str:
|
||||
from archivebox.config.common import get_config
|
||||
|
||||
config_model = get_config(crawl=crawl)
|
||||
config = config_model.for_crawl_runtime(
|
||||
crawl=crawl,
|
||||
persona=crawl.resolve_persona(),
|
||||
)
|
||||
return CrawlLimitState.from_config(config).get_stop_reason()
|
||||
return crawl.limit_stop_reason(config=get_config(crawl=crawl))
|
||||
|
||||
|
||||
class SnapshotService(BaseService):
|
||||
|
||||
@@ -1036,7 +1036,6 @@
|
||||
}
|
||||
|
||||
#result_list tbody td {
|
||||
padding: 12px 16px;
|
||||
color: #334155;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -1246,8 +1245,8 @@
|
||||
body.change-list #changelist {
|
||||
border: 0 !important;
|
||||
}
|
||||
body.change-list #changelist .actions .button,
|
||||
body.change-list #changelist .actions select {
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist .actions .button,
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist .actions select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
@@ -1340,8 +1339,8 @@
|
||||
}
|
||||
|
||||
/* The native select receives the actual click, including in WKWebView. */
|
||||
body.change-list #changelist .actions .action-rearchive-select,
|
||||
body.change-list #changelist .actions .action-permissions-select {
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist .actions .action-rearchive-select,
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist .actions .action-permissions-select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
@@ -1352,9 +1351,9 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body.change-list #changelist .action-rearchive-wrapper > .button,
|
||||
body.change-list #changelist .action-permissions-wrapper > .button {
|
||||
padding: 8px 16px !important;
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist .action-rearchive-wrapper > .button,
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist .action-permissions-wrapper > .button {
|
||||
padding: 8px 18px !important;
|
||||
width: max-content;
|
||||
min-width: max-content;
|
||||
flex: 0 0 auto;
|
||||
@@ -1528,6 +1527,9 @@
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
background: transparent;
|
||||
@@ -1541,15 +1543,27 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tag-editor-inline .tag-pill {
|
||||
min-width: 0;
|
||||
max-width: min(160px, 100%);
|
||||
box-sizing: border-box;
|
||||
padding: 2px 6px 2px 8px;
|
||||
font-size: 11px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.tag-editor-inline .tag-link {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tag-editor-inline .tag-remove-btn {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 12px;
|
||||
@@ -2548,6 +2562,7 @@
|
||||
}
|
||||
</script>
|
||||
<script src="{% static 'admin-inline-tags.js' %}"></script>
|
||||
<script src="{% static 'files-icon-piles.js' %}"></script>
|
||||
{% include "includes/output_delete_controls.html" %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -46,7 +46,7 @@
|
||||
<script src="{% static 'admin/js/filters.js' %}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% if embedded_changelist %} embedded-change-list{% endif %}{% endblock %}
|
||||
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list filters-collapsed{% if embedded_changelist %} embedded-change-list{% endif %}{% endblock %}
|
||||
|
||||
{% if not is_popup %}
|
||||
{% block breadcrumbs %}
|
||||
|
||||
@@ -69,10 +69,7 @@
|
||||
if (!toggle) return;
|
||||
|
||||
function applyState() {
|
||||
var storedState = localStorage.getItem(storageKey);
|
||||
var collapsed = storedState === null
|
||||
? window.matchMedia('(max-width: 1180px)').matches
|
||||
: storedState === 'true';
|
||||
var collapsed = localStorage.getItem(storageKey) !== 'false';
|
||||
document.body.classList.toggle('filters-collapsed', collapsed);
|
||||
toggle.textContent = collapsed ? toggle.dataset.showLabel : toggle.dataset.hideLabel;
|
||||
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{{ media.js }}
|
||||
{% endblock %}
|
||||
|
||||
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% endblock %}
|
||||
{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list filters-collapsed{% endblock %}
|
||||
|
||||
{% if not is_popup %}
|
||||
{% block breadcrumbs %}
|
||||
@@ -113,7 +113,7 @@
|
||||
if (!toggle) return;
|
||||
|
||||
function applyState() {
|
||||
var collapsed = localStorage.getItem(storageKey) === 'true';
|
||||
var collapsed = localStorage.getItem(storageKey) !== 'false';
|
||||
document.body.classList.toggle('filters-collapsed', collapsed);
|
||||
toggle.textContent = collapsed ? toggle.dataset.showLabel : toggle.dataset.hideLabel;
|
||||
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
|
||||
@@ -231,6 +231,7 @@
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
{% if add %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const modeInputs = Array.from(document.querySelectorAll('input[name="import_mode"]'));
|
||||
@@ -256,9 +257,11 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block form_top %}
|
||||
{% if add %}
|
||||
<section class="persona-import-hero">
|
||||
<div>
|
||||
<h2>Create a new Persona (Authentication + Configuration Profile)</h2>
|
||||
@@ -274,6 +277,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -74,8 +74,8 @@
|
||||
type="button"
|
||||
class="button filter-pane-toggle"
|
||||
id="changelist-toolbar-filter-toggle"
|
||||
aria-expanded="true"
|
||||
>{% translate "Filters" %} ◂</button>
|
||||
aria-expanded="false"
|
||||
>{% translate "Filters" %} ▸</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,13 @@
|
||||
}
|
||||
.cards .card .card-tags span {
|
||||
display: inline-block;
|
||||
max-width: min(160px, 100%);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
pointer-events: auto;
|
||||
padding: 2px 5px;
|
||||
border-radius: 5px;
|
||||
opacity: 0.95;
|
||||
@@ -108,21 +115,21 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
.cards .card .card-title {
|
||||
padding: 4px 5px;
|
||||
padding: 4px 5px 1px;
|
||||
background-color: #fffcfc;
|
||||
/*height: 50px;
|
||||
vertical-align: middle;
|
||||
line-height: 50px;*/
|
||||
}
|
||||
.cards .card .card-title h4 {
|
||||
color: initial;
|
||||
color: #1e293b;
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
line-height: normal;
|
||||
margin: 0px;
|
||||
padding: 5px 0px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -131,6 +138,20 @@
|
||||
.cards .card .card-title h4 .title-text {
|
||||
user-select: all;
|
||||
}
|
||||
.cards .card .card-title a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.cards .card .card-title a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cards .card .card-url .snapshot-url-domain {
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
}
|
||||
.cards .card .card-url :is(.snapshot-url-prefix, .snapshot-url-suffix) {
|
||||
color: #b0b7c0;
|
||||
font-weight: 400;
|
||||
}
|
||||
.cards .card .card-title .link-favicon {
|
||||
height: 15px;
|
||||
margin: 2px;
|
||||
@@ -156,8 +177,10 @@
|
||||
justify-self: start;
|
||||
}
|
||||
.cards .card .card-meta .card-date {
|
||||
display: block;
|
||||
justify-self: center;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.cards .card .card-meta .card-size {
|
||||
justify-self: end;
|
||||
@@ -187,8 +210,11 @@
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0 8px 6px;
|
||||
font-size: 10px;
|
||||
opacity: 0.4;
|
||||
font-family: "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.25;
|
||||
color: #84909f;
|
||||
user-select: all;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -240,51 +266,40 @@
|
||||
<div class="card-info card-meta">
|
||||
<input type="checkbox" class="action-select" name="_selected_action" value="{{obj.pk}}"/>
|
||||
<a href="{% url 'admin:core_snapshot_change' obj.pk %}" class="card-date">
|
||||
<span class="timestamp">{{obj.bookmarked_at}}</span>
|
||||
<span class="timestamp">{{ obj.bookmarked_at|date:"Y-m-d" }}</span>
|
||||
<span class="timestamp" style="white-space:nowrap">{{ obj.bookmarked_at|date:"g:i A" }}</span>
|
||||
</a>
|
||||
<span class="card-size">{{ obj.archive_size | file_size }}</span>
|
||||
{% with size=obj.archive_size|grid_file_size %}
|
||||
{% if size %}<span class="card-size">{{ size }}</span>{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="card-title" title="{{obj.title}}">
|
||||
<a href="{% snapshot_base_url obj %}/index.html">
|
||||
<a href="{% snapshot_detail_url obj %}">
|
||||
<h4>
|
||||
{% if obj.is_archived %}
|
||||
<img src="{% snapshot_base_url obj %}/favicon.ico" onerror="this.style.display='none'" class="link-favicon" decoding="async"/>
|
||||
{% snapshot_role_output_url obj 'list_icon' True as favicon_url %}
|
||||
{% if favicon_url %}<img src="{{ favicon_url }}" onerror="this.style.display='none'" class="link-favicon" decoding="async"/>{% endif %}
|
||||
{% else %}
|
||||
<img src="{% static 'spinner.gif' %}" onerror="this.style.display='none'" class="link-favicon" decoding="async"/>
|
||||
{% endif %}
|
||||
<span class="title-text">{{obj.title|default:'Pending...' }}</span>
|
||||
<span class="title-text">{{ obj.title|default:obj.domain }}</span>
|
||||
</h4>
|
||||
</a>
|
||||
</div>
|
||||
<code class="card-url" title="{{obj.url}}">{{obj.url}}</code>
|
||||
<code class="card-url" title="{{obj.url}}">{{obj.url|snapshot_url_text}}</code>
|
||||
<div class="card-media">
|
||||
<a href="{% snapshot_base_url obj %}/index.html" class="card-thumbnail">
|
||||
{% admin_snapshot_archiveresult_url obj 'screenshot' 'screenshot.png' as screenshot_url %}
|
||||
{% admin_snapshot_archiveresult_url obj 'chrome_extension_screenshot' 'screenshot-1.png' as extension_screenshot_1_url %}
|
||||
{% admin_snapshot_archiveresult_url obj 'chrome_extension_screenshot' 'screenshot.png' as extension_screenshot_url %}
|
||||
{% admin_snapshot_archiveresult_url obj 'favicon' 'favicon.ico' as favicon_url %}
|
||||
{% if screenshot_url or extension_screenshot_1_url or extension_screenshot_url or favicon_url %}
|
||||
<img
|
||||
src="{% firstof screenshot_url extension_screenshot_1_url extension_screenshot_url favicon_url %}"
|
||||
alt="{{obj.title|default:'Not yet archived...'}}"
|
||||
data-fallbacks="{{ extension_screenshot_1_url }},{{ extension_screenshot_url }},{{ favicon_url }}"
|
||||
onerror="const current=this.src; const fallbacks=(this.dataset.fallbacks || '').split(',').filter(Boolean).map(candidate => new URL(candidate, document.baseURI).href).filter((candidate, index, all) => candidate !== current && all.indexOf(candidate) === index); if (fallbacks.length) { this.dataset.fallbacks=fallbacks.slice(1).join(','); this.src=fallbacks[0]; } else { this.onerror=null; this.closest('.card-thumbnail').classList.add('missing'); this.remove(); }"
|
||||
/>
|
||||
{% elif obj.status == 'started' %}
|
||||
<img src="{% static 'spinner.gif' %}" class="loading-preview" alt="Archiving in progress"/>
|
||||
{% else %}
|
||||
<span class="missing-preview">No preview captured</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a href="{% snapshot_detail_url obj %}" class="card-thumbnail">
|
||||
{% snapshot_thumbnail obj admin=True width="100%" height="245px" %}
|
||||
{% if obj.tags_str %}
|
||||
<div class="card-tags">
|
||||
{% for tag in obj.tags_str|split:',' %}
|
||||
<span>
|
||||
<span title="{{tag}}">
|
||||
{{tag}}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-outputs">
|
||||
{% if obj.status == 'started' %}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{% static 'admin/css/responsive.css' %}" />
|
||||
<link rel="stylesheet" href="{% static 'add.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
}
|
||||
|
||||
#table-bookmarks tbody td {
|
||||
padding: 8px 10px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -198,21 +198,29 @@
|
||||
}
|
||||
|
||||
.snapshot-preview-cell {
|
||||
width: 116px;
|
||||
position: relative;
|
||||
width: 110px;
|
||||
min-width: 116px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.snapshot-preview-cell a {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 102px;
|
||||
height: 102px;
|
||||
line-height: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.snapshot-preview-cell .snapshot-thumbnail {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
#content .public-snapshot-list .snapshot-preview-cell .snapshot-preview,
|
||||
#content .public-snapshot-list .snapshot-preview-cell img.snapshot-preview.screenshot {
|
||||
box-sizing: border-box;
|
||||
@@ -256,34 +264,29 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.snapshot-favicon-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
#content .snapshot-favicon-link .link-favicon {
|
||||
#content .snapshot-url .link-favicon {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
max-width: 18px;
|
||||
border-radius: 4px;
|
||||
object-fit: contain;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
max-width: 1em;
|
||||
flex: 0 0 1em;
|
||||
border-radius: 50%;
|
||||
object-fit: contain; opacity: 0.75; font-size: inherit;
|
||||
}
|
||||
|
||||
#content .snapshot-url .link-favicon-empty { display: none; }
|
||||
.snapshot-url-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.snapshot-title {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -291,12 +294,17 @@
|
||||
}
|
||||
|
||||
.snapshot-url {
|
||||
display: block;
|
||||
margin-left: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-left: 0;
|
||||
overflow: hidden;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: #84909f;
|
||||
font-family: "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.25;
|
||||
user-select: all;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
@@ -307,6 +315,16 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.snapshot-url .snapshot-url-domain {
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.snapshot-url :is(.snapshot-url-prefix, .snapshot-url-suffix) {
|
||||
color: #b0b7c0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.snapshot-tags-cell {
|
||||
width: 190px;
|
||||
}
|
||||
@@ -320,9 +338,10 @@
|
||||
}
|
||||
|
||||
.snapshot-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 160px;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: min(160px, 100%);
|
||||
box-sizing: border-box;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 999px;
|
||||
@@ -378,14 +397,87 @@
|
||||
}
|
||||
|
||||
.snapshot-files-cell {
|
||||
width: 220px;
|
||||
overflow: hidden;
|
||||
width: 132px;
|
||||
overflow: visible;
|
||||
}
|
||||
.snapshot-files-cell:has(.files-icon-pile:hover), .snapshot-files-cell:has(.files-icon-pile:focus-within) { position: relative; z-index: 10000; }
|
||||
|
||||
.snapshot-files-cell .files-icons {
|
||||
max-width: 200px;
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(5, 22px);
|
||||
grid-template-rows: 18px;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 22px;
|
||||
gap: 0;
|
||||
max-width: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.snapshot-files-cell .files-icon-pile { position: relative; display: inline-block; width: 16px; height: 18px; isolation: isolate; vertical-align: top; }
|
||||
.snapshot-files-cell .files-icon-pile::before, .snapshot-files-cell .files-icon-pile::after { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border: 1px solid #d9cdd3; border-radius: 3px; background: #f7f2f4; content: ""; pointer-events: none; }
|
||||
.snapshot-files-cell .files-icon-pile::before { transform: translate(2px,-2px) rotate(7deg); z-index: -2; }
|
||||
.snapshot-files-cell .files-icon-pile::after { transform: translate(1px,-1px) rotate(3deg); z-index: -1; background: #ece5e8; }
|
||||
.snapshot-files-cell .files-icon-pile-backplate { position: absolute; top: 20px; left: var(--files-icon-popup-left,0px); z-index: 0; display: none; width: var(--files-icon-card-width,116px); height: calc(var(--files-icon-rows,1) * 18px + 10px); border: 1px solid #d9cdd3; border-radius: 6px; background: #fff; box-shadow: 0 4px 12px #35202d26; pointer-events: auto; }
|
||||
.snapshot-files-cell .files-icon-pile-popup { position: absolute; top: 20px; left: var(--files-icon-popup-left,0px); z-index: 0; box-sizing: border-box; display: block; width: var(--files-icon-card-width,116px); min-height: calc(var(--files-icon-rows,1) * 18px + 10px); margin: 0; padding: 8px 6px 4px; overflow: visible; border: 1px solid #d9cdd3; border-radius: 6px; background: #fff; box-shadow: 0 4px 12px #35202d26; opacity: 1; }
|
||||
.snapshot-files-cell .files-icon-pile-popup > a, .snapshot-files-cell .files-icon-pile-popup > span { position: absolute; top: var(--files-icon-member-top,8px); left: var(--files-icon-member-left,6px); }
|
||||
.snapshot-files-cell .files-icon-pile-popup::before { position: absolute; top: -5px; left: 0; width: 100%; height: 5px; clip-path: polygon(var(--files-icon-bridge-start,0px) 0,var(--files-icon-bridge-end,16px) 0,100% 100%,0 100%); content: ""; pointer-events: auto; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > .files-icon-pile-backplate, .snapshot-files-cell .files-icon-pile:focus-within > .files-icon-pile-backplate { display: block; }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-hover-bridge { position: absolute; top: 17px; left: var(--files-icon-popup-left,0px); z-index: 1; display: none; width: var(--files-icon-card-width,116px); height: 5px; clip-path: polygon(var(--files-icon-bridge-start,0px) 0,var(--files-icon-bridge-end,16px) 0,100% 100%,0 100%); pointer-events: auto; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > .files-icon-pile-hover-bridge, .snapshot-files-cell .files-icon-pile:focus-within > .files-icon-pile-hover-bridge { display: block; }
|
||||
.snapshot-files-cell .files-icon-pile > a:not(.files-icon-pile-popup), .snapshot-files-cell .files-icon-pile > span:not(.files-icon-pile-popup) { position: absolute; top: 1px; left: min(calc(var(--files-icon-index,0) * 1px),2px); z-index: var(--files-icon-index,0); }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-top { left: var(--files-icon-front-offset,0px); z-index: calc(100 + var(--files-icon-front-index,0)); }
|
||||
.snapshot-files-cell .files-icon-pile > a:hover, .snapshot-files-cell .files-icon-pile > a:focus-visible { z-index: 10000 !important; }
|
||||
.snapshot-files-cell .files-icon-pile:hover, .snapshot-files-cell .files-icon-pile:focus-within { z-index: 9999; overflow: visible; }
|
||||
.snapshot-files-cell .files-icon-pile > :not(.files-icon-pile-cover):not(.files-icon-pile-backplate):not(.files-icon-pile-hover-bridge):not(.files-icon-pile-popup) { visibility: hidden; transition: visibility 0s linear 150ms; }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-cover { z-index: 200; }
|
||||
.snapshot-files-cell .files-icon-pile a[data-tooltip]::after, .snapshot-files-cell .files-icon-pile span[data-tooltip]::after { position: absolute; bottom: calc(100% + 7px); left: 0; z-index: 10001; padding: 5px 8px; border: 1px solid #111827; border-radius: 6px; background: #1f2937; box-shadow: 0 4px 12px #0f172a33; color: #fff; content: attr(data-tooltip); font-size: 11px; font-weight: 600; line-height: 1.2; opacity: 0; pointer-events: none; transition: opacity .08s ease,visibility 0s linear .08s; visibility: hidden; white-space: nowrap; }
|
||||
.snapshot-files-cell .files-icon-pile a[data-tooltip]:hover::after, .snapshot-files-cell .files-icon-pile a[data-tooltip]:focus-visible::after, .snapshot-files-cell .files-icon-pile span[data-tooltip]:hover::after { opacity: 1; transition-delay: .08s,0s; visibility: visible; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > :not(.files-icon-pile-cover):not(.files-icon-pile-backplate):not(.files-icon-pile-hover-bridge):not(.files-icon-pile-popup), .snapshot-files-cell .files-icon-pile:focus-within > :not(.files-icon-pile-cover):not(.files-icon-pile-backplate):not(.files-icon-pile-hover-bridge):not(.files-icon-pile-popup) { top: calc(24px + var(--files-icon-row,0) * 18px); left: var(--files-icon-member-left,3px); visibility: visible; transition-delay: 0s; }
|
||||
.snapshot-files-cell .files-icons:has(.files-icon-pile:hover) .files-icon-pile:not(:hover), .snapshot-files-cell .files-icons:has(.files-icon-pile:focus-within) .files-icon-pile:not(:focus-within) { opacity: .2; }
|
||||
.snapshot-files-cell .files-icon-pile > a.files-icon-pile-cover:hover::before, .snapshot-files-cell .files-icon-pile > a.files-icon-pile-cover:focus-visible::before { position: absolute; top: 17px; left: 50%; z-index: 210; width: 7px; height: 7px; transform: translateX(-50%) rotate(45deg); border-top: 1px solid #d9cdd3; border-left: 1px solid #d9cdd3; background: #fff; content: ""; }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-popup { top: 20px; left: var(--files-icon-popup-left,0px); visibility: hidden; opacity: 0; pointer-events: none; transition: opacity .12s ease,visibility 0s linear 350ms; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > .files-icon-pile-popup, .snapshot-files-cell .files-icon-pile:focus-within > .files-icon-pile-popup, .snapshot-files-cell .files-icon-pile > .files-icon-pile-popup:popover-open { top: 20px; left: var(--files-icon-popup-left,0px); visibility: visible; opacity: 1; pointer-events: auto; transition-delay: 0s; }
|
||||
|
||||
.snapshot-files-cell .files-icons a {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.snapshot-files-cell .abx-output-icon {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 3px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.snapshot-files-cell .abx-output-icon svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.snapshot-files-cell .abx-output-icon--singlefile { color: #4f46e5; background: #eef2ff; border-color: #c7d2fe; }
|
||||
.snapshot-files-cell .abx-output-icon--archivewebpage { color: #7c3aed; background: #f5f3ff; border-color: #ddd6fe; }
|
||||
.snapshot-files-cell .abx-output-icon--ytdlp { color: #dc2626; background: #fef2f2; border-color: #fecaca; }
|
||||
.snapshot-files-cell .abx-output-icon--readability { color: #b45309; background: #fffbeb; border-color: #fde68a; }
|
||||
.snapshot-files-cell .abx-output-icon--forumdl { color: #0369a1; background: #f0f9ff; border-color: #bae6fd; }
|
||||
.snapshot-files-cell .abx-output-icon--papersdl { color: #0f766e; background: #f0fdfa; border-color: #99f6e4; }
|
||||
.snapshot-files-cell .abx-output-icon--git { color: #c2410c; background: #fff7ed; border-color: #fed7aa; }
|
||||
.snapshot-files-cell .abx-output-icon--gallerydl { color: #be185d; background: #fdf2f8; border-color: #fbcfe8; }
|
||||
.snapshot-files-cell .abx-output-icon--screenshot { color: #15803d; background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.snapshot-files-cell .files-icon-plugin--screenshot { color: #15803d; }
|
||||
|
||||
@keyframes snapshot-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -502,7 +594,8 @@
|
||||
|
||||
.snapshot-preview-cell a {
|
||||
width: 82px;
|
||||
height: 82px;
|
||||
top: 7px;
|
||||
bottom: 7px;
|
||||
}
|
||||
|
||||
#content .public-snapshot-list .snapshot-preview-cell .snapshot-preview,
|
||||
@@ -565,8 +658,8 @@
|
||||
{% if public_search_stream_pending %}
|
||||
<span class="archivebox-search-stream-status" role="status" aria-live="polite">Searching...</span>
|
||||
{% else %}
|
||||
{{ page_obj.start_index }}-{{ page_obj.end_index }} of {{ page_obj.paginator.count }}
|
||||
<span>(page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }})</span>
|
||||
{{ page_obj.start_index }}-{{ page_obj.end_index }}{% if public_snapshot_count != None %} of {{ public_snapshot_count }}{% endif %}
|
||||
<span>(page {{ page_obj.number }}{% if public_page_count != None %} of {{ public_page_count }}{% endif %})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,7 +669,7 @@
|
||||
<tr>
|
||||
<th class="snapshot-time">Saved</th>
|
||||
<th class="snapshot-preview-cell">Preview</th>
|
||||
<th>Snapshot ({{ page_obj.paginator.count }})</th>
|
||||
<th>Snapshot{% if public_snapshot_count != None %} ({{ public_snapshot_count }}){% endif %}</th>
|
||||
<th class="snapshot-tags-cell">Tags</th>
|
||||
<th class="snapshot-status-cell">Status</th>
|
||||
<th class="snapshot-files-cell">Files</th>
|
||||
@@ -604,7 +697,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<div class="public-pagination">
|
||||
Showing {{ page_obj.start_index }}-{{ page_obj.end_index }} of {{ page_obj.paginator.count }} total
|
||||
Showing {{ page_obj.start_index }}-{{ page_obj.end_index }}{% if public_snapshot_count != None %} of {{ public_snapshot_count }} total{% endif %}
|
||||
<br>
|
||||
<span class="step-links">
|
||||
{% if page_obj.has_previous %}
|
||||
@@ -613,14 +706,14 @@
|
||||
{% endif %}
|
||||
|
||||
<span class="current">
|
||||
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
|
||||
Page {{ page_obj.number }}{% if public_page_count != None %} of {{ public_page_count }}{% endif %}
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?{% url_replace page=page_obj.next_page_number %}">next</a>
|
||||
{% endif %}
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?{% url_replace page=page_obj.paginator.num_pages %}">last »</a>
|
||||
{% if page_obj.has_next and public_page_count != None %}
|
||||
<a href="?{% url_replace page=public_page_count %}">last »</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
{% load core_tags %}
|
||||
<div class="snapshot-variants-list">
|
||||
{% for capture in captures %}
|
||||
<a href="{% snapshot_detail_url capture %}" title="{{ capture.url }}"{% if capture.pk == snapshot.pk %} aria-current="page"{% endif %}>
|
||||
{{ capture.bookmarked_at|default:capture.created_at|default:capture.downloaded_at|date:"Y-m-d H:i:s" }} 💾 {{ capture.output_size|filesizeformat }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -3,10 +3,10 @@
|
||||
{% with display_path=result.path display_url='' preview_url='' %}
|
||||
{% if display_path %}{% snapshot_url snapshot display_path as display_url %}{% endif %}
|
||||
{% if display_path %}{% if result.direct_preview_path %}{% snapshot_url snapshot result.direct_preview_path as preview_url %}{% else %}{% snapshot_preview_url snapshot display_path result.result as preview_url %}{% endif %}{% endif %}
|
||||
<div class="thumb-card{% if forloop.first %} selected-card{% endif %}" data-plugin-name="{{result.name|plugin_name}}" data-output-group="{{result.output_group}}"{% if preview_url %} data-preview-url="{{preview_url}}"{% endif %}{% if display_path %} data-output-path="{{display_path}}"{% endif %}>
|
||||
<div class="thumb-card{% if forloop.first %} selected-card{% endif %}" data-plugin-name="{{result.name|plugin_name}}" data-output-group="{{result.output_group}}" data-output-size="{{result.size|default:0}}"{% if preview_url %} data-preview-url="{{preview_url}}"{% endif %}{% if display_path %} data-output-path="{{display_path}}"{% endif %}>
|
||||
<div class="thumb-body">
|
||||
<div class="thumb-actions">
|
||||
{% if not STATIC_EXPORT %}<a href="{% snapshot_url snapshot result.folder_path|default:result.name %}/?files=1" data-no-preview="1" title="Open output folder" target="_blank" rel="noopener">📁</a>{% endif %}
|
||||
{% if not STATIC_EXPORT %}<a href="{% snapshot_files_url snapshot result.folder_path %}" data-no-preview="1" title="Open output folder" target="_blank" rel="noopener">📁</a>{% endif %}
|
||||
{% if display_path %}
|
||||
<a href="{{display_url}}" data-no-preview="1" title="Download output file" download>⬇️</a>
|
||||
{% endif %}
|
||||
@@ -16,31 +16,23 @@
|
||||
</div>
|
||||
{% if display_path %}
|
||||
<a href="{{preview_url}}" target="preview" title="./{{display_path}} (downloaded {{result.ts}})">
|
||||
<h4>{% plugin_icon result.name %} {{result.name|plugin_name|truncatechars:20}}{% if result.size %} <small>({{result.size|filesizeformat}})</small>{% endif %}</h4>
|
||||
<h4><span class="card-title-icon">{% plugin_icon result.name %}</span> {{result.name|plugin_name|truncatechars:20}}{% if result.size %} <small class="card-title-size">{{result.size|file_size:0}}</small>{% endif %}</h4>
|
||||
</a>
|
||||
{% else %}
|
||||
<h4>{% plugin_icon result.name %} {{result.name|plugin_name|truncatechars:20}}{% if result.size %} <small>({{result.size|filesizeformat}})</small>{% endif %}</h4>
|
||||
{% endif %}
|
||||
{% if result.result %}
|
||||
{% with plugin_base=result.name|plugin_name %}
|
||||
{% if plugin_base == 'ytdlp' or plugin_base == 'yt-dlp' or plugin_base == 'youtube-dl' %}
|
||||
{% plugin_card result.result %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<h4><span class="card-title-icon">{% plugin_icon result.name %}</span> {{result.name|plugin_name|truncatechars:20}}{% if result.size %} <small class="card-title-size">{{result.size|file_size:0}}</small>{% endif %}</h4>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if result.result and display_path %}
|
||||
{% with plugin_base=result.name|plugin_name %}
|
||||
{% if plugin_base != 'ytdlp' and plugin_base != 'yt-dlp' and plugin_base != 'youtube-dl' %}
|
||||
{# Use plugin-specific card template when ArchiveResult is available #}
|
||||
<div class="card-img-top thumbnail-wrapper">
|
||||
<a class="thumbnail-click-overlay" href="{{preview_url}}" target="preview" title="./{{display_path}} (downloaded {{result.ts}})" aria-label="Preview {{result.name|plugin_name}}"></a>
|
||||
{% plugin_card result.result %}
|
||||
</div>
|
||||
{% else %}
|
||||
{# YT-DLP renders its file list in the body #}
|
||||
<div class="card-img-top thumbnail-wrapper">
|
||||
{% comment %}The outer detail UI is trusted and may be on web.*. Every
|
||||
live plugin card uses the same scoped _card document on the
|
||||
snapshot origin in both stack positions. Never run saved
|
||||
HTML/JS/CSS on web.* or share the admin session with snap-*.{% endcomment %}
|
||||
{% if not result.name|plugin_card_is_interactive %}
|
||||
<a class="thumbnail-click-overlay" href="{{preview_url}}" target="preview" title="./{{display_path}} (downloaded {{result.ts}})" aria-label="Preview {{result.name|plugin_name}}"></a>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% plugin_card result.result %}
|
||||
</div>
|
||||
{% elif result.is_metadata and display_path %}
|
||||
<div class="card-img-top thumbnail-wrapper">
|
||||
<a class="thumbnail-click-overlay" href="{{preview_url}}" target="preview" title="./{{display_path}}" aria-label="Preview {{result.name|plugin_name}}"></a>
|
||||
@@ -57,22 +49,24 @@
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% if loose_items or not STATIC_EXPORT %}
|
||||
<div class="thumb-card" data-other-files{% if not STATIC_EXPORT %} data-discover-files{% endif %}>
|
||||
<div class="thumb-card" data-other-files data-output-size="0"{% if not STATIC_EXPORT %} data-discover-files{% endif %}>
|
||||
<div class="thumb-body">
|
||||
<div class="thumb-actions">
|
||||
<a href="{% if STATIC_EXPORT %}{% snapshot_url snapshot 'index.jsonl' %}{% else %}{% snapshot_base_url snapshot %}/?files=1{% endif %}" data-no-preview="1" title="{% if STATIC_EXPORT %}Open snapshot manifest{% else %}Browse all snapshot files{% endif %}" target="_blank" rel="noopener">📁</a>
|
||||
<a href="{% snapshot_files_url snapshot %}" data-no-preview="1" title="{% if STATIC_EXPORT %}Open snapshot manifest{% else %}Browse all snapshot files{% endif %}" target="_blank" rel="noopener">📁</a>
|
||||
</div>
|
||||
<h4>📦 Other files</h4>
|
||||
<h4><span class="card-title-icon">📦</span> Other files</h4>
|
||||
</div>
|
||||
<div class="thumbnail-wrapper other-files-preview">
|
||||
<small class="discovery-status" aria-live="polite"></small>
|
||||
<div class="loose-items">
|
||||
{% for item in loose_items %}
|
||||
{% if item.is_dir %}
|
||||
<a href="{% if STATIC_EXPORT %}{% snapshot_url snapshot 'index.jsonl' %}{% else %}{% snapshot_url snapshot item.path %}/?files=1{% endif %}" data-no-preview="1" target="_blank" rel="noopener">📁 {{item.name}}</a>
|
||||
<a href="{% snapshot_files_url snapshot item.path %}" data-output-size="{{item.size|default:0}}" data-no-preview="1" target="_blank" rel="noopener">📁 {{item.name}}</a>
|
||||
{% else %}
|
||||
<a href="{% snapshot_url snapshot item.path %}" data-no-preview="1" target="_blank" rel="noopener">📄 {{item.name}}</a>
|
||||
<a href="{% snapshot_url snapshot item.path %}" data-output-size="{{item.size|default:0}}" data-no-preview="1" target="_blank" rel="noopener">📄 {{item.name}}</a>
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
<a href="{% snapshot_base_url snapshot %}/?files=1" data-no-preview="1" target="_blank" rel="noopener">📁 Browse all files</a>
|
||||
<a href="{% snapshot_files_url snapshot %}" data-no-preview="1" target="_blank" rel="noopener">📁 Browse all files</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
.snapshot-stacks .header-bottom {
|
||||
background: #f5f3f4;
|
||||
padding: 6px 8px 7px;
|
||||
max-height: 64vh;
|
||||
overflow: auto;
|
||||
overflow: visible;
|
||||
border-bottom: 1px solid #ded5da;
|
||||
}
|
||||
.snapshot-stacks [hidden] {
|
||||
@@ -31,15 +30,49 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.stack-heading {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.stack-heading-label,
|
||||
.stack-heading-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 16px;
|
||||
min-height: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.stack-heading-label {
|
||||
min-width: 0;
|
||||
}
|
||||
.stack-heading-right {
|
||||
justify-self: end;
|
||||
}
|
||||
.stack-heading strong {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
font-weight: 650;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stack-size {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 16px;
|
||||
color: #8b8588;
|
||||
font-size: 10px;
|
||||
font-weight: 450;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stack-size[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.stack-count {
|
||||
display: inline-flex;
|
||||
@@ -56,7 +89,7 @@
|
||||
.stack-pile {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 100px;
|
||||
height: 158px;
|
||||
margin: 0 6px 0 0;
|
||||
perspective: 500px;
|
||||
}
|
||||
@@ -99,7 +132,7 @@
|
||||
color: #a51d52;
|
||||
}
|
||||
.output-stack[aria-expanded=true] .stack-chevron {
|
||||
transform: rotate(180deg);
|
||||
transform: rotate(225deg);
|
||||
}
|
||||
.output-stack.has-selection .stack-count {
|
||||
background: #ac2258;
|
||||
@@ -119,41 +152,36 @@
|
||||
}
|
||||
.stack-cover {
|
||||
display: block;
|
||||
height: 83px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
background: #fff;
|
||||
}
|
||||
.stack-cover img {
|
||||
width: 100%;
|
||||
.stack-cover .thumb-card {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
content-visibility: visible;
|
||||
border: 0;
|
||||
}
|
||||
.stack-cover iframe:not([data-compact]) {
|
||||
width: 400%!important;
|
||||
height: 400px!important;
|
||||
transform: scale(.25)!important;
|
||||
transform-origin: 0 0!important;
|
||||
border: 0!important;
|
||||
}
|
||||
.stack-cover:has([data-compact]) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: linear-gradient(145deg,#fff,#f5eff2);
|
||||
padding: 10px 4px;
|
||||
}
|
||||
.stack-cover [data-compact] {
|
||||
width: 100%!important;
|
||||
/* Hide only header chrome on stack covers, including icons with inline display styles. */
|
||||
.stack-cover .thumb-body > .thumb-actions,
|
||||
.stack-cover .thumb-body h4 small,
|
||||
.stack-cover .thumb-body h4 .card-title-icon,
|
||||
.stack-cover .thumb-body h4 .abx-plugin-icon {
|
||||
display: none !important;
|
||||
}
|
||||
.stack-chevron {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: 0 3px 0 2px;
|
||||
transform: rotate(45deg);
|
||||
border-right: 1px solid #9c486a;
|
||||
border-bottom: 1px solid #9c486a;
|
||||
color: #9c486a;
|
||||
line-height: 12px;
|
||||
transition: transform .2s;
|
||||
margin-left: auto;
|
||||
}
|
||||
.stack-no-output {
|
||||
display: flex;
|
||||
@@ -186,7 +214,7 @@
|
||||
grid-template-columns: repeat(auto-fill,minmax(200px,1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.stack-tray .thumb-card,.stack-tray .thumb-card:has([data-compact]) {
|
||||
.stack-tray .thumb-card {
|
||||
min-height: 158px;
|
||||
max-height: 158px;
|
||||
height: 158px;
|
||||
@@ -201,30 +229,45 @@
|
||||
border: 2px solid #ac2258;
|
||||
box-shadow: 0 0 0 2px #ac225813;
|
||||
}
|
||||
.stack-tray .thumb-card .thumb-body {
|
||||
.stack-browser .thumb-card .thumb-body {
|
||||
padding: 3px 5px;
|
||||
background: #fff;
|
||||
}
|
||||
.stack-tray .thumb-card .thumb-body h4 {
|
||||
font-size: 11px;
|
||||
text-transform: none;
|
||||
.stack-browser .stack-cover .thumb-body {
|
||||
min-height: 20px;
|
||||
padding: 3px 6px;
|
||||
background: #faf8f9;
|
||||
border-bottom: 1px solid #eee7eb;
|
||||
}
|
||||
.stack-tray .thumb-card .thumb-body small {
|
||||
.stack-cover {
|
||||
--card-title-size: 10px;
|
||||
--card-title-color: #6c5862;
|
||||
}
|
||||
.stack-browser .thumb-card .thumb-body small {
|
||||
display: inline;
|
||||
font-size: 9px;
|
||||
}
|
||||
.stack-tray .thumb-card .thumbnail-wrapper,.stack-tray .thumb-card:has([data-compact]) .thumbnail-wrapper {
|
||||
.stack-browser .thumb-card .thumbnail-wrapper {
|
||||
display: block;
|
||||
height: 130px!important;
|
||||
min-height: 130px;
|
||||
max-height: 130px!important;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stack-tray .thumb-card .thumbnail-wrapper > iframe[data-compact] {
|
||||
position: static;
|
||||
.stack-browser .thumb-card .thumbnail-wrapper.other-files-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
height: 130px!important;
|
||||
min-height: 130px;
|
||||
max-height: 130px!important;
|
||||
}
|
||||
.stack-tray .thumb-card .thumbnail-wrapper:has([data-compact]) {
|
||||
background: #fff;
|
||||
.stack-browser .thumb-card .thumbnail-wrapper.other-files-preview > .loose-items {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.stack-empty {
|
||||
grid-column: 1/-1;
|
||||
@@ -237,9 +280,6 @@
|
||||
outline: 3px solid #b64574;
|
||||
outline-offset: 5px;
|
||||
}
|
||||
.stack-tray .thumb-card .thumbnail-wrapper iframe {
|
||||
max-width: none!important;
|
||||
}
|
||||
.stack-close {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@@ -270,11 +310,16 @@
|
||||
font-size: 15px;
|
||||
line-height: 14px;
|
||||
}
|
||||
.stack-cover iframe {
|
||||
max-width: none!important;
|
||||
@media (max-width:1200px) {
|
||||
.stack-heading { height: 32px; grid-template-rows: 16px 16px; }
|
||||
.stack-heading-label { display: contents; }
|
||||
.stack-heading strong { grid-column: 1 / -1; grid-row: 1; }
|
||||
.stack-size { grid-column: 1; grid-row: 2; }
|
||||
.stack-heading-right { grid-column: 2; grid-row: 2; }
|
||||
}
|
||||
@media (max-width:1000px) {
|
||||
.stack-shelf { grid-template-columns:repeat(4,minmax(0,1fr)); }
|
||||
.stack-shelf { grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
.stack-pile { height:126px; }
|
||||
}
|
||||
@media (max-width:600px) {
|
||||
.stack-shelf { grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px 8px; }
|
||||
@@ -290,20 +335,13 @@
|
||||
.stack-browser * { animation:none!important; transition:none!important; }
|
||||
}
|
||||
|
||||
.stack-tray .thumb-card[data-other-files] .thumb-body {
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
flex: 1;
|
||||
}
|
||||
.stack-tray [data-other-files] .loose-items,
|
||||
.stack-tray [data-other-files] .discovery-status {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.stack-tray [data-other-files] .discovery-status:empty {
|
||||
display: none;
|
||||
}
|
||||
.stack-tray [data-other-files] .loose-items {
|
||||
.other-files-preview { padding: 6px; }
|
||||
.other-files-preview .discovery-status:empty { display: none; }
|
||||
.other-files-preview .loose-items {
|
||||
height: auto;
|
||||
max-height: 116px;
|
||||
align-items: flex-start;
|
||||
align-content: flex-start;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -8,6 +8,27 @@ function initializeOutputStacks() {
|
||||
(groups.find(group => group.id === card.dataset.outputGroup) || groups.find(group => group.id === 'other')).cards.push(card);
|
||||
}
|
||||
const name = card => card.dataset.pluginName || 'Files';
|
||||
const formatStackSize = bytes => {
|
||||
if (!bytes || bytes <= 0) return '';
|
||||
const kb = 1024;
|
||||
const mb = kb * 1024;
|
||||
const gb = mb * 1024;
|
||||
if (bytes < mb) return `${Math.max(1, Math.ceil(bytes / kb))} KB`;
|
||||
if (bytes < gb) return `${Math.ceil(bytes / mb)} MB`;
|
||||
return `${(Math.ceil(bytes / gb * 10) / 10).toFixed(1)} GB`;
|
||||
};
|
||||
const groupSize = group => group.cards.reduce((sum, card) => {
|
||||
const cardSize = Number.parseInt(card.dataset.outputSize, 10) || 0;
|
||||
const looseSize = [...card.querySelectorAll('.loose-items a[data-output-size]')]
|
||||
.reduce((total, link) => total + (Number.parseInt(link.dataset.outputSize, 10) || 0), 0);
|
||||
return sum + cardSize + looseSize;
|
||||
}, 0);
|
||||
const updateGroupSize = group => {
|
||||
const size = buttons.get(group.id)?.querySelector('.stack-size');
|
||||
if (!size) return;
|
||||
size.textContent = formatStackSize(groupSize(group));
|
||||
size.hidden = !size.textContent;
|
||||
};
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
@@ -29,22 +50,14 @@ function initializeOutputStacks() {
|
||||
tray.append(close, trayCards);
|
||||
const parked = el('div', 'stack-parked');
|
||||
parked.hidden = true;
|
||||
const sharedFrames = [];
|
||||
function movePreviewNode(frame, parent, before = null) {
|
||||
// Preserve the browsing context when supported; never create a second one.
|
||||
if (parent.moveBefore && frame.isConnected && parent.isConnected && frame.ownerDocument === parent.ownerDocument) parent.moveBefore(frame, before);
|
||||
else parent.insertBefore(frame, before);
|
||||
}
|
||||
function placeGroupFrames(group, inCover) {
|
||||
for (const shared of sharedFrames.filter(item => item.group === group)) {
|
||||
movePreviewNode(shared.frame, inCover ? shared.cover : shared.home, inCover ? null : shared.marker);
|
||||
}
|
||||
function movePreviewNode(node, parent, before = null) {
|
||||
if (parent.moveBefore && node.isConnected && parent.isConnected) parent.moveBefore(node, before);
|
||||
else parent.insertBefore(node, before);
|
||||
}
|
||||
let active = null;
|
||||
const buttons = new Map();
|
||||
function collapse() {
|
||||
if (!active) return;
|
||||
placeGroupFrames(active, true);
|
||||
active.cards.forEach(card => movePreviewNode(card, parked));
|
||||
const button = buttons.get(active.id);
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
@@ -90,7 +103,10 @@ function initializeOutputStacks() {
|
||||
}
|
||||
buttons.get(group.id).querySelector('.stack-count').textContent = String(group.cards.length);
|
||||
buttons.get(group.id).setAttribute('aria-label', `${group.label}, ${group.cards.length} outputs, expand stack`);
|
||||
updateGroupSize(group);
|
||||
status.textContent = '';
|
||||
const coverItems = buttons.get(group.id).querySelector('.stack-cover .loose-items');
|
||||
if (coverItems) coverItems.replaceChildren(...[...existingItems.children].map(item => item.cloneNode(true)));
|
||||
discoveryState = 'done';
|
||||
// Resolve the current hash again; navigation may have changed during the scan.
|
||||
if (pendingPreviewHash && pendingPreviewHash === window.location.hash) selectInitialPreview(active === group);
|
||||
@@ -111,18 +127,11 @@ function initializeOutputStacks() {
|
||||
active = group;
|
||||
buttons.get(group.id).setAttribute('aria-expanded', 'true');
|
||||
trayCards.replaceChildren();
|
||||
placeGroupFrames(group, false);
|
||||
if (!group.cards.length) {
|
||||
const empty = el('div', 'stack-empty', 'No captures');
|
||||
trayCards.append(empty);
|
||||
}
|
||||
for (const card of group.cards) {
|
||||
// Promote one-line summaries into real thumbnail previews in the expanded tray.
|
||||
for (const frame of card.querySelectorAll('iframe[data-compact]')) {
|
||||
frame.removeAttribute('data-compact');
|
||||
frame.src = card.dataset.previewUrl;
|
||||
frame.style.cssText = 'width:400%!important;height:560px!important;transform:scale(.25)!important;transform-origin:0 0!important;border:0!important;pointer-events:none';
|
||||
}
|
||||
movePreviewNode(card, trayCards);
|
||||
}
|
||||
tray.setAttribute('aria-label', group.label);
|
||||
@@ -137,43 +146,49 @@ function initializeOutputStacks() {
|
||||
button.setAttribute('aria-controls', tray.id);
|
||||
button.setAttribute('aria-label', `${group.label}, ${group.cards.length} outputs, expand stack`);
|
||||
const heading = el('span', 'stack-heading');
|
||||
heading.append(el('strong', '', group.label), el('span', 'stack-count', String(group.cards.length)));
|
||||
const headingLabel = el('span', 'stack-heading-label');
|
||||
headingLabel.append(el('strong', '', group.label));
|
||||
const size = el('span', 'stack-size', formatStackSize(groupSize(group)));
|
||||
size.hidden = !size.textContent;
|
||||
headingLabel.append(size);
|
||||
const headingRight = el('span', 'stack-heading-right');
|
||||
const chevron = el('span', 'stack-chevron');
|
||||
chevron.setAttribute('aria-hidden', 'true');
|
||||
headingRight.append(el('span', 'stack-count', String(group.cards.length)), chevron);
|
||||
heading.append(headingLabel, headingRight);
|
||||
const pile = el('span', 'stack-pile');
|
||||
pile.setAttribute('aria-hidden', 'true');
|
||||
const top = group.cards[0];
|
||||
const availableLayers = Math.min(3, group.cards.length);
|
||||
for (let index = availableLayers - 1; index >= 0; index--) {
|
||||
const layer = el('span', `stack-sheet stack-sheet-${index}`);
|
||||
layer.append(el('span', 'stack-sheet-label', name(group.cards[index]) || 'Files'));
|
||||
if (index === 0) {
|
||||
const preview = top.querySelector('.thumbnail-wrapper');
|
||||
if (preview) {
|
||||
const cover = preview.cloneNode(true);
|
||||
cover.className = 'stack-cover';
|
||||
cover.inert = true;
|
||||
cover.querySelectorAll('a, script, button, [id]').forEach(node => {
|
||||
if (node.hasAttribute('id')) node.removeAttribute('id');
|
||||
if (node.matches('script,button,.thumbnail-click-overlay')) node.remove();
|
||||
else if (node.matches('a')) node.replaceWith(...node.childNodes);
|
||||
});
|
||||
const originals = [...preview.querySelectorAll('iframe')];
|
||||
cover.querySelectorAll('iframe').forEach((copy, index) => {
|
||||
const frame = originals[index];
|
||||
const marker = document.createComment('Shared stack preview');
|
||||
frame.before(marker);
|
||||
sharedFrames.push({group, frame, cover: copy.parentNode, home: frame.parentNode, marker});
|
||||
// The detached clone must never enter the document with a live src.
|
||||
copy.remove();
|
||||
});
|
||||
layer.append(cover);
|
||||
} else layer.append(el('span', 'stack-file-symbol', '▤'));
|
||||
const cover = el('div', 'stack-cover');
|
||||
const card = top.cloneNode(true);
|
||||
card.classList.remove('selected-card');
|
||||
card.inert = true;
|
||||
for (const key of Object.keys(card.dataset)) delete card.dataset[key];
|
||||
card.querySelectorAll('[id]').forEach(node => node.removeAttribute('id'));
|
||||
// Covers stay visible, but native lazy loading defers offscreen
|
||||
// iframe work. The expanded tray has its own copy of this card.
|
||||
setPreviewResourcePriority(card, false);
|
||||
cover.append(card);
|
||||
layer.append(cover);
|
||||
} else {
|
||||
layer.append(el('span', 'stack-sheet-label', name(group.cards[index])));
|
||||
}
|
||||
pile.append(layer);
|
||||
}
|
||||
if (!top) pile.append(el('span', 'stack-no-output', 'No captures'));
|
||||
heading.append(el('span', 'stack-chevron', '⌄'));
|
||||
button.append(heading, pile);
|
||||
button.addEventListener('click', () => expand(group));
|
||||
button.addEventListener('click', () => {
|
||||
const opening = active !== group;
|
||||
expand(group);
|
||||
const first = group.cards[0];
|
||||
if (opening && first) {
|
||||
activateCardPreview(first, first.querySelector('a[target=preview]') || first.querySelector('a'));
|
||||
}
|
||||
});
|
||||
buttons.set(group.id, button);
|
||||
shelf.append(button);
|
||||
|
||||
@@ -206,8 +221,9 @@ function initializeOutputStacks() {
|
||||
panel.append(shelf, tray, parked);
|
||||
grid.before(panel);
|
||||
cards.forEach(card => movePreviewNode(card, parked));
|
||||
groups.forEach(group => placeGroupFrames(group, true));
|
||||
grid.hidden = true;
|
||||
document.body.classList.add('snapshot-stacks');
|
||||
syncSelection();
|
||||
const otherGroup = groups.find(group => group.id === 'other');
|
||||
if (otherGroup && buttons.has('other')) discoverOtherFiles(otherGroup);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% load core_tags %}
|
||||
<span class="snapshot-thumbnail" role="img" aria-label="Preview of {{ host }}" style="display:inline-flex;position:relative;vertical-align:middle;align-items:center;justify-content:center;overflow:hidden;border-radius:4px;box-shadow:4px 4px 4px rgba(0,0,0,0.02);width:{{ width }};height:{{ height }};background:hsl({{ hue }} 35% 92%);color:hsl({{ hue }} 35% 30%);">
|
||||
<span style="display:flex;flex-direction:column;align-items:center;gap:4px;max-width:90%;">
|
||||
<span aria-hidden="true" style="font:bold 28px sans-serif;">{{ initial }}</span>
|
||||
<span style="font:10px sans-serif;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ host }}</span>
|
||||
<span aria-hidden="true" style="font-size:12px;">▤</span>
|
||||
</span>
|
||||
{% if card_result %}
|
||||
<span aria-hidden="true" style="position:absolute;inset:0;display:block;pointer-events:none;background:#f8fafc;">
|
||||
{% plugin_card card_result %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -19,7 +19,7 @@
|
||||
.table-wrap { overflow-x: auto; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; box-shadow: 0 1px 2px rgba(15,23,42,.04); }
|
||||
table { width: 100%; min-width: 980px; table-layout: fixed; border-collapse: collapse; }
|
||||
th { padding: 9px 10px; border-bottom: 1px solid #e2e8f0; background: #f8fafc; color: #475569; font-size: 11px; letter-spacing: .04em; text-align: left; text-transform: uppercase; }
|
||||
td { padding: 8px 10px; border-bottom: 1px solid #edf2f7; vertical-align: middle; }
|
||||
td { padding: 10px; border-bottom: 1px solid #edf2f7; vertical-align: middle; }
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
.snapshot-time { width: 102px; white-space: nowrap; }
|
||||
@@ -30,11 +30,15 @@
|
||||
.snapshot-preview { display: block; width: 100px; height: 100px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; object-fit: cover; object-position: top; }
|
||||
.snapshot-preview-spinner { display: grid; place-items: center; }
|
||||
.snapshot-preview-spinner::after { width: 20px; height: 20px; border: 2px solid #cbd5e1; border-top-color: #aa1e55; border-radius: 50%; content: ""; animation: spin .8s linear infinite; }
|
||||
.snapshot-title-line { display: flex; align-items: center; gap: 8px; min-width: 0; margin-bottom: 2px; }
|
||||
.snapshot-favicon-link { display: inline-flex; width: 22px; min-width: 22px; height: 22px; align-items: center; justify-content: center; }
|
||||
.link-favicon { display: block; width: 18px; height: 18px; border-radius: 4px; object-fit: contain; }
|
||||
.snapshot-title { overflow: hidden; color: #0f172a; font-size: 14px; font-weight: 650; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
|
||||
.snapshot-url { display: block; margin-left: 30px; overflow: hidden; color: #64748b; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
|
||||
.snapshot-title-line { display: flex; align-items: center; gap: 8px; min-width: 0; margin-bottom: 1px; }
|
||||
.snapshot-url .link-favicon { display: block; width: 1em; height: 1em; flex: 0 0 1em; border-radius: 50%; object-fit: contain; opacity: 0.75; font-size: inherit; }
|
||||
.snapshot-url .link-favicon-empty { display: none; }
|
||||
.snapshot-url-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.snapshot-title { overflow: hidden; color: #0f172a; font-size: 15px; font-weight: 600; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
|
||||
.snapshot-url { display: flex; align-items: center; gap: 5px; margin-left: 0; overflow: hidden; color: #84909f; font-family: "Helvetica Neue", Arial, sans-serif; font-size: 11.5px; font-weight: 400; line-height: 1.25; user-select: all; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
|
||||
.snapshot-url .snapshot-url-domain { color: #64748b; font-weight: 400; }
|
||||
.snapshot-url :is(.snapshot-url-prefix, .snapshot-url-suffix) { color: #b0b7c0; font-weight: 400; }
|
||||
.snapshot-url:hover, .snapshot-title:hover { text-decoration: underline; }
|
||||
.snapshot-mobile-saved { display: none; }
|
||||
.snapshot-tags-cell { width: 180px; }
|
||||
.snapshot-tags { display: flex; flex-wrap: wrap; gap: 4px; max-height: 46px; overflow: hidden; }
|
||||
@@ -45,8 +49,46 @@
|
||||
.status-started .snapshot-status { background: #dbeafe; color: #1d4ed8; }
|
||||
.status-queued .snapshot-status, .status-backoff .snapshot-status { background: #fef3c7; color: #b45309; }
|
||||
.status-failed .snapshot-status { background: #fee2e2; color: #b91c1c; }
|
||||
.snapshot-files-cell { width: 220px; overflow: hidden; }
|
||||
.snapshot-files-cell .files-icons { max-width: 200px; }
|
||||
.snapshot-files-cell { width: 132px; overflow: visible; }
|
||||
.snapshot-files-cell:has(.files-icon-pile:hover), .snapshot-files-cell:has(.files-icon-pile:focus-within) { position: relative; z-index: 10000; }
|
||||
.snapshot-files-cell .files-icons { display: inline-grid; grid-template-columns: repeat(5, 22px); grid-template-rows: 18px; grid-auto-flow: column; grid-auto-columns: 22px; gap: 0; max-width: 100%; vertical-align: middle; }
|
||||
.snapshot-files-cell .files-icon-pile { position: relative; display: inline-block; width: 16px; height: 18px; isolation: isolate; vertical-align: top; }
|
||||
.snapshot-files-cell .files-icon-pile::before, .snapshot-files-cell .files-icon-pile::after { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border: 1px solid #d9cdd3; border-radius: 3px; background: #f7f2f4; content: ""; pointer-events: none; }
|
||||
.snapshot-files-cell .files-icon-pile::before { transform: translate(2px,-2px) rotate(7deg); z-index: -2; }
|
||||
.snapshot-files-cell .files-icon-pile::after { transform: translate(1px,-1px) rotate(3deg); z-index: -1; background: #ece5e8; }
|
||||
.snapshot-files-cell .files-icon-pile-backplate { position: absolute; top: 20px; left: calc(var(--files-icon-pile-column,0) * -22px); z-index: 0; display: none; width: var(--files-icon-card-width,116px); height: calc(var(--files-icon-rows,1) * 18px + 10px); border: 1px solid #d9cdd3; border-radius: 6px; background: #fff; box-shadow: 0 4px 12px #35202d26; pointer-events: auto; }
|
||||
.snapshot-files-cell .files-icon-pile-popup { position: absolute; top: 20px; left: calc(var(--files-icon-pile-column,0) * -22px); z-index: 0; box-sizing: border-box; display: block; width: var(--files-icon-card-width,116px); min-height: calc(var(--files-icon-rows,1) * 18px + 10px); margin: 0; padding: 8px 6px 4px; overflow: visible; border: 1px solid #d9cdd3; border-radius: 6px; background: #fff; box-shadow: 0 4px 12px #35202d26; opacity: 1; }
|
||||
.snapshot-files-cell .files-icon-pile-popup > a, .snapshot-files-cell .files-icon-pile-popup > span { position: absolute; top: var(--files-icon-member-top,8px); left: var(--files-icon-member-left,6px); }
|
||||
.snapshot-files-cell .files-icon-pile-popup::before { position: absolute; top: -5px; left: 0; width: 100%; height: 5px; clip-path: polygon(var(--files-icon-bridge-start,0px) 0,var(--files-icon-bridge-end,16px) 0,100% 100%,0 100%); content: ""; pointer-events: auto; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > .files-icon-pile-backplate, .snapshot-files-cell .files-icon-pile:focus-within > .files-icon-pile-backplate { display: block; }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-hover-bridge { position: absolute; top: 17px; left: calc(var(--files-icon-pile-column,0) * -22px); z-index: 1; display: none; width: var(--files-icon-card-width,116px); height: 5px; clip-path: polygon(var(--files-icon-bridge-start,0px) 0,calc(var(--files-icon-bridge-start,0px) + 16px) 0,100% 100%,0 100%); pointer-events: auto; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > .files-icon-pile-hover-bridge, .snapshot-files-cell .files-icon-pile:focus-within > .files-icon-pile-hover-bridge { display: block; }
|
||||
.snapshot-files-cell .files-icon-pile > a:not(.files-icon-pile-popup), .snapshot-files-cell .files-icon-pile > span:not(.files-icon-pile-popup) { position: absolute; top: 1px; left: min(calc(var(--files-icon-index,0) * 1px),2px); z-index: var(--files-icon-index,0); }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-top { left: calc(var(--files-icon-front-index,0) * 2px); z-index: calc(100 + var(--files-icon-front-index,0)); }
|
||||
.snapshot-files-cell .files-icon-pile > a:hover, .snapshot-files-cell .files-icon-pile > a:focus-visible { z-index: 10000 !important; }
|
||||
.snapshot-files-cell .files-icon-pile:hover, .snapshot-files-cell .files-icon-pile:focus-within { z-index: 9999; overflow: visible; }
|
||||
.snapshot-files-cell .files-icon-pile > :not(.files-icon-pile-cover):not(.files-icon-pile-backplate):not(.files-icon-pile-hover-bridge):not(.files-icon-pile-popup) { visibility: hidden; transition: visibility 0s linear 150ms; }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-cover { z-index: 200; }
|
||||
.snapshot-files-cell .files-icon-pile a[data-tooltip]::after, .snapshot-files-cell .files-icon-pile span[data-tooltip]::after { position: absolute; bottom: calc(100% + 7px); left: 0; z-index: 10001; padding: 5px 8px; border: 1px solid #111827; border-radius: 6px; background: #1f2937; box-shadow: 0 4px 12px #0f172a33; color: #fff; content: attr(data-tooltip); font-size: 11px; font-weight: 600; line-height: 1.2; opacity: 0; pointer-events: none; transition: opacity .08s ease,visibility 0s linear .08s; visibility: hidden; white-space: nowrap; }
|
||||
.snapshot-files-cell .files-icon-pile a[data-tooltip]:hover::after, .snapshot-files-cell .files-icon-pile a[data-tooltip]:focus-visible::after, .snapshot-files-cell .files-icon-pile span[data-tooltip]:hover::after { opacity: 1; transition-delay: .08s,0s; visibility: visible; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > :not(.files-icon-pile-cover):not(.files-icon-pile-backplate):not(.files-icon-pile-hover-bridge):not(.files-icon-pile-popup), .snapshot-files-cell .files-icon-pile:focus-within > :not(.files-icon-pile-cover):not(.files-icon-pile-backplate):not(.files-icon-pile-hover-bridge):not(.files-icon-pile-popup) { top: calc(24px + var(--files-icon-row,0) * 18px); left: calc(3px + var(--files-icon-row-offset,3px) + var(--files-icon-column,0) * 22px - var(--files-icon-pile-column,0) * 22px); visibility: visible; transition-delay: 0s; }
|
||||
.snapshot-files-cell .files-icons:has(.files-icon-pile:hover) .files-icon-pile:not(:hover), .snapshot-files-cell .files-icons:has(.files-icon-pile:focus-within) .files-icon-pile:not(:focus-within) { opacity: .2; }
|
||||
.snapshot-files-cell .files-icon-pile > a.files-icon-pile-cover:hover::before, .snapshot-files-cell .files-icon-pile > a.files-icon-pile-cover:focus-visible::before { position: absolute; top: 17px; left: 50%; z-index: 210; width: 7px; height: 7px; transform: translateX(-50%) rotate(45deg); border-top: 1px solid #d9cdd3; border-left: 1px solid #d9cdd3; background: #fff; content: ""; }
|
||||
.snapshot-files-cell .files-icon-pile > .files-icon-pile-popup { top: 20px; left: calc(var(--files-icon-pile-column,0) * -22px); visibility: hidden; opacity: 0; pointer-events: none; transition: opacity .12s ease,visibility 0s linear 350ms; }
|
||||
.snapshot-files-cell .files-icon-pile:hover > .files-icon-pile-popup, .snapshot-files-cell .files-icon-pile:focus-within > .files-icon-pile-popup, .snapshot-files-cell .files-icon-pile > .files-icon-pile-popup:popover-open { top: 20px; left: calc(var(--files-icon-pile-column,0) * -22px); visibility: visible; opacity: 1; pointer-events: auto; transition-delay: 0s; }
|
||||
.snapshot-files-cell .files-icons a { display: inline-flex; width: 16px; height: 16px; align-items: center; justify-content: center; }
|
||||
.snapshot-files-cell .abx-output-icon { display: inline-flex; width: 16px; height: 16px; align-items: center; justify-content: center; border: 1px solid #e2e8f0; border-radius: 3px; background: #f8fafc; color: #475569; font-size: 7px; font-weight: 700; pointer-events: none; }
|
||||
.snapshot-files-cell .abx-output-icon svg { display: block; width: 16px; height: 16px; }
|
||||
.snapshot-files-cell .abx-output-icon--singlefile { color: #4f46e5; background: #eef2ff; border-color: #c7d2fe; }
|
||||
.snapshot-files-cell .abx-output-icon--archivewebpage { color: #7c3aed; background: #f5f3ff; border-color: #ddd6fe; }
|
||||
.snapshot-files-cell .abx-output-icon--ytdlp { color: #dc2626; background: #fef2f2; border-color: #fecaca; }
|
||||
.snapshot-files-cell .abx-output-icon--readability { color: #b45309; background: #fffbeb; border-color: #fde68a; }
|
||||
.snapshot-files-cell .abx-output-icon--forumdl { color: #0369a1; background: #f0f9ff; border-color: #bae6fd; }
|
||||
.snapshot-files-cell .abx-output-icon--papersdl { color: #0f766e; background: #f0fdfa; border-color: #99f6e4; }
|
||||
.snapshot-files-cell .abx-output-icon--git { color: #c2410c; background: #fff7ed; border-color: #fed7aa; }
|
||||
.snapshot-files-cell .abx-output-icon--gallerydl { color: #be185d; background: #fdf2f8; border-color: #fbcfe8; }
|
||||
.snapshot-files-cell .abx-output-icon--screenshot { color: #15803d; background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.snapshot-files-cell .files-icon-plugin--screenshot { color: #15803d; }
|
||||
.snapshot-size-cell { width: 102px; white-space: nowrap; }
|
||||
.snapshot-size-cell a { display: block; color: #334155; font-size: 12px; font-weight: 700; text-decoration: none; }
|
||||
.snapshot-size-cell small, .empty-value { color: #94a3b8; font-size: 10px; }
|
||||
|
||||
@@ -60,6 +60,10 @@
|
||||
if (!deleteUrl) return window.alert('Delete failed: missing admin request data')
|
||||
deleteOutputButtons.forEach((button) => button.disabled = true)
|
||||
if (button.dataset.deleteHandoff) {
|
||||
// web.* and snap-* only get the one-bit login hint, never the
|
||||
// admin session or CSRF cookie. Navigate to admin.* so its
|
||||
// host-only cookies authorize the standard confirmation page;
|
||||
// the queued GET cannot delete anything by itself.
|
||||
deleteUrl = new URL(deleteUrl, window.location.href)
|
||||
deleteUrl.searchParams.set('action', 'delete_selected')
|
||||
deleteUrl.searchParams.set('snapshot', button.dataset.deleteSnapshotId)
|
||||
|
||||
@@ -1049,3 +1049,17 @@ input:focus, select:focus, textarea:focus, button:focus {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
html.archivebox-native-app #container {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
html.archivebox-native-app #content {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html.archivebox-native-app .add-page .crawl-explanation:has(.crawl-tip) {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -85,6 +85,7 @@
|
||||
link.href = '/admin/core/snapshot/?tags__id__exact=' + td.id;
|
||||
link.className = 'tag-link';
|
||||
link.textContent = td.name;
|
||||
link.title = td.name;
|
||||
pill.appendChild(link);
|
||||
|
||||
var removeBtn = document.createElement('button');
|
||||
|
||||
@@ -362,7 +362,7 @@ body.change-list:not(.model-snapshot) #changelist #toolbar .search-mode-selector
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
body.change-list #changelist #changelist-form {
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist #changelist-form {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@@ -791,6 +791,7 @@ body.change-list:not(.model-snapshot) #changelist #toolbar,
|
||||
line-height: 1.2;
|
||||
text-align: right;
|
||||
white-space: nowrap !important;
|
||||
container: admin-navigation / inline-size;
|
||||
}
|
||||
|
||||
#user-tools .navbar-item {
|
||||
@@ -882,6 +883,26 @@ body.change-list:not(.model-snapshot) #changelist #toolbar,
|
||||
}
|
||||
}
|
||||
|
||||
/* Drop secondary links before they crowd the primary archive actions. Use the
|
||||
navigation's available width, including space taken by the branding. */
|
||||
@container admin-navigation (max-width: 760px) {
|
||||
#user-tools :is(.navbar-user, .navbar-docs, .navbar-api) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container admin-navigation (max-width: 640px) {
|
||||
#user-tools .navbar-account {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container admin-navigation (max-width: 560px) {
|
||||
#user-tools :is(.navbar-log, .navbar-tags, .navbar-admin) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Filter Sidebar - Improved Layout */
|
||||
#content #changelist-filter {
|
||||
background: #fff;
|
||||
@@ -1036,8 +1057,124 @@ body.change-list:not(.model-snapshot) #changelist #toolbar,
|
||||
}
|
||||
|
||||
#content td.field-title_str {
|
||||
padding-left: 2px;
|
||||
padding-left: 8px;
|
||||
padding-right: 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody :is(td, th) {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
#result_list tbody .field-title_str > .snapshot-title-detail-hitbox {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#result_list tbody .field-title_str > a {
|
||||
color: #1e293b;
|
||||
font-family: "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#result_list tbody .field-title_str > a > b {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#result_list tbody .field-title_str > .snapshot-title-url {
|
||||
margin-top: 1px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#result_list tbody .field-title_str > .snapshot-title-url a {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#result_list .snapshot-original-url {
|
||||
font-size: 11.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #84909f;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#result_list .snapshot-original-url:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#result_list .snapshot-original-url .snapshot-url-domain {
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#result_list .snapshot-original-url :is(.snapshot-url-prefix, .snapshot-url-suffix) {
|
||||
color: #b0b7c0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#result_list .snapshot-original-url code {
|
||||
display: block;
|
||||
font-family: "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
line-height: 1.25;
|
||||
color: inherit;
|
||||
/* Fill the cell without making a long URL widen the table. */
|
||||
flex: 1;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#result_list .snapshot-original-url .snapshot-url-favicon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
flex: 0 0 1em;
|
||||
border-radius: 50%;
|
||||
object-fit: contain; opacity: 0.75; font-size: inherit;
|
||||
}
|
||||
|
||||
#result_list tbody :is(.field-size, .field-size_with_stats) a,
|
||||
#result_list tbody :is(.field-size, .field-size_with_stats) a:hover {
|
||||
color: #334155;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#result_list tbody :is(.field-size, .field-size_with_stats) a:not(:has(b)) {
|
||||
font-weight: 400;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
#result_list tbody td.field-preview_icon {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#result_list tbody td.field-preview_icon > a {
|
||||
position: absolute;
|
||||
inset: 4px 2px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#result_list tbody td.field-preview_icon .snapshot-thumbnail {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transition: box-shadow 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
#result_list tbody td.field-preview_icon > a:hover .snapshot-thumbnail,
|
||||
#result_list tbody td.field-preview_icon > a:focus-visible .snapshot-thumbnail {
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#content th.field-preview_icon,
|
||||
@@ -1046,10 +1183,37 @@ body.change-list:not(.model-snapshot) #changelist #toolbar,
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
#content th.column-created_at,
|
||||
#content tbody th.field-created_at {
|
||||
width: 1%;
|
||||
#content th.column-created_at_display,
|
||||
#content tbody th.field-created_at_display,
|
||||
#content tbody td.field-created_at_display {
|
||||
width: 104px !important;
|
||||
min-width: 104px !important;
|
||||
max-width: 104px !important;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#result_list tbody .field-created_at_display a,
|
||||
#result_list tbody .field-title_str a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#result_list tbody .field-created_at_display a:hover,
|
||||
#result_list tbody .field-title_str a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.snapshot-created-at {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#result_list .snapshot-created-at__time {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
#content th.column-action-checkbox,
|
||||
@@ -1182,6 +1346,22 @@ body.model-crawl.change-list #result_list td.field-short_id a {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.model-snapshot .field-permissions_badge .snapshot-permissions-button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.model-snapshot .field-permissions_badge .snapshot-permissions-icon {
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12);
|
||||
transition: box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.model-snapshot .field-permissions_badge .snapshot-permissions-button:hover .snapshot-permissions-icon,
|
||||
.model-snapshot .field-permissions_badge .snapshot-permissions-button:focus-visible .snapshot-permissions-icon {
|
||||
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.24);
|
||||
}
|
||||
|
||||
.snapshot-permissions-menu {
|
||||
position: absolute;
|
||||
top: 26px;
|
||||
@@ -1218,8 +1398,8 @@ body.model-crawl.change-list #result_list td.field-short_id a {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
#content th.column-created_at,
|
||||
#content tbody th.field-created_at {
|
||||
#content th.column-created_at_display,
|
||||
#content tbody th.field-created_at_display {
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
@@ -1243,22 +1423,28 @@ body.model-crawl.change-list #result_list td.field-short_id a {
|
||||
#content td.field-size_with_stats {
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
width: 82px;
|
||||
min-width: 82px;
|
||||
max-width: 82px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#content th.column-files,
|
||||
#content th.field-files,
|
||||
#content td.field-files {
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
width: 212px;
|
||||
max-width: 212px;
|
||||
width: var(--files-icon-card-width, 132px);
|
||||
max-width: 132px;
|
||||
}
|
||||
|
||||
#content th.column-files,
|
||||
#content th.field-files,
|
||||
#content td.field-files {
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
width: 212px;
|
||||
max-width: 212px;
|
||||
width: 132px;
|
||||
max-width: 132px;
|
||||
}
|
||||
|
||||
#content th.field-size_with_stats,
|
||||
@@ -1275,48 +1461,40 @@ body.model-crawl.change-list #result_list td.field-short_id a {
|
||||
max-width: 90px;
|
||||
}
|
||||
|
||||
#content th.field-tags_inline,
|
||||
#content td.field-tags_inline {
|
||||
max-width: 400px;
|
||||
width: 380px;
|
||||
min-width: 340px;
|
||||
#result_list th.column-tags_inline,
|
||||
#result_list th.field-tags_inline,
|
||||
#result_list td.field-tags_inline {
|
||||
width: 1px;
|
||||
min-width: 0;
|
||||
max-width: 220px;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
#content td.field-tags_inline .tag-pills-inline {
|
||||
#result_list td.field-tags_inline .tag-pills-inline {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#content td.field-tags_inline .tag-editor-inline {
|
||||
max-width: 400px;
|
||||
#result_list td.field-tags_inline .tag-editor-inline {
|
||||
width: max-content;
|
||||
max-width: 220px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#content td.field-tags_inline .tag-editor-inline.readonly {
|
||||
#result_list td.field-tags_inline .tag-editor-inline.readonly {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
#content th.field-tags_inline,
|
||||
#content td.field-tags_inline {
|
||||
max-width: 220px;
|
||||
width: 220px;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
#content td.field-tags_inline .tag-pills-inline {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#content td.field-tags_inline .tag-editor-inline {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
#content td.field-files {
|
||||
white-space: nowrap;
|
||||
width: 212px;
|
||||
max-width: 212px;
|
||||
overflow: hidden;
|
||||
width: 132px;
|
||||
max-width: 132px;
|
||||
overflow: visible !important;
|
||||
}
|
||||
#content td.field-files:has(.files-icon-pile:hover),
|
||||
#content td.field-files:has(.files-icon-pile:focus-within) {
|
||||
position: relative;
|
||||
z-index: 10000;
|
||||
}
|
||||
#content td.field-files .files-icons a {
|
||||
display: inline-flex;
|
||||
@@ -1382,21 +1560,177 @@ body.model-crawl.change-list #result_list td.field-short_id a {
|
||||
}
|
||||
|
||||
.files-icons {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(5, 22px);
|
||||
grid-template-rows: 18px;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 22px;
|
||||
gap: 0;
|
||||
vertical-align: middle;
|
||||
align-content: start;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.files-icons--compact {
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(5, 22px);
|
||||
grid-template-rows: 18px;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: auto;
|
||||
grid-template-rows: repeat(4, auto);
|
||||
gap: 2px 4px;
|
||||
grid-auto-columns: 22px;
|
||||
gap: 0;
|
||||
justify-content: start;
|
||||
align-content: start;
|
||||
max-width: 212px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.files-icon-pile {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 18px;
|
||||
isolation: isolate;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.files-icon-pile::before,
|
||||
.files-icon-pile::after {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid #d9cdd3;
|
||||
border-radius: 3px;
|
||||
background: #f7f2f4;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.files-icon-pile::before { transform: translate(2px, -2px) rotate(7deg); z-index: -2; }
|
||||
.files-icon-pile::after { transform: translate(1px, -1px) rotate(3deg); z-index: -1; background: #ece5e8; }
|
||||
.files-icon-pile-popup {
|
||||
position: fixed;
|
||||
z-index: 0;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: var(--files-icon-card-width, 116px);
|
||||
min-height: calc(var(--files-icon-rows, 1) * 18px + 34px);
|
||||
margin: 0;
|
||||
padding: 8px 6px;
|
||||
overflow: visible;
|
||||
border: 1px solid #d9cdd3;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 12px #35202d26;
|
||||
opacity: 1;
|
||||
}
|
||||
.files-icon-pile-popup > a {
|
||||
position: absolute;
|
||||
top: var(--files-icon-member-top, 8px);
|
||||
left: var(--files-icon-member-left, 6px);
|
||||
}
|
||||
.files-icon-pile-popup::before {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: var(--files-icon-caret-x, 8px);
|
||||
z-index: 1;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
border-top: 1px solid #d9cdd3;
|
||||
border-left: 1px solid #d9cdd3;
|
||||
background: #fff;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
.files-icon-pile-popup > .files-icon-pile-label {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 4px;
|
||||
left: 6px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid #eef0f2;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.files-icon-pile > .files-icon-pile-top {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: min(calc(var(--files-icon-index, 0) * 1px), 2px);
|
||||
z-index: var(--files-icon-index, 0);
|
||||
}
|
||||
.files-icon-pile > .files-icon-pile-top {
|
||||
left: var(--files-icon-front-offset, 0px);
|
||||
z-index: calc(100 + var(--files-icon-front-index, 0));
|
||||
}
|
||||
.files-icon-pile:hover,
|
||||
.files-icon-pile:focus-within {
|
||||
z-index: 9999;
|
||||
overflow: visible;
|
||||
}
|
||||
.files-icon-pile > .files-icon-pile-cover {
|
||||
z-index: 200;
|
||||
}
|
||||
.files-icon-pile a[data-tooltip]::after,
|
||||
.files-icon-pile span[data-tooltip]::after {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 7px);
|
||||
left: 0;
|
||||
z-index: 10001;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #111827;
|
||||
border-radius: 6px;
|
||||
background: #1f2937;
|
||||
box-shadow: 0 4px 12px #0f172a33;
|
||||
color: #fff;
|
||||
content: attr(data-tooltip);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .08s ease, visibility 0s linear .08s;
|
||||
visibility: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.files-icon-pile a[data-tooltip]:hover::after,
|
||||
.files-icon-pile a[data-tooltip]:focus-visible::after,
|
||||
.files-icon-pile span[data-tooltip]:hover::after {
|
||||
opacity: 1;
|
||||
transition-delay: .08s, 0s;
|
||||
visibility: visible;
|
||||
}
|
||||
.files-icons:has(.files-icon-pile.is-open) .files-icon-pile:not(.is-open) {
|
||||
opacity: .2;
|
||||
}
|
||||
.files-icons .files-icon-pile.is-open { z-index: 9999; }
|
||||
.files-icon-pile > a.files-icon-pile-cover:hover::before,
|
||||
.files-icon-pile > a.files-icon-pile-cover:focus-visible::before,
|
||||
.files-icon-pile > span.files-icon-pile-cover:hover::before {
|
||||
position: absolute;
|
||||
top: 17px;
|
||||
left: 50%;
|
||||
z-index: 210;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
border-top: 1px solid #d9cdd3;
|
||||
border-left: 1px solid #d9cdd3;
|
||||
background: #fff;
|
||||
content: "";
|
||||
}
|
||||
.files-icon-pile > .files-icon-pile-popup {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.files-icon-pile > .files-icon-pile-popup:popover-open {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.files-icons a {
|
||||
@@ -1421,6 +1755,7 @@ body.model-crawl.change-list #result_list td.field-short_id a {
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.files-icons .abx-output-icon svg {
|
||||
@@ -1429,6 +1764,17 @@ body.model-crawl.change-list #result_list td.field-short_id a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.files-icons .abx-output-icon--singlefile { color: #4f46e5; background: #eef2ff; border-color: #c7d2fe; }
|
||||
.files-icons .abx-output-icon--archivewebpage { color: #7c3aed; background: #f5f3ff; border-color: #ddd6fe; }
|
||||
.files-icons .abx-output-icon--ytdlp { color: #dc2626; background: #fef2f2; border-color: #fecaca; }
|
||||
.files-icons .abx-output-icon--readability { color: #b45309; background: #fffbeb; border-color: #fde68a; }
|
||||
.files-icons .abx-output-icon--forumdl { color: #0369a1; background: #f0f9ff; border-color: #bae6fd; }
|
||||
.files-icons .abx-output-icon--papersdl { color: #0f766e; background: #f0fdfa; border-color: #99f6e4; }
|
||||
.files-icons .abx-output-icon--git { color: #c2410c; background: #fff7ed; border-color: #fed7aa; }
|
||||
.files-icons .abx-output-icon--gallerydl { color: #be185d; background: #fdf2f8; border-color: #fbcfe8; }
|
||||
.files-icons .abx-output-icon--screenshot { color: #15803d; background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.files-icons .files-icon-plugin--screenshot { color: #15803d; }
|
||||
|
||||
.exists-False {
|
||||
opacity: 0.1;
|
||||
filter: grayscale(100%);
|
||||
@@ -1653,8 +1999,8 @@ body.model-archiveresult.change-list #result_list td.field-machine_link {
|
||||
|
||||
body.model-archiveresult.change-list #result_list th.column-output_str_display,
|
||||
body.model-archiveresult.change-list #result_list td.field-output_str_display {
|
||||
width: 110px;
|
||||
max-width: 110px;
|
||||
width: 120px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
body.model-archiveresult.change-list #result_list td.field-snapshot_info a {
|
||||
@@ -1828,6 +2174,7 @@ tbody .output-link:hover {opacity: 1;}
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody tr {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 24px 84px minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
@@ -1835,7 +2182,7 @@ tbody .output-link:hover {opacity: 1;}
|
||||
"check preview title title"
|
||||
"check preview tags tags"
|
||||
"check preview files size";
|
||||
gap: 5px 8px;
|
||||
gap: 3px 6px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
margin: 0 !important;
|
||||
@@ -1845,6 +2192,17 @@ tbody .output-link:hover {opacity: 1;}
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-permissions_badge {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
width: 22px !important;
|
||||
min-width: 22px !important;
|
||||
max-width: 22px !important;
|
||||
padding: 0 !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody tr.selected {
|
||||
border-color: #facc15;
|
||||
background: #fffde7;
|
||||
@@ -1868,12 +2226,37 @@ tbody .output-link:hover {opacity: 1;}
|
||||
padding-top: 2px !important;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody th.field-created_at,
|
||||
.model-snapshot.change-list #result_list tbody td.field-created_at {
|
||||
.model-snapshot.change-list #result_list tbody th.field-created_at_display,
|
||||
.model-snapshot.change-list #result_list tbody td.field-created_at_display {
|
||||
grid-area: date;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
white-space: nowrap !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody th.field-created_at_display > a,
|
||||
.model-snapshot.change-list #result_list tbody td.field-created_at_display > a {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody th.field-created_at_display .snapshot-created-at,
|
||||
.model-snapshot.change-list #result_list tbody td.field-created_at_display .snapshot-created-at {
|
||||
display: inline-flex;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
row-gap: 0;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody .snapshot-created-at__date,
|
||||
.model-snapshot.change-list #result_list tbody .snapshot-created-at__time {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-preview_icon {
|
||||
@@ -1881,22 +2264,32 @@ tbody .output-link:hover {opacity: 1;}
|
||||
grid-area: preview;
|
||||
align-self: stretch;
|
||||
place-items: center;
|
||||
min-height: 84px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-preview_icon img.snapshot-preview.screenshot {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
max-width: 80px;
|
||||
max-height: 80px;
|
||||
.model-snapshot.change-list #result_list tbody td.field-preview_icon .snapshot-thumbnail {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-preview_icon .snapshot-thumbnail > img {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
object-fit: contain !important;
|
||||
object-position: center !important;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-title_str {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-title_str a {
|
||||
.model-snapshot.change-list #result_list tbody td.field-title_str > a + div {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-title_str > a {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -1906,6 +2299,21 @@ tbody .output-link:hover {opacity: 1;}
|
||||
grid-area: tags;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-tags_inline .tags-inline-editor {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-tags_inline .tag-editor-inline {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-tags_inline .tag-pills-inline {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-status_with_progress {
|
||||
grid-area: status;
|
||||
justify-self: end;
|
||||
@@ -1918,10 +2326,13 @@ tbody .output-link:hover {opacity: 1;}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-files .files-icons,
|
||||
.model-snapshot.change-list #result_list tbody td.field-files .files-icons--compact {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap;
|
||||
display: inline-grid !important;
|
||||
grid-template-columns: none !important;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 22px;
|
||||
justify-content: start;
|
||||
max-width: 100%;
|
||||
gap: 4px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-size_with_stats {
|
||||
@@ -1935,6 +2346,15 @@ tbody .output-link:hover {opacity: 1;}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.files-icons--compact,
|
||||
.model-snapshot.change-list .cards .card .card-info .files-icons,
|
||||
.model-snapshot.change-list .cards .card .card-outputs .files-icons {
|
||||
grid-template-columns: repeat(5, 22px) !important;
|
||||
grid-template-rows: 18px !important;
|
||||
grid-auto-flow: column !important;
|
||||
grid-auto-columns: 22px !important;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #changelist #toolbar #changelist-search > div {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
@@ -2017,11 +2437,14 @@ tbody .output-link:hover {opacity: 1;}
|
||||
|
||||
.model-snapshot.change-list .cards .card .card-info .files-icons,
|
||||
.model-snapshot.change-list .cards .card .card-outputs .files-icons {
|
||||
display: inline-flex !important;
|
||||
flex-flow: row wrap !important;
|
||||
display: inline-grid !important;
|
||||
grid-template-columns: repeat(5, 22px) !important;
|
||||
grid-template-rows: 18px !important;
|
||||
grid-auto-flow: column !important;
|
||||
grid-auto-columns: 22px !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
gap: 2px !important;
|
||||
align-items: start !important;
|
||||
gap: 0 !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -2029,7 +2452,7 @@ tbody .output-link:hover {opacity: 1;}
|
||||
.model-snapshot.change-list .cards .card .card-info .files-icons .abx-output-icon,
|
||||
.model-snapshot.change-list .cards .card .card-outputs .files-icons a,
|
||||
.model-snapshot.change-list .cards .card .card-outputs .files-icons .abx-output-icon {
|
||||
flex: 0 0 16px;
|
||||
flex: 0 0 17px;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list .cards .card .card-info label {
|
||||
@@ -2102,27 +2525,11 @@ tbody .output-link:hover {opacity: 1;}
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody tr {
|
||||
grid-template-columns: 24px 72px minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"check preview date"
|
||||
"check preview title"
|
||||
"check preview tags"
|
||||
"check preview status"
|
||||
"check preview files"
|
||||
"check preview size";
|
||||
gap: 2px 4px;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-preview_icon img.snapshot-preview.screenshot {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
max-width: 68px;
|
||||
max-height: 68px;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list tbody td.field-status_with_progress,
|
||||
.model-snapshot.change-list #result_list tbody td.field-size_with_stats {
|
||||
justify-self: start;
|
||||
text-align: left;
|
||||
.model-snapshot.change-list #result_list tbody td.field-files .files-icons {
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2196,6 +2603,12 @@ body.embedded-change-list.model-snapshot.change-list #changelist .actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Keep snapshot results in the page's scroll area. Django's overflow-x: auto
|
||||
also makes overflow-y auto, creating a second scrollbar around the table. */
|
||||
body.model-snapshot.change-list #changelist-form > .results {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #toolbar .changelist-toolbar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) 96px;
|
||||
@@ -3472,15 +3885,15 @@ body.change-list:not(.model-snapshot) #changelist #toolbar .changelist-search-su
|
||||
}
|
||||
}
|
||||
|
||||
body.change-list #changelist #changelist-form > .actions-top .action-buttons[hidden],
|
||||
body.change-list #changelist #changelist-form > .actions-top .actions-tags-with-buttons[hidden] {
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist #changelist-form > .actions-top .action-buttons[hidden],
|
||||
:is(body.change-list, .snapshot-changelist-widget) #changelist #changelist-form > .actions-top .actions-tags-with-buttons[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Keep the search controls and the clickable selection totals in one compact
|
||||
toolbar row on every changelist. The action form is rendered as a sibling of
|
||||
toolbar row on standalone and embedded changelists. The action form is rendered as a sibling of
|
||||
#toolbar, so the shared wrapper owns the two-column layout. */
|
||||
body.change-list:not(.embedded-change-list) #changelist .changelist-form-container > div {
|
||||
:is(body.change-list:not(.embedded-change-list), .snapshot-changelist-widget) #changelist .changelist-form-container > div {
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) max-content !important;
|
||||
grid-template-areas:
|
||||
@@ -3491,7 +3904,7 @@ body.change-list:not(.embedded-change-list) #changelist .changelist-form-contain
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
body.change-list:not(.embedded-change-list) #changelist #toolbar {
|
||||
:is(body.change-list:not(.embedded-change-list), .snapshot-changelist-widget) #changelist #toolbar {
|
||||
grid-area: toolbar !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
@@ -3501,11 +3914,11 @@ body.change-list:not(.embedded-change-list) #changelist #toolbar {
|
||||
/* The action summary already exposes selected, filtered, and total counts with
|
||||
clickable values. Django's older search-result count duplicates that data
|
||||
below the field and makes the toolbar row taller on large result sets. */
|
||||
body.change-list:not(.embedded-change-list) #changelist #toolbar .small.quiet {
|
||||
:is(body.change-list:not(.embedded-change-list), .snapshot-changelist-widget) #changelist #toolbar .small.quiet {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.change-list:not(.embedded-change-list) #changelist #changelist-form > .actions-top {
|
||||
:is(body.change-list:not(.embedded-change-list), .snapshot-changelist-widget) #changelist #changelist-form > .actions-top {
|
||||
grid-area: counts !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
@@ -3523,7 +3936,7 @@ body.change-list:not(.embedded-change-list) #changelist #changelist-form > .acti
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
body.change-list:not(.embedded-change-list) #changelist #changelist-form > .actions-top .actions-left {
|
||||
:is(body.change-list:not(.embedded-change-list), .snapshot-changelist-widget) #changelist #changelist-form > .actions-top .actions-left {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-end !important;
|
||||
@@ -3535,7 +3948,7 @@ body.change-list:not(.embedded-change-list) #changelist #changelist-form > .acti
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
body.change-list:not(.embedded-change-list) #changelist #changelist-form > .actions-top .action-summary {
|
||||
:is(body.change-list:not(.embedded-change-list), .snapshot-changelist-widget) #changelist #changelist-form > .actions-top .action-summary {
|
||||
display: inline-flex !important;
|
||||
flex: 0 0 auto !important;
|
||||
flex-direction: column !important;
|
||||
@@ -3557,18 +3970,18 @@ body.change-list:not(.embedded-change-list) #changelist #changelist-form > .acti
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
body.change-list:not(.embedded-change-list) #changelist #changelist-form > .actions-top .action-summary a,
|
||||
body.change-list:not(.embedded-change-list) #changelist #changelist-form > .actions-top .action-summary span {
|
||||
:is(body.change-list:not(.embedded-change-list), .snapshot-changelist-widget) #changelist #changelist-form > .actions-top .action-summary a,
|
||||
:is(body.change-list:not(.embedded-change-list), .snapshot-changelist-widget) #changelist #changelist-form > .actions-top .action-summary span {
|
||||
font-size: inherit !important;
|
||||
line-height: inherit !important;
|
||||
}
|
||||
|
||||
body.change-list:not(.embedded-change-list) #changelist #changelist-form > .cards,
|
||||
body.change-list:not(.embedded-change-list) #changelist #changelist-form > .results,
|
||||
body.change-list:not(.embedded-change-list) #changelist #changelist-form > .changelist-footer,
|
||||
body.change-list:not(.embedded-change-list) #changelist #changelist-form > .actions-bottom,
|
||||
body.change-list:not(.embedded-change-list) #changelist .paginator,
|
||||
body.change-list:not(.embedded-change-list) #changelist .xfull {
|
||||
:is(body.change-list:not(.embedded-change-list), .snapshot-changelist-widget) #changelist #changelist-form > .cards,
|
||||
:is(body.change-list:not(.embedded-change-list), .snapshot-changelist-widget) #changelist #changelist-form > .results,
|
||||
:is(body.change-list:not(.embedded-change-list), .snapshot-changelist-widget) #changelist #changelist-form > .changelist-footer,
|
||||
:is(body.change-list:not(.embedded-change-list), .snapshot-changelist-widget) #changelist #changelist-form > .actions-bottom,
|
||||
:is(body.change-list:not(.embedded-change-list), .snapshot-changelist-widget) #changelist .paginator,
|
||||
:is(body.change-list:not(.embedded-change-list), .snapshot-changelist-widget) #changelist .xfull {
|
||||
grid-column: 1 / -1 !important;
|
||||
}
|
||||
|
||||
@@ -3581,6 +3994,37 @@ body.change-list:not(.embedded-change-list) #changelist .xfull {
|
||||
border-radius: 7px 0 0 7px;
|
||||
}
|
||||
|
||||
/* Keep the snapshot list flush with the page and its heading rows compact. */
|
||||
body.model-snapshot.change-list #content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #changelist {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
body.model-snapshot.change-list #changelist .changelist-form-container > div {
|
||||
row-gap: 4px !important;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #changelist #toolbar {
|
||||
padding-top: 2px !important;
|
||||
padding-left: 2px !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #result_list thead th {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.model-snapshot.change-list #content #result_list thead th .text a,
|
||||
.model-snapshot.change-list #content #result_list thead th .text span {
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Change forms use a dense grid so ordinary cards keep tiling even when a
|
||||
full-width card appears earlier in document order. */
|
||||
body.change-form #content-main > form > div {
|
||||
@@ -3639,7 +4083,7 @@ body.change-form #content-main > form > div > .submit-row {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
body.change-form #content-main form fieldset > div,
|
||||
body.change-form #content-main form fieldset > div,
|
||||
body.change-form #content form fieldset > div,
|
||||
body.change-form #content-main form fieldset .form-row,
|
||||
body.change-form #content form fieldset .form-row,
|
||||
@@ -3653,3 +4097,61 @@ body.change-form #content-main > form > div > .submit-row {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* Presentation adjustments for the native ArchiveBox shell. The app only
|
||||
marks its document; the server owns the layout and colors. */
|
||||
html.archivebox-native-app #header.archivebox-app-header {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 12px !important;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: auto !important;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px !important;
|
||||
flex-shrink: 0;
|
||||
background: #a51c50 !important;
|
||||
color: #fff !important;
|
||||
font: 500 13px/1.5 -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
html.archivebox-native-app #header.archivebox-app-header > :not(#branding):not(.breadcrumbs),
|
||||
html.archivebox-native-app #header.archivebox-app-header .branding-label,
|
||||
html.archivebox-native-app #header.archivebox-app-header #progress-monitor { display: none !important; }
|
||||
html.archivebox-native-app #header.archivebox-app-header #branding,
|
||||
html.archivebox-native-app #header.archivebox-app-header #site-name { margin: 0 !important; padding: 0 !important; }
|
||||
html.archivebox-native-app #header.archivebox-app-header #branding { flex: 0 0 auto; }
|
||||
html.archivebox-native-app #header.archivebox-app-header #site-name { line-height: 1 !important; }
|
||||
html.archivebox-native-app #header.archivebox-app-header #branding a { display: flex; align-items: center; }
|
||||
html.archivebox-native-app #header.archivebox-app-header #branding img {
|
||||
width: 26px !important; height: 26px !important; margin: 0 !important; object-fit: contain;
|
||||
}
|
||||
html.archivebox-native-app #header.archivebox-app-header .breadcrumbs {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
color: #fff !important;
|
||||
font: inherit !important;
|
||||
white-space: normal !important;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
html.archivebox-native-app #header.archivebox-app-header .breadcrumbs a {
|
||||
color: #fff !important; font: inherit !important; text-decoration: none; text-underline-offset: 3px;
|
||||
}
|
||||
html.archivebox-native-app #header.archivebox-app-header .breadcrumbs a:hover { text-decoration: underline; }
|
||||
html.archivebox-native-app #header.archivebox-app-header a:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
|
||||
|
||||
html.archivebox-native-app:has(body > #progress-monitor),
|
||||
html.archivebox-native-app:has(body > #progress-monitor) body {
|
||||
margin: 0 !important; padding: 0 !important; background: #0d1117 !important;
|
||||
}
|
||||
html.archivebox-native-app:has(body > #progress-monitor) #progress-monitor { display: block !important; min-height: 100vh; }
|
||||
html.archivebox-native-app:has(body > #progress-monitor) #progress-monitor .progress-content { display: flex !important; }
|
||||
html.archivebox-native-app:has(body > #progress-monitor) #progress-monitor .tree-container { max-height: calc(100vh - 80px) !important; }
|
||||
html.archivebox-native-app:has(body > #progress-monitor) #progress-monitor .header-bar { pointer-events: none; }
|
||||
html.archivebox-native-app:has(body > #progress-monitor) #progress-monitor #progress-collapse { display: none !important; }
|
||||
@@ -0,0 +1,83 @@
|
||||
(() => {
|
||||
const HIDE_DELAY_MS = 600;
|
||||
let activePopup = null;
|
||||
|
||||
document.querySelectorAll('.files-icon-pile').forEach((pile) => {
|
||||
const popup = pile.querySelector('.files-icon-pile-popup[popover]');
|
||||
if (!popup) return;
|
||||
let hideTimer;
|
||||
let trackingPosition = false;
|
||||
|
||||
function updatePosition() {
|
||||
const pileRect = pile.getBoundingClientRect();
|
||||
const pileStyle = window.getComputedStyle(pile);
|
||||
const popupWidth = Number.parseFloat(pileStyle.getPropertyValue('--files-icon-card-width')) || 116;
|
||||
const popupOffset = Number.parseFloat(pileStyle.getPropertyValue('--files-icon-popup-left')) || 0;
|
||||
const maxLeft = Math.max(4, window.innerWidth - popupWidth - 4);
|
||||
const left = Math.max(4, Math.min(maxLeft, pileRect.left + popupOffset));
|
||||
popup.style.left = `${left}px`;
|
||||
popup.style.top = `${pileRect.top + 20}px`;
|
||||
popup.style.setProperty('--files-icon-caret-x', `${Math.max(8, Math.min(popupWidth - 8, pileRect.left + pileRect.width / 2 - left))}px`);
|
||||
}
|
||||
|
||||
function openPopup() {
|
||||
window.clearTimeout(hideTimer);
|
||||
if (activePopup && activePopup !== controller) activePopup.closeImmediately();
|
||||
activePopup = controller;
|
||||
pile.classList.add('is-open');
|
||||
updatePosition();
|
||||
if (!popup.matches(':popover-open')) popup.showPopover();
|
||||
if (!trackingPosition) {
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
trackingPosition = true;
|
||||
}
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
if (popup.matches(':popover-open')) popup.hidePopover();
|
||||
if (trackingPosition) {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
trackingPosition = false;
|
||||
}
|
||||
popup.style.removeProperty('left');
|
||||
popup.style.removeProperty('top');
|
||||
popup.style.removeProperty('--files-icon-caret-x');
|
||||
pile.classList.remove('is-open');
|
||||
if (activePopup === controller) activePopup = null;
|
||||
}
|
||||
|
||||
function scheduleClose() {
|
||||
window.clearTimeout(hideTimer);
|
||||
hideTimer = window.setTimeout(() => {
|
||||
if (pile.matches(':hover') || popup.matches(':hover') || pile.contains(document.activeElement)) return;
|
||||
closePopup();
|
||||
}, HIDE_DELAY_MS);
|
||||
}
|
||||
|
||||
const controller = {
|
||||
closeImmediately() {
|
||||
window.clearTimeout(hideTimer);
|
||||
closePopup();
|
||||
},
|
||||
};
|
||||
|
||||
popup.addEventListener('toggle', (event) => {
|
||||
if (event.newState === 'closed' && trackingPosition) {
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
trackingPosition = false;
|
||||
pile.classList.remove('is-open');
|
||||
if (activePopup === controller) activePopup = null;
|
||||
}
|
||||
});
|
||||
|
||||
pile.addEventListener('pointerenter', openPopup);
|
||||
pile.addEventListener('pointerleave', scheduleClose);
|
||||
pile.addEventListener('focusin', openPopup);
|
||||
pile.addEventListener('focusout', scheduleClose);
|
||||
popup.addEventListener('pointerenter', openPopup);
|
||||
popup.addEventListener('pointerleave', scheduleClose);
|
||||
});
|
||||
})();
|
||||
@@ -324,6 +324,28 @@ def pytest_configure():
|
||||
django.setup()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def django_db_modify_db_settings(
|
||||
django_db_modify_db_settings_parallel_suffix,
|
||||
request,
|
||||
):
|
||||
"""Use file-backed SQLite for threaded live_server requests in tests.
|
||||
|
||||
pytest-django shares an in-memory SQLite DatabaseWrapper with live_server.
|
||||
Its threaded request handlers close that wrapper after each response, so
|
||||
concurrent browser requests can race over the same connection.
|
||||
"""
|
||||
from django.conf import settings
|
||||
|
||||
database = settings.DATABASES["default"]
|
||||
if "sqlite" in database["ENGINE"]:
|
||||
worker_id = getattr(request.config, "workerinput", {}).get("workerid", "")
|
||||
tox_env = os.environ.get("TOX_PARALLEL_ENV", "")
|
||||
suffix = "-".join(part for part in (tox_env, worker_id) if part)
|
||||
filename = f"pytest{('-' + suffix) if suffix else ''}.sqlite3"
|
||||
database.setdefault("TEST", {})["NAME"] = str(SESSION_DATA_DIR / filename)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_test_runtime(tmp_path):
|
||||
"""
|
||||
@@ -988,11 +1010,15 @@ def kill_processes_for_data_dir(data_dir: Path) -> None:
|
||||
continue
|
||||
if pid != os.getpid():
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
# Retain the process identity before signalling: a fast, clean
|
||||
# exit can remove the PID before a subsequent Process lookup.
|
||||
# terminate() also checks that the PID has not been reused.
|
||||
process = psutil.Process(pid)
|
||||
process.terminate()
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
else:
|
||||
processes.append(psutil.Process(pid))
|
||||
processes.append(process)
|
||||
_gone, alive = psutil.wait_procs(processes, timeout=10)
|
||||
assert not alive, f"processes did not stop after SIGTERM: {[proc.pid for proc in alive]}"
|
||||
|
||||
@@ -1637,6 +1663,8 @@ def resolve_abxpkg_binary_env(
|
||||
deps_from: Path | list[Path] | tuple[Path, ...] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Resolve required test dependencies through abxpkg's normal install path."""
|
||||
from abxpkg.config import apply_exec_env
|
||||
|
||||
command_env = dict(env) if env is not None else os.environ.copy()
|
||||
command_env["ABXPKG_LIB_DIR"] = str(lib_dir)
|
||||
command = [
|
||||
@@ -1657,7 +1685,11 @@ def resolve_abxpkg_binary_env(
|
||||
)
|
||||
assert result.returncode == 0, result.stderr or result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
return {str(key): str(value) for key, value in payload.items()}
|
||||
# abxpkg env emits prepend/append deltas, not a replacement environment.
|
||||
# Applying a PATH ending in ':' verbatim hides the caller's abxpkg CLI.
|
||||
delta = {str(key): str(value) for key, value in payload.items()}
|
||||
apply_exec_env(delta, command_env)
|
||||
return {key: command_env[key] for key in delta}
|
||||
|
||||
|
||||
def run_test_hook(
|
||||
@@ -1755,7 +1787,7 @@ def install_real_binary(
|
||||
from archivebox.machine.models import Binary, Machine
|
||||
|
||||
binary = Binary.objects.create(
|
||||
machine=machine or Machine.current(refresh=True),
|
||||
machine=machine or Machine.current(),
|
||||
name=name,
|
||||
binproviders=binproviders,
|
||||
overrides=overrides or {},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{"generator":"forum-dl","version":"0.3.0","extractor":"hackernews","download_time":"2026-09-23T00:02:37.335955+00:00","type":"board","item":{"path":[],"url":"https://news.ycombinator.com/","origin":"https://news.ycombinator.com/","data":{}}}
|
||||
{"generator":"forum-dl","version":"0.3.0","extractor":"hackernews","download_time":"2026-09-23T00:02:37.521243+00:00","type":"thread","item":{"path":["49795260"],"url":"https://news.ycombinator.com/item?id=49795260","origin":"https://hacker-news.firebaseio.com/v0/item/49795260.json","data":{"by":"tomjakubowski","descendants":45,"id":49795260,"kids":[49808018,49809900,49798685,49808546,49807838,49809484,49809009,49808381,49807868,49800224,49807841,49799354,49808016],"score":91,"time":1790036640,"title":"Did OpenAI solve the wrong Navier-Stokes problem?","type":"story","url":"https://www.scientificamerican.com/article/did-openai-solve-the-wrong-navier-stokes-problem/"}}}
|
||||
{"generator":"forum-dl","version":"0.3.0","extractor":"hackernews","download_time":"2026-09-23T00:02:37.521310+00:00","type":"post","item":{"path":["49795260"],"url":"https://news.ycombinator.com/item?id=49795260","origin":"https://hacker-news.firebaseio.com/v0/item/49795260.json","data":{"by":"tomjakubowski","descendants":45,"id":49795260,"kids":[49808018,49809900,49798685,49808546,49807838,49809484,49809009,49808381,49807868,49800224,49807841,49799354,49808016],"score":91,"time":1790036640,"title":"Did OpenAI solve the wrong Navier-Stokes problem?","type":"story","url":"https://www.scientificamerican.com/article/did-openai-solve-the-wrong-navier-stokes-problem/"}}}
|
||||
{"generator":"forum-dl","version":"0.3.0","extractor":"hackernews","download_time":"2026-09-23T00:02:37.521341+00:00","type":"post","item":{"path":["49795260"],"url":"https://news.ycombinator.com/item?id=49795260","origin":"https://hacker-news.firebaseio.com/v0/item/49808018.json","data":{"by":"jonlong","id":49808018,"kids":[49809241,49809903,49809835,49809098],"parent":49795260,"text":"No, OpenAI did not solve the "wrong" Navier-Stokes problem. OpenAI did not solve the hardest version of the problem (unforced blow-up), but did give a solution to the Clay Millennium Prize Problem as written and understood, choosing the explicitly allowed forced option.<p>SciAm writes "in a sense, the LLM found and exploited a loophole in the framing of the question". This is pure sensationalism. Choosing option (C) (out of an explicit list of four options) is neither a "loophole" nor something "found by the LLM"; everyone involved knew this was the option they were pursuing.<p>With the grumbling out the way, there is some actual scientific content to the article: there's a strong argument that OpenAI's method will not extend to the unforced case, leaving our understanding of NS incomplete. This negative result is itself new and interesting (and predicated entirely on the solution found by OpenAI)!","time":1790110812,"type":"comment"}}}
|
||||
{"generator":"forum-dl","version":"0.3.0","extractor":"hackernews","download_time":"2026-09-23T00:02:37.583876+00:00","type":"post","item":{"path":["49795260"],"url":"https://news.ycombinator.com/item?id=49795260","origin":"https://hacker-news.firebaseio.com/v0/item/49809900.json","data":{"by":"jongjong","id":49809900,"parent":49795260,"text":"This situation illustrates exactly the limitation of AI and why we still need humans in the loop.<p>It reminds me of a junior coding bootcamp lecture I once gave. One of the first slides said "Computers will do what you say, not what you mean."","time":1790121697,"type":"comment"}}}
|
||||
@@ -0,0 +1,7 @@
|
||||
These are unmodified public outputs from the September 20, 2026 Hacker News capture:
|
||||
https://web.archivebox.io/squash/20260920/news.ycombinator.com/06aaff7baefe7eeb800041cec04173ea/index.html
|
||||
|
||||
The TLSNotary receipt and response verify with the bundled trusted key. The
|
||||
OpenTimestamps proof begins with the SHA-256 of its saved hashes.json and has
|
||||
pending calendar attestations. This older capture predates receipt navigation
|
||||
metadata and submission.json, so neither is included in the fixture.
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
{
|
||||
"root_hash": "09fa04574d0f07fb8a359e62c7e8780c8c0b69745ede2620ffe53067a5ff5542",
|
||||
"tree_levels": [
|
||||
[
|
||||
"dedbb2bc69e82a663e86648ff17bca36c1dd64d5314d4c6cb2e4746f2a81c9c5",
|
||||
"a999623f18965839155400688db876e0b98fbe7bc87ece2d98174134811a416f",
|
||||
"b97b35c65620666983f2bcbb6a5d7f5af5252740ba79ec408cdb5d71db2db62b",
|
||||
"28066ddc81b82defde2f59727247e35e8f4dbac4f9f6672a1fc83518296df944",
|
||||
"07f96635afc1e0215960c1f470bf52c35b95f01462f97aa430ce471a9ce923bf",
|
||||
"7c93183225dc4469a29de7cae6f0a47ba5f18ae8376a586c1831050a29c2c796",
|
||||
"d7f6d016e67079eb2eaa101fa1d4432de89f9103f8c7623c123378ed4eb191e8",
|
||||
"4f500484681c31fab3751dd86443cfe9a3423c537f2f5607c683110688f1737e",
|
||||
"909fd990f43f3a851211377198f4e9c2791eb905a037a704a1b5d993104ed887",
|
||||
"d35c4fccb34675f98d907d63b90fefebef389da6ace3928f7d889e1ee2ae686f",
|
||||
"8e622ba24d67ae99d89d6e15ae135839995d06f6bb4f45d22a082b58b6e63451",
|
||||
"cb79de32c2c2b16eae91285753143df51b13e8c5f99ab11b4839b29cc9878ca3",
|
||||
"ba168aec0c8196b2eeb9659a732d2cc8fce5ad60c6a209e099eebb0f5448eb2d",
|
||||
"5666e56766c081af73eb53393a42de9271e5975eb3c04e6efacb53ae951c22fd",
|
||||
"df769c3a68b04998697ba319939272666e271985004eb69df02fa15b67508b41",
|
||||
"0f63a2a5a5620b745938e6a248b49704e580e3c8d9d24a4fb7ec68461f1edd41",
|
||||
"b835890eee41993030b3df591e068b49170b8f9b704b0112128faa004f1fb56f",
|
||||
"ec8107390d5b87541db67fcf7d5215fc443fe6288ee6f43c15c5ad932c42208e",
|
||||
"e9be93dd9f25c87e6fb992ae0b9da37f6096acfa2e0850a01de0e6c5d9f3694d",
|
||||
"a0fa4fc475e73d20cc466d6e513d45cdcbcf7c3ae8d9bc248adf29809446eae4",
|
||||
"755648454e58f666d1b50c9ccafd57ceccb4f9588cc731a79c51283ecb92e85d",
|
||||
"1c999c9a3eac28f93a05b00ef16f7454b77d0b9d4f394f1b7cc7c74ad2c69d04",
|
||||
"2ca87e7d9cf251fcea454bf7fdd584ac6239e1ccdd4db2a08fa482b685bc253a",
|
||||
"e2ea5ee5949e07e64a77d01f0d9cf8646ec83f8d26f023a5ddef1b0d7389f767",
|
||||
"8eebb0799014a38852ffad12b8ba8c3fad326e1b92f83a01549c4e69b0bb9893",
|
||||
"75e2688bc393fc13e76333455e8577549ce46e3c46e408d543707b1c6471c07a",
|
||||
"084774f667f7d3abb4967a72a52f5e6cdc1aa65d2f0a8db52966292f28f25ad0",
|
||||
"0eeb1683bd734e13a7d61c91591f432868d3dab27d5d315ee7301434774c1dfe",
|
||||
"22b557a27055b33606b6559f37703928d3e4ad79f110b407d04986e1843543d1",
|
||||
"9f3782df2016d63398d38acb955dbc70b1a465f66833dd07b094e9baeb242a34",
|
||||
"80cb74edba2ca7cbf109c6c653b782590ea643d501acaac632381db8e5c521a1",
|
||||
"619f304ad920ca567a2409329f53669fc019c613940df4dfe2fd7d2f079b7243",
|
||||
"926bbb3430498e9ebd1651a7cbf5c828328fc6a773ffc66b5225ca036dd7fa56",
|
||||
"13ea3b156c9f4f041900ccdb5a92c89681a22dcee3d75e365f63c563ff36b2a7",
|
||||
"bb28ef3a595dfab6fbe8d20663744cafdf8f53b9838e7c2b7e1f70c571ba0f01",
|
||||
"6323f18cc7706c5ada5e169872cf918c8ca958a01db765d54c84811f8246a4b0",
|
||||
"6e2b1f053a1e262c42493f927adf2bd535aab638bf3f91729c7a48b15224c6eb",
|
||||
"d89142c4302333c641c21611f8be9ed7cb53a18092d3b334f3a0b2d527edbe67",
|
||||
"d0d2846d9d9012615624d9728b32c23511cb4de9a3c2937a6a5775451b4b0a10",
|
||||
"6ac167df81ee8c4b8c7f23a6fe210f9005815f811721fff8e9eeee309c0db9e7",
|
||||
"e6d66d9383723efa9c019106748b8268478a77cdf71a7c7b6b87c798d7aa1a7e",
|
||||
"26e10e56dffbc9fcb4fcf2a2c1ebc3e0fa96eb00822d000697a7511aafa7fd9d"
|
||||
],
|
||||
[
|
||||
"0d7bee6217efba700443aabc967121f3601d9ae8b87ca89d0839e21b314b84e2",
|
||||
"6213aee4f2fc9cc91a1719a47f78f607dfb4c987d27b9cd3736c21cdd8a949b2",
|
||||
"6aae689d7cfa19135744f36192cf06a6380f8cb226002017f3b4bde036ce74c4",
|
||||
"dd70a1c9761cf61034027fbd26131c85d2ce6b42de2a1d828d115fd186051d1c",
|
||||
"edc3ccd1985076ceaa1def9c0cbb06228e1afa315f4304951e8e2c57f9441e01",
|
||||
"875b6c486af7d67ce7f915ea6bfd316054fa2708475fcdc1cd1328bf52a707ca",
|
||||
"f655176f4eac625c64148a265a1766c6252e3deaadd777e752cebab92cae4e91",
|
||||
"60173922d4a239c28334d5d0bc4851959f30023f17ef5eab3592bdd10972cbf5",
|
||||
"37ab905d5f02c4cf1b32c1f79f2b40e1445f3d5d42fff0320ff3cd80e939deea",
|
||||
"2e81d4c4d4e00facc5062a64922da9f3a492488ef4e166b1fd46a5f30e2365cd",
|
||||
"0c5690641eb5700bd59fa756674b2c6c44c4e0b6e12bddaec2684abea2e7bf26",
|
||||
"a8035e8fd9eb0e4829912cce9db7a735cdcd89d5529310b365b47f0550545fe2",
|
||||
"dba20032186fc60d61e2c1ede3fee11d083915e11bb675e07c3a59a45b3682a3",
|
||||
"99278a3b9cefbcccd54ec9f82a40970fa170979bce7f967cb3999220e8c549be",
|
||||
"3b384207a367d042f1b6067736f46f0ae0e1deefb49c81ae6bc1adc8c25f32ca",
|
||||
"857a09afb32de1aefb65f735a99ea998ad5aa7104b1632aa82e6461a88b2f553",
|
||||
"eb463a30e921d291c865361344482798a2d4cf44118bd708770390ccbade049b",
|
||||
"9dfbf9498b18253089b559c6e882f03ebc5784759d01dabd6d95c2bb124390ce",
|
||||
"bbf2f99cde36068241d195dbb230a4fd97a4c16f02155416547f9809285591bf",
|
||||
"e5528e9f7ecf479044e3660cf2781414777ad0c3c2584aa9df387997d72acdc0",
|
||||
"6af0be44483b62b8c77f62e7250c2f5b81e17ed9d521c170d3c0328bb216b7dc"
|
||||
],
|
||||
[
|
||||
"cc0a4a6502cafe3cf1d21ecba50566fc9c717f9218f3cf8a595d80aa818b8f92",
|
||||
"03e052ea1d102ae85766458d56e06143288279ae16ad33930f14dfb3049758f9",
|
||||
"c12f9fd9970e96696338ec8751489ed190a2bd90be8b984102cbb7da71333fa9",
|
||||
"489634257a25e373b2a5967b891376fb02085635f66c53c27481e74a0db8a225",
|
||||
"5e648356525216eba8fef232292f06556df007b1654bc64f4f9efa400d2dc20c",
|
||||
"d3a9346d542bfd4e624073e44f069438648a64bf08b81d1ecd9167e0af98d193",
|
||||
"bc979d056391b07ae93cb236320d25fb3e9b5389276a892d8566fa271503c60e",
|
||||
"d02470e16ed25731213f4dcbb0ee12b7e66c7cdb0b8cff02db65e35350268c48",
|
||||
"f482a6d066a7cef3f3c4b80a4debe40c80dff4312c42072a68c93f33a3ec6eac",
|
||||
"939bbf47b41ce4be3cba25f5390740e2beef1193b30e06bf8981da2287458949",
|
||||
"0d3c86002d9ed751985e3dad92c04fedac95a2b0ba2853b8eeff99688b388b27"
|
||||
],
|
||||
[
|
||||
"4d5af0dd25eb5518a475f15dd1cd1fa0a2ed882b5fe51b0448454c383e3c7b65",
|
||||
"2aaae9987d396da36cf137584edf29481edb570c1bc6262bc1946b6efb3a68e2",
|
||||
"4fa1b15257d0314523cdfe738cde609bce56fa742038ae9354c775dcd1dac032",
|
||||
"f08e32cdd99c07a022c17bcf06381981afb56480b3d8b2a1ae3f3f90a338dd4a",
|
||||
"1c1c31c156b2beb63a42c8fdda9454989aa8627bf4df21c54a8c2b0286f4e34e",
|
||||
"238acf711780565d1027c64903f488b740b3539c3fe231ecbcbcfc5bab58d83f"
|
||||
],
|
||||
[
|
||||
"5019f1909beff7fe2bd9d11824b42df49aaf200478388320c752f05b0d4147f8",
|
||||
"5d7d54385da979e09eb8ad9805289724215e153a278c82523c147e43918c9977",
|
||||
"0099af09bc071e754cf9119bbe458a28de601454d55dd1c9406f996cc9de90f1"
|
||||
],
|
||||
[
|
||||
"94f2c5af5317bc747ecedbd7f5f914d5ee3358b58fa6132f059aaf2c9cb60f9d",
|
||||
"55ff9c5a901ae59c76eeaf37f98f5c303f5c314955f6e4800998f4ab3ef958c5"
|
||||
],
|
||||
[
|
||||
"09fa04574d0f07fb8a359e62c7e8780c8c0b69745ede2620ffe53067a5ff5542"
|
||||
]
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "archivewebpage/archivewebpage.wacz",
|
||||
"hash": "dedbb2bc69e82a663e86648ff17bca36c1dd64d5314d4c6cb2e4746f2a81c9c5",
|
||||
"size": 27003
|
||||
},
|
||||
{
|
||||
"path": "archivewebpage/on_Snapshot__16_archivewebpage_start.01a0bf639eb455c8cc193aab49e788cd.sh",
|
||||
"hash": "a999623f18965839155400688db876e0b98fbe7bc87ece2d98174134811a416f",
|
||||
"size": 215
|
||||
},
|
||||
{
|
||||
"path": "archivewebpage/on_Snapshot__65_archivewebpage_stop.01a0bf640435b0cd89ed4822424f969c.sh",
|
||||
"hash": "b97b35c65620666983f2bcbb6a5d7f5af5252740ba79ec408cdb5d71db2db62b",
|
||||
"size": 214
|
||||
},
|
||||
{
|
||||
"path": "archivewebpage/recording.json",
|
||||
"hash": "28066ddc81b82defde2f59727247e35e8f4dbac4f9f6672a1fc83518296df944",
|
||||
"size": 176
|
||||
},
|
||||
{
|
||||
"path": "chrome/browser.json",
|
||||
"hash": "07f96635afc1e0215960c1f470bf52c35b95f01462f97aa430ce471a9ce923bf",
|
||||
"size": 2243
|
||||
},
|
||||
{
|
||||
"path": "chrome/cdp_url.txt",
|
||||
"hash": "7c93183225dc4469a29de7cae6f0a47ba5f18ae8376a586c1831050a29c2c796",
|
||||
"size": 74
|
||||
},
|
||||
{
|
||||
"path": "chrome/chrome.pid",
|
||||
"hash": "d7f6d016e67079eb2eaa101fa1d4432de89f9103f8c7623c123378ed4eb191e8",
|
||||
"size": 4
|
||||
},
|
||||
{
|
||||
"path": "chrome/navigation.json",
|
||||
"hash": "4f500484681c31fab3751dd86443cfe9a3423c537f2f5607c683110688f1737e",
|
||||
"size": 204
|
||||
},
|
||||
{
|
||||
"path": "chrome/on_Snapshot__00_chrome_launch.daemon.bg.01a0bf639165f5b151c5b4b54232a04e.sh",
|
||||
"hash": "909fd990f43f3a851211377198f4e9c2791eb905a037a704a1b5d993104ed887",
|
||||
"size": 210
|
||||
},
|
||||
{
|
||||
"path": "chrome/on_Snapshot__01_chrome_tab.daemon.bg.01a0bf639973a6a6da2a809244dd9195.pid",
|
||||
"hash": "d35c4fccb34675f98d907d63b90fefebef389da6ace3928f7d889e1ee2ae686f",
|
||||
"size": 4
|
||||
},
|
||||
{
|
||||
"path": "chrome/on_Snapshot__01_chrome_tab.daemon.bg.01a0bf639973a6a6da2a809244dd9195.sh",
|
||||
"hash": "8e622ba24d67ae99d89d6e15ae135839995d06f6bb4f45d22a082b58b6e63451",
|
||||
"size": 207
|
||||
},
|
||||
{
|
||||
"path": "chrome/on_Snapshot__01_chrome_tab.daemon.bg.01a0bf639973a6a6da2a809244dd9195.stderr.log",
|
||||
"hash": "cb79de32c2c2b16eae91285753143df51b13e8c5f99ab11b4839b29cc9878ca3",
|
||||
"size": 175
|
||||
},
|
||||
{
|
||||
"path": "chrome/on_Snapshot__01_chrome_tab.daemon.bg.01a0bf639973a6a6da2a809244dd9195.stdout.log",
|
||||
"hash": "ba168aec0c8196b2eeb9659a732d2cc8fce5ad60c6a209e099eebb0f5448eb2d",
|
||||
"size": 251
|
||||
},
|
||||
{
|
||||
"path": "chrome/on_Snapshot__30_chrome_navigate.01a0bf63ad8cd3c34b21f838401a84cc.sh",
|
||||
"hash": "5666e56766c081af73eb53393a42de9271e5975eb3c04e6efacb53ae951c22fd",
|
||||
"size": 202
|
||||
},
|
||||
{
|
||||
"path": "chrome/target_id.txt",
|
||||
"hash": "df769c3a68b04998697ba319939272666e271985004eb69df02fa15b67508b41",
|
||||
"size": 32
|
||||
},
|
||||
{
|
||||
"path": "chrome/url.txt",
|
||||
"hash": "0f63a2a5a5620b745938e6a248b49704e580e3c8d9d24a4fb7ec68461f1edd41",
|
||||
"size": 29
|
||||
},
|
||||
{
|
||||
"path": "index.jsonl",
|
||||
"hash": "b835890eee41993030b3df591e068b49170b8f9b704b0112128faa004f1fb56f",
|
||||
"size": 24969
|
||||
},
|
||||
{
|
||||
"path": "screenshot/on_Snapshot__51_screenshot.01a0bf63e304a89a3302e8f64721a406.sh",
|
||||
"hash": "ec8107390d5b87541db67fcf7d5215fc443fe6288ee6f43c15c5ad932c42208e",
|
||||
"size": 201
|
||||
},
|
||||
{
|
||||
"path": "screenshot/screenshot.json",
|
||||
"hash": "e9be93dd9f25c87e6fb992ae0b9da37f6096acfa2e0850a01de0e6c5d9f3694d",
|
||||
"size": 152
|
||||
},
|
||||
{
|
||||
"path": "screenshot/screenshot.png",
|
||||
"hash": "a0fa4fc475e73d20cc466d6e513d45cdcbcf7c3ae8d9bc248adf29809446eae4",
|
||||
"size": 225168
|
||||
},
|
||||
{
|
||||
"path": "singlefile/on_Snapshot__50_singlefile.01a0bf63b887e93ce99e17b34b5a9829.sh",
|
||||
"hash": "755648454e58f666d1b50c9ccafd57ceccb4f9588cc731a79c51283ecb92e85d",
|
||||
"size": 201
|
||||
},
|
||||
{
|
||||
"path": "singlefile/singlefile.html",
|
||||
"hash": "1c999c9a3eac28f93a05b00ef16f7454b77d0b9d4f394f1b7cc7c74ad2c69d04",
|
||||
"size": 40410
|
||||
},
|
||||
{
|
||||
"path": "sslcerts/chain.pem",
|
||||
"hash": "2ca87e7d9cf251fcea454bf7fdd584ac6239e1ccdd4db2a08fa482b685bc253a",
|
||||
"size": 6768
|
||||
},
|
||||
{
|
||||
"path": "sslcerts/leaf.pem",
|
||||
"hash": "e2ea5ee5949e07e64a77d01f0d9cf8646ec83f8d26f023a5ddef1b0d7389f767",
|
||||
"size": 1306
|
||||
},
|
||||
{
|
||||
"path": "sslcerts/on_Snapshot__23_sslcerts.daemon.bg.01a0bf63aabc27fb74a2d19e4b2f8c81.pid",
|
||||
"hash": "8eebb0799014a38852ffad12b8ba8c3fad326e1b92f83a01549c4e69b0bb9893",
|
||||
"size": 4
|
||||
},
|
||||
{
|
||||
"path": "sslcerts/on_Snapshot__23_sslcerts.daemon.bg.01a0bf63aabc27fb74a2d19e4b2f8c81.sh",
|
||||
"hash": "75e2688bc393fc13e76333455e8577549ce46e3c46e408d543707b1c6471c07a",
|
||||
"size": 207
|
||||
},
|
||||
{
|
||||
"path": "sslcerts/on_Snapshot__23_sslcerts.daemon.bg.01a0bf63aabc27fb74a2d19e4b2f8c81.stderr.log",
|
||||
"hash": "084774f667f7d3abb4967a72a52f5e6cdc1aa65d2f0a8db52966292f28f25ad0",
|
||||
"size": 37
|
||||
},
|
||||
{
|
||||
"path": "sslcerts/on_Snapshot__23_sslcerts.daemon.bg.01a0bf63aabc27fb74a2d19e4b2f8c81.stdout.log",
|
||||
"hash": "0eeb1683bd734e13a7d61c91591f432868d3dab27d5d315ee7301434774c1dfe",
|
||||
"size": 27
|
||||
},
|
||||
{
|
||||
"path": "sslcerts/root.pem",
|
||||
"hash": "22b557a27055b33606b6559f37703928d3e4ad79f110b407d04986e1843543d1",
|
||||
"size": 1939
|
||||
},
|
||||
{
|
||||
"path": "sslcerts/sslcerts.jsonl",
|
||||
"hash": "9f3782df2016d63398d38acb955dbc70b1a465f66833dd07b094e9baeb242a34",
|
||||
"size": 4583
|
||||
},
|
||||
{
|
||||
"path": "title/on_Snapshot__54_title.01a0bf63f3332ee69f67acdb4e1e922e.sh",
|
||||
"hash": "80cb74edba2ca7cbf109c6c653b782590ea643d501acaac632381db8e5c521a1",
|
||||
"size": 191
|
||||
},
|
||||
{
|
||||
"path": "title/title.txt",
|
||||
"hash": "619f304ad920ca567a2409329f53669fc019c613940df4dfe2fd7d2f079b7243",
|
||||
"size": 11
|
||||
},
|
||||
{
|
||||
"path": "tlsnotary/capture-82e4bf62f426a03f80f8f4fb7c6d7b0c634a9ae4fd78b5f4c06e398311a727c4/app.mjs",
|
||||
"hash": "926bbb3430498e9ebd1651a7cbf5c828328fc6a773ffc66b5225ca036dd7fa56",
|
||||
"size": 3167
|
||||
},
|
||||
{
|
||||
"path": "tlsnotary/capture-82e4bf62f426a03f80f8f4fb7c6d7b0c634a9ae4fd78b5f4c06e398311a727c4/diagram.mjs",
|
||||
"hash": "13ea3b156c9f4f041900ccdb5a92c89681a22dcee3d75e365f63c563ff36b2a7",
|
||||
"size": 24403
|
||||
},
|
||||
{
|
||||
"path": "tlsnotary/capture-82e4bf62f426a03f80f8f4fb7c6d7b0c634a9ae4fd78b5f4c06e398311a727c4/index.html",
|
||||
"hash": "bb28ef3a595dfab6fbe8d20663744cafdf8f53b9838e7c2b7e1f70c571ba0f01",
|
||||
"size": 59638
|
||||
},
|
||||
{
|
||||
"path": "tlsnotary/capture-82e4bf62f426a03f80f8f4fb7c6d7b0c634a9ae4fd78b5f4c06e398311a727c4/metadata.json",
|
||||
"hash": "6323f18cc7706c5ada5e169872cf918c8ca958a01db765d54c84811f8246a4b0",
|
||||
"size": 135
|
||||
},
|
||||
{
|
||||
"path": "tlsnotary/capture-82e4bf62f426a03f80f8f4fb7c6d7b0c634a9ae4fd78b5f4c06e398311a727c4/receipt.json",
|
||||
"hash": "6e2b1f053a1e262c42493f927adf2bd535aab638bf3f91729c7a48b15224c6eb",
|
||||
"size": 434
|
||||
},
|
||||
{
|
||||
"path": "tlsnotary/capture-82e4bf62f426a03f80f8f4fb7c6d7b0c634a9ae4fd78b5f4c06e398311a727c4/response.http",
|
||||
"hash": "d89142c4302333c641c21611f8be9ed7cb53a18092d3b334f3a0b2d527edbe67",
|
||||
"size": 6199
|
||||
},
|
||||
{
|
||||
"path": "tlsnotary/capture-82e4bf62f426a03f80f8f4fb7c6d7b0c634a9ae4fd78b5f4c06e398311a727c4/site-chrome.mjs",
|
||||
"hash": "d0d2846d9d9012615624d9728b32c23511cb4de9a3c2937a6a5775451b4b0a10",
|
||||
"size": 871
|
||||
},
|
||||
{
|
||||
"path": "tlsnotary/capture-82e4bf62f426a03f80f8f4fb7c6d7b0c634a9ae4fd78b5f4c06e398311a727c4/style.css",
|
||||
"hash": "6ac167df81ee8c4b8c7f23a6fe210f9005815f811721fff8e9eeee309c0db9e7",
|
||||
"size": 24268
|
||||
},
|
||||
{
|
||||
"path": "tlsnotary/capture-82e4bf62f426a03f80f8f4fb7c6d7b0c634a9ae4fd78b5f4c06e398311a727c4/verify.mjs",
|
||||
"hash": "e6d66d9383723efa9c019106748b8268478a77cdf71a7c7b6b87c798d7aa1a7e",
|
||||
"size": 6531
|
||||
},
|
||||
{
|
||||
"path": "tlsnotary/on_Snapshot__92_tlsnotary.01a0bf640d72a9c21ca7f71840af99d9.sh",
|
||||
"hash": "26e10e56dffbc9fcb4fcf2a2c1ebc3e0fa96eb00822d000697a7511aafa7fd9d",
|
||||
"size": 199
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"timestamp": "2026-09-20T15:17:06.872964+00:00",
|
||||
"file_count": 42,
|
||||
"total_size": 463262,
|
||||
"tree_depth": 7
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+23
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
|
||||
<title>OpenTimestamps submission</title>
|
||||
<style>body{font:16px/1.6 system-ui,sans-serif;max-width:760px;margin:2rem auto;padding:0 1rem;color:#243443;background:#fafafa}h1{font-size:1.6rem}code{overflow-wrap:anywhere}pre{white-space:pre-wrap;padding:1rem;background:#edf1f5;border-radius:8px}a{color:#245c93}.status{background:#fff1cf;padding:.6rem 1rem;border-radius:8px}dt{font-weight:600}dd{margin:0 0 1rem}</style>
|
||||
<style>@media(max-width:400px){body{font-size:12px;margin:.5rem auto;padding:0 .5rem}h1{font-size:1rem;margin:.4rem 0}.status{padding:.4rem .6rem;margin:.4rem 0}}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>◷ OpenTimestamps</h1>
|
||||
<p class="status">Calendar submission saved</p>
|
||||
<p>This proof timestamps the saved hash manifest, binding the listed archive files and any included TLS certificates and TLSNotary evidence to the same commitment.</p>
|
||||
<p><a href="hashes.json.ots" download>Download timestamp proof</a> · <a href="hashes.json" download>Download stamped manifest</a></p>
|
||||
<dl><dt>Archive Merkle root</dt><dd><code>09fa04574d0f07fb8a359e62c7e8780c8c0b69745ede2620ffe53067a5ff5542</code></dd><dt>Stamped manifest SHA-256</dt><dd><code>915021e1f15582b4eb2e5c1d949609c65f506f15507340d16b5120737fa813a7</code></dd></dl>
|
||||
<h2>Upgrade and verify later</h2>
|
||||
<p>Keep both files together. After Bitcoin confirmation, upgrade and verify with the OpenTimestamps client:</p>
|
||||
<pre>ots upgrade hashes.json.ots
|
||||
ots verify hashes.json.ots</pre>
|
||||
<p><a href="https://tlsnotary.zervice.io/">How the archive trust chain works</a> · <a href="https://github.com/opentimestamps/opentimestamps-client">OpenTimestamps client documentation</a></p>
|
||||
</body>
|
||||
</html>
|
||||
+625
@@ -0,0 +1,625 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Keep the verifier markup in sync with templates/full.html. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'"
|
||||
/>
|
||||
<meta charset="utf-8" /><meta
|
||||
name="viewport"
|
||||
content="width=device-width"
|
||||
/><title>TLSNotary verification</title
|
||||
><link rel="stylesheet" href="style.css?v=6ac167df81ee" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- BEGIN ARCHIVEBOX HEADER — generated by monorepo/site-chrome/sync.py -->
|
||||
<header class="abx-header">
|
||||
<a class="abx-brand" href="https://tlsnotary.zervice.io/"><svg class="abx-logo" viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#9b2854"/><path d="M10 14h20v17H10z" fill="#fff4ed"/><path d="M8 9h24v6H8z" fill="#fff4ed"/><path d="M16 19h8v3h-8z" fill="#9b2854"/><path d="M12 31V17" stroke="#9b2854" stroke-width="2"/></svg><span>ArchiveBox</span><span class="abx-subsite">TLSNotary</span></a>
|
||||
<nav class="abx-nav" aria-label="Main navigation">
|
||||
<details class="abx-apps"><summary>Apps</summary><div class="abx-app-links"><a href="https://archivebox.io/">ArchiveBox Server</a><a href="https://app.archivebox.io/">ArchiveBox for macOS & iOS</a><a href="https://extension.archivebox.io/">ArchiveBox Browser Extension</a><a href="https://plugins.archivebox.io/">ArchiveBox Plugin Library</a><a href="https://abx-dl.archivebox.io/"><span>One-shot <code>abx-dl</code> CLI</span></a><a href="https://github.com/ArchiveBox">More on Github...</a></div></details>
|
||||
<a href="#guide-title">How it works</a><a href="https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/tlsnotary/server">Self-host</a>
|
||||
<a href="https://github.com/ArchiveBox/abx-plugins">GitHub ↗</a>
|
||||
<a class="abx-cta" href="#status">Verify a capture</a>
|
||||
</nav>
|
||||
</header>
|
||||
<!-- END ARCHIVEBOX HEADER -->
|
||||
<main>
|
||||
<span class="brand">ArchiveBox · TLSNotary</span>
|
||||
<h1>Verify an ArchiveBox web archive using TLSNotary</h1>
|
||||
<p id="status" role="status">
|
||||
Look under <code>tlsnotary/</code> inside an <code>abx-dl</code> output
|
||||
directory or <code>archivebox</code> snapshot directory, and drag both
|
||||
<code>receipt.json</code> and <code>response.http</code> here.
|
||||
</p>
|
||||
<label class="drop"
|
||||
>Choose capture files<input id="files" type="file" multiple
|
||||
/></label>
|
||||
<dl id="details"></dl>
|
||||
<p class="note">
|
||||
Verification runs on this device. Files are never uploaded. Trust depends
|
||||
on the configured verifier key and its clock. Only this response is
|
||||
authenticated.
|
||||
</p>
|
||||
<details>
|
||||
<summary>Verify independently</summary>
|
||||
<p>
|
||||
Obtain verify.mjs and the verifier public key from an independently
|
||||
trusted source. Check the Ed25519 signature over the decoded receipt
|
||||
payload, then compare SHA-256(response.http bytes || decoded blinder) to
|
||||
its signed hash. Check hostname, signed range and HTTP completeness too.
|
||||
The receipt contains no response bytes.
|
||||
</p>
|
||||
</details>
|
||||
<details id="content" hidden>
|
||||
<summary>Authenticated response content</summary>
|
||||
<pre id="body"></pre>
|
||||
</details>
|
||||
<article class="guide" aria-labelledby="guide-title">
|
||||
<header class="guide-intro">
|
||||
<h2 id="guide-title">Proving the source of archived web content</h2>
|
||||
<p class="lead">
|
||||
Suppose someone walks into a notary’s office with a screenshot printed
|
||||
on paper and signs it in front of the notary. The notary can witness
|
||||
the signature and record when it happened, but cannot establish whether
|
||||
the screenshot was edited in Photoshop before it was printed. The
|
||||
notary did not observe what the website actually served.
|
||||
</p>
|
||||
<p>
|
||||
The same problem applies if someone tampers with a page in their browser
|
||||
before capturing it as evidence, then hashes, timestamps and signs the
|
||||
capture. Those checks can all pass for the altered copy. They do not
|
||||
establish that the website served the content shown in it.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Non-repudiation of web content</strong> requires evidence of what
|
||||
a server sent that a third party can verify later. Ordinary HTTPS does
|
||||
not provide that evidence by itself. TLSNotary addresses this by involving
|
||||
a verifier during the connection, without disclosing the page contents
|
||||
to that verifier.
|
||||
</p>
|
||||
<nav class="guide-nav" aria-label="Evidence guide">
|
||||
<a href="#copy-and-source">The problem</a>
|
||||
<a href="#private-witness">TLSNotary’s approach</a>
|
||||
<a href="#privacy">Privacy</a>
|
||||
<a href="#trust">Trust assumptions</a>
|
||||
<a href="#independent-check">Independent verification</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section id="copy-and-source" aria-labelledby="problem-title">
|
||||
<span class="eyebrow">01 · Preservation and provenance</span>
|
||||
<h2 id="problem-title">File integrity and source authentication</h2>
|
||||
<p>
|
||||
A <strong>hash</strong> is a digital fingerprint of a file. It helps
|
||||
detect changes. A trusted timestamp can establish that those bytes
|
||||
existed by a particular time. Neither, on its own, establishes who
|
||||
supplied the original bytes. A fabricated page can be hashed and
|
||||
timestamped just as successfully as a genuine one.
|
||||
</p>
|
||||
<figure class="analogy">
|
||||
<figcaption>
|
||||
Imagine someone fabricates a social media post by editing it in their
|
||||
browser or Photoshop, then takes a printout to a notary and signs it
|
||||
in front of them.
|
||||
</figcaption>
|
||||
<div class="flow">
|
||||
<div class="flow-card">
|
||||
<span class="step-number">1</span>
|
||||
<h3>Capture “authentic” content</h3>
|
||||
<p>
|
||||
Save a screenshot, HTML, or a recording of requests and responses.
|
||||
The content may already have been altered.
|
||||
</p>
|
||||
</div>
|
||||
<span class="flow-arrow" aria-hidden="true">→</span>
|
||||
<div class="flow-card">
|
||||
<span class="step-number">2</span>
|
||||
<h3>Sign and record the time</h3>
|
||||
<p>
|
||||
Bring it to a notary, timestamping service or blockchain:
|
||||
“I saw ‘xyz’ at this time. Record it.” The signature and timestamp
|
||||
can be valid even if the content is fabricated.
|
||||
</p>
|
||||
</div>
|
||||
<span class="flow-arrow" aria-hidden="true">→</span>
|
||||
<div class="flow-card">
|
||||
<span class="step-number">3</span>
|
||||
<h3>Mistake preservation for authenticity</h3>
|
||||
<p>
|
||||
In court, someone argues: “The signature and timestamp verify,
|
||||
and the hashes match. It’s unchanged, so it must be authentic.”
|
||||
Those checks do not establish the source of step 1.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
A fabricated capture can pass the same checks. The unanswered question
|
||||
is: <strong>did facebook.com/some/post actually serve “xyz” to this
|
||||
viewer on Thursday at 2 p.m.?</strong>
|
||||
</p>
|
||||
</figure>
|
||||
<p>
|
||||
Hashing a saved response and retaining its SSL/TLS certificate leaves
|
||||
the same gap. Even if you sign or timestamp that package, you have
|
||||
preserved a claim about its origin—not cryptographically established
|
||||
that origin. The certificate identifies a server’s key; it is not the
|
||||
server’s signature on the saved page.
|
||||
</p>
|
||||
<div class="distinction">
|
||||
<p><strong>Integrity:</strong> “This copy has not changed since it was recorded.”</p>
|
||||
<p><strong>Origin:</strong> “This server actually sent these bytes.”</p>
|
||||
</div>
|
||||
<p>
|
||||
A common pitfall when evaluating existing products in this space
|
||||
(e.g. Hunchly, Snapoena, Page Vault, ProofSnap and GoFullPage) is assuming
|
||||
that a signed or timestamped archive proves remote origin. An assessment
|
||||
needs to establish <strong>how the capture process rules out fabrication
|
||||
before hashing</strong>. A signature on the evidence package alone does
|
||||
not resolve that question.
|
||||
</p>
|
||||
<details>
|
||||
<summary>Technical detail · TLS connection authentication and server signatures</summary>
|
||||
<p>
|
||||
TLS authenticates the connection to its participants. Its application
|
||||
records use symmetric authentication keys available to the endpoints.
|
||||
A client controlling its capture environment can construct a false
|
||||
transcript and authenticate it with those keys. Saving network
|
||||
packets, certificates, HAR or WARC files does not by itself eliminate
|
||||
that possibility.
|
||||
</p>
|
||||
<p>
|
||||
In TLS 1.3, the server’s CertificateVerify signature covers the
|
||||
handshake transcript; later application records use symmetric AEAD
|
||||
protection. The distinction is explicit in the
|
||||
<a href="https://www.rfc-editor.org/rfc/rfc8446.html#section-4.4.3">IETF’s TLS 1.3 handshake specification</a>
|
||||
and <a href="https://www.rfc-editor.org/rfc/rfc8446.html#section-5.2">record protection specification</a>.
|
||||
TLS 1.2 makes the corresponding separation in
|
||||
<a href="https://www.rfc-editor.org/rfc/rfc5246.html#section-6.2.3">RFC 5246</a>.
|
||||
</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>For evidence reviewers · What timestamps establish</summary>
|
||||
<p>
|
||||
Preservation, timing and source authentication answer different
|
||||
questions. A timestamp is useful evidence about when particular data
|
||||
existed; a hash helps check whether it changed afterward. Neither
|
||||
mechanism examines whether the material was fabricated before it was
|
||||
submitted. See the <a href="https://www.rfc-editor.org/rfc/rfc3161.html#section-2.1">IETF time-stamping protocol</a>.
|
||||
</p>
|
||||
<p>
|
||||
TLSNotary adds evidence of remote origin. It does not replace record
|
||||
keeping, context or an assessment of evidentiary weight. This service’s
|
||||
receipt is not a qualified electronic timestamp or a legal notarial act.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="private-witness" aria-labelledby="witness-title">
|
||||
<span class="eyebrow">02 · Protocol</span>
|
||||
<h2 id="witness-title">Verification during the TLS connection</h2>
|
||||
<p>
|
||||
TLSNotary brings an independent verifier into the cryptographic process
|
||||
<strong>while the website is being contacted</strong>. The person making
|
||||
the capture cannot simply substitute an invented response and convince
|
||||
an honest verifier that it came from that website.
|
||||
</p>
|
||||
<figure class="exchange" aria-labelledby="exchange-title">
|
||||
<figcaption>
|
||||
<span class="exchange-kicker">A capture, message by message</span>
|
||||
<strong id="exchange-title">Follow the exchange as evidence is assembled</strong>
|
||||
<span>Play or scrub. Messages move between the three participants as the client assembles the signature package.</span>
|
||||
</figcaption>
|
||||
<div class="exchange-controls">
|
||||
<button type="button" id="exchange-play" aria-label="Play exchange">Play</button>
|
||||
<div class="exchange-track">
|
||||
<label for="exchange-time">Exchange timeline <output id="exchange-position">01 / 22</output></label>
|
||||
<input id="exchange-time" type="range" min="0" max="2199" step="1" value="0" aria-describedby="exchange-caption" />
|
||||
<div class="exchange-chapters" aria-hidden="true"><span>Setup</span><span>TLS</span><span>HTTP</span><span>Proof</span><span>Receipt</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="exchange-workbench">
|
||||
<div class="exchange-map-scroll" role="region" aria-label="Client, Server and Verifier exchange">
|
||||
<svg class="exchange-map" viewBox="0 0 800 660" role="img" aria-labelledby="network-title network-desc">
|
||||
<title id="network-title">Client, Server and Verifier</title>
|
||||
<desc id="network-desc">Two connections leave the Client: one to the website Server for TLS and HTTP traffic, and one to the Verifier for MPC, proofs and receipts. Implementation-specific transport and hosting layers are omitted.</desc>
|
||||
<defs>
|
||||
<pattern id="network-grid" width="25" height="25" patternUnits="userSpaceOnUse"><path d="M 25 0 L 0 0 0 25" fill="none" stroke="#35515b" stroke-width=".4" /></pattern>
|
||||
<marker id="network-arrow" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto-start-reverse"><path d="M 0 0 L 8 4 L 0 8 Z" fill="#60828e" /></marker>
|
||||
</defs>
|
||||
<rect width="800" height="660" fill="url(#network-grid)" opacity=".28" />
|
||||
<path class="network-link" d="M 330 420 L 150 215" />
|
||||
<path class="network-link" d="M 470 420 L 650 215" />
|
||||
<text x="132" y="346" class="connection-title">TLS + HTTP</text>
|
||||
<text x="668" y="346" class="connection-title">Private verification</text>
|
||||
<path id="exchange-route" d="M 330 420 L 150 215" />
|
||||
<g data-node="client" transform="translate(400 520)">
|
||||
<rect x="-115" y="-100" width="230" height="200" rx="14" />
|
||||
<text y="-57" class="node-title">Client</text>
|
||||
<text y="-31" class="node-sub">ArchiveBox Browser</text>
|
||||
<text y="-5" class="node-memory" id="browser-memory">Private URL + cookies</text>
|
||||
</g>
|
||||
<g data-node="server" transform="translate(150 125)">
|
||||
<rect x="-115" y="-90" width="230" height="180" rx="14" />
|
||||
<text y="-48" class="node-title">Server</text>
|
||||
<text y="-22" class="node-sub">The website you are archiving</text>
|
||||
<text y="2" class="node-memory">example.com</text>
|
||||
</g>
|
||||
<g data-node="verifier" transform="translate(650 125)">
|
||||
<rect x="-115" y="-90" width="230" height="180" rx="14" />
|
||||
<text y="-48" class="node-title">Verifier</text>
|
||||
<text y="-22" class="node-sub">TLSNotary.zervice.io</text>
|
||||
<text y="2" class="node-memory" id="verifier-memory">Not involved yet</text>
|
||||
</g>
|
||||
<g id="exchange-packet" transform="translate(400 569)" aria-hidden="true">
|
||||
<rect x="-110" y="-38" width="220" height="76" rx="10" />
|
||||
<text y="-3" id="packet-label">GET /…</text>
|
||||
<text y="22" id="packet-detail">TLS encrypted · request</text>
|
||||
</g>
|
||||
<text x="400" y="648" class="network-caption">Message labels describe contents; encrypted bytes stay encrypted in transit.</text>
|
||||
</svg>
|
||||
</div>
|
||||
<aside class="evidence-paper" aria-labelledby="evidence-title">
|
||||
<header><h3 id="evidence-title">Signature Package</h3></header>
|
||||
<div class="evidence-lines" id="evidence-lines" role="region" aria-label="Signature package assembly" tabindex="0"></div>
|
||||
<div id="proof-machine" hidden>
|
||||
<strong id="proof-machine-label">Private computation</strong>
|
||||
<code id="proof-scramble" aria-hidden="true"></code>
|
||||
<progress id="proof-machine-progress" max="1" value="0" aria-label="Illustrated computation progress"></progress>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="exchange-legend"><span data-kind="control">Session / metadata</span><span data-kind="handshake">TLS handshake</span><span data-kind="request">HTTP request</span><span data-kind="response">HTTP response</span><span data-kind="mpc">Private computation / proof</span><span data-kind="receipt">Signed receipt</span><span data-kind="local">Local operation</span></div>
|
||||
<div class="exchange-event">
|
||||
<div class="exchange-narrative">
|
||||
<span class="exchange-kicker" id="exchange-phase">Prepare</span>
|
||||
<h3 id="exchange-heading">Prepare the private request</h3>
|
||||
<p id="exchange-copy">The extension prepares a separate TLSNotary request using the URL and request headers from Chrome. The path and cookies remain private client inputs.</p>
|
||||
<p class="exchange-hop"><span>Now</span><strong id="exchange-hop">Client · local operation</strong></p>
|
||||
<p id="exchange-hop-detail">No network transfer at this step.</p>
|
||||
</div>
|
||||
<div class="exchange-envelope">
|
||||
<span id="exchange-payload-label">Message contents · example values</span>
|
||||
<pre id="exchange-payload">GET /private/report?key=example HTTP/1.1
|
||||
Host: example.com
|
||||
Cookie: session=example-secret</pre>
|
||||
<p id="exchange-visibility">Readable at the browser and website. Encrypted on the network.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p id="exchange-caption" class="exchange-footnote">Example exchange with condensed protocol messages and a symbolic signature. The saved package contains response.http and receipt.json.</p>
|
||||
<details class="exchange-sources"><summary>Protocol sources</summary><p>The sequence follows TLSNotary’s <a href="https://tlsnotary.org/docs/protocol/mpc-tls/handshake/">joint TLS handshake</a>, <a href="https://tlsnotary.org/docs/protocol/mpc-tls/encryption/">private encryption and decryption</a> and the extension’s <a href="https://github.com/tlsnotary/tlsn-extension/blob/d446234/packages/extension/src/offscreen/ProveManager/index.ts">session and disclosure flow</a>. The compact signed receipt is part of <a href="https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/tlsnotary">ArchiveBox’s integration</a>.</p><p>The ordinary browser page load precedes this request. TLS traffic and joint computations can interleave. The example hash uses the displayed response and an all-zero blinder; real captures use a random blinder.</p></details>
|
||||
<noscript><p class="exchange-footnote">Enable JavaScript to play or scrub the exchange. The network diagram and explanation remain available above.</p></noscript>
|
||||
</figure>
|
||||
<p>
|
||||
An independent service could always visit a public page and attest to
|
||||
what it saw. TLSNotary uses secure multi-party computation (MPC) and
|
||||
zero-knowledge proofs to <strong>verify the source without disclosing the
|
||||
content to the verifier</strong>.
|
||||
</p>
|
||||
<p class="source-note">
|
||||
TLSNotary has a
|
||||
<a href="https://tlsnotary.org/assets/files/TLSNotary-6f4ad5e83bdff50c765c0ae08143414c.pdf">published protocol dating to 2014</a>.
|
||||
Its current <a href="https://tlsnotary.org/docs/protocol/mpc-tls/">MPC-TLS protocol</a>
|
||||
addresses the problem without requiring websites to adopt a new signing system.
|
||||
</p>
|
||||
<details>
|
||||
<summary>Technical detail · What MPC and zero-knowledge each contribute</summary>
|
||||
<p>
|
||||
MPC lets the browser and verifier perform the TLS operations together
|
||||
without giving the browser unilateral control of the authentication
|
||||
secrets. Zero-knowledge then lets the browser prove that a concealed
|
||||
response matches a cryptographic commitment—a fingerprint locked to
|
||||
those bytes—without revealing the response itself.
|
||||
</p>
|
||||
<p>
|
||||
A zero-knowledge proof alone would not fix a transcript the client was
|
||||
free to fabricate. Participation in the connection is essential.
|
||||
This plugin uses TLSNotary’s MPC mode, plus a SHA-256 hash commitment;
|
||||
it does not use the separate protocol called “proxy mode.” See
|
||||
<a href="https://tlsnotary.org/docs/faq/#why-does-tlsnotary-need-an-online-verifier-cant-this-be-done-serverlessly-in-the-browser-with-zero-knowledge">why an online verifier is needed</a>
|
||||
and <a href="https://tlsnotary.org/docs/protocol/commit_strategy/">commitment strategies</a>.
|
||||
</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Research context · Other protocol approaches</summary>
|
||||
<p>
|
||||
The <a href="https://eprint.iacr.org/2017/578">2017 TLS-N paper</a>
|
||||
describes extending TLS to support transferable proofs. TLSNotary
|
||||
instead uses a separate verifier and works without changes to the
|
||||
website’s server software.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="privacy" aria-labelledby="privacy-title">
|
||||
<span class="eyebrow">03 · Privacy</span>
|
||||
<h2 id="privacy-title">Capturing pages that require authentication</h2>
|
||||
<p>
|
||||
A public archive can visit a news article itself. It cannot ordinarily
|
||||
see your private social media posts, a document shared only with you,
|
||||
or a page behind your login without being given access. Giving the
|
||||
service credentials or a readable copy discloses that private data.
|
||||
</p>
|
||||
<p>
|
||||
TLSNotary makes it possible to authenticate data from a session without
|
||||
revealing its contents or the user’s cookies to a third-party verifier.
|
||||
It allows you to authenticate and sign logged-in content such as social
|
||||
media posts, paywalled articles and private Google Docs without revealing
|
||||
them to a verifier service that could spy on your data.
|
||||
</p>
|
||||
<div class="privacy-grid">
|
||||
<div class="privacy-panel">
|
||||
<h3>Kept from this verifier</h3>
|
||||
<ul>
|
||||
<li>Readable page contents and response headers</li>
|
||||
<li>Cookies, authorization headers and embedded secrets</li>
|
||||
<li>The URL path and query, including private document IDs</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="privacy-panel metadata-panel">
|
||||
<h3>Still visible to the service</h3>
|
||||
<ul>
|
||||
<li>Destination hostname and IP address</li>
|
||||
<li>Traffic sizes, timing and connection metadata</li>
|
||||
<li>The commitment and fixed HTTP protocol markers</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Your saved archive is still sensitive.</strong> It contains the
|
||||
actual response, including any secrets the website sent in it. Someone
|
||||
you give that file to can read it. The verifier at the top of this page
|
||||
checks selected files locally; it does not upload them.
|
||||
</p>
|
||||
<details>
|
||||
<summary>Technical detail · Redaction, transport and the limits of this integration</summary>
|
||||
<p>
|
||||
The capture configuration discloses no request transcript. It hashes
|
||||
the complete received response and reveals only fixed syntax such as
|
||||
<code>HTTP/1.1</code>, spaces and line endings. The response opening
|
||||
stays local. The destination is deliberately visible to the transport
|
||||
proxy; this is not an anonymity service. A transport provider can also observe connection metadata. A sensitive value
|
||||
encoded in a hostname is not hidden like a value in the URL path.
|
||||
</p>
|
||||
<p>
|
||||
TLSNotary supports selective disclosure, but this ArchiveBox receipt
|
||||
binds the <em>whole saved response</em>. Editing or redacting that file
|
||||
afterward makes this receipt fail verification. The plugin currently
|
||||
authenticates a separate main-document request made by the extension,
|
||||
using captured browser request headers. It does not certify all tab
|
||||
traffic, downloaded assets, video content or the rendered screenshot.
|
||||
A JavaScript application’s initial response may not contain the document
|
||||
visible on screen.
|
||||
</p>
|
||||
<p class="source-note">
|
||||
See the <a href="https://tlsnotary.org/docs/extension/verifier/#what-the-verifier-does-not-see">extension’s disclosure model</a>
|
||||
and <a href="https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/tlsnotary">this plugin’s capture and server code</a>.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="trust" aria-labelledby="trust-title">
|
||||
<span class="eyebrow">04 · Trust assumptions</span>
|
||||
<h2 id="trust-title">What receipt verification establishes</h2>
|
||||
<p>A successful check of an ArchiveBox TLSNotary receipt establishes that:</p>
|
||||
<blockquote>
|
||||
These saved response bytes match a commitment signed by the configured
|
||||
verifier. That verifier attests to their origin from the named server
|
||||
and records its own time of issuing the receipt.
|
||||
</blockquote>
|
||||
<p>
|
||||
Someone cannot alter the response and retain a valid result without
|
||||
defeating the cryptography or obtaining a different valid receipt.
|
||||
A valid signature identifies the signing key, but does not establish
|
||||
that its holder was honest. Verification therefore depends on the
|
||||
following trust assumptions.
|
||||
</p>
|
||||
<svg class="trust-icon-defs" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<symbol id="trust-reviewer" viewBox="0 0 40 40"><path d="M5 16 20 7l15 9M7 17h26M9 32h22M6 35h28M12 20v9m8-9v9m8-9v9" /></symbol>
|
||||
<symbol id="trust-client" viewBox="0 0 40 40"><rect x="7" y="8" width="26" height="20" rx="2"/><path d="M3 32h34l-4-4H7zM12 13h16M12 17h9"/></symbol>
|
||||
<symbol id="trust-verifier" viewBox="0 0 40 40"><path d="m20 4 13 5v10c0 8-7 13-13 17C14 32 7 27 7 19V9zM13 20l5 5 10-11"/></symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
<p class="trust-diagram-intro">Arrows show who relies on whose honesty for each claim.</p>
|
||||
<div class="trust-diagrams" role="group" aria-label="Trust relationships for receipt verification">
|
||||
<div class="trust-diagram requires-trust">
|
||||
<div class="trust-relation" aria-label="Reviewer must trust Verifier for origin and time">
|
||||
<span class="trust-party"><svg aria-hidden="true"><use href="#trust-reviewer"/></svg><strong>Reviewer</strong></span>
|
||||
<span class="trust-edge"><svg viewBox="0 0 100 32" aria-hidden="true"><path d="M5 16h86m-10-9 10 9-10 9"/></svg><span>Trust required</span></span>
|
||||
<span class="trust-party"><svg aria-hidden="true"><use href="#trust-verifier"/></svg><strong>Verifier</strong></span>
|
||||
</div>
|
||||
<h3>Origin and time</h3>
|
||||
<p>The reviewer trusts the verifier to attest honestly, protect its signing key and report its issuance time accurately.</p>
|
||||
</div>
|
||||
<div class="trust-diagram checks-without-trust">
|
||||
<div class="trust-relation" aria-label="Reviewer need not trust Client for unchanged response bytes">
|
||||
<span class="trust-party"><svg aria-hidden="true"><use href="#trust-reviewer"/></svg><strong>Reviewer</strong></span>
|
||||
<span class="trust-edge"><svg viewBox="0 0 100 32" aria-hidden="true"><path d="M5 16h86m-10-9 10 9-10 9"/><path class="trust-cross" d="m40 5 20 22m0-22L40 27"/></svg><span>Honesty not required</span></span>
|
||||
<span class="trust-party"><svg aria-hidden="true"><use href="#trust-client"/></svg><strong>Client</strong></span>
|
||||
</div>
|
||||
<h3>Unchanged response bytes</h3>
|
||||
<p>With an honest verifier and its independently trusted key, the reviewer checks the saved bytes without taking the client’s word for them.</p>
|
||||
</div>
|
||||
<div class="trust-diagram checks-without-trust">
|
||||
<div class="trust-relation" aria-label="Verifier need not trust Client for authenticity during capture">
|
||||
<span class="trust-party"><svg aria-hidden="true"><use href="#trust-verifier"/></svg><strong>Verifier</strong></span>
|
||||
<span class="trust-edge"><svg viewBox="0 0 100 32" aria-hidden="true"><path d="M5 16h86m-10-9 10 9-10 9"/><path class="trust-cross" d="m40 5 20 22m0-22L40 27"/></svg><span>Honesty not required</span></span>
|
||||
<span class="trust-party"><svg aria-hidden="true"><use href="#trust-client"/></svg><strong>Client</strong></span>
|
||||
</div>
|
||||
<h3>Authenticity during capture</h3>
|
||||
<p>The protocol lets an honest verifier reject a fabricated exchange, even when the client is dishonest.</p>
|
||||
</div>
|
||||
<div class="trust-diagram checks-without-trust">
|
||||
<div class="trust-relation" aria-label="Client need not trust Verifier for privacy during capture">
|
||||
<span class="trust-party"><svg aria-hidden="true"><use href="#trust-client"/></svg><strong>Client</strong></span>
|
||||
<span class="trust-edge"><svg viewBox="0 0 100 32" aria-hidden="true"><path d="M5 16h86m-10-9 10 9-10 9"/><path class="trust-cross" d="m40 5 20 22m0-22L40 27"/></svg><span>Honesty not required</span></span>
|
||||
<span class="trust-party"><svg aria-hidden="true"><use href="#trust-verifier"/></svg><strong>Verifier</strong></span>
|
||||
</div>
|
||||
<h3>Privacy during capture</h3>
|
||||
<p>The protocol keeps unrevealed URLs, cookies and page contents private even if the verifier tries to learn them.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="trust-diagram-note">These guarantees assume correct cryptography and software. Crossed arrows do not remove the reviewer’s reliance on an independent verifier or establish the truth of the website’s statements.</p>
|
||||
<div class="trust-list">
|
||||
<div><h3>The verifier operator</h3><p>You trust it to run the protocol correctly, sign only successful results, protect its signing key and not collude with the person making the capture. You also trust its clock for the displayed time.</p></div>
|
||||
<div><h3>The server’s identity</h3><p>You rely on TLS certificate validation and the trusted certificate authorities that bind a key to a hostname. This authenticates a domain—not a particular human author or the truth of a statement.</p></div>
|
||||
<div><h3>The checking software and key</h3><p>You trust the software doing the check, the device running it, and the source of the verifier’s public key. A key supplied only by the person presenting the evidence is not an independent trust anchor.</p></div>
|
||||
<div><h3>The cryptography and its implementation</h3><p>You rely on TLSNotary’s protocol, the signature and hash algorithms, and their correct implementation. This integration is experimental software.</p></div>
|
||||
</div>
|
||||
<h3>Reducing reliance on one verifier</h3>
|
||||
<p>
|
||||
Independent attestations from several verifiers can reduce the risk of
|
||||
one operator being compromised, coerced or collaborating with a dishonest
|
||||
client. A possible arrangement would use separately operated services in
|
||||
the United States, China, Russia, India, France and Brazil. Different
|
||||
jurisdictions and political interests can make coordinated interference
|
||||
harder, provided the operators also have independent management,
|
||||
administrators and signing keys. Geography alone does not establish
|
||||
independence.
|
||||
</p>
|
||||
<p>
|
||||
Universities, government agencies and other large institutions could
|
||||
provide these services with published operating policies, independent
|
||||
audits and formal procedures for exceptional access. Recorded approvals
|
||||
and auditable administrative actions can make bribery, social engineering
|
||||
or pressure harder to conceal. That paper trail should concern operation
|
||||
of the service, without recording users’ private URLs, cookies or page
|
||||
contents. Institutional size is useful only when these controls exist.
|
||||
</p>
|
||||
<p>
|
||||
Keeping content private also limits selective interference: the verifier
|
||||
cannot inspect hidden pages and decide to falsify every capture containing
|
||||
a particular phrase. Targeting hidden content would require additional
|
||||
information, such as a signal from a cooperating client. Visible metadata,
|
||||
including the destination hostname, can still be used to select sessions.
|
||||
</p>
|
||||
<details>
|
||||
<summary>Technical detail · What multiple verifiers would need to check</summary>
|
||||
<p>
|
||||
Each operator must independently authenticate the claimed exchange;
|
||||
several signatures on one operator’s unchecked assertion do not provide
|
||||
that independence. A reviewer must choose the required operators and
|
||||
acceptance rule in advance. Requiring every designated operator to
|
||||
attest to the same claim means all would have to fail or collude to
|
||||
accept a fabricated claim. A quorum requires compromising that quorum,
|
||||
not every operator. Shared software flaws remain a common risk.
|
||||
</p>
|
||||
<p>
|
||||
Separate captures can return different content. Their receipts must be
|
||||
checked against the actual request and response each authenticates,
|
||||
rather than counted as confirmations of identical bytes automatically.
|
||||
This is a possible deployment model: the current ArchiveBox plugin uses
|
||||
one verifier per capture and does not implement multi-verifier consensus.
|
||||
TLSNotary describes the underlying
|
||||
<a href="https://tlsnotary.org/docs/faq/#why-can-a-verifier-trust-a-tlsnotary-proof">trust in an independent notary</a>.
|
||||
</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>For evidence reviewers · What this receipt does not establish</summary>
|
||||
<ul>
|
||||
<li><strong>The whole page:</strong> it covers the accompanying response, not every file in an ArchiveBox snapshot or an ArchiveWeb.page WACZ.</li>
|
||||
<li><strong>The exact requested URL or account:</strong> the signed payload names the server, not the private request path, query, cookies or user identity.</li>
|
||||
<li><strong>Truth or authorship:</strong> a server can publish false statements, display user-supplied text or be compromised.</li>
|
||||
<li><strong>A publication date:</strong> the receipt’s time is the verifier’s issuance time, not when the website first published the content or a separately certified time.</li>
|
||||
<li><strong>Automatic legal acceptance:</strong> admissibility and weight still depend on the applicable rules, context and supporting evidence.</li>
|
||||
</ul>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Technical detail · The signed receipt and delegated verification</summary>
|
||||
<p>
|
||||
During capture, the upstream TLSNotary verifier checks the protocol
|
||||
and commitment proof. ArchiveBox’s gateway signs a payload containing
|
||||
the authenticated hostname, its issuance time, the response range and
|
||||
the commitment. Later verification checks that signature and opens
|
||||
the commitment using your saved response. It does <em>not</em> replay
|
||||
the interactive proof or independently repeat certificate validation.
|
||||
</p>
|
||||
<p>
|
||||
This is an ArchiveBox receipt backed by a verifier’s attestation,
|
||||
not a website-issued signature or a standalone TLSNotary presentation.
|
||||
A dishonest operator, a compromised signing key or collusion could
|
||||
produce a false receipt that passes the local check. Running your own
|
||||
verifier gives you operational control, but does not make your
|
||||
self-issued receipts independent evidence to someone who distrusts you.
|
||||
See <a href="https://tlsnotary.org/docs/faq/#what-is-the-role-of-a-notary">TLSNotary’s explanation of delegated trust</a>.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="independent-check" aria-labelledby="check-title">
|
||||
<span class="eyebrow">05 · Independent verification</span>
|
||||
<h2 id="check-title">Required files and verification procedure</h2>
|
||||
<div class="file-pair">
|
||||
<div><code>response.http</code><p>The original response bytes, stored once. This is the content being authenticated.</p></div>
|
||||
<span aria-hidden="true">+</span>
|
||||
<div><code>receipt.json</code><p>The signed commitment and the data needed to check it against the response. It contains no copy of the page.</p></div>
|
||||
</div>
|
||||
<p>
|
||||
You do not need this public service to stay online to check an existing
|
||||
capture. Preserve both files unchanged, the verification software, and
|
||||
an independently obtained copy of the verifier’s public key. You can
|
||||
give those to another reviewer to check on their own device.
|
||||
</p>
|
||||
<details>
|
||||
<summary>Developer instructions · Verify locally with Node.js</summary>
|
||||
<p>
|
||||
Review and obtain <a href="https://github.com/ArchiveBox/abx-plugins/blob/main/abx_plugins/plugins/tlsnotary/server/web/verify.mjs">verify.mjs from the source repository</a>.
|
||||
Place it alongside the two capture files. With a current Node.js
|
||||
version supporting Web Crypto and <code>DecompressionStream</code>,
|
||||
run the command below from that directory. Replace the placeholder
|
||||
with the base64 SPKI public key you independently trust for the
|
||||
verifier that issued the receipt.
|
||||
</p>
|
||||
<pre class="code-example"><code>TRUSTED_KEY='INDEPENDENTLY_TRUSTED_BASE64_SPKI_KEY' node --input-type=module <<'JS'
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { verifyReceipt } from './verify.mjs';
|
||||
|
||||
const receipt = JSON.parse(readFileSync('receipt.json', 'utf8'));
|
||||
const response = new Uint8Array(readFileSync('response.http'));
|
||||
const result = await verifyReceipt(receipt, response, process.env.TRUSTED_KEY);
|
||||
console.log('Verified:', result.server_name);
|
||||
console.log('Verifier time:', new Date(result.time * 1000).toISOString());
|
||||
console.log('Authenticated bytes:', response.length);
|
||||
JS</code></pre>
|
||||
<p>
|
||||
The check validates an Ed25519 signature over the decoded payload,
|
||||
recomputes <code>SHA-256(response.http bytes || 16-byte blinder)</code>,
|
||||
matches the signed byte range and requires a complete successful HTTP
|
||||
response. The blinder is random data that hides the commitment from
|
||||
guessing while the response is private. It is saved locally in the receipt.
|
||||
</p>
|
||||
<p>
|
||||
A green result on a website controlled by an untrusted party is not
|
||||
independent verification. For contested evidence, review the code and
|
||||
establish the key through a separate trusted channel. Keep the original
|
||||
key when an operator rotates keys; newly published keys do not
|
||||
automatically authenticate older receipts.
|
||||
</p>
|
||||
</details>
|
||||
<p class="source-note">
|
||||
For self-hosted verification, the plugin includes
|
||||
<a href="https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/tlsnotary/server">Docker Compose, example configuration and setup instructions</a>.
|
||||
Captures are opt-in because the cryptographic work adds time and bandwidth.
|
||||
</p>
|
||||
</section>
|
||||
<footer class="guide-footer">
|
||||
Built with <a href="https://tlsnotary.org/">TLSNotary</a> ·
|
||||
<a href="https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/tlsnotary">Inspect the ArchiveBox integration</a> ·
|
||||
<a href="#">Back to verification ↑</a>
|
||||
</footer>
|
||||
</article>
|
||||
</main>
|
||||
<!-- BEGIN ARCHIVEBOX FOOTER — generated by monorepo/site-chrome/sync.py -->
|
||||
<footer class="abx-footer" aria-label="ArchiveBox ecosystem">
|
||||
<div class="abx-footer-intro"><div><a class="abx-brand" href="https://archivebox.io/"><svg class="abx-logo" viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="10" fill="#9b2854"/><path d="M10 14h20v17H10z" fill="#fff4ed"/><path d="M8 9h24v6H8z" fill="#fff4ed"/><path d="M16 19h8v3h-8z" fill="#9b2854"/><path d="M12 31V17" stroke="#9b2854" stroke-width="2"/></svg><span>ArchiveBox</span></a><p class="abx-tagline">Open-source tools for keeping a lasting copy of the web.<br>Your data. Your devices. Your archive.</p></div><p class="abx-footer-motto">A home for the web<br>you want to keep.</p></div>
|
||||
<div class="abx-footer-grid"><section class="abx-footer-column"><h2>Apps & tools</h2><ul><li><a href="https://archivebox.io/">ArchiveBox Server</a><a class="abx-source" href="https://github.com/ArchiveBox/ArchiveBox" aria-label="ArchiveBox Server source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://app.archivebox.io/#get-started">ArchiveBox for macOS & iOS</a><a class="abx-source" href="https://github.com/ArchiveBox/ios-archivebox" aria-label="ArchiveBox for macOS & iOS source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://extension.archivebox.io/">ArchiveBox Browser Extension</a><a class="abx-source" href="https://github.com/ArchiveBox/archivebox-browser-extension" aria-label="ArchiveBox Browser Extension source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://plugins.archivebox.io/">ArchiveBox Plugin Library</a><a class="abx-source" href="https://github.com/ArchiveBox/abx-plugins" aria-label="ArchiveBox Plugin Library source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://abx-dl.archivebox.io/">One-shot abx-dl CLI</a><a class="abx-source" href="https://github.com/ArchiveBox/abx-dl" aria-label="One-shot abx-dl CLI source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://app.archivebox.io/#your-server-your-choice">ArchiveBox Server.app</a><a class="abx-source" href="https://github.com/ArchiveBox/ios-archivebox" aria-label="ArchiveBox Server.app source on GitHub" title="View source on GitHub"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 .75a11.25 11.25 0 0 0-3.56 21.92c.56.1.77-.24.77-.54v-2.1c-3.14.68-3.8-1.33-3.8-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 1.72 2.63 1.22 3.27.93.1-.73.39-1.22.71-1.5-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.1 1.15a10.8 10.8 0 0 1 5.64 0c2.15-1.46 3.1-1.15 3.1-1.15.62 1.55.23 2.7.12 2.98.72.79 1.16 1.8 1.16 3.02 0 4.32-2.64 5.27-5.15 5.55.4.35.76 1.03.76 2.08v3.11c0 .3.2.65.77.54A11.25 11.25 0 0 0 12 .75Z"/></svg></a></li><li><a href="https://abxpkg.archivebox.io/">Package &Line truncated
|
||||
|
||||
<div class="abx-footer-bottom"><p>Built in the open. Supported by our community.</p><p><a href="https://github.com/ArchiveBox">GitHub organization</a> · <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview">Security & privacy</a> · <a href="https://archivebox.io">ArchiveBox.io ↗</a></p></div>
|
||||
</footer>
|
||||
<script type="module" src="site-chrome.mjs"></script>
|
||||
<!-- END ARCHIVEBOX FOOTER -->
|
||||
<script type="module" src="app.mjs"></script>
|
||||
<script type="module" src="diagram.mjs?v=13ea3b156c9f"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user