Compare commits

...
337 Commits
Author SHA1 Message Date
github-actions[bot] d9b648e6a9 release: v0.9.35rc136 2026-07-20 23:23:21 +00:00
Nick Sweeting 81ea053f97 Avoid empty binary resolution buses 2026-07-20 16:22:53 -07:00
github-actions[bot] 1a9796bcaa release: v0.9.35rc135 2026-07-20 22:56:39 +00:00
Nick Sweeting 5a30ab0451 Scope install status to requested binaries 2026-07-20 15:54:33 -07:00
github-actions[bot] 765aef56e9 release: v0.9.35rc134 2026-07-20 22:25:37 +00:00
Nick Sweeting ee342a0313 Use supported uv group install in lint CI 2026-07-20 15:23:20 -07:00
github-actions[bot] 39e1a77de7 release: v0.9.35rc133 2026-07-20 22:22:55 +00:00
Nick Sweeting 70fb360bae Preserve feature branch test coverage 2026-07-20 15:21:27 -07:00
github-actions[bot] 95f8f0fbd8 release: v0.9.35rc132 2026-07-20 22:21:07 +00:00
Nick Sweeting 582499be86 Deduplicate ArchiveBox release CI 2026-07-20 15:19:32 -07:00
github-actions[bot] 84624844aa release: v0.9.35rc131 2026-07-20 22:14:36 +00:00
Nick Sweeting ba405c5f1f Keep PyPI publishing in release state 2026-07-20 15:06:51 -07:00
github-actions[bot] 1481dedf31 release: v0.9.35rc130 2026-07-20 22:01:31 +00:00
Nick Sweeting 56b0abfb78 Use fixed binary projection releases 2026-07-20 15:00:10 -07:00
github-actions[bot] 6ddf9fcd84 release: v0.9.35rc129 2026-07-20 21:26:39 +00:00
Nick Sweeting 1ca93ee0f6 Use corrected unified runtime releases 2026-07-20 14:23:03 -07:00
github-actions[bot] 2d5760642a release: v0.9.35rc128 2026-07-20 20:09:34 +00:00
Nick Sweeting ecf35c33d6 Pin unified runtime dependency releases 2026-07-20 13:08:38 -07:00
github-actions[bot] 713eb61829 release: v0.9.35rc127 2026-07-20 19:53:24 +00:00
Nick Sweeting d92b2649bd Use configured PyPI token for package publishing 2026-07-20 12:52:58 -07:00
github-actions[bot] fcc9457825 release: v0.9.35rc126 2026-07-20 19:50:42 +00:00
Nick Sweeting 01f5666dbc Route runtime binaries through abxpkg 2026-07-20 12:50:14 -07:00
github-actions[bot] e17248b5f1 release: v0.9.35rc125 2026-07-20 15:32:29 +00:00
Nick Sweeting d0c39fdf2c Pin corrected hook lifecycle releases 2026-07-20 08:32:01 -07:00
github-actions[bot] 637d2ec210 release: v0.9.35rc124 2026-07-20 15:09:27 +00:00
Nick Sweeting ed7e3f0ec8 Pin restored hook lifecycle releases 2026-07-20 08:08:47 -07:00
github-actions[bot] f1a574192a release: v0.9.35rc124 2026-07-20 15:05:54 +00:00
Nick Sweeting a5ae6e9022 Restore existing hook readiness boundary 2026-07-20 08:05:25 -07:00
github-actions[bot] 5cb50f5c61 release: v0.9.35rc123 2026-07-20 08:44:16 +00:00
Nick Sweeting 829dd95fe2 Ship deterministic downloader releases 2026-07-20 01:43:26 -07:00
github-actions[bot] 05447232d0 release: v0.9.35rc122 2026-07-20 08:32:27 +00:00
Nick Sweeting 98b2202e0a Update downloader dependency releases 2026-07-20 01:31:53 -07:00
Nick Sweeting 48bef35706 Restore deterministic background lifecycle tests 2026-07-20 01:31:28 -07:00
github-actions[bot] a6684c773d release: v0.9.35rc121 2026-07-20 07:04:50 +00:00
Nick Sweeting be9b826503 Wait for imported root snapshot completion 2026-07-20 00:04:22 -07:00
github-actions[bot] 67a1bde963 release: v0.9.35rc120 2026-07-20 06:25:52 +00:00
Nick Sweeting 15f85be84b Assert recovered snapshots record completion 2026-07-19 23:25:21 -07:00
github-actions[bot] 8ffb0ef986 release: v0.9.35rc119 2026-07-20 05:59:17 +00:00
Nick Sweeting 2cd0c88cd0 Finalize parser output before sealing snapshots 2026-07-19 22:58:05 -07:00
github-actions[bot] 5bc028eb76 release: v0.9.35rc118 2026-07-20 05:12:22 +00:00
Nick Sweeting 9a2bde2252 Keep import parser tests offline 2026-07-19 22:08:58 -07:00
github-actions[bot] 11fcda6412 release: v0.9.35rc117 2026-07-20 04:22:05 +00:00
Nick Sweeting 66907dc81f Test compose with the built image and normal add flow 2026-07-19 21:20:11 -07:00
github-actions[bot] a9273e6627 release: v0.9.35rc116 2026-07-20 03:59:12 +00:00
Nick Sweeting 6cf6256277 Wait for Docker service health before assertions 2026-07-19 20:53:37 -07:00
github-actions[bot] 52ed0eac5e release: v0.9.35rc115 2026-07-20 03:36:20 +00:00
Nick Sweeting 3f52a7ad2b Execute documented verification commands in CI 2026-07-19 20:28:16 -07:00
github-actions[bot] 070628e2db release: v0.9.35rc114 2026-07-20 03:09:56 +00:00
Nick Sweeting 6e915c11aa Restore complete deterministic ArchiveBox CI 2026-07-19 20:08:29 -07:00
github-actions[bot] b67088016f release: v0.9.35rc113 2026-07-19 15:55:06 +00:00
Nick Sweeting ee0e3eb43c Enforce the sealed search maintenance boundary 2026-07-19 08:54:32 -07:00
github-actions[bot] aab13e2d9f release: v0.9.35rc112 2026-07-19 04:23:33 +00:00
Nick Sweeting 0843fc67e4 Restore complete deterministic CI coverage 2026-07-18 21:22:53 -07:00
github-actions[bot] d132fe5b71 release: v0.9.35rc111 2026-07-19 00:17:06 +00:00
Nick Sweeting b169d98614 Pin final host-first provider fixes 2026-07-18 17:12:26 -07:00
github-actions[bot] 47a6c6272a release: v0.9.35rc110 2026-07-19 00:00:19 +00:00
Nick Sweeting 1106b84d99 Treat zombie test processes as exited 2026-07-18 16:59:57 -07:00
github-actions[bot] 9b85d82a62 release: v0.9.35rc109 2026-07-18 23:18:33 +00:00
Nick Sweeting 5be2e78eea Require corrected provider releases 2026-07-18 16:17:54 -07:00
github-actions[bot] 2f6be9f39f release: v0.9.35rc108 2026-07-18 22:53:04 +00:00
Nick Sweeting c879af3712 Update search and binary provider fixes 2026-07-18 15:52:26 -07:00
github-actions[bot] 610f5e0942 release: v0.9.35rc107 2026-07-18 22:40:07 +00:00
Nick Sweeting a82a94ffa7 Make recursive crawl coverage deterministic 2026-07-18 15:39:40 -07:00
github-actions[bot] 4105b45813 release: v0.9.35rc106 2026-07-18 21:20:12 +00:00
Nick Sweeting 9d6f167a03 Use isolated Chrome extension environments 2026-07-18 14:19:44 -07:00
github-actions[bot] e2445998b5 release: v0.9.35rc105 2026-07-18 20:48:15 +00:00
Nick Sweeting 21953b8934 Update ABX packages after CI fixes 2026-07-18 13:47:32 -07:00
github-actions[bot] 6bccb4ca39 release: v0.9.35rc104 2026-07-18 17:44:33 +00:00
Nick Sweeting 6e2853fbf0 Isolate recursive crawl plugin runtime paths 2026-07-18 10:44:10 -07:00
github-actions[bot] f80e6cea2b release: v0.9.35rc103 2026-07-15 18:29:42 +00:00
Nick Sweeting 2a0f2405b0 Update SECURITY.md 2026-07-15 14:29:17 -04:00
github-actions[bot] 2d7d22776b release: v0.9.35rc102 2026-06-24 23:06:21 +00:00
Nick Sweeting a5eefee17d Retry interrupted hook projections 2026-06-24 16:05:34 -07:00
github-actions[bot] 6e82c55371 release: v0.9.35rc101 2026-06-24 20:47:50 +00:00
Nick Sweeting cbeeda9210 Keep plugin dependency selection server-side 2026-06-24 13:47:11 -07:00
github-actions[bot] c85149db13 release: v0.9.35rc100 2026-06-24 20:20:04 +00:00
Nick Sweeting cfb0f7c5d9 Keep search backend selection independent of plugin filters 2026-06-24 13:19:25 -07:00
github-actions[bot] 99282e739e release: v0.9.35rc99 2026-06-24 19:36:45 +00:00
Nick Sweeting d9b7052d5a Use abx-dl plugin dependency pruning 2026-06-24 12:36:00 -07:00
github-actions[bot] 533101033f release: v0.9.35rc98 2026-06-24 19:13:21 +00:00
Nick Sweeting ca2254d55b Centralize plugin selection dependency resolution 2026-06-24 12:12:52 -07:00
github-actions[bot] 598c5a9d19 release: v0.9.35rc97 2026-06-24 18:41:05 +00:00
Nick Sweeting 0a9a32b6e8 Respect crawl plugin selection over env defaults 2026-06-24 11:40:30 -07:00
github-actions[bot] 74b24fa8b1 release: v0.9.35rc96 2026-06-24 18:22:07 +00:00
Nick Sweeting cfbb2ba6cf Update install docs for dev uv packages 2026-06-24 11:21:36 -07:00
github-actions[bot] 928da17a29 release: v0.9.35rc95 2026-06-24 18:16:12 +00:00
Nick Sweeting bf1d7ebe15 Limit disabled extractor plugin narrowing to version tests 2026-06-24 11:15:43 -07:00
github-actions[bot] 23d215591e release: v0.9.35rc94 2026-06-24 17:58:32 +00:00
Nick Sweeting f61c8ad5ea Keep disabled extractor CLI tests from resolving plugins 2026-06-24 10:58:08 -07:00
github-actions[bot] b30f097c17 release: v0.9.35rc93 2026-06-24 17:42:26 +00:00
Nick Sweeting 28bce9ebaf Honor explicit plugin enable aliases over plugin selection 2026-06-24 10:41:59 -07:00
github-actions[bot] bd451e2fbc release: v0.9.35rc92 2026-06-24 17:14:50 +00:00
Nick Sweeting 8284321510 Resolve plugin config aliases in ArchiveBox config 2026-06-24 10:14:28 -07:00
github-actions[bot] b0a7f2d1b8 release: v0.9.35rc91 2026-06-24 16:51:57 +00:00
Nick Sweeting 3fa3794bf3 Skip disabled plugin binary checks in version 2026-06-24 09:51:28 -07:00
github-actions[bot] 1d70e5a4bc release: v0.9.35rc90 2026-06-24 16:29:07 +00:00
Nick Sweeting 11d962406d Update setup script for dev uv installs 2026-06-24 09:28:32 -07:00
github-actions[bot] 785933ee51 release: v0.9.35rc89 2026-06-24 16:08:24 +00:00
Nick Sweeting c91fea7773 Fix unreadable persona profile handling 2026-06-24 09:07:49 -07:00
github-actions[bot] 369d41f26b release: v0.9.35rc88 2026-06-22 11:13:52 +00:00
Nick Sweeting 83188f70e6 Fix stuck snapshots with disabled queued plugins 2026-06-22 04:13:22 -07:00
github-actions[bot] e9cb936c81 release: v0.9.35rc87 2026-06-22 10:48:01 +00:00
Nick Sweeting 0e75c70832 Use locked versions for related repo CI clones 2026-06-22 03:47:33 -07:00
github-actions[bot] 81d91adb49 release: v0.9.35rc86 2026-06-22 10:34:58 +00:00
Nick Sweeting 63e1eaca8c Bump abxpkg runtime dependency 2026-06-22 03:34:32 -07:00
github-actions[bot] 1d328397ab release: v0.9.35rc85 2026-06-22 10:14:42 +00:00
Nick Sweeting 682b2e25b1 Bump opencode plugin session route 2026-06-22 03:13:30 -07:00
github-actions[bot] 28d5052abb release: v0.9.35rc84 2026-06-22 09:51:04 +00:00
Nick Sweeting b0a9f388cf Bump abxpkg and abx-plugins runtime deps 2026-06-22 02:49:36 -07:00
github-actions[bot] ea0315b102 release: v0.9.35rc83 2026-06-22 07:53:57 +00:00
Nick Sweeting f0834ea1b5 Bump abxpkg runtime env fix 2026-06-22 00:53:17 -07:00
github-actions[bot] 481e4be1a1 release: v0.9.35rc82 2026-06-22 07:04:32 +00:00
Nick Sweeting 276d98b5c5 Use shared opencode plugin release 2026-06-22 00:01:24 -07:00
github-actions[bot] 8aba8e98de release: v0.9.35rc81 2026-06-22 06:27:33 +00:00
Nick Sweeting 1dd2a538b1 Bump abx-plugins floor for pnpm isolation 2026-06-21 23:26:47 -07:00
github-actions[bot] 64e7dc92e8 release: v0.9.35rc80 2026-06-22 06:12:58 +00:00
Nick Sweeting 3d4978f89d Align internal package floors 2026-06-21 23:06:53 -07:00
github-actions[bot] 1efcbbf631 release: v0.9.35rc79 2026-06-22 06:05:11 +00:00
Nick Sweeting 7516559c54 Bump abx-plugins floor for OpenCode default 2026-06-21 23:04:46 -07:00
github-actions[bot] 9976993ba6 release: v0.9.35rc78 2026-06-22 05:59:10 +00:00
Nick Sweeting 6fa3b5c9bb Test disabled OpenCode navigation explicitly 2026-06-21 22:58:47 -07:00
github-actions[bot] 395beea104 release: v0.9.35rc77 2026-06-22 05:04:25 +00:00
Nick Sweeting 7712a9803f Hide agent links when OpenCode is disabled 2026-06-21 22:03:44 -07:00
github-actions[bot] 60da688218 release: v0.9.35rc76 2026-06-22 04:33:47 +00:00
Nick Sweeting 095c0e5112 fix admin snapshot changelist preview summaries 2026-06-21 21:33:14 -07:00
github-actions[bot] f40c5629a5 release: v0.9.35rc75 2026-06-21 18:48:15 +00:00
Nick Sweeting 4982b041c6 Scope recursive crawl plugin matrix 2026-06-21 11:47:51 -07:00
github-actions[bot] 5679a2cfe7 release: v0.9.35rc74 2026-06-21 18:21:41 +00:00
Nick Sweeting 711b94161a Limit legacy config version probe 2026-06-21 11:21:17 -07:00
github-actions[bot] 7b7d57c202 release: v0.9.35rc73 2026-06-21 18:07:44 +00:00
Nick Sweeting 676c561372 Stabilize recursive crawl CI assertions 2026-06-21 11:07:21 -07:00
github-actions[bot] 3d2f926a82 release: v0.9.35rc72 2026-06-21 16:41:44 +00:00
Nick Sweeting 4e21be2b1a Read add-view runtime tags from archive DB 2026-06-21 09:41:16 -07:00
github-actions[bot] 6ac4efc864 release: v0.9.35rc71 2026-06-21 16:21:39 +00:00
Nick Sweeting 67959c9013 Pin chrome plugin CI fixes 2026-06-21 09:21:13 -07:00
github-actions[bot] 50cb4f8a8d release: v0.9.35rc70 2026-06-21 15:53:43 +00:00
Nick Sweeting 6b362fbb1a Pin corrected abx-plugins CI fixes 2026-06-21 08:53:19 -07:00
github-actions[bot] 79873adc3c release: v0.9.35rc69 2026-06-21 15:29:42 +00:00
Nick Sweeting 830422c6c6 Pin abx-plugins remote disconnect fix 2026-06-21 08:29:00 -07:00
github-actions[bot] 67a8418809 release: v0.9.35rc68 2026-06-21 15:26:36 +00:00
Nick Sweeting 75d5c53d36 Wait for all CLI import snapshots before restart 2026-06-21 08:26:10 -07:00
github-actions[bot] 8bd69a0f0e release: v0.9.35rc67 2026-06-21 15:03:28 +00:00
Nick Sweeting a350f58e44 Mark hook subprocesses as abxpkg scripts 2026-06-21 08:03:00 -07:00
github-actions[bot] c35c808da8 release: v0.9.35rc66 2026-06-21 14:51:10 +00:00
Nick Sweeting 82724e99dc Tighten public preview screenshot assertions 2026-06-21 07:50:45 -07:00
github-actions[bot] ce8a4790ae release: v0.9.35rc65 2026-06-21 14:31:03 +00:00
Nick Sweeting 614a5bd095 Prefer explicit hook library overrides 2026-06-21 07:30:32 -07:00
github-actions[bot] 215f7f5925 release: v0.9.35rc64 2026-06-21 14:10:05 +00:00
Nick Sweeting 74399c5456 Use maintenance runner for retention cleanup test 2026-06-21 07:09:35 -07:00
github-actions[bot] 284a9fde3d release: v0.9.35rc63 2026-06-21 13:50:09 +00:00
Nick Sweeting fee4bf13cb Handle sealed crawl pause race in API test 2026-06-21 06:49:38 -07:00
github-actions[bot] be9c77320c release: v0.9.35rc62 2026-06-21 13:37:31 +00:00
Nick Sweeting 1f0b2795bb Treat Chrome zombie cleanup as nonfatal 2026-06-21 06:37:00 -07:00
github-actions[bot] 6382722d2f release: v0.9.35rc61 2026-06-21 13:26:23 +00:00
Nick Sweeting 4be16fc00e Pin nonfatal Chrome cleanup plugin 2026-06-21 06:25:50 -07:00
github-actions[bot] dcccafc00e release: v0.9.35rc60 2026-06-21 13:09:33 +00:00
Nick Sweeting 3fa4fd5d18 Fix runtime hook config regressions 2026-06-21 06:09:04 -07:00
github-actions[bot] ef6f616b37 release: v0.9.35rc59 2026-06-21 12:42:53 +00:00
Nick Sweeting 0e0759a680 Skip binary probes outside data dirs 2026-06-21 05:42:26 -07:00
github-actions[bot] 947c340e74 release: v0.9.35rc58 2026-06-21 12:33:06 +00:00
Nick Sweeting 6f6eb92a90 Pin abx-plugins test fixes 2026-06-21 05:32:42 -07:00
github-actions[bot] 0064e20805 release: v0.9.35rc57 2026-06-21 12:24:51 +00:00
Nick Sweeting 59a956bf9f Show dynamic DB binaries in version output 2026-06-21 05:24:15 -07:00
github-actions[bot] afd2fac50b release: v0.9.35rc56 2026-06-21 12:01:57 +00:00
Nick Sweeting edb6a263e9 Disable package age gate in parallel CI installs 2026-06-21 05:01:33 -07:00
github-actions[bot] 1c00719f68 release: v0.9.35rc55 2026-06-21 11:55:23 +00:00
Nick Sweeting e30a4f2a1c Use fixed abx-plugins daemon test tag in CI 2026-06-21 04:54:45 -07:00
github-actions[bot] ba198e000b release: v0.9.35rc54 2026-06-21 11:38:12 +00:00
Nick Sweeting 24ed99a65d Use fixed abx-plugins cookie tag in CI 2026-06-21 04:37:46 -07:00
github-actions[bot] 2cc0035a40 release: v0.9.35rc53 2026-06-21 11:23:24 +00:00
Nick Sweeting b41f12ee38 Use fixed abx-plugins screenshot tag in CI 2026-06-21 04:22:48 -07:00
github-actions[bot] 714caded9c release: v0.9.35rc52 2026-06-21 11:15:03 +00:00
Nick Sweeting 200da8a167 Use fixed abx-plugins ublock tag in CI 2026-06-21 04:14:37 -07:00
github-actions[bot] 9b02796049 release: v0.9.35rc51 2026-06-21 10:33:57 +00:00
Nick Sweeting 062e4e12fd Use fixed abxpkg tag in CI 2026-06-21 03:33:20 -07:00
github-actions[bot] 8d9702b423 release: v0.9.35rc50 2026-06-21 10:20:24 +00:00
Nick Sweeting ec728caeab Use fixed abx-plugins tag in CI 2026-06-21 03:19:45 -07:00
Nick Sweeting 7bee5417e7 Stabilize parallel plugin CI setup 2026-06-21 03:18:18 -07:00
github-actions[bot] 580b49ab14 release: v0.9.35rc49 2026-06-21 10:03:04 +00:00
Nick Sweeting 128e6aeff1 pin CI helper repo clones 2026-06-21 03:02:37 -07:00
github-actions[bot] d8f0e0b472 release: v0.9.35rc48 2026-06-21 09:53:59 +00:00
Nick Sweeting 76da863a02 fix docker image commit metadata 2026-06-21 02:53:28 -07:00
github-actions[bot] a0d032ead3 release: v0.9.35rc47 2026-06-21 09:41:45 +00:00
Nick Sweeting 9dbeece35b fix version command exit status 2026-06-21 02:41:18 -07:00
github-actions[bot] 66835dcfb8 release: v0.9.35rc46 2026-06-21 09:30:21 +00:00
Nick Sweeting d259ac8095 fix machine network interface identity 2026-06-21 02:29:53 -07:00
Nick Sweeting 00e818018a release: v0.9.35rc46 2026-06-21 01:44:30 -07:00
github-actions[bot] bbc12762c0 release: v0.9.35rc45 2026-06-21 02:17:03 +00:00
Nick Sweeting aa2df34d02 Update SECURITY.md with XSS/CSRF information
Clarify security risks and update information on CSRF/XSS protections.
2026-06-20 22:16:38 -04:00
github-actions[bot] e6c774f637 release: v0.9.35rc44 2026-06-19 16:57:38 +00:00
Nick Sweeting d7edaa04d6 fix: pass archive_results=[] to LiveBusUI.print_summary() in _run_install (#1826)
Fixes #1825

`abx-dl` updated `LiveBusUI.print_summary()` to require
`archive_results` as a keyword-only argument, but `_run_install` in
`runner.py` wasn't updated to match. This caused a `TypeError` at the
end of `archivebox init --install` after all binaries had already
installed successfully — the install itself completed fine, only the
summary display crashed.

Fix: pass `archive_results=[]` since the install path has no archive
results.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes a crash at the end of `archivebox init --install` by passing
`archive_results=[]` to `LiveBusUI.print_summary()` in `_run_install`.
Aligns with the new keyword-only signature in `abx-dl`, so installs
complete and the summary renders without error.

<sup>Written for commit c28e3a9810.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/ArchiveBox/ArchiveBox/pull/1826?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-06-19 09:57:16 -07:00
slmingolandClaude Sonnet 4.6 c28e3a9810 fix: pass archive_results=[] to LiveBusUI.print_summary() in _run_install
abx-dl updated LiveBusUI.print_summary() to require archive_results as a
keyword-only argument, causing TypeError at the end of archivebox init --install
after all binaries had already installed successfully.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 00:08:49 -04:00
github-actions[bot] bf8b2f0a90 release: v0.9.35rc43 2026-06-15 18:46:52 +00:00
Nick Sweeting 3a542f9b34 Revise SECURITY.md for improved clarity and structure
Updated the security policy document to improve clarity and organization of information regarding security practices, CVEs, and reporting vulnerabilities.
2026-06-15 11:46:05 -07:00
github-actions[bot] 915d9420ff release: v0.9.35rc42 2026-06-15 18:40:28 +00:00
Nick Sweeting c076737e4a Update SECURITY.md 2026-06-15 11:39:50 -07:00
github-actions[bot] c8eaeb531b release: v0.9.35rc41 2026-06-15 04:03:13 +00:00
Nick Sweeting 78fb23b47f test: update recursive crawl expectations 2026-06-14 21:02:35 -07:00
github-actions[bot] 40a2bc6f4c release: v0.9.35rc40 2026-06-15 02:36:52 +00:00
Nick Sweeting 4d4a4a96b6 fix docker commit metadata lookup 2026-06-14 19:32:24 -07:00
github-actions[bot] 4377c208d8 release: v0.9.35rc39 2026-06-15 00:30:46 +00:00
Nick Sweeting a729790b8e chore: publish local release fixes 2026-06-14 17:29:38 -07:00
github-actions[bot] 322b4cbe3f release: v0.9.35rc38 2026-06-14 18:45:53 +00:00
Nick Sweeting 8b57085827 chore: commit local archivebox changes 2026-06-14 11:44:38 -07:00
Nick Sweeting f3c8460767 ci: use pypi version for archivebox rc bumps 2026-06-14 11:43:38 -07:00
github-actions[bot] 19a2e44c5b release: v0.9.35rc37 2026-06-14 18:19:49 +00:00
Nick Sweeting e63f24dc71 ci: require trusted publishing for pypi 2026-06-14 11:19:03 -07:00
github-actions[bot] 0064e31ebf release: v0.9.35rc37 2026-06-14 17:58:46 +00:00
Nick Sweeting c17c08c09f fix: resolve prebaked docker binaries via env provider 2026-06-14 10:58:17 -07:00
github-actions[bot] fe4e0c7639 release: v0.9.35rc37 2026-06-14 17:52:28 +00:00
Nick Sweeting 88d46a302f fix: verify docker binaries through abxpkg 2026-06-14 10:51:59 -07:00
github-actions[bot] d1aec6ed3b release: v0.9.35rc37 2026-06-14 17:48:25 +00:00
Nick Sweeting cb5c822eb4 ci: use pypi trusted publishing in release state 2026-06-14 10:47:44 -07:00
github-actions[bot] a25a33eba1 release: v0.9.35rc37 2026-06-14 17:44:59 +00:00
Nick Sweeting 5ac48301d6 fix: own prebaked docker lib cache 2026-06-14 10:44:29 -07:00
github-actions[bot] 6f4b53bfca release: v0.9.35rc37 2026-06-14 17:36:32 +00:00
Nick Sweeting 53d5d0ece4 ci: verify full install for built packages 2026-06-14 10:36:02 -07:00
github-actions[bot] 311d333b4b release: v0.9.35rc37 2026-06-14 16:35:15 +00:00
Nick Sweeting 6f321797c9 fix: publish dev build with updated package deps 2026-06-14 09:34:32 -07:00
github-actions[bot] 79df97b99d release: v0.9.35rc36 2026-06-14 16:29:19 +00:00
Nick Sweeting 88a92b6548 fix: keep version informational without installed binaries 2026-06-14 09:28:41 -07:00
Nick Sweeting 541b3fec87 release: archivebox 0.9.35rc36 2026-06-14 09:13:02 -07:00
Nick Sweeting 0ddda66ee3 release: archivebox 0.9.35rc35 2026-06-14 08:55:48 -07:00
Nick Sweeting b768e1767e release: archivebox 0.9.35rc34 2026-06-14 08:41:37 -07:00
Nick Sweeting 6bf3ce989d release: archivebox 0.9.35rc33 2026-06-14 08:08:16 -07:00
Nick Sweeting 24c123e331 ci: validate archivebox docker image metadata 2026-06-14 07:58:06 -07:00
Nick Sweeting 3596325733 fix: keep docker init cache out of archivebox home 2026-06-14 02:51:51 -07:00
Nick Sweeting 8baec62daa fix: keep docker build cache out of archivebox home 2026-06-14 02:47:00 -07:00
Nick Sweeting 27d39749bf release: archivebox 0.9.35rc32 2026-06-14 02:07:03 -07:00
Nick Sweeting 9a6d1b0410 fix: wait for abx-dl docker base in dev release 2026-06-14 01:43:20 -07:00
Nick Sweeting 3adb214b42 release: archivebox 0.9.35rc31 2026-06-14 01:39:23 -07:00
Nick Sweeting d3ee9512e5 fix: pin archivebox docker base to abx-dl dependency 2026-06-14 01:07:24 -07:00
Nick Sweeting 12deb40daa fix: keep docker install caches out of runtime image 2026-06-14 00:52:58 -07:00
Nick Sweeting fa5c918c2f release: archivebox 0.9.35rc30 2026-06-14 00:43:10 -07:00
Nick Sweeting 11c9f8c163 fix: improve snapshot preview rendering 2026-06-14 00:16:21 -07:00
Nick Sweeting 18e3580dcd fix: harden reflected metadata rendering 2026-06-14 00:06:35 -07:00
Nick Sweeting 6ae56b7366 release: archivebox 0.9.35rc28 2026-06-13 23:53:45 -07:00
Nick Sweeting 73ec255855 fix: harden admin metadata surfaces 2026-06-13 23:44:59 -07:00
Nick Sweeting faf2109642 test: tighten tag field form assertions 2026-06-13 23:21:24 -07:00
Nick Sweeting 1dbde4776f release: archivebox 0.9.35rc27 2026-06-13 23:12:36 -07:00
Nick Sweeting c19b8de5d6 Update test_cli_recursive_crawl depth assertions to depth=0 input convention 2026-06-13 21:05:17 -07:00
Nick Sweeting 87dbff486d Update direct URL snapshot depth assertion to depth=0 2026-06-13 20:43:39 -07:00
Nick Sweeting a48ce6871b Make per-snapshot crawl seal idempotent against the run_snapshot race
run_snapshot calls load_snapshot_payload which replaces self.crawl with
a fresh Crawl model instance every time. When N snapshot tasks finish
near-simultaneously their independent self.crawl/SM pairs all see
'STARTED + no open snapshots' and race on sm.seal(). The first task
drives the SM to a final state (engine.running becomes False), but the
loser's current_state still reads STARTED off its stale model field,
so python-statemachine then raises 'Can't Seal when in Started.' and
fails the whole snapshot task even though the crawl is already sealed.

Refresh the row right before the call so the guard sees the committed
status, and treat TransitionNotAllowed as the expected idempotent
no-op when another task already drove the transition.
2026-06-13 20:17:30 -07:00
Nick Sweeting 8ba658b4dc Fix two more depth assertions missed in earlier alignment 2026-06-13 19:12:30 -07:00
Nick Sweeting 824af25371 Align remaining tests with depth=0 input-layer convention
Reverts 6a635d3c's piecewise depth-shift in test_recursive_crawl.py
plus matching test_cli_add tagged-URL, test_api_v1_cli_workflow,
test_config_MAX_limits assertions. Direct URLs land at snapshot.depth=0
and max_depth==depth as the single user-facing contract; only stdin/
import text adds the +1 hop for the synthetic archivebox://internal
root.
2026-06-13 18:50:54 -07:00
Nick Sweeting cfdd1293e9 Use archivebox install in the Dockerfile so archivebox-only plugins ship preinstalled
abx-dl install defaults to ABX_RUNTIME=abx-dl, which makes
discover_plugins() filter out any plugin whose config.json declares
"x-runtimes": ["archivebox"] (opencode, search_backend_sonic,
search_backend_sqlite). Running 'archivebox install' here uses
discover_plugins(runtime="archivebox") so those plugin binaries
(notably opencode) land in LIB_DIR at image build time instead of
trying to install on the first /admin/agent request.
2026-06-13 18:23:36 -07:00
Nick Sweeting 54dae36aec release: archivebox 0.9.35rc26 2026-06-13 18:11:04 -07:00
Nick Sweeting e547abbf27 Align direct URL Crawl flow with historical depth=0 convention
archivebox add and other entry points now seed Crawl.urls as
CrawlSeed JSONL at depth=0 (the input layer) with max_depth=depth
for direct URLs and depth+1 only for stdin/import text where the
synthetic archivebox://internal root lives at depth=0. The runner
also accepts one plain URL per line for ORM/crawl-create/schedule
callers so every Crawl row goes through the same expansion path
without scattering CrawlSeed knowledge across the codebase.

Tests updated to match restored convention.
2026-06-13 18:04:45 -07:00
Nick Sweeting 22453418fb release: archivebox 0.9.35rc25 2026-06-13 17:19:18 -07:00
Nick Sweeting c9e63ccffd Centralize synthetic root snapshot creation in CrawlRunner
Direct URL inputs from CLI/UI/API now seed Crawl.urls as explicit
{type:CrawlSeed,url,depth} JSONL rows; raw stdin/UI/API import text
stays verbatim. The runner's create_initial_snapshots() is now the
single place that either expands seed rows or creates the synthetic
archivebox://internal root + staticfile/stdin.txt, so add paths no
longer perform DB/FS side effects and the parser hooks run through
the same Snapshot lifecycle as every other extractor.
2026-06-13 17:10:06 -07:00
Nick Sweeting 6b6ac24307 Specify version in installation command for ArchiveBox
Updated installation command to specify version 0.9.5rc1.
2026-06-12 11:36:56 -04:00
Nick Sweeting 6a635d3cd6 Fix UI add direct URL runner flow 2026-06-11 09:28:28 -07:00
Nick Sweeting 7e4dd2ac2d Preserve queued hook validation for maintenance 2026-06-11 08:45:57 -07:00
Nick Sweeting 29cf3fe4ee Align limit tests with direct snapshot depth 2026-06-11 08:18:36 -07:00
Nick Sweeting 14d43c88f5 Repair stale binaries in version output 2026-06-11 07:52:05 -07:00
Nick Sweeting 33436ae646 Run queued maintenance plugins explicitly 2026-06-11 07:26:00 -07:00
Nick Sweeting 6e0f2a41a3 Fix add input routing and plugin dependencies 2026-06-11 07:06:41 -07:00
Nick Sweeting b6921d5e03 Restore direct URL add snapshots 2026-06-11 06:21:46 -07:00
Nick Sweeting f6c98b67d7 Limit stale binary checks to version output 2026-06-11 01:32:30 -07:00
Nick Sweeting 24caacdd1d Invalidate stale binary paths after lib moves 2026-06-11 01:16:02 -07:00
Nick Sweeting 426e23f33c Prevent deploy exec from consuming script input 2026-06-11 01:02:09 -07:00
Nick Sweeting e6091cf50e Capture deploy version warnings 2026-06-11 00:59:55 -07:00
Nick Sweeting 77500bfac8 Fix dev deploy image selection 2026-06-11 00:56:28 -07:00
Nick Sweeting 2e9be6751e release: archivebox 0.9.35rc24 2026-06-11 00:48:08 -07:00
Nick Sweeting bb598b9710 release: archivebox 0.9.35rc23 2026-06-11 00:41:34 -07:00
Nick Sweeting 283f0afdb5 release: archivebox 0.9.35rc22 2026-06-11 00:15:52 -07:00
Nick Sweeting 9d5ed1bc42 release: archivebox 0.9.35rc21 2026-06-11 00:08:54 -07:00
Nick Sweeting 585c951aa7 release: archivebox 0.9.35rc20 2026-06-11 00:00:21 -07:00
Nick Sweeting 635e318647 Use PyPI version endpoint in release wait 2026-06-10 23:45:16 -07:00
Nick Sweeting 1da4b79e9d Make dev stack release resumable after PyPI lag 2026-06-10 23:43:27 -07:00
Nick Sweeting e63552ad1d release: archivebox 0.9.35rc19 2026-06-10 23:02:37 -07:00
Nick Sweeting dfc0c66fc0 release: archivebox 0.9.35rc18 2026-06-10 22:19:59 -07:00
Nick Sweeting cd959844ca release: archivebox 0.9.35rc17 2026-06-10 21:56:32 -07:00
Nick Sweeting 0920431b53 Fix discovered snapshot plugin selection 2026-06-10 21:34:44 -07:00
Nick Sweeting 7c51542335 release: archivebox 0.9.35rc16 2026-06-10 21:11:33 -07:00
Nick Sweeting 2bda2abbf0 Fix tagged internal add crawl sealing 2026-06-10 21:04:48 -07:00
Nick Sweeting 2f65977b2e Tighten tag JSONL export assertions 2026-06-10 11:30:22 -07:00
Nick Sweeting 9b6b95c97c release: archivebox 0.9.35rc15 2026-06-10 11:17:40 -07:00
Nick Sweeting 2081fe55e4 Clarify Docker data directory ownership 2026-06-10 11:02:28 -07:00
Nick Sweeting f468b5b471 release: archivebox 0.9.35rc14 2026-06-10 10:49:57 -07:00
Nick Sweeting 9149ee12bf Reopen crawls before queuing discovered snapshots 2026-06-10 10:43:52 -07:00
Nick Sweeting 1fd8c8b7c2 release: archivebox 0.9.35rc13 2026-06-10 10:01:10 -07:00
Nick Sweeting 7e54e3f813 Stabilize API add import tag assertion 2026-06-10 09:53:29 -07:00
Nick Sweeting d3e3a5fc23 release: archivebox 0.9.35rc12 2026-06-10 09:20:00 -07:00
Nick Sweeting 303e99258c Simplify Docker data ownership detection 2026-06-10 09:13:02 -07:00
Nick Sweeting 77964bcde9 release: archivebox 0.9.35rc11 2026-06-10 00:32:05 -07:00
Nick Sweeting 2127d07697 Inherit crawl tags on snapshot creation 2026-06-10 00:24:21 -07:00
Nick Sweeting 1118d5f1e6 release: archivebox 0.9.35rc10 2026-06-09 23:54:33 -07:00
Nick Sweeting e38719f15c Rename image build UID defaults 2026-06-09 23:46:47 -07:00
Nick Sweeting 9c29724efa Allow selecting Docker release registries 2026-06-09 23:31:07 -07:00
Nick Sweeting 3328d9fc6a release: archivebox 0.9.35rc9 2026-06-09 23:24:28 -07:00
Nick Sweeting 98aecfaf91 release: archivebox 0.9.35rc8 2026-06-09 23:12:22 -07:00
Nick Sweeting a5e419ca3f release: archivebox 0.9.35rc6 2026-06-09 22:45:25 -07:00
Nick Sweeting 2bfb3ad4eb release: archivebox 0.9.35rc5 2026-06-09 22:09:28 -07:00
Nick Sweeting b8c3ce36a8 Exercise import parsing through live add APIs 2026-06-08 23:47:45 -07:00
Nick Sweeting a1449c2822 Use plugin URL patterns for source imports 2026-06-08 23:40:25 -07:00
Nick Sweeting f59af9cb2a Cover malicious feed add input 2026-06-08 23:32:52 -07:00
Nick Sweeting 5adec53b4c Expand add flow runtime handling 2026-06-08 23:27:08 -07:00
Nick Sweeting c48531b61c release: archivebox 0.9.35rc4 2026-06-08 16:33:44 -07:00
Nick Sweeting c7ad2fe60d Fix takeover log assertion 2026-06-08 16:00:34 -07:00
Nick Sweeting d1975c90a1 Fix paused crawl integration expectation 2026-06-08 06:25:07 -07:00
Nick Sweeting cc701b6ab2 release: archivebox 0.9.35rc3 2026-06-08 06:08:22 -07:00
Nick Sweeting 302479308b Fix CI test expectations for current runtime 2026-06-08 05:37:02 -07:00
Nick Sweeting 0642d085be release: archivebox 0.9.35rc2 2026-06-08 05:03:19 -07:00
Nick Sweeting a99f3bfd49 bump version 2026-06-08 04:48:46 -07:00
Nick Sweeting 7bf9bac08c Delete CLAUDE.md 2026-06-08 07:47:23 -04:00
Nick Sweeting 4b0a143174 Change copyright year to 2026
Updated copyright year in the LICENSE file.
2026-06-08 07:45:56 -04:00
Nick Sweeting 1eadff04f1 root dir cleanup 2026-06-08 04:45:13 -07:00
Nick Sweeting 2b71254fe9 Restore Docker lib root under opt archivebox 2026-06-08 04:44:40 -07:00
Nick Sweeting 24677ae3a7 Delete .claude directory 2026-06-08 07:33:37 -04:00
Nick Sweeting 6b03472086 Update WorkingDirectory and ExecStart in service file 2026-06-08 07:32:33 -04:00
Nick Sweeting 1bb51ca9ee Repair abxbus cache ownership at Docker startup 2026-06-08 04:30:12 -07:00
Nick Sweeting 4fe678055b Fix CI expectations for fast crawl and takeover logs 2026-06-07 21:36:06 -07:00
Nick Sweeting 608b9348bb Restore Docker chromium shim path 2026-06-07 21:01:43 -07:00
Nick Sweeting c57f0f2543 Fix Docker chrome shim path 2026-06-07 20:57:55 -07:00
Nick Sweeting 4fa90e484a release: archivebox 0.9.34rc71 2026-06-07 20:51:28 -07:00
Nick Sweeting 667f1658f0 release: archivebox 0.9.34rc70 2026-06-07 13:10:34 -07:00
Nick Sweeting 101c73f67c Add bundled HTTPS ingress compose profiles 2026-06-07 12:36:51 -07:00
Nick Sweeting 1540e66193 test: relax search stream latency ceiling 2026-06-07 12:34:21 -07:00
Nick Sweeting 8a56553f07 ci: verify all published docker tags 2026-06-07 12:02:16 -07:00
Nick Sweeting 2659f20dc4 fix runner takeover for scoped snapshot workers 2026-06-07 11:41:17 -07:00
Nick Sweeting 0816435b68 test: make sonic takeover fixtures explicit 2026-06-07 06:07:44 -07:00
Nick Sweeting bb436c5b8d ci: pin uv setup version 2026-06-07 05:48:58 -07:00
Nick Sweeting 7e62118da0 test: wait for stable daemon runner gate 2026-06-07 05:41:02 -07:00
Nick Sweeting 6c497e543e ci: install sonic backend plugin for sonic shards 2026-06-07 05:25:32 -07:00
Nick Sweeting 03879c453f ci: install sonic for runtime search shards 2026-06-07 05:22:24 -07:00
Nick Sweeting c306018c8c test: expect normalized snapshot permissions 2026-06-07 04:59:04 -07:00
Nick Sweeting ea82a36d2b release: archivebox 0.9.34rc69 2026-06-07 04:34:11 -07:00
Nick Sweeting 1ba5281343 release: archivebox 0.9.34rc68 2026-06-07 04:19:40 -07:00
Nick Sweeting 87b518314a release: archivebox 0.9.34rc67 2026-06-07 04:06:45 -07:00
Nick Sweeting 2b9a1a7887 Use mkdir -p in install docs 2026-06-07 02:57:38 -07:00
Nick Sweeting 8a22449c29 Update apt install documentation 2026-06-07 02:42:18 -07:00
Nick Sweeting e580f1a550 release: archivebox 0.9.34rc66 2026-06-06 23:40:29 -07:00
Nick Sweeting d14dd37b95 release: archivebox 0.9.34rc65 2026-06-06 23:34:58 -07:00
Nick Sweeting 200906410a release: archivebox 0.9.34rc64 2026-06-06 23:28:53 -07:00
Nick Sweeting 0a3c89e73c release: archivebox 0.9.34rc63 2026-06-06 23:27:54 -07:00
Nick Sweeting 36e0e3e2b6 release: archivebox 0.9.34rc62 2026-06-06 23:19:13 -07:00
204 changed files with 12811 additions and 5023 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
{
"enabledPlugins": {
"pyright-lsp@claude-plugins-official": true
}
}
-51
View File
@@ -1,51 +0,0 @@
{
"permissions": {
"allow": [
"Read(**)",
"Glob(**)",
"Grep(**)",
"Bash(python -m archivebox:*)",
"Bash(ls:*)",
"Bash(xargs:*)",
"Bash(python -c:*)",
"Bash(printf:*)",
"Bash(pkill:*)",
"Bash(python3:*)",
"Bash(sqlite3:*)",
"WebFetch(domain:github.com)",
"Bash(uv add:*)",
"Bash(mkdir:*)",
"Bash(chmod:*)",
"Bash(python -m forum_dl:*)",
"Bash(archivebox manage migrate:*)",
"Bash(cat:*)",
"Bash(python archivebox/plugins/pip/on_Dependency__install_using_pip_provider.py:*)",
"Bash(forum-dl:*)",
"Bash(pip uninstall:*)",
"Bash(python:*)",
"Bash(source .venv/bin/activate)",
"Bash(mv:*)",
"Bash(echo:*)",
"Bash(grep:*)",
"WebFetch(domain:python-statemachine.readthedocs.io)",
"Bash(./bin/run_plugin_tests.sh:*)",
"Bash(done)",
"Bash(coverage erase:*)",
"Bash(gh api:*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null); if [ -n \"$REPO_ROOT\" ] && [ \"$PWD\" != \"$REPO_ROOT\" ]; then echo \"ERROR: Not in repo root ($REPO_ROOT). Current dir: $PWD\" >&2; exit 1; fi",
"statusMessage": "Checking working directory..."
}
]
}
]
}
}
+9
View File
@@ -13,10 +13,14 @@ __pycache__
.pytest_cache/
.ruff_cache/
.uv-cache/
.cache/
.cache*/
.github/
.pdm-build/
.pdm-build/**
.pdm-python
.eggs/
*.egg-info/
.git/
!.git/
.git/*
@@ -42,6 +46,7 @@ chromeprofile/
chrome_profile/
lib/
out/
tests/out/
users/
archive/
crawls/
@@ -49,6 +54,8 @@ snapshots/
logs/
archivebox-docker-smoke*/
archivebox-compose-smoke*/
docker-test/
docker-test*/
core
*.core
@@ -61,6 +68,8 @@ dist/
brew_dist/
deb_dist/
pip_dist/
cache/
caches/
assets/
docker/
website/
+1 -1
View File
@@ -69,7 +69,7 @@ body:
0.8.6
ArchiveBox v0.8.6rc0 COMMIT_HASH=721427a BUILD_TIME=2024-10-21 12:57:02 1729515422
IN_DOCKER=False IN_QEMU=False ARCH=arm64 OS=Darwin PLATFORM=macOS-15.1-arm64-arm-64bit PYTHON=Cpython (venv)
EUID=502:20 UID=502:20 PUID=502:20 FS_UID=502:20 FS_PERMS=644 FS_ATOMIC=True FS_REMOTE=False
EUID=502:20 UID=502:20 FS_UID=502:20 FS_PERMS=644 FS_ATOMIC=True FS_REMOTE=False
DEBUG=False IS_TTY=True SUDO=False ID=dfa11485:aa78ad45 SEARCH_BACKEND=ripgrep LDAP=False
Binary Dependencies:
+1 -1
View File
@@ -71,7 +71,7 @@ body:
0.8.6
ArchiveBox v0.8.6rc0 COMMIT_HASH=721427a BUILD_TIME=2024-10-21 12:57:02 1729515422
IN_DOCKER=False IN_QEMU=False ARCH=arm64 OS=Darwin PLATFORM=macOS-15.1-arm64-arm-64bit PYTHON=Cpython (venv)
EUID=502:20 UID=502:20 PUID=502:20 FS_UID=502:20 FS_PERMS=644 FS_ATOMIC=True FS_REMOTE=False
EUID=502:20 UID=502:20 FS_UID=502:20 FS_PERMS=644 FS_ATOMIC=True FS_REMOTE=False
DEBUG=False IS_TTY=True SUDO=False ID=dfa11485:aa78ad45 SEARCH_BACKEND=ripgrep LDAP=False
Binary Dependencies:
+48 -10
View File
@@ -2,18 +2,15 @@
---
## Security Information
## Security Documentation
Please see this wiki page for important notices about ArchiveBox security, publishing your archives securely, and the dangers of executing archived JS:
https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview
Also see this section of the README about important caveats when running ArchiveBox:
https://github.com/ArchiveBox/ArchiveBox?tab=readme-ov-file#caveats
You can also read these pages for more information about ArchiveBox's internals, development environment, DB schema, and more:
Please start by reading these links to understand our existing security model, known issues, API surfaces, and setup steps:
- https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview
- https://github.com/ArchiveBox/ArchiveBox/security/advisories/
- https://github.com/ArchiveBox/ArchiveBox/tree/v0.7.4#security-risks-of-viewing-archived-js
- https://github.com/ArchiveBox/ArchiveBox/wiki/Publishing-Your-Archive#security-concerns
- https://github.com/ArchiveBox/ArchiveBox/wiki/Setting-up-Authentication
- https://github.com/ArchiveBox/ArchiveBox#archive-layout
- https://github.com/ArchiveBox/ArchiveBox#archivebox-development
- https://github.com/ArchiveBox/ArchiveBox/wiki/Upgrading-or-Merging-Archives
@@ -32,3 +29,44 @@ We use Github's built-in [Private Reporting](https://docs.github.com/en/code-sec
3. Fill out the form to submit the details of the report and it will be securely sent to the maintainers
You can also contact the maintainers via our public [Zulip Chat Server zulip.archivebox.io](https://zulip.archivebox.io) or [Twitter DMs @ArchiveBoxApp](https://twitter.com/ArchiveBoxApp).
---
## CVEs and Report Credits
> [!IMPORTANT]
> **Reports that only affect `dev` or beta versions will be converted to normal Github issues and will not be issued CVEs.**
> Reporters **will still receive credit in release notes** however.
> [!TIP]
> Reports that affect **stable published versions will be issued CVEs** and published w/ full credit.
Please read our existing published [security advisories](https://github.com/ArchiveBox/ArchiveBox/security/advisories), [Security Overview Docs](https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview), and [issues](https://github.com/ArchiveBox/ArchiveBox/issues) and avoid creating duplicates.
---
## Threat Model
### All admin users are assumed to be trusted
ArchiveBox does not support granular user permissions, there are only two types of users available in the Admin UI & REST API:
- logged in admin superusers (which are assumed to have `root` on the ArchiveBox host machine already)
- non-logged in anonymous guest users
Because admin / REST API / CLI / Python API users are already assumed to have `root` on the host machine, there are deliberately no protections against admins inputting malicious config values, attempts to attack other admin users, or destructive/malicious actions. Permissions for non-logged in users are managed via the [`PERMISSIONS`](https://github.com/archiveBox/archiveBox/wiki/configuration#permissions) + [`PUBLIC_INDEX` + `PUBLIC_ADD_VIEW`](https://github.com/archiveBox/archiveBox/wiki/configuration#public_index--public_add_view) + other config options.
### Sanitization of Config Values, Filesystem Paths, DB Fields, etc.
Non-logged in users can only choose between public Persona config presets on the `/add/` page, but they should never be allowed to directly set config options like `*_ARGS`, `*_BINARY` or DB fields like `url`, `title`, `tag.name`, `notes` without sanitization/filtering on save and/or use.
Non-logged in users should be tightly limited to prevent attacks like SQL/shell injection, path traversal attacks, XSS, cookie leaks between admin/non-admin domains, etc. Any reports related to non-logged-in users will be prioritized as this is a surface we care a lot about hardening.
### XSS, CSRF, CORS, CSP
`< v0.9.x` versions do not include any XSS/CSRF protections at all, as documented in the [readme](https://github.com/ArchiveBox/ArchiveBox/tree/v0.7.4#security-risks-of-viewing-archived-js), [wiki](https://github.com/ArchiveBox/ArchiveBox/wiki/Publishing-Your-Archive#security-concerns), [issue #239](https://github.com/ArchiveBox/ArchiveBox/issues/239), and [existing advisory](https://github.com/ArchiveBox/ArchiveBox/security/advisories/GHSA-cr45-98w9-gwqx). Do not open advisories related to XSS or CSRF for those versions, as they are missing fundamental architectural changes added in `v0.9.0` needed to support per-snapshot replay isolation.
[`SERVER_SECURITY_MODE`](https://github.com/archiveBox/archiveBox/wiki/configuration#server_security_mode) + CSRF/XSS defenses were only added in the new 0.9.0 `dev` work and are planned to land in `main` in mid-2026.
Note archivebox is designed to be able to archive any URL the server can reach, including internal intranet URLs and localhost. Users are expected to configure `URL_ALLOWLIST`/`URL_DENYLIST` accordingly if they need to limit what the server should archive.
It does a basic check to try and avoid recursively archiving snapshots hosted by the archivebox server itself, but it's not a strict security check, it's just to prevent wasted CPU/disk from recursively archiving snapshots in a browser that has the ArchiveBox Browser Extension turned on.
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -Eeuo pipefail
repo_name="$1"
target_dir="${2:-$repo_name}"
version="$(
python3 - "$repo_name" <<'PY'
import re
import sys
from pathlib import Path
repo_name = sys.argv[1]
lock_text = Path("uv.lock").read_text()
match = re.search(
rf'^\[\[package\]\]\s*\nname = "{re.escape(repo_name)}"\s*\nversion = "([^"]+)"',
lock_text,
re.MULTILINE,
)
if not match:
raise SystemExit(f"Could not find {repo_name} in uv.lock")
print(match.group(1))
PY
)"
echo "Cloning ArchiveBox/${repo_name}@v${version} into ${target_dir}"
git clone --depth=1 --branch "v${version}" "https://github.com/ArchiveBox/${repo_name}.git" "$target_dir"
+56 -55
View File
@@ -2,18 +2,14 @@ name: Build Docker image
on:
workflow_dispatch:
workflow_call:
push:
branches:
- "**"
tags:
- 'v*'
- '**'
# pull_request:
env:
DOCKERHUB_IMAGE: archivebox/archivebox
GHCR_IMAGE: ghcr.io/archivebox/archivebox
ABX_DL_IMAGE: archivebox/abx-dl:latest
permissions:
contents: read
@@ -46,44 +42,6 @@ jobs:
# submodules: true
# fetch-depth: 1
- name: Wait for released ArchiveBox deps on PyPI
run: |
python3 - <<'PY'
import json
import re
import sys
import time
import tomllib
import urllib.request
watched = {"abxbus", "abxpkg", "abx-plugins", "abx-dl"}
deps = tomllib.loads(open("pyproject.toml", "rb").read().decode())["project"]["dependencies"]
required = {}
for dep in deps:
for name in watched:
match = re.match(rf"{re.escape(name)}\s*(==|>=)\s*([^,;\s]+)", dep)
if match:
required[name] = match.group(2)
deadline = time.monotonic() + 300
missing = required.copy()
while missing and time.monotonic() < deadline:
for name, version in list(missing.items()):
with urllib.request.urlopen(f"https://pypi.org/pypi/{name}/json", timeout=20) as resp:
releases = json.load(resp)["releases"]
if version in releases:
print(f"{name} {version} is available on PyPI")
missing.pop(name)
else:
print(f"{name} {version} is not available on PyPI yet")
if missing:
time.sleep(10)
if missing:
print(f"Missing PyPI releases after wait: {missing}", file=sys.stderr)
sys.exit(1)
PY
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
@@ -97,21 +55,28 @@ jobs:
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Wait for published abx-dl image
- name: Verify published abx-dl image
id: abx_dl_image
shell: bash
run: |
set -Eeuo pipefail
deadline=$((SECONDS + 1800))
until docker buildx imagetools inspect "${ABX_DL_IMAGE}" >/tmp/abx-dl-image.json; do
if (( SECONDS >= deadline )); then
echo "Timed out waiting for published ${ABX_DL_IMAGE}" >&2
exit 1
fi
echo "${ABX_DL_IMAGE} is not published yet; waiting..."
sleep 30
done
ABX_DL_VERSION="$(python3 - <<'PY'
import re
import tomllib
with open("pyproject.toml", "rb") as f:
deps = tomllib.load(f)["project"]["dependencies"]
for dep in deps:
match = re.match(r"abx-dl\s*(?:==|>=)\s*([^,;\s]+)", dep)
if match:
print(match.group(1))
break
else:
raise SystemExit("Missing abx-dl dependency in pyproject.toml")
PY
)"
ABX_DL_IMAGE="${ABX_DL_IMAGE:-archivebox/abx-dl:${ABX_DL_VERSION}}"
echo "image=${ABX_DL_IMAGE}" >> "$GITHUB_OUTPUT"
docker buildx imagetools inspect "${ABX_DL_IMAGE}"
@@ -168,14 +133,46 @@ jobs:
labels: ${{ steps.docker_meta.outputs.labels }}
build-args: |
ABX_DL_IMAGE=${{ steps.abx_dl_image.outputs.image }}
ARCHIVEBOX_COMMIT_HASH=${{ github.sha }}
cache-from: type=gha,scope=${{ matrix.cache_scope }}
cache-to: type=gha,mode=max,scope=${{ matrix.cache_scope }}
pull: true
platforms: ${{ matrix.platform }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
- name: Validate pushed image version, commit, and size
shell: bash
run: |
set -Eeuo pipefail
IMAGE="${DOCKERHUB_IMAGE}@${{ steps.docker_build.outputs.digest }}"
SHORT_SHA="${GITHUB_SHA::7}"
DATA_DIR="$(mktemp -d)"
docker run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox init
docker run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox version | tee /tmp/archivebox-version.out
docker run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox status
docker run --rm --platform '${{ matrix.platform }}' --entrypoint cat "$IMAGE" /VERSION.txt > /tmp/archivebox-image-version.txt
grep -F "ArchiveBox v${{ steps.docker_meta.outputs.version }}" /tmp/archivebox-version.out
grep -F "COMMIT_HASH=${SHORT_SHA}" /tmp/archivebox-version.out
grep -Fx "COMMIT_HASH=${GITHUB_SHA}" /tmp/archivebox-image-version.txt
python3 - "$IMAGE" <<'PY'
import json
import subprocess
import sys
image = sys.argv[1]
limit = 780 * 1024 * 1024
manifest = json.loads(subprocess.check_output(["docker", "manifest", "inspect", image]))
total = manifest.get("config", {}).get("size", 0) + sum(layer.get("size", 0) for layer in manifest.get("layers", []))
print(f"{image} compressed_size={total / 1024 / 1024:.2f} MiB limit={limit / 1024 / 1024:.2f} MiB")
if total > limit:
raise SystemExit(f"{image} is over the compressed size limit")
PY
- name: Export digest
shell: bash
run: |
@@ -320,8 +317,12 @@ jobs:
shell: bash
run: |
set -Eeuo pipefail
docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:sha-${{ steps.docker_meta.outputs.short_sha }}"
docker buildx imagetools inspect "${GHCR_IMAGE}:sha-${{ steps.docker_meta.outputs.short_sha }}"
while IFS= read -r tag; do
[[ -n "$tag" ]] && docker buildx imagetools inspect "$tag"
done <<< "${{ steps.docker_meta.outputs.dockerhub_tags }}"
while IFS= read -r tag; do
[[ -n "$tag" ]] && docker buildx imagetools inspect "$tag"
done <<< "${{ steps.docker_meta.outputs.ghcr_tags }}"
- name: Update README
uses: peter-evans/dockerhub-description@v4
+6 -4
View File
@@ -3,6 +3,7 @@ name: Run linters
on:
workflow_dispatch:
push:
branches: ['**']
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -22,13 +23,13 @@ jobs:
fetch-depth: 1
- name: Clone abxpkg
run: git clone --depth=1 https://github.com/ArchiveBox/abxpkg.git abxpkg
run: bash .github/scripts/clone_abx_repo.sh abxpkg
- name: Clone abx-plugins
run: git clone --depth=1 https://github.com/ArchiveBox/abx-plugins.git abx-plugins
run: bash .github/scripts/clone_abx_repo.sh abx-plugins
- name: Clone abx-dl
run: git clone --depth=1 https://github.com/ArchiveBox/abx-dl.git abx-dl
run: bash .github/scripts/clone_abx_repo.sh abx-dl
- name: Set up Python
uses: actions/setup-python@v5
@@ -39,12 +40,13 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
version: "0.10.6"
enable-cache: false
- name: Install dependencies with uv
run: |
uv venv
uv pip install --all-groups -e ./abxpkg -e ./abx-plugins -e ./abx-dl -e ".[sonic,debug]"
uv pip install --group dev -e ./abxpkg -e ./abx-plugins -e ./abx-dl -e ".[sonic,debug]"
- name: Run prek
run: uv run --no-sync --no-sources prek run --all-files
+19 -61
View File
@@ -2,12 +2,9 @@ name: Build Pip package
on:
workflow_dispatch:
workflow_call:
push:
branches:
- '**'
tags:
- 'v*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -18,17 +15,14 @@ env:
jobs:
build:
permissions:
id-token: write
runs-on: ubuntu-24.04
environment: pypi
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
version: "0.10.6"
enable-cache: false
- name: Set up Python
@@ -43,66 +37,30 @@ jobs:
packages: ripgrep build-essential python3-dev python3-setuptools libssl-dev libldap2-dev libsasl2-dev zlib1g-dev libatomic1 gnupg2 curl wget python3-ldap python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps
version: 1.0
- name: Wait for released ArchiveBox deps on PyPI
run: |
python - <<'PY'
import json
import re
import sys
import time
import tomllib
import urllib.request
watched = {"abxbus", "abxpkg", "abx-plugins", "abx-dl"}
deps = tomllib.loads(open("pyproject.toml", "rb").read().decode())["project"]["dependencies"]
required = {}
for dep in deps:
for name in watched:
match = re.match(rf"{re.escape(name)}\s*(==|>=)\s*([^,;\s]+)", dep)
if match:
required[name] = match.group(2)
deadline = time.monotonic() + 300
missing = required.copy()
while missing and time.monotonic() < deadline:
for name, version in list(missing.items()):
with urllib.request.urlopen(f"https://pypi.org/pypi/{name}/json", timeout=20) as resp:
releases = json.load(resp)["releases"]
if version in releases:
print(f"{name} {version} is available on PyPI")
missing.pop(name)
else:
print(f"{name} {version} is not available on PyPI yet")
if missing:
time.sleep(10)
if missing:
print(f"Missing PyPI releases after wait: {missing}", file=sys.stderr)
sys.exit(1)
PY
- name: UV install archivebox dev + run sub-dependencies
env:
UV_PROJECT_ENVIRONMENT: ${{ github.workspace }}/.venv
run: uv sync --all-extras --no-install-project --no-install-workspace --no-sources --no-cache
- name: UV build archivebox and archivebox/pkgs/* packages
run: |
uv build --all
- name: Publish new package wheels and sdists to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# ignore when publish to PyPI fails due to duplicate tag
continue-on-error: true
with:
password: ${{ secrets.PYPI_PAT_SECRET }}
- name: UV install archivebox and archivebox/pkgs/* locally for tests
run: uv sync --all-extras --no-sources --no-cache
- name: UV run archivebox init + archivebox version
env:
UV_PROJECT_ENVIRONMENT: ${{ github.workspace }}/.venv
run: |
mkdir -p data && cd data
uv run --no-sync --no-sources archivebox init \
&& uv run --no-sync --no-sources archivebox version
# && uv run archivebox add 'https://example.com' \
# && uv run archivebox status \
# || (echo "UV Failed to run archivebox!" && exit 1)
uv sync --all-extras --no-install-project --no-install-workspace --no-sources --no-cache
uv pip install --python .venv/bin/python --no-deps dist/archivebox-*.whl
- name: Verify built package full install
env:
UV_PROJECT_ENVIRONMENT: ${{ github.workspace }}/.venv
run: |
set -Eeuo pipefail
DATA_DIR="$(mktemp -d)"
cd "$DATA_DIR"
uv run --project "$GITHUB_WORKSPACE" --no-sync --no-sources archivebox init
timeout 30m uv run --project "$GITHUB_WORKSPACE" --no-sync --no-sources archivebox install
uv run --project "$GITHUB_WORKSPACE" --no-sync --no-sources archivebox version
uv run --project "$GITHUB_WORKSPACE" --no-sync --no-sources archivebox status
+8 -5
View File
@@ -3,7 +3,7 @@ name: Release State
on:
push:
branches:
- '**'
- dev
workflow_dispatch:
permissions:
@@ -12,17 +12,20 @@ permissions:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false
jobs:
release-state:
if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'release:')
runs-on: ubuntu-latest
environment: pypi
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
ref: ${{ github.ref_name }}
token: ${{ secrets.RELEASE_GH_TOKEN || github.token }}
- uses: actions/setup-python@v5
with:
@@ -30,6 +33,7 @@ jobs:
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.6"
enable-cache: false
- uses: actions/setup-node@v4
@@ -43,7 +47,6 @@ jobs:
- name: Run release script
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ github.token }}
PYPI_PAT_SECRET: ${{ secrets.PYPI_PAT_SECRET }}
DEFAULT_BRANCH: dev
GH_TOKEN: ${{ secrets.RELEASE_GH_TOKEN || github.token }}
run: ./bin/release.sh
-41
View File
@@ -1,41 +0,0 @@
name: Release
# Orchestrates the full release pipeline:
# 1. Build and publish pip package to PyPI
# 2. Build Homebrew formula after pip
# 3. Build Docker images in parallel
#
# Individual workflows also run on push for CI (see their own triggers).
# Manual fallback for the hosted release pipeline. The normal prerelease loop
# is run by bin/release.sh locally so published GitHub releases do not launch a
# duplicate release job.
on:
workflow_dispatch:
permissions:
contents: write
packages: write
id-token: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
pip:
name: Publish to PyPI
uses: ./.github/workflows/pip.yml
secrets: inherit
homebrew:
name: Update Homebrew formula
needs: pip
uses: ./.github/workflows/homebrew.yml
secrets: inherit
docker:
name: Build Docker images
needs: pip
uses: ./.github/workflows/docker.yml
secrets: inherit
+107 -68
View File
@@ -16,6 +16,7 @@ env:
PYTHONLEGACYWINDOWSSTDIO: utf-8
USE_COLOR: False
UV_NO_SOURCES: "1"
ABXPKG_MIN_RELEASE_AGE: "0"
jobs:
discover-tests:
@@ -53,8 +54,12 @@ jobs:
if grep -Eiq '(chrom|archivewebpage|PLUGINS=.*title|--plugins=.*title|SAVE_TITLE.*[Tt]rue)' "$test_file"; then
needs_chromium=true
fi
needs_sonic=false
if grep -Eiq "shutil\\.which\\([\"']sonic|SEARCH_BACKEND_ENGINE=.*sonic|worker_sonic" "$test_file"; then
needs_sonic=true
fi
json_array+="{\"path\":\"$test_file\",\"name\":\"$name\",\"needs_chromium\":$needs_chromium}"
json_array+="{\"path\":\"$test_file\",\"name\":\"$name\",\"needs_chromium\":$needs_chromium,\"needs_sonic\":$needs_sonic}"
done
json_array+="]"
@@ -63,7 +68,7 @@ jobs:
echo "$json_array" | jq '.'
- name: Clone abx-plugins
run: git clone --depth=1 https://github.com/ArchiveBox/abx-plugins.git abx-plugins
run: bash .github/scripts/clone_abx_repo.sh abx-plugins
- name: Discover plugin tests
id: set-plugin-matrix
@@ -83,8 +88,12 @@ jobs:
if grep -Riq "chrom" "abx-plugins/abx_plugins/plugins/$plugin_name"; then
needs_chromium=true
fi
needs_sonic=false
if [ "$plugin_name" = "search_backend_sonic" ]; then
needs_sonic=true
fi
json_array+="{\"plugin\":\"$plugin_name\",\"name\":\"plugin/$plugin_name\",\"needs_chromium\":$needs_chromium}"
json_array+="{\"plugin\":\"$plugin_name\",\"name\":\"plugin/$plugin_name\",\"needs_chromium\":$needs_chromium,\"needs_sonic\":$needs_sonic}"
done
json_array+="]"
@@ -99,7 +108,6 @@ jobs:
env:
PYTHONPATH: ${{ github.workspace }}/abxpkg:${{ github.workspace }}/abx-plugins:${{ github.workspace }}/abx-dl
CHROME_HEADLESS: "true"
CHROME_SANDBOX: "false"
PERSONAS_DIR: /tmp/abx-personas
CHROME_USER_DATA_DIR: /tmp/abx-personas/Default/chrome_profile
@@ -116,13 +124,13 @@ jobs:
fetch-depth: 1
- name: Clone abxpkg
run: git clone --depth=1 https://github.com/ArchiveBox/abxpkg.git abxpkg
run: bash .github/scripts/clone_abx_repo.sh abxpkg
- name: Clone abx-plugins
run: git clone --depth=1 https://github.com/ArchiveBox/abx-plugins.git abx-plugins
run: bash .github/scripts/clone_abx_repo.sh abx-plugins
- name: Clone abx-dl
run: git clone --depth=1 https://github.com/ArchiveBox/abx-dl.git abx-dl
run: bash .github/scripts/clone_abx_repo.sh abx-dl
- name: Set up Python ${{ matrix.python }}
uses: actions/setup-python@v5
@@ -133,7 +141,7 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
version: "latest"
version: "0.10.6"
- name: Set up Node JS
uses: actions/setup-node@v4
@@ -150,7 +158,7 @@ jobs:
- uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: git ripgrep build-essential python3-dev python3-setuptools libssl-dev libldap2-dev libsasl2-dev zlib1g-dev libatomic1 python3-minimal gnupg2 curl wget python3-ldap python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps
packages: build-essential python3-dev python3-setuptools libssl-dev libldap2-dev libsasl2-dev zlib1g-dev libatomic1 python3-minimal gnupg2 curl python3-ldap python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps
version: 1.1
- name: Install dependencies with uv
@@ -158,51 +166,64 @@ jobs:
uv venv
uv pip install --group dev -e ./abxpkg -e ./abx-plugins -e ./abx-dl -e ".[all]"
- name: Install Chrome through ArchiveBox
if: ${{ matrix.test.needs_chromium }}
- name: Resolve core test binaries through abxpkg
env:
MIN_CHROMIUM_MAJOR: "149"
LIB_DIR: ${{ runner.temp }}/abx-lib
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
ABXPKG_INSTALL_TIMEOUT: "900"
ABX_CI_DATA_DIR: ${{ runner.temp }}/archivebox-ci-data
run: |
set -euo pipefail
mkdir -p "$LIB_DIR" "$ABX_CI_DATA_DIR"
mkdir -p "$LIB_DIR"
echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV"
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV"
uv run --no-sync --no-sources abxpkg env \
--install \
--lib="$LIB_DIR" \
--deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/wget/config.json:required_binaries" \
--deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/git/config.json:required_binaries" \
--deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/search_backend_ripgrep/config.json:required_binaries" \
>> "$GITHUB_ENV"
- name: Resolve Chrome through abxpkg
if: ${{ matrix.test.needs_chromium }}
env:
LIB_DIR: ${{ runner.temp }}/abx-lib
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
ABXPKG_INSTALL_TIMEOUT: "900"
run: |
set -euo pipefail
mkdir -p "$LIB_DIR"
echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV"
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV"
uv run --project "$GITHUB_WORKSPACE" --directory "$ABX_CI_DATA_DIR" --no-sync --no-sources archivebox install chrome
uv run --no-sync --no-sources abxpkg env \
--install \
--lib="$LIB_DIR" \
--deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/chrome/config.json:required_binaries" \
>> "$GITHUB_ENV"
candidate="$(uv run --project "$GITHUB_WORKSPACE" --directory "$ABX_CI_DATA_DIR" --no-sync --no-sources archivebox shell -c 'from archivebox.machine.models import Binary; binary = Binary.objects.filter(name="chromium", status="installed").order_by("-modified_at").first(); print(binary.abspath if binary else "")' | tail -n 1)"
if [ ! -x "$candidate" ]; then
echo "ArchiveBox did not install an executable Chromium binary: ${candidate:-not found}" >&2
exit 1
fi
version="$("$candidate" --version || true)"
major="$(printf '%s\n' "$version" | sed -E 's/.* ([0-9]+)\..*/\1/' | head -1)"
case "$major" in
''|*[!0-9]*) major=0 ;;
esac
if [ "$major" -lt "$MIN_CHROMIUM_MAJOR" ]; then
echo "ArchiveBox installed Chromium is too old: ${version:-unknown}" >&2
exit 1
fi
echo "Using ArchiveBox-installed Chromium: $candidate ($version)"
echo "CHROME_BINARY=$candidate" >> "$GITHUB_ENV"
- name: Resolve Sonic through abxpkg
if: ${{ matrix.test.needs_sonic }}
env:
LIB_DIR: ${{ runner.temp }}/abx-lib
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
ABXPKG_INSTALL_TIMEOUT: "900"
run: |
set -euo pipefail
mkdir -p "$LIB_DIR"
echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV"
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV"
uv run --no-sync --no-sources abxpkg env \
--install \
--lib="$LIB_DIR" \
--deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/search_backend_sonic/config.json:required_binaries" \
>> "$GITHUB_ENV"
- name: Run test - ${{ matrix.test.name }}
run: |
mkdir -p tests/out
set +e
uv run --no-sync --no-sources pytest -xvs "${{ matrix.test.path }}" --basetemp=tests/out --ignore=archivebox/pkgs
status=$?
set -e
if [ "$status" -eq 5 ]; then
echo "No tests collected from ${{ matrix.test.path }}; treating as an empty test module."
exit 0
fi
exit "$status"
uv run --no-sync --no-sources pytest -xvs "${{ matrix.test.path }}" --basetemp=tests/out
plugin-tests:
name: ${{ matrix.plugin.name }}
@@ -211,7 +232,6 @@ jobs:
env:
PYTHONPATH: ${{ github.workspace }}/abxpkg:${{ github.workspace }}/abx-plugins:${{ github.workspace }}/abx-dl
CHROME_HEADLESS: "true"
CHROME_SANDBOX: "false"
PERSONAS_DIR: /tmp/abx-personas
CHROME_USER_DATA_DIR: /tmp/abx-personas/Default/chrome_profile
@@ -227,13 +247,13 @@ jobs:
fetch-depth: 1
- name: Clone abxpkg
run: git clone --depth=1 https://github.com/ArchiveBox/abxpkg.git abxpkg
run: bash .github/scripts/clone_abx_repo.sh abxpkg
- name: Clone abx-plugins
run: git clone --depth=1 https://github.com/ArchiveBox/abx-plugins.git abx-plugins
run: bash .github/scripts/clone_abx_repo.sh abx-plugins
- name: Clone abx-dl
run: git clone --depth=1 https://github.com/ArchiveBox/abx-dl.git abx-dl
run: bash .github/scripts/clone_abx_repo.sh abx-dl
- name: Set up Python 3.13
uses: actions/setup-python@v5
@@ -244,7 +264,7 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
version: "latest"
version: "0.10.6"
- name: Set up Node JS
uses: actions/setup-node@v4
@@ -261,7 +281,7 @@ jobs:
- uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: git ripgrep build-essential python3-dev python3-setuptools libssl-dev libldap2-dev libsasl2-dev zlib1g-dev libatomic1 python3-minimal gnupg2 curl wget python3-ldap python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps
packages: build-essential python3-dev python3-setuptools libssl-dev libldap2-dev libsasl2-dev zlib1g-dev libatomic1 python3-minimal gnupg2 curl python3-ldap python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps
version: 1.1
- name: Install dependencies with uv
@@ -269,38 +289,57 @@ jobs:
uv venv
uv pip install --group dev -e ./abxpkg -e ./abx-plugins -e ./abx-dl -e ".[all]"
- name: Install Chrome through ArchiveBox
if: ${{ matrix.plugin.needs_chromium }}
- name: Resolve plugin dependencies through abxpkg
env:
MIN_CHROMIUM_MAJOR: "149"
LIB_DIR: ${{ runner.temp }}/abx-lib
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
ABXPKG_INSTALL_TIMEOUT: "900"
ABX_CI_DATA_DIR: ${{ runner.temp }}/archivebox-ci-data
run: |
set -euo pipefail
mkdir -p "$LIB_DIR" "$ABX_CI_DATA_DIR"
mkdir -p "$LIB_DIR"
echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV"
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV"
uv run --no-sync --no-sources abxpkg env \
--install \
--lib="$LIB_DIR" \
--deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/${{ matrix.plugin.plugin }}/config.json:required_binaries" \
>> "$GITHUB_ENV"
- name: Resolve Chrome through abxpkg
if: ${{ matrix.plugin.needs_chromium }}
env:
LIB_DIR: ${{ runner.temp }}/abx-lib
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
ABXPKG_INSTALL_TIMEOUT: "900"
run: |
set -euo pipefail
mkdir -p "$LIB_DIR"
echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV"
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV"
uv run --project "$GITHUB_WORKSPACE" --directory "$ABX_CI_DATA_DIR" --no-sync --no-sources archivebox install chrome
uv run --no-sync --no-sources abxpkg env \
--install \
--lib="$LIB_DIR" \
--deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/chrome/config.json:required_binaries" \
>> "$GITHUB_ENV"
candidate="$(uv run --project "$GITHUB_WORKSPACE" --directory "$ABX_CI_DATA_DIR" --no-sync --no-sources archivebox shell -c 'from archivebox.machine.models import Binary; binary = Binary.objects.filter(name="chromium", status="installed").order_by("-modified_at").first(); print(binary.abspath if binary else "")' | tail -n 1)"
if [ ! -x "$candidate" ]; then
echo "ArchiveBox did not install an executable Chromium binary: ${candidate:-not found}" >&2
exit 1
fi
version="$("$candidate" --version || true)"
major="$(printf '%s\n' "$version" | sed -E 's/.* ([0-9]+)\..*/\1/' | head -1)"
case "$major" in
''|*[!0-9]*) major=0 ;;
esac
if [ "$major" -lt "$MIN_CHROMIUM_MAJOR" ]; then
echo "ArchiveBox installed Chromium is too old: ${version:-unknown}" >&2
exit 1
fi
echo "Using ArchiveBox-installed Chromium: $candidate ($version)"
echo "CHROME_BINARY=$candidate" >> "$GITHUB_ENV"
- name: Resolve Sonic through abxpkg
if: ${{ matrix.plugin.needs_sonic }}
env:
LIB_DIR: ${{ runner.temp }}/abx-lib
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
ABXPKG_INSTALL_TIMEOUT: "900"
run: |
set -euo pipefail
mkdir -p "$LIB_DIR"
echo "LIB_DIR=$LIB_DIR" >> "$GITHUB_ENV"
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV"
uv run --no-sync --no-sources abxpkg env \
--install \
--lib="$LIB_DIR" \
--deps-from="$GITHUB_WORKSPACE/abx-plugins/abx_plugins/plugins/search_backend_sonic/config.json:required_binaries" \
>> "$GITHUB_ENV"
- name: Run plugin tests - ${{ matrix.plugin.name }}
env:
+28 -22
View File
@@ -1,5 +1,8 @@
name: Run tests
on: [push]
on:
push:
branches: ['**']
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -7,6 +10,7 @@ concurrency:
env:
DOCKER_IMAGE: archivebox-ci
ARCHIVEBOX_IMAGE: archivebox-ci
PYTHONIOENCODING: utf-8
PYTHONLEGACYWINDOWSSTDIO: utf-8
USE_COLOR: False
@@ -20,8 +24,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-22.04]
# os: [ubuntu-22.04, macos-latest, windows-latest]
os: [ubuntu-24.04]
python: ["3.13"]
steps:
@@ -31,13 +34,13 @@ jobs:
fetch-depth: 1
- name: Clone abxpkg
run: git clone --depth=1 https://github.com/ArchiveBox/abxpkg.git abxpkg
run: bash .github/scripts/clone_abx_repo.sh abxpkg
- name: Clone abx-plugins
run: git clone --depth=1 https://github.com/ArchiveBox/abx-plugins.git abx-plugins
run: bash .github/scripts/clone_abx_repo.sh abx-plugins
- name: Clone abx-dl
run: git clone --depth=1 https://github.com/ArchiveBox/abx-dl.git abx-dl
run: bash .github/scripts/clone_abx_repo.sh abx-dl
### Setup Python & JS Languages
- name: Set up Python ${{ matrix.python }}
@@ -49,7 +52,7 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
version: "0.10.6"
- name: Set up Node JS
uses: actions/setup-node@v4
@@ -81,20 +84,27 @@ jobs:
pwd
ls
- name: Archivebox version
- name: ArchiveBox full install check
run: |
mkdir -p tests/out/data
DATA_DIR="$PWD/tests/out/data" uv run --no-sync --no-sources archivebox version
set -Eeuo pipefail
DATA_DIR="$PWD/tests/out/data"
mkdir -p "$DATA_DIR"
uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox init
timeout 30m uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox install
uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox version
uv run --directory "$DATA_DIR" --no-sync --no-sources archivebox status
- name: Test built package with pytest
# TODO: remove this exception for windows once we get tests passing on that platform
if: ${{ !contains(matrix.os, 'windows') }}
if: github.ref != 'refs/heads/dev'
run: |
mkdir -p tests/out
uv run --no-sync --no-sources pytest -s archivebox/tests --basetemp=tests/out --ignore=archivebox/pkgs
uv run --no-sync --no-sources pytest -s archivebox/tests --basetemp=tests/out
- name: Run documentation code blocks
run: uv run --no-sync --no-sources pytest -vv --tb=long README.md AGENTS.md skills docs
- name: Run plugin tests
if: ${{ !contains(matrix.os, 'windows') }}
if: github.ref != 'refs/heads/dev'
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
TWOCAPTCHA_API_KEY: ${{ secrets.TWOCAPTCHA_API_KEY }}
@@ -111,9 +121,6 @@ jobs:
submodules: true
fetch-depth: 1
# TODO: as of 2020-11 this helper layer broke, upgrade and re-enable this once it's usable again
# - uses: satackey/action-docker-layer-caching@v0.0.8
- name: Build image
run: |
docker build . -t "$DOCKER_IMAGE"
@@ -143,14 +150,13 @@ jobs:
- name: Start docker-compose stack
run: |
docker-compose run archivebox init
docker-compose up -d
sleep 5
docker compose run --rm archivebox init
docker compose up -d --wait
curl --silent --location 'http://127.0.0.1:8000' | grep 'ArchiveBox'
curl --silent --location 'http://127.0.0.1:8000/static/admin/js/jquery.init.js' | grep 'window.django'
- name: Check added urls show up in index
run: |
docker-compose run archivebox add 'http://example.com/#test_docker' --index-only
docker compose run --rm archivebox add --plugins=parse_txt_urls 'http://example.com/#test_docker'
curl --silent --location 'http://127.0.0.1:8000' | grep 'http://example.com/#test_docker'
docker-compose down || true
docker compose down
+16 -14
View File
@@ -1,15 +1,15 @@
name: Update Homebrew tap
name: Update downstream packages
on:
push:
branches: [dev]
tags: ['v*']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: update-homebrew-tap-${{ github.ref }}
group: update-downstream-packages-${{ github.ref }}
cancel-in-progress: true
jobs:
@@ -17,19 +17,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Dispatch tap update
- name: Dispatch downstream package updates
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
DOWNSTREAM_TOKEN: ${{ secrets.RELEASE_GH_TOKEN || secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ -z "$HOMEBREW_TAP_TOKEN" ]; then
echo "HOMEBREW_TAP_TOKEN is not configured; tap hourly fallback will update the formula."
if [ -z "$DOWNSTREAM_TOKEN" ]; then
echo "RELEASE_GH_TOKEN/HOMEBREW_TAP_TOKEN is not configured; downstream schedules will update packages."
exit 0
fi
curl --fail-with-body \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${HOMEBREW_TAP_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/ArchiveBox/homebrew-archivebox/dispatches \
-d '{"event_type":"archivebox-dev-updated"}'
for repo in homebrew-archivebox debian-archivebox; do
curl --fail-with-body \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${DOWNSTREAM_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/ArchiveBox/${repo}/dispatches" \
-d '{"event_type":"archivebox-dev-updated","client_payload":{"ref":"dev"}}'
done
+85
View File
@@ -0,0 +1,85 @@
# ArchiveBox Agent Guide
ArchiveBox is the full self-hosted web archiving app. Keep this repo on the `dev` branch.
## Shared Standards
- Use `uv` and `uv run` for Python commands. Do not use system `python`, direct `.venv/bin/python`, or `pip` commands.
- Prefer existing repo patterns, helper APIs, fixtures, scripts, and command surfaces.
- Keep edits focused and minimal. Do not add wrappers, shims, aliases, or extra abstraction layers unless the current code path requires them.
- Do not weaken assertions, skip tests, xfail tests, or accept flaky behavior.
- No mocks, monkeypatches, fakes, simulated handlers, fake binaries, fake hooks, fake buses, or direct shortcuts around user-facing flows.
- Tests and verification should use real CLI commands, REST/API calls, browser UI flows, real hooks, real installs, real subprocesses, real DB rows, real files, and existing fixtures.
- Assertions must verify real correctness: exit codes, returned values, DB state, filesystem contents, field values, rendered output, and side effects.
- Start behavior fixes with a red failing test when a test is requested or practical.
- Trace root causes from observed behavior. Do not paper over failures with retries, wider timeouts, broad fallbacks, or looser assertions.
- Read `README.md` for the full setup, CLI, Docker, API, and release surface.
## Development Setup
```bash
uv sync --dev --all-extras
mkdir -p data
cd data
uv run --project .. archivebox init --install
```
Run collection commands from inside an initialized data directory:
```bash
cd data
uv run --project .. archivebox status
uv run --project .. archivebox add 'https://example.com'
uv run --project .. archivebox run
```
## User-Facing Setup
Recommended CLI install:
```bash
uv tool install --force .
export PLUGINS=parse_txt_urls
archivebox_data="$(mktemp -d)"
cd "$archivebox_data"
archivebox init --install
archivebox add --plugins=parse_txt_urls 'https://example.com'
```
Alternative install methods:
- Docker Compose / Docker
- Homebrew
- Debian package
- pip
## Basic Usage
<!--pytest-codeblocks:cont-->
```bash
archivebox version
archivebox help
archivebox status
archivebox install
archivebox add --plugins=parse_txt_urls 'https://example.com/docs-basic-usage'
archivebox list --json --with-headers
archivebox search 'example'
archivebox update --filter-type=domain example.com
archivebox remove --filter-type=exact 'https://example.com'
archivebox run
```
## Verification
Use targeted tests for focused work:
```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
```
Use the full release/deploy loop only when requested:
```console
./bin/release_dev_stack.sh
```
-497
View File
@@ -1,497 +0,0 @@
# Claude Code Development Guide for ArchiveBox
## Quick Start
```bash
# Set up dev environment (always use uv, never pip directly)
uv sync --dev --all-extras
# Run tests as non-root user (required - ArchiveBox always refuses to run as root)
sudo -u testuser bash -c 'source .venv/bin/activate && python -m pytest archivebox/tests/ -v'
```
## Development Environment Setup
### Prerequisites
- Python 3.11+ (3.13 recommended)
- uv package manager
- A non-root user for running tests (e.g., `testuser`)
### Install Dependencies
```bash
uv sync --dev --all-extras # Always use uv, never pip directly
```
### Activate Virtual Environment
```bash
source .venv/bin/activate
```
### Common Gotchas
#### File Permissions
New files created by root need permissions fixed for testuser:
```bash
chmod 644 archivebox/tests/test_*.py
```
#### DATA_DIR Environment Variable
ArchiveBox commands must run inside a data directory. Tests use temp directories - the `run_archivebox()` helper sets `DATA_DIR` automatically.
## Code Style Guidelines
### Naming Conventions for Grep-ability
Use consistent naming for everything to enable easy grep-ability and logical grouping:
**Principle**: Fewest unique names. If you must create a new unique name, make it grep and group well.
**Examples**:
```python
# Filesystem migration methods - all start with fs_
def fs_migration_needed() -> bool: ...
def fs_migrate() -> None: ...
def _fs_migrate_from_0_7_0_to_0_8_0() -> None: ...
def _fs_migrate_from_0_8_0_to_0_9_0() -> None: ...
def _fs_next_version(current: str) -> str: ...
# Logging methods - ALL must start with log_ or _log
def log_migration_start(snapshot_id: str) -> None: ...
def _log_error(message: str) -> None: ...
def log_validation_result(ok: bool, msg: str) -> None: ...
```
**Rules**:
- Group related functions with common prefixes
- Use `_` prefix for internal/private helpers within the same family
- ALL logging-related methods MUST start with `log_` or `_log`
- Search for all migration functions: `grep -r "def.*fs_.*(" archivebox/`
- Search for all logging: `grep -r "def.*log_.*(" archivebox/`
### Minimize Unique Names and Data Structures
**Do not invent new data structures, variable names, or keys if possible.** Try to use existing field names and data structures exactly to keep the total unique data structures and names in the codebase to an absolute minimum.
**Example - GOOD**:
```python
# Binary has overrides field
binary = Binary(overrides={'TIMEOUT': '60s'})
# Binary reuses the same field name and structure
class Binary(models.Model):
overrides = models.JSONField(default=dict) # Same name, same structure
```
**Example - BAD**:
```python
# Don't invent new names like custom_bin_cmds, binary_overrides, etc.
class Binary(models.Model):
custom_bin_cmds = models.JSONField(default=dict) # ❌ New unique name
```
**Principle**: If you're storing the same conceptual data (e.g., `overrides`), use the same field name across all models and keep the internal structure identical. This makes the codebase predictable and reduces cognitive load.
## Testing
### CRITICAL: Never Run as Root
ArchiveBox has a root check that prevents running as root user. All ArchiveBox commands (including tests) must run as non-root user inside a data directory:
```bash
# Run all migration tests
sudo -u testuser bash -c 'source /path/to/.venv/bin/activate && python -m pytest archivebox/tests/test_migrations_*.py -v'
# Run specific test file
sudo -u testuser bash -c 'source .venv/bin/activate && python -m pytest archivebox/tests/test_migrations_08_to_09.py -v'
# Run single test
sudo -u testuser bash -c 'source .venv/bin/activate && python -m pytest archivebox/tests/test_migrations_fresh.py::TestFreshInstall::test_init_creates_database -xvs'
```
### Test File Structure
```
archivebox/tests/
├── test_migrations_helpers.py # Schemas, seeding functions, verification helpers
├── test_migrations_fresh.py # Fresh install tests
├── test_migrations_04_to_09.py # 0.4.x → 0.9.x migration tests
├── test_migrations_07_to_09.py # 0.7.x → 0.9.x migration tests
└── test_migrations_08_to_09.py # 0.8.x → 0.9.x migration tests
```
### Test Writing Standards
#### NO MOCKS - Real Tests Only
Tests must exercise real code paths:
- Create real SQLite databases with version-specific schemas
- Seed with realistic test data
- Run actual `python -m archivebox` commands via subprocess
- Query SQLite directly to verify results
**If something is hard to test**: Modify the implementation to make it easier to test, or fix the underlying issue. Never mock, skip, simulate, or exit early from a test because you can't get something working inside the test.
#### NO SKIPS
Never use `@skip`, `skipTest`, or `pytest.mark.skip`. Every test must run. If a test is difficult, fix the code or test environment - don't disable the test.
#### Strict Assertions
- `init` command must return exit code 0 (not `[0, 1]`)
- Verify ALL data is preserved, not just "at least one"
- Use exact counts (`==`) not loose bounds (`>=`)
### Example Test Pattern
```python
def test_migration_preserves_snapshots(self):
"""Migration should preserve all snapshots."""
result = run_archivebox(self.work_dir, ['init'], timeout=45)
self.assertEqual(result.returncode, 0, f"Init failed: {result.stderr}")
ok, msg = verify_snapshot_count(self.db_path, expected_count)
self.assertTrue(ok, msg)
```
### Testing Gotchas
#### Extractors Disabled for Speed
Tests disable all extractors via environment variables for faster execution:
```python
env['SAVE_TITLE'] = 'False'
env['SAVE_FAVICON'] = 'False'
# ... etc
```
#### Timeout Settings
Use appropriate timeouts for migration tests (45s for init, 60s default).
### Plugin Testing & Code Coverage
**Target: 80-90% coverage** for critical plugins (screenshot, chrome, singlefile, dom)
```bash
# Run plugin tests with coverage (both Python + JavaScript)
bash bin/test_plugins.sh screenshot
# View coverage reports
bash bin/test_plugins.sh --coverage-report
# Or individual reports:
coverage report --show-missing --include='archivebox/plugins/*' --omit='*/tests/*'
```
#### Plugin Test Structure
Tests are **completely isolated** from ArchiveBox - they replicate production directory structure in temp dirs:
```python
# Correct production paths:
# Crawl: DATA_DIR/users/{username}/crawls/YYYYMMDD/example.com/{crawl-id}/{plugin}/
# Snapshot: DATA_DIR/users/{username}/snapshots/YYYYMMDD/example.com/{snapshot-uuid}/{plugin}/
with tempfile.TemporaryDirectory() as tmpdir:
data_dir = Path(tmpdir)
# Crawl-level plugin (e.g., chrome launcher)
crawl_dir = data_dir / 'users' / 'testuser' / 'crawls' / '20240101' / 'example.com' / 'crawl-123'
chrome_dir = crawl_dir / 'chrome'
chrome_dir.mkdir(parents=True)
# Snapshot-level plugin (e.g., screenshot)
snapshot_dir = data_dir / 'users' / 'testuser' / 'snapshots' / '20240101' / 'example.com' / 'snap-456'
screenshot_dir = snapshot_dir / 'screenshot'
screenshot_dir.mkdir(parents=True)
# Run hook in its output directory
result = subprocess.run(
['node', str(SCREENSHOT_HOOK), '--url=https://example.com'],
cwd=str(screenshot_dir),
env={**get_test_env(), 'EXTRA_CONTEXT': '{"snapshot_id":"snap-456"}'},
capture_output=True,
timeout=120
)
```
#### Coverage Improvement Loop
To improve from ~20% to 80%+:
1. **Run tests**: `bash bin/test_plugins.sh screenshot` → Shows: `19.1% (13/68 ranges)`
2. **Identify gaps**: Check hook file for untested paths (session connection vs fallback, config branches, error cases)
3. **Add tests**: Test both execution paths (connect to session + launch own browser), skip conditions, error cases, config variations
4. **Verify**: Re-run tests → Should show: `85%+ (58+/68 ranges)`
**Critical**: JavaScript hooks have TWO paths that both must be tested (connect to session ~50% + launch browser ~30% + shared ~20%). Testing only one path = max 50% coverage possible!
## Database Migrations
### Generate and Apply Migrations
```bash
# Generate migrations (run from archivebox subdirectory)
cd archivebox
./manage.py makemigrations
# Apply migrations to test database
cd data/
archivebox init
```
### Schema Versions
- **0.4.x**: First Django version. Tags as comma-separated string, no ArchiveResult model
- **0.7.x**: Tag model with M2M, ArchiveResult model, AutoField PKs
- **0.8.x**: Crawl/Seed models, UUID PKs, status fields, depth/retry_at
- **0.9.x**: Seed model removed, seed_id FK removed from Crawl
### Testing a Migration Path
1. Create SQLite DB with source version schema (from `test_migrations_helpers.py`)
2. Seed with realistic test data using `seed_0_X_data()`
3. Run `archivebox init` to trigger migrations
4. Verify data preservation with `verify_*` functions
5. Test CLI commands work post-migration (`status`, `list`, `add`, etc.)
### Squashed Migrations
When testing 0.8.x (dev branch), you must record ALL replaced migrations:
```python
# The squashed migration replaces these - all must be recorded
('core', '0023_alter_archiveresult_options_archiveresult_abid_and_more'),
('core', '0024_auto_20240513_1143'),
# ... all 52 migrations from 0023-0074 ...
('core', '0023_new_schema'), # Also record the squashed migration itself
```
### Migration Strategy
- Squashed migrations for clean installs
- Individual migrations recorded for upgrades from dev branch
- `replaces` attribute in squashed migrations lists what they replace
### Migration Gotchas
#### Circular FK References in Schemas
SQLite handles circular references with `IF NOT EXISTS`. Order matters less than in other DBs.
## Plugin System Architecture
### Plugin Dependency Rules
Like other plugins, chrome plugins **ARE NOT ALLOWED TO DEPEND ON ARCHIVEBOX OR DJANGO**.
However, they are allowed to depend on two shared files ONLY:
- `archivebox/plugins/chrome/chrome_utils.js` ← source of truth API for all basic chrome ops
- `archivebox/plugins/chrome/tests/chrome_test_utils.py` ← use for your tests, do not implement launching/killing/pid files/cdp/etc. in python, just extend this file as needed.
### Chrome-Dependent Plugins
Many plugins depend on Chrome/Chromium via CDP (Chrome DevTools Protocol). When checking for script name references or debugging Chrome-related issues, check these plugins:
**Main puppeteer-based chrome installer + launcher plugin**:
- `chrome` - Core Chrome integration (CDP, launch, navigation)
**Metadata extraction using chrome/chrome_utils.js / CDP**:
- `dns` - DNS resolution info
- `ssl` - SSL certificate info
- `headers` - HTTP response headers
- `redirects` - Capture redirect chains
- `staticfile` - Direct file downloads (e.g. if the url itself is a .png, .exe, .zip, etc.)
- `responses` - Capture network responses
- `consolelog` - Capture console.log output
- `title` - Extract page title
- `accessibility` - Extract accessibility tree
- `seo` - Extract SEO metadata
**Extensions installed using chrome/chrome_utils.js / controlled using CDP**:
- `ublock` - uBlock Origin ad blocking
- `istilldontcareaboutcookies` - Cookie banner dismissal
- `twocaptcha` - 2captcha CAPTCHA solver integration
**Page-alteration plugins to prepare the content for archiving**:
- `modalcloser` - Modal dialog dismissal
- `infiniscroll` - Infinite scroll handler
**Main Extractor Outputs**:
- `dom` - DOM snapshot extraction
- `pdf` - Generate PDF snapshots
- `screenshot` - Generate screenshots
- `singlefile` - SingleFile archival, can be single-file-cli that launches chrome, or singlefile extension running inside chrome
**Crawl URL parsers** (post-process dom.html, singlefile.html, staticfile, responses, headers, etc. for URLs to re-emit as new queued Snapshots during recursive crawling):
- `parse_dom_outlinks` - Extract outlinks from DOM (special, uses CDP to directly query browser)
- `parse_html_urls` - Parse URLs from HTML (doesn't use chrome directly, just reads dom.html)
- `parse_jsonl_urls` - Parse URLs from JSONL (doesn't use chrome directly, just reads dom.html)
- `parse_netscape_urls` - Parse Netscape bookmark format (doesn't use chrome directly, just reads dom.html)
### Finding Chrome-Dependent Plugins
```bash
# Find all files containing "chrom" (case-insensitive)
grep -ri "chrom" archivebox/plugins/*/on_*.* --include="*.*" 2>/dev/null | cut -d: -f1 | sort -u
# Or get just the plugin names
grep -ri "chrom" archivebox/plugins/*/on_*.* --include="*.*" 2>/dev/null | cut -d/ -f3 | sort -u
```
**Note**: This list may not be complete. Always run the grep command above when checking for Chrome-related script references or debugging Chrome integration issues.
## Architecture Notes
### Crawl Model (0.9.x)
- Crawl groups multiple Snapshots from a single `add` command
- Each `add` creates one Crawl with one or more Snapshots
- Seed model was removed - crawls now store URLs directly
## Code Coverage
### Overview
Coverage tracking is enabled for passive collection across all contexts:
- Unit tests (pytest)
- Integration tests
- Dev server (manual testing)
- CLI usage
Coverage data accumulates in `.coverage` file and can be viewed/analyzed to find dead code.
### Install Coverage Tools
```bash
uv sync --dev # Installs pytest-cov and coverage
```
### Running with Coverage
#### Unit Tests
```bash
# Run tests with coverage
pytest --cov=archivebox --cov-report=term archivebox/tests/
# Or run specific test file
pytest --cov=archivebox --cov-report=term archivebox/tests/test_migrations_08_to_09.py
```
#### Dev Server with Coverage
```bash
# Start dev server with coverage tracking
coverage run --parallel-mode -m archivebox server
# Or CLI commands
coverage run --parallel-mode -m archivebox init
coverage run --parallel-mode -m archivebox add https://example.com
```
#### Manual Testing (Always-On)
To enable coverage during ALL Python executions (passive tracking):
```bash
# Option 1: Use coverage run wrapper
coverage run --parallel-mode -m archivebox [command]
# Option 2: Set environment variable (tracks everything)
export COVERAGE_PROCESS_START=pyproject.toml
# Now all Python processes will track coverage
archivebox server
archivebox add https://example.com
```
### Viewing Coverage
#### Text Report (Quick View)
```bash
# Combine all parallel coverage data
coverage combine
# View summary
coverage report
# View detailed report with missing lines
coverage report --show-missing
# View specific file
coverage report --include="archivebox/core/models.py" --show-missing
```
#### JSON Report (LLM-Friendly)
```bash
# Generate JSON report
coverage json
# View the JSON
cat coverage.json | jq '.files | keys' # List all files
# Find files with low coverage
cat coverage.json | jq -r '.files | to_entries[] | select(.value.summary.percent_covered < 50) | "\(.key): \(.value.summary.percent_covered)%"'
# Find completely uncovered files (dead code candidates)
cat coverage.json | jq -r '.files | to_entries[] | select(.value.summary.percent_covered == 0) | .key'
# Get missing lines for a specific file
cat coverage.json | jq '.files["archivebox/core/models.py"].missing_lines'
```
#### HTML Report (Visual)
```bash
# Generate interactive HTML report
coverage html
# Open in browser
open htmlcov/index.html
```
### Isolated Runs
To measure coverage for specific scenarios:
```bash
# 1. Reset coverage data
coverage erase
# 2. Run your isolated test/scenario
pytest --cov=archivebox archivebox/tests/test_migrations_fresh.py
# OR
coverage run --parallel-mode -m archivebox add https://example.com
# 3. View results
coverage combine
coverage report --show-missing
# 4. Optionally export for analysis
coverage json
```
### Finding Dead Code
```bash
# 1. Run comprehensive tests + manual testing to build coverage
pytest --cov=archivebox archivebox/tests/
coverage run --parallel-mode -m archivebox server # Use the app manually
coverage combine
# 2. Find files with 0% coverage (strong dead code candidates)
coverage json
cat coverage.json | jq -r '.files | to_entries[] | select(.value.summary.percent_covered == 0) | .key'
# 3. Find files with <10% coverage (likely dead code)
cat coverage.json | jq -r '.files | to_entries[] | select(.value.summary.percent_covered < 10) | "\(.key): \(.value.summary.percent_covered)%"' | sort -t: -k2 -n
# 4. Generate detailed report for analysis
coverage report --show-missing > coverage_report.txt
```
### Tips
- **Parallel mode** (`--parallel-mode`): Allows multiple processes to track coverage simultaneously without conflicts
- **Combine**: Always run `coverage combine` before viewing reports to merge parallel data
- **Reset**: Use `coverage erase` to start fresh for isolated measurements
- **Branch coverage**: Enabled by default - tracks if both branches of if/else are executed
- **Exclude patterns**: Config in `pyproject.toml` excludes tests, migrations, type stubs
## Debugging Tips
### Check Migration State
```bash
sqlite3 /path/to/index.sqlite3 "SELECT app, name FROM django_migrations WHERE app='core' ORDER BY id;"
```
### Check Table Schema
```bash
sqlite3 /path/to/index.sqlite3 "PRAGMA table_info(core_snapshot);"
```
### Verbose Test Output
```bash
sudo -u testuser bash -c 'source .venv/bin/activate && python -m pytest archivebox/tests/test_migrations_08_to_09.py -xvs 2>&1 | head -200'
```
### Kill Zombie Chrome Processes
```bash
./bin/kill_chrome.sh
```
+112 -67
View File
@@ -2,7 +2,7 @@
# Multistage ArchiveBox Dockerfile that consumes the abx-dl runtime image.
# abx-dl owns Python, Node, Chromium, and downloader plugin runtimes.
# ArchiveBox owns ripgrep, sonic, supervisor, Django, and the app runtime.
# ArchiveBox owns sonic, supervisor, Django, and the app runtime.
# Build abx-dl first, then point this file at it:
# docker buildx build ../abx-dl -f ../abx-dl/Dockerfile \
# --build-context abxbus=../abxbus \
@@ -22,6 +22,7 @@ ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
ARG ARCHIVEBOX_COMMIT_HASH=""
ENV TZ=UTC \
LANGUAGE=en_US:en \
@@ -38,20 +39,19 @@ ENV TZ=UTC \
npm_config_loglevel=error
ENV PYTHON_VERSION=3.13 \
NODE_VERSION=22.22.3
NODE_VERSION=24
ENV ARCHIVEBOX_USER=archivebox \
DEFAULT_PUID=911 \
DEFAULT_PGID=911 \
DEFAULT_ARCHIVEBOX_UID=911 \
DEFAULT_ARCHIVEBOX_GID=911 \
IN_DOCKER=True
ENV CODE_DIR=/app \
DATA_DIR=/data \
LIB_DIR=/opt/archivebox/lib \
CONFIG_DIR=/opt/archivebox \
ABXPKG_LIB_DIR=/opt/archivebox/lib \
PLAYWRIGHT_BROWSERS_PATH=/opt/archivebox/lib/playwright/cache \
PERSONAS_DIR=/data/personas \
CHROME_USER_DATA_DIR=/data/personas/Default/chrome_profile \
CHROME_HEADLESS=true \
CHROME_SANDBOX=false \
CHROME_ISOLATION=crawl \
@@ -65,7 +65,7 @@ ENV TMP_DIR=/tmp/archivebox \
ENV HOME=/home/archivebox \
XDG_CONFIG_HOME=/home/archivebox/.config \
XDG_CACHE_HOME=/home/archivebox/.cache \
XDG_CACHE_HOME=/opt/archivebox/lib/cache \
ABXPKG_INSTALL_TIMEOUT=600 \
ABXPKG_POSTINSTALL_SCRIPTS=True \
ABXPKG_MIN_RELEASE_AGE=0 \
@@ -74,11 +74,10 @@ ENV HOME=/home/archivebox \
ENV UV_COMPILE_BYTECODE=false \
UV_PYTHON_PREFERENCE=managed \
UV_PYTHON_INSTALL_DIR=/opt/uv/python \
ARCHIVEBOX_PYTHON_INSTALL_DIR=/opt/archivebox/python \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/venv \
VIRTUAL_ENV=/venv \
PATH="/venv/bin:/opt/node/bin:/opt/archivebox/lib/bin:$PATH"
PATH="/venv/bin:/opt/node/bin:$PATH"
SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-o", "errtrace", "-o", "nounset", "-c"]
WORKDIR "$CODE_DIR"
@@ -89,50 +88,87 @@ RUN cp /VERSION.txt /ABX-DL-VERSION.txt \
&& echo "BUILD_START_TIME=$(date +"%Y-%m-%d %H:%M:%S %s") TZ=${TZ} LANG=${LANG}" \
&& uname -a \
&& sed -n '1,7p' /etc/os-release \
&& which node && node --version \
&& which uv && uv self version \
&& abxpkg load --binproviders=env node \
&& abxpkg load --binproviders=env uv \
) | tee -a /VERSION.txt
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
echo "[+] APT Installing ArchiveBox search dependency ripgrep for $TARGETPLATFORM..." \
&& apt-get update -qq \
&& apt-get install -qq -y --no-install-recommends ripgrep \
&& rm -rf /var/lib/apt/lists/*
FROM archivebox-runtime-base AS archivebox-builder
WORKDIR "$CODE_DIR"
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
--mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
--mount=type=bind,source=pyproject.toml,target=/app/pyproject.toml \
echo "[+] UV Installing ArchiveBox dependencies from pyproject.toml..." \
&& export UV_PYTHON_INSTALL_DIR="$ARCHIVEBOX_PYTHON_INSTALL_DIR" \
&& echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/99keep-cache \
&& echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/99no-install-recommends \
&& echo 'APT::Install-Suggests "0";' > /etc/apt/apt.conf.d/99no-install-suggests \
&& rm -f /etc/apt/apt.conf.d/docker-clean \
&& apt-get update -qq \
&& apt-get install -qq -y --no-install-recommends \
build-essential gcc libldap2-dev libsasl2-dev libssl-dev \
&& /usr/bin/uv venv --clear /venv --python "${PYTHON_VERSION}" \
&& /usr/bin/uv pip install setuptools pip wheel \
&& /usr/bin/uv sync \
--refresh \
--no-dev \
--inexact \
--all-extras \
--no-install-project \
--no-install-workspace \
--no-sources \
&& apt-get purge -y build-essential gcc libldap2-dev libsasl2-dev libssl-dev \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
<<'EOF'
echo "[+] UV Installing ArchiveBox dependencies from pyproject.toml..."
echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/99keep-cache
echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/99no-install-recommends
echo 'APT::Install-Suggests "0";' > /etc/apt/apt.conf.d/99no-install-suggests
rm -f /etc/apt/apt.conf.d/docker-clean
apt-get update -qq
apt-get install -qq -y --no-install-recommends \
build-essential gcc libldap2-dev libsasl2-dev libssl-dev
/usr/bin/uv venv --clear /venv --python "${PYTHON_VERSION}"
/usr/bin/uv pip install setuptools pip wheel
mkdir -p /tmp/archivebox-uv-project
/venv/bin/python - <<'PY'
from pathlib import Path
import json
import re
import urllib.request
source = Path("/app/pyproject.toml")
target = Path("/tmp/archivebox-uv-project/pyproject.toml")
text = source.read_text()
text = text.replace(
'environments = ["sys_platform == \'darwin\'", "sys_platform == \'linux\'"]',
'environments = ["sys_platform == \'linux\'"]',
)
# Docker builds need the just-published internal abx wheels immediately, but
# PyPI simple can lag the version JSON endpoints by tens of minutes. Generate a
# Docker-only dependency view from the version JSON so the published package
# metadata stays normal while image builds remain resumable after a release.
for package in ("abxpkg", "abx-plugins", "abx-dl"):
match = re.search(rf'"{re.escape(package)}>=(?P<version>[^"]+)"', text)
if not match:
continue
version = match.group("version")
with urllib.request.urlopen(f"https://pypi.org/pypi/{package}/{version}/json", timeout=20) as response:
data = json.load(response)
wheel_url = next(url["url"] for url in data["urls"] if url["filename"].endswith(".whl"))
text = re.sub(
rf'"{re.escape(package)}>=[^"]+"',
f'"{package} @ {wheel_url}"',
text,
count=1,
)
target.write_text(text)
PY
/usr/bin/uv sync \
--project /tmp/archivebox-uv-project \
--refresh \
--no-dev \
--inexact \
--no-install-project \
--no-install-workspace \
--no-sources
(find /venv/lib/python3.*/site-packages -type f -name '*.so' -exec strip --strip-unneeded {} + 2>/dev/null || true)
rm -f /venv/bin/uv /venv/bin/uvx
apt-get purge -y build-essential gcc libldap2-dev libsasl2-dev libssl-dev
apt-get autoremove -y
rm -rf /var/lib/apt/lists/*
EOF
COPY --chown=root:root --chmod=755 "." "$CODE_DIR/"
RUN --mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
echo "[*] Installing ArchiveBox Python source code from $CODE_DIR..." \
&& COMMIT_HASH="$( \
if [[ -f "$CODE_DIR/.git/HEAD" ]]; then \
if [[ "$ARCHIVEBOX_COMMIT_HASH" =~ ^[0-9a-fA-F]{40}$ ]]; then \
echo "$ARCHIVEBOX_COMMIT_HASH"; \
elif [[ -f "$CODE_DIR/.git/HEAD" ]]; then \
HEAD_REF="$(cat "$CODE_DIR/.git/HEAD")"; \
if [[ "$HEAD_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then \
echo "$HEAD_REF"; \
@@ -143,8 +179,8 @@ RUN --mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$T
fi)" \
&& if [[ "$COMMIT_HASH" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "COMMIT_HASH=$COMMIT_HASH" | tee -a /VERSION.txt; fi \
&& /usr/bin/uv pip install --no-deps "$CODE_DIR" \
&& (/usr/bin/uv pip show archivebox && which archivebox) | tee -a /VERSION.txt \
&& rm -rf "$CODE_DIR/.git"
&& rm -f /venv/bin/uv /venv/bin/uvx \
&& /usr/bin/uv pip show archivebox | tee -a /VERSION.txt
FROM archivebox-runtime-base
@@ -162,50 +198,59 @@ LABEL name="archivebox" \
COPY --from=sonic /usr/local/bin/sonic /usr/local/bin/sonic
COPY --chown=root:root --chmod=755 "etc/sonic.cfg" /etc/sonic.cfg
COPY --from=archivebox-builder /opt/archivebox/python /opt/archivebox/python
COPY --from=archivebox-builder /opt/uv/python /opt/uv/python
COPY --from=archivebox-builder /venv /venv
COPY --from=archivebox-builder /app /app
COPY --from=archivebox-builder /VERSION.txt /VERSION.txt
RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_PUID}..." \
RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..." \
&& printf 'export PATH="/venv/bin:/opt/node/bin:$PATH"\n' > /etc/profile.d/archivebox-path.sh \
&& ln -sf /venv/bin/archivebox /usr/local/bin/archivebox \
&& ln -sf /venv/bin/daphne /usr/local/bin/daphne \
&& ln -sf /venv/bin/supervisord /usr/local/bin/supervisord \
&& ln -sf /venv/bin/supervisorctl /usr/local/bin/supervisorctl \
&& getent group "$ARCHIVEBOX_USER" >/dev/null || groupadd --system "$ARCHIVEBOX_USER" \
&& id -u "$ARCHIVEBOX_USER" >/dev/null 2>&1 || useradd --system --create-home --gid "$ARCHIVEBOX_USER" --groups audio,video "$ARCHIVEBOX_USER" \
&& usermod --append --groups audio,video "$ARCHIVEBOX_USER" \
&& [[ "$(id -u "$ARCHIVEBOX_USER")" == "$DEFAULT_PUID" ]] || usermod -u "$DEFAULT_PUID" "$ARCHIVEBOX_USER" \
&& [[ "$(id -g "$ARCHIVEBOX_USER")" == "$DEFAULT_PGID" ]] || groupmod -g "$DEFAULT_PGID" "$ARCHIVEBOX_USER" \
&& (which sonic && sonic --version) | tee -a /VERSION.txt \
&& install -d -o "$DEFAULT_PUID" -g "$DEFAULT_PGID" "$DATA_DIR" "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
&& install -d -o "$DEFAULT_PUID" -g "$DEFAULT_PGID" "/home/$ARCHIVEBOX_USER" "/home/$ARCHIVEBOX_USER/.config" "/home/$ARCHIVEBOX_USER/.cache" \
&& install -d -o "$DEFAULT_PUID" -g "$DEFAULT_PGID" "/home/$ARCHIVEBOX_USER/.config/abx" "/home/$ARCHIVEBOX_USER/.cache/abxbus" "/home/$ARCHIVEBOX_USER/.cache/pnpm" "/home/$ARCHIVEBOX_USER/.cache/uv" \
&& chown "$DEFAULT_PUID:$DEFAULT_PGID" "$DATA_DIR" "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
&& [[ "$(id -u "$ARCHIVEBOX_USER")" == "$DEFAULT_ARCHIVEBOX_UID" ]] || usermod -u "$DEFAULT_ARCHIVEBOX_UID" "$ARCHIVEBOX_USER" \
&& [[ "$(id -g "$ARCHIVEBOX_USER")" == "$DEFAULT_ARCHIVEBOX_GID" ]] || groupmod -g "$DEFAULT_ARCHIVEBOX_GID" "$ARCHIVEBOX_USER" \
&& abxpkg load --binproviders=env sonic | tee -a /VERSION.txt \
&& install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" "$CONFIG_DIR" "$ABXPKG_LIB_DIR" "$XDG_CACHE_HOME" "$PLAYWRIGHT_BROWSERS_PATH" \
&& install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "/home/$ARCHIVEBOX_USER" \
&& chown "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" \
&& chown -R "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$ABXPKG_LIB_DIR" \
&& openssl rand -hex 16 > /etc/machine-id \
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER PUID=$(id -u "$ARCHIVEBOX_USER") PGID=$(id -g "$ARCHIVEBOX_USER")" | tee -a /VERSION.txt \
&& echo -e "TMP_DIR=$TMP_DIR\nLIB_DIR=$LIB_DIR\nPLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH\nMACHINE_ID=$(cat /etc/machine-id)\n" | tee -a /VERSION.txt
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER ARCHIVEBOX_UID=$(id -u "$ARCHIVEBOX_USER") ARCHIVEBOX_GID=$(id -g "$ARCHIVEBOX_USER")" | tee -a /VERSION.txt \
&& echo -e "TMP_DIR=$TMP_DIR\nABXPKG_LIB_DIR=$ABXPKG_LIB_DIR\nPLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH\nMACHINE_ID=$(cat /etc/machine-id)\n" | tee -a /VERSION.txt
WORKDIR "$DATA_DIR"
RUN echo "[+] Initializing image collection..." \
&& find "$DATA_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} + \
&& PUID=0 PGID=0 archivebox init \
&& (chown "$DEFAULT_PUID:$DEFAULT_PGID" \
&& HOME="$TMP_DIR" archivebox init \
&& (chown "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" \
"$DATA_DIR" "$DATA_DIR"/.archivebox_id "$DATA_DIR"/ArchiveBox.conf "$DATA_DIR"/index.sqlite3 \
"$DATA_DIR"/logs "$DATA_DIR"/logs/* "$DATA_DIR"/sources \
"$DATA_DIR"/archive "$DATA_DIR"/archive/users "$DATA_DIR"/personas \
"$DATA_DIR"/tmp "$DATA_DIR"/tmp/* \
"$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
2>/dev/null || true)
"$CONFIG_DIR" "$CONFIG_DIR"/config.env "$CONFIG_DIR"/derived.env \
"$TMP_DIR" "$ABXPKG_LIB_DIR" "$XDG_CACHE_HOME" "$PLAYWRIGHT_BROWSERS_PATH" \
2>/dev/null || true) \
&& find "$TMP_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
RUN chmod +x "$CODE_DIR"/bin/*.sh \
&& chmod g+w "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH"
&& chmod g+w "$TMP_DIR" "$ABXPKG_LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH"
RUN "$LIB_DIR/bin/chromium" --version | tee -a /VERSION.txt \
&& "$LIB_DIR/uv/packages/papers-dl/venv/bin/papers-dl" --version | tee -a /VERSION.txt \
&& /usr/bin/rg --version | head -1 | tee -a /VERSION.txt \
&& /usr/local/bin/sonic --version | tee -a /VERSION.txt \
&& /venv/bin/supervisord --version | tee -a /VERSION.txt \
&& for forbidden_bin in gcc g++ make; do ! command -v "$forbidden_bin" || (echo "Unexpected build tool in runtime: $forbidden_bin=$(command -v "$forbidden_bin")" >&2 && exit 1); done \
&& stat -c "%U:%G %a %n" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "$LIB_DIR" \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups archivebox version 2>&1 | tee -a /VERSION.txt \
RUN --mount=type=cache,target=/tmp/abxpkg-cache,sharing=locked,mode=1777 \
chmod 1777 /tmp/abxpkg-cache \
&& for forbidden_bin in gcc g++ make; do ! abxpkg load --binproviders=env "$forbidden_bin" >/dev/null 2>&1 || (echo "Unexpected build tool in runtime: $forbidden_bin" >&2 && exit 1); done \
&& stat -c "%U:%G %a %n" "$CONFIG_DIR" "$ABXPKG_LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "$CONFIG_DIR" \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "$ABXPKG_LIB_DIR" \
&& python3 -c 'from abx_dl.models import discover_plugins; [print(f"export {plugin.enabled_key}=True") for plugin in discover_plugins(runtime="archivebox").values() if plugin.enabled_key in plugin.config.properties]' > /tmp/archivebox-enable-plugins.env \
&& sort /tmp/archivebox-enable-plugins.env | tee -a /VERSION.txt \
&& source /tmp/archivebox-enable-plugins.env \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups env HOME=/tmp/abxpkg-cache ABXPKG_NO_CACHE=True ABXPKG_TMP_CACHE_DIR=/tmp/abxpkg-cache archivebox install \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups env HOME=/tmp/abxpkg-cache archivebox version 2>&1 | tee -a /VERSION.txt \
&& rm -rf /root/.cache /var/cache/apt/* /var/lib/apt/lists/*
RUN (echo -e "\n\n[√] Finished ArchiveBox multistage Docker build successfully." \
-224
View File
@@ -1,224 +0,0 @@
# syntax=docker/dockerfile:1.7
# Multistage ArchiveBox Dockerfile that consumes the abx-dl runtime image.
# abx-dl owns Python, Node, Chromium, and downloader plugin runtimes.
# ArchiveBox owns ripgrep, sonic, supervisor, Django, and the app runtime.
# Build abx-dl first, then point this file at it:
# docker buildx build ../abx-dl -f ../abx-dl/Dockerfile \
# --build-context abxbus=../abxbus \
# --build-context abxpkg=../abxpkg \
# --build-context abx-plugins=../abx-plugins \
# -t archivebox/abx-dl:dev
# docker buildx build . -f Dockerfile \
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:latest \
# -t archivebox:multistage
ARG ABX_DL_IMAGE=archivebox/abx-dl:latest
FROM archivebox/sonic:1.4.9 AS sonic
FROM ${ABX_DL_IMAGE} AS archivebox-runtime-base
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
ENV TZ=UTC \
LANGUAGE=en_US:en \
LC_ALL=C.UTF-8 \
LANG=C.UTF-8 \
DEBIAN_FRONTEND=noninteractive \
APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 \
PYTHONIOENCODING=UTF-8 \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_COMPILE=1 \
PIP_ONLY_BINARY=aiohttp \
npm_config_loglevel=error
ENV PYTHON_VERSION=3.13 \
NODE_VERSION=22.22.3
ENV ARCHIVEBOX_USER=archivebox \
DEFAULT_PUID=911 \
DEFAULT_PGID=911 \
IN_DOCKER=True
ENV CODE_DIR=/app \
DATA_DIR=/data \
LIB_DIR=/opt/archivebox/lib \
ABXPKG_LIB_DIR=/opt/archivebox/lib \
PLAYWRIGHT_BROWSERS_PATH=/opt/archivebox/lib/playwright/cache \
PERSONAS_DIR=/data/personas \
CHROME_USER_DATA_DIR=/data/personas/Default/chrome_profile \
CHROME_HEADLESS=true \
CHROME_SANDBOX=false \
CHROME_ISOLATION=crawl \
CHROME_ARGS_EXTRA='["--disable-gpu","--disable-features=Translate,OptimizationGuideModelDownloading,MediaRouter"]'
ENV TMP_DIR=/tmp/archivebox \
PIP_VENV_PYTHON=/venv/bin/python3 \
GOOGLE_API_KEY=no \
GOOGLE_DEFAULT_CLIENT_ID=no \
GOOGLE_DEFAULT_CLIENT_SECRET=no
ENV HOME=/home/archivebox \
XDG_CONFIG_HOME=/home/archivebox/.config \
XDG_CACHE_HOME=/home/archivebox/.cache \
ABXPKG_INSTALL_TIMEOUT=600 \
ABXPKG_POSTINSTALL_SCRIPTS=True \
ABXPKG_MIN_RELEASE_AGE=0 \
TIMEOUT=600
ENV UV_COMPILE_BYTECODE=false \
UV_PYTHON_PREFERENCE=managed \
UV_PYTHON_INSTALL_DIR=/opt/uv/python \
ARCHIVEBOX_PYTHON_INSTALL_DIR=/opt/archivebox/python \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/venv \
VIRTUAL_ENV=/venv \
PATH="/venv/bin:/opt/node/bin:/opt/archivebox/lib/bin:$PATH"
SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-o", "errtrace", "-o", "nounset", "-c"]
WORKDIR "$CODE_DIR"
RUN cp /VERSION.txt /ABX-DL-VERSION.txt \
&& (echo "[i] Docker build for ArchiveBox multistage starting..." \
&& echo "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) (${TARGETARCH} ${TARGETVARIANT})" \
&& echo "BUILD_START_TIME=$(date +"%Y-%m-%d %H:%M:%S %s") TZ=${TZ} LANG=${LANG}" \
&& uname -a \
&& sed -n '1,7p' /etc/os-release \
&& which node && node --version \
&& which uv && uv self version \
) | tee -a /VERSION.txt
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
echo "[+] APT Installing ArchiveBox search dependency ripgrep for $TARGETPLATFORM..." \
&& apt-get update -qq \
&& apt-get install -qq -y --no-install-recommends ripgrep \
&& rm -rf /var/lib/apt/lists/*
FROM archivebox-runtime-base AS archivebox-builder
WORKDIR "$CODE_DIR"
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
--mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
--mount=type=bind,source=pyproject.toml,target=/app/pyproject.toml \
echo "[+] UV Installing ArchiveBox dependencies from pyproject.toml..." \
&& export UV_PYTHON_INSTALL_DIR="$ARCHIVEBOX_PYTHON_INSTALL_DIR" \
&& echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/99keep-cache \
&& echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/99no-install-recommends \
&& echo 'APT::Install-Suggests "0";' > /etc/apt/apt.conf.d/99no-install-suggests \
&& rm -f /etc/apt/apt.conf.d/docker-clean \
&& apt-get update -qq \
&& apt-get install -qq -y --no-install-recommends \
build-essential gcc libldap2-dev libsasl2-dev libssl-dev \
&& /usr/bin/uv venv --clear /venv --python "${PYTHON_VERSION}" \
&& /usr/bin/uv pip install setuptools pip wheel \
&& /usr/bin/uv sync \
--refresh \
--no-dev \
--inexact \
--all-extras \
--no-install-project \
--no-install-workspace \
--no-sources \
&& apt-get purge -y build-essential gcc libldap2-dev libsasl2-dev libssl-dev \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
COPY --chown=root:root --chmod=755 "." "$CODE_DIR/"
RUN --mount=type=cache,target=/root/.cache/uv,sharing=locked,id=uv-$TARGETARCH$TARGETVARIANT \
echo "[*] Installing ArchiveBox Python source code from $CODE_DIR..." \
&& COMMIT_HASH="$( \
if [[ -f "$CODE_DIR/.git/HEAD" ]]; then \
HEAD_REF="$(cat "$CODE_DIR/.git/HEAD")"; \
if [[ "$HEAD_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then \
echo "$HEAD_REF"; \
elif [[ "$HEAD_REF" == ref:\ * ]]; then \
REF_PATH="${HEAD_REF#ref: }"; \
cat "$CODE_DIR/.git/$REF_PATH" 2>/dev/null || awk -v ref="$REF_PATH" '$2 == ref {print $1}' "$CODE_DIR/.git/packed-refs" 2>/dev/null || true; \
fi; \
fi)" \
&& if [[ "$COMMIT_HASH" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "COMMIT_HASH=$COMMIT_HASH" | tee -a /VERSION.txt; fi \
&& /usr/bin/uv pip install --no-deps "$CODE_DIR" \
&& (/usr/bin/uv pip show archivebox && which archivebox) | tee -a /VERSION.txt \
&& rm -rf "$CODE_DIR/.git"
FROM archivebox-runtime-base
LABEL name="archivebox" \
maintainer="Nick Sweeting <dockerfile@archivebox.io>" \
description="All-in-one self-hosted internet archiving solution" \
homepage="https://github.com/ArchiveBox/ArchiveBox" \
documentation="https://github.com/ArchiveBox/ArchiveBox/wiki/Docker" \
org.opencontainers.image.title="ArchiveBox" \
org.opencontainers.image.vendor="ArchiveBox" \
org.opencontainers.image.description="All-in-one self-hosted internet archiving solution" \
org.opencontainers.image.source="https://github.com/ArchiveBox/ArchiveBox" \
com.docker.image.source.entrypoint="Dockerfile"
COPY --from=sonic /usr/local/bin/sonic /usr/local/bin/sonic
COPY --chown=root:root --chmod=755 "etc/sonic.cfg" /etc/sonic.cfg
COPY --from=archivebox-builder /opt/archivebox/python /opt/archivebox/python
COPY --from=archivebox-builder /venv /venv
COPY --from=archivebox-builder /app /app
COPY --from=archivebox-builder /VERSION.txt /VERSION.txt
RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_PUID}..." \
&& getent group "$ARCHIVEBOX_USER" >/dev/null || groupadd --system "$ARCHIVEBOX_USER" \
&& id -u "$ARCHIVEBOX_USER" >/dev/null 2>&1 || useradd --system --create-home --gid "$ARCHIVEBOX_USER" --groups audio,video "$ARCHIVEBOX_USER" \
&& usermod --append --groups audio,video "$ARCHIVEBOX_USER" \
&& [[ "$(id -u "$ARCHIVEBOX_USER")" == "$DEFAULT_PUID" ]] || usermod -u "$DEFAULT_PUID" "$ARCHIVEBOX_USER" \
&& [[ "$(id -g "$ARCHIVEBOX_USER")" == "$DEFAULT_PGID" ]] || groupmod -g "$DEFAULT_PGID" "$ARCHIVEBOX_USER" \
&& (which sonic && sonic --version) | tee -a /VERSION.txt \
&& install -d -o "$DEFAULT_PUID" -g "$DEFAULT_PGID" "$DATA_DIR" "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
&& install -d -o "$DEFAULT_PUID" -g "$DEFAULT_PGID" "/home/$ARCHIVEBOX_USER" "/home/$ARCHIVEBOX_USER/.config" "/home/$ARCHIVEBOX_USER/.cache" \
&& install -d -o "$DEFAULT_PUID" -g "$DEFAULT_PGID" "/home/$ARCHIVEBOX_USER/.config/abx" "/home/$ARCHIVEBOX_USER/.cache/abxbus" "/home/$ARCHIVEBOX_USER/.cache/pnpm" "/home/$ARCHIVEBOX_USER/.cache/uv" \
&& chown "$DEFAULT_PUID:$DEFAULT_PGID" "$DATA_DIR" "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
&& openssl rand -hex 16 > /etc/machine-id \
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER PUID=$(id -u "$ARCHIVEBOX_USER") PGID=$(id -g "$ARCHIVEBOX_USER")" | tee -a /VERSION.txt \
&& echo -e "TMP_DIR=$TMP_DIR\nLIB_DIR=$LIB_DIR\nPLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH\nMACHINE_ID=$(cat /etc/machine-id)\n" | tee -a /VERSION.txt
WORKDIR "$DATA_DIR"
RUN echo "[+] Initializing image collection..." \
&& find "$DATA_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} + \
&& PUID=0 PGID=0 archivebox init \
&& (chown "$DEFAULT_PUID:$DEFAULT_PGID" \
"$DATA_DIR" "$DATA_DIR"/.archivebox_id "$DATA_DIR"/ArchiveBox.conf "$DATA_DIR"/index.sqlite3 \
"$DATA_DIR"/logs "$DATA_DIR"/logs/* "$DATA_DIR"/sources \
"$DATA_DIR"/archive "$DATA_DIR"/archive/users "$DATA_DIR"/personas \
"$DATA_DIR"/tmp "$DATA_DIR"/tmp/* \
"$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
2>/dev/null || true)
RUN chmod +x "$CODE_DIR"/bin/*.sh \
&& chmod g+w "$TMP_DIR" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH"
RUN "$LIB_DIR/bin/chromium" --version | tee -a /VERSION.txt \
&& "$LIB_DIR/uv/packages/papers-dl/venv/bin/papers-dl" --version | tee -a /VERSION.txt \
&& /usr/bin/rg --version | head -1 | tee -a /VERSION.txt \
&& /usr/local/bin/sonic --version | tee -a /VERSION.txt \
&& /venv/bin/supervisord --version | tee -a /VERSION.txt \
&& for forbidden_bin in gcc g++ make; do ! command -v "$forbidden_bin" || (echo "Unexpected build tool in runtime: $forbidden_bin=$(command -v "$forbidden_bin")" >&2 && exit 1); done \
&& stat -c "%U:%G %a %n" "$LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "$LIB_DIR" \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups archivebox version 2>&1 | tee -a /VERSION.txt \
&& rm -rf /root/.cache /var/cache/apt/* /var/lib/apt/lists/*
RUN (echo -e "\n\n[√] Finished ArchiveBox multistage Docker build successfully." \
&& echo -e "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) (${TARGETARCH} ${TARGETVARIANT})" \
&& echo -e "BUILD_END_TIME=$(date +"%Y-%m-%d %H:%M:%S %s")\n\n" \
) | tee -a /VERSION.txt
WORKDIR "$DATA_DIR"
VOLUME "$DATA_DIR"
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=20s --retries=15 \
CMD curl --fail --silent --show-error --max-time 5 --connect-timeout 2 'http://admin.archivebox.localhost:8000/health/' | grep -q 'OK'
ENTRYPOINT ["dumb-init", "--", "/app/bin/docker_entrypoint.sh"]
CMD ["archivebox", "server", "--init", "0.0.0.0:8000"]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 Nick Sweeting
Copyright (c) 2026 Nick Sweeting
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+119 -60
View File
@@ -29,7 +29,7 @@ Without active preservation effort, everything on the internet eventually disapp
*ArchiveBox is an open source tool that lets organizations & individuals archive both public & private web content while retaining control over their data. It can be used to save copies of bookmarks, preserve evidence for legal cases, backup photos from FB/Insta/Flickr or media from YT/Soundcloud/etc., save research papers, and more...*
<br/>
> ➡️ Get ArchiveBox with `pip install archivebox` on [Linux](#quickstart)/[macOS](#quickstart), or via **[Docker](#quickstart)** ⭐️ on any OS.
> ➡️ Get ArchiveBox with `uv tool install --python 3.13 --upgrade 'git+https://github.com/ArchiveBox/ArchiveBox.git@dev'` on [Linux](#quickstart)/[macOS](#quickstart), or via **[Docker](#quickstart)** ⭐️ on any OS.
*Once installed, you can interact with it through the: [Browser Extension](https://github.com/ArchiveBox/archivebox-browser-extension), [CLI](#usage), [self-hosted web interface](https://github.com/ArchiveBox/ArchiveBox/wiki/Publishing-Your-Archive), [Python API](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#python-shell-usage), or [filesystem](#static-archive-exporting).*
@@ -52,7 +52,7 @@ It also detects any content featured *inside* pages & extracts it out into a fol
- 💾 **Github**/**Gitlab**/etc. links ➡️ `clone of GIT source code`, `README`, `images`, ...
- ✨ *and more, see [Output Formats](#output-formats) below...*
You can run ArchiveBox as a Docker web app to manage these snapshots, or continue accessing the same collection using the `pip`-installed CLI, Python API, and SQLite3 APIs.
You can run ArchiveBox as a Docker web app to manage these snapshots, or continue accessing the same collection using the `uv`-installed CLI, Python API, and SQLite3 APIs.
All the ways of using it are equivalent, and provide matching features like adding tags, scheduling regular crawls, viewing logs, and more...
<br/>
@@ -68,7 +68,7 @@ The goal is to sleep soundly knowing the part of the internet you care about wil
<br/>
**📦&nbsp; Install ArchiveBox using your preferred method: `docker` / `pip` / `apt` / etc. ([see full Quickstart below](#quickstart)).**
**📦&nbsp; Install ArchiveBox using your preferred method: `docker` / `uv` / `brew` / `apt` / etc. ([see full Quickstart below](#quickstart)).**
<details>
@@ -85,14 +85,14 @@ docker compose run archivebox init
<br/>
# Option B: Or use it as a plain Docker container:
mkdir -p ~/archivebox/data && cd ~/archivebox/data
docker run -it -v $PWD:/data archivebox/archivebox init
# docker run -it -v $PWD:/data archivebox/archivebox add 'https://example.com'
# docker run -it -v $PWD:/data archivebox/archivebox help
# docker run -it -v $PWD:/data -p 8000:8000 archivebox/archivebox
docker run -it -v $PWD:/data archivebox/archivebox:dev init
# docker run -it -v $PWD:/data archivebox/archivebox:dev add 'https://example.com'
# docker run -it -v $PWD:/data archivebox/archivebox:dev help
# docker run -it -v $PWD:/data -p 8000:8000 archivebox/archivebox:dev
<br/>
<br/>
# Option C: Or install it with your preferred pkg manager (see Quickstart below for apt, brew, and more)
pip install archivebox
# Option C: Or install it with uv (see Quickstart below for apt, brew, and more)
uv tool install --python 3.13 --upgrade 'git+https://github.com/ArchiveBox/ArchiveBox.git@dev'
mkdir -p ~/archivebox/data && cd ~/archivebox/data
archivebox init
archivebox install
@@ -214,14 +214,14 @@ See <a href="#%EF%B8%8F-cli-usage">below</a> for more usage examples using the C
<li>Install <a href="https://docs.docker.com/get-docker/">Docker</a> on your system (if not already installed).</li>
<li>Create a new empty directory and initialize your collection (can be anywhere).
<pre lang="bash"><code style="white-space: pre-line">mkdir -p ~/archivebox/data && cd ~/archivebox/data
docker run -v $PWD:/data -it archivebox/archivebox init
docker run -v $PWD:/data -it archivebox/archivebox:dev init
</code></pre>
</li>
<li>Optional: Start the server then login to the Web UI <a href="http://archivebox.localhost:8000">http://archivebox.localhost:8000</a> ⇢ Admin.
<pre lang="bash"><code style="white-space: pre-line">docker run -v $PWD:/data -p 8000:8000 archivebox/archivebox
<pre lang="bash"><code style="white-space: pre-line">docker run -v $PWD:/data -p 8000:8000 archivebox/archivebox:dev
# completely optional, CLI can always be used without running a server
# docker run -v $PWD:/data -it [subcommand] [--help]
docker run -v $PWD:/data -it archivebox/archivebox help
docker run -v $PWD:/data -it archivebox/archivebox:dev help
</code></pre>
<i>For more info, see <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Install#option-a-docker--docker-compose-setup-%EF%B8%8F">Install: Docker Compose</a> in the Wiki. ➡️</i>
</li>
@@ -256,13 +256,13 @@ See <a href="https://docs.sweeting.me/s/against-curl-sh">"Against curl | sh as a
<details>
<summary><b><img src="https://user-images.githubusercontent.com/511499/117447613-ba4c5d80-af0b-11eb-8f89-1d98e31b6a79.png" alt="Pip" height="28px" align="top"/> <code>pip</code></b> (macOS/Linux/BSD)</summary>
<summary><b><img src="https://user-images.githubusercontent.com/511499/117447613-ba4c5d80-af0b-11eb-8f89-1d98e31b6a79.png" alt="uv" height="28px" align="top"/> <code>uv</code></b> (macOS/Linux/BSD)</summary>
<br/>
<ol>
<li>Install <a href="https://realpython.com/installing-python/">Python >= v3.13</a> and <a href="https://nodejs.org/en/download/package-manager/">Node >= v22</a> on your system (if not already installed).</li>
<li>Install the ArchiveBox package using <code>pip3</code> (or <a href="https://docs.astral.sh/uv/guides/tools/#running-tools"><code>uvx</code></a>).
<pre lang="bash"><code style="white-space: pre-line">pip3 install --upgrade archivebox
<li>Install <a href="https://docs.astral.sh/uv/getting-started/installation/">uv</a> on your system (if not already installed).</li>
<li>Install the ArchiveBox package using <code>uv</code>.
<pre lang="bash"><code style="white-space: pre-line">uv tool install --python 3.13 --upgrade 'git+https://github.com/ArchiveBox/ArchiveBox.git@dev'
archivebox version
# install any missing extras shown using apt/brew/pkg/etc. see Wiki for instructions
# python@3.13 node curl wget git ripgrep ...
@@ -299,12 +299,14 @@ See <a href="#%EF%B8%8F-cli-usage">below</a> for more usage examples using the C
<pre lang="bash"><code style="white-space: pre-line">echo 'deb [trusted=yes] https://archivebox.github.io/debian-archivebox dev main' | sudo tee /etc/apt/sources.list.d/archivebox.list
sudo apt update
sudo apt install archivebox
archivebox version # make sure all dependencies are installed
archivebox version # make sure the package is installed
</code></pre>
</li>
<li>Create a new empty directory and initialize your collection (can be anywhere).
<pre lang="bash"><code style="white-space: pre-line">mkdir -p ~/archivebox/data && cd ~/archivebox/data
archivebox init --install
archivebox init
archivebox install
archivebox add 'https://example.com'
</code></pre>
<br/>
</li>
@@ -317,7 +319,7 @@ archivebox help
</li>
</ol>
See <a href="#%EF%B8%8F-cli-usage">below</a> for more usage examples using the CLI, Web UI, or filesystem/SQL/Python to manage your archive.<br/>
<sub>See the <a href="https://github.com/ArchiveBox/debian-archivebox"><code>debian-archivebox</code></a> repo for more details about this distribution.</sub>
<sub>The apt package is a thin dev-channel wrapper around the normal Python install flow. See the <a href="https://github.com/ArchiveBox/debian-archivebox"><code>debian-archivebox</code></a> repo for details, and run <code>sudo archivebox install</code> only if you want it to install missing system packages via apt.</sub>
<br/><br/>
</details>
@@ -474,7 +476,23 @@ For more discussion on managed and paid hosting options see here: <a href="https
ArchiveBox commands can be run in a terminal [directly on your host](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#cli-usage), or via [Docker](https://github.com/ArchiveBox/ArchiveBox/wiki/Docker#usage-1)/[Docker Compose](https://github.com/ArchiveBox/ArchiveBox/wiki/Docker#usage).
<sup>(depending on how you chose to install it above)</sup>
<!--
```bash
set -euo pipefail
__archivebox_docs_home="$(mktemp -d)"
export HOME="$__archivebox_docs_home"
export PLUGINS=parse_txt_urls
mkdir -p ~/archivebox/data
cd ~/archivebox/data
archivebox init
archivebox version
archivebox help
test -f index.sqlite3
test -d archive
rm -rf "$__archivebox_docs_home"
```
-->
```console
mkdir -p ~/archivebox/data # create a new data dir anywhere
cd ~/archivebox/data # IMPORTANT: cd into the directory
@@ -485,8 +503,8 @@ archivebox help
# equivalent: docker compose run archivebox [subcommand] [--help]
docker compose run archivebox help
# equivalent: docker run -it -v $PWD:/data archivebox/archivebox [subcommand] [--help]
docker run -it -v $PWD:/data archivebox/archivebox help
# equivalent: docker run -it -v $PWD:/data archivebox/archivebox:dev [subcommand] [--help]
docker run -it -v $PWD:/data archivebox/archivebox:dev help
# optional: import your browser cookies into a persona for logged-in archiving
archivebox persona create --import=chrome personal
@@ -507,13 +525,15 @@ archivebox persona create --import=chrome personal
<summary><img src="https://user-images.githubusercontent.com/511499/117456282-08665e80-af16-11eb-91a1-8102eff54091.png" alt="curl sh automatic setup script" height="22px" align="top"/> <b>CLI Usage Examples: non-Docker</b></summary>
<br/>
<pre lang="bash"><code style="white-space: pre-line">
# make sure you have pip-installed ArchiveBox and it's available in your $PATH first
# make sure you have uv-installed ArchiveBox and it's available in your $PATH first
<br/>
# archivebox [subcommand] [--help]
archivebox init --install # safe to run init multiple times (also how you update versions)
mkdir -p ~/archivebox/data && cd ~/archivebox/data
archivebox init
archivebox install
archivebox add 'https://example.com'
archivebox version # get archivebox version info + check dependencies
archivebox help # get list of archivebox subcommands that can be run
archivebox add --depth=1 'https://news.ycombinator.com'
</code></pre>
<i>For more info, see our <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#cli-usage">Usage: CLI Usage</a> wiki. ➡️</i>
</details>
@@ -527,10 +547,11 @@ archivebox add --depth=1 'https://news.ycombinator.com'
# make sure you have `docker-compose.yml` from the Quickstart instructions first
<br/>
# docker compose run archivebox [subcommand] [--help]
docker compose run archivebox init --install
docker compose run archivebox init
docker compose run archivebox install
docker compose run archivebox version
docker compose run archivebox help
docker compose run archivebox add --depth=1 'https://news.ycombinator.com'
docker compose run archivebox add 'https://example.com'
# to start webserver: docker compose up
</code></pre>
<i>For more info, see our <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Docker#usage">Usage: Docker Compose CLI</a> wiki. ➡️</i>
@@ -544,12 +565,13 @@ docker compose run archivebox add --depth=1 'https://news.ycombinator.com'
<pre lang="bash"><code style="white-space: pre-line">
# make sure you create and cd into in a new empty directory first
<br/>
# docker run -it -v $PWD:/data archivebox/archivebox [subcommand] [--help]
docker run -v $PWD:/data -it archivebox/archivebox init --install
docker run -v $PWD:/data -it archivebox/archivebox version
docker run -v $PWD:/data -it archivebox/archivebox help
docker run -v $PWD:/data -it archivebox/archivebox add --depth=1 'https://news.ycombinator.com'
# to start webserver: docker run -v $PWD:/data -it -p 8000:8000 archivebox/archivebox
# docker run -it -v $PWD:/data archivebox/archivebox:dev [subcommand] [--help]
docker run -v $PWD:/data -it archivebox/archivebox:dev init
docker run -v $PWD:/data -it archivebox/archivebox:dev install
docker run -v $PWD:/data -it archivebox/archivebox:dev version
docker run -v $PWD:/data -it archivebox/archivebox:dev help
docker run -v $PWD:/data -it archivebox/archivebox:dev add 'https://example.com'
# to start webserver: docker run -v $PWD:/data -it -p 8000:8000 archivebox/archivebox:dev
</code></pre>
<i>For more info, see our <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Docker#usage-1">Usage: Docker CLI</a> wiki. ➡️</i>
</details>
@@ -581,8 +603,8 @@ nano docker-compose.yml # setup initial ADMIN_USERNAME &
docker compose up # start the server
<br/>
# Or with a Docker container:
docker run -v $PWD:/data -it archivebox/archivebox archivebox manage createsuperuser
docker run -v $PWD:/data -it -p 8000:8000 archivebox/archivebox
docker run -v $PWD:/data -it archivebox/archivebox:dev archivebox manage createsuperuser
docker run -v $PWD:/data -it -p 8000:8000 archivebox/archivebox:dev
</code></pre>
<sup>Open <a href="http://web.archivebox.localhost:8000"><code>http://web.archivebox.localhost:8000</code></a> for the public UI and <a href="http://admin.archivebox.localhost:8000"><code>http://admin.archivebox.localhost:8000</code></a> for the admin UI ➡️</sup><br/>
@@ -608,15 +630,15 @@ docker compose run archivebox config --set ...
> [!TIP]
> Whether in Docker or not, ArchiveBox commands work the same way, and can be used to access the same data on-disk.
> For example, you could run the Web UI in Docker Compose, and run one-off commands with `pip`-installed ArchiveBox.
> For example, you could run the Web UI in Docker Compose, and run one-off commands with `uv`-installed ArchiveBox.
<details>
<summary><i>Expand to show comparison...</i></summary><br/>
<pre lang="bash"><code style="white-space: pre-line">
archivebox add --depth=1 'https://example.com' # add a URL with pip-installed archivebox on the host
archivebox add --depth=1 'https://example.com' # add a URL with uv-installed archivebox on the host
docker compose run archivebox add --depth=1 'https://example.com' # or w/ Docker Compose
docker run -it -v $PWD:/data archivebox/archivebox add --depth=1 'https://example.com' # or w/ Docker, all equivalent
docker run -it -v $PWD:/data archivebox/archivebox:dev add --depth=1 'https://example.com' # or w/ Docker, all equivalent
</code></pre>
<i>For more info, see our <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Docker">Docker</a> wiki. ➡️</i>
@@ -674,19 +696,58 @@ docker run -it -v $PWD:/data archivebox/archivebox add --depth=1 'https://exampl
<img src="https://github.com/ArchiveBox/ArchiveBox/assets/511499/e1e5bd78-b0b6-45dc-914c-e1046fee4bc4" width="330px" align="right" style="float: right"/>
<!--
```bash
set -euo pipefail
__archivebox_docs_home="$(mktemp -d)"
export HOME="$__archivebox_docs_home"
mkdir -p ~/archivebox/data ~/Downloads
cd ~/archivebox/data
archivebox init
cat > ~/Downloads/some_feed.xml <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>ArchiveBox docs test feed</title>
<link>https://example.com/</link>
<description>ArchiveBox docs test feed</description>
<item>
<title>Feed item</title>
<link>https://example.com/from-feed</link>
<guid>https://example.com/from-feed</guid>
</item>
</channel>
</rss>
EOF
```
-->
<!--pytest-codeblocks:cont-->
```bash
# archivebox add --help
archivebox add 'https://example.com/some/page'
archivebox add --parser=generic_rss < ~/Downloads/some_feed.xml
archivebox add --depth=1 'https://news.ycombinator.com#2020-12-12'
echo 'http://example.com' | archivebox add
echo 'any text with <a href="https://example.com">urls</a> in it' | archivebox add
archivebox add --plugins=parse_txt_urls 'https://example.com/some/page'
archivebox add --depth=1 --plugins=parse_rss_urls < "$HOME/Downloads/some_feed.xml"
archivebox add --plugins=parse_txt_urls 'https://example.com/docs-example'
echo 'http://example.com' | archivebox add --plugins=parse_txt_urls
echo 'any text with <a href="https://example.com">urls</a> in it' | archivebox add --plugins=parse_txt_urls
# if using Docker, add -i when piping stdin:
# echo 'https://example.com' | docker run -v $PWD:/data -i archivebox/archivebox add
# echo 'https://example.com' | docker run -v $PWD:/data -i archivebox/archivebox:dev add
# if using Docker Compose, add -T when piping stdin / stdout:
# echo 'https://example.com' | docker compose run -T archivebox add
```
<!--pytest-codeblocks:cont-->
<!--
```bash
archivebox list --json > snapshots.json
grep -q 'https://example.com/some/page' snapshots.json
grep -q 'https://example.com/from-feed' snapshots.json
grep -q 'https://example.com/docs-example' snapshots.json
grep -q 'http://example.com' snapshots.json
test -d archive/users/system/crawls
test "$(grep -c '\"url\"' snapshots.json)" -ge 4
rm -rf "$__archivebox_docs_home"
```
-->
See the [Usage: CLI](https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#CLI-Usage) page for documentation and examples.
@@ -821,10 +882,8 @@ You don't need to install every dependency to use ArchiveBox. ArchiveBox will au
If not using Docker, make sure to keep the dependencies up-to-date yourself and check that ArchiveBox isn't reporting any incompatibility with the versions you install.
<pre lang="bash"><code style="white-space: pre-line">#install python3 and archivebox with your system package manager
# apt/brew/pip/etc install ... (see Quickstart instructions above)
<pre lang="bash"><code style="white-space: pre-line"># install uv + archivebox first (see Quickstart instructions above)
<br/>
which -a archivebox # see where you have installed archivebox
archivebox install # auto install all the extractors and extras
archivebox --version # see info and check validity of installed dependencies
</code></pre>
@@ -1083,7 +1142,7 @@ Because ArchiveBox is designed to ingest a large volume of URLs with multiple co
<li><strong>Don't store large collections on older filesystems like EXT3/FAT</strong> as they may not be able to handle more than 50k directory entries in the <code>data/archive/</code> folder.
</li>
<li><strong>Try to keep the <code>data/index.sqlite3</code> file on local drive (not a network mount)</strong> or SSD for maximum performance, however the <code>data/archive/</code> folder can be on a network mount or slower HDD.</li>
<li>If using Docker or NFS/SMB/FUSE for the <code>data/archive/</code> folder, you may need to set <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#puid--pgid"><code>PUID</code> & <code>PGID</code></a> and <a href="https://github.com/ArchiveBox/ArchiveBox/issues/1304">disable <code>root_squash</code></a> on your fileshare server.
<li>If using Docker or NFS/SMB/FUSE for the <code>data/archive/</code> folder, make sure the mounted data directory is writable by its intended owner and consider disabling <a href="https://github.com/ArchiveBox/ArchiveBox/issues/1304"><code>root_squash</code></a> on your fileshare server.
</li>
</ul>
@@ -1093,7 +1152,6 @@ Because ArchiveBox is designed to ingest a large volume of URLs with multiple co
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#Disk-Layout">Wiki: Usage (Disk Layout)</a></li>
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview#output-folder">Wiki: Security Overview (Output-Folder)</a></li>
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#large-archives">Wiki: Usage (Large Archives)</a></li>
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#puid--pgid">Wiki: Configuration (<code>PUID</code> & <code>GUID</code>)</a></li>
<li><a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview#do-not-run-as-root">Wiki: Security Overview (Do Not Run as Root)</a></li>
</ul>
@@ -1346,7 +1404,7 @@ For low hanging fruit / easy first tickets, see: <a href="https://github.com/Arc
First make sure you have `uv` installed: https://docs.astral.sh/uv/getting-started/installation/
```bash
```console
git clone https://github.com/ArchiveBox/monorepo
cd monorepo
./bin/setup.sh
@@ -1367,7 +1425,7 @@ Repos included in monorepo setup:
#### 2. Option A: Install the Python, JS, and system dependencies directly on your machine
```bash
```console
# Install ArchiveBox runtime dependencies
mkdir -p data && cd data
archivebox init
@@ -1382,13 +1440,14 @@ archivebox server 0.0.0.0:8000
#### 2. Option B: Build the docker container and use that for development instead
```bash
```console
# Optional: develop via docker by mounting the code dir into the container
# if you edit e.g. ./archivebox/core/models.py on the docker host, runserver
# inside the container will reload and pick up your changes
./bin/build_docker.sh dev
docker run -it -v $PWD/data:/data archivebox/archivebox:dev init --install
docker run -it -v $PWD/data:/data archivebox/archivebox:dev init
docker run -it -v $PWD/data:/data archivebox/archivebox:dev install
# Run the development server w/ autoreloading (but no bg workers)
docker run -it -v $PWD/data:/data -v $PWD/archivebox:/app/archivebox -p 8000:8000 archivebox/archivebox:dev server --debug --reload 0.0.0.0:8000
@@ -1411,7 +1470,7 @@ You can also run all these in Docker. For more examples see the GitHub Actions C
<details><summary><i>Click to expand...</i></summary>
```bash
```console
# set up persistent DEBUG=True for all runs
archivebox config --set DEBUG=True
@@ -1435,17 +1494,17 @@ https://stackoverflow.com/questions/1074212/how-can-i-see-the-raw-sql-queries-dj
If you're looking for the latest `dev` Docker image, it's often available pre-built on Docker Hub, simply pull and use `archivebox/archivebox:dev`.
```bash
```console
docker pull archivebox/archivebox:dev
docker run archivebox/archivebox:dev version
# verify the BUILD_TIME and COMMIT_HASH in the output are recent
```
##### Build Branch from Source
You can also build and run any branch yourself from source, for example to build & use `dev` locally:
```bash
```console
# docker-compose.yml:
services:
archivebox:
@@ -1469,7 +1528,7 @@ archivebox install
<details><summary><i>Click to expand...</i></summary>
```bash
```console
./bin/lint.sh
./bin/test.sh
```
@@ -1482,7 +1541,7 @@ archivebox install
<details><summary><i>Click to expand...</i></summary>
```bash
```console
# generate the database migrations after changes to models.py
cd archivebox/
./manage.py makemigrations
@@ -1546,7 +1605,7 @@ Copy a similar plugin as a template to modify, then open a new PR to add it in t
<details><summary><i>Click to expand...</i></summary>
(Normally CI takes care of this, but these scripts can be run to do it manually)
```bash
```console
./bin/build.sh
# or individually:
@@ -1562,7 +1621,7 @@ Copy a similar plugin as a template to modify, then open a new PR to add it in t
<details><summary><i>Click to expand...</i></summary>
(Normally CI takes care of this, but these scripts can be run to do it manually)
```bash
```console
./bin/release.sh
# or individually:
+10 -1
View File
@@ -54,6 +54,7 @@ class AddCommandSchema(Schema):
snapshot_max_size: int = 0
parser: str = "auto"
plugins: str = ""
persona: str = "Default"
only_new: bool | None = None
update: bool = False
overwrite: bool = False
@@ -121,14 +122,21 @@ def snapshot_filter_kwargs(args: SnapshotFilterCommandSchema, *, default_filter_
@router.post("/add", response=CLICommandResponseSchema, summary="archivebox add [args] [urls]")
def cli_add(request: HttpRequest, args: AddCommandSchema):
from archivebox.cli.archivebox_add import add
from archivebox.misc.util import validate_url
config_overrides: dict[str, object] = {}
if args.only_new is not None:
config_overrides["ONLY_NEW"] = bool(args.only_new)
if args.update or args.overwrite:
config_overrides["ONLY_NEW"] = False
submitted_urls: str | list[str] = args.urls
if len(args.urls) == 1:
try:
validate_url(args.urls[0])
except ValueError:
submitted_urls = args.urls[0]
crawl, snapshots = add(
urls=args.urls,
urls=submitted_urls,
snapshot_ids=args.snapshot_ids,
tag=args.tag,
depth=args.depth,
@@ -138,6 +146,7 @@ def cli_add(request: HttpRequest, args: AddCommandSchema):
snapshot_max_size=args.snapshot_max_size,
index_only=args.index_only,
plugins=args.plugins,
persona=args.persona,
parser=args.parser,
bg=True, # Always run in background for API calls
created_by_id=request.user.pk,
+11 -2
View File
@@ -4,6 +4,7 @@ import math
import json
import mimetypes
import re
from html import unescape
from collections import defaultdict
from pathlib import Path, PurePosixPath
from uuid import UUID
@@ -832,7 +833,7 @@ def _snapshots_rss_response(
crawl_user = snapshot.crawl.created_by if snapshot.crawl_id else None
description = f"Original URL: {snapshot.url}\nArchived snapshot: {archived_url}"
feed.add_item(
title=snapshot.title or snapshot.url,
title=unescape(snapshot.title or snapshot.url),
link=archived_url or web_base_url,
description=description,
unique_id=str(snapshot.id),
@@ -1051,7 +1052,15 @@ def delete_snapshot(request: HttpRequest, snapshot_id: str):
snapshot = get_snapshot(request, snapshot_id, with_archiveresults=False)
snapshot_id_str = str(snapshot.id)
crawl_id_str = str(snapshot.crawl.pk)
deleted_count, _ = snapshot.delete()
snapshot.cancel()
from archivebox.crawls.locks import crawl_lifecycle_lock
from archivebox.services.runner import run_pending_crawls
with crawl_lifecycle_lock(crawl_id_str):
run_pending_crawls(crawl_id=crawl_id_str, daemon=False)
snapshot = get_snapshot(request, snapshot_id_str, with_archiveresults=False)
deleted_count, _ = snapshot.delete()
return {
"success": True,
"snapshot_id": snapshot_id_str,
+12 -3
View File
@@ -1,5 +1,6 @@
__package__ = "archivebox.api"
import json
from pathlib import Path
from uuid import UUID
from datetime import datetime
@@ -124,7 +125,7 @@ def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
config = dict(data.config or {})
config.setdefault("PERMISSIONS", str(get_config().PERMISSIONS))
crawl = Crawl.objects.create(
urls="\n".join(urls),
urls="\n".join(json.dumps({"type": "CrawlSeed", "url": url, "depth": 0}, separators=(",", ":")) for url in urls),
max_depth=data.max_depth,
tags_str=",".join(tags),
label=data.label,
@@ -250,8 +251,16 @@ def patch_crawl(request: HttpRequest, crawl_id: str, data: CrawlUpdateSchema):
def delete_crawl(request: HttpRequest, crawl_id: str):
crawl = get_crawl_by_ref(crawl_id)
crawl_id_str = str(crawl.id)
snapshot_count = crawl.snapshot_set.count()
deleted_count, _ = crawl.delete()
crawl.cancel()
from archivebox.crawls.locks import crawl_lifecycle_lock
from archivebox.services.runner import run_pending_crawls
with crawl_lifecycle_lock(crawl_id_str):
run_pending_crawls(crawl_id=crawl_id_str, daemon=False)
crawl = get_crawl_by_ref(crawl_id_str)
snapshot_count = crawl.snapshot_set.count()
deleted_count, _ = crawl.delete()
return {
"success": True,
"crawl_id": crawl_id_str,
+6 -4
View File
@@ -18,10 +18,11 @@ if "--debug" in sys.argv:
# Universal `--init` flag: when passed to ANY subcommand (e.g. `archivebox server --init`,
# `archivebox add --init`, `archivebox shell --init`), run a `quick` archivebox init before
# the subcommand executes. Strip it from argv here so each subcommand's own click parser
# never sees it. Ignored for `help` and `init` themselves.
if "--init" in sys.argv:
sys.argv = [arg for arg in sys.argv if arg != "--init"]
# the subcommand executes. `--quick-init` is the old server spelling. Strip either from
# argv here so each subcommand's own click parser never sees it. Ignored for `help` and
# `init` themselves.
if "--init" in sys.argv or "--quick-init" in sys.argv:
sys.argv = [arg for arg in sys.argv if arg not in ("--init", "--quick-init")]
os.environ["ARCHIVEBOX_WANTS_INIT"] = "1"
@@ -32,6 +33,7 @@ class ArchiveBoxGroup(click.Group):
"help": "archivebox.cli.archivebox_help.main",
"version": "archivebox.cli.archivebox_version.main",
"mcp": "archivebox.cli.archivebox_mcp.main",
"oneshot": "archivebox.cli.archivebox_oneshot.main",
}
setup_commands = {
"init": "archivebox.cli.archivebox_init.main",
+53 -39
View File
@@ -5,14 +5,15 @@ __package__ = "archivebox.cli"
__command__ = "archivebox add"
import sys
import json
import os
import json
from pathlib import Path
from typing import Any, TYPE_CHECKING
import rich_click as click
from archivebox.config import CONSTANTS
from archivebox.misc.util import enforce_types, docstring
from archivebox.misc.util import parse_filesize_to_bytes
@@ -23,21 +24,28 @@ if TYPE_CHECKING:
from archivebox.core.models import Snapshot
def _collect_input_urls(args: tuple[str, ...]) -> list[str]:
def _collect_input_urls(args: tuple[str, ...], *, parser: str = "auto") -> list[str]:
from archivebox.misc.jsonl import read_args_or_stdin
from archivebox.misc.util import validate_url
urls: list[str] = []
for record in read_args_or_stdin(args):
url = record.get("url")
if isinstance(url, str) and url:
urls.append(url)
try:
urls.append(validate_url(url))
except ValueError as err:
raise click.BadParameter(str(err), param_hint="URL") from err
urls_field = record.get("urls")
if isinstance(urls_field, str):
for line in urls_field.splitlines():
line = line.strip()
if line and not line.startswith("#"):
urls.append(line)
try:
urls.append(validate_url(line))
except ValueError as err:
raise click.BadParameter(str(err), param_hint="URL") from err
return urls
@@ -82,7 +90,6 @@ def add(
crawl_max_size = parse_filesize_to_bytes(crawl_max_size)
crawl_timeout = int(crawl_timeout or 0)
snapshot_max_size = parse_filesize_to_bytes(snapshot_max_size)
from archivebox import CONSTANTS
from archivebox.config.permissions import USER, HOSTNAME
from archivebox.config.common import get_config
@@ -108,7 +115,6 @@ def add(
# import models once django is set up
from archivebox.crawls.models import Crawl
from archivebox.core.models import Snapshot
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.personas.models import Persona
from archivebox.misc.logging_util import printable_filesize
@@ -119,34 +125,12 @@ def add(
created_by_id = created_by_id or get_or_create_system_user_pk()
started_at = timezone.now()
if isinstance(urls, str):
url_list = [line.strip() for line in urls.splitlines() if line.strip()]
else:
url_list = [str(url).strip() for url in urls if str(url).strip()]
if snapshot_ids and len(snapshot_ids) != len(url_list):
raise ValueError("snapshot_ids length must match urls length")
admitted_urls: list[str] = []
admitted_snapshot_ids: list[str] | None = [] if snapshot_ids else None
for index, url in enumerate(url_list):
if Snapshot.is_archivebox_internal_url(url):
print(f"[yellow][!] Skipping internal ArchiveBox URL: {url}[/yellow]")
continue
admitted_urls.append(url)
if admitted_snapshot_ids is not None and snapshot_ids:
admitted_snapshot_ids.append(snapshot_ids[index])
# 1. Save the provided URLs to sources/2024-11-05__23-59-59__cli_add.txt
sources_file = CONSTANTS.SOURCES_DIR / f"{timezone.now().strftime('%Y-%m-%d__%H-%M-%S')}__cli_add.txt"
sources_file.parent.mkdir(parents=True, exist_ok=True)
if admitted_snapshot_ids is not None:
sources_file.write_text(
"\n".join(
json.dumps({"url": url, "id": admitted_snapshot_ids[index], "tags": tag, "depth": 0})
for index, url in enumerate(admitted_urls)
),
)
else:
sources_file.write_text("\n".join(admitted_urls))
use_internal_input_root = isinstance(urls, str)
source_text = (
urls
if use_internal_input_root
else "\n".join(json.dumps({"type": "CrawlSeed", "url": str(url), "depth": 0}, separators=(",", ":")) for url in urls)
)
# 2. Create a new Crawl with inline URLs
cli_args = [*sys.argv]
@@ -156,10 +140,9 @@ def add(
timestamp = timezone.now().strftime("%Y-%m-%d__%H-%M-%S")
urls_content = sources_file.read_text()
persona_name = (persona or "Default").strip() or "Default"
plugins = plugins or ""
persona_obj, _ = Persona.objects.get_or_create(name=persona_name)
persona_obj = Persona.get_or_create_named(persona_name)
persona_obj.ensure_dirs()
effective_persona_config = get_config(persona=persona_obj)
@@ -186,9 +169,18 @@ def add(
# stripped by Crawl.save() and rederived when hooks run.
crawl_config.update(config_overrides)
# Crawl.urls is the user's submitted source, not a derived work queue for
# this add path. Keeping it byte-for-byte readable is what lets API/UI/CLI
# callers audit or resume imports without losing RSS/Netscape/JSON metadata
# that is not representable as one plain URL per line.
crawl = Crawl.objects.create(
urls=urls_content,
max_depth=depth,
urls=source_text,
# Stdin/import text gets an extra hop because the synthetic
# archivebox://internal root lives at depth 0 and parser-discovered
# URLs land at depth 1; direct URL args become the depth=0 input
# snapshots themselves so --depth=N matches the deepest hop the user
# asked for.
max_depth=depth + 1 if use_internal_input_root else depth,
tags_str=tag,
persona_id=persona_obj.id,
label=f"{USER}@{HOSTNAME} $ {cmd_str} [{timestamp}]",
@@ -222,6 +214,9 @@ def add(
print(
"[yellow]\\[*] URLs queued. The background runner will process them (run `archivebox server` or `archivebox run --daemon` if not already running).[/yellow]",
)
from archivebox.services.runner import ensure_background_runner
ensure_background_runner(allow_under_pytest=True)
else:
# Foreground mode: run full crawl runner until all work is done
print("[green]\\[*] Starting crawl runner to process crawl...[/green]")
@@ -336,6 +331,7 @@ def add(
@click.option("--url-denylist", "--domain-denylist", default="", help="Comma-separated URL/domain denylist for this crawl")
@click.option("--parser", default="auto", help="Parser for reading input URLs (auto, txt, html, rss, json, jsonl, netscape, ...)")
@click.option("--plugins", "-p", default="", help="Comma-separated list of plugins to run e.g. title,favicon,screenshot,singlefile,...")
@click.option("--extract", default="", help="Comma-separated plugins or output types to extract e.g. title,pdf,text/html,image,...")
@click.option("--persona", default="Default", help="Authentication profile to use when archiving")
@click.option(
"--only-new/--no-only-new",
@@ -357,7 +353,12 @@ def main(**kwargs):
with foreground_shutdown_signals(), foreground_parent_watchdog():
raw_urls = kwargs.pop("urls")
urls = _collect_input_urls(raw_urls)
if raw_urls:
urls = _collect_input_urls(raw_urls, parser=kwargs.get("parser", "auto"))
elif not sys.stdin.isatty():
urls = sys.stdin.read()
else:
urls = []
if not urls:
raise click.UsageError("No URLs provided. Pass URLs as arguments or via stdin.")
if int(kwargs.get("max_urls") or 0) < 0:
@@ -380,10 +381,23 @@ def main(**kwargs):
overwrite = kwargs.pop("overwrite", False)
update = kwargs.pop("update", False)
only_new = kwargs.pop("only_new", None)
extract = kwargs.pop("extract", "")
if overwrite or update:
only_new = False
if only_new is not None:
kwargs["config"] = {"ONLY_NEW": bool(only_new)}
if extract:
from abx_dl.models import discover_plugins, plugins_matching_output
all_plugins = discover_plugins()
tokens = [token.strip() for token in extract.split(",") if token.strip()]
plugin_names = {name.lower(): name for name in all_plugins}
selected = [plugin_names[token.lower()] for token in tokens if token.lower() in plugin_names]
selected += plugins_matching_output(all_plugins, tokens)
if not selected:
raise click.UsageError(f"No plugins found matching extract types: {extract}")
existing = [token.strip() for token in (kwargs.get("plugins") or "").split(",") if token.strip()]
kwargs["plugins"] = ",".join(dict.fromkeys([*existing, *selected]))
add(urls=urls, **kwargs)
+11 -2
View File
@@ -65,6 +65,7 @@ def create_crawl(
1: Failure
"""
from archivebox.misc.jsonl import read_args_or_stdin, write_record, TYPE_CRAWL
from archivebox.misc.util import validate_url
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
@@ -98,7 +99,11 @@ def create_crawl(
# Collect URLs
url = record.get("url")
if url:
url_list.append(url)
try:
url_list.append(validate_url(str(url)))
except ValueError as err:
rprint(f"[red]Invalid URL: {err}[/red]", file=sys.stderr)
return 1
# Handle 'urls' field (newline-separated)
urls_field = record.get("urls")
@@ -106,7 +111,11 @@ def create_crawl(
for line in urls_field.split("\n"):
line = line.strip()
if line and not line.startswith("#"):
url_list.append(line)
try:
url_list.append(validate_url(line))
except ValueError as err:
rprint(f"[red]Invalid URL: {err}[/red]", file=sys.stderr)
return 1
# Output pass-through records first
if not is_tty:
+25 -30
View File
@@ -46,7 +46,6 @@ def process_archiveresult_by_id(archiveresult_id: str) -> int:
through the shared crawl runner with the corresponding plugin selected.
"""
from rich import print as rprint
from django.utils import timezone
from archivebox.core.models import ArchiveResult
from archivebox.api.v1_core import _uuid_ref_query
from archivebox.services.runner import run_crawl
@@ -60,22 +59,9 @@ def process_archiveresult_by_id(archiveresult_id: str) -> int:
rprint(f"[blue]Extracting {archiveresult.plugin} for {archiveresult.snapshot.url}[/blue]", file=sys.stderr)
try:
was_paused = archiveresult.snapshot.is_paused
archiveresult.reset_for_retry()
snapshot = archiveresult.snapshot
if not was_paused:
snapshot.queue_for_extraction()
else:
# A paused snapshot may still accept explicit maintenance for one
# ArchiveResult, but this path must not transition it back to
# queued/startable work. Guard: only set retry_at while the row is
# still paused — concurrent resume would otherwise see a stale
# retry_at marker.
snapshot.safe_update(
{"retry_at": timezone.now()},
refresh=False,
extra_filter={"status": snapshot.StatusChoices.PAUSED},
)
snapshot.queue_for_extraction()
crawl = snapshot.crawl
if not crawl.claim_processing_lock(lock_seconds=10):
rprint(
@@ -84,11 +70,7 @@ def process_archiveresult_by_id(archiveresult_id: str) -> int:
)
return 1
try:
run_crawl(str(snapshot.crawl_id), snapshot_ids=[str(snapshot.id)], selected_plugins=[archiveresult.plugin])
finally:
if was_paused:
snapshot.restore_paused_scheduler_marker()
run_crawl(str(snapshot.crawl_id), snapshot_ids=[str(snapshot.id)], selected_plugins=[archiveresult.plugin])
archiveresult.refresh_from_db()
if archiveresult.status == ArchiveResult.StatusChoices.SUCCEEDED:
@@ -290,12 +272,9 @@ def run_plugins(
queue_at = timezone.now()
if existing_snapshot_ids:
if requested_rows:
# Targeted ArchiveResult retries use retry_at as the scheduling
# signal and keep sealed snapshots sealed so extractors are not
# re-run outside the explicitly queued plugin rows. Paused snapshots
# also keep status=paused here: `retry_at` only asks the orchestrator
# to process the queued plugin rows, and run_due_snapshot restores
# retry_at=MAX afterward instead of resuming the snapshot lifecycle.
# Search indexing on a sealed Snapshot is the only targeted hook
# allowed to bypass the normal lifecycle. Every other requested
# plugin requeues its Snapshot through the unified state machine.
affected_snapshot_ids = {snapshot_id for snapshot_id, _plugin_name, _hook_name in rows_to_queue}
if preserve_queued and queued_rows:
queued_snapshot_ids = {snapshot_id for snapshot_id, _plugin_name, _hook_name in queued_rows}
@@ -312,14 +291,30 @@ def run_plugins(
flat=True,
)
)
requested_plugins_by_id: dict[str, set[str]] = defaultdict(set)
for snapshot_id, plugin_name, _hook_name in requested_rows:
requested_plugins_by_id[snapshot_id].add(plugin_name)
for snapshot in Snapshot.objects.filter(id__in=affected_snapshot_ids).only("id", "status", "modified_at"):
# Guard the read-time status so we never bump retry_at on a
# row that's been re-queued / started by a concurrent runner.
snapshot.safe_update(
{"retry_at": queue_at, "modified_at": queue_at},
refresh=False,
extra_filter={"status": snapshot.status},
plugin_names = requested_plugins_by_id.get(str(snapshot.id), set())
sealed_search_backfill = (
snapshot.status == Snapshot.StatusChoices.SEALED
and plugin_names
and all(plugin_name.startswith("search_backend_") for plugin_name in plugin_names)
)
if sealed_search_backfill:
snapshot.safe_update(
{"retry_at": queue_at, "modified_at": queue_at},
refresh=False,
extra_filter={"status": snapshot.status},
)
else:
snapshot.update_and_requeue(
status=Snapshot.StatusChoices.QUEUED,
retry_at=queue_at,
current_step=0,
)
else:
# No plugin rows were requested, so this is a full snapshot retry.
for snapshot in Snapshot.objects.filter(id__in=existing_snapshot_ids).only("id", "status", "retry_at", "modified_at"):
+3 -4
View File
@@ -153,8 +153,8 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
config = get_config()
config.TMP_DIR.mkdir(parents=True, exist_ok=True)
config.LIB_DIR.mkdir(parents=True, exist_ok=True)
(config.LIB_DIR / "bin").mkdir(parents=True, exist_ok=True)
config.ABXPKG_LIB_DIR.mkdir(parents=True, exist_ok=True)
(config.ABXPKG_LIB_DIR / "bin").mkdir(parents=True, exist_ok=True)
working_tmp_dir = get_or_create_working_tmp_dir(autofix=True, quiet=True)
if working_tmp_dir:
@@ -163,7 +163,6 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
working_lib_dir = get_or_create_working_lib_dir(autofix=True, quiet=True)
if working_lib_dir:
working_lib_dir.mkdir(parents=True, exist_ok=True)
(working_lib_dir / "bin").mkdir(parents=True, exist_ok=True)
if install:
from archivebox.cli.archivebox_install import install as install_method
@@ -187,7 +186,7 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
@click.command()
@click.option("--force", "-f", is_flag=True, help="Ignore unrecognized files in current directory and initialize anyway")
@click.option("--quick", "-q", is_flag=True, help="Run any updates or migrations without rechecking all snapshot dirs")
@click.option("--install", "-s", is_flag=True, help="Automatically install dependencies and extras used for archiving")
@click.option("--install", "--setup", "-s", is_flag=True, help="Automatically install dependencies and extras used for archiving")
@docstring(init.__doc__)
def main(**kwargs) -> None:
init(**kwargs)
+40 -1
View File
@@ -3,6 +3,8 @@
__package__ = "archivebox.cli"
import os
import shutil
from pathlib import Path
import rich_click as click
from rich import print
@@ -10,6 +12,38 @@ from rich import print
from archivebox.misc.util import docstring, enforce_types
def ensure_data_dir_lib_symlink(data_dir: Path, abxpkg_lib_dir: Path) -> Path | None:
"""Expose the shared abxpkg lib dir at DATA_DIR/lib after install."""
link_path = data_dir / "lib"
target_config_path = abxpkg_lib_dir.expanduser().absolute()
target_path = abxpkg_lib_dir.expanduser().resolve()
from archivebox.config.paths import get_machine_type
scoped_data_lib_path = (link_path / get_machine_type()).absolute()
if target_config_path == scoped_data_lib_path:
if link_path.is_symlink() or link_path.is_file():
link_path.unlink()
target_config_path.mkdir(parents=True, exist_ok=True)
return None
if link_path.resolve(strict=False) == target_path:
return None
target_path.mkdir(parents=True, exist_ok=True)
if link_path.is_symlink():
if link_path.resolve(strict=False) == target_path:
return link_path
link_path.unlink()
elif link_path.exists():
shutil.rmtree(link_path)
relative_target = os.path.relpath(target_path, start=link_path.parent)
link_path.symlink_to(relative_target, target_is_directory=True)
return link_path
@enforce_types
def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bool = False) -> None:
"""Detect and install ArchiveBox dependencies by running the abx-dl install flow
@@ -67,6 +101,11 @@ def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bo
run_install(plugin_names=plugin_names or None)
from archivebox.config.common import get_config
config = get_config(include_machine=False)
ensure_data_dir_lib_symlink(CONSTANTS.DATA_DIR, config.ABXPKG_LIB_DIR)
print()
# Check for superuser
@@ -84,7 +123,7 @@ def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bo
# Django is already loaded, so just import and call the function directly
from archivebox.cli.archivebox_version import version as show_version
show_version(quiet=False)
show_version(quiet=False, binaries=binaries)
@click.command()
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
__package__ = "archivebox.cli"
__command__ = "archivebox oneshot"
import subprocess
from pathlib import Path
import rich_click as click
from archivebox.config import CONSTANTS
@click.command(add_help_option=False, context_settings=dict(ignore_unknown_options=True))
@click.argument("args", nargs=-1)
def main(args: tuple[str, ...] = ()) -> None:
"""Download URLs using abx-dl"""
cwd = Path.cwd()
if any((path / CONSTANTS.SQL_INDEX_FILENAME).exists() for path in (cwd, *cwd.parents)):
raise click.ClickException(
"Refusing to run `archivebox oneshot` inside an ArchiveBox DATA_DIR. Use `archivebox add` here, or run oneshot from another directory.",
)
raise SystemExit(subprocess.run(["abx-dl", *args]).returncode)
if __name__ == "__main__":
main()
-63
View File
@@ -137,65 +137,6 @@ def get_edge_user_data_dir() -> Path | None:
return None
def get_browser_binary(browser: str) -> str | None:
system = platform.system()
home = Path.home()
browser = browser.lower()
if system == "Darwin":
candidates = {
"chrome": ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
"chromium": ["/Applications/Chromium.app/Contents/MacOS/Chromium"],
"brave": ["/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
"edge": ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
}.get(browser, [])
elif system == "Linux":
candidates = {
"chrome": [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome-beta",
"/usr/bin/google-chrome-unstable",
],
"chromium": ["/usr/bin/chromium", "/usr/bin/chromium-browser"],
"brave": ["/usr/bin/brave-browser", "/usr/bin/brave-browser-beta", "/usr/bin/brave-browser-nightly"],
"edge": [
"/usr/bin/microsoft-edge",
"/usr/bin/microsoft-edge-stable",
"/usr/bin/microsoft-edge-beta",
"/usr/bin/microsoft-edge-dev",
],
}.get(browser, [])
elif system == "Windows":
local_app_data = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
candidates = {
"chrome": [
str(local_app_data / "Google" / "Chrome" / "Application" / "chrome.exe"),
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
],
"chromium": [str(local_app_data / "Chromium" / "Application" / "chrome.exe")],
"brave": [
str(local_app_data / "BraveSoftware" / "Brave-Browser" / "Application" / "brave.exe"),
"C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
"C:\\Program Files (x86)\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
],
"edge": [
str(local_app_data / "Microsoft" / "Edge" / "Application" / "msedge.exe"),
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
],
}.get(browser, [])
else:
candidates = []
for candidate in candidates:
if candidate and Path(candidate).exists():
return candidate
return None
BROWSER_PROFILE_FINDERS = {
"chrome": get_chrome_user_data_dir,
"chromium": get_chrome_user_data_dir, # Same locations
@@ -317,10 +258,6 @@ def create_personas(
if profile is None and (source_profile_dir / "Default").exists():
profile = "Default"
browser_binary = get_browser_binary(import_from)
if browser_binary:
rprint(f"[dim]Using {import_from} binary: {browser_binary}[/dim]", file=sys.stderr)
created_count = 0
for name in name_list:
name = name.strip()
+2
View File
@@ -34,6 +34,7 @@ def remove(
after: float | None = None,
before: float | None = None,
yes: bool = False,
delete: bool = False,
out_dir: Path = CONSTANTS.DATA_DIR,
timeout: float | None = None,
**kwargs,
@@ -142,6 +143,7 @@ def remove(
@click.command()
@click.option("--yes", is_flag=True, help="Remove links instantly without prompting to confirm")
@click.option("--delete", is_flag=True, help="Compatibility no-op; archived content is deleted automatically")
@click.option("--timeout", type=float, default=None, help="Maximum seconds to spend deleting snapshots")
@snapshot_filter_options(default_filter_type="exact")
@docstring(remove.__doc__)
+48 -8
View File
@@ -248,7 +248,12 @@ def process_stdin_records() -> int:
return 0
def run_runner(daemon: bool = False, crawl_id: str | None = None, maintenance_only: bool = False) -> int:
def run_runner(
daemon: bool = False,
crawl_id: str | None = None,
maintenance_only: bool = False,
maintenance_batch_size: int | None = None,
) -> int:
"""
Run the background runner loop.
@@ -282,7 +287,7 @@ def run_runner(daemon: bool = False, crawl_id: str | None = None, maintenance_on
current.mark_exited()
return 0
recover_orchestrator_state(include_chrome=True)
recover_orchestrator_state(include_chrome=crawl_id is None, crawl_id=crawl_id)
if crawl_id:
from django.utils import timezone
from archivebox.crawls.models import Crawl
@@ -319,6 +324,7 @@ def run_runner(daemon: bool = False, crawl_id: str | None = None, maintenance_on
crawl_id=crawl_id,
maintenance_only=maintenance_only,
interactive_interrupts=interactive_interrupts,
**({"maintenance_batch_size": maintenance_batch_size} if maintenance_batch_size else {}),
)
return 0
except KeyboardInterrupt:
@@ -342,9 +348,24 @@ def run_runner(daemon: bool = False, crawl_id: str | None = None, maintenance_on
@click.option("--crawl-id", help="Run the crawl runner for a specific crawl only")
@click.option("--snapshot-id", help="Run one snapshot through its crawl")
@click.option("--binary-id", help="Run one queued binary install directly on the bus")
@click.option("--maintenance-only", is_flag=True, help="Only process due maintenance ticks on sealed/paused snapshots")
@click.option("--maintenance-only", is_flag=True, help="Only process sealed Snapshot maintenance and search-index backfills")
@click.option(
"--maintenance-batch-size",
type=int,
default=None,
hidden=True,
help="Limit queued maintenance snapshots claimed per scheduler tick",
)
@click.option("--no-stdin", is_flag=True, hidden=True, help="Run the scheduler even when stdin is not a TTY")
def main(daemon: bool, crawl_id: str, snapshot_id: str, binary_id: str, maintenance_only: bool, no_stdin: bool):
def main(
daemon: bool,
crawl_id: str,
snapshot_id: str,
binary_id: str,
maintenance_only: bool,
maintenance_batch_size: int | None,
no_stdin: bool,
):
"""
Process queued work.
@@ -367,7 +388,7 @@ def main(daemon: bool, crawl_id: str, snapshot_id: str, binary_id: str, maintena
),
foreground_parent_watchdog(enabled=False),
):
sys.exit(run_runner(daemon=True, maintenance_only=maintenance_only))
sys.exit(run_runner(daemon=True, maintenance_only=maintenance_only, maintenance_batch_size=maintenance_batch_size))
except KeyboardInterrupt:
sys.exit(0)
@@ -391,23 +412,38 @@ def main(daemon: bool, crawl_id: str, snapshot_id: str, binary_id: str, maintena
sys.exit(1)
if crawl_id:
sys.exit(run_runner(daemon=False, crawl_id=crawl_id, maintenance_only=maintenance_only))
sys.exit(
run_runner(
daemon=False,
crawl_id=crawl_id,
maintenance_only=maintenance_only,
maintenance_batch_size=maintenance_batch_size,
),
)
if maintenance_only:
sys.exit(run_runner(daemon=daemon, maintenance_only=True))
sys.exit(run_runner(daemon=daemon, maintenance_only=True, maintenance_batch_size=maintenance_batch_size))
if not no_stdin and not sys.stdin.isatty():
sys.exit(process_stdin_records())
else:
sys.exit(run_runner(daemon=daemon, maintenance_only=maintenance_only))
sys.exit(run_runner(daemon=daemon, maintenance_only=maintenance_only, maintenance_batch_size=maintenance_batch_size))
def run_snapshot_worker(snapshot_id: str) -> 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
from archivebox.machine.models import Process
from archivebox.core.models import Snapshot
from archivebox.services.runner import run_due_snapshot
from django.utils import timezone
current = Process.current()
if not enter_single_runner_gate(current, data_dir=CONSTANTS.DATA_DIR):
current.mark_exited()
return 0
snapshot = None
try:
with foreground_shutdown_signals(), foreground_parent_watchdog():
@@ -437,6 +473,10 @@ def run_snapshot_worker(snapshot_id: str) -> int:
traceback.print_exc()
return 1
finally:
current.refresh_from_db()
if current.status != Process.StatusChoices.EXITED:
current.mark_exited()
if __name__ == "__main__":
+8
View File
@@ -231,6 +231,7 @@ def server(
debug: bool = False,
daemonize: bool = False,
nothreading: bool = False,
createsuperuser: bool = False,
) -> None:
"""Run the ArchiveBox HTTP server"""
from archivebox.config.common import get_config
@@ -244,6 +245,12 @@ def server(
from django.contrib.auth.models import User
if createsuperuser:
from archivebox.cli.archivebox_manage import manage
manage(args=["createsuperuser"])
print()
if not User.objects.filter(is_superuser=True).exclude(username="system").exists():
print()
print(
@@ -404,6 +411,7 @@ def server(
@click.option("--debug", is_flag=True, help="Enable DEBUG=True mode with more verbose errors")
@click.option("--nothreading", is_flag=True, help="Force runserver to run in single-threaded mode")
@click.option("--daemonize", is_flag=True, help="Run the server in the background as a daemon")
@click.option("--createsuperuser", is_flag=True, help="Run archivebox manage createsuperuser before starting the server")
@docstring(server.__doc__)
def main(**kwargs):
server(**kwargs)
+3 -3
View File
@@ -67,7 +67,7 @@ def create_snapshots(
TYPE_SNAPSHOT,
TYPE_CRAWL,
)
from archivebox.misc.util import validate_url_length
from archivebox.misc.util import validate_url
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.core.models import Snapshot
from archivebox.crawls.models import Crawl
@@ -112,7 +112,7 @@ def create_snapshots(
# Create snapshots for each URL in the crawl
for url in crawl.get_urls_list():
try:
validate_url_length(url)
validate_url(url)
except ValueError as err:
rprint(f"[red]Error creating snapshot: {err}[/red]", file=sys.stderr)
continue
@@ -135,7 +135,7 @@ def create_snapshots(
elif record_type == TYPE_SNAPSHOT or record.get("url"):
# Input is a Snapshot or plain URL
if record.get("url"):
validate_url_length(str(record["url"]))
validate_url(str(record["url"]))
if tag and not record.get("tags"):
record["tags"] = tag
if status:
+36 -12
View File
@@ -76,9 +76,14 @@ def reindex_snapshots(
wait_for_turn=None,
) -> dict[str, Any]:
from archivebox.cli.archivebox_extract import run_plugins
from archivebox.core.models import ArchiveResult
from archivebox.core.models import ArchiveResult, Snapshot
from abx_dl.models import discover_plugins
# Search backfill is the one maintenance hook allowed to execute without
# reopening a Snapshot. Restrict that exception to already-sealed rows;
# every open lifecycle state remains owned by the normal state machine.
snapshots = snapshots.filter(status=Snapshot.StatusChoices.SEALED)
stats: dict[str, Any] = {"processed": 0, "requested": 0, "queued": 0, "skipped_queued": 0, "reindexed": 0, "snapshot_ids": []}
records: list[dict[str, str]] = []
plugins_by_name = discover_plugins(runtime="archivebox")
@@ -228,12 +233,21 @@ def update(
command_owns_foreground_runner,
current_command,
ensure_daemon_stack,
foreground_runner_owner,
standby_until_foreground_runner_needed,
)
from archivebox.workers.supervisord_util import run_runner_worker, stop_own_supervisord_process
command = current_command(Process.TypeChoices.UPDATE, data_dir=CONSTANTS.DATA_DIR)
def still_owns_foreground_runner() -> bool:
from django.db import connections
try:
return command_owns_foreground_runner(command, data_dir=CONSTANTS.DATA_DIR)
finally:
connections.close_all()
def wait_for_turn() -> None:
raise_if_shutdown_requested()
standby_until_foreground_runner_needed(command, data_dir=CONSTANTS.DATA_DIR)
@@ -244,7 +258,11 @@ def update(
wait_for_turn()
if ensure_daemon_reason:
ensure_daemon_stack(reason=ensure_daemon_reason)
exit_code = run_runner_worker(list(args), name=f"worker_runner_update_{os.getpid()}")
exit_code = run_runner_worker(
list(args),
name=f"worker_runner_update_{os.getpid()}",
keep_running=still_owns_foreground_runner,
)
if exit_code == 0:
return
if not command_owns_foreground_runner(command, data_dir=CONSTANTS.DATA_DIR):
@@ -352,7 +370,7 @@ def update(
# Snapshot rows: Snapshot.save() moves archive/<ts> to
# the current output_dir and preserves the lifecycle
# status. Drain those retry_at ticks before queuing
# search backfill below. Otherwise the sealed/paused
# search backfill below. Otherwise the sealed search
# runner branch correctly sees queued ArchiveResult
# rows first, runs the targeted plugins, and may leave
# the fs_version maintenance tick hidden behind that
@@ -373,7 +391,7 @@ def update(
for snapshot_id in sorted(touched_snapshot_ids):
run_scoped_runner("--snapshot-id", snapshot_id)
else:
run_scoped_runner("--maintenance-only")
run_scoped_runner("--maintenance-only", "--maintenance-batch-size", str(batch_size))
ran_post_migrate_runner = True
if do_index:
@@ -417,9 +435,7 @@ def update(
now = timezone.now()
queued_result_for_snapshot = queued_index_results.filter(snapshot_id=OuterRef("pk"))
snapshots_to_wake = (
Snapshot.objects.filter(
status__in=(Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED),
)
Snapshot.objects.filter(status=Snapshot.StatusChoices.SEALED)
.annotate(
has_queued_index_result=Exists(queued_result_for_snapshot),
)
@@ -441,11 +457,19 @@ def update(
f"skipping backfill scan and waking {woken_count} snapshot(s) for the runner.",
)
else:
collect_index_ids = (
is_filtered_update
or foreground_runner_owner(
data_dir=CONSTANTS.DATA_DIR,
exclude_id=command.id,
)
is not None
)
stats = reindex_snapshots(
snapshots,
search_plugins=search_plugins,
batch_size=batch_size,
collect_ids=True,
collect_ids=collect_index_ids,
wait_for_turn=wait_for_turn,
)
print_index_stats(stats)
@@ -454,8 +478,8 @@ def update(
if do_run_until_idle and (do_index or not ran_post_migrate_runner):
# Search/index backfill intentionally queues targeted
# ArchiveResult rows without reopening sealed/paused
# snapshots. This second runner pass drains those plugin
# ArchiveResult rows without reopening sealed snapshots.
# This second runner pass drains those plugin
# rows after filesystem maintenance has had its own turn.
# For a normal unfiltered `archivebox update`, keep the
# historical final pass broad enough to resume genuinely
@@ -470,14 +494,14 @@ def update(
pass
elif not runner_work_queued:
pass
elif touched_snapshot_ids:
elif touched_snapshot_ids and is_filtered_update:
if not touched_snapshot_ids:
print("[*] No matching snapshots queued work for the runner.")
for snapshot_id in sorted(touched_snapshot_ids):
run_scoped_runner("--snapshot-id", snapshot_id)
else:
run_scoped_runner(
*(["--maintenance-only"] if index_only or migrate_only else []),
*(["--maintenance-only", "--maintenance-batch-size", str(batch_size)] if index_only or migrate_only else []),
ensure_daemon_reason="search indexing" if do_index else None,
)
+335 -78
View File
@@ -4,6 +4,8 @@ __package__ = "archivebox.cli"
import sys
import os
import json
import asyncio
import platform
from pathlib import Path
from collections.abc import Iterable
@@ -29,8 +31,7 @@ def _format_binary_abspath(
candidate_bases: tuple[tuple[Path, str], ...] = (
(pwd, "./"),
(lib_dir, "LIB_DIR/"),
(Path(os.environ.get("LIB_DIR", "")), "LIB_DIR/") if os.environ.get("LIB_DIR") else (Path(), ""),
(lib_dir, "ABXPKG_LIB_DIR/"),
(personas_dir, "PERSONAS_DIR/"),
(home, "~/"),
)
@@ -47,8 +48,8 @@ def _format_binary_abspath(
relative_str = relative.as_posix()
if prefix == "./":
return "." if not relative_str else f"./{relative_str}"
if prefix == "LIB_DIR/":
return "LIB_DIR" if not relative_str else f"LIB_DIR/{relative_str}"
if prefix == "ABXPKG_LIB_DIR/":
return "ABXPKG_LIB_DIR" if not relative_str else f"ABXPKG_LIB_DIR/{relative_str}"
if prefix == "PERSONAS_DIR/":
return "PERSONAS_DIR" if not relative_str else f"PERSONAS_DIR/{relative_str}"
return "~" if not relative_str else f"~/{relative_str}"
@@ -59,10 +60,10 @@ def _format_binary_abspath(
def _render_binary_abspath(abspath: str):
from rich.text import Text
if abspath.startswith("LIB_DIR/"):
return Text.assemble(("LIB_DIR", "bright_blue"), (abspath.removeprefix("LIB_DIR"), "green"))
if abspath == "LIB_DIR":
return Text("LIB_DIR", style="bright_blue")
if abspath.startswith("ABXPKG_LIB_DIR/"):
return Text.assemble(("ABXPKG_LIB_DIR", "bright_blue"), (abspath.removeprefix("ABXPKG_LIB_DIR"), "green"))
if abspath == "ABXPKG_LIB_DIR":
return Text("ABXPKG_LIB_DIR", style="bright_blue")
if abspath.startswith("PERSONAS_DIR/"):
return Text.assemble(("PERSONAS_DIR", "medium_purple"), (abspath.removeprefix("PERSONAS_DIR"), "green"))
if abspath == "PERSONAS_DIR":
@@ -78,6 +79,79 @@ def _render_binary_abspath(abspath: str):
return Text(abspath, style="green")
def _build_binary_table(rows: list[dict[str, object]]):
from rich import box
from rich.table import Table
table = Table(title="Binary Dependencies", box=box.SIMPLE_HEAVY, expand=True)
table.add_column("Binary", no_wrap=True, max_width=28)
table.add_column("Plugin", no_wrap=True, max_width=24)
table.add_column("State", no_wrap=True, width=8)
table.add_column("Status", justify="center", no_wrap=True, width=6)
table.add_column("Version", no_wrap=True, width=16)
table.add_column("Provider", no_wrap=True, width=8)
table.add_column("Path", overflow="fold", ratio=1)
for row in rows:
table.add_row(
str(row["binary"]),
str(row["plugin"]),
str(row["state"]),
str(row["status"]),
str(row["version"]),
str(row["provider"]),
row["path"],
style=str(row.get("style") or ""),
)
return table
def _print_binary_row(prnt, row: dict[str, object]) -> None:
prnt(
"",
str(row["binary"]).ljust(28),
row["status"],
str(row["plugin"]).ljust(24),
str(row["state"]).ljust(8),
str(row["version"]).ljust(16),
str(row["provider"]).ljust(8),
row["path"],
overflow="ignore",
crop=False,
style=str(row.get("style") or ""),
)
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,
valid: bool,
version: str,
provider: str,
abspath: str,
) -> tuple[str, str, str, str]:
if not valid:
return (display_name, "", "", "")
try:
resolved_abspath = Path(abspath).expanduser().resolve(strict=False).as_posix()
except Exception:
resolved_abspath = abspath
return (display_name, provider, version, resolved_abspath)
@enforce_types
def version(
quiet: bool = False,
@@ -94,13 +168,14 @@ def version(
from rich.panel import Panel
from rich.console import Console
from rich.live import Live
from archivebox.config import CONSTANTS
from archivebox.config.version import get_COMMIT_HASH, get_BUILD_TIME
from archivebox.config.permissions import ARCHIVEBOX_USER, ARCHIVEBOX_GROUP, RUNNING_AS_UID, RUNNING_AS_GID, IN_DOCKER
from archivebox.config.paths import get_data_locations, get_code_locations
from archivebox.misc.logging_util import printable_folder_status
from archivebox.config.common import get_config
from archivebox.config.common import get_config, normalize_runtime_config
console = Console()
prnt = console.print
@@ -133,7 +208,7 @@ def version(
try:
DATA_DIR_STAT = CONSTANTS.DATA_DIR.stat()
prnt(
f"EUID={os.geteuid()}:{os.getegid()} UID={RUNNING_AS_UID}:{RUNNING_AS_GID} PUID={ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP}",
f"EUID={os.geteuid()}:{os.getegid()} UID={RUNNING_AS_UID}:{RUNNING_AS_GID} ARCHIVEBOX_USER={ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP}",
f"FS_UID={DATA_DIR_STAT.st_uid}:{DATA_DIR_STAT.st_gid}",
f"FS_PERMS={config.OUTPUT_PERMISSIONS}",
f"FS_ATOMIC={config.ENFORCE_ATOMIC_WRITES}",
@@ -141,7 +216,7 @@ def version(
)
except Exception:
prnt(
f"EUID={os.geteuid()}:{os.getegid()} UID={RUNNING_AS_UID}:{RUNNING_AS_GID} PUID={ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP}",
f"EUID={os.geteuid()}:{os.getegid()} UID={RUNNING_AS_UID}:{RUNNING_AS_GID} ARCHIVEBOX_USER={ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP}",
)
prnt(
@@ -154,7 +229,13 @@ def version(
)
prnt()
if not (os.access(CONSTANTS.ARCHIVE_DIR, os.R_OK) and os.access(CONSTANTS.CONFIG_FILE, os.R_OK)):
in_data_dir = os.access(CONSTANTS.ARCHIVE_DIR, os.R_OK) and os.access(CONSTANTS.CONFIG_FILE, os.R_OK)
if isinstance(binaries, str):
requested_names = {name.strip() for name in binaries.split(",") if name.strip()}
else:
requested_names = {name for name in (binaries or ()) if name}
if not in_data_dir:
PANEL_TEXT = "\n".join(
(
"",
@@ -174,93 +255,271 @@ def version(
subtitle="Full version info is only available when inside a collection [light_slate_blue]DATA DIR[/light_slate_blue]",
),
)
prnt()
if not in_data_dir and not requested_names:
prnt("[pale_green1][i] Binary Dependencies:[/pale_green1]")
prnt("", "[grey53]Dependency checks require an initialized collection DATA_DIR.[/grey53]")
prnt()
prnt("[deep_sky_blue3][i] Code locations:[/deep_sky_blue3]")
try:
for name, path in get_code_locations().items():
if isinstance(name, str) and isinstance(path, dict):
prnt(printable_folder_status(name, path), overflow="ignore", crop=False)
except Exception as e:
prnt(f" [red]Error getting code locations: {e}[/red]")
prnt()
prnt("[red][i] Data locations:[/red] (not in a data directory)")
prnt()
return []
prnt("[pale_green1][i] Binary Dependencies:[/pale_green1]")
failures = []
seen_failures: set[str] = set()
seen_rows: set[tuple[str, str, str, str]] = set()
# Setup Django before importing models
from archivebox.plugins.discovery import get_enabled_plugins
from abx_dl.config import get_required_binary_requests
from abx_dl.dependencies import resolve_binary_requests
from abx_dl.models import discover_plugins, filter_plugins
from abx_dl.orchestrator import create_bus
from abxpkg.binary_service import BinaryEvent, BinaryService
plugins = discover_plugins(runtime="archivebox")
enabled_plugins = filter_plugins(plugins, get_enabled_plugins(config=config), include_providers=True)
enabled_plugin_names = set(enabled_plugins)
runtime_config = normalize_runtime_config(config.for_crawl(), 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
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]")
declared_binary_specs: dict[str, dict[str, object]] = {}
for plugin_name, plugin in plugins.items():
plugin_enabled = plugin_name in enabled_plugin_names
logical_records = get_required_binary_requests(
plugin,
plugin.config.required_binaries,
overrides=runtime_config,
derived_overrides=derived_config,
run_output_dir=CONSTANTS.DATA_DIR,
)
actual_records = get_required_binary_requests(
plugin,
plugin.config.required_binaries,
overrides=runtime_config,
derived_overrides=derived_config,
run_output_dir=CONSTANTS.DATA_DIR,
logical_names=False,
)
for logical_record, actual_record in zip(logical_records, actual_records, strict=False):
logical_name = str(logical_record["name"])
actual_name = str(actual_record["name"])
display_name = Path(actual_name).expanduser().name if ("/" in actual_name or actual_name.startswith("~")) else logical_name
if not plugin_enabled and not (
requested_names and (logical_name in requested_names or actual_name in requested_names or display_name in requested_names)
):
continue
signature = json.dumps(actual_record, sort_keys=True, default=str)
declared_binary_specs.setdefault(signature, actual_record)
loaded_binaries: dict[str, BinaryEvent | None] = {}
if declared_binary_specs:
binary_bus = create_bus(name="ArchiveBoxVersionBinaryCheck")
BinaryService(binary_bus, auto_install=False, lib_dir=config.ABXPKG_LIB_DIR)
async def resolve_declared_binaries() -> dict[str, BinaryEvent | None]:
try:
return await resolve_binary_requests(binary_bus, declared_binary_specs)
finally:
await binary_bus.wait_until_idle()
await binary_bus.destroy(clear=False)
loaded_binaries = asyncio.run(resolve_declared_binaries())
rows: list[dict[str, object]] = []
any_rows = False
any_available = False
compact_paths = console.is_terminal
live_enabled = console.is_terminal
live_cm = Live(_build_binary_table(rows), console=console, refresh_per_second=8) if live_enabled else None
if not live_enabled:
prnt("", "Binary".ljust(28), "Status", "Plugin".ljust(24), "State".ljust(8), "Version".ljust(16), "Provider".ljust(8), "Path")
def emit_row(row: dict[str, object]) -> None:
rows.append(row)
if live_cm is not None:
live_cm.update(_build_binary_table(rows), refresh=True)
else:
_print_binary_row(prnt, row)
if live_cm is not None:
live_cm.start()
try:
from archivebox.config.django import setup_django
setup_django()
from archivebox.machine.models import Machine, Binary
machine = Machine.current()
if isinstance(binaries, str):
requested_names = {name.strip() for name in binaries.split(",") if name.strip()}
else:
requested_names = {name for name in (binaries or ()) if name}
db_binaries: dict[str, Binary] = {}
for binary in Binary.objects.filter(machine=machine).order_by("name", "-modified_at"):
db_binaries.setdefault(binary.name, binary)
all_binary_names = sorted(requested_names or set(db_binaries.keys()))
if not all_binary_names:
prnt("", "[grey53]No binaries detected. Run [green]archivebox install[/green] to detect dependencies.[/grey53]")
else:
any_available = False
compact_paths = console.is_terminal
for name in all_binary_names:
if requested_names and name not in requested_names:
for plugin_name, plugin in plugins.items():
plugin_enabled = plugin_name in enabled_plugin_names
logical_records = get_required_binary_requests(
plugin,
plugin.config.required_binaries,
overrides=runtime_config,
derived_overrides=derived_config,
run_output_dir=CONSTANTS.DATA_DIR,
)
actual_records = get_required_binary_requests(
plugin,
plugin.config.required_binaries,
overrides=runtime_config,
derived_overrides=derived_config,
run_output_dir=CONSTANTS.DATA_DIR,
logical_names=False,
)
for logical_record, actual_record in zip(logical_records, actual_records, strict=False):
logical_name = str(logical_record["name"])
actual_name = str(actual_record["name"])
display_name = Path(actual_name).expanduser().name if ("/" in actual_name or actual_name.startswith("~")) else logical_name
if (
requested_names
and logical_name not in requested_names
and actual_name not in requested_names
and display_name not in requested_names
):
continue
installed = db_binaries.get(name)
if installed and installed.is_valid:
display_name = Path(name).expanduser().name if ("/" in name or name.startswith("~")) else name
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(actual_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(
installed.abspath,
abspath,
pwd=Path.cwd(),
lib_dir=config.LIB_DIR,
lib_dir=config.ABXPKG_LIB_DIR,
personas_dir=CONSTANTS.PERSONAS_DIR,
home=Path.home(),
)
if compact_paths
else installed.abspath
else abspath
)
rendered_path = _render_binary_abspath(display_path) if compact_paths else display_path
version_str = (installed.version or "unknown")[:15]
provider = (installed.binprovider or "env")[:8]
prnt(
"",
"[green]√[/green]",
"",
display_name.ljust(18),
version_str.ljust(16),
provider.ljust(8),
rendered_path,
overflow="ignore",
crop=False,
)
status = "[green]√[/green]" if plugin_enabled else "[grey53]-[/grey53]"
any_available = True
continue
else:
rendered_path = "[grey53]not installed[/grey53]"
status = "[red]X[/red]" if plugin_enabled else "[grey53]-[/grey53]"
if plugin_enabled and display_name not in seen_failures:
failures.append(display_name)
seen_failures.add(display_name)
status = (
"[grey53]not recorded[/grey53]" if name in requested_names and installed is None else "[grey53]not installed[/grey53]"
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,
)
prnt("", "[red]X[/red]", "", name.ljust(18), status, overflow="ignore", crop=False)
failures.append(name)
if row_key in seen_rows:
continue
seen_rows.add(row_key)
if not any_available:
prnt("", "[grey53]No binaries detected. Run [green]archivebox install[/green] to detect dependencies.[/grey53]")
emit_row(
{
"plugin": plugin_name,
"state": "enabled" if plugin_enabled else "disabled",
"status": status,
"binary": display_name,
"version": version_str if valid else "-",
"provider": provider if valid else "-",
"path": rendered_path,
"style": "" if plugin_enabled else "dim",
},
)
# Show hint if no binaries are installed yet
has_any_installed = Binary.objects.filter(machine=machine).exclude(abspath="").exists()
if not has_any_installed:
prnt()
prnt("", "[grey53]Run [green]archivebox install[/green] to detect and install dependencies.[/grey53]")
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)
except Exception as e:
# Handle database errors gracefully (locked, missing, etc.)
prnt()
prnt("", f"[yellow]Warning: Could not query binaries from database: {e}[/yellow]")
prnt("", "[grey53]Run [green]archivebox init[/green] and [green]archivebox install[/green] to set up dependencies.[/grey53]")
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
)
emit_row(
{
"plugin": "(database)",
"state": "detected",
"status": "[green]√[/green]",
"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
any_available = True
finally:
if live_cm is not None:
live_cm.stop()
if not any_rows:
prnt("", "[grey53]No required binaries declared for discovered plugins.[/grey53]")
elif not any_available:
prnt("", "[grey53]No binaries detected. Run [green]archivebox install[/green] to detect dependencies.[/grey53]")
if not binaries:
# Show code and data locations
@@ -319,9 +578,7 @@ def version(
)
@docstring(version.__doc__)
def main(**kwargs):
failures = version(**kwargs)
if failures:
raise SystemExit(1)
version(**kwargs)
if __name__ == "__main__":
+108 -54
View File
@@ -176,6 +176,10 @@ def build_crawl_config_snapshot(
plugin_owned_keys = set(_plugin_config_properties(PLUGIN_CONFIG_SCHEMAS)) - set(ArchiveBoxBaseConfig.model_fields)
effective = get_config(persona=persona, base_config=base_config)
frozen = effective.for_crawl_frozen(persona=persona)
for key in ("BIND_ADDR", "BASE_URL", "CSRF_TRUSTED_ORIGINS", "SERVER_SECURITY_MODE"):
value = getattr(effective, key, None)
if value is not None:
frozen[key] = value
if persona is not None:
persona_config = persona.get_derived_config()
for key in plugin_owned_keys - explicit_overrides:
@@ -185,6 +189,10 @@ def build_crawl_config_snapshot(
resolved = get_config(base_config=frozen, overrides=overrides, include_machine=False)
resolved_payload = normalize_runtime_config(resolved)
frozen = resolved.for_crawl_frozen(persona=persona)
for key in ("BIND_ADDR", "BASE_URL", "CSRF_TRUSTED_ORIGINS", "SERVER_SECURITY_MODE"):
value = getattr(resolved, key, None)
if value is not None:
frozen[key] = value
for key in plugin_owned_keys & explicit_overrides:
if ArchiveBoxConfig.scope_for_key(key) == _SCOPE_CRAWL_FROZEN and key in resolved_payload:
frozen[key] = resolved_payload[key]
@@ -238,18 +246,14 @@ class StorageConfig(BaseConfigSet):
_scope: str = PrivateAttr(default=_SCOPE_SERVER)
# TMP_DIR must be a local, fast, readable/writable dir by archivebox user,
# must be a short path due to unix path length restrictions for socket files (<100 chars)
# must be a short path due to unix path length restrictions for socket files (<90 chars)
# must be a local SSD/tmpfs for speed and because bind mounts/network mounts/FUSE dont support unix sockets
TMP_DIR: Path = Field(default=CONSTANTS.DEFAULT_TMP_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
# LIB_DIR must be a local, fast, readable/writable dir by archivebox user,
# ABXPKG_LIB_DIR must be a local, fast, readable/writable dir by archivebox user,
# must be able to contain executable binaries (up to 5GB size)
# should not be a remote/network/FUSE mount for speed reasons, otherwise extractors will be slow
LIB_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
# LIB_BIN_DIR is an optional human-facing symlink convenience directory.
# Runtime lookup must use provider-specific paths under LIB_DIR instead.
LIB_BIN_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_BIN_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
ABXPKG_LIB_DIR: Path = Field(default=CONSTANTS.DEFAULT_ABXPKG_LIB_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
OUTPUT_PERMISSIONS: str = Field(default="644")
ENFORCE_ATOMIC_WRITES: bool = Field(default=True)
@@ -503,6 +507,32 @@ def _plugin_user_config(config: Mapping[str, object]) -> dict[str, str]:
return {key: _plugin_user_config_value(value) for key, value in config.items()}
def _plugin_input_config(config: Mapping[str, object]) -> dict[str, object]:
"""Return raw config inputs understood by core or plugin schemas."""
input_names = _archivebox_config_input_names()
return {str(key): value for key, value in config.items() if str(key) in input_names}
def _explicit_plugin_enabled_keys(config: Mapping[str, object]) -> set[str]:
"""Return canonical ``*_ENABLED`` keys explicitly provided by the user.
``PLUGINS`` is a selector, not an override for concrete plugin enable flags.
If a user passes ``PLUGINS=wget`` and ``SAVE_WGET=False`` together, the
alias-resolved ``WGET_ENABLED=False`` must survive the model validator that
expands ``PLUGINS`` into concrete booleans.
"""
provided = {str(key) for key in config}
explicit: set[str] = set()
for key, prop in _plugin_config_properties(PLUGIN_CONFIG_SCHEMAS).items():
if not key.endswith("_ENABLED") or not isinstance(prop, Mapping):
continue
aliases = prop.get("x-aliases") if isinstance(prop, Mapping) else None
input_names = {key, *(str(alias) for alias in aliases if str(alias).strip())} if isinstance(aliases, list) else {key}
if provided & input_names:
explicit.add(key)
return explicit
def _discover_plugin_config_schemas() -> PluginSchemaDocuments:
from archivebox.plugins.discovery import discover_plugin_configs
@@ -532,6 +562,10 @@ def _archivebox_config_input_names() -> set[str]:
for field in ArchiveBoxConfig.model_fields.values():
if isinstance(field.alias, str):
names.add(field.alias)
for prop in _plugin_config_properties(PLUGIN_CONFIG_SCHEMAS).values():
aliases = prop.get("x-aliases") if isinstance(prop, Mapping) else None
if isinstance(aliases, list):
names.update(str(alias) for alias in aliases if str(alias).strip())
return names
@@ -643,11 +677,10 @@ class ArchiveBoxBaseConfig(
runtime_derived_keys = {
"ABX_INSTALL_CACHE",
"ACTIVE_PERSONA",
"CHROME_DOWNLOADS_DIR",
"CHROME_USER_DATA_DIR",
"CRAWL_DIR",
"DEFAULT_PERSONA",
"EXTRA_CONTEXT",
"PERSONAS_DIR",
"SNAP_DIR",
}
return frozenset(
@@ -657,7 +690,7 @@ class ArchiveBoxBaseConfig(
def _scoped_config(self, *, include_execution: bool) -> dict[str, Any]:
keys = type(self)._crawl_runtime_keys() if include_execution else type(self)._crawl_frozen_keys()
payload = self.model_dump(mode="json")
return {key: payload[key] for key in keys if payload.get(key) is not None}
return {key: payload[key] for key in keys if payload.get(key, None) is not None}
def for_crawl(self) -> dict[str, Any]:
"""Config scoped to crawl execution, without runtime object overlays."""
@@ -693,9 +726,9 @@ class ArchiveBoxBaseConfig(
config["DATA_DIR"] = str(CONSTANTS.DATA_DIR)
scope_by_key = type(self)._scope_by_key()
model_fields = type(self).model_fields
# ArchiveBox owns SEARCH_BACKEND_ENGINE and uses it during model
# validation to derive the selected backend's *_ENABLED flag. Hooks
# only receive the backend-local flags, never the selector itself.
# ArchiveBox-only selectors are consumed during model validation to
# derive hook-owned *_ENABLED flags. They are stripped below after
# crawl/snapshot overlays have had a chance to apply.
config.pop("SEARCH_BACKEND_ENGINE", None)
if persona is not None:
for key, value in persona.get_derived_config().items():
@@ -717,6 +750,11 @@ class ArchiveBoxBaseConfig(
if runtime_overrides:
config.update(normalize_runtime_config(runtime_overrides, json_safe=False))
# Hooks should only see concrete plugin-local flags, never the
# ArchiveBox selectors used to derive them.
config.pop("PLUGINS", None)
config.pop("SEARCH_BACKEND_ENGINE", None)
if extra_context:
context: dict[str, Any] = {}
if config.get("EXTRA_CONTEXT"):
@@ -731,31 +769,44 @@ class ArchiveBoxBaseConfig(
@model_validator(mode="after")
def resolve_runtime_paths(self):
lib_dir = self.LIB_DIR.expanduser()
lib_dir = self.ABXPKG_LIB_DIR.expanduser()
if not lib_dir.is_absolute():
lib_dir = CONSTANTS.DATA_DIR / lib_dir
self.LIB_DIR = lib_dir.resolve()
lib_bin_dir = self.LIB_BIN_DIR.expanduser()
if lib_bin_dir == CONSTANTS.DEFAULT_LIB_BIN_DIR and self.LIB_DIR != CONSTANTS.DEFAULT_LIB_DIR:
lib_bin_dir = self.LIB_DIR / "bin"
elif not lib_bin_dir.is_absolute():
lib_bin_dir = CONSTANTS.DATA_DIR / lib_bin_dir
self.LIB_BIN_DIR = lib_bin_dir.resolve()
self.ABXPKG_LIB_DIR = lib_dir.resolve()
return self
@model_validator(mode="after")
def derive_plugin_enabled_config(self):
self._derive_plugin_enabled_config(respect_current_enabled=False)
return self
def _derive_plugin_enabled_config(self, *, respect_current_enabled: bool) -> None:
plugin_names = _normalize_plugins_config_value(self.PLUGINS)
selected_plugins = _plugins_with_required_plugins(plugin_names) if plugin_names else set()
enabled_config_keys = _plugin_enabled_config_keys()
disabled_plugins = []
if respect_current_enabled:
disabled_plugins = [plugin_name for plugin_name, enabled_key in enabled_config_keys.items() if not getattr(self, enabled_key)]
selected_plugin_roots = plugin_names
if selected_plugin_roots:
from abx_dl.models import discover_plugins, filter_plugins
selected_plugins = set(
filter_plugins(
discover_plugins(runtime="archivebox"),
sorted(selected_plugin_roots),
include_providers=True,
disabled_names=disabled_plugins,
),
)
else:
selected_plugins = set()
search_backend = self.SEARCH_BACKEND_ENGINE.strip().lower()
if search_backend:
selected_plugins.add(f"search_backend_{search_backend}")
for plugin_name, enabled_key in _plugin_enabled_config_keys().items():
for plugin_name, enabled_key in enabled_config_keys.items():
if plugin_names or plugin_name in selected_plugins:
setattr(self, enabled_key, plugin_name in selected_plugins)
return self
def _build_archivebox_config_model(plugin_schemas: PluginSchemaDocuments) -> type[ArchiveBoxBaseConfig]:
@@ -812,23 +863,6 @@ def _plugin_enabled_config_keys() -> dict[str, str]:
return enabled_keys
def _plugins_with_required_plugins(plugin_names: set[str]) -> set[str]:
selected = set(plugin_names)
pending = list(selected)
while pending:
plugin_name = pending.pop()
schema = PLUGIN_CONFIG_SCHEMAS.get(plugin_name, {})
required_plugins = schema.get("required_plugins") if isinstance(schema, dict) else None
if not isinstance(required_plugins, list):
continue
for required_plugin in required_plugins:
required_plugin_name = str(required_plugin).strip().lower()
if required_plugin_name and required_plugin_name not in selected:
selected.add(required_plugin_name)
pending.append(required_plugin_name)
return selected
def get_live_config_url(key: str) -> str:
return f"{LIVE_CONFIG_BASE_URL}{quote(key)}/"
@@ -1000,10 +1034,13 @@ def get_config(
persona = crawl.resolve_persona()
config_data: ConfigPayload = dict(defaults or {})
config_data["PERSONAS_DIR"] = str(CONSTANTS.PERSONAS_DIR)
base_config_payload: ConfigPayload = {}
base_config_model = ArchiveBoxConfig()
if crawl_config_base:
config_data.update(
normalize_runtime_config(ArchiveBoxConfig().model_dump(mode="json"), exclude_runtime_derived=True, json_safe=False),
normalize_runtime_config(base_config_model.model_dump(mode="json"), exclude_runtime_derived=True, json_safe=False),
)
config_data.update(normalize_runtime_config(dict(crawl.config or {}), exclude_crawl_execution=True, json_safe=False))
elif base_config is not None:
@@ -1014,7 +1051,7 @@ def get_config(
config_data.update(normalize_runtime_config(base_config_payload, exclude_runtime_derived=True, json_safe=False))
else:
config_data.update(
normalize_runtime_config(ArchiveBoxConfig().model_dump(mode="json"), exclude_runtime_derived=True, json_safe=False),
normalize_runtime_config(base_config_model.model_dump(mode="json"), exclude_runtime_derived=True, json_safe=False),
)
legacy_permissions = permissions_from_legacy_public_flags({**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **os.environ})
if legacy_permissions:
@@ -1027,7 +1064,7 @@ def get_config(
scope_overrides.update(
normalize_runtime_config(
_sanitize_machine_config(machine.config, lib_dir=config_data.get("LIB_DIR")),
_sanitize_machine_config(machine.config, lib_dir=config_data.get("ABXPKG_LIB_DIR")),
only_crawl_execution=crawl_config_base,
exclude_runtime_derived=True,
json_safe=False,
@@ -1066,22 +1103,29 @@ def get_config(
archivebox_scope_overrides = {key: value for key, value in scope_overrides.items() if key in _archivebox_config_input_names()}
config_data.update(archivebox_scope_overrides)
explicit_plugin_enabled_keys: set[str] = set()
if resolve_plugins:
plugin_schemas = {plugin_name: schema for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items() if isinstance(schema, dict)}
plugin_global_config = {key: str(value) if isinstance(value, Path) else value for key, value in config_data.items()}
crawl_selected_plugins = crawl_config_base and bool(_normalize_plugins_config_value(dict(crawl.config or {}).get("PLUGINS")))
# A frozen crawl-level PLUGINS selector is the exact extractor set for
# this crawl. Env/file *_ENABLED values are ambient defaults and must
# not be re-applied after validation, or a later process env can change
# which hooks an already-queued crawl runs.
if not crawl_selected_plugins:
explicit_plugin_enabled_keys.update(_explicit_plugin_enabled_keys(os.environ))
explicit_plugin_enabled_keys.update(_explicit_plugin_enabled_keys(scope_overrides))
plugin_user_config = _plugin_user_config(
{
**normalize_runtime_config(config_data, only_crawl_execution=True, json_safe=False),
**_plugin_input_config(os.environ),
**scope_overrides,
},
)
if not crawl_config_base:
file_config = BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE)
explicit_plugin_enabled_keys.update(_explicit_plugin_enabled_keys(file_config))
plugin_user_config = {
**normalize_runtime_config(
BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE),
exclude_runtime_derived=True,
json_safe=False,
),
**_plugin_user_config(_plugin_input_config(file_config)),
**plugin_user_config,
}
plugin_sections = resolve_plugin_configs(
@@ -1106,6 +1150,13 @@ def get_config(
if crawl_config_base:
config_data.update(normalize_runtime_config(dict(crawl.config or {}), exclude_crawl_execution=True, json_safe=False))
config_data.update(archivebox_scope_overrides)
if crawl_selected_plugins:
# Crawl PLUGINS is a frozen selector. Drop ambient enable flags from
# env/file/defaults before model validation so the selected plugin
# set is derived from the crawl, not from the worker process that
# happens to resume it.
for enabled_key in _plugin_enabled_config_keys().values():
config_data.pop(enabled_key, None)
# Decode JSON-encoded complex values (dict/list fields) that came from
# string-only sources before validation. ``IniConfigSettingsSource`` does
@@ -1131,14 +1182,17 @@ def get_config(
)
config = ArchiveBoxConfig.model_validate(config_data)
for key in explicit_plugin_enabled_keys:
if key in config_data:
setattr(config, key, config_data[key])
if config.PLUGINS:
config._derive_plugin_enabled_config(respect_current_enabled=True)
if redact_sensitive:
for key in type(config).model_fields:
value = config[key]
if is_sensitive_config_key(key) and value not in (None, ""):
setattr(config, key, SENSITIVE_CONFIG_VALUE_REDACTED)
os.environ["LIB_DIR"] = str(config.LIB_DIR)
os.environ["LIB_BIN_DIR"] = str(config.LIB_BIN_DIR)
os.environ["ABXPKG_LIB_DIR"] = str(config.LIB_DIR)
os.environ["ABXPKG_LIB_DIR"] = str(config.ABXPKG_LIB_DIR)
archiving_warning_key = (config.TIMEOUT, config.USE_COLOR)
if archiving_warning_key not in _WARNED_ARCHIVING_CONFIGS:
config.warn_if_invalid()
+8 -10
View File
@@ -36,8 +36,8 @@ from .permissions import (
IN_DOCKER,
RUNNING_AS_UID,
RUNNING_AS_GID,
DEFAULT_PUID,
DEFAULT_PGID,
DEFAULT_UID,
DEFAULT_GID,
ARCHIVEBOX_USER,
ARCHIVEBOX_GROUP,
)
@@ -66,8 +66,8 @@ class ConstantsDict:
ARCHIVEBOX_GROUP: int = ARCHIVEBOX_GROUP
RUNNING_AS_UID: int = RUNNING_AS_UID
RUNNING_AS_GID: int = RUNNING_AS_GID
DEFAULT_PUID: int = DEFAULT_PUID
DEFAULT_PGID: int = DEFAULT_PGID
DEFAULT_UID: int = DEFAULT_UID
DEFAULT_GID: int = DEFAULT_GID
IS_INSIDE_VENV: bool = sys.prefix != sys.base_prefix
# Source code dirs
@@ -113,12 +113,10 @@ class ConstantsDict:
TMP_DIR_NAME: str = "tmp"
DEFAULT_TMP_DIR: Path = DATA_DIR / TMP_DIR_NAME / MACHINE_ID # ./data/tmp/abc3244323
LIB_DIR_NAME: str = "lib"
DEFAULT_LIB_DIR: Path = _env_path(
"LIB_DIR",
user_config_path("abx") / LIB_DIR_NAME,
DEFAULT_ABXPKG_LIB_DIR: Path = _env_path(
"ABXPKG_LIB_DIR",
user_config_path("abx") / "lib",
)
DEFAULT_LIB_BIN_DIR: Path = _env_path("LIB_BIN_DIR", DEFAULT_LIB_DIR / "bin")
RESERVED_ARCHIVE_DIR_NAMES: frozenset[str] = frozenset(
(
@@ -241,7 +239,7 @@ class ConstantsDict:
SOURCES_DIR_NAME,
LOGS_DIR_NAME,
CACHE_DIR_NAME,
LIB_DIR_NAME,
"lib",
TMP_DIR_NAME,
PERSONAS_DIR_NAME,
CUSTOM_TEMPLATES_DIR_NAME,
+6
View File
@@ -41,6 +41,12 @@ def setup_django(check_db=False, in_memory_db=False) -> None:
# TODO: figure out why CLI entrypoints with init_pending are running this twice sometimes
return
# SQLite creates index.sqlite3 during django.setup()/migrate. Apply the
# ArchiveBox file-mode policy before any DB connection can create the file,
# otherwise a permissive parent umask can expose a just-created DB until a
# later chmod runs.
os.umask(0o777 - (int(CONFIG.OUTPUT_PERMISSIONS, base=8) | 0o111))
# Third-party patches are only needed once Django/apps are about to load.
# Keeping them out of archivebox.__init__ avoids paying Django/Daphne setup
# cost for cheap CLI startup paths like `archivebox <cmd> --help`.
+47 -39
View File
@@ -20,6 +20,8 @@ if TYPE_CHECKING:
PACKAGE_DIR: Path = Path(__file__).resolve().parent.parent # archivebox source code dir
DATA_DIR: Path = Path(os.getcwd()).resolve() # archivebox user data dir
MAX_TMP_SOCKET_URL_LENGTH = 90
SUPERVISORD_SOCKET_FILENAME = "supervisord.sock"
def _env_path(key: str, default: Path) -> Path:
@@ -120,7 +122,7 @@ def dir_is_writable(dir_path: Path, uid: int | None = None, gid: int | None = No
try:
with SudoPermission(uid=uid, fallback=fallback):
test_file.exists()
test_file.write_text(f"Checking if PUID={uid} PGID={gid} can write to dir")
test_file.write_text(f"Checking if uid={uid} gid={gid} can write to dir")
test_file.unlink()
return True
except (OSError, PermissionError):
@@ -176,33 +178,45 @@ def create_and_chown_dir(dir_path: Path) -> None:
def tmp_dir_socket_path_is_short_enough(dir_path: Path) -> bool:
socket_file = dir_path.absolute().resolve() / "supervisord.sock"
return len(f"file://{socket_file}") <= 96
socket_file = dir_path.absolute().resolve() / SUPERVISORD_SOCKET_FILENAME
return len(f"file://{socket_file}") < MAX_TMP_SOCKET_URL_LENGTH
def tmp_dir_candidates(config: "ArchiveBoxConfig") -> list[Path]:
from archivebox.config.constants import CONSTANTS
collection_id = get_collection_id()
collection_id_short = collection_id[:4]
system_tmp_dir = Path(tempfile.gettempdir())
candidates = [
config.TMP_DIR, # <user-specified>
CONSTANTS.DEFAULT_TMP_DIR, # ./data/tmp/<machine_id>
Path("/var/run/archivebox") / collection_id,
Path("/tmp") / "archivebox" / collection_id,
Path("~/.tmp/archivebox").expanduser() / collection_id,
system_tmp_dir / "archivebox" / collection_id,
system_tmp_dir / "archivebox" / collection_id_short,
system_tmp_dir / "abx" / collection_id_short,
]
seen = set()
unique_candidates = []
for path in candidates:
path_key = str(path.expanduser().absolute())
if path_key in seen:
continue
seen.add(path_key)
unique_candidates.append(path)
return unique_candidates
def get_or_create_working_tmp_dir(autofix=True, quiet=True, config: "ArchiveBoxConfig | None" = None, **config_kwargs):
from archivebox.config.constants import CONSTANTS
from archivebox.config.common import get_config
from archivebox.misc.checks import check_tmp_dir
config = config or get_config(**config_kwargs)
# try a few potential directories in order of preference
CANDIDATES = [
config.TMP_DIR, # <user-specified>
CONSTANTS.DEFAULT_TMP_DIR, # ./data/tmp/<machine_id>
Path("/var/run/archivebox") / get_collection_id(), # /var/run/archivebox/abc5d8512
Path("/tmp") / "archivebox" / get_collection_id(), # /tmp/archivebox/abc5d8512
Path("~/.tmp/archivebox").expanduser() / get_collection_id(), # ~/.tmp/archivebox/abc5d8512
Path(tempfile.gettempdir())
/ "archivebox"
/ get_collection_id(), # /var/folders/qy/6tpfrpx100j1t4l312nz683m0000gn/T/archivebox/abc5d8512
Path(tempfile.gettempdir())
/ "archivebox"
/ get_collection_id()[:4], # /var/folders/qy/6tpfrpx100j1t4l312nz683m0000gn/T/archivebox/abc5d
Path(tempfile.gettempdir()) / "abx" / get_collection_id()[:4], # /var/folders/qy/6tpfrpx100j1t4l312nz683m0000gn/T/abx/abc5
]
candidates = tmp_dir_candidates(config)
fallback_candidate = None
for candidate in CANDIDATES:
for candidate in candidates:
try:
create_and_chown_dir(candidate)
except Exception:
@@ -231,7 +245,7 @@ def get_or_create_working_tmp_dir(autofix=True, quiet=True, config: "ArchiveBoxC
return fallback_candidate
if not quiet:
raise OSError(f"ArchiveBox is unable to find a writable TMP_DIR, tried {CANDIDATES}!")
raise OSError(f"ArchiveBox is unable to find a writable TMP_DIR, tried {candidates}!")
def get_or_create_working_lib_dir(autofix=True, quiet=False, config: "ArchiveBoxConfig | None" = None, **config_kwargs):
@@ -240,8 +254,8 @@ def get_or_create_working_lib_dir(autofix=True, quiet=False, config: "ArchiveBox
config = config or get_config(**config_kwargs)
# LIB_DIR is either the shared platformdirs default or an explicit env/config override.
CANDIDATES = [config.LIB_DIR]
# ABXPKG_LIB_DIR is either the shared platformdirs default or an explicit env/config override.
CANDIDATES = [config.ABXPKG_LIB_DIR]
for candidate in CANDIDATES:
try:
@@ -249,12 +263,12 @@ def get_or_create_working_lib_dir(autofix=True, quiet=False, config: "ArchiveBox
except Exception:
pass
if check_lib_dir(candidate, throw=False, quiet=True, must_exist=True):
if autofix and config.LIB_DIR != candidate:
os.environ["LIB_DIR"] = str(candidate)
if autofix and config.ABXPKG_LIB_DIR != candidate:
os.environ["ABXPKG_LIB_DIR"] = str(candidate)
return candidate
if not quiet:
raise OSError(f"ArchiveBox is unable to find a writable LIB_DIR, tried {CANDIDATES}!")
raise OSError(f"ArchiveBox is unable to find a writable ABXPKG_LIB_DIR, tried {CANDIDATES}!")
def get_data_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs):
@@ -329,7 +343,10 @@ def get_data_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs
"TMP_DIR": {
"path": tmp_dir.resolve(),
"enabled": True,
"is_valid": os.path.isdir(tmp_dir) and os.access(tmp_dir, os.R_OK) and os.access(tmp_dir, os.W_OK), # read + write
"is_valid": os.path.isdir(tmp_dir)
and os.access(tmp_dir, os.R_OK)
and os.access(tmp_dir, os.W_OK)
and tmp_dir_socket_path_is_short_enough(tmp_dir),
},
# "CACHE_DIR": {
# "path": CACHE_DIR.resolve(),
@@ -347,11 +364,9 @@ def get_code_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs
config = config or get_config(**config_kwargs)
try:
lib_dir = get_or_create_working_lib_dir(autofix=True, quiet=True, config=config) or config.LIB_DIR
lib_dir = get_or_create_working_lib_dir(autofix=True, quiet=True, config=config) or config.ABXPKG_LIB_DIR
except Exception:
lib_dir = config.LIB_DIR
lib_bin_dir = lib_dir / "bin"
lib_dir = config.ABXPKG_LIB_DIR
return AttrDict(
{
@@ -375,17 +390,10 @@ def get_code_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs
"enabled": os.path.isdir(CONSTANTS.USER_PLUGINS_DIR),
"is_valid": os.path.isdir(CONSTANTS.USER_PLUGINS_DIR) and os.access(CONSTANTS.USER_PLUGINS_DIR, os.R_OK), # read
},
"LIB_DIR": {
"ABXPKG_LIB_DIR": {
"path": lib_dir.resolve(),
"enabled": True,
"is_valid": os.path.isdir(lib_dir) and os.access(lib_dir, os.R_OK) and os.access(lib_dir, os.W_OK), # read + write
},
"LIB_BIN_DIR": {
"path": lib_bin_dir.resolve(),
"enabled": True,
"is_valid": os.path.isdir(lib_bin_dir)
and os.access(lib_bin_dir, os.R_OK)
and os.access(lib_bin_dir, os.W_OK), # read + write
},
},
)
+18 -22
View File
@@ -24,8 +24,8 @@ except PermissionError:
DATA_DIR_UID = 0
DATA_DIR_GID = 0
DEFAULT_PUID = 911
DEFAULT_PGID = 911
DEFAULT_UID = 911
DEFAULT_GID = 911
RUNNING_AS_UID = os.getuid()
RUNNING_AS_GID = os.getgid()
EUID = os.geteuid()
@@ -42,23 +42,20 @@ IN_DOCKER = os.environ.get("IN_DOCKER", False) in ("1", "true", "True", "TRUE",
FALLBACK_UID = RUNNING_AS_UID or SUDO_UID
FALLBACK_GID = RUNNING_AS_GID or SUDO_GID
if RUNNING_AS_UID == 0:
try:
# if we are running as root it's really hard to figure out what the correct archivebox user should be
# as a last resort instead of setting DATA_DIR ownership to 0:0 (which breaks it for non-root users)
# check if 911:911 archivebox user exists on host system, and use it instead of 0
if pwd.getpwuid(DEFAULT_PUID).pw_name == "archivebox":
FALLBACK_UID = DEFAULT_PUID
FALLBACK_GID = DEFAULT_PGID
except Exception:
pass
try:
ARCHIVEBOX_ACCOUNT = pwd.getpwnam("archivebox")
except KeyError:
ARCHIVEBOX_ACCOUNT = None
os.environ.setdefault("PUID", str(DATA_DIR_UID or EUID or RUNNING_AS_UID or FALLBACK_UID))
os.environ.setdefault("PGID", str(DATA_DIR_GID or EGID or RUNNING_AS_GID or FALLBACK_GID))
ARCHIVEBOX_USER = int(os.environ["PUID"])
ARCHIVEBOX_GROUP = int(os.environ["PGID"])
if DATA_DIR_UID != 0:
ARCHIVEBOX_USER = DATA_DIR_UID
ARCHIVEBOX_GROUP = DATA_DIR_GID
elif RUNNING_AS_UID == 0 and ARCHIVEBOX_ACCOUNT is not None:
ARCHIVEBOX_USER = ARCHIVEBOX_ACCOUNT.pw_uid
ARCHIVEBOX_GROUP = ARCHIVEBOX_ACCOUNT.pw_gid
else:
ARCHIVEBOX_USER = EUID or RUNNING_AS_UID or FALLBACK_UID
ARCHIVEBOX_GROUP = EGID or RUNNING_AS_GID or FALLBACK_GID
if not USER:
try:
# alternative method 1 to get username
@@ -94,13 +91,12 @@ except Exception:
def drop_privileges():
"""If running as root, drop privileges to the user that owns the data dir (or PUID)"""
"""If running as root, drop privileges to the data dir owner or archivebox user."""
# always run archivebox as the user that owns the data dir, never as root
# Always run ArchiveBox as the user that owns the data dir, or as the
# archivebox service account when the data dir is root-owned.
if os.getuid() == 0:
# drop permissions to the user that owns the data dir / provided PUID
if os.geteuid() != ARCHIVEBOX_USER and ARCHIVEBOX_USER != 0 and ARCHIVEBOX_USER_EXISTS:
# drop our effective UID to the archivebox user's UID
os.seteuid(ARCHIVEBOX_USER)
# update environment variables so that subprocesses dont try to write to /root
+8 -11
View File
@@ -49,10 +49,10 @@ def get_COMMIT_HASH() -> str | None:
if IN_DOCKER:
try:
version_txt = Path("/VERSION.txt").read_text()
docker_commit_hashes = re.findall(r"COMMIT_HASH=([0-9a-fA-F]{40})", version_txt)
if docker_commit_hashes:
return docker_commit_hashes[-1]
version_text = Path("/VERSION.txt").read_text()
matches = re.findall(r"^COMMIT_HASH=([0-9a-fA-F]{40})$", version_text, re.MULTILINE)
if matches:
return matches[-1]
except Exception:
pass
@@ -106,13 +106,10 @@ def get_COMMIT_HASH() -> str | None:
@cache
def get_BUILD_TIME() -> str:
if IN_DOCKER:
try:
# if we're in the archivebox official docker image, /VERSION.txt will contain the build time
docker_build_end_time = Path("/VERSION.txt").read_text().rsplit("BUILD_END_TIME=")[-1].split("\n", 1)[0]
return docker_build_end_time
except Exception:
pass
for env_var in ("ARCHIVEBOX_BUILD_TIME", "BUILD_TIME"):
build_time = os.environ.get(env_var, "").strip()
if build_time:
return build_time
src_last_modified_unix_timestamp = (PACKAGE_DIR / "README.md").stat().st_mtime
return datetime.fromtimestamp(src_last_modified_unix_timestamp).strftime("%Y-%m-%d %H:%M:%S %s")
-5
View File
@@ -18,7 +18,6 @@ from archivebox.misc.util import parse_date
from archivebox.machine.models import Binary
LIVE_CONFIG_BASE_URL = "/admin/environment/config/"
ENVIRONMENT_BINARIES_BASE_URL = "/admin/environment/binaries/"
INSTALLED_BINARIES_BASE_URL = "/admin/machine/binary/"
@@ -32,10 +31,6 @@ def format_parsed_datetime(value: object) -> str:
return parsed.strftime("%Y-%m-%d %H:%M:%S") if parsed else ""
def get_live_config_url(key: str) -> str:
return f"{LIVE_CONFIG_BASE_URL}{quote(key)}/"
def get_environment_binary_url(name: str) -> str:
return f"{ENVIRONMENT_BINARIES_BASE_URL}{quote(name)}/"
+25 -15
View File
@@ -142,25 +142,31 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
process = result.process_record
process_display = "-"
if process:
process_url = html.escape(reverse("admin:machine_process_change", args=[process.id]), quote=True)
process_label = html.escape(get_process_link_label(process), quote=True)
process_display = f'''
<a href="{reverse("admin:machine_process_change", args=[process.id])}"
<a href="{process_url}"
style="color: #2563eb; text-decoration: none; font-family: ui-monospace, monospace; font-size: 12px;"
title="View process">{get_process_link_label(process)}</a>
title="View process">{process_label}</a>
'''
machine_display = "-"
if process and process.machine_id:
machine_url = html.escape(reverse("admin:machine_machine_change", args=[process.machine_id]), quote=True)
machine_label = html.escape(str(process.machine.hostname or ""), quote=True)
machine_display = f'''
<a href="{reverse("admin:machine_machine_change", args=[process.machine_id])}"
<a href="{machine_url}"
style="color: #2563eb; text-decoration: none; font-size: 12px;"
title="View machine">{process.machine.hostname}</a>
title="View machine">{machine_label}</a>
'''
# Truncate output for display
full_output = result.output_str_for_display() or "-"
output_display = full_output[:60]
if len(full_output) > 60:
output_display += "..."
full_output_raw = result.output_str_for_display() or "-"
output_display_raw = full_output_raw[:60]
if len(full_output_raw) > 60:
output_display_raw += "..."
full_output = html.escape(full_output_raw)
output_display = html.escape(output_display_raw)
display_cmd = build_abx_dl_display_command(result)
replay_cmd = build_abx_dl_replay_command(result, config=config)
@@ -174,9 +180,13 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
output_link = build_snapshot_url(snapshot_id, embed_path, config=config)
else:
output_link = build_snapshot_url(snapshot_id, "", config=config)
output_link_attr = html.escape(output_link, quote=True)
# Get version - try cmd_version field
version = result.cmd_version if result.cmd_version else "-"
version = html.escape(str(result.cmd_version if result.cmd_version else "-"), quote=True)
plugin_text = html.escape(str(result.plugin or ""), quote=True)
status_text = html.escape(str(status), quote=True)
pwd_text = html.escape(str(result.pwd or "-"), quote=True)
# Unique ID for this row's expandable output
row_id = f"output_{idx}_{str(result.id)[:8]}"
@@ -193,18 +203,18 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
<td style="padding: 10px 12px; white-space: nowrap;">
<span style="display: inline-block; padding: 3px 10px; border-radius: 12px;
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: {color}; background: {bg};">{status}</span>
color: {color}; background: {bg};">{status_text}</span>
</td>
<td style="padding: 10px 12px; white-space: nowrap; font-size: 20px;" title="{result.plugin}">
<td style="padding: 10px 12px; white-space: nowrap; font-size: 20px;" title="{plugin_text}">
{icon}
</td>
<td style="padding: 10px 12px; font-weight: 500; color: #334155;">
<a href="{output_link}" target="_blank"
<a href="{output_link_attr}" target="_blank"
style="color: #334155; text-decoration: none;"
title="View output fullscreen"
onmouseover="this.style.color='#2563eb'; this.style.textDecoration='underline';"
onmouseout="this.style.color='#334155'; this.style.textDecoration='none';">
{result.plugin}
{plugin_text}
</a>
</td>
<td style="padding: 10px 12px; max-width: 280px;">
@@ -231,7 +241,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
</td>
<td style="padding: 10px 8px; white-space: nowrap;">
<div style="display: flex; gap: 4px;">
<a href="{output_link}" target="_blank"
<a href="{output_link_attr}" target="_blank"
style="padding: 4px 8px; background: #f1f5f9; border-radius: 4px; color: #475569; text-decoration: none; font-size: 11px;"
title="View output">📄</a>
<a href="{reverse("admin:core_archiveresult_change", args=[result.id])}"
@@ -250,7 +260,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
<div style="font-size: 11px; color: #64748b; margin-bottom: 8px;">
<span style="margin-right: 16px;"><b>ID:</b> <code>{str(result.id)}</code></span>
<span style="margin-right: 16px;"><b>Version:</b> <code>{version}</code></span>
<span style="margin-right: 16px;"><b>PWD:</b> <code>{result.pwd or "-"}</code></span>
<span style="margin-right: 16px;"><b>PWD:</b> <code>{pwd_text}</code></span>
</div>
<div style="font-size: 11px; color: #64748b; margin-bottom: 8px;">
<b>Output:</b>
+48
View File
@@ -3,7 +3,15 @@ __package__ = "archivebox.core"
from typing import TYPE_CHECKING, Any
from django.contrib import admin
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.contrib.auth.decorators import login_not_required
from django.contrib.auth.views import LoginView
from django.db import DatabaseError, connection
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views.decorators.cache import never_cache
from admin_data_views.admin import (
admin_data_index_view as adv_admin_data_index_view,
get_admin_data_urls as adv_get_admin_data_urls,
@@ -12,6 +20,7 @@ from admin_data_views.admin import (
from archivebox.config import VERSION
from archivebox.config.version import get_COMMIT_HASH
from archivebox.core.routes_util import is_allowed_archivebox_redirect_url
if TYPE_CHECKING:
from django.http import HttpRequest
@@ -21,6 +30,17 @@ if TYPE_CHECKING:
from admin_data_views.typing import AppDict
class ArchiveBoxLoginView(LoginView):
def get_redirect_url(self) -> str:
redirect_to = self.request.POST.get(
self.redirect_field_name,
self.request.GET.get(self.redirect_field_name),
)
if is_allowed_archivebox_redirect_url(redirect_to, request=self.request):
return redirect_to
return ""
class ArchiveBoxAdmin(admin.AdminSite):
site_header = "ArchiveBox"
index_title = "Admin Views"
@@ -72,6 +92,34 @@ class ArchiveBoxAdmin(admin.AdminSite):
def admin_data_index_view(self, request: "HttpRequest", **kwargs: Any) -> "TemplateResponse":
return adv_admin_data_index_view(self, request, **kwargs)
@method_decorator(never_cache)
@login_not_required
def login(self, request: "HttpRequest", extra_context: dict[str, Any] | None = None) -> "TemplateResponse":
if request.method == "GET" and self.has_permission(request):
return HttpResponseRedirect(reverse("admin:index", current_app=self.name))
from django.contrib.admin.forms import AdminAuthenticationForm
context = {
**self.each_context(request),
"title": _("Log in"),
"subtitle": None,
"app_path": request.get_full_path(),
"username": request.user.get_username(),
}
if REDIRECT_FIELD_NAME not in request.GET and REDIRECT_FIELD_NAME not in request.POST:
context[REDIRECT_FIELD_NAME] = reverse("admin:index", current_app=self.name)
context.update(extra_context or {})
index_path = reverse("admin:index", current_app=self.name)
request.current_app = self.name
return ArchiveBoxLoginView.as_view(
extra_context=context,
authentication_form=self.login_form or AdminAuthenticationForm,
template_name=self.login_template or "admin/login.html",
next_page=index_path,
)(request)
def index(self, request: "HttpRequest", extra_context: dict[str, Any] | None = None) -> "TemplateResponse":
response = super().index(request, extra_context)
if connection.vendor != "sqlite":
+78 -69
View File
@@ -30,7 +30,9 @@ from archivebox.plugins.discovery import get_plugin_icon, get_plugin_name, get_p
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,
@@ -107,9 +109,7 @@ class SnapshotPermissionsListFilter(admin.SimpleListFilter):
def queryset(self, request, queryset):
value = self.value()
if value:
return queryset.filter(
Q(permissions=value) | (Q(permissions__isnull=True) & Q(crawl__permissions=value)),
)
return queryset.filter(permissions=value)
return queryset
@@ -229,8 +229,6 @@ class SnapshotResultHealthListFilter(admin.SimpleListFilter):
total_results=ArchiveResult.snapshot_count_expr(),
matching_results=ArchiveResult.snapshot_count_expr(status=status),
).filter(matching_results__gt=F("total_results") / 2)
queryset._archivebox_count_hint = "model_estimate"
queryset.query._archivebox_count_hint = queryset._archivebox_count_hint
return queryset
# Rare statuses are faster status-first: use the
@@ -262,13 +260,15 @@ 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")
.values_list("snapshot_id", "plugin", "status", "output_size", "output_files")
)
for snapshot_id, plugin, status, output_size in rows.iterator(chunk_size=1000):
for snapshot_id, plugin, status, output_size, output_files in rows.iterator(chunk_size=1000):
if plugin in seen_plugins[snapshot_id]:
continue
seen_plugins[snapshot_id].add(plugin)
results_by_snapshot[snapshot_id].append(SimpleNamespace(plugin=plugin, status=status, output_size=output_size))
results_by_snapshot[snapshot_id].append(
SimpleNamespace(plugin=plugin, status=status, output_size=output_size, output_files=output_files),
)
for obj in self.result_list:
obj.__dict__["_admin_archiveresults"] = results_by_snapshot[obj.pk]
@@ -277,13 +277,7 @@ class SnapshotChangeList(SearchResultsChangeList):
super().get_results(request)
if request.GET.get("_embedded") == "crawl":
self.full_result_count = self.result_count
else:
self.full_result_count = self.model_admin.get_paginator(
request,
self.model._default_manager.all().order_by("-id"),
self.list_per_page,
).count
self.show_full_result_count = True
self.show_full_result_count = True
self._attach_archiveresult_summaries()
@@ -320,10 +314,7 @@ class SnapshotAdminForm(forms.ModelForm):
instance = super().save(commit=False)
permissions = self.cleaned_data["permissions_config"]
config = dict(instance.config or {})
if permissions == instance.crawl.permissions:
config.pop("PERMISSIONS", None)
else:
config["PERMISSIONS"] = permissions
config["PERMISSIONS"] = permissions
instance.config = config
# Handle tags_editor field
@@ -482,7 +473,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
paginator = AcceleratedPaginator
save_on_top = True
show_full_result_count = False
show_full_result_count = True
def get_changelist(self, request, **kwargs):
return SnapshotChangeList
@@ -578,12 +569,9 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
if permissions not in dict(PERMISSIONS_CHOICES):
return HttpResponseBadRequest("Invalid permissions value")
snapshot = get_object_or_404(Snapshot.objects.select_related("crawl"), pk=object_id)
snapshot = get_object_or_404(Snapshot, pk=object_id)
config = dict(snapshot.config or {})
if permissions == snapshot.crawl.permissions:
config.pop("PERMISSIONS", None)
else:
config["PERMISSIONS"] = permissions
config["PERMISSIONS"] = permissions
# Keep the quick-edit write to one targeted UPDATE so SQLite only holds
# the write lock for the permission/config change itself. safe_update()
@@ -606,18 +594,10 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
now = timezone.now()
updated = 0
batch = []
snapshots = (
queryset.select_related(None)
.select_related("crawl")
.only("id", "config", "crawl__id", "crawl__permissions")
.prefetch_related(None)
)
snapshots = queryset.select_related(None).only("id", "config").prefetch_related(None)
for snapshot in snapshots.iterator(chunk_size=500):
config = dict(snapshot.config or {})
if permissions == snapshot.crawl.permissions:
config.pop("PERMISSIONS", None)
else:
config["PERMISSIONS"] = permissions
config["PERMISSIONS"] = permissions
snapshot.config = config
snapshot.modified_at = now
batch.append(snapshot)
@@ -665,13 +645,26 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
"plugin",
"status",
"output_size",
"output_files",
),
),
)
else:
prefetches.append(
Prefetch(
"crawl",
queryset=Crawl.objects.select_related("created_by").only(
"id",
"created_by_id",
"created_by__id",
"created_by__username",
),
),
)
qs = super().get_queryset(request).select_related("crawl__created_by")
qs = super().get_queryset(request)
if is_change_view:
qs = qs.defer("notes")
qs = qs.select_related("crawl__created_by").defer("notes")
else:
qs = qs.only(
"id",
@@ -685,13 +678,6 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
"fs_version",
"output_size",
"permissions",
"crawl__id",
"crawl__permissions",
"crawl__persona_id",
"crawl__status",
"crawl__created_by_id",
"crawl__created_by__id",
"crawl__created_by__username",
)
qs = qs.prefetch_related(*prefetches)
if needs_files_sort:
@@ -707,10 +693,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
def permissions_badge(self, obj):
permissions = obj.__dict__.get("snapshot_permissions")
if permissions is None:
if obj.permissions:
permissions = obj.permissions
else:
permissions = obj.crawl.permissions
permissions = obj.permissions
permissions = normalize_permissions(permissions)
icon, label, fg, bg = SNAPSHOT_PERMISSION_META[permissions]
menu_items = format_html_join(
@@ -959,30 +942,54 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
config = request.archivebox_config
results = self._get_prefetched_results(obj)
if results is not None:
has_screenshot = any(r.plugin == "screenshot" for r in results)
has_favicon = any(r.plugin == "favicon" for r in results)
results = [r for r in results if r.plugin in ("screenshot", EXTENSION_SCREENSHOT_PLUGIN, "favicon")]
else:
available_plugins = set(obj.archiveresult_set.filter(plugin__in=("screenshot", "favicon")).values_list("plugin", flat=True))
has_screenshot = "screenshot" in available_plugins
has_favicon = "favicon" in available_plugins
results = list(
obj.archiveresult_set.filter(plugin__in=("screenshot", EXTENSION_SCREENSHOT_PLUGIN, "favicon")).only(
"snapshot_id",
"plugin",
"status",
"output_files",
"output_str",
),
)
if not has_screenshot and not has_favicon:
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
if has_screenshot:
img_url = build_snapshot_url(str(obj.id), "screenshot/screenshot.png", request=request, config=config)
fallbacks = [
build_snapshot_url(str(obj.id), "screenshot.png", request=request, config=config),
build_snapshot_url(str(obj.id), "favicon/favicon.ico", request=request, config=config),
build_snapshot_url(str(obj.id), "favicon.ico", request=request, config=config),
]
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 = build_snapshot_url(str(obj.id), "favicon/favicon.ico", request=request, config=config)
fallbacks = [
build_snapshot_url(str(obj.id), "favicon.ico", request=request, config=config),
]
img_url = favicon_urls[0]
fallbacks = favicon_urls[1:]
img_alt = "Favicon"
preview_class = "favicon"
@@ -1000,6 +1007,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
"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="")
@@ -1008,10 +1017,10 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
if not preview:
return ""
request = self.request
config = request.archivebox_config
favicon_url = build_snapshot_url(str(obj.id), "favicon/favicon.ico", request=request, config=config)
fallback_list = ",".join([build_snapshot_url(str(obj.id), "favicon.ico", request=request, config=config)])
favicon_url = preview.get("favicon_url") or ""
if not favicon_url:
return ""
fallback_list = preview.get("favicon_fallback_list") or ""
onerror_js = (
"this.dataset.fallbacks && this.dataset.fallbacks.length ? "
"(this.src=this.dataset.fallbacks.split(',').shift(), "
+77 -21
View File
@@ -6,11 +6,10 @@ from decimal import Decimal, InvalidOperation, ROUND_CEILING
from django import forms
from archivebox.misc.util import URL_REGEX, find_all_urls, parse_filesize_to_bytes
from taggit.utils import edit_string_for_tags, parse_tags
from archivebox.base_models.admin import KeyValueWidget
from archivebox.crawls.schedule_util import validate_schedule
from archivebox.config.common import get_config, parse_delete_after
from archivebox.core.permissions import PERMISSIONS_CHOICES, PERMISSIONS_PUBLIC, filter_personas_by_permissions, is_admin_user
from archivebox.core.permissions import PERMISSIONS_CHOICES, PERMISSIONS_PUBLIC, filter_personas_by_permissions
from archivebox.core.widgets import TagEditorWidget, URLFiltersWidget
from archivebox.plugins.discovery import get_plugins
from archivebox.plugins.forms import (
@@ -30,22 +29,84 @@ DEPTH_CHOICES = (
)
def _split_strip(value: str, delimiter: str) -> list[str]:
return [part.strip() for part in value.split(delimiter) if part.strip()]
def parse_tag_string(value: str | None) -> list[str]:
"""Parse the legacy tag-editing format used by the admin tag field."""
if not value:
return []
if "," not in value and '"' not in value:
return sorted(set(_split_strip(value, " ")))
tags: list[str] = []
buffer: list[str] = []
deferred_chunks: list[str] = []
saw_unquoted_comma = False
in_quote = False
chars = iter(value)
try:
while True:
char = next(chars)
if char == '"':
if buffer:
deferred_chunks.append("".join(buffer))
buffer = []
in_quote = True
char = next(chars)
while char != '"':
buffer.append(char)
char = next(chars)
tag = "".join(buffer).strip()
if tag:
tags.append(tag)
buffer = []
in_quote = False
else:
if not saw_unquoted_comma and char == ",":
saw_unquoted_comma = True
buffer.append(char)
except StopIteration:
if buffer:
if in_quote and "," in buffer:
saw_unquoted_comma = True
deferred_chunks.append("".join(buffer))
delimiter = "," if saw_unquoted_comma else " "
for chunk in deferred_chunks:
tags.extend(_split_strip(chunk, delimiter))
return sorted(set(tags))
def edit_string_for_tag_names(tags) -> str:
names = []
for tag in tags:
name = tag.name
names.append(f'"{name}"' if "," in name or " " in name else name)
return ", ".join(sorted(names))
class AddLinkForm(PluginConfigFormMixin, forms.Form):
allow_crawl_execution_config_fields = False
# Basic fields
url = forms.CharField(
label="URLs",
strip=True,
strip=False,
widget=forms.Textarea(
attrs={
"data-url-regex": URL_REGEX.pattern,
"placeholder": (
"Enter URLs to archive, as one per line, CSV, JSON, or embedded in text "
"(e.g. markdown, HTML, etc.). Examples:\n"
"https://example.com\n"
"https://news.ycombinator.com,https://news.google.com\n"
"[ArchiveBox](https://github.com/ArchiveBox/ArchiveBox)"
"\n",
"Enter URL(s) to archive. Any format is ok: one per line, CSV, JSON, embedded in text, etc."
"Examples:\n\n"
"https://example.com\n\n"
"https://news.ycombinator.com,https://news.google.com\n\n"
"Or any text-based content [containing URLs](https://github.com/ArchiveBox/ArchiveBox)...",
),
},
),
@@ -238,7 +299,10 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
self.can_override_crawl_config = bool(self.request and is_admin_user(self.request))
self.can_override_crawl_config = False
if self.request is not None:
user = self.request.user
self.can_override_crawl_config = bool(user.is_authenticated and user.is_active and user.is_superuser)
super().__init__(*args, **kwargs)
default_persona = Persona.get_or_create_default()
@@ -314,12 +378,9 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
def clean_url(self):
value = self.cleaned_data.get("url") or ""
valid_urls = []
for url in find_all_urls(value):
valid_urls.append(url)
if not valid_urls:
if not list(find_all_urls(value)):
raise forms.ValidationError("Enter at least one valid URL.")
return "\n".join(valid_urls)
return value
def clean_url_filters(self):
from archivebox.crawls.models import Crawl
@@ -425,7 +486,7 @@ class AddLinkForm(PluginConfigFormMixin, forms.Form):
class TagWidget(forms.TextInput):
def format_value(self, value):
if value is not None and not isinstance(value, str):
value = edit_string_for_tags(value)
value = edit_string_for_tag_names(value)
return super().format_value(value)
@@ -434,12 +495,7 @@ class TagField(forms.CharField):
def clean(self, value):
value = super().clean(value)
try:
return parse_tags(value)
except ValueError:
raise forms.ValidationError(
"Please provide a comma-separated list of tags.",
)
return parse_tag_string(value)
def has_changed(self, initial, data):
# Always return False if the field is disabled since self.bound_data
+30 -9
View File
@@ -3,6 +3,7 @@ __package__ = "archivebox.core"
import ipaddress
import re
from pathlib import Path
from django.conf import settings
from django.utils import timezone
from django.contrib.auth.middleware import RemoteUserMiddleware
from django.contrib.auth.models import AnonymousUser
@@ -24,7 +25,6 @@ from archivebox.core.routes_util import (
get_base_host,
get_listen_host,
get_listen_subdomain,
get_public_host,
get_web_host,
host_matches,
is_snapshot_subdomain,
@@ -42,7 +42,7 @@ def _admin_login_hint_cookie_domain(config) -> str | None:
NOTE: this cookie carries only the single bit "user is logged in on
admin somewhere"; it MUST NOT be confused with the session cookie,
which stays admin-host-scoped (see core/settings.py
SESSION_COOKIE_DOMAIN comment — admin/public is a security boundary).
SESSION_COOKIE_DOMAIN comment — admin/web is a security boundary).
Returns the hostname portion of ``get_base_host`` (which respects
``BASE_URL`` and falls back to the local-bind mapping). Strips the
@@ -85,6 +85,34 @@ def TimezoneMiddleware(get_response):
return middleware
def AdminCookieIsolationMiddleware(get_response):
def middleware(request):
response = get_response(request)
config = request.__dict__.get("archivebox_config")
if config is None:
config = get_config(resolve_plugins=False)
request.archivebox_config = config
if not config.USES_SUBDOMAIN_ROUTING:
return response
request_host = (request.get_host() or "").lower()
if host_matches(request_host, get_admin_host(config=config)):
return response
if host_matches(request_host, get_web_host(config=config)):
for cookie_name in tuple(response.cookies.keys()):
if cookie_name != ADMIN_LOGIN_HINT_COOKIE:
response.cookies.pop(cookie_name, None)
return response
response.cookies.pop(settings.SESSION_COOKIE_NAME, None)
response.cookies.pop(settings.CSRF_COOKIE_NAME, None)
return response
return middleware
def CacheControlMiddleware(get_response):
snapshot_path_re = re.compile(r"^/[^/]+/\\d{8}/[^/]+/[0-9a-fA-F-]{8,36}/")
static_cache_key = (get_COMMIT_HASH() or VERSION or "dev").strip()
@@ -176,7 +204,6 @@ def HostRoutingMiddleware(get_response):
admin_host = get_admin_host(config=config)
web_host = get_web_host(config=config)
api_host = get_api_host(config=config)
public_host = get_public_host(config=config)
listen_host = get_listen_host(config=config)
subdomain = get_listen_subdomain(request_host, config=config)
@@ -278,12 +305,6 @@ def HostRoutingMiddleware(get_response):
request._cached_user = request.user
return get_response(request)
if host_matches(request_host, public_host):
if request.COOKIES.get(ADMIN_LOGIN_HINT_COOKIE) == "1" and (request.path == "/public" or request.path.startswith("/public/")):
target = build_admin_url("/admin/core/snapshot/", request=request)
return redirect(target)
return get_response(request)
if subdomain:
view = OriginalDomainHostView.as_view()
return view(request, domain=subdomain, path=request.path.lstrip("/"))
@@ -0,0 +1,69 @@
import django.db.models.fields.json
import django.db.models.functions.comparison
from django.db import migrations, models
from django.db.models import Q
VALID_PERMISSIONS = {"public", "unlisted", "private"}
BATCH_SIZE = 1000
def normalize_permissions(value, default="private"):
value = str(value or "").strip().lower()
return value if value in VALID_PERMISSIONS else default
def hydrate_snapshot_permissions(apps, schema_editor):
Snapshot = apps.get_model("core", "Snapshot")
db_alias = schema_editor.connection.alias
missing_permissions = Q(permissions__isnull=True) | (Q(permissions__isnull=False) & ~Q(permissions__in=VALID_PERMISSIONS))
batch = []
snapshots = (
Snapshot.objects.using(db_alias)
.filter(missing_permissions)
.select_related("crawl")
.only("id", "config", "crawl__permissions")
.iterator(chunk_size=BATCH_SIZE)
)
for snapshot in snapshots:
config = dict(snapshot.config or {})
config["PERMISSIONS"] = normalize_permissions(snapshot.crawl.permissions)
snapshot.config = config
batch.append(snapshot)
if len(batch) >= BATCH_SIZE:
Snapshot.objects.using(db_alias).bulk_update(batch, ["config"], batch_size=BATCH_SIZE)
batch.clear()
if batch:
Snapshot.objects.using(db_alias).bulk_update(batch, ["config"], batch_size=BATCH_SIZE)
class Migration(migrations.Migration):
dependencies = [
("crawls", "0016_hydrate_crawl_permissions"),
("core", "0049_alter_snapshot_url"),
]
operations = [
migrations.RunPython(hydrate_snapshot_permissions, migrations.RunPython.noop),
migrations.RemoveField(
model_name="snapshot",
name="permissions",
),
migrations.AddField(
model_name="snapshot",
name="permissions",
field=models.GeneratedField(
db_index=True,
db_persist=True,
editable=False,
expression=django.db.models.functions.comparison.Coalesce(
django.db.models.fields.json.KeyTextTransform("PERMISSIONS", "config"),
models.Value("private"),
output_field=models.CharField(max_length=16),
),
output_field=models.CharField(max_length=16),
),
),
]
+179 -44
View File
@@ -1,7 +1,7 @@
__package__ = "archivebox.core"
from typing import TYPE_CHECKING, Optional, Any
from collections.abc import Iterable, Sequence
from collections.abc import Iterable, Mapping, Sequence
import uuid
from archivebox.uuid_compat import CompactUUIDField, uuid7
from datetime import datetime, timedelta
@@ -36,9 +36,10 @@ from archivebox.misc.util import (
to_json,
ts_to_date_str,
urlencode,
htmlencode,
htmldecode,
sanitize_html_text,
urldecode,
validate_url_length,
validate_url,
)
from archivebox.plugins.discovery import (
get_plugins,
@@ -94,6 +95,12 @@ class Tag(ModelWithUUID):
def __str__(self):
return self.name
def save(self, *args, **kwargs):
update_fields = kwargs.get("update_fields")
if update_fields is None or "name" in update_fields:
self.name = sanitize_html_text(self.name).strip()
super().save(*args, **kwargs)
@property
def slug(self) -> str:
"""ASCII-safe slugified form of the tag name (derived, not stored)."""
@@ -136,7 +143,7 @@ class Tag(ModelWithUUID):
# Auto-attach to snapshot if in overrides
if overrides and "snapshot" in overrides and tag:
overrides["snapshot"].tags.add(tag)
overrides["snapshot"].add_tag_ids([tag.pk])
return tag
@@ -155,6 +162,32 @@ class SnapshotTag(models.Model):
class SnapshotQuerySet(models.QuerySet):
"""Custom QuerySet for Snapshot model with export methods that persist through .filter() etc."""
def bulk_create(self, objs, *args, **kwargs):
objs = list(objs)
missing_crawl_ids = set()
from archivebox.core.permissions import PERMISSIONS_VALUES
for obj in objs:
if isinstance(obj, self.model):
config = dict(obj.config or {})
permission = str(config.get("PERMISSIONS") or "").strip().lower()
if permission not in PERMISSIONS_VALUES and obj.crawl_id:
crawl = getattr(obj, "crawl", None)
if not getattr(crawl, "permissions", None):
missing_crawl_ids.add(str(obj.crawl_id))
crawl_permissions_by_id = {}
if missing_crawl_ids:
crawl_permissions_by_id = {
str(crawl_id): permissions
for crawl_id, permissions in Crawl.objects.filter(pk__in=missing_crawl_ids).values_list("pk", "permissions")
}
for obj in objs:
if isinstance(obj, self.model):
obj.ensure_permissions_config(crawl_permissions=crawl_permissions_by_id.get(str(obj.crawl_id)))
return super().bulk_create(objs, *args, **kwargs)
def paged_iterator(self, chunk_size: int = 500):
"""
Iterate snapshots using bounded keyset pages instead of one streaming cursor.
@@ -463,6 +496,8 @@ class SnapshotManager(models.Manager.from_queryset(SnapshotQuerySet)): # ty: ig
class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWithNotes, ModelWithHealthStats, ModelWithStateMachine):
INTERNAL_INPUT_URL = "archivebox://internal"
id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True)
created_at = models.DateTimeField(default=timezone.now, db_index=True)
modified_at = models.DateTimeField(auto_now=True)
@@ -505,8 +540,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
)
config = models.JSONField(default=dict, null=False, blank=False, editable=True)
permissions = models.GeneratedField(
expression=KT("config__PERMISSIONS"),
output_field=models.CharField(max_length=16, null=True),
expression=Coalesce(KT("config__PERMISSIONS"), Value("private"), output_field=models.CharField(max_length=16)),
output_field=models.CharField(max_length=16, null=False),
db_persist=True,
db_index=True,
editable=False,
@@ -543,6 +578,15 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
@property
def sm(self) -> "SnapshotMachine": ...
def add_tag_ids(self, tag_ids: Iterable[int | str]) -> None:
tag_ids = [tag_id for tag_id in dict.fromkeys(tag_ids) if tag_id]
if not tag_ids:
return
SnapshotTag.objects.bulk_create(
[SnapshotTag(snapshot_id=self.pk, tag_id=tag_id) for tag_id in tag_ids],
ignore_conflicts=True,
)
class Meta(
ModelWithDeleteAfter.Meta,
ModelWithOutputDir.Meta,
@@ -651,12 +695,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
def restore_paused_scheduler_marker(self) -> None:
"""
Keep explicit maintenance from accidentally resuming paused snapshots.
Targeted jobs such as `archivebox update --index-only` may bump
retry_at so the orchestrator can run only queued search ArchiveResult
rows. After that maintenance pass, the lifecycle must remain PAUSED and
retry_at must go back to MAX until a real resume transition happens.
Restore the indefinite scheduler marker owned by the PAUSED lifecycle.
"""
type(self).objects.filter(
pk=self.pk,
@@ -756,7 +795,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
)
@classmethod
def is_archivebox_internal_url(cls, url: str) -> bool:
def is_archivebox_internal_url(cls, url: str, *, config: Mapping[str, Any] | Any | None = None) -> bool:
parsed = urlparse((url or "").strip())
if parsed.scheme not in ("http", "https") or not parsed.hostname:
return False
@@ -766,15 +805,29 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
get_api_host,
get_base_host,
get_listen_host,
get_public_host,
get_web_host,
split_host_port,
)
config = get_config()
if config is None:
config = get_config()
elif isinstance(config, Mapping):
route_config = config
class RouteConfig:
BIND_ADDR = str(route_config.get("BIND_ADDR") or "")
BASE_URL = str(route_config.get("BASE_URL") or "")
CSRF_TRUSTED_ORIGINS = str(route_config.get("CSRF_TRUSTED_ORIGINS") or "")
SERVER_SECURITY_MODE = str(route_config.get("SERVER_SECURITY_MODE") or "")
@property
def USES_SUBDOMAIN_ROUTING(self) -> bool:
return self.SERVER_SECURITY_MODE == "safe-subdomains-fullreplay"
config = RouteConfig()
host = parsed.hostname.lower().strip(".")
port = str(parsed.port) if parsed.port else None
protected_subdomains = {"admin", "web", "api", "public"}
protected_subdomains = {"admin", "web", "api"}
protected_hosts: set[tuple[str, str | None]] = set()
protected_roots: set[tuple[str, str | None]] = set()
for host_value in (
@@ -783,7 +836,6 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
get_admin_host(config=config),
get_web_host(config=config),
get_api_host(config=config),
get_public_host(config=config),
):
if not host_value:
continue
@@ -836,17 +888,59 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
return Binary.objects.filter(process_set__archiveresult__snapshot_id=self.id).distinct()
def ensure_permissions_config(self, crawl_permissions: str | None = None) -> bool:
config = dict(self.config or {})
permission = str(config.get("PERMISSIONS") or "").strip().lower()
from archivebox.core.permissions import PERMISSIONS_PUBLIC, PERMISSIONS_VALUES, normalize_permissions
if permission not in PERMISSIONS_VALUES:
if self.crawl_id:
if not crawl_permissions:
crawl_permissions = Crawl.objects.filter(pk=self.crawl_id).values_list("permissions", flat=True).first()
config["PERMISSIONS"] = normalize_permissions(
crawl_permissions,
default=PERMISSIONS_PUBLIC,
)
self.config = config
return True
elif config.get("PERMISSIONS") != permission:
config["PERMISSIONS"] = permission
self.config = config
return True
return False
def validate_url_for_archiving(self, *, config: Mapping[str, Any] | Any | None = None) -> None:
if self.is_internal_input_url():
return
try:
validate_url(self.url or "")
except ValueError as err:
raise ValidationError({"url": str(err)}) from err
if self.is_archivebox_internal_url(self.url, config=config):
raise ValidationError({"url": "ArchiveBox cannot archive its own admin, web, api, or snapshot URLs."})
def is_internal_input_url(self) -> bool:
return (self.url or "").strip() == self.INTERNAL_INPUT_URL and self.depth == 0 and bool(self.crawl_id)
def save(self, *args, **kwargs):
update_fields = kwargs.get("update_fields")
validate_url_field = self._state.adding or update_fields is None or "url" in update_fields
if validate_url_field:
try:
validate_url_length(self.url or "")
except ValueError as err:
raise ValidationError({"url": str(err)}) from err
crawl_config_for_save = None
crawl_permissions_for_save = None
if self.crawl_id and validate_url_field:
crawl_row = Crawl.objects.filter(pk=self.crawl_id).values("config", "permissions").first()
if crawl_row:
crawl_config_for_save = crawl_row.get("config") or {}
crawl_permissions_for_save = crawl_row.get("permissions")
if self.is_archivebox_internal_url(self.url):
raise ValidationError({"url": "ArchiveBox cannot archive its own admin, web, api, or snapshot URLs."})
if self.ensure_permissions_config(crawl_permissions=crawl_permissions_for_save):
if update_fields is not None:
kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "config"]))
if validate_url_field:
self.validate_url_for_archiving(config=crawl_config_for_save if self.crawl_id else None)
if not self.bookmarked_at:
self.bookmarked_at = self.created_at or timezone.now()
@@ -855,14 +949,17 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
if self._state.adding or update_fields is None or "title" in update_fields:
self.title = self._normalize_title_candidate(self.title, snapshot_url=self.url or "") or None
if self._state.adding or update_fields is None or "notes" in update_fields:
self.notes = sanitize_html_text(self.notes)
# Migrate filesystem if needed (happens automatically on save)
if self.pk and self.fs_migration_needed:
existing_snapshot = self.pk and not self._state.adding
if existing_snapshot and self.fs_migration_needed:
self.migrate_filesystem_to_current_version()
update_fields = kwargs.get("update_fields")
if update_fields is not None:
kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "fs_version", "modified_at"]))
elif self.pk:
elif existing_snapshot:
current_dir = self.get_storage_path_for_version(self._fs_current_version())
source_dir = Path(self.output_dir)
if source_dir.exists() and source_dir != current_dir and not source_dir.is_symlink():
@@ -875,8 +972,29 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
def finish_snapshot_save():
self.ensure_legacy_archive_symlink()
self.ensure_crawl_symlink()
crawl = self.crawl
if not crawl.url_passes_filters(self.url, snapshot=self):
crawl = Crawl.objects.filter(pk=self.crawl_id).first()
if crawl is None:
return
crawl_tag_names = crawl.current_tag_names()
if crawl_tag_names:
# Snapshots can be created by parser hook side-effect records,
# direct ORM creates, or legacy crawl URL expansion. Crawl tags
# are user-facing metadata on the whole import, so attach them
# at the Snapshot.save() boundary instead of relying on every
# caller to remember to duplicate this fanout logic.
tags_by_name = {tag.name: tag for tag in Tag.objects.filter(name__in=crawl_tag_names)}
missing_tags = [Tag(name=name) for name in crawl_tag_names if name not in tags_by_name]
if missing_tags:
Tag.objects.bulk_create(missing_tags, ignore_conflicts=True)
tags_by_name = {tag.name: tag for tag in Tag.objects.filter(name__in=crawl_tag_names)}
self.add_tag_ids([tag.pk for name in crawl_tag_names if (tag := tags_by_name.get(name))])
# Snapshot.save() normally appends newly created URLs to Crawl.urls
# so legacy/direct crawls can keep their queue text in sync. For
# internal-input crawls that would corrupt the original submitted
# import text; parsed URLs are represented by child Snapshot rows.
if crawl.has_internal_input_root():
return
if not crawl.url_passes_filters(self.url, snapshot=self, use_effective_config=False):
return
# Best-effort skip if our URL is already recorded on the crawl;
# the atomic UPDATE below is what actually prevents clobbering.
@@ -1585,7 +1703,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
with transaction.atomic():
for tag_name in new_tags:
tag, _ = Tag.objects.get_or_create(name=tag_name)
self.tags.add(tag)
self.add_tag_ids([tag.pk])
def _merge_archive_results_from_index(self, index_data: dict, update_existing: bool = True):
"""Merge ArchiveResults one row per hook; retries update the existing row."""
@@ -2031,7 +2149,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
# Merge tags
for tag in dup.tags.all():
keeper.tags.add(tag)
keeper.add_tag_ids([tag.pk])
# Move ArchiveResults
ArchiveResult.objects.filter(snapshot=dup).update(
@@ -2065,6 +2183,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
@admin.display(description="Tags")
def tags_str(self, nocache=True) -> str | None:
if "_tags_str_cached" in self.__dict__:
return self.__dict__["_tags_str_cached"]
calc_tags_str = lambda: ",".join(sorted(tag.name for tag in self.tags.all()))
prefetched_cache = self.__dict__.get("_prefetched_objects_cache", {})
if "tags" in prefetched_cache:
@@ -2202,7 +2322,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
@staticmethod
def _normalize_title_candidate(candidate: str | None, *, snapshot_url: str) -> str:
title = " ".join(line.strip() for line in str(candidate or "").splitlines() if line.strip()).strip()
title = " ".join(line.strip() for line in sanitize_html_text(candidate).splitlines() if line.strip()).strip()
if not title:
return ""
if title.lower() in {"pending...", "no title found", "unable to detect page title"}:
@@ -2477,7 +2597,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
def save_tags(self, tags: Iterable[str] = ()) -> None:
tags_id = [Tag.objects.get_or_create(name=tag)[0].pk for tag in tags if tag.strip()]
self.tags.clear()
self.tags.add(*tags_id)
self.add_tag_ids(tags_id)
def pending_archiveresults(self) -> QuerySet["ArchiveResult"]:
return self.archiveresult_set.exclude(status__in=ArchiveResult.FINAL_OR_ACTIVE_STATES)
@@ -2514,10 +2634,16 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
return
# Delete ArchiveResults that produced no output files
empty_ars = self.archiveresult_set.filter(
output_files={}, # No output files
).filter(
status__in=ArchiveResult.FINAL_STATES, # Only delete finished ones
empty_ars = (
self.archiveresult_set.filter(
output_files={}, # No output files
)
.filter(
status__in=ArchiveResult.FINAL_STATES, # Only delete finished ones
)
.exclude(
status=ArchiveResult.StatusChoices.FAILED,
)
)
if empty_ars.exists():
@@ -2658,15 +2784,18 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
)
rprint(f"[red]⚠️ Snapshot.from_json auto-created new crawl {crawl.id} for url={url}[/red]", file=sys.stderr)
# Parse tags (accept either a list ["tag1", "tag2"] or a comma-separated string "tag1,tag2")
# Parser hooks emit child Snapshot records through this generic
# dispatcher after crawling an internal import root. Those child rows
# must inherit crawl-level tags just like direct Crawl.urls snapshots;
# otherwise `archivebox add --tag ... < bookmarks.html` loses the tag
# unless the parser format also happened to provide its own tags.
tags_raw = record.get("tags", "")
tag_list = []
tag_list = list(crawl.current_tag_names()) if crawl else []
if isinstance(tags_raw, list):
tag_list = list(dict.fromkeys(tag.strip() for tag in tags_raw if tag.strip()))
tag_list.extend(tag.strip() for tag in tags_raw if tag.strip())
elif tags_raw:
tag_list = list(
dict.fromkeys(tag.strip() for tag in re.split(config.TAG_SEPARATOR_PATTERN, tags_raw) if tag.strip()),
)
tag_list.extend(tag.strip() for tag in re.split(config.TAG_SEPARATOR_PATTERN, tags_raw) if tag.strip())
tag_list = list(dict.fromkeys(tag_list))
# Check for existing snapshot with same URL in same crawl
# (URLs can exist in multiple crawls, but should be unique within a crawl)
@@ -2750,6 +2879,12 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
Creates one ArchiveResult per hook (not per plugin), with hook_name set.
This enables step-based execution where all hooks in a step can run in parallel.
"""
try:
self.validate_url_for_archiving()
except ValidationError as err:
rprint(f"[yellow][!] Skipping blocked snapshot URL: {(self.url or '')[:120]}... ({err})[/yellow]")
return []
if hooks is None:
from archivebox.plugins.hooks import discover_hooks
from archivebox.config.common import get_config
@@ -3300,8 +3435,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
context = {
**self.to_dict(extended=True),
"snapshot": self,
"title": htmlencode(self.resolved_title or (self.base_url if is_archived else TITLE_LOADING_MSG)),
"url_str": htmlencode(urldecode(self.base_url)),
"title": htmldecode(self.resolved_title or (self.base_url if is_archived else TITLE_LOADING_MSG)),
"url_str": htmldecode(urldecode(self.base_url)),
"archive_url": urlencode(f"warc/{self.timestamp}" or (self.domain if is_archived else "")) or "about:blank",
"extension": self.extension or "html",
"tags": self.tags_str() or "untagged",
+3 -9
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from django.db.models import Q, QuerySet
from django.db.models import QuerySet
from django.http import HttpRequest
PERMISSIONS_PUBLIC = "public"
@@ -32,10 +32,7 @@ def is_admin_user(request: HttpRequest) -> bool:
def get_snapshot_permissions(snapshot) -> str:
permissions = snapshot.permissions
if not permissions:
permissions = snapshot.crawl.permissions
return normalize_permissions(permissions)
return normalize_permissions(snapshot.permissions)
def can_view_snapshot(request: HttpRequest, snapshot) -> bool:
@@ -56,10 +53,7 @@ def filter_personas_by_permissions(queryset: QuerySet, allowed_permissions: set[
def filter_snapshots_by_permissions(queryset: QuerySet, *, direct: bool = False, allowed_permissions: set[str] | None = None) -> QuerySet:
allowed_permissions = allowed_permissions or ({PERMISSIONS_PUBLIC, PERMISSIONS_UNLISTED} if direct else {PERMISSIONS_PUBLIC})
allowed = sorted(allowed_permissions)
return queryset.exclude(
(Q(permissions__isnull=False) & ~Q(permissions__in=allowed))
| (Q(permissions__isnull=True) & (Q(crawl__permissions__isnull=True) | ~Q(crawl__permissions__in=allowed))),
)
return queryset.filter(permissions__in=allowed)
def public_snapshots_queryset(queryset: QuerySet) -> QuerySet:
+4
View File
@@ -0,0 +1,4 @@
__package__ = "archivebox.core"
EXTENSION_SCREENSHOT_PLUGIN = "chrome_extension_screenshot"
+32 -8
View File
@@ -10,7 +10,7 @@ def _is_signal_interrupted_exit(exit_code: int | None) -> bool:
return exit_code is not None and (exit_code < 0 or exit_code >= 128)
def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int]:
def recover_orchestrator_state(*, include_chrome: bool = False, crawl_id: str | None = None) -> dict[str, int]:
from archivebox.crawls.models import Crawl
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.services.archive_result_service import _collect_output_metadata
@@ -21,10 +21,13 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
now = timezone.now()
recovery_console = Console(stderr=True, highlight=False, soft_wrap=True)
crawl_filter = {"id": crawl_id} if crawl_id else {}
snapshot_filter = {"crawl_id": crawl_id} if crawl_id else {}
result_filter = {"snapshot__crawl_id": crawl_id} if crawl_id else {}
cleaned = {
"processes_stale_running": Process.cleanup_stale_running(),
"processes_orphaned_workers": Process.cleanup_orphaned_workers(),
"chrome_processes_orphaned": Process.cleanup_orphaned_chrome() if include_chrome else 0,
"processes_stale_running": 0 if crawl_id else Process.cleanup_stale_running(),
"processes_orphaned_workers": 0 if crawl_id else Process.cleanup_orphaned_workers(),
"chrome_processes_orphaned": Process.cleanup_orphaned_chrome() if include_chrome and not crawl_id else 0,
"crawls_queued_without_retry_at": 0,
"snapshots_queued_without_retry_at": 0,
"archiveresults_backoff": 0,
@@ -58,14 +61,16 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
cleaned["crawls_queued_without_retry_at"] = Crawl.objects.filter(
status=Crawl.StatusChoices.QUEUED,
retry_at__isnull=True,
**crawl_filter,
).update(retry_at=now, modified_at=now)
cleaned["snapshots_queued_without_retry_at"] = Snapshot.objects.filter(
status=Snapshot.StatusChoices.QUEUED,
retry_at__isnull=True,
crawl__status__in=Crawl.RUNNABLE_STATES,
**snapshot_filter,
).update(retry_at=now, modified_at=now)
backoff_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.BACKOFF)
orphaned_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.STARTED).exclude(
backoff_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.BACKOFF, **result_filter)
orphaned_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.STARTED, **result_filter).exclude(
process__status=Process.StatusChoices.RUNNING,
)
# ArchiveResult has no retry_at scheduler. Wake only the parent Snapshots
@@ -92,7 +97,7 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
# only after this runner has won the single-runner gate, so it can safely
# unlock those stale plugin leases for immediate processing instead of
# waiting out the previous owner's full lock timeout.
queued_plugin_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.QUEUED)
queued_plugin_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.QUEUED, **result_filter)
cleaned["snapshots_queued_plugin_rows_waiting_on_stale_lease"] = (
Snapshot.objects.filter(
id__in=queued_plugin_results.values("snapshot_id"),
@@ -113,16 +118,33 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
process_type=Process.TypeChoices.HOOK,
archiveresult__isnull=True,
).exclude(status=Process.StatusChoices.RUNNING)
crawl_snapshot_ids = (
{str(snapshot_id) for snapshot_id in Snapshot.objects.filter(crawl_id=crawl_id).values_list("id", flat=True)} if crawl_id else None
)
if crawl_snapshot_ids is not None:
# Targeted `archivebox run --crawl-id ...` is used by foreground add/update
# commands and by resume of one existing crawl. It must still repair bad
# hook state for that crawl, but it must not scan historical orphaned hook
# rows from unrelated crawls before the first snapshot can run.
if crawl_snapshot_ids:
snapshot_pwd_filter = Q()
for snapshot_id in crawl_snapshot_ids:
snapshot_pwd_filter |= Q(pwd__contains=snapshot_id)
orphaned_hook_processes = orphaned_hook_processes.filter(snapshot_pwd_filter)
else:
orphaned_hook_processes = orphaned_hook_processes.none()
for process in orphaned_hook_processes.only("id", "pwd", "cmd", "process_type", "status"):
hook_script_name = process.hook_script_name
if not hook_script_name or not process.pwd:
continue
plugin_dir = Path(process.pwd)
if crawl_snapshot_ids is not None and plugin_dir.parent.name not in crawl_snapshot_ids:
continue
try:
# Old or synthetic hook Process rows can point at arbitrary paths.
# Only paths whose parent directory is a valid Snapshot id can be
# reconstructed into ArchiveResult rows.
snapshot = Snapshot.objects.filter(id=plugin_dir.parent.name).first()
snapshot = Snapshot.objects.filter(id=plugin_dir.parent.name, **snapshot_filter).first()
except ValidationError:
continue
if snapshot is None:
@@ -181,6 +203,7 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
Snapshot.objects.filter(id=snapshot.id).update(retry_at=now, modified_at=now)
started_snapshots = Snapshot.objects.filter(status=Snapshot.StatusChoices.STARTED).filter(
Q(retry_at__isnull=True) | Q(retry_at__gt=now),
**snapshot_filter,
)
# Broken lock repair: STARTED + retry_at=NULL or retry_at in the future
@@ -205,6 +228,7 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
# are already final.
recoverable_started_crawls = Crawl.objects.filter(status=Crawl.StatusChoices.STARTED).filter(
Q(retry_at__isnull=True) | Q(retry_at__gt=now),
**crawl_filter,
)
due_started_crawls = recoverable_started_crawls.annotate(has_due_child=Exists(due_child_snapshots)).filter(has_due_child=True)
+58 -23
View File
@@ -4,12 +4,14 @@ import re
from typing import Any
from urllib.parse import urlparse
from django.utils.http import url_has_allowed_host_and_scheme
from archivebox.config.common import get_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", "public")
_ROLE_SUBDOMAIN_LABELS = ("admin", "web", "api")
def split_host_port(host: str) -> tuple[str, str | None]:
@@ -102,7 +104,7 @@ def _with_port(host: str, port: str | None) -> str:
def strip_role_subdomain(host: str) -> str:
"""Strip leading ``admin.`` / ``web.`` / ``api.`` / ``public.`` / ``snap-*.``
"""Strip leading ``admin.`` / ``web.`` / ``api.`` / ``snap-*.``
labels from a host (preserving the port). Strips repeatedly so an
already-compounded host like ``snap-X.snap-X.<base>`` reduces all the
way down to ``<base>``.
@@ -172,13 +174,12 @@ def get_base_url(request=None, config: dict[str, Any] | None = None, **config_kw
return f"{scheme}://{_with_port('archivebox.localhost', req_port)}"
# C) Per-request fallback: when ``BASE_URL`` is unset and CSRF didn't
# give us a single origin, trust the request's Host header — but first
# peel off any ``admin.`` / ``web.`` / ``api.`` / ``public.`` /
# ``snap-*.`` label. Otherwise the URL builders below prepend their own
# role label onto a host that already carries one, producing the
# ``snap-X.snap-X.snap-X.<base>`` compounding bug. Django has already
# admitted the host via ALLOWED_HOSTS; the misconfig banner surfaces
# the case where the resulting URL doesn't match what the operator
# probably intended.
# peel off any ``admin.`` / ``web.`` / ``api.`` / ``snap-*.`` label.
# Otherwise the URL builders below prepend their own role label onto a
# host that already carries one, producing the ``snap-X.snap-X.snap-X``
# compounding bug. Django has already admitted the host via
# ALLOWED_HOSTS; the misconfig banner surfaces the case where the
# resulting URL doesn't match what the operator probably intended.
canonical_host = strip_role_subdomain(request.get_host())
return f"{scheme}://{canonical_host}"
@@ -220,13 +221,6 @@ def get_api_host(config: dict[str, Any] | None = None, **config_kwargs: Any) ->
return _build_base_host("api", config=config)
def get_public_host(config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
config = config or get_config(**config_kwargs)
if not config.USES_SUBDOMAIN_ROUTING:
return get_base_host(config=config)
return _build_base_host("public", config=config)
def get_snapshot_subdomain(snapshot_id: str) -> str:
normalized = re.sub(r"[^0-9a-fA-F]", "", snapshot_id or "")
suffix = (normalized[-12:] if len(normalized) >= 12 else normalized).lower()
@@ -292,6 +286,54 @@ def host_matches(request_host: str, target_host: str) -> bool:
return True
def is_allowed_archivebox_redirect_url(url: str | None, request=None, config: dict[str, Any] | None = None) -> bool:
"""Allow login redirects to this host or to the explicit ArchiveBox BASE_URL family.
Django already rejects arbitrary absolute ``next=`` URLs for the current host.
ArchiveBox also has legitimate cross-subdomain handoffs (admin/web/api/snap-*),
but only when BASE_URL is pinned. Without an explicit BASE_URL we do not widen
trust based on a request Host header that may be user supplied.
"""
if not url:
return False
require_https = bool(request and request.is_secure())
request_host = request.get_host() if request else ""
if url_has_allowed_host_and_scheme(url=url, allowed_hosts={request_host}, require_https=require_https):
return True
parsed = urlparse(url)
if not parsed.netloc:
return False
if parsed.scheme not in {"http", "https"}:
return False
if require_https and parsed.scheme != "https":
return False
if parsed.username or parsed.password:
return False
config = config or get_config()
base_url = _normalize_base_url(config.BASE_URL)
if not base_url:
return False
base_host, base_port = split_host_port(urlparse(base_url).netloc)
if not base_host:
return False
redirect_host = (parsed.hostname or "").lower()
if redirect_host != base_host and not redirect_host.endswith(f".{base_host}"):
return False
try:
redirect_port = str(parsed.port) if parsed.port else None
except ValueError:
return False
if base_port:
return redirect_port == base_port
return redirect_port is None
def _scheme_from_request(request=None, config: dict[str, Any] | None = None) -> str:
config = config or get_config()
override = _normalize_base_url(config.BASE_URL)
@@ -330,13 +372,6 @@ def get_api_base_url(request=None, config: dict[str, Any] | None = None, **confi
return _build_base_url_for_host(_build_base_host("api", request=request, config=config), request=request, config=config)
def get_public_base_url(request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
config = config or get_config(**config_kwargs)
if not config.USES_SUBDOMAIN_ROUTING:
return get_base_url(request=request, config=config)
return _build_base_url_for_host(_build_base_host("public", request=request, config=config), request=request, config=config)
def get_snapshot_base_url(snapshot_id: str, request=None, config: dict[str, Any] | None = None, **config_kwargs: Any) -> str:
config = config or get_config(**config_kwargs)
if not config.USES_SUBDOMAIN_ROUTING:
+20 -4
View File
@@ -13,7 +13,7 @@ import archivebox
from archivebox.config.constants import CONSTANTS
from archivebox.config.common import get_config
from archivebox.core.routes_util import normalize_base_url, get_admin_base_url, get_api_base_url
from archivebox.core.routes_util import get_api_base_url, get_admin_base_url, get_base_url, normalize_base_url
from .settings_logging import SETTINGS_LOGGING
@@ -84,6 +84,7 @@ DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD = "POST"
MIDDLEWARE = [
"archivebox.core.middleware.TimezoneMiddleware",
"django.middleware.security.SecurityMiddleware",
"archivebox.core.middleware.AdminCookieIsolationMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"archivebox.api.middleware.ApiCorsMiddleware",
@@ -419,13 +420,28 @@ SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
CSRF_COOKIE_SECURE = False
SESSION_COOKIE_SECURE = False
# When BASE_URL is an https:// URL the deployment is HTTPS end-to-end, typically
# behind a TLS-terminating proxy/tunnel (the bundled traefik/cloudflared profiles,
# or your own caddy/traefik/nginx) where the proxy -> archivebox hop is plain HTTP, so
# request.is_secure() / request.scheme would otherwise report http. Honour the
# proxy's X-Forwarded-Proto so request-derived schemes are correct, and mark the
# admin session + CSRF cookies Secure so auth cookies are never sent in cleartext.
# Derived from the RESOLVED base URL's scheme — no separate flag to keep in sync.
# get_base_url() also covers deployments that only set CSRF_TRUSTED_ORIGINS (the
# implicit-BASE_URL fallback used on 0.7.x->0.9.x upgrades), so HTTPS hardening
# isn't lost until BASE_URL is migrated. A plain-http base (e.g. local
# http://archivebox.localhost:8000) keeps the defaults below.
BASE_URL_IS_HTTPS = get_base_url(config=CONFIG).strip().lower().startswith("https://")
if BASE_URL_IS_HTTPS:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_COOKIE_SECURE = BASE_URL_IS_HTTPS
SESSION_COOKIE_SECURE = BASE_URL_IS_HTTPS
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_NAME = f"archivebox_sessionid_{CONSTANTS.COLLECTION_ID}"
CSRF_COOKIE_NAME = f"archivebox_csrftoken_{CONSTANTS.COLLECTION_ID}"
# Auth cookies are intentionally scoped to the exact host that set them so
# the admin session is NOT readable from public.* / web.* / api.* — that
# the admin session is NOT readable from web.* / api.* — that
# split is a security boundary, not a UX choice. Subdomains that need to
# render an "is the user logged in?" indicator must use the single-bit
# `archivebox_admin_logged_in` hint cookie set by core/middleware.py
+2 -1
View File
@@ -12,6 +12,7 @@ from django.http import HttpRequest
from django.urls import reverse
from archivebox.config.common import get_config
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
@@ -33,7 +34,7 @@ TAG_HAS_SNAPSHOTS_CHOICES = (
def normalize_tag_name(name: str) -> str:
return (name or "").strip()
return sanitize_html_text(name).strip()
def normalize_tag_sort(sort: str = "created_desc") -> str:
+199 -6
View File
@@ -4,6 +4,9 @@ from django import template
from django.contrib.admin.templatetags.base import InclusionAdminNode
from django.utils.safestring import mark_safe
from django.utils.html import escape
from django.templatetags.static import static
from django.utils import timezone
from django.utils.text import Truncator
from pathlib import Path
@@ -17,7 +20,6 @@ from archivebox.plugins.discovery import (
from archivebox.core.routes_util import (
canonical_base_host_for_request,
get_admin_base_url,
get_public_base_url,
get_web_base_url,
get_snapshot_base_url,
build_snapshot_url,
@@ -495,11 +497,6 @@ def web_base_url(context) -> str:
return get_web_base_url(request=context.get("request"), config=context.get("CONFIG"))
@register.simple_tag(takes_context=True)
def public_base_url(context) -> str:
return get_public_base_url(request=context.get("request"), config=context.get("CONFIG"))
@register.simple_tag(takes_context=True)
def snapshot_base_url(context, snapshot) -> str:
snapshot_id = _snapshot_id(snapshot)
@@ -512,6 +509,202 @@ def snapshot_url(context, snapshot, path: str = "") -> str:
return build_snapshot_url(str(snapshot_id), path, request=context.get("request"), config=context.get("CONFIG"))
@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", {})
cache_key = (plugin, filename)
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"]
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",
),
)
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] = build_snapshot_url(snapshot_id, output_path, request=context.get("request"), config=context.get("CONFIG"))
return url_cache[cache_key]
url_cache[cache_key] = ""
return ""
@register.simple_tag(takes_context=True)
def snapshot_index_row(context, link) -> str:
snapshot_id = str(_snapshot_id(link))
request = context.get("request")
config = context.get("CONFIG")
snapshot_base = get_snapshot_base_url(snapshot_id, request=request, config=config)
status = getattr(link, "status", None) or "unknown"
bookmarked_at = getattr(link, "bookmarked_at", None)
timestamp = getattr(link, "timestamp", "")
if bookmarked_at:
bookmarked_at = timezone.localtime(bookmarked_at)
date_text = bookmarked_at.strftime("%Y-%m-%d")
time_text = bookmarked_at.strftime("%H:%M")
sort_value = str(bookmarked_at.timestamp())
title_time = f"Bookmarked: {bookmarked_at} ({timestamp})"
else:
date_text = ""
time_text = ""
sort_value = ""
title_time = f"Bookmarked: ({timestamp})"
url = getattr(link, "url", "") or ""
title = getattr(link, "title", "") or ""
is_pending = status in {"queued", "started", "backoff"}
title_text = title or ("Loading..." if is_pending else 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)
if tag_html:
tag_cell = f'<span class="snapshot-tags">{tag_html}</span>'
else:
tag_cell = '<span class="empty-value">...</span>'
num_outputs = int(getattr(link, "num_outputs", 0) or 0)
icons = link.icons() if callable(getattr(link, "icons", None)) else getattr(link, "icons", "")
icons_cell = str(icons) if icons else '<span class="empty-value">...</span>'
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"
if is_pending:
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 = [build_snapshot_url(snapshot_id, path, request=request, config=config) 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>'
if "_public_favicon_paths" in link.__dict__:
favicon_paths = list(getattr(link, "_public_favicon_paths", []) or [])
if favicon_paths:
favicon_urls = [build_snapshot_url(snapshot_id, path, request=request, config=config) for path in favicon_paths]
favicon_html = (
f'<img src="{escape(favicon_urls[0])}" '
f'data-fallbacks="{escape(",".join(favicon_urls[1:]))}" '
'onerror="nextPublicSnapshotPreview(this)" '
'class="link-favicon" alt="" decoding="async" loading="lazy">'
)
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_html = (
(
f'<img src="{escape(favicon_url)}" '
'data-fallbacks="" '
'onerror="nextPublicSnapshotPreview(this)" '
'class="link-favicon" alt="" decoding="async" loading="lazy">'
)
if favicon_url
else '<span class="link-favicon link-favicon-empty" aria-hidden="true"></span>'
)
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">
<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">
{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>
</div>
<a href="{escape(url)}" class="snapshot-url" title="{escape(url)}" target="_blank" rel="noopener noreferrer">
{escape(url)}
</a>
</td>
<td class="snapshot-tags-cell">
{tag_cell}
</td>
<td class="snapshot-status-cell">
<span class="snapshot-status">{escape(status)}</span>
</td>
<td class="snapshot-files-cell">
<span data-number-for="{escape(url)}" title="{num_outputs} successful outputs">
{icons_cell}
</span>
</td>
<td class="snapshot-size-cell">
<a href="{escape(snapshot_base)}/?files=1" title="View archived files">
{size_cell}
</a>
<small>{num_outputs} output{output_plural}</small>
</td>
</tr>
"""
return mark_safe(html)
@register.simple_tag(takes_context=True)
def snapshot_preview_url(context, snapshot, path: str = "") -> str:
snapshot_id = _snapshot_id(snapshot)
+2
View File
@@ -27,6 +27,7 @@ from archivebox.core.views import (
HealthCheckView,
)
from archivebox.progressmonitor.views import live_progress_view
from archivebox.search.views import public_snapshot_search_stream_view
from abx_plugins.plugins.opencode.views import opencode_proxy_view
@@ -45,6 +46,7 @@ urlpatterns = [
path("docs/", RedirectView.as_view(url="https://github.com/ArchiveBox/ArchiveBox/wiki"), name="Docs"),
re_path(r"^admin/agent/?(?=$|opencode)", include("abx_plugins.plugins.opencode.urls")),
re_path(r"^(?P<path>assets/.*)$", opencode_proxy_view, name="opencode-assets"),
path("public/search-stream/", public_snapshot_search_stream_view, name="public-search-stream"),
path("public/", PublicIndexView.as_view(), name="public-index"),
path("public.html", RedirectView.as_view(url="/public/"), name="public-index-html"),
path("archive/", RedirectView.as_view(url="/")),
+337 -169
View File
@@ -11,12 +11,13 @@ from urllib.parse import quote, urlparse
from django.shortcuts import render, redirect
from django.http import HttpRequest, HttpResponse, Http404, HttpResponseForbidden, QueryDict
from django.utils.html import format_html
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
from django.views import View
from django.views.generic.list import ListView
from django.views.generic import FormView
from django.db.models import Q
from django.db.models import Case, IntegerField, Q, Value, When
from django.core.paginator import InvalidPage
from django.contrib import messages
from django.conf import settings
from django.contrib.auth import HASH_SESSION_KEY, SESSION_KEY, get_user_model
@@ -46,13 +47,15 @@ from archivebox.config.common import (
)
from archivebox.config.common import PLUGIN_CONFIG_SCHEMAS
from archivebox.config.configset import BaseConfigSet
from archivebox.misc.paginators import CountlessPaginator
from archivebox.misc.paginators import AcceleratedPaginator
from archivebox.misc.util import (
base_url,
filter_queryset_by_uuid_substring,
htmlencode,
htmldecode,
sanitize_html_text,
ts_to_date_str,
urldecode,
validate_url,
without_fragment,
)
from archivebox.misc.serve_static import serve_static_with_byterange_support
@@ -63,11 +66,13 @@ from archivebox.search.config import (
get_search_mode_base,
get_search_mode_options,
)
from archivebox.search.query import apply_snapshot_search
from archivebox.search.views import get_cached_public_search_state
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.core.models import ArchiveResult, Snapshot, SnapshotTag
from archivebox.core.permissions import (
PERMISSIONS_PRIVATE,
PERMISSIONS_PUBLIC,
PERMISSIONS_UNLISTED,
can_view_snapshot,
direct_snapshots_queryset,
filter_personas_by_permissions,
@@ -88,8 +93,6 @@ from archivebox.core.routes_util import (
from archivebox.core.forms import AddLinkForm
from archivebox.plugins.forms import get_plugin_config_binary_urls
from archivebox.crawls.models import Crawl
from archivebox.workers.models import RETRY_AT_MAX
from archivebox.plugins.discovery import discover_plugin_configs
from archivebox.plugins.views import get_config_definition_link
from archivebox.progressmonitor.views import live_progress_view, progress_endpoint
@@ -437,7 +440,7 @@ class SnapshotView(View):
"progress_endpoint": progress_endpoint("snapshot", snapshot.id),
"url": snapshot.url,
"archive_path": snapshot.archive_path_from_db,
"title": htmlencode(snapshot.resolved_title or (snapshot.base_url if is_archived else TITLE_LOADING_MSG)),
"title": htmldecode(snapshot.resolved_title or (snapshot.base_url if is_archived else TITLE_LOADING_MSG)),
"extension": snapshot.extension or "html",
"tags": snapshot.tags_str() or "untagged",
"size": printable_filesize(output_size) if output_size else "—",
@@ -567,37 +570,43 @@ class SnapshotView(View):
# Snapshot dir exists but file within does not e.g. 124235.324234/screenshot.png
return HttpResponse(
format_html(
(
"<html><head>"
"<title>Snapshot Not Found</title>"
#'<script>'
#'setTimeout(() => { window.location.reload(); }, 5000);'
#'</script>'
"</head><body>"
"<center><br/><br/><br/>"
f'Snapshot <a href="/{snapshot.archive_path}/index.html" target="_top"><b><code>[{snapshot.timestamp}]</code></b></a>: <a href="{snapshot.url}" target="_blank" rel="noreferrer">{snapshot.url}</a><br/>'
f"was queued on {str(snapshot.bookmarked_at).split('.')[0]}, "
f'but no files have been saved yet in:<br/><b><a href="/{snapshot.archive_path}/" target="_top"><code>{snapshot.timestamp}</code></a><code>/'
"{}"
f"</code></b><br/><br/>"
"It's possible {} "
f"during the last capture on {str(snapshot.bookmarked_at).split('.')[0]},<br/>or that the archiving process has not completed yet.<br/>"
f"<pre><code># run this cmd to finish/retry archiving this Snapshot</code><br/>"
f'<code style="user-select: all; color: #333">archivebox update -t timestamp {snapshot.timestamp}</code></pre><br/><br/>'
'<div class="text-align: left; width: 100%; max-width: 400px">'
"<i><b>Next steps:</i></b><br/>"
f'- list all the <a href="/{snapshot.archive_path}/" target="_top">Snapshot files <code>.*</code></a><br/>'
f'- view the <a href="/{snapshot.archive_path}/index.html" target="_top">Snapshot <code>./index.html</code></a><br/>'
f'- go to the <a href="/admin/core/snapshot/{snapshot.pk}/change/" target="_top">Snapshot admin</a> to edit<br/>'
f'- go to the <a href="/admin/core/snapshot/?id__exact={snapshot.id}" target="_top">Snapshot actions</a> to re-archive<br/>'
'- or return to <a href="/" target="_top">the main index...</a></div>'
"</center>"
"</body></html>"
),
"""
<html><head>
<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/>
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/>
- 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,
snapshot.timestamp,
snapshot.url,
snapshot.url,
str(snapshot.bookmarked_at).split(".")[0],
snapshot.archive_path,
snapshot.timestamp,
archivefile if str(archivefile) != "None" else "",
f"the {archivefile} resource could not be fetched"
if str(archivefile) != "None"
else "the original site was not available",
str(snapshot.bookmarked_at).split(".")[0],
snapshot.timestamp,
snapshot.archive_path,
snapshot.archive_path,
snapshot.pk,
snapshot.id,
),
content_type="text/html",
status=404,
@@ -810,6 +819,34 @@ def _safe_archive_relpath(path: str) -> str | None:
return cleaned
def _resolve_archiveresult_relpath(snapshot: Snapshot, rel_path: str) -> tuple[str, bool]:
"""Resolve plugin-relative output paths through ArchiveResult.output_files."""
parts = Path(rel_path).parts
if len(parts) < 2:
return rel_path, False
plugin = parts[0]
plugin_relpath = posixpath.join(*parts[1:])
result = (
ArchiveResult.objects.filter(snapshot=snapshot, plugin=plugin, status=ArchiveResult.StatusChoices.SUCCEEDED)
.only("output_files")
.first()
)
if not result or not result.output_files:
return rel_path, False
output_files = result.output_files or {}
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, True
return rel_path, True
return rel_path, True
def _coerce_sort_timestamp(value: str | float | None) -> float:
if value is None:
return 0.0
@@ -1003,6 +1040,8 @@ def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str =
if rel_path is None:
raise Http404
rel_path, _is_known_plugin_output = _resolve_archiveresult_relpath(snapshot, rel_path)
try:
return serve_static_with_byterange_support(
request,
@@ -1126,7 +1165,8 @@ class PublicIndexView(ListView):
template_name = "public_index.html"
model = Snapshot
ordering = ["-bookmarked_at", "-created_at"]
paginator_class = CountlessPaginator
paginator_class = AcceleratedPaginator
public_page_scan_chunk_size = 50
def get_paginate_by(self, queryset):
runtime_config = self.__dict__.get("runtime_config")
@@ -1134,12 +1174,66 @@ class PublicIndexView(ListView):
self.runtime_config = runtime_config = get_request_config(self.request, resolve_plugins=False)
return runtime_config.SNAPSHOTS_PER_PAGE
def _base_public_snapshot_fields(self) -> tuple[str, ...]:
return (
"id",
"created_at",
"modified_at",
"url",
"timestamp",
"bookmarked_at",
"title",
"downloaded_at",
"status",
"output_size",
"permissions",
)
def _ordered_public_page_from_order_index(self, *, page_number: int, page_size: int) -> list[Snapshot] | None:
target_count = page_number * page_size
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())
while len(public_snapshots) < target_count:
chunk = list(ordered_snapshots[scanned : scanned + chunk_size])
if not chunk:
break
scanned += len(chunk)
public_snapshots.extend(snapshot for snapshot in chunk if snapshot.permissions == PERMISSIONS_PUBLIC)
start = (page_number - 1) * page_size
return public_snapshots[start:target_count]
def paginate_queryset(self, queryset, page_size):
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:
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()
def get_context_data(self, **kwargs):
runtime_config = self.__dict__.get("runtime_config")
if runtime_config is None:
self.runtime_config = runtime_config = get_request_config(self.request, resolve_plugins=False)
search_mode = get_search_mode(self.request.GET.get("search_mode"), config=runtime_config)
search_mode_backend = get_search_mode_backend(search_mode, config=runtime_config)
query = self.request.GET.get("q", default="").strip()
public_search_state = self.__dict__.get("public_search_state")
public_search_pending = bool(query and (public_search_state is None or not public_search_state.get("done")))
context = {
**super().get_context_data(**kwargs),
"VERSION": VERSION,
@@ -1149,15 +1243,20 @@ class PublicIndexView(ListView):
"WEB_BASE_URL": build_web_url(request=self.request, config=runtime_config),
"search_mode": search_mode,
"search_mode_options": get_search_mode_options(config=runtime_config),
"public_search_stream_pending": public_search_pending,
}
context["show_search_index_hint"] = bool(
self.request.GET.get("q")
query
and not public_search_pending
and get_search_mode_base(search_mode, config=runtime_config) == "deep"
and search_mode_backend
and context["paginator"].count == 0,
)
snapshots = list(context.get("object_list") or ())
icons_by_snapshot: dict[str, set[str]] = {str(snapshot.id): set() 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}
progress_by_snapshot: dict[str, dict[str, int]] = {
str(snapshot.id): {
"total": 0,
@@ -1170,20 +1269,59 @@ class PublicIndexView(ListView):
for snapshot in snapshots
}
if icons_by_snapshot:
for snapshot_id, plugin, status in (
for snapshot_id, tag_name in (
SnapshotTag.objects.filter(snapshot_id__in=icons_by_snapshot.keys())
.order_by("tag__name")
.values_list("snapshot_id", "tag__name")
.iterator(chunk_size=1000)
):
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 result in (
ArchiveResult.objects.filter(
snapshot_id__in=icons_by_snapshot.keys(),
)
.exclude(plugin="")
.values_list("snapshot_id", "plugin", "status")
.only("snapshot_id", "plugin", "status", "output_files", "output_str")
.iterator(chunk_size=1000)
):
snapshot_id = result.snapshot_id
plugin = result.plugin
status = result.status
snapshot_key = str(snapshot_id)
progress = progress_by_snapshot[snapshot_key]
progress["total"] += 1
if status == ArchiveResult.StatusChoices.SUCCEEDED:
icons_by_snapshot[snapshot_key].add(plugin)
progress["succeeded"] += 1
if plugin in preview_candidates:
plugin_rank = preview_plugin_order[plugin]
for filename_rank, filename in enumerate(preview_candidates[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 plugin == "favicon":
output_path = result_output_path(result, "favicon.ico")
if output_path:
favicon_paths_by_snapshot[snapshot_key].append(output_path)
elif status == ArchiveResult.StatusChoices.FAILED:
progress["failed"] += 1
elif status == ArchiveResult.StatusChoices.STARTED:
@@ -1197,41 +1335,41 @@ class PublicIndexView(ListView):
snapshot._icons_compact = True
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._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))
.select_related("crawl__created_by")
.annotate(
num_outputs_cached=ArchiveResult.snapshot_count_expr(status=ArchiveResult.StatusChoices.SUCCEEDED),
)
.prefetch_related(
"tags",
)
)
qs = public_snapshots_queryset(super().get_queryset(**kwargs)).only(*self._base_public_snapshot_fields())
query = self.request.GET.get("q", default="").strip()
if not query:
return qs
runtime_config = self.__dict__.get("runtime_config")
search_mode = get_search_mode(self.request.GET.get("search_mode"), config=runtime_config)
try:
return apply_snapshot_search(
qs,
query,
search_mode=search_mode,
config=runtime_config,
ordering=self.ordering,
)
except Exception as err:
print(f"[!] Error while using search backend: {err.__class__.__name__} {err}")
if get_search_mode_backend(search_mode, config=runtime_config):
cached_state = get_cached_public_search_state(self.request)
self.public_search_state = cached_state
if cached_state is not None:
cached_ids = cached_state.get("ids") or []
if not cached_ids:
return qs.none()
return apply_snapshot_search(qs, query, search_mode="meta", config=runtime_config)
search_rank = Case(
*(When(pk=snapshot_id, then=Value(index)) for index, snapshot_id in enumerate(cached_ids)),
output_field=IntegerField(),
)
return qs.filter(pk__in=cached_ids).annotate(search_rank=search_rank).order_by("search_rank", *self.ordering)
return qs.none()
def get(self, *args, **kwargs):
if self.request.user.is_authenticated:
@@ -1266,7 +1404,8 @@ class AddView(UserPassesTestMixin, FormView):
return get_request_config(self.request).PUBLIC_ADD_VIEW or self.request.user.is_authenticated
def _can_override_crawl_config(self) -> bool:
return is_admin_user(self.request)
user = self.request.user
return bool(user.is_authenticated and user.is_active and user.is_superuser)
def _get_custom_config_overrides(self, form: AddLinkForm) -> dict:
custom_config = form.cleaned_data.get("config") or {}
@@ -1284,7 +1423,6 @@ class AddView(UserPassesTestMixin, FormView):
request_config = get_request_config(self.request, resolve_plugins=True)
required_search_plugin = f"search_backend_{request_config.SEARCH_BACKEND_ENGINE}".strip()
can_override_crawl_config = self._can_override_crawl_config()
plugin_configs = discover_plugin_configs() if can_override_crawl_config else {}
public_persona_config_keys = {
"CRAWL_MAX_CONCURRENT_SNAPSHOTS",
"DELETE_AFTER",
@@ -1313,17 +1451,6 @@ class AddView(UserPassesTestMixin, FormView):
"binary_urls": binary_urls,
}
recent_personas = list(persona_queryset.order_by("-created_at", "name")[:5])
plugin_dependency_map = {}
if can_override_crawl_config:
plugin_dependency_map = {
plugin_name: [
str(required_plugin).strip()
for required_plugin in (schema.get("required_plugins") or [])
if str(required_plugin).strip()
]
for plugin_name, schema in plugin_configs.items()
if isinstance(schema.get("required_plugins"), list) and schema.get("required_plugins")
}
return {
**context,
"title": "Create Crawl",
@@ -1333,7 +1460,6 @@ class AddView(UserPassesTestMixin, FormView):
"VERSION": VERSION,
"FOOTER_INFO": request_config.FOOTER_INFO,
"required_search_plugin": required_search_plugin,
"plugin_dependency_map_json": json.dumps(plugin_dependency_map, sort_keys=True),
"persona_config_map_json": json.dumps(persona_config_map, sort_keys=True, default=str),
"recent_personas": recent_personas,
"can_override_crawl_config": can_override_crawl_config,
@@ -1341,8 +1467,22 @@ class AddView(UserPassesTestMixin, FormView):
}
def _create_crawl_from_form(self, form, *, created_by_id=None) -> Crawl:
urls = form.cleaned_data["url"]
print(f"[+] Adding URL: {urls}")
from archivebox.cli.archivebox_add import add
urls_input = form.cleaned_data["url"]
urls = urls_input
submitted_lines = [line.strip() for line in urls_input.splitlines() if line.strip()]
if len(submitted_lines) == 1:
try:
# A lone URL pasted into /add/ is the same user-facing input as
# `archivebox add https://...`: queue that URL directly so a
# narrow plugin selection like `wget` can archive it without
# also needing parser plugins. Multi-line or formatted text
# remains verbatim import content for the internal parser root.
urls = [validate_url(submitted_lines[0])]
except ValueError:
pass
print(f"[+] Adding URL: {urls_input}")
# Extract all form fields
tag = form.cleaned_data["tag"]
@@ -1373,8 +1513,6 @@ class AddView(UserPassesTestMixin, FormView):
if persona:
persona.ensure_dirs()
from archivebox.config.permissions import HOSTNAME
if created_by_id is None:
if self.request.user.is_authenticated:
created_by_id = self.request.user.pk
@@ -1383,60 +1521,43 @@ class AddView(UserPassesTestMixin, FormView):
created_by_id = get_or_create_system_user_pk()
created_by_name = self.request.user.username if self.request.user.is_authenticated else "web"
# 1. save the provided urls to sources/2024-11-05__23-59-59__web_ui_add_by_user_<user_pk>.txt
sources_file = CONSTANTS.SOURCES_DIR / f"{timezone.now().strftime('%Y-%m-%d__%H-%M-%S')}__web_ui_add_by_user_{created_by_id}.txt"
sources_file.parent.mkdir(parents=True, exist_ok=True)
sources_file.write_text(urls if isinstance(urls, str) else "\n".join(urls))
# 2. create a new Crawl with the URLs from the file
timestamp = timezone.now().strftime("%Y-%m-%d__%H-%M-%S")
urls_content = sources_file.read_text()
# Store explicit crawl-scoped overrides; Crawl.save() freezes them
# over the resolved persona/user/machine defaults at creation time.
config = {}
if plugins:
config["PLUGINS"] = plugins
effective_config = get_config(persona=persona) if persona else get_config()
if crawl_max_concurrent_snapshots != int(effective_config.CRAWL_MAX_CONCURRENT_SNAPSHOTS):
config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] = crawl_max_concurrent_snapshots
if delete_after != str(effective_config.DELETE_AFTER):
config["DELETE_AFTER"] = delete_after
config["PERMISSIONS"] = permissions
if max_urls:
config["CRAWL_MAX_URLS"] = max_urls
if crawl_max_size:
config["CRAWL_MAX_SIZE"] = crawl_max_size
if crawl_timeout:
config["CRAWL_TIMEOUT"] = crawl_timeout
if timeout is not None and int(timeout) != int(effective_config.TIMEOUT):
config["TIMEOUT"] = int(timeout)
if snapshot_max_size:
config["SNAPSHOT_MAX_SIZE"] = snapshot_max_size
if permissions:
config["PERMISSIONS"] = permissions
# Merge custom config overrides
config.update(plugin_config)
config.update(custom_config)
if bool(url_filters.get("only_new")) != bool(effective_config.ONLY_NEW):
config["ONLY_NEW"] = bool(url_filters.get("only_new"))
if url_filters.get("allowlist"):
config["URL_ALLOWLIST"] = url_filters["allowlist"]
if url_filters.get("denylist"):
config["URL_DENYLIST"] = url_filters["denylist"]
crawl = Crawl.create_scheduler_row(
urls=urls_content,
max_depth=depth,
tags_str=tag,
notes=notes,
label=f"{created_by_name}@{HOSTNAME}{self.request.path} {timestamp}",
crawl, _snapshots = add(
urls=urls,
depth=depth,
max_urls=max_urls,
crawl_max_size=crawl_max_size,
crawl_timeout=crawl_timeout,
snapshot_max_size=snapshot_max_size,
crawl_max_concurrent_snapshots=crawl_max_concurrent_snapshots,
tag=tag,
url_allowlist=url_filters.get("allowlist") or "",
url_denylist=url_filters.get("denylist") or "",
plugins=plugins,
persona=persona.name if persona else "Default",
bg=True,
created_by_id=created_by_id,
config=config,
persona_id=persona.id if persona else None,
status=Crawl.StatusChoices.PAUSED if start_paused else Crawl.StatusChoices.QUEUED,
retry_at=RETRY_AT_MAX if start_paused else timezone.now(),
)
if notes:
crawl.safe_update({"notes": sanitize_html_text(notes)}, refresh=False)
if permissions and crawl.config.get("PERMISSIONS") != permissions:
next_config = {**crawl.config, "PERMISSIONS": permissions}
crawl.safe_update({"config": next_config}, refresh=True)
if start_paused:
crawl.pause()
# 3. create a CrawlSchedule if schedule is provided
if schedule:
@@ -1454,11 +1575,6 @@ class AddView(UserPassesTestMixin, FormView):
crawl.schedule = crawl_schedule
crawl.safe_update({"schedule": crawl_schedule}, refresh=False)
if not start_paused:
from archivebox.services.runner import ensure_background_runner
ensure_background_runner()
return crawl
def form_valid(self, form):
@@ -1468,15 +1584,17 @@ class AddView(UserPassesTestMixin, FormView):
schedule = form.cleaned_data.get("schedule", "").strip()
rough_url_count = len([url for url in urls.splitlines() if url.strip()])
# Build success message with schedule link if created
schedule_msg = ""
if schedule and crawl.schedule_id:
schedule_msg = f" and <a href='{crawl.schedule.admin_change_url}'>scheduled to repeat {schedule}</a>"
schedule_msg = format_html(" and <a href='{}'>scheduled to repeat {}</a>", crawl.schedule.admin_change_url, schedule)
messages.success(
self.request,
mark_safe(
f"Created crawl with {rough_url_count} starting URL(s){schedule_msg}. Snapshots will be created and archived in the background. <a href='{crawl.admin_change_url}'>View Crawl →</a>",
format_html(
"Created crawl with {} starting URL(s){}. Snapshots will be created and archived in the background. <a href='{}'>View Crawl →</a>",
rough_url_count,
schedule_msg,
crawl.admin_change_url,
),
)
@@ -1614,7 +1732,7 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
# Use merged config value (includes machine overrides)
actual_value = merged_config.get(key, dict(section)[key])
rows["Value"].append(mark_safe(f"<code>{actual_value}</code>"))
rows["Value"].append(format_html("<code>{}</code>", actual_value))
# Show where the value comes from
source = find_config_source(key, merged_config)
@@ -1622,8 +1740,10 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
rows["Source"].append(format_html('<code style="color: {}">{}</code>', source_colors.get(source, "gray"), source))
rows["Default"].append(
mark_safe(
f'<a href="https://github.com/search?q=repo%3AArchiveBox%2FArchiveBox+path%3Aconfig+{key}&type=code"><code style="text-decoration: underline">{find_config_default(key) or "See here..."}</code></a>',
format_html(
'<a href="https://github.com/search?q=repo%3AArchiveBox%2FArchiveBox+path%3Aconfig+{}&type=code"><code style="text-decoration: underline">{}</code></a>',
key,
find_config_default(key) or "See here...",
),
)
# rows['Documentation'].append(mark_safe(f'Wiki: <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#{key.lower()}">{key}</a>'))
@@ -1637,8 +1757,10 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
rows["Value"].append(format_html("<code>{}</code>", redact_sensitive_config(CONSTANTS_CONFIG).get(key)))
rows["Source"].append(mark_safe('<code style="color: gray">Constant</code>'))
rows["Default"].append(
mark_safe(
f'<a href="https://github.com/search?q=repo%3AArchiveBox%2FArchiveBox+path%3Aconfig+{key}&type=code"><code style="text-decoration: underline">{find_config_default(key) or "See here..."}</code></a>',
format_html(
'<a href="https://github.com/search?q=repo%3AArchiveBox%2FArchiveBox+path%3Aconfig+{}&type=code"><code style="text-decoration: underline">{}</code></a>',
key,
find_config_default(key) or "See here...",
),
)
# rows['Documentation'].append(mark_safe(f'Wiki: <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#{key.lower()}">{key}</a>'))
@@ -1692,25 +1814,56 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
is_redacted = final_value == SENSITIVE_CONFIG_VALUE_REDACTED
# Build sources display
sources_html = "<br/>".join([f'<b style="color: {color}">{source}:</b> <code>{value}</code>' for source, value, color in sources_info])
sources_html = format_html_join(
mark_safe("<br/>"),
'<b style="color: {}">{}:</b> <code>{}</code>',
((color, source, value) for source, value, color in sources_info),
)
# aliases = USER_CONFIG.get(key, {}).get("aliases", [])
aliases = []
if key in CONSTANTS_CONFIG:
section_header = mark_safe(
f'[CONSTANTS] &nbsp; <b><code style="color: lightgray">{key}</code></b> &nbsp; <small>(read-only, hardcoded by ArchiveBox)</small>',
section_header = format_html(
'[CONSTANTS] &nbsp; <b><code style="color: lightgray">{}</code></b> &nbsp; <small>(read-only, hardcoded by ArchiveBox)</small>',
key,
)
elif key in merged_config:
section_header = mark_safe(
f'data / ArchiveBox.conf &nbsp; [{find_config_section(key)}] &nbsp; <b><code style="color: lightgray">{key}</code></b>',
section_header = format_html(
'data / ArchiveBox.conf &nbsp; [{}] &nbsp; <b><code style="color: lightgray">{}</code></b>',
find_config_section(key),
key,
)
else:
section_header = mark_safe(
f'[DYNAMIC CONFIG] &nbsp; <b><code style="color: lightgray">{key}</code></b> &nbsp; <small>(read-only, calculated at runtime)</small>',
section_header = format_html(
'[DYNAMIC CONFIG] &nbsp; <b><code style="color: lightgray">{}</code></b> &nbsp; <small>(read-only, calculated at runtime)</small>',
key,
)
definition_url, definition_label = get_config_definition_link(key)
redacted_message = (
mark_safe(
'<b style="color: red">Value is redacted for your security. (Passwords, secrets, API tokens, etc. cannot be viewed in the Web UI)</b><br/><br/>',
)
if is_redacted
else ""
)
default_command_value = val.strip("'") if (val := find_config_default(key)) else str(final_value).strip("'")
machine_config_link = (
format_html('<br/><a href="{}">→ Edit <code>{}</code> in Machine.config for this server</a>', machine_admin_url, key)
if machine_admin_url
else ""
)
machine_config_tip = (
format_html(
'<br/><b>Tip:</b> To override <code>{}</code> on this machine, <a href="{}">edit the Machine.config field</a> and add:<br/><code>{}</code>',
key,
machine_admin_url,
f'{{"{key}": "your_value_here"}}',
)
if machine_admin_url and key not in CONSTANTS_CONFIG
else ""
)
section_data = cast(
SectionData,
@@ -1724,50 +1877,65 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
"Currently read from": config_source,
},
"help_texts": {
"Key": mark_safe(f"""
<a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#{key.lower()}">Documentation</a> &nbsp;
<span style="display: {"inline" if aliases else "none"}">
Aliases: {", ".join(aliases)}
"Key": format_html(
"""
<a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#{}">Documentation</a> &nbsp;
<span style="display: {}">
Aliases: {}
</span>
"""),
"Type": mark_safe(f'''
<a href="{definition_url}" target="_blank" rel="noopener noreferrer">
See full definition in <code>{definition_label}</code>...
""",
key.lower(),
"inline" if aliases else "none",
", ".join(aliases),
),
"Type": format_html(
"""
<a href="{}" target="_blank" rel="noopener noreferrer">
See full definition in <code>{}</code>...
</a>
'''),
"Value": mark_safe(f'''
{
'<b style="color: red">Value is redacted for your security. (Passwords, secrets, API tokens, etc. cannot be viewed in the Web UI)</b><br/><br/>'
if is_redacted
else ""
}
""",
definition_url,
definition_label,
),
"Value": format_html(
"""
{}
<br/><hr/><br/>
<b>Configuration Sources (highest priority first):</b><br/><br/>
{sources_html}
{}
<br/><br/>
<p style="display: {"block" if key in merged_config and key not in CONSTANTS_CONFIG else "none"}">
<p style="display: {}">
<i>To change this value, edit <code>data/ArchiveBox.conf</code> or run:</i>
<br/><br/>
<code>archivebox config --set {key}="{
val.strip("'") if (val := find_config_default(key)) else str(final_value).strip("'")
}"</code>
<code>archivebox config --set {}="{}"</code>
</p>
'''),
"Currently read from": mark_safe(f"""
The value shown in the "Value" field comes from the <b>{config_source}</b> source.
""",
redacted_message,
sources_html,
"block" if key in merged_config and key not in CONSTANTS_CONFIG else "none",
key,
default_command_value,
),
"Currently read from": format_html(
"""
The value shown in the "Value" field comes from the <b>{}</b> source.
<br/><br/>
Priority order (highest to lowest):
<ol>
<li><b style="color: purple">Machine</b> - Machine-specific overrides
{f'<br/><a href="{machine_admin_url}">→ Edit <code>{key}</code> in Machine.config for this server</a>' if machine_admin_url else ""}
{}
</li>
<li><b style="color: blue">Environment</b> - process defaults from environment variables</li>
<li><b style="color: green">File</b> - data/ArchiveBox.conf</li>
<li><b style="color: gray">Plugin Default</b> - Default value from plugin config.json</li>
<li><b style="color: gray">Default</b> - Default value from code</li>
</ol>
{f'<br/><b>Tip:</b> To override <code>{key}</code> on this machine, <a href="{machine_admin_url}">edit the Machine.config field</a> and add:<br/><code>{{"\\"{key}\\": "your_value_here"}}</code>' if machine_admin_url and key not in CONSTANTS_CONFIG else ""}
"""),
{}
""",
config_source,
machine_config_link,
machine_config_tip,
),
},
},
)
+13 -1
View File
@@ -40,6 +40,16 @@ class TagEditorWidget(forms.Widget):
normalized = f"t_{normalized}"
return normalized
def _json_for_inline_script(self, value):
"""Serialize JSON so it cannot close the surrounding inline <script> tag."""
return json.dumps(value).translate(
{
ord(">"): "\\u003E",
ord("<"): "\\u003C",
ord("&"): "\\u0026",
},
)
def _tag_style(self, value):
"""Compute a stable pastel color style for a tag value."""
tag = (value or "").strip().lower()
@@ -106,6 +116,8 @@ class TagEditorWidget(forms.Widget):
</span>
'''
tags_json = self._json_for_inline_script(tags)
# Build the widget HTML
html = f'''
<div id="{widget_id}_container" class="tag-editor-container" onclick="focusTagInput_{widget_id}(event)">
@@ -128,7 +140,7 @@ class TagEditorWidget(forms.Widget):
<script>
(function() {{
var currentTags_{widget_id} = {json.dumps(tags)};
var currentTags_{widget_id} = {tags_json};
var autocompleteTimeout_{widget_id} = null;
window.focusTagInput_{widget_id} = function(event) {{
+8 -12
View File
@@ -15,7 +15,7 @@ from django.utils.html import escape, format_html, format_html_join
from django.utils import timezone
from django.utils.safestring import mark_safe
from django.contrib import admin, messages
from django.db.models import Case, CharField, Count, Q, Value, When
from django.db.models import Count, F, Q
from django_object_actions import action
@@ -80,16 +80,7 @@ def render_snapshots_list(snapshots_qs, request=None, crawl=None, page_size=50,
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=Case(
When(permissions=PERMISSIONS_PUBLIC, then=Value(PERMISSIONS_PUBLIC)),
When(permissions=PERMISSIONS_UNLISTED, then=Value(PERMISSIONS_UNLISTED)),
When(permissions=PERMISSIONS_PRIVATE, then=Value(PERMISSIONS_PRIVATE)),
When(crawl__permissions=PERMISSIONS_PUBLIC, then=Value(PERMISSIONS_PUBLIC)),
When(crawl__permissions=PERMISSIONS_UNLISTED, then=Value(PERMISSIONS_UNLISTED)),
When(crawl__permissions=PERMISSIONS_PRIVATE, then=Value(PERMISSIONS_PRIVATE)),
default=Value(PERMISSIONS_PRIVATE),
output_field=CharField(),
),
snapshot_permissions=F("permissions"),
)
page_number = request.GET.get(page_param, 1) if request is not None else 1
@@ -928,11 +919,14 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
now = timezone.now()
updated = 0
batch = []
for crawl in queryset.only("id", "config").iterator(chunk_size=500):
crawls_to_update = []
for crawl in queryset.only("id", "config", "permissions").iterator(chunk_size=500):
old_permissions = crawl.permissions
config = dict(crawl.config or {})
config["PERMISSIONS"] = permissions
crawl.config = config
crawl.modified_at = now
crawls_to_update.append((crawl, old_permissions))
batch.append(crawl)
if len(batch) >= 500:
Crawl.objects.bulk_update(batch, ["config", "modified_at"], batch_size=500)
@@ -941,6 +935,8 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
if batch:
Crawl.objects.bulk_update(batch, ["config", "modified_at"], batch_size=500)
updated += len(batch)
for crawl, old_permissions in crawls_to_update:
crawl.update_child_snapshot_permissions(old_permissions, permissions)
return updated
@action(label="Recrawl", description="Create a new crawl with the same settings", methods=("POST",))
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
import fcntl
import os
import threading
from contextlib import contextmanager
from pathlib import Path
from typing import IO
from collections.abc import Iterator
from archivebox.config.constants import CONSTANTS
class _LifecycleLockState:
def __init__(self) -> None:
self.thread_lock = threading.RLock()
self.users = 0
self.depth = 0
self.file: IO[str] | None = None
_registry_lock = threading.Lock()
_registry_pid = os.getpid()
_registry: dict[str, _LifecycleLockState] = {}
def crawl_lifecycle_lock_path(crawl_id: str) -> Path:
return CONSTANTS.DEFAULT_TMP_DIR / "crawl-locks" / f"{crawl_id}.lock"
@contextmanager
def _lifecycle_lock(key: str, lock_path: Path) -> Iterator[None]:
global _registry_pid
with _registry_lock:
if _registry_pid != os.getpid():
for inherited_state in _registry.values():
if inherited_state.file is not None:
inherited_state.file.close()
_registry.clear()
_registry_pid = os.getpid()
state = _registry.setdefault(key, _LifecycleLockState())
state.users += 1
state.thread_lock.acquire()
try:
if state.depth == 0:
lock_path.parent.mkdir(parents=True, exist_ok=True)
state.file = lock_path.open("a+", encoding="utf-8")
try:
fcntl.flock(state.file.fileno(), fcntl.LOCK_EX)
except BaseException:
state.file.close()
state.file = None
raise
state.depth += 1
try:
yield
finally:
state.depth -= 1
if state.depth == 0 and state.file is not None:
fcntl.flock(state.file.fileno(), fcntl.LOCK_UN)
state.file.close()
state.file = None
finally:
state.thread_lock.release()
with _registry_lock:
state.users -= 1
if state.users == 0 and state.depth == 0 and _registry.get(key) is state:
_registry.pop(key, None)
@contextmanager
def crawl_lifecycle_lock(crawl_id: str) -> Iterator[None]:
key = str(crawl_id)
with _lifecycle_lock(f"crawl:{key}", crawl_lifecycle_lock_path(key)):
yield
@contextmanager
def binary_lifecycle_lock(binary_id: str) -> Iterator[None]:
key = str(binary_id)
lock_path = CONSTANTS.DEFAULT_TMP_DIR / "binary-locks" / f"{key}.lock"
with _lifecycle_lock(f"binary:{key}", lock_path):
yield
+187 -170
View File
@@ -2,7 +2,6 @@ __package__ = "archivebox.crawls"
from typing import TYPE_CHECKING, Any
from collections.abc import Iterable, Mapping
from io import StringIO
import uuid
import json
import re
@@ -35,7 +34,7 @@ from archivebox.base_models.models import (
)
from archivebox.workers.models import RETRY_AT_MAX, ModelWithStateMachine, BaseStateMachine
from archivebox.crawls.schedule_util import next_run_for_schedule, validate_schedule
from archivebox.misc.util import validate_url, validate_url_length
from archivebox.misc.util import parse_date, sanitize_html_text, validate_url, validate_url_length
if TYPE_CHECKING:
from archivebox.core.models import Snapshot
@@ -70,9 +69,14 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
return str(reverse_lazy("api-1:get_any", args=[self.id]))
def save(self, *args, **kwargs):
update_fields = kwargs.get("update_fields")
if update_fields is None or "label" in update_fields:
self.label = sanitize_html_text(self.label).strip()
if update_fields is None or "notes" in update_fields:
self.notes = sanitize_html_text(self.notes)
self.schedule = (self.schedule or "").strip()
validate_schedule(self.schedule)
self.label = self.label or (self.template.label if self.template else "")
self.label = self.label or (sanitize_html_text(self.template.label).strip() if self.template else "")
super().save(*args, **kwargs)
if self.template:
self.template.safe_update(
@@ -280,6 +284,12 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
def save(self, *args, **kwargs):
update_fields = kwargs.get("update_fields")
if update_fields is None or "label" in update_fields:
self.label = sanitize_html_text(self.label).strip()
if update_fields is None or "notes" in update_fields:
self.notes = sanitize_html_text(self.notes)
if update_fields is None or "tags_str" in update_fields:
self.tags_str = ",".join(self.parse_tag_names(self.tags_str or ""))
sync_tags = update_fields is None or "tags_str" in update_fields
old_crawl = type(self).objects.filter(pk=self.pk).first() if self.pk else None
previous_tag_names = set()
@@ -311,6 +321,9 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "config"]))
super().save(*args, **kwargs)
old_permissions = getattr(old_crawl, "permissions", None)
if old_crawl is not None and old_permissions != self.permissions:
transaction.on_commit(lambda: self.update_child_snapshot_permissions(old_permissions, self.permissions))
if sync_tags:
next_tag_names = set(self.parse_tag_names(self.tags_str or ""))
added_tag_names = next_tag_names - previous_tag_names
@@ -340,6 +353,29 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
# },
# )
def update_child_snapshot_permissions(self, old_permissions: str | None, new_permissions: str | None) -> int:
from archivebox.core.models import Snapshot
normalized_new_permissions = normalize_permissions(new_permissions)
now = timezone.now()
batch = []
updated = 0
queryset = self.snapshot_set.filter(Q(permissions=old_permissions) | Q(permissions__isnull=True)).only("id", "config")
for snapshot in queryset.iterator(chunk_size=500):
config = dict(snapshot.config or {})
config["PERMISSIONS"] = normalized_new_permissions
snapshot.config = config
snapshot.modified_at = now
batch.append(snapshot)
if len(batch) >= 500:
Snapshot.objects.bulk_update(batch, ["config", "modified_at"], batch_size=500)
updated += len(batch)
batch.clear()
if batch:
Snapshot.objects.bulk_update(batch, ["config", "modified_at"], batch_size=500)
updated += len(batch)
return updated
@property
def api_url(self) -> str:
return str(reverse_lazy("api-1:get_crawl", args=[self.id]))
@@ -350,7 +386,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
tag_names: list[str] = []
seen: set[str] = set()
for raw_tag in raw_tags:
tag_name = str(raw_tag or "").strip()
tag_name = sanitize_html_text(raw_tag).strip()
if not tag_name:
continue
lowered = tag_name.lower()
@@ -477,12 +513,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
from archivebox.core.models import Snapshot
date_str = self.created_at.strftime("%Y%m%d")
first_url = ""
for raw_line in StringIO(self.urls or ""):
candidate = raw_line.strip()
if candidate and not candidate.startswith("#"):
first_url = candidate
break
first_url = next((url for url in self.get_urls_list() if url), "")
domain = Snapshot.extract_domain_from_url(first_url) if first_url else "unknown"
output_dir = CONSTANTS.USERS_DIR / self.created_by.username / CONSTANTS.CRAWLS_DIR_NAME / date_str / domain / str(self.id)
@@ -493,7 +524,19 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
"""Get list of URLs from urls field, filtering out comments and empty lines."""
if not self.urls:
return []
return [url.strip() for url in self.urls.split("\n") if url.strip() and not url.strip().startswith("#")]
return [url for _raw_line, url in self._iter_url_lines() if url]
def has_internal_input_root(self) -> bool:
"""Return True when Crawl.urls is preserved source text, not the work queue.
The runner creates a synthetic root snapshot for raw import text so
parser hooks use the same Snapshot lifecycle as every other extractor.
In that mode the submitted text must remain in Crawl.urls forever;
parsed URLs live as child Snapshot rows and should not be appended back.
"""
from archivebox.core.models import Snapshot
return self.snapshot_set.filter(url=Snapshot.INTERNAL_INPUT_URL, depth=0).exists()
@staticmethod
def normalize_domain(value: str) -> str:
@@ -562,22 +605,29 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
except re.error:
return False
def get_current_config(self, *, refresh: bool = False) -> dict[str, Any]:
if refresh and self.pk:
config = type(self).objects.filter(pk=self.pk).values_list("config", flat=True).first()
if config is not None:
self.config = config
return dict(self.config or {})
def get_url_allowlist(self, *, use_effective_config: bool = False, snapshot=None) -> list[str]:
if use_effective_config:
from archivebox.config.common import get_config
config = get_config(crawl=self, snapshot=snapshot)
config = self.get_current_config(refresh=True)
else:
config = self.config or {}
config = self.get_current_config()
if snapshot is not None and snapshot.config:
config.update(snapshot.config)
return self.split_filter_patterns(config.get("URL_ALLOWLIST", ""))
def get_url_denylist(self, *, use_effective_config: bool = False, snapshot=None) -> list[str]:
if use_effective_config:
from archivebox.config.common import get_config
config = get_config(crawl=self, snapshot=snapshot)
config = self.get_current_config(refresh=True)
else:
config = self.config or {}
config = self.get_current_config()
if snapshot is not None and snapshot.config:
config.update(snapshot.config)
return self.split_filter_patterns(config.get("URL_DENYLIST", ""))
def url_passes_filters(self, url: str, *, snapshot=None, use_effective_config: bool = True) -> bool:
@@ -656,7 +706,14 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
continue
try:
entry = json.loads(stripped)
entries.append((raw_line.rstrip(), str(entry.get("url", "") or "").strip()))
# Crawl.urls accepts plain lines and JSONL URL records. Other
# valid JSON values, e.g. a quoted string from hostile input,
# are not records and must stay inert text instead of raising
# during later Snapshot.save() bookkeeping.
if isinstance(entry, dict):
entries.append((raw_line.rstrip(), str(entry.get("url", "") or "").strip()))
else:
entries.append((raw_line.rstrip(), stripped))
except json.JSONDecodeError:
entries.append((raw_line.rstrip(), stripped))
return entries
@@ -678,9 +735,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
return len(urls)
def remaining_url_capacity(self) -> int | None:
from archivebox.config.common import get_config
max_urls = int(get_config(crawl=self).CRAWL_MAX_URLS or 0)
max_urls = int(self._config_value(self.get_current_config(refresh=True), "CRAWL_MAX_URLS", 0) or 0)
if max_urls <= 0:
return None
return max(max_urls - self.count_urls_for_limit(), 0)
@@ -690,9 +745,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
return remaining is None or remaining > 0
def remaining_snapshot_capacity(self) -> int | None:
from archivebox.config.common import get_config
max_urls = int(get_config(crawl=self).CRAWL_MAX_URLS or 0)
max_urls = int(self._config_value(self.get_current_config(refresh=True), "CRAWL_MAX_URLS", 0) or 0)
if max_urls <= 0:
return None
return max(max_urls - self.snapshot_set.count(), 0)
@@ -922,21 +975,27 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
List of newly created Snapshot objects
"""
from archivebox.core.models import Snapshot, Tag
from archivebox.config.common import get_config
from archivebox.misc.util import fix_url_from_markdown, sanitize_extracted_url
if self.status == self.StatusChoices.SEALED:
return []
# Internal-input crawls preserve the submitted text verbatim in
# Crawl.urls. The root snapshot's parser hooks are the only supported
# path for turning that text into child snapshots, otherwise a later
# runner pass could reinterpret plain URL-looking lines as direct
# depth-0 work and bypass format-specific metadata parsing.
if self.has_internal_input_root():
return []
created_snapshots = []
crawl_tag_names = self.current_tag_names()
tags_by_name: dict[str, Tag] = {}
config = get_config(crawl=self)
only_new_urls = bool(config.ONLY_NEW)
for line in self.urls.splitlines():
if not line.strip():
continue
config = self.get_current_config(refresh=True)
only_new_urls = bool(self._config_value(config, "ONLY_NEW", True))
# Parse JSONL or plain URL
try:
@@ -962,10 +1021,10 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
except ValueError as err:
print(f"[yellow][!] Skipping invalid snapshot URL: {url[:120]}... ({err})[/yellow]")
continue
if Snapshot.is_archivebox_internal_url(url):
if Snapshot.is_archivebox_internal_url(url, config=config):
print(f"[yellow][!] Skipping internal ArchiveBox snapshot URL: {url}[/yellow]")
continue
if not self.url_passes_filters(url):
if not self.url_passes_filters(url, use_effective_config=False):
continue
if only_new_urls and Snapshot.objects.filter(url=url).exists():
continue
@@ -1035,7 +1094,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
# relation without clearing any non-crawl snapshot tags.
Tag.objects.bulk_create(missing_tags, ignore_conflicts=True)
tags_by_name.update({tag.name: tag for tag in Tag.objects.filter(name__in=missing_names)})
snapshot.tags.add(*[tag.pk for tag_name in tag_names if (tag := tags_by_name.get(tag_name))])
snapshot.add_tag_ids([tag.pk for tag_name in tag_names if (tag := tags_by_name.get(tag_name))])
# Symlink creation touches the filesystem and can be slow on remote disks.
# Defer it until after any active DB transaction commits so SQLite does
@@ -1073,7 +1132,6 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
) -> list["Snapshot"]:
"""Create child snapshots from discovered URL records after filtering and deduping once."""
from archivebox.core.models import Snapshot, SnapshotTag, Tag
from archivebox.config.common import get_config
from archivebox.misc.util import fix_url_from_markdown, sanitize_extracted_url
if self.status == self.StatusChoices.SEALED:
@@ -1083,30 +1141,71 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
return []
crawl_tag_names = self.current_tag_names()
config = get_config(crawl=self, snapshot=parent_snapshot)
config = self.get_current_config(refresh=True)
if parent_snapshot is not None and parent_snapshot.config:
config.update(parent_snapshot.config)
allowlist = self.split_filter_patterns(config.get("URL_ALLOWLIST", ""))
denylist = self.split_filter_patterns(config.get("URL_DENYLIST", ""))
def metadata_score(record: Mapping[str, Any]) -> int:
# Multiple parsers can discover the same URL from one import root.
# Keep the record with the richest user-facing metadata so generic
# text/HTML extraction does not erase RSS/Netscape/JSON fields.
return sum(bool(record.get(field)) for field in ("title", "bookmarked_at", "timestamp", "tags"))
deduped_records: dict[str, Mapping[str, Any]] = {}
for record in records:
url = sanitize_extracted_url(fix_url_from_markdown(str(record.get("url") or "").strip()))
if not url or url in deduped_records:
if not url:
continue
try:
validate_url(url)
except ValueError as err:
print(f"[yellow][!] Skipping invalid discovered snapshot URL: {url[:120]}... ({err})[/yellow]")
continue
if Snapshot.is_archivebox_internal_url(url):
if Snapshot.is_archivebox_internal_url(url, config=config):
print(f"[yellow][!] Skipping internal ArchiveBox discovered snapshot URL: {url}[/yellow]")
continue
if self.url_passes_compiled_filters(url, allowlist=allowlist, denylist=denylist):
deduped_records[url] = record
existing_record = deduped_records.get(url)
if existing_record is None or metadata_score(record) > metadata_score(existing_record):
deduped_records[url] = record
if not deduped_records:
return []
existing_scope = Snapshot.objects if bool(config.ONLY_NEW) else self.snapshot_set
existing_in_crawl = {
snapshot.url: snapshot for snapshot in self.snapshot_set.prefetch_related("tags").filter(url__in=deduped_records.keys())
}
for url, snapshot in existing_in_crawl.items():
record = deduped_records[url]
update_fields = []
title = Snapshot._normalize_title_candidate(str(record.get("title") or "").strip()[:512], snapshot_url=url)
if title and (not snapshot.title or len(title) > len(snapshot.title or "")):
snapshot.title = title
update_fields.append("title")
bookmarked_at = None
try:
bookmarked_at = parse_date(record.get("bookmarked_at") or record.get("timestamp"))
except (TypeError, ValueError, OSError):
pass
if bookmarked_at and snapshot.bookmarked_at != bookmarked_at:
snapshot.bookmarked_at = bookmarked_at
update_fields.append("bookmarked_at")
if update_fields:
snapshot.save(update_fields=[*update_fields, "modified_at"])
tag_names = {
*crawl_tag_names,
*self.parse_tag_names(
str(record.get("tags") or ""),
pattern=self._config_value(config, "TAG_SEPARATOR_PATTERN", r"[,]"),
),
}
if tag_names:
tag_ids = [Tag.objects.get_or_create(name=tag_name)[0].pk for tag_name in tag_names]
snapshot.add_tag_ids(tag_ids)
existing_scope = Snapshot.objects if bool(self._config_value(config, "ONLY_NEW", True)) else self.snapshot_set
existing_urls = set(existing_scope.filter(url__in=deduped_records.keys()).values_list("url", flat=True))
urls = [url for url in deduped_records.keys() if url not in existing_urls]
remaining = self.remaining_snapshot_capacity()
@@ -1116,27 +1215,34 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
return []
now = timezone.now()
snapshots = [
Snapshot(
url=url,
timestamp=str((now + timedelta(microseconds=index)).timestamp()),
title=Snapshot._normalize_title_candidate(
str(deduped_records[url].get("title") or "").strip()[:512],
snapshot_url=url,
)
or None,
crawl=self,
parent_snapshot=parent_snapshot,
depth=depth,
status=Snapshot.StatusChoices.QUEUED,
retry_at=now,
bookmarked_at=now,
created_at=now,
snapshots = []
for index, url in enumerate(urls):
record = deduped_records[url]
bookmarked_at = now
try:
bookmarked_at = parse_date(record.get("bookmarked_at") or record.get("timestamp")) or now
except (TypeError, ValueError, OSError):
pass
snapshots.append(
Snapshot(
url=url,
timestamp=str((now + timedelta(microseconds=index)).timestamp()),
title=Snapshot._normalize_title_candidate(
str(record.get("title") or "").strip()[:512],
snapshot_url=url,
)
or None,
crawl=self,
parent_snapshot=parent_snapshot,
depth=depth,
status=Snapshot.StatusChoices.QUEUED,
retry_at=now,
bookmarked_at=bookmarked_at,
created_at=now,
),
)
for index, url in enumerate(urls)
]
for snapshot in snapshots:
snapshot.set_delete_at_from_config(config.DELETE_AFTER)
snapshot.set_delete_at_from_config(self._config_value(config, "DELETE_AFTER", "0"))
created_snapshots = []
for snapshot in snapshots:
@@ -1155,7 +1261,11 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
crawl_urls = {url for _raw_line, url in self._iter_url_lines() if url}
new_url_lines = [snapshot.url for snapshot in created_snapshots if snapshot.url not in crawl_urls]
if new_url_lines:
# For internal-input crawls, Crawl.urls is the immutable source text.
# Child snapshots are the parsed/indexed representation, so appending
# discovered URLs here would both duplicate state and destroy the exact
# import artifact users submitted through CLI/API/UI.
if new_url_lines and not self.has_internal_input_root():
self.urls = (self.urls.rstrip() + "\n" + "\n".join(new_url_lines)).lstrip("\n")
self.save(update_fields=["urls", "modified_at"])
@@ -1163,7 +1273,10 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
for snapshot in created_snapshots:
tag_names = {
*crawl_tag_names,
*self.parse_tag_names(str(deduped_records[snapshot.url].get("tags") or ""), pattern=config.TAG_SEPARATOR_PATTERN),
*self.parse_tag_names(
str(deduped_records[snapshot.url].get("tags") or ""),
pattern=self._config_value(config, "TAG_SEPARATOR_PATTERN", r"[,]"),
),
}
if tag_names:
tag_names_by_url[snapshot.url] = tag_names
@@ -1192,71 +1305,23 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
return created_snapshots
def install_declared_binaries(self, binary_names: set[str], machine=None) -> None:
"""
Install crawl-declared Binary rows without violating the retry_at lock lifecycle.
Correct calling pattern:
1. Crawl hooks declare Binary records and queue them with retry_at <= now
2. Exactly one actor claims each Binary by moving retry_at into the future
3. Only that owner executes `.sm.tick()` and performs install side effects
4. Everyone else waits for the claimed owner to finish instead of launching
a second install against shared state such as the pip or npm trees
This helper follows that contract by claiming each Binary before ticking
it, and by waiting when another worker already owns the row. That keeps
synchronous crawl execution compatible with the shared background runner and
avoids duplicate installs of the same dependency.
"""
import time
"""Install crawl-declared binaries through their unified state machine."""
from archivebox.crawls.locks import binary_lifecycle_lock
from archivebox.machine.models import Binary, Machine
if not binary_names:
return
machine = machine or Machine.current()
lock_seconds = 600
deadline = time.monotonic() + max(lock_seconds, len(binary_names) * lock_seconds)
while time.monotonic() < deadline:
unresolved_binaries = list(
Binary.objects.filter(
machine=machine,
name__in=binary_names,
)
.exclude(
status=Binary.StatusChoices.INSTALLED,
)
.order_by("name"),
)
if not unresolved_binaries:
return
claimed_any = False
waiting_on_existing_owner = False
now = timezone.now()
for binary in unresolved_binaries:
try:
if binary.tick_claimed(lock_seconds=lock_seconds):
claimed_any = True
continue
except Exception:
claimed_any = True
continue
binaries = Binary.objects.filter(machine=machine, name__in=binary_names).order_by("name")
for binary in binaries:
with binary_lifecycle_lock(str(binary.id)):
binary.refresh_from_db()
if binary.status == Binary.StatusChoices.INSTALLED:
claimed_any = True
continue
if binary.retry_at and binary.retry_at > now:
waiting_on_existing_owner = True
if claimed_any:
continue
if waiting_on_existing_owner:
time.sleep(0.5)
continue
break
binary.update_and_requeue(retry_at=timezone.now())
binary.refresh_from_db()
binary.tick_claimed(lock_seconds=600)
unresolved_binaries = list(
Binary.objects.filter(
@@ -1286,9 +1351,9 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
The root Snapshot for this crawl, or None for system crawls that don't create snapshots
"""
import time
from archivebox.plugins.hooks import run_hook, discover_hooks, process_hook_records, is_finite_background_hook
from archivebox.plugins.hooks import run_hook, discover_hooks, process_hook_records
from archivebox.config.common import get_config
from archivebox.machine.models import Binary, Machine
from archivebox.machine.models import Machine
def get_runtime_config():
return get_config(crawl=self).for_crawl_runtime(
@@ -1316,10 +1381,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
chrome_binary=chrome_binary,
)
executed_crawl_hooks: set[str] = set()
def run_crawl_hook(hook: Path) -> set[str]:
executed_crawl_hooks.add(str(hook))
primary_url = next(
(line.strip() for line in self.urls.splitlines() if line.strip()),
self.urls.strip(),
@@ -1344,19 +1406,15 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
print(f"[yellow]⏱️ Hook {hook.name} took {hook_elapsed:.2f}s[/yellow]")
if process.status == process.StatusChoices.RUNNING:
if not is_finite_background_hook(hook.name):
return set()
try:
process.wait(timeout=process.timeout)
except Exception:
if process.poll() is None:
return set()
from archivebox.plugins.hooks import extract_records_from_process
records = []
# Finite background hooks can exit before their completed Process
# metadata is visible. Give successful hooks a brief chance to
# flush JSONL stdout into the Process row before downstream hooks.
# A hook can exit before its completed Process metadata is visible.
# Give successful hooks a brief chance to flush JSONL stdout into
# the Process row before downstream hooks.
for delay in (0.0, 0.05, 0.1, 0.25, 0.5):
if delay:
time.sleep(delay)
@@ -1384,53 +1442,12 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
declared_binary_names.update(hook_binary_names)
return hook_binary_names
def resolve_provider_binaries(binary_names: set[str]) -> set[str]:
if not binary_names:
return set()
resolved_binary_names = set(binary_names)
while True:
unresolved_binaries = list(
Binary.objects.filter(
machine=machine,
name__in=resolved_binary_names,
)
.exclude(
status=Binary.StatusChoices.INSTALLED,
)
.order_by("name"),
)
if not unresolved_binaries:
return resolved_binary_names
needed_provider_names: set[str] = set()
for binary in unresolved_binaries:
allowed_binproviders = binary._allowed_binproviders()
if allowed_binproviders is None:
continue
needed_provider_names.update(allowed_binproviders)
if not needed_provider_names:
return resolved_binary_names
provider_hooks = [
hook
for hook in discover_hooks("Crawl", filter_disabled=False, config=get_runtime_config())
if hook.parent.name in needed_provider_names and str(hook) not in executed_crawl_hooks
]
if not provider_hooks:
return resolved_binary_names
for hook in provider_hooks:
resolved_binary_names.update(run_crawl_hook(hook))
hooks = discover_hooks("Crawl", config=get_runtime_config())
for hook in hooks:
hook_binary_names = run_crawl_hook(hook)
if hook_binary_names:
self.install_declared_binaries(resolve_provider_binaries(hook_binary_names), machine=machine)
self.install_declared_binaries(hook_binary_names, machine=machine)
# Safety check: don't create snapshots if any crawl-declared dependency
# is still unresolved after all crawl hooks have run.
+9 -4
View File
@@ -145,6 +145,11 @@ ip_addrs = lambda addrs: (a for a in addrs if a.family == socket.AF_INET)
mac_addrs = lambda addrs: (a for a in addrs if a.family == psutil.AF_LINK)
def unknown_if_blank(value):
normalized = str(value or "").strip()
return normalized or "Unknown"
def get_isp_info(ip=None):
# Get public IP
try:
@@ -161,10 +166,10 @@ def get_isp_info(ip=None):
except Exception:
pass
isp = data.get("org", "Unknown")
city = data.get("city", "Unknown")
region = data.get("region", "Unknown")
country = data.get("country_name", "Unknown")
isp = unknown_if_blank(data.get("org"))
city = unknown_if_blank(data.get("city"))
region = unknown_if_blank(data.get("region"))
country = unknown_if_blank(data.get("country_name"))
# Get system DNS resolver servers
dns_server = None
@@ -0,0 +1,42 @@
# Generated by Django 6.0.6 on 2026-06-21 09:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("machine", "0020_repair_process_binary_iface_links"),
]
operations = [
migrations.AlterField(
model_name="networkinterface",
name="city",
field=models.CharField(default="", max_length=63),
),
migrations.AlterField(
model_name="networkinterface",
name="country",
field=models.CharField(default="", max_length=63),
),
migrations.AlterField(
model_name="networkinterface",
name="hostname",
field=models.CharField(default="", max_length=63),
),
migrations.AlterField(
model_name="networkinterface",
name="iface",
field=models.CharField(default="", max_length=15),
),
migrations.AlterField(
model_name="networkinterface",
name="isp",
field=models.CharField(default="", max_length=63),
),
migrations.AlterField(
model_name="networkinterface",
name="region",
field=models.CharField(default="", max_length=63),
),
]
@@ -0,0 +1,60 @@
# Generated by Django 6.0.6 on 2026-06-21 09:23
from django.db import migrations, models
from django.db.models import Count
def dedupe_network_interfaces_without_mac(apps, schema_editor):
NetworkInterface = apps.get_model("machine", "NetworkInterface")
Process = apps.get_model("machine", "Process")
duplicate_groups = (
NetworkInterface.objects.values(
"machine_id",
"ip_public",
"ip_local",
"dns_server",
)
.annotate(count=Count("id"))
.filter(count__gt=1)
)
for group in duplicate_groups.iterator(chunk_size=100):
lookup = {
"machine_id": group["machine_id"],
"ip_public": group["ip_public"],
"ip_local": group["ip_local"],
"dns_server": group["dns_server"],
}
keep = NetworkInterface.objects.filter(**lookup).order_by("-modified_at", "-created_at").first()
if keep is None:
continue
duplicates = NetworkInterface.objects.filter(**lookup).exclude(id=keep.id)
duplicate_ids = list(duplicates.values_list("id", flat=True))
if not duplicate_ids:
continue
Process.objects.filter(iface_id__in=duplicate_ids).update(iface_id=keep.id)
duplicates.delete()
class Migration(migrations.Migration):
dependencies = [
("machine", "0021_networkinterface_blank_defaults"),
]
operations = [
migrations.RemoveConstraint(
model_name="networkinterface",
name="unique_network_interface_identity",
),
migrations.RunPython(dedupe_network_interfaces_without_mac, migrations.RunPython.noop),
migrations.AlterUniqueTogether(
name="networkinterface",
unique_together={("machine", "ip_public", "ip_local", "dns_server")},
),
migrations.AddConstraint(
model_name="networkinterface",
constraint=models.UniqueConstraint(
fields=("machine", "ip_public", "ip_local", "dns_server"),
name="unique_network_interface_identity",
),
),
]
+24 -23
View File
@@ -140,7 +140,7 @@ def _sanitize_machine_config(config: dict[str, Any] | None, *, lib_dir: str | Pa
"""Validate ``Machine.config`` in place.
Drops stale ``*_BINARY`` overrides whose path no longer exists or whose
path falls outside of ``LIB_DIR`` (so a binary uninstall or a lib_dir
path falls outside of ``ABXPKG_LIB_DIR`` (so a binary uninstall or a lib_dir
move clears the override automatically). Non-``_BINARY`` keys
(``BASE_URL``, ``SERVER_SECURITY_MODE``, plugin tunables, etc.) are
pass-through — they're arbitrary config overrides and not ours to filter.
@@ -287,7 +287,7 @@ class Machine(ModelWithHealthStats):
def _sanitize_config(cls, machine: Machine) -> Machine:
from archivebox.config.common import get_config
sanitized = _sanitize_machine_config(machine.config, lib_dir=get_config(include_machine=False).LIB_DIR)
sanitized = _sanitize_machine_config(machine.config, lib_dir=get_config(include_machine=False).ABXPKG_LIB_DIR)
current = machine.config or {}
if sanitized != current:
machine.config = sanitized
@@ -394,12 +394,12 @@ class NetworkInterface(ModelWithHealthStats):
ip_public = models.GenericIPAddressField(default=None, null=False, editable=False)
ip_local = models.GenericIPAddressField(default=None, null=False, editable=False)
dns_server = models.GenericIPAddressField(default=None, null=False, editable=False)
hostname = models.CharField(max_length=63, default=None, null=False)
iface = models.CharField(max_length=15, default=None, null=False)
isp = models.CharField(max_length=63, default=None, null=False)
city = models.CharField(max_length=63, default=None, null=False)
region = models.CharField(max_length=63, default=None, null=False)
country = models.CharField(max_length=63, default=None, null=False)
hostname = models.CharField(max_length=63, default="", null=False)
iface = models.CharField(max_length=15, default="", null=False)
isp = models.CharField(max_length=63, default="", null=False)
city = models.CharField(max_length=63, default="", null=False)
region = models.CharField(max_length=63, default="", null=False)
country = models.CharField(max_length=63, default="", null=False)
# num_uses_failed = models.PositiveIntegerField(default=0) # from ModelWithHealthStats
# num_uses_succeeded = models.PositiveIntegerField(default=0) # from ModelWithHealthStats
@@ -408,10 +408,10 @@ class NetworkInterface(ModelWithHealthStats):
class Meta(ModelWithHealthStats.Meta):
app_label = "machine"
unique_together = (("machine", "ip_public", "ip_local", "mac_address", "dns_server"),)
unique_together = (("machine", "ip_public", "ip_local", "dns_server"),)
constraints = [
models.UniqueConstraint(
fields=["machine", "ip_public", "ip_local", "mac_address", "dns_server"],
fields=["machine", "ip_public", "ip_local", "dns_server"],
name="unique_network_interface_identity",
),
]
@@ -444,7 +444,6 @@ class NetworkInterface(ModelWithHealthStats):
machine=machine,
ip_public=net_info.pop("ip_public"),
ip_local=net_info.pop("ip_local"),
mac_address=net_info.pop("mac_address"),
dns_server=net_info.pop("dns_server"),
)
_CURRENT_INTERFACE = cls.objects.filter(**lookup).order_by("-modified_at", "-created_at").first()
@@ -681,7 +680,7 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
)
from archivebox.config.common import get_config
binary.symlink_to_lib_bin_after_commit(get_config().LIB_BIN_DIR)
binary.symlink_to_lib_bin_after_commit(get_config().ABXPKG_LIB_DIR / "bin")
return binary
# Case 2: From binaries.json - create queued binary (needs installation)
@@ -714,7 +713,7 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
)
from archivebox.config.common import get_config
binary.symlink_to_lib_bin_after_commit(get_config().LIB_BIN_DIR)
binary.symlink_to_lib_bin_after_commit(get_config().ABXPKG_LIB_DIR / "bin")
return binary
return None
@@ -750,15 +749,15 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
def symlink_to_lib_bin(self, lib_bin_dir: str | Path) -> Path | None:
"""
Symlink this binary into LIB_BIN_DIR for human-facing convenience.
Symlink this binary into a derived lib/bin directory for human-facing convenience.
After a binary is installed by any binprovider (pip, npm, brew, apt, etc),
we can optionally expose a flat convenience directory for shell users.
ArchiveBox/abx-dl runtime lookup must use the provider-specific LIB_DIR
ArchiveBox/abx-dl runtime lookup must use the provider-specific ABXPKG_LIB_DIR
paths, not this indirection.
Args:
lib_bin_dir: Path to LIB_BIN_DIR (e.g., /data/lib/arm64-darwin/bin)
lib_bin_dir: Path to the derived convenience bin dir (e.g., /data/lib/bin)
Returns:
Path to the created symlink, or None if symlinking failed
@@ -782,14 +781,14 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
except StopIteration:
app_index = -1
# Create LIB_BIN_DIR if it doesn't exist
# Create the derived convenience bin dir if it doesn't exist.
try:
lib_bin_dir.mkdir(parents=True, exist_ok=True)
except (OSError, PermissionError) as e:
print(f"Failed to create LIB_BIN_DIR {lib_bin_dir}: {e}", file=sys.stderr)
print(f"Failed to create lib/bin convenience dir {lib_bin_dir}: {e}", file=sys.stderr)
return None
# Expose the canonical Binary.name in LIB_BIN_DIR. Some providers point
# Expose the canonical Binary.name in the convenience bin dir. Some providers point
# abspath at implementation files like cli.js or manifest.json; those
# are valid targets, but they are not user-facing binary names.
binary_name = _canonical_binary_name(self.name) or binary_abspath.name
@@ -1544,9 +1543,11 @@ class Process(ModelWithDeleteAfter, models.Model):
avoids any race window where the row briefly exists as ADD before a
higher-level demotion.
"""
argv_str = " ".join(sys.argv).lower()
argv = [str(arg) for arg in sys.argv]
argv_str = " ".join(argv).lower()
executable = Path(argv[0]).name.lower() if argv else ""
if "supervisord" in argv_str:
if executable == "supervisord" or any(arg.startswith("--configuration=") for arg in argv[1:]):
return cls.TypeChoices.SUPERVISORD
elif "runner_watch" in argv_str:
return cls.TypeChoices.WORKER
@@ -1574,7 +1575,7 @@ class Process(ModelWithDeleteAfter, models.Model):
Processes are stale if:
- Status is RUNNING but OS process no longer exists
- Status is RUNNING but exceeded its timeout plus a small grace margin
- A bounded HOOK or BINARY process exceeded its timeout plus a small grace margin
- Status is RUNNING but started_at is older than PID_REUSE_WINDOW
Returns count of processes cleaned up.
@@ -1594,7 +1595,7 @@ class Process(ModelWithDeleteAfter, models.Model):
is_stale = False
if proc.started_at:
if proc.started_at and proc.process_type in (cls.TypeChoices.HOOK, cls.TypeChoices.BINARY):
timeout_seconds = max(int(proc.timeout or 0), 0)
timeout_deadline = proc.started_at + timedelta(seconds=timeout_seconds) + PROCESS_TIMEOUT_GRACE
if timeout_seconds > 0 and timezone.now() >= timeout_deadline:
+33 -22
View File
@@ -220,14 +220,14 @@ def check_not_inside_source_dir():
def check_data_dir_permissions(config=None, **config_kwargs):
from archivebox import DATA_DIR
from archivebox.misc.logging import STDERR
from archivebox.config.permissions import ARCHIVEBOX_USER, ARCHIVEBOX_GROUP, DEFAULT_PUID, DEFAULT_PGID, IS_ROOT, USER
from archivebox.config.permissions import ARCHIVEBOX_USER, ARCHIVEBOX_GROUP, DEFAULT_UID, DEFAULT_GID, IS_ROOT, USER
from archivebox.config.paths import get_or_create_working_tmp_dir, get_or_create_working_lib_dir
data_dir_stat = Path(DATA_DIR).stat()
data_dir_uid, data_dir_gid = data_dir_stat.st_uid, data_dir_stat.st_gid
data_owned_by_root = data_dir_uid == 0
# data_owned_by_default_user = data_dir_uid == DEFAULT_PUID or data_dir_gid == DEFAULT_PGID
# data_owned_by_default_user = data_dir_uid == DEFAULT_UID or data_dir_gid == DEFAULT_GID
data_owner_doesnt_match = (data_dir_uid != ARCHIVEBOX_USER and data_dir_gid != ARCHIVEBOX_GROUP) if not IS_ROOT else False
data_not_writable = not (os.path.isdir(DATA_DIR) and os.access(DATA_DIR, os.W_OK))
if data_not_writable:
@@ -245,9 +245,9 @@ def check_data_dir_permissions(config=None, **config_kwargs):
if data_not_writable:
STDERR.print(
f"[violet]Hint:[/violet] Change the current ownership [red]{data_dir_uid}[/red]:{data_dir_gid} (PUID:PGID) to the user & group that will run ArchiveBox, e.g.:",
f"[violet]Hint:[/violet] Change the current ownership [red]{data_dir_uid}[/red]:{data_dir_gid} to the user & group that will run ArchiveBox, e.g.:",
)
STDERR.print(f" [grey53]sudo[/grey53] chown -R [blue]{DEFAULT_PUID}:{DEFAULT_PGID}[/blue] {DATA_DIR.resolve()}")
STDERR.print(f" [grey53]sudo[/grey53] chown -R [blue]{DEFAULT_UID}:{DEFAULT_GID}[/blue] {DATA_DIR.resolve()}")
STDERR.print(" Avoid recursive chown on very large archives unless you know the full tree needs repair.")
STDERR.print()
STDERR.print("[blue]More info:[/blue]")
@@ -273,9 +273,9 @@ def check_data_dir_permissions(config=None, **config_kwargs):
tmp_dir = config.TMP_DIR
try:
lib_dir = get_or_create_working_lib_dir(autofix=True, quiet=True, config=config) or config.LIB_DIR
lib_dir = get_or_create_working_lib_dir(autofix=True, quiet=True, config=config) or config.ABXPKG_LIB_DIR
except Exception:
lib_dir = config.LIB_DIR
lib_dir = config.ABXPKG_LIB_DIR
# Check /tmp dir permissions
check_tmp_dir(tmp_dir, throw=False, must_exist=True, config=config)
@@ -289,7 +289,14 @@ def check_data_dir_permissions(config=None, **config_kwargs):
def check_tmp_dir(tmp_dir=None, throw=False, quiet=False, must_exist=True, config=None, **config_kwargs):
from archivebox.config.paths import assert_dir_can_contain_unix_sockets, dir_is_writable, get_or_create_working_tmp_dir
from archivebox.config.paths import (
MAX_TMP_SOCKET_URL_LENGTH,
SUPERVISORD_SOCKET_FILENAME,
assert_dir_can_contain_unix_sockets,
dir_is_writable,
get_or_create_working_tmp_dir,
tmp_dir_socket_path_is_short_enough,
)
from archivebox.misc.logging import STDERR
from archivebox.misc.logging_util import pretty_path
from archivebox.config.permissions import ARCHIVEBOX_USER, ARCHIVEBOX_GROUP
@@ -297,20 +304,22 @@ def check_tmp_dir(tmp_dir=None, throw=False, quiet=False, must_exist=True, confi
config = config or get_config(**config_kwargs)
tmp_dir = tmp_dir or config.TMP_DIR
socket_file = tmp_dir.absolute().resolve() / "supervisord.sock"
socket_file = tmp_dir.absolute().resolve() / SUPERVISORD_SOCKET_FILENAME
if not must_exist and not os.path.isdir(tmp_dir):
# just check that its viable based on its length (because dir may not exist yet, we cant check if its writable)
return len(f"file://{socket_file}") <= 96
return tmp_dir_socket_path_is_short_enough(tmp_dir)
tmp_is_valid = False
try:
tmp_is_valid = dir_is_writable(tmp_dir)
if not config.ALLOW_NO_UNIX_SOCKETS:
tmp_is_valid = tmp_is_valid and assert_dir_can_contain_unix_sockets(tmp_dir)
assert tmp_is_valid, f"ArchiveBox user PUID={ARCHIVEBOX_USER} PGID={ARCHIVEBOX_GROUP} is unable to write to TMP_DIR={tmp_dir}"
assert len(f"file://{socket_file}") <= 96, (
f"ArchiveBox TMP_DIR={tmp_dir} is too long, dir containing unix socket files must be <90 chars."
assert tmp_is_valid, f"ArchiveBox user {ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP} is unable to write to TMP_DIR={tmp_dir}"
socket_url_len = len(f"file://{socket_file}")
assert tmp_dir_socket_path_is_short_enough(tmp_dir), (
f"ArchiveBox TMP_DIR={tmp_dir} is too long, file://{socket_file} is {socket_url_len} chars "
f"and must be <{MAX_TMP_SOCKET_URL_LENGTH} chars."
)
return True
except Exception as e:
@@ -324,7 +333,7 @@ def check_tmp_dir(tmp_dir=None, throw=False, quiet=False, must_exist=True, confi
"",
"[blue]Info:[/blue] [grey53]The TMP_DIR is used for the supervisord unix socket file and other temporary files.",
" - It [red]must[/red] be on a local drive (not inside a docker volume, remote network drive, or FUSE mount).",
f" - It [red]must[/red] be readable and writable by the ArchiveBox user (PUID={ARCHIVEBOX_USER}, PGID={ARCHIVEBOX_GROUP}).",
f" - It [red]must[/red] be readable and writable by the ArchiveBox user ({ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP}).",
" - It [red]must[/red] be a *short* path (less than 90 characters) due to UNIX path length restrictions for sockets.",
" - It [yellow]should[/yellow] be able to hold at least 200MB of data (in-progress downloads can be large).[/grey53]",
"",
@@ -356,7 +365,7 @@ def check_lib_dir(lib_dir: Path | None = None, throw=False, quiet=False, must_ex
from archivebox.config.common import get_config
config = config or get_config(**config_kwargs)
lib_dir = lib_dir or config.LIB_DIR
lib_dir = lib_dir or config.ABXPKG_LIB_DIR
if not must_exist and not os.path.isdir(lib_dir):
return True
@@ -364,7 +373,7 @@ def check_lib_dir(lib_dir: Path | None = None, throw=False, quiet=False, must_ex
lib_is_valid = False
try:
lib_is_valid = dir_is_writable(lib_dir)
assert lib_is_valid, f"ArchiveBox user PUID={ARCHIVEBOX_USER} PGID={ARCHIVEBOX_GROUP} is unable to write to LIB_DIR={lib_dir}"
assert lib_is_valid, f"ArchiveBox user {ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP} is unable to write to ABXPKG_LIB_DIR={lib_dir}"
return True
except Exception as e:
if not quiet:
@@ -372,16 +381,16 @@ def check_lib_dir(lib_dir: Path | None = None, throw=False, quiet=False, must_ex
ERROR_TEXT = "\n".join(
(
"",
f"[red]:cross_mark: ArchiveBox is unable to use LIB_DIR={pretty_path(lib_dir)}[/red]",
f"[red]:cross_mark: ArchiveBox is unable to use ABXPKG_LIB_DIR={pretty_path(lib_dir)}[/red]",
f" [yellow]{e}[/yellow]",
"",
"[blue]Info:[/blue] [grey53]The LIB_DIR is used to store ArchiveBox auto-installed plugin library and binary dependencies.",
f" - It [red]must[/red] be readable and writable by the ArchiveBox user (PUID={ARCHIVEBOX_USER}, PGID={ARCHIVEBOX_GROUP}).",
"[blue]Info:[/blue] [grey53]The ABXPKG_LIB_DIR is used to store ArchiveBox auto-installed plugin library and binary dependencies.",
f" - It [red]must[/red] be readable and writable by the ArchiveBox user ({ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP}).",
" - It [yellow]should[/yellow] be on a local (ideally fast) drive like an SSD or HDD (not on a network drive or external HDD).",
" - It [yellow]should[/yellow] be able to hold at least 1GB of data (some dependencies like Chrome can be large).[/grey53]",
"",
"[violet]Hint:[/violet] Fix it by setting LIB_DIR to a path that meets these requirements, e.g.:",
f" [green]archivebox config --set LIB_DIR={get_or_create_working_lib_dir(autofix=False, quiet=True) or config.LIB_DIR}[/green]",
"[violet]Hint:[/violet] Fix it by setting ABXPKG_LIB_DIR to a path that meets these requirements, e.g.:",
f" [green]archivebox config --set ABXPKG_LIB_DIR={get_or_create_working_lib_dir(autofix=False, quiet=True) or config.ABXPKG_LIB_DIR}[/green]",
"",
),
)
@@ -390,11 +399,13 @@ def check_lib_dir(lib_dir: Path | None = None, throw=False, quiet=False, must_ex
ERROR_TEXT,
expand=False,
border_style="red",
title="[red]:cross_mark: Error with configured LIB_DIR[/red]",
title="[red]:cross_mark: Error with configured ABXPKG_LIB_DIR[/red]",
subtitle="[yellow]Dependencies may not auto-install properly until fixed.[/yellow]",
),
)
STDERR.print()
if throw:
raise OSError(f"LIB_DIR={lib_dir} is invalid, ArchiveBox is unable to use it and dependencies will fail to install.") from e
raise OSError(
f"ABXPKG_LIB_DIR={lib_dir} is invalid, ArchiveBox is unable to use it and dependencies will fail to install.",
) from e
return False
+8 -9
View File
@@ -18,6 +18,9 @@ Typed JSONL Format:
Plain URLs (also supported):
https://example.com
https://foo.com
Local filesystem paths and file:// URLs are intentionally not accepted here.
Pipe file contents through stdin instead of passing a path/URI as a crawl URL.
"""
__package__ = "archivebox.misc"
@@ -80,7 +83,7 @@ def parse_line(line: str) -> dict[str, Any] | None:
pass
# Treat as plain URL if it looks like one
if line.startswith("http://") or line.startswith("https://") or line.startswith("file://"):
if line.startswith("http://") or line.startswith("https://"):
return {"type": TYPE_SNAPSHOT, "url": line}
# Could be a snapshot ID (UUID with dashes or compact 32-char hex)
@@ -135,17 +138,13 @@ def read_args_or_stdin(args: Iterable[str], stream: TextIO | None = None) -> Ite
Read from CLI arguments if provided, otherwise from stdin.
Handles both URLs and JSONL from either source.
Does not expand local file path arguments; pipe file contents via stdin.
"""
if args:
for arg in args:
# Check if it's a file path
path = Path(arg)
if path.exists() and path.is_file():
yield from read_file(path)
else:
record = parse_line(arg)
if record:
yield record
record = parse_line(arg)
if record:
yield record
else:
yield from read_stdin(stream)
+2 -84
View File
@@ -1,63 +1,12 @@
__package__ = "archivebox.misc"
from django.core.paginator import Paginator
from django.core.paginator import Page
from django.core.paginator import EmptyPage
from django.db import connection
from django.db.models import QuerySet
from django.utils.functional import cached_property
class CountlessPage(Page):
def __init__(self, object_list, number, paginator, has_next_page=False):
self.has_next_page = has_next_page
super().__init__(object_list, number, paginator)
def has_next(self):
return self.has_next_page
class CountlessPaginator(Paginator):
has_exact_count = False
def __init__(self, *args, **kwargs):
self._count_hint = 0
super().__init__(*args, **kwargs)
@cached_property
def count(self):
return self._count_hint
@cached_property
def num_pages(self):
return self.count
def validate_number(self, number):
try:
number = int(number)
except (TypeError, ValueError):
raise EmptyPage("That page number is not an integer")
if number < 1:
raise EmptyPage("That page number is less than 1")
return number
def page(self, number):
number = self.validate_number(number)
bottom = (number - 1) * self.per_page
page_objects = list(self.object_list[bottom : bottom + self.per_page + 1])
has_next_page = len(page_objects) > self.per_page
if has_next_page:
page_objects = page_objects[: self.per_page]
self._count_hint = bottom + len(page_objects) + (1 if has_next_page else 0)
return CountlessPage(page_objects, number, self, has_next_page=has_next_page)
class AcceleratedPaginator(Paginator):
"""
Accelerated paginator ignores DISTINCT when counting total number of rows.
Speeds up SELECT Count(*) on Admin views by >20x.
https://hakibenita.com/optimizing-the-django-admin-paginator
"""
"""Paginator that accepts exact count hints for already-optimized query paths."""
@cached_property
def count(self):
@@ -69,39 +18,8 @@ class AcceleratedPaginator(Paginator):
if count_hint is None:
count_hint = query.__dict__.get("_archivebox_count_hint")
if count_hint is not None:
model = self.object_list.model
if count_hint == "model_estimate":
return self._model_count_estimate(model)
if callable(count_hint):
return count_hint()
return count_hint
if query.distinct or query.where.children:
# fallback to normal count method on filtered queryset
return super().count
# otherwise count total rows in a separate fast query
return self._model_count_estimate(self.object_list.model)
def _model_count_estimate(self, model):
if connection.vendor == "sqlite":
table_name = model._meta.db_table
with connection.cursor() as cursor:
try:
cursor.execute("SELECT stat FROM sqlite_stat1 WHERE tbl = %s", [table_name])
stats = [int(str(row[0]).split()[0]) for row in cursor.fetchall() if row and row[0]]
except Exception:
stats = []
if stats:
return max(stats)
return model.objects.count()
# Alternative approach for PostgreSQL: fallback count takes > 200ms
# from django.db import connection, transaction, OperationalError
# with transaction.atomic(), connection.cursor() as cursor:
# cursor.execute('SET LOCAL statement_timeout TO 200;')
# try:
# return super().count
# except OperationalError:
# return 9999999999999
return super().count
+16
View File
@@ -66,6 +66,22 @@ htmlencode = lambda s: s and escape(s, quote=True)
htmldecode = lambda s: s and unescape(s)
def sanitize_html_text(value: Any) -> str:
"""Strip all HTML from user-editable text before storing it."""
if value is None:
return ""
import bleach
return bleach.clean(
str(value),
tags=[],
attributes={},
protocols=[],
strip=True,
strip_comments=True,
)
def ts_to_date_str(ts: Any) -> str | None:
parsed = parse_date(ts)
return None if parsed is None else parsed.strftime("%Y-%m-%d %H:%M")
+5 -5
View File
@@ -8,8 +8,8 @@
* CHROME_CDP_URL Existing browser CDP URL to attach to
* COOKIES_OUTPUT_FILE Optional output path for Netscape cookies.txt
* AUTH_STORAGE_OUTPUT_FILE Optional output path for auth.json
* CHROME_BINARY Optional browser binary override
* NODE_MODULES_DIR Optional node_modules path for puppeteer-core
* CHROME_BINARY Absolute browser path resolved by abxpkg
* NODE_MODULES_DIR node_modules path resolved by abxpkg
*/
const fs = require('fs');
@@ -116,9 +116,9 @@ async function openBrowser() {
}
const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abx-browser-state-'));
const binary = process.env.CHROME_BINARY || chromeUtils.findAnyChromiumBinary();
if (!binary) {
throw new Error('Could not find a Chromium binary for browser state export');
const binary = process.env.CHROME_BINARY;
if (!binary || !path.isAbsolute(binary) || !fs.existsSync(binary)) {
throw new Error('CHROME_BINARY must be an absolute executable path resolved by abxpkg');
}
const launched = await chromeUtils.launchChromium({
+1 -1
View File
@@ -62,7 +62,7 @@ class PersonaAdminForm(PluginConfigFormMixin, forms.ModelForm):
label="Absolute path or CDP URL",
widget=forms.TextInput(
attrs={
"placeholder": "/Users/alice/Library/Application Support/Google/Chrome or ws://127.0.0.1:9222/devtools/browser/...",
"placeholder": "/Users/alice/Library/Application Support/Google/Chrome or http://127.0.0.1:9222 or ws://127.0.0.1:9222/devtools/browser/...",
"style": "width: 100%; font-family: monospace;",
},
),
+94 -78
View File
@@ -12,6 +12,7 @@ import os
import platform
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
@@ -47,6 +48,12 @@ VOLATILE_PROFILE_COPY_PATTERNS = (
"ShaderCache",
"Service Worker",
"GCM Store",
"chrome-extension_*",
"DNR Extension Rules",
"Extension Rules",
"Extension Scripts",
"Extension State",
"Local Extension Settings",
"*.log",
"Crashpad",
"BrowserMetrics",
@@ -62,6 +69,55 @@ PERSONA_PROFILE_DIR_CANDIDATES = (
)
def _path_exists(path: Path) -> bool:
try:
return path.exists()
except OSError:
return False
def _path_is_dir(path: Path) -> bool:
try:
return path.is_dir()
except OSError:
return False
def _iter_children(path: Path) -> list[Path]:
try:
return list(path.iterdir())
except OSError:
return []
def _is_copyable_profile_entry(path: Path) -> bool:
try:
if path.is_symlink():
return True
if path.is_dir():
return os.access(path, os.R_OK | os.X_OK)
return os.access(path, os.R_OK)
except OSError:
return False
def profile_copy_ignore(src: str, names: list[str]) -> set[str]:
ignored = set(shutil.ignore_patterns(*VOLATILE_PROFILE_COPY_PATTERNS)(src, names))
src_path = Path(src)
for name in names:
if name in ignored:
continue
if not _is_copyable_profile_entry(src_path / name):
# Chrome can leave generated profile components unreadable when a
# previous run used a different runtime uid, a mounted profile came
# from the host, or Chrome tightened permissions on cache/model
# directories. Those entries are not required to launch a cloned
# profile, and letting copytree abort here prevents the crawl from
# even creating its first Snapshot.
ignored.add(name)
return ignored
@dataclass(frozen=True)
class PersonaImportSource:
kind: str
@@ -107,7 +163,7 @@ class PersonaImportSource:
def as_choice_label(self) -> SafeString:
path_str = str(self.profile_path or self.user_data_dir or self.cdp_url or "")
binary_suffix = f"Using {self.browser_binary}" if self.browser_binary else "Will auto-detect a Chromium binary"
binary_suffix = f"Using {self.browser_binary}" if self.browser_binary else "Chromium resolved by abxpkg"
return format_html(
'<span class="abx-profile-option"><strong>{}</strong><span class="abx-profile-option__meta">{}</span><code>{}</code></span>',
self.display_label,
@@ -245,65 +301,6 @@ def get_edge_user_data_dir() -> Path | None:
return None
def get_browser_binary(browser: str) -> str | None:
system = platform.system()
home = Path.home()
browser = browser.lower()
if system == "Darwin":
candidates = {
"chrome": ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
"chromium": ["/Applications/Chromium.app/Contents/MacOS/Chromium"],
"brave": ["/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
"edge": ["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
}.get(browser, [])
elif system == "Linux":
candidates = {
"chrome": [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/google-chrome-beta",
"/usr/bin/google-chrome-unstable",
],
"chromium": ["/usr/bin/chromium", "/usr/bin/chromium-browser"],
"brave": ["/usr/bin/brave-browser", "/usr/bin/brave-browser-beta", "/usr/bin/brave-browser-nightly"],
"edge": [
"/usr/bin/microsoft-edge",
"/usr/bin/microsoft-edge-stable",
"/usr/bin/microsoft-edge-beta",
"/usr/bin/microsoft-edge-dev",
],
}.get(browser, [])
elif system == "Windows":
local_app_data = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
candidates = {
"chrome": [
str(local_app_data / "Google" / "Chrome" / "Application" / "chrome.exe"),
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
],
"chromium": [str(local_app_data / "Chromium" / "Application" / "chrome.exe")],
"brave": [
str(local_app_data / "BraveSoftware" / "Brave-Browser" / "Application" / "brave.exe"),
"C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
"C:\\Program Files (x86)\\BraveSoftware\\Brave-Browser\\Application\\brave.exe",
],
"edge": [
str(local_app_data / "Microsoft" / "Edge" / "Application" / "msedge.exe"),
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
],
}.get(browser, [])
else:
candidates = []
for candidate in candidates:
if candidate and Path(candidate).exists():
return candidate
return None
BROWSER_PROFILE_FINDERS = {
"chrome": get_chrome_user_data_dir,
"chromium": get_chrome_user_data_dir,
@@ -347,7 +344,6 @@ def discover_local_browser_profiles() -> list[PersonaImportSource]:
if not user_data_dir:
continue
browser_binary = get_browser_binary(browser)
for profile_dir in _list_profile_names(user_data_dir):
try:
discovered.append(
@@ -355,7 +351,6 @@ def discover_local_browser_profiles() -> list[PersonaImportSource]:
browser=browser,
user_data_dir=user_data_dir,
profile_dir=profile_dir,
browser_binary=browser_binary,
),
)
except ValueError:
@@ -378,7 +373,6 @@ def discover_persona_template_profiles(personas_dir: Path | None = None) -> list
candidate_roots.extend(
[
CONSTANTS.PERSONAS_DIR.expanduser(),
Path.home() / ".config" / "abx" / "personas",
],
)
@@ -389,13 +383,16 @@ def discover_persona_template_profiles(personas_dir: Path | None = None) -> list
continue
seen_roots.add(resolved_root)
if not resolved_root.exists() or not resolved_root.is_dir():
if not _path_is_dir(resolved_root):
continue
for persona_dir in sorted((path for path in resolved_root.iterdir() if path.is_dir()), key=lambda path: path.name.lower()):
for persona_dir in sorted(
(path for path in _iter_children(resolved_root) if _path_is_dir(path)),
key=lambda path: path.name.lower(),
):
for candidate_dir_name in PERSONA_PROFILE_DIR_CANDIDATES:
user_data_dir = persona_dir / candidate_dir_name
if not user_data_dir.exists() or not user_data_dir.is_dir():
if not _path_is_dir(user_data_dir):
continue
for profile_dir in _list_profile_names(user_data_dir):
@@ -406,7 +403,6 @@ def discover_persona_template_profiles(personas_dir: Path | None = None) -> list
source_name=persona_dir.name,
user_data_dir=user_data_dir,
profile_dir=profile_dir,
browser_binary=get_browser_binary("chrome"),
),
)
except ValueError:
@@ -433,7 +429,6 @@ def resolve_browser_import_source(browser: str, profile_dir: str | None = None)
browser=browser,
user_data_dir=user_data_dir,
profile_dir=chosen_profile,
browser_binary=get_browser_binary(browser),
)
@@ -589,7 +584,7 @@ def copy_browser_user_data_dir(source_dir: Path, destination_dir: Path) -> None:
source_dir,
destination_dir,
symlinks=True,
ignore=shutil.ignore_patterns(*VOLATILE_PROFILE_COPY_PATTERNS),
ignore=profile_copy_ignore,
)
@@ -612,11 +607,34 @@ def export_browser_state(
if not state_script.exists():
return False, None, f"Browser state export script not found at {state_script}"
node_modules_dir = get_config().LIB_DIR / "pnpm" / "packages" / "chrome" / "node_modules"
chrome_plugin_dir = Path(get_plugins_dir()).resolve()
chrome_config = chrome_plugin_dir / "chrome" / "config.json"
env = os.environ.copy()
env["NODE_MODULES_DIR"] = str(node_modules_dir)
if chrome_binary:
env["CHROME_BINARY"] = str(chrome_binary)
dependency_env = subprocess.run(
[
str(Path(sys.executable).with_name("abxpkg")),
"env",
"--install",
"--json",
f"--lib={get_config().ABXPKG_LIB_DIR}",
f"--deps-from={chrome_config}:required_binaries",
],
capture_output=True,
text=True,
env=env,
)
if dependency_env.returncode != 0:
return False, None, dependency_env.stderr.strip() or "abxpkg could not resolve browser export dependencies."
try:
resolved_env = json.loads(dependency_env.stdout)
except json.JSONDecodeError:
return False, None, "abxpkg returned an invalid browser dependency environment."
if not isinstance(resolved_env, dict):
return False, None, "abxpkg returned an invalid browser dependency environment."
env.update({str(key): str(value) for key, value in resolved_env.items()})
env["ARCHIVEBOX_ABX_PLUGINS_DIR"] = str(chrome_plugin_dir)
if user_data_dir:
@@ -624,8 +642,6 @@ def export_browser_state(
if cdp_url:
env["CHROME_CDP_URL"] = cdp_url
env["CHROME_IS_LOCAL"] = "false"
if chrome_binary:
env["CHROME_BINARY"] = str(chrome_binary)
if profile_dir:
extra_arg = f"--profile-directory={profile_dir}"
existing_extra = env.get("CHROME_ARGS_EXTRA", "").strip()
@@ -702,12 +718,12 @@ def export_browser_state(
def _list_profile_names(user_data_dir: Path) -> list[str]:
if not user_data_dir.exists() or not user_data_dir.is_dir():
if not _path_is_dir(user_data_dir):
return []
profiles: list[str] = []
for child in sorted(user_data_dir.iterdir(), key=lambda path: path.name.lower()):
if not child.is_dir():
for child in sorted(_iter_children(user_data_dir), key=lambda path: path.name.lower()):
if not _path_is_dir(child):
continue
if child.name == "System Profile":
continue
@@ -721,7 +737,7 @@ def _list_profile_names(user_data_dir: Path) -> list[str]:
def _looks_like_profile_dir(path: Path) -> bool:
if not path.exists() or not path.is_dir():
if not _path_is_dir(path):
return False
marker_paths = (
@@ -733,7 +749,7 @@ def _looks_like_profile_dir(path: Path) -> bool:
path / "Session Storage",
)
if any(marker.exists() for marker in marker_paths):
if any(_path_exists(marker) for marker in marker_paths):
return True
return any(path.name == prefix or path.name.startswith(prefix) for prefix in BROWSER_PROFILE_DIR_NAMES)
+58 -39
View File
@@ -11,14 +11,13 @@ Each persona has its own:
__package__ = "archivebox.personas"
import shutil
import subprocess
import sys
import os
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any
from collections.abc import Mapping
from django.db import models
from django.db import IntegrityError, models
from django.db.models.fields.json import KT
from django.conf import settings
from django.utils import timezone
@@ -62,11 +61,7 @@ VOLATILE_PROFILE_FILE_NAMES = {
def derive_persona_config(*, name: str, config: Mapping[str, Any] | None, persona_dir: Path) -> dict[str, Any]:
derived = dict(config or {})
if "CHROME_USER_DATA_DIR" not in derived:
derived["CHROME_USER_DATA_DIR"] = str(persona_dir / "chrome_profile")
if "CHROME_DOWNLOADS_DIR" not in derived:
derived["CHROME_DOWNLOADS_DIR"] = str(persona_dir / "chrome_downloads")
derived["PERSONAS_DIR"] = str(persona_dir.parent)
cookies_path = persona_dir / "cookies.txt"
if "COOKIES_FILE" not in derived and cookies_path.exists():
@@ -165,8 +160,7 @@ class Persona(ModelWithConfig):
Returns dict with:
- All values from self.config JSONField
- CHROME_USER_DATA_DIR (derived from persona path)
- CHROME_DOWNLOADS_DIR (derived from persona path)
- PERSONAS_DIR (derived from DATA_DIR/personas)
- COOKIES_FILE (derived from persona path, if file exists)
- AUTH_STORAGE_FILE (derived from persona path, if file exists)
- ACTIVE_PERSONA (set to this persona's name)
@@ -186,7 +180,16 @@ class Persona(ModelWithConfig):
if not profile_dir.exists():
return False
for path in profile_dir.rglob("*"):
def profile_paths():
# Chrome profiles are user-controlled filesystem state. If a cache
# or generated component is unreadable, cleanup should skip it and
# continue pruning the rest instead of blocking the crawl runner.
for dirpath, dirnames, filenames in os.walk(profile_dir, onerror=lambda _err: None):
current_dir = Path(dirpath)
for name in [*dirnames, *filenames]:
yield current_dir / name
for path in profile_paths():
if path.name in VOLATILE_PROFILE_FILE_NAMES:
try:
path.unlink()
@@ -195,13 +198,21 @@ class Persona(ModelWithConfig):
pass
for dirname in VOLATILE_PROFILE_DIR_NAMES:
for path in profile_dir.rglob(dirname):
if not path.is_dir():
for path in profile_paths():
if path.name != dirname:
continue
try:
is_dir = path.is_dir()
except OSError:
is_dir = False
if not is_dir:
continue
shutil.rmtree(path, ignore_errors=True)
cleaned = True
for path in profile_dir.rglob("*.log"):
for path in profile_paths():
if not path.match("*.log"):
continue
try:
path.unlink()
cleaned = True
@@ -228,6 +239,18 @@ class Persona(ModelWithConfig):
if fcntl is not None:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
@classmethod
def get_or_create_named(cls, name: str) -> "Persona":
persona_name = (name or "Default").strip() or "Default"
persona = cls.objects.filter(name=persona_name).first()
if persona is not None:
return persona
try:
return cls.objects.create(name=persona_name)
except IntegrityError:
return cls.objects.get(name=persona_name)
def runtime_root_for_crawl(self, crawl) -> Path:
return Path(crawl.output_dir) / ".persona" / self.name
@@ -247,27 +270,16 @@ class Persona(ModelWithConfig):
return self.runtime_root_for_snapshot(snapshot) / "chrome_downloads"
def copy_chrome_profile(self, source_dir: Path, destination_dir: Path) -> None:
from archivebox.personas.importers import profile_copy_ignore
destination_dir.parent.mkdir(parents=True, exist_ok=True)
shutil.rmtree(destination_dir, ignore_errors=True)
destination_dir.mkdir(parents=True, exist_ok=True)
copy_cmd: list[str] | None = None
source_contents = f"{source_dir}/."
if sys.platform == "darwin":
copy_cmd = ["cp", "-cR", source_contents, str(destination_dir)]
elif sys.platform.startswith("linux"):
copy_cmd = ["cp", "-a", source_contents, str(destination_dir)]
if copy_cmd:
result = subprocess.run(copy_cmd, capture_output=True, text=True)
if result.returncode == 0:
return
shutil.rmtree(destination_dir, ignore_errors=True)
destination_dir.mkdir(parents=True, exist_ok=True)
shutil.copytree(source_dir, destination_dir, symlinks=True, dirs_exist_ok=True)
shutil.copytree(
source_dir,
destination_dir,
symlinks=True,
ignore=profile_copy_ignore,
)
def prepare_runtime_for_crawl(self, crawl, chrome_binary: str = "") -> dict[str, str]:
self.ensure_dirs()
@@ -294,8 +306,13 @@ class Persona(ModelWithConfig):
(runtime_root / "chrome_binary.txt").write_text(chrome_binary)
return {
"CHROME_USER_DATA_DIR": str(runtime_profile_dir),
"CHROME_DOWNLOADS_DIR": str(runtime_downloads_dir),
# Hooks derive CHROME_USER_DATA_DIR/CHROME_DOWNLOADS_DIR from
# PERSONAS_DIR + ACTIVE_PERSONA. Point PERSONAS_DIR at the
# per-crawl runtime root here so CHROME_ISOLATION=crawl never
# leaks or reuses the template profile while keeping Chrome path
# derivation centralized in the Chrome plugin helpers.
"PERSONAS_DIR": str(runtime_root.parent),
"ACTIVE_PERSONA": self.name,
}
def prepare_runtime_for_snapshot(self, snapshot, chrome_binary: str = "") -> dict[str, str]:
@@ -321,8 +338,11 @@ class Persona(ModelWithConfig):
(runtime_root / "chrome_binary.txt").write_text(chrome_binary)
return {
"CHROME_USER_DATA_DIR": str(runtime_profile_dir),
"CHROME_DOWNLOADS_DIR": str(runtime_downloads_dir),
# See prepare_runtime_for_crawl(): snapshot isolation changes the
# persona root, not individual CHROME_* config keys, so standalone
# Chrome hooks and ArchiveBox-driven hooks resolve paths the same way.
"PERSONAS_DIR": str(runtime_root.parent),
"ACTIVE_PERSONA": self.name,
}
def cleanup_runtime_for_crawl(self, crawl) -> None:
@@ -331,8 +351,7 @@ class Persona(ModelWithConfig):
@classmethod
def get_or_create_default(cls) -> "Persona":
"""Get or create the Default persona."""
persona, _ = cls.objects.get_or_create(name="Default")
return persona
return cls.get_or_create_named("Default")
@classmethod
def cleanup_chrome_all(cls) -> int:
+21 -79
View File
@@ -64,6 +64,15 @@ def get_plugins() -> list[str]:
return sorted(set(plugins))
def get_plugin_models():
from abx_dl.models import discover_plugins
plugins = {}
for base_dir in (BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR):
plugins.update(discover_plugins(plugins_dir=base_dir, runtime="archivebox"))
return plugins
def get_plugin_name(plugin: str) -> str:
"""
Get the base plugin name without numeric prefix.
@@ -90,36 +99,18 @@ def get_enabled_plugins(config: ConfigLookup | None = None, **config_kwargs: Any
config = get_config(**config_kwargs)
def normalize_enabled_plugins(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, str):
raw = value.strip()
if not raw:
return []
if raw.startswith("["):
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, list):
return [str(plugin).strip() for plugin in parsed if str(plugin).strip()]
return [plugin.strip() for plugin in raw.split(",") if plugin.strip()]
if isinstance(value, (list, tuple, set)):
return [str(plugin).strip() for plugin in value if str(plugin).strip()]
return [str(value).strip()] if str(value).strip() else []
plugins_override = config.get("PLUGINS")
if plugins_override:
return normalize_enabled_plugins(plugins_override)
enabled = []
disabled = []
for plugin in get_plugins():
plugin_config = get_plugin_special_config(plugin, config)
if plugin_config["enabled"]:
enabled.append(plugin)
else:
disabled.append(plugin)
return enabled
from abx_dl.models import filter_plugins
return list(filter_plugins(get_plugin_models(), enabled, include_providers=True, disabled_names=disabled))
def discover_plugins_that_provide_interface(
@@ -245,61 +236,12 @@ def get_plugin_special_config(plugin_name: str, config: ConfigLookup, _visited:
"""
plugin_upper = plugin_name.upper()
plugins_whitelist = config.get("PLUGINS", "")
if plugins_whitelist:
plugin_configs = discover_plugin_configs()
plugin_names = {p.strip().lower() for p in plugins_whitelist.split(",") if p.strip()}
pending = list(plugin_names)
while pending:
current = pending.pop()
schema = plugin_configs.get(current, {})
required_plugins = schema.get("required_plugins", [])
if not isinstance(required_plugins, list):
continue
for required_plugin in required_plugins:
required_plugin_name = str(required_plugin).strip().lower()
if not required_plugin_name or required_plugin_name in plugin_names:
continue
plugin_names.add(required_plugin_name)
pending.append(required_plugin_name)
if plugin_name.lower() not in plugin_names:
enabled = False
else:
enabled_key = f"{plugin_upper}_ENABLED"
enabled = config.get(enabled_key)
if enabled is None:
enabled = True
elif isinstance(enabled, str):
enabled = enabled.lower() not in ("false", "0", "no", "")
else:
enabled_key = f"{plugin_upper}_ENABLED"
enabled = config.get(enabled_key)
if enabled is None:
enabled = True
elif isinstance(enabled, str):
enabled = enabled.lower() not in ("false", "0", "no", "")
plugin_configs = discover_plugin_configs()
plugin_name_lower = plugin_name.lower()
if enabled:
visited = _visited or set()
if plugin_name_lower not in visited:
next_visited = visited | {plugin_name_lower}
schema = plugin_configs.get(plugin_name_lower, {})
required_plugins = schema.get("required_plugins", [])
if isinstance(required_plugins, list):
for required_plugin in required_plugins:
required_plugin_name = str(required_plugin).strip()
if not required_plugin_name:
continue
required_config = get_plugin_special_config(required_plugin_name, config, _visited=next_visited)
if not required_config["enabled"]:
enabled = False
break
enabled_key = f"{plugin_upper}_ENABLED"
enabled = config.get(enabled_key)
if enabled is None:
enabled = True
elif isinstance(enabled, str):
enabled = enabled.lower() not in ("false", "0", "no", "")
timeout_key = f"{plugin_upper}_TIMEOUT"
timeout = config.get(timeout_key) or config.get("TIMEOUT", 300)
+34 -20
View File
@@ -32,7 +32,7 @@ Execution order:
- After all foreground hooks complete, background hooks receive SIGTERM and must finalize
Hook naming convention:
on_{EventFamily}__{run_order}_{description}[.finite.bg|.daemon.bg].{ext}
on_{EventFamily}__{run_order}_{description}[.bg].{ext}
API:
discover_hooks(event) -> List[Path] Find hook scripts for a hook-backed event family
@@ -55,6 +55,7 @@ from archivebox.plugins.discovery import (
BUILTIN_PLUGINS_DIR,
USER_PLUGINS_DIR,
ConfigLookup,
get_enabled_plugins,
get_plugin_special_config,
)
@@ -104,11 +105,6 @@ def is_background_hook(hook_name: str) -> bool:
return ".bg." in hook_name or "__background" in hook_name
def is_finite_background_hook(hook_name: str) -> bool:
"""Check if a background hook is finite-lived and should be awaited."""
return ".finite.bg." in hook_name
def normalize_hook_event_name(event_name: str) -> str | None:
"""
Normalize a hook event family or event class name to its on_* prefix.
@@ -220,6 +216,7 @@ def discover_hooks(
config = get_config(**config_kwargs)
enabled_plugins = set(get_enabled_plugins(config=config))
enabled_hooks = []
for hook in hooks:
@@ -234,9 +231,7 @@ def discover_hooks(
enabled_hooks.append(hook)
continue
# Check if plugin is enabled
plugin_config = get_plugin_special_config(plugin_name, config)
if plugin_config["enabled"]:
if plugin_name in enabled_plugins:
enabled_hooks.append(hook)
hooks = enabled_hooks
@@ -284,15 +279,27 @@ def run_hook(
records = process.get_records() # Get parsed JSONL output
"""
from archivebox.machine.models import Process, Machine, NetworkInterface
from archivebox.config.common import get_config, normalize_runtime_config
from archivebox.config.common import (
ArchiveBoxConfig,
_archivebox_config_input_names,
get_config,
normalize_runtime_config,
_plugin_enabled_config_keys,
)
config_scope = {key.removeprefix("config_"): kwargs.pop(key) for key in list(kwargs) if key.startswith("config_")}
config_overrides = _config_to_overrides(config)
explicit_override_keys = set(config_overrides)
resolved_config = get_config(overrides=config_overrides, **config_scope)
hook_config = normalize_runtime_config(
config_overrides if config is not None else resolved_config.for_crawl(),
resolved_config.for_crawl_runtime(runtime_overrides=config_overrides),
json_safe=False,
)
hook_config.update(normalize_runtime_config(config_overrides, json_safe=False))
plugin_enabled_keys = set(_plugin_enabled_config_keys().values())
if plugin_enabled_keys.intersection(hook_config):
for enabled_key in plugin_enabled_keys:
hook_config.setdefault(enabled_key, False)
# Auto-detect timeout from plugin config if not explicitly provided
if timeout is None:
@@ -370,8 +377,13 @@ def run_hook(
# Set up environment with base paths
env = os.environ.copy()
archivebox_config_input_names = _archivebox_config_input_names()
for key in archivebox_config_input_names:
env.pop(key, None)
env.pop("PLUGINS", None)
env["DATA_DIR"] = str(CONSTANTS.DATA_DIR)
env["LIBRARY_VERSION"] = VERSION
env.setdefault("ABXPKG_FAST_SCRIPT", "1")
env.setdefault("MACHINE_ID", os.environ.get("MACHINE_ID", CONSTANTS.MACHINE_ID))
snap_dir = hook_config.get("SNAP_DIR") or _model_output_dir_from_child_path(output_dir, CONSTANTS.SNAPSHOTS_DIR_NAME)
crawl_dir = hook_config.get("CRAWL_DIR") or _model_output_dir_from_child_path(output_dir, CONSTANTS.CRAWLS_DIR_NAME)
@@ -380,16 +392,17 @@ def run_hook(
if crawl_dir:
env["CRAWL_DIR"] = str(crawl_dir)
# Export runtime library roots; abx-dl/abxpkg own executable lookup env.
lib_dir = hook_config.get("LIB_DIR")
# Export the runtime library root; abx-dl/abxpkg own executable lookup env.
lib_dir = hook_config.get("ABXPKG_LIB_DIR")
if lib_dir:
env["LIB_DIR"] = str(lib_dir)
env["ABXPKG_LIB_DIR"] = str(lib_dir)
# Set Node.js module resolution paths.
# NODE_PATH may be a path list, but NODE_MODULES_DIR is a single canonical directory.
node_modules_dir = hook_config.get("NODE_MODULES_DIR")
if not node_modules_dir and lib_dir:
if lib_dir and "ABXPKG_LIB_DIR" in explicit_override_keys and "NODE_MODULES_DIR" not in explicit_override_keys:
node_modules_dir = Path(lib_dir) / "pnpm" / "packages" / "chrome" / "node_modules"
elif not node_modules_dir and lib_dir:
node_modules_dir = Path(lib_dir) / "pnpm" / "packages" / "chrome" / "node_modules"
node_path_parts = [part for part in str(hook_config.get("NODE_PATH") or "").split(os.pathsep) if part]
@@ -405,12 +418,10 @@ def run_hook(
env["NODE_PATH"] = os.pathsep.join(node_path_parts)
# Export all config values to environment (already merged by get_config())
# Skip keys we've already handled specially above (PATH, LIB_DIR, NODE_PATH, etc.)
# Skip keys we've already handled specially above (PATH, ABXPKG_LIB_DIR, NODE_PATH, etc.)
SKIP_KEYS = {
"PATH",
"LIB_DIR",
"ABXPKG_LIB_DIR",
"LIB_BIN_DIR",
"NODE_PATH",
"NODE_MODULES_DIR",
"NODE_MODULE_DIR",
@@ -419,9 +430,12 @@ def run_hook(
"SNAP_DIR",
"CRAWL_DIR",
}
canonical_config_keys = set(ArchiveBoxConfig.model_fields)
for key, value in hook_config.items():
if key in SKIP_KEYS:
continue # Already handled specially above, don't overwrite
if key in archivebox_config_input_names and key not in canonical_config_keys:
continue
if value is None:
continue
elif isinstance(value, bool):
@@ -434,8 +448,8 @@ def run_hook(
# Create output directory if needed
output_dir.mkdir(parents=True, exist_ok=True)
# Detect if this is a background hook (long-running daemon)
# Background hooks use the .daemon.bg. or .finite.bg. filename convention.
# Detect if this is a background hook.
# Background hooks use the .bg. filename marker.
# Old convention: __background in stem (for backwards compatibility)
is_background = ".bg." in script.name or "__background" in script.stem
+1 -5
View File
@@ -14,13 +14,13 @@ from django.utils.safestring import mark_safe
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 archivebox.config.common import get_live_config_url
from archivebox.config.views import get_environment_binary_url, is_superuser
from archivebox.plugins.discovery import BUILTIN_PLUGINS_DIR, USER_PLUGINS_DIR, discover_plugin_configs, iter_plugin_dirs
ABX_PLUGINS_DOCS_BASE_URL = "https://archivebox.github.io/abx-plugins/"
ABX_PLUGINS_GITHUB_BASE_URL = "https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/"
LIVE_CONFIG_BASE_URL = "/admin/environment/config/"
LIVE_PLUGIN_BASE_URL = "/admin/environment/plugins/"
@@ -73,10 +73,6 @@ def get_plugin_hook_source_url(plugin_name: str, hook_name: str) -> str:
return f"{ABX_PLUGINS_GITHUB_BASE_URL}{quote(plugin_name)}/{quote(hook_name)}"
def get_live_config_url(key: str) -> str:
return f"{LIVE_CONFIG_BASE_URL}{quote(key)}/"
def get_machine_admin_url() -> str | None:
from archivebox.machine.models import Machine
+23 -23
View File
@@ -4,6 +4,7 @@ from functools import lru_cache
from pathlib import Path
from typing import Literal
from django.conf import settings
from django.db.models import CharField, Count, Q, Sum
from django.db.models.functions import Cast
from django.http import HttpResponse, JsonResponse
@@ -935,27 +936,26 @@ def live_progress_view(request):
except ImportError:
return JsonResponse(payload)
except Exception as e:
import traceback
error_payload = {
"error": str(e),
"orchestrator_running": False,
"total_workers": 0,
"crawls_active": 0,
"crawls_queued": 0,
"crawls_recent": 0,
"snapshots_active": 0,
"snapshots_queued": 0,
"archiveresults_active": 0,
"archiveresults_queued": 0,
"downloads_active": 0,
"downloads_queued": 0,
"indexing_active": 0,
"indexing_queued": 0,
"active_crawls": [],
"server_time": timezone.now().isoformat(),
}
if settings.DEBUG:
import traceback
return JsonResponse(
{
"error": str(e),
"traceback": traceback.format_exc(),
"orchestrator_running": False,
"total_workers": 0,
"crawls_active": 0,
"crawls_queued": 0,
"crawls_recent": 0,
"snapshots_active": 0,
"snapshots_queued": 0,
"archiveresults_active": 0,
"archiveresults_queued": 0,
"downloads_active": 0,
"downloads_queued": 0,
"indexing_active": 0,
"indexing_queued": 0,
"active_crawls": [],
"server_time": timezone.now().isoformat(),
},
status=500,
)
error_payload["traceback"] = traceback.format_exc()
return JsonResponse(error_payload, status=500)
+8 -1
View File
@@ -2,6 +2,7 @@ __package__ = "archivebox.search"
from django.contrib import admin
from django.contrib.admin.views.main import ChangeList
from django.db.models import Case, IntegerField, Value, When
from archivebox.search.config import (
get_default_search_mode,
@@ -71,7 +72,13 @@ class SearchResultsAdminMixin(admin.ModelAdmin):
if queryset.model._meta.label_lower == "core.snapshot" and request.GET.get("_embedded") != "crawl":
cached_ids = get_cached_admin_search_ids(request)
if cached_ids is not None:
return queryset.filter(pk__in=cached_ids) if cached_ids else queryset.none(), False
if not cached_ids:
return queryset.none(), False
search_rank = Case(
*(When(pk=snapshot_id, then=Value(index)) for index, snapshot_id in enumerate(cached_ids)),
output_field=IntegerField(),
)
return queryset.filter(pk__in=cached_ids).annotate(search_rank=search_rank).order_by("search_rank"), False
return queryset.none(), False
if get_search_mode_base(search_mode, config=request.archivebox_config) == "meta":
+276 -89
View File
@@ -4,20 +4,23 @@ import asyncio
import hashlib
import json
import threading
import time
from copy import copy
from queue import Full, Queue
from urllib.parse import urlsplit
from uuid import UUID
from django.core.cache import cache
from django.db import connections
from django.db.models import Q
from django.http import QueryDict, StreamingHttpResponse
from django.http import HttpResponseForbidden, QueryDict, StreamingHttpResponse
from archivebox.search.config import get_search_mode, get_search_mode_base
from archivebox.search.query import crawl_config_values_search_wave, iter_query_search_ids
SEARCH_RESULT_CACHE_TTL = 60
URL_PREFIX_SEARCH_LIMIT = 500
def get_admin_search_cache_key(request, url: str | None = None) -> str:
@@ -34,6 +37,17 @@ def get_admin_search_cache_key(request, url: str | None = None) -> str:
return f"abx:admin-search:{hashlib.sha256(payload.encode()).hexdigest()}"
def get_public_search_cache_key(request, url: str | None = None) -> str:
"""Build the cache key for one public search URL."""
payload = json.dumps(
{
"url": url or request.get_full_path(),
},
sort_keys=True,
)
return f"abx:public-search:{hashlib.sha256(payload.encode()).hexdigest()}"
def get_cached_admin_search_ids(request) -> list[str] | None:
"""Return streamed admin search IDs from Django cache."""
cached = cache.get(get_admin_search_cache_key(request))
@@ -42,7 +56,100 @@ def get_cached_admin_search_ids(request) -> list[str] | None:
return None
def iter_admin_meta_search_ids(query, queryset):
def get_cached_public_search_ids(request) -> list[str] | None:
"""Return streamed public search IDs from Django cache."""
cached = get_cached_public_search_state(request)
if isinstance(cached, dict):
return cached.get("ids") or []
return None
def get_cached_public_search_state(request) -> dict | None:
"""Return streamed public search state from Django cache."""
cached = cache.get(get_public_search_cache_key(request))
return cached if isinstance(cached, dict) else None
def iter_url_search_prefixes(query: str):
"""Yield URL prefixes that can use indexed startswith scans for common search input."""
query = query.strip().lower()
if not query or any(char.isspace() for char in query):
return
prefixes = []
def add(prefix: str):
if prefix and prefix not in prefixes:
prefixes.append(prefix)
add(query)
if "://" in query:
parsed = urlsplit(query)
if parsed.scheme and parsed.netloc:
host = parsed.netloc
path = parsed.path or ""
if parsed.query:
path = f"{path}?{parsed.query}"
if host.startswith("www."):
add(f"{parsed.scheme}://{host[4:]}{path}")
else:
add(f"{parsed.scheme}://www.{host}{path}")
else:
trimmed = query.lstrip("/")
for scheme in ("https://", "http://"):
add(f"{scheme}{trimmed}")
if trimmed.startswith("www."):
add(f"{scheme}{trimmed[4:]}")
else:
add(f"{scheme}www.{trimmed}")
yield from prefixes
def url_prefix_upper_bound(prefix: str) -> str:
"""Return the exclusive upper bound for an indexed URL prefix range."""
if not prefix:
return prefix
return f"{prefix[:-1]}{chr(ord(prefix[-1]) + 1)}"
def iter_url_prefix_search_ids(prefix: str, queryset):
"""Yield IDs for one URL prefix using the URL index, then apply caller filters."""
if not prefix:
return
model = queryset.model
db_alias = queryset.db
connection = connections[db_alias]
table = connection.ops.quote_name(model._meta.db_table)
pk_column = connection.ops.quote_name(model._meta.pk.column)
url_column = connection.ops.quote_name(model._meta.get_field("url").column)
upper_bound = url_prefix_upper_bound(prefix)
raw_ids = []
with connection.cursor() as cursor:
cursor.execute(
f"""
SELECT {pk_column}
FROM {table}
WHERE {url_column} >= %s AND {url_column} < %s
ORDER BY {url_column}
LIMIT %s
""",
[prefix, upper_bound, URL_PREFIX_SEARCH_LIMIT],
)
raw_ids = [str(row[0]).replace("-", "") for row in cursor.fetchall()]
if not raw_ids:
return
valid_ids = {str(pk).replace("-", "") for pk in queryset.filter(pk__in=raw_ids).values_list("pk", flat=True)}
for snapshot_id in raw_ids:
if snapshot_id in valid_ids:
yield snapshot_id
def iter_meta_search_ids(query, queryset):
"""Yield metadata search matches from a filtered Snapshot queryset."""
seen = set()
try:
@@ -54,18 +161,20 @@ def iter_admin_meta_search_ids(query, queryset):
seen.add(pk)
yield pk
for prefix in iter_url_search_prefixes(query):
for pk in iter_url_prefix_search_ids(prefix, queryset):
if pk in seen:
continue
seen.add(pk)
yield pk
waves = [
Q(timestamp__startswith=query) | Q(url__istartswith=query) | Q(title__istartswith=query),
Q(timestamp__startswith=query) | Q(title__istartswith=query),
Q(url__icontains=query),
Q(title__icontains=query),
Q(tags__name__icontains=query),
Q(notes__icontains=query) | Q(crawl__notes__icontains=query) | Q(crawl__label__icontains=query),
Q(crawl__created_by__username=query),
Q(notes__icontains=query),
]
config_wave = crawl_config_values_search_wave(query)
if config_wave is not None:
waves.append(config_wave)
for wave in waves:
for pk in queryset.filter(wave).values_list("pk", flat=True).distinct().iterator(chunk_size=500):
if pk in seen:
@@ -73,38 +182,157 @@ def iter_admin_meta_search_ids(query, queryset):
seen.add(pk)
yield pk
crawl_metadata_wave = Q(crawl__notes__icontains=query) | Q(crawl__label__icontains=query) | Q(crawl__created_by__username=query)
if not seen:
for pk in queryset.filter(crawl_metadata_wave).values_list("pk", flat=True).distinct().iterator(chunk_size=500):
seen.add(pk)
yield pk
def iter_admin_backend_search_ids(iterator, queryset):
"""Yield backend search IDs that still match the filtered queryset."""
config_wave = crawl_config_values_search_wave(query)
if config_wave is not None and not seen:
for pk in queryset.filter(config_wave).values_list("pk", flat=True).distinct().iterator(chunk_size=500):
seen.add(pk)
yield pk
def normalize_search_result_id(snapshot_id) -> str | None:
"""Return a compact Snapshot ID string from a search provider result."""
snapshot_id = str(snapshot_id).strip().lower().replace("-", "")
if len(snapshot_id) != 32:
return None
return snapshot_id
def iter_filtered_search_result_ids(iterator, queryset, *, flush_max_delay=0.05):
"""Yield provider IDs that still match the filtered queryset.
This is the single intersection/dedupe path used for metadata and every
search backend. It flushes by elapsed time so sparse providers stream rows
as soon as IDs are found instead of waiting for a fixed batch size.
"""
batch = []
seen = set()
queued = set()
last_flush_at = 0.0
def flush_batch():
valid = {str(pk) for pk in queryset.filter(pk__in=batch).values_list("pk", flat=True)}
for snapshot_id in batch:
nonlocal batch, queued, last_flush_at
if not batch:
return
batch_ids = batch
batch = []
queued = set()
last_flush_at = time.monotonic()
valid = {str(pk).replace("-", "") for pk in queryset.filter(pk__in=batch_ids).values_list("pk", flat=True)}
for snapshot_id in batch_ids:
if snapshot_id in valid and snapshot_id not in seen:
seen.add(snapshot_id)
yield snapshot_id
for snapshot_id in iterator:
snapshot_id = str(snapshot_id).strip().lower().replace("-", "")
if len(snapshot_id) != 32:
snapshot_id = normalize_search_result_id(snapshot_id)
if not snapshot_id or snapshot_id in seen or snapshot_id in queued:
continue
batch.append(snapshot_id)
if len(batch) >= 200:
queued.add(snapshot_id)
if not seen or time.monotonic() - last_flush_at >= flush_max_delay:
yield from flush_batch()
batch = []
if batch:
yield from flush_batch()
def iter_search_result_ids(query, base_queryset, *, search_mode, config):
"""Yield filtered Snapshot IDs from the selected search provider."""
search_mode_base = get_search_mode_base(search_mode, config=config)
provider = (
iter_meta_search_ids(query, base_queryset)
if search_mode_base == "meta"
else iter_query_search_ids(query, search_mode=search_mode, config=config)
)
yield from iter_filtered_search_result_ids(provider, base_queryset)
def snapshot_search_stream_response(query, base_queryset, *, search_mode, config, cache_key, thread_name):
"""Stream Snapshot search progress and cache matching IDs for a list view."""
if not query:
return StreamingHttpResponse((), content_type="text/plain")
async def snapshot_ids():
seen = set()
ids = []
last_sent = 0
last_sent_at = time.monotonic()
stream_max_delay = 0.05
stream_padding = " " * 4096
cache.set(cache_key, {"ids": [], "done": False}, SEARCH_RESULT_CACHE_TTL)
queue = Queue(maxsize=8)
stop_event = threading.Event()
def emit(item):
while not stop_event.is_set():
try:
queue.put(item, timeout=0.1)
return
except Full:
continue
def publish_count(done=False):
nonlocal last_sent, last_sent_at
cache.set(cache_key, {"ids": list(ids), "done": done}, SEARCH_RESULT_CACHE_TTL)
last_sent = len(ids)
last_sent_at = time.monotonic()
emit(f"{last_sent}{stream_padding}\n")
def run_search():
iterator = None
try:
iterator = iter_search_result_ids(query, base_queryset, search_mode=search_mode, config=config)
for snapshot_id in iterator:
if stop_event.is_set():
break
snapshot_id = normalize_search_result_id(snapshot_id)
if not snapshot_id or snapshot_id in seen:
continue
seen.add(snapshot_id)
ids.append(snapshot_id)
if len(ids) == 1 or time.monotonic() - last_sent_at >= stream_max_delay:
publish_count()
if not stop_event.is_set() and len(ids) != last_sent:
publish_count(done=True)
except BaseException as err:
emit(err)
finally:
if iterator is not None:
try:
iterator.close()
except AttributeError:
pass
cache.set(cache_key, {"ids": list(ids), "done": True}, SEARCH_RESULT_CACHE_TTL)
emit(None)
threading.Thread(target=run_search, name=thread_name, daemon=True).start()
yield f"0{stream_padding}\n"
try:
while True:
item = await asyncio.to_thread(queue.get)
if item is None:
break
if isinstance(item, BaseException):
raise item
yield item
finally:
stop_event.set()
response = StreamingHttpResponse(snapshot_ids(), content_type="text/plain")
response["X-Accel-Buffering"] = "no"
return response
def admin_snapshot_search_stream_view(model_admin, request):
"""Stream admin Snapshot search progress and cache matching IDs."""
query = (request.GET.get("q") or "").strip()
config = request.archivebox_config
search_mode = get_search_mode(request.GET.get("search_mode"), config=config)
if not query:
return StreamingHttpResponse((), content_type="text/plain")
search_url = request.GET.get("search_url") or request.get_full_path()
target_url = urlsplit(search_url)
@@ -127,77 +355,36 @@ def admin_snapshot_search_stream_view(model_admin, request):
finally:
model_admin.request = current_request
async def snapshot_ids():
seen = set()
ids = []
last_sent = 0
stream_batch_size = 100
stream_padding = " " * 4096
cache_key = get_admin_search_cache_key(request, search_url)
cache.set(cache_key, {"ids": ids, "done": False}, SEARCH_RESULT_CACHE_TTL)
yield f"0{stream_padding}\n"
queue = Queue(maxsize=8)
stop_event = threading.Event()
return snapshot_search_stream_response(
query,
base_queryset,
search_mode=search_mode,
config=config,
cache_key=get_admin_search_cache_key(request, search_url),
thread_name="admin-snapshot-search-stream",
)
def emit(item):
while not stop_event.is_set():
try:
queue.put(item, timeout=0.1)
return
except Full:
continue
def run_search():
nonlocal last_sent
iterator = None
try:
search_mode_base = get_search_mode_base(search_mode, config=config)
iterator = (
iter_admin_meta_search_ids(query, base_queryset)
if search_mode_base == "meta"
else iter_admin_backend_search_ids(
iter_query_search_ids(query, search_mode=search_mode, config=config),
base_queryset,
)
)
for snapshot_id in iterator:
if stop_event.is_set():
break
snapshot_id = str(snapshot_id).strip().lower().replace("-", "")
if len(snapshot_id) != 32 or snapshot_id in seen:
continue
seen.add(snapshot_id)
ids.append(snapshot_id)
if len(ids) - last_sent >= stream_batch_size:
cache.set(cache_key, {"ids": ids, "done": False}, SEARCH_RESULT_CACHE_TTL)
last_sent = len(ids)
emit(f"{last_sent}{stream_padding}\n")
if not stop_event.is_set() and len(ids) != last_sent:
cache.set(cache_key, {"ids": ids, "done": False}, SEARCH_RESULT_CACHE_TTL)
emit(f"{len(ids)}{stream_padding}\n")
except BaseException as err:
emit(err)
finally:
if iterator is not None:
try:
iterator.close()
except AttributeError:
pass
cache.set(cache_key, {"ids": ids, "done": True}, SEARCH_RESULT_CACHE_TTL)
emit(None)
def public_snapshot_search_stream_view(request):
"""Stream public Snapshot search progress and cache matching IDs."""
from archivebox.config.common import get_request_config
from archivebox.core.models import Snapshot
from archivebox.core.permissions import public_snapshots_queryset
threading.Thread(target=run_search, name="admin-snapshot-search-stream", daemon=True).start()
try:
while True:
item = await asyncio.to_thread(queue.get)
if item is None:
break
if isinstance(item, BaseException):
raise item
yield item
finally:
stop_event.set()
config = getattr(request, "archivebox_config", None) or get_request_config(request, resolve_plugins=False)
if not request.user.is_authenticated and not config.PUBLIC_INDEX:
return HttpResponseForbidden("Public index is disabled")
response = StreamingHttpResponse(snapshot_ids(), content_type="text/plain")
response["X-Accel-Buffering"] = "no"
return response
query = (request.GET.get("q") or "").strip()
search_mode = get_search_mode(request.GET.get("search_mode"), config=config)
search_url = request.GET.get("search_url") or request.get_full_path()
base_queryset = public_snapshots_queryset(Snapshot.objects.all())
return snapshot_search_stream_response(
query,
base_queryset,
search_mode=search_mode,
config=config,
cache_key=get_public_search_cache_key(request, search_url),
thread_name="public-snapshot-search-stream",
)
+19 -19
View File
@@ -1,8 +1,10 @@
from __future__ import annotations
import asyncio
import inspect
import json
import os
import signal
import sys
import time
from collections import defaultdict
@@ -25,7 +27,7 @@ from .process_service import parse_event_datetime
def _perf_trace(label):
def decorator(func):
if asyncio.iscoroutinefunction(func):
if inspect.iscoroutinefunction(func):
@wraps(func)
async def async_wrapper(*args, **kwargs):
@@ -236,12 +238,20 @@ def _should_update_snapshot_title(current_title: str, next_title: str, *, snapsh
return len(next_title) > len(current)
def _has_content_files(output_files: Any) -> bool:
return any(Path(path).suffix not in {".log", ".pid", ".sh"} for path in _normalize_output_files(output_files))
def _is_signal_interrupted_exit(exit_code: int) -> bool:
return exit_code < 0 or (exit_code >= 128 and exit_code != PROCESS_EXIT_SKIPPED)
def _status_for_process_without_archive_result(event: ProcessCompletedEvent) -> str:
if event.exit_code == PROCESS_EXIT_SKIPPED:
return "skipped"
if event.exit_code in {128 + signal.SIGHUP, 128 + signal.SIGINT, 128 + signal.SIGTERM}:
# This fallback only runs when a snapshot hook exited before emitting a
# structured ArchiveResult. A polite shutdown signal means the runner
# was interrupted during ownership transfer, not that the extractor
# produced a durable negative result. Keep the hook queued so the next
# runner can retry the exact work item instead of sealing in a transient
# process-lifecycle failure.
return "queued"
if event.exit_code != 0:
return "failed"
return "noresults"
def _iter_archiveresult_records(stdout: str) -> list[dict]:
@@ -443,24 +453,14 @@ class ArchiveResultService(BaseService):
).now()
return
# TODO: consider moving this fallback derivation into abx-dl itself.
# First try both patterns: if the whole abx-dl process crashes, restarting
# the snapshot may be enough, but don't guess before validating it.
process_interrupted = _is_signal_interrupted_exit(event.exit_code)
process_failed = event.exit_code not in (0, PROCESS_EXIT_SKIPPED) and not process_interrupted
process_failed = _status_for_process_without_archive_result(event) == "failed"
with _perf_span("archivebox.ArchiveResultService.on_ProcessCompletedEvent.emit_archive_result_fallback"):
await event.emit(
ArchiveResultEvent(
snapshot_id=snapshot_event.snapshot_id,
plugin=event.plugin_name,
hook_name=event.hook_name,
status=(
"queued"
if process_interrupted
else "failed"
if process_failed
else ("succeeded" if _has_content_files(event.output_files) else "noresult")
),
status=_status_for_process_without_archive_result(event),
output_str=event.stderr if process_failed else "",
output_files=event.output_files,
start_ts=event.start_ts,
+64 -84
View File
@@ -16,21 +16,6 @@ from abxbus import BaseEvent, EventBus
from abx_dl.services.base import BaseService
_LIB_DIR_MANAGED_PROVIDERS = {
"bash",
"cargo",
"deno",
"gem",
"goget",
"chromewebstore",
"nix",
"npm",
"pip",
"puppeteer",
"uv",
}
class ArchiveBoxDBBinaryCacheBackend:
"""ArchiveBox machine.Binary projection backend for abxpkg BinaryCacheService."""
@@ -43,11 +28,58 @@ class ArchiveBoxDBBinaryCacheBackend:
if not binary_name:
return None
existing = await Binary.objects.filter(machine=machine, name=binary_name).afirst()
persisted_overrides = _persisted_overrides_for_request(request)
native_overrides = request.overrides or {}
cache_invalidated = False
if existing and existing.status == Binary.StatusChoices.INSTALLED:
requested_provider_names = _provider_names(request.binproviders)
await sync_to_async(get_config, thread_sensitive=True)()
installed_qs = (
Binary.objects.filter(machine=machine, name=binary_name, status=Binary.StatusChoices.INSTALLED)
.exclude(abspath="")
.exclude(abspath__isnull=True)
.order_by("-modified_at")
)
async for installed in installed_qs:
installed_path = Path(installed.abspath).expanduser().resolve(strict=False)
if not await sync_to_async(installed_path.exists, thread_sensitive=True)():
await _mark_binary_queued(installed)
continue
if persisted_overrides and installed.overrides != persisted_overrides:
await _mark_binary_queued(installed)
continue
provider_name = (installed.binprovider or installed.binproviders.split(",", 1)[0]).strip()
if provider_name and provider_name not in requested_provider_names:
await _mark_binary_queued(installed)
continue
provider = _provider_for_name(provider_name, installed.name, native_overrides)
binary_env = BinProvider.build_exec_env(providers=[provider], base_env={}) if provider is not None else {}
provider_names = _provider_names(installed.binproviders or request.binproviders or "env")
return AbxBinary.model_validate(
{
"name": request.name,
"description": request.description,
"binproviders": _providers_for_names(provider_names),
"overrides": native_overrides,
"loaded_binprovider": provider,
"loaded_abspath": installed.abspath,
"loaded_version": installed.version or None,
"loaded_sha256": installed.sha256 or None,
"env": binary_env,
},
)
existing = await Binary.objects.filter(machine=machine, name=binary_name).order_by("-modified_at").afirst()
if existing is None:
await Binary.objects.acreate(
machine=machine,
name=binary_name,
binproviders=_binproviders_to_str(request.binproviders),
overrides=persisted_overrides,
status=Binary.StatusChoices.QUEUED,
)
else:
changed = False
requested_binproviders = _binproviders_to_str(request.binproviders)
if requested_binproviders and existing.binproviders != requested_binproviders:
@@ -56,75 +88,15 @@ class ArchiveBoxDBBinaryCacheBackend:
if persisted_overrides and existing.overrides != persisted_overrides:
existing.overrides = persisted_overrides
changed = True
if changed:
if existing.status != Binary.StatusChoices.QUEUED:
existing.status = Binary.StatusChoices.QUEUED
existing.retry_at = None
cache_invalidated = True
changed = True
if changed:
await existing.asave(update_fields=["binproviders", "overrides", "status", "retry_at", "modified_at"])
elif existing is None:
await Binary.objects.acreate(
machine=machine,
name=binary_name,
binproviders=_binproviders_to_str(request.binproviders),
overrides=persisted_overrides,
status=Binary.StatusChoices.QUEUED,
)
installed = None
if not cache_invalidated:
installed = (
await Binary.objects.filter(machine=machine, name=binary_name, status=Binary.StatusChoices.INSTALLED)
.exclude(abspath="")
.exclude(abspath__isnull=True)
.order_by("-modified_at")
.afirst()
)
if installed is not None and not await sync_to_async(Path(installed.abspath).expanduser().exists, thread_sensitive=True)():
installed.status = Binary.StatusChoices.QUEUED
installed.retry_at = None
await installed.asave(update_fields=["status", "retry_at", "modified_at"])
installed = None
if installed is not None and persisted_overrides and installed.overrides != persisted_overrides:
installed.status = Binary.StatusChoices.QUEUED
installed.retry_at = None
await installed.asave(update_fields=["status", "retry_at", "modified_at"])
installed = None
if installed is None:
return None
installed_path = Path(installed.abspath).expanduser().resolve(strict=False)
active_lib_dir = (
Path(str((await sync_to_async(get_config, thread_sensitive=True)()).get("LIB_DIR", ""))).expanduser().resolve(strict=False)
)
provider_name = (installed.binprovider or installed.binproviders.split(",", 1)[0]).strip()
if active_lib_dir and provider_name in _LIB_DIR_MANAGED_PROVIDERS:
try:
installed_path.relative_to(active_lib_dir)
except ValueError:
installed.status = Binary.StatusChoices.QUEUED
installed.retry_at = None
await installed.asave(update_fields=["status", "retry_at", "modified_at"])
return None
provider = _provider_for_name(provider_name, installed.name, native_overrides)
binary_env = BinProvider.build_exec_env(providers=[provider], base_env={}) if provider is not None else {}
provider_names = _provider_names(installed.binproviders or request.binproviders or "env")
return AbxBinary.model_validate(
{
"name": request.name,
"description": request.description,
"binproviders": _providers_for_names(provider_names),
"overrides": native_overrides,
"loaded_binprovider": provider,
"loaded_abspath": installed.abspath,
"loaded_version": installed.version or None,
"loaded_sha256": installed.sha256 or None,
"env": binary_env,
},
)
return None
async def set(self, request: BinaryRequestEvent | None, binary: AbxBinary) -> None:
from archivebox.config.common import get_config
from archivebox.machine.models import Binary, Machine, _canonical_binary_name
machine = await sync_to_async(Machine.current, thread_sensitive=True)()
@@ -160,8 +132,6 @@ class ArchiveBoxDBBinaryCacheBackend:
await existing.asave(
update_fields=["abspath", "version", "sha256", "binproviders", "binprovider", "overrides", "status", "retry_at", "modified_at"],
)
lib_bin_dir = await sync_to_async(lambda: get_config().LIB_BIN_DIR, thread_sensitive=True)()
await sync_to_async(existing.symlink_to_lib_bin_after_commit, thread_sensitive=True)(lib_bin_dir)
async def invalidate(self, request: BinaryRequestEvent, binary: AbxBinary, reason: str) -> None:
from archivebox.machine.models import Binary, Machine, _canonical_binary_name
@@ -421,6 +391,16 @@ def _provider_for_name(provider_name: str, binary_name: str, overrides: dict[str
return provider
async def _mark_binary_queued(binary) -> None:
from archivebox.machine.models import Binary
if binary.status == Binary.StatusChoices.QUEUED:
return
binary.status = Binary.StatusChoices.QUEUED
binary.retry_at = None
await binary.asave(update_fields=["status", "retry_at", "modified_at"])
def _persisted_overrides_for_request(request: BinaryRequestEvent | None) -> dict[str, Any]:
if request is None:
return {}
+27 -3
View File
@@ -31,6 +31,27 @@ def current_network_interface_with_machine():
return NetworkInterface.objects.select_related("machine").get(id=current_iface.id)
def normalize_process_env(env: dict) -> dict:
normalized = dict(env or {})
raw_plugins = normalized.pop("PLUGINS", "")
selected_plugins = {name.strip().lower() for name in str(raw_plugins).split(",") if name.strip()}
from archivebox.config.common import ArchiveBoxConfig, _archivebox_config_input_names, is_sensitive_config_key
allowed_config_keys = ArchiveBoxConfig._crawl_runtime_keys()
config_input_names = _archivebox_config_input_names()
for key in list(normalized):
if is_sensitive_config_key(key) or (key in config_input_names and key not in allowed_config_keys):
normalized.pop(key, None)
if selected_plugins:
from abx_dl.models import discover_plugins, filter_plugins
from archivebox.config.common import _plugin_enabled_config_keys
selected_plugins = set(filter_plugins(discover_plugins(runtime="archivebox"), sorted(selected_plugins), include_providers=True))
for plugin_name, enabled_key in _plugin_enabled_config_keys().items():
normalized.setdefault(enabled_key, "True" if plugin_name in selected_plugins else "False")
return normalized
class ProcessService(BaseService):
LISTENS_TO: ClassVar[list[type[BaseEvent]]] = [
ProcessStartedEvent,
@@ -74,6 +95,7 @@ class ProcessService(BaseService):
started_at=started_at,
)
process = await process_query.order_by("-modified_at").afirst()
process_env = normalize_process_env(event.env)
if process is None:
process = await Process.objects.acreate(
machine=iface.machine,
@@ -82,7 +104,7 @@ class ProcessService(BaseService):
worker_type=worker_type,
pwd=event.output_dir,
cmd=[event.hook_path, *event.hook_args],
env=event.env,
env=process_env,
timeout=event.timeout,
pid=event.pid or None,
url=event.url or None,
@@ -97,7 +119,7 @@ class ProcessService(BaseService):
process.pwd = event.output_dir
process.cmd = [event.hook_path, *event.hook_args]
process.env = event.env
process.env = process_env
process.timeout = event.timeout
process.pid = event.pid or None
process.url = event.url or process.url
@@ -172,6 +194,7 @@ class ProcessService(BaseService):
started_at=started_at,
)
process = await process_query.order_by("-modified_at").afirst()
process_env = normalize_process_env(event.env)
if process is None:
await Process.objects.acreate(
machine=iface.machine,
@@ -180,7 +203,7 @@ class ProcessService(BaseService):
worker_type=worker_type,
pwd=event.output_dir,
cmd=[event.hook_path, *event.hook_args],
env=event.env,
env=process_env,
timeout=event.timeout,
pid=event.pid or None,
url=event.url or None,
@@ -197,6 +220,7 @@ class ProcessService(BaseService):
"machine_id": iface.machine_id,
"iface_id": iface.id,
"pwd": event.output_dir,
"env": process_env,
"pid": event.pid or process.pid,
"url": event.url or process.url,
"process_type": process_type or process.process_type,
File diff suppressed because it is too large. Load diff
+35
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import sys
from pathlib import Path
from asgiref.sync import sync_to_async
from django.utils import timezone
@@ -11,6 +12,35 @@ from abx_dl.limits import CrawlLimitState
from abx_dl.services.base import BaseService
def project_discovered_snapshots(snapshot_id: str) -> list:
"""Persist parser output before the parent Snapshot can enter a final state."""
from archivebox.config.common import get_config
from archivebox.core.models import Snapshot
from archivebox.plugins.hooks import collect_urls_from_plugins
snapshot = Snapshot.objects.select_related("crawl", "crawl__created_by", "crawl__persona").filter(id=snapshot_id).first()
if snapshot is None:
return []
crawl = snapshot.crawl
if crawl.status not in crawl.RUNNABLE_STATES or crawl.is_paused or snapshot.depth >= crawl.max_depth:
return []
discovered_urls = collect_urls_from_plugins(Path(snapshot.output_dir))
if not discovered_urls:
return []
config = get_config(crawl=crawl, snapshot=snapshot).for_crawl_runtime(
crawl=crawl,
snapshot=snapshot,
persona=crawl.resolve_persona(),
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"):
return []
return crawl.create_discovered_snapshots(snapshot, discovered_urls, depth=snapshot.depth + 1)
def finalize_completed_snapshot(
snapshot_id: str,
*,
@@ -23,6 +53,11 @@ def finalize_completed_snapshot(
if snapshot is None:
return
# urls.jsonl is durable hook output. Project it while the Snapshot/Crawl are
# still runnable so an interruption cannot seal a parser root after its
# ArchiveResults finish but before its discovered child rows are persisted.
project_discovered_snapshots(str(snapshot.id))
if snapshot.downloaded_at is None:
snapshot.downloaded_at = timezone.now()
snapshot.save(update_fields=["downloaded_at", "modified_at"])
-7
View File
@@ -2028,13 +2028,6 @@
const scope = document.querySelector(selector)
const addLink = scope?.querySelector('.object-tools a.addlink')
const actionButtons = scope?.querySelector('.actions-top .action-buttons')
if (addLink && actionButtons && !actionButtons.querySelector('.admin-add-action')) {
const addButton = addLink.cloneNode(true)
addButton.classList.add('button', 'admin-add-action')
addButton.classList.remove('addlink')
addButton.textContent = '➕ Add'
actionButtons.appendChild(addButton)
}
})
const tagContainer = document.querySelector('.actions-tags')
if (tagContainer) {
@@ -253,9 +253,9 @@ document.addEventListener('DOMContentLoaded', function () {
<div>
<h2>Create a new Persona (Authentication + Configuration Profile)</h2>
<p>
Import cookies and settings from any Chrome-based browser by pasting in the local path of a User Data Directory to import from,
or by pasting in your CDP url from
<code class="persona-import-copy" data-copy-text="chrome://inspect#remote-debugging" title="Copy chrome://inspect#remote-debugging">chrome://inspect#remote-debugging</code>.
Import cookies and settings from any Chrome-based browser by pasting in its <code>Profile Path</code> from <code class="persona-import-copy" data-copy-text="chrome://version" title="Copy chrome://version">chrome://version</code>,
or by pasting in a CDP url from
<code class="persona-import-copy" data-copy-text="chrome://inspect#remote-debugging" title="Copy chrome://inspect#remote-debugging">chrome://inspect#remote-debugging</code> (e.g. <code>http://127.0.0.1:9222</code>).
</p>
</div>
</section>
@@ -5,9 +5,66 @@
window.archiveboxSnapshotSearchStreamReady = true;
var activeSearch = null;
function setSearchLoading(controller, text) {
if (activeSearch !== controller) return;
var form = document.querySelector('form#changelist-search');
if (!form) return;
var status = form.querySelector('.archivebox-search-stream-status');
if (!status) {
status = document.createElement('span');
status.className = 'archivebox-search-stream-status';
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
form.appendChild(status);
}
status.textContent = text || 'Searching...';
status.hidden = false;
form.setAttribute('aria-busy', 'true');
}
function clearSearchLoading(controller) {
if (activeSearch && activeSearch !== controller) return;
var form = document.querySelector('form#changelist-search');
if (!form) return;
var status = form.querySelector('.archivebox-search-stream-status');
if (status) status.hidden = true;
form.removeAttribute('aria-busy');
}
function cancelActiveSearch(event) {
if (!event.target || (event.target.name !== "q" && event.target.name !== "search_mode")) return;
if (activeSearch) activeSearch.abort();
if (activeSearch) {
var controller = activeSearch;
activeSearch.abort();
clearSearchLoading(controller);
}
}
function setChangelistSearchingState(controller) {
if (activeSearch !== controller) return;
var changelist = document.getElementById("changelist");
if (!changelist) return;
var results = changelist.querySelector(".results");
if (!results) {
results = document.createElement("div");
var toolbar = changelist.querySelector("#toolbar");
if (toolbar && toolbar.nextSibling) {
toolbar.parentNode.insertBefore(results, toolbar.nextSibling);
} else {
changelist.appendChild(results);
}
}
results.className = "results search-empty-state";
while (results.firstChild) results.removeChild(results.firstChild);
var message = document.createElement("p");
message.textContent = "Searching matching snapshots...";
results.appendChild(message);
var paginator = changelist.querySelector(".paginator");
if (paginator) paginator.hidden = true;
var actions = changelist.querySelector(".actions");
if (actions) actions.hidden = true;
}
function streamSnapshotSearch(eventOrForm) {
@@ -24,6 +81,8 @@
var controller = new AbortController();
activeSearch = controller;
setSearchLoading(controller, "Searching...");
setChangelistSearchingState(controller);
var resultVersion = 0;
var pendingRender = null;
var renderInFlight = false;
@@ -55,6 +114,7 @@
currentMain.replaceWith(nextMain);
if (window.archiveboxInitAdminChangelist) window.archiveboxInitAdminChangelist();
if (window.archiveboxInitChangelistFilters) window.archiveboxInitChangelistFilters();
setSearchLoading(controller, resultVersion ? "Searching... " + resultVersion + " found" : "Searching...");
history.replaceState(null, "", url);
}
})
@@ -95,6 +155,7 @@
count = parseInt(count.trim(), 10);
resultVersion = Number.isFinite(count) ? count : resultVersion + 1;
});
setSearchLoading(controller, resultVersion ? "Searching... " + resultVersion + " found" : "Searching...");
if (lines.length) renderSoon();
if (!result.done) return read();
renderSoon(true);
@@ -104,10 +165,11 @@
})
.catch(function(error) {
if (error.name !== "AbortError") throw error;
})
.finally(function() {
if (activeSearch === controller) activeSearch = null;
});
})
.finally(function() {
clearSearchLoading(controller);
if (activeSearch === controller) activeSearch = null;
});
}
document.addEventListener("input", cancelActiveSearch);
@@ -229,13 +229,14 @@
<code class="card-url" title="{{obj.url}}">{{obj.url}}</code>
<div class="card-media">
<a href="{% snapshot_base_url obj %}/index.html" class="card-thumbnail">
{% snapshot_url obj 'screenshot/screenshot.png' as screenshot_plugin_url %}
{% snapshot_url obj 'screenshot.png' as screenshot_root_url %}
{% snapshot_url obj 'favicon.ico' as favicon_root_url %}
{% snapshot_archiveresult_url obj 'screenshot' 'screenshot.png' as screenshot_url %}
{% snapshot_archiveresult_url obj 'chrome_extension_screenshot' 'screenshot-1.png' as extension_screenshot_1_url %}
{% snapshot_archiveresult_url obj 'chrome_extension_screenshot' 'screenshot.png' as extension_screenshot_url %}
{% snapshot_archiveresult_url obj 'favicon' 'favicon.ico' as favicon_url %}
<img
src="{{ screenshot_plugin_url }}"
src="{% firstof screenshot_url extension_screenshot_1_url extension_screenshot_url favicon_url '/static/spinner.gif' %}"
alt="{{obj.title|default:'Not yet archived...'}}"
data-fallbacks="{{ screenshot_root_url }},{{ favicon_root_url }}"
data-fallbacks="{{ extension_screenshot_1_url }},{{ extension_screenshot_url }},{{ favicon_url }}"
onerror="const fallbacks=(this.dataset.fallbacks || '').split(',').filter(Boolean); 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.src='/static/spinner.gif'; }"
/>
</a>
+6 -67
View File
@@ -47,7 +47,12 @@
</center>
<div class="crawl-explanation">
<p class="crawl-tip" style="user-select: none;">
<a href="https://github.com/ArchiveBox/archivebox-browser-extension" style="float: right"><img src="{% static 'chrome_extension_icon.png' %}" alt="Chrome Extension" style="height: 33px; margin-top: -5px;">&nbsp; Get the extension</a>
<span style="float: right">
{% if user.is_authenticated and user.is_superuser and request.archivebox_config.OPENCODE_ENABLED %}
<a href="/admin/agent">💬 Crawl with AI</a> &nbsp; | &nbsp;
{% endif %}
<a href="https://github.com/ArchiveBox/archivebox-browser-extension"><img src="{% static 'chrome_extension_icon.png' %}" alt="Chrome Extension" style="height: 33px; margin-top: -5px;">&nbsp; Get the extension</a>
</span>
💡 <strong>Tip:</strong> Instantly save a single URL by visiting:
<code class="crawl-tip-url" style="user-select: text;"><span class="crawl-tip-url-prefix">{{ web_base_url }}/web/</span><span class="crawl-tip-url-target">https://example.com/url_to_save</span></code>
</p>
@@ -369,7 +374,6 @@
if (sameDomainToggleSlot && shortcutToggles.length) {
shortcutToggles.forEach((toggle) => sameDomainToggleSlot.appendChild(toggle));
}
const pluginDependencyMap = JSON.parse('{{ plugin_dependency_map_json|default:"{}"|escapejs }}');
const personaConfigMap = JSON.parse('{{ persona_config_map_json|default:"{}"|escapejs }}');
const personaSelect = document.querySelector('select[name="persona"]');
@@ -401,22 +405,6 @@
return document.querySelector(`.plugin-section-toggle[value="${requiredSearchPlugin}"]`);
}
function getPluginCheckbox(pluginName) {
if (!pluginName) return null;
return document.querySelector(`.plugin-section-toggle[value="${pluginName}"]`);
}
function getRequiredPlugins(pluginName) {
const requiredPlugins = pluginDependencyMap[pluginName];
return Array.isArray(requiredPlugins) ? requiredPlugins : [];
}
function getDependentPlugins(pluginName) {
return Object.entries(pluginDependencyMap)
.filter(([, requiredPlugins]) => Array.isArray(requiredPlugins) && requiredPlugins.includes(pluginName))
.map(([dependentPlugin]) => dependentPlugin);
}
function getConfigEditorRows() {
return document.getElementById('id_config_rows');
}
@@ -552,47 +540,6 @@
updateURLPreview();
}
function ensureRequiredPluginsChecked(pluginName, visited = new Set()) {
if (!pluginName || visited.has(pluginName)) {
return;
}
visited.add(pluginName);
getRequiredPlugins(pluginName).forEach(requiredPlugin => {
const requiredCheckbox = getPluginCheckbox(requiredPlugin);
if (!requiredCheckbox) {
return;
}
requiredCheckbox.checked = true;
dispatchChange(requiredCheckbox);
ensureRequiredPluginsChecked(requiredPlugin, visited);
});
}
function uncheckDependentPlugins(pluginName, visited = new Set()) {
if (!pluginName || visited.has(pluginName)) {
return;
}
visited.add(pluginName);
getDependentPlugins(pluginName).forEach(dependentPlugin => {
const dependentCheckbox = getPluginCheckbox(dependentPlugin);
if (!dependentCheckbox) {
return;
}
dependentCheckbox.checked = false;
dispatchChange(dependentCheckbox);
uncheckDependentPlugins(dependentPlugin, visited);
});
}
function normalizePluginSelections() {
const checkedPlugins = Array.from(document.querySelectorAll('.plugin-section-toggle:checked'))
.map(cb => cb.value)
.filter(Boolean);
checkedPlugins.forEach(pluginName => ensureRequiredPluginsChecked(pluginName));
}
function applyRequiredSearchPlugin(preferredValue = undefined) {
const requiredCheckbox = getRequiredSearchCheckbox();
if (!requiredCheckbox) return;
@@ -1242,7 +1189,6 @@
});
}
normalizePluginSelections();
applyRequiredSearchPlugin(requiredSearchPreference);
syncEnabledPluginsConfig();
updateChromeToggleButton();
@@ -1276,7 +1222,6 @@
dispatchChange(cb);
});
normalizePluginSelections();
applyRequiredSearchPlugin(requiredSearchPreference);
syncEnabledPluginsConfig();
updateChromeToggleButton();
@@ -1286,11 +1231,6 @@
document.querySelectorAll('.plugin-section-toggle').forEach(checkbox => {
checkbox.addEventListener('change', function() {
if (this.checked) {
ensureRequiredPluginsChecked(this.value);
} else {
uncheckDependentPlugins(this.value);
}
syncEnabledPluginsConfig();
updateChromeToggleButton();
saveFormState();
@@ -1345,7 +1285,6 @@
if (personaSelect) {
applyPersonaConfig(personaSelect.value);
}
normalizePluginSelections();
applyRequiredSearchPlugin();
syncEnabledPluginsConfig();
updateChromeToggleButton();
+1 -89
View File
@@ -1,89 +1 @@
{% load static tz core_tags %}
{% snapshot_base_url link as snapshot_base %}
{% snapshot_url link 'screenshot/screenshot.png' as screenshot_plugin_url %}
{% snapshot_url link 'screenshot.png' as screenshot_root_url %}
{% snapshot_url link 'favicon/favicon.ico' as favicon_plugin_url %}
{% snapshot_url link 'favicon.ico' as favicon_root_url %}
<tr class="snapshot-row status-{{ link.status|default:'unknown' }}">
<td class="snapshot-time" title="Bookmarked: {{ link.bookmarked_at|localtime }} ({{ link.timestamp }})" data-sort="{{ link.bookmarked_at.timestamp }}">
<a href="{{ snapshot_base }}/index.html">
<span>{{ link.bookmarked_at|localtime|date:"Y-m-d" }}</span>
<small>{{ link.bookmarked_at|localtime|date:"H:i" }}</small>
</a>
</td>
<td class="snapshot-preview-cell">
<a href="{{ snapshot_base }}/index.html" title="Open archived snapshot">
{% if link.is_archived %}
<img src="{{ screenshot_plugin_url }}"
data-fallbacks="{{ screenshot_root_url }},{{ favicon_plugin_url }},{{ favicon_root_url }}"
onerror="nextPublicSnapshotPreview(this)"
class="snapshot-preview screenshot"
alt=""
decoding="async"
loading="lazy">
{% else %}
<img src="{% static 'spinner.gif' %}"
class="snapshot-preview"
alt=""
decoding="async"
loading="lazy">
{% endif %}
</a>
</td>
<td class="snapshot-title-cell" title="{{ link.title|default:link.url }}">
<div class="snapshot-title-line">
<a href="{{ snapshot_base }}/index.html" class="snapshot-favicon-link" title="Open archived snapshot">
<img src="{{ favicon_plugin_url }}"
data-fallbacks="{{ favicon_root_url }}"
onerror="nextPublicSnapshotPreview(this)"
class="link-favicon"
alt=""
decoding="async"
loading="lazy">
</a>
<a href="{{ snapshot_base }}/index.html" class="snapshot-title">
{{ link.title|default:'Loading...'|truncatechars:110 }}
</a>
</div>
<a href="{{ link.url }}" class="snapshot-url" title="{{ link.url }}" target="_blank" rel="noopener noreferrer">
{{ link.url }}
</a>
</td>
<td class="snapshot-tags-cell">
{% if link.tags_str %}
<span class="snapshot-tags">
{% for tag in link.tags_str|split:',' %}
{% if tag %}
<span class="snapshot-tag">{{ tag }}</span>
{% endif %}
{% endfor %}
</span>
{% else %}
<span class="empty-value">...</span>
{% endif %}
</td>
<td class="snapshot-status-cell">
<span class="snapshot-status">{{ link.status|default:'unknown' }}</span>
</td>
<td class="snapshot-files-cell">
<span data-number-for="{{ link.url }}" title="{{ link.num_outputs }} successful outputs">
{% if link.icons %}
{{ link.icons }}
{% else %}
<span class="empty-value">...</span>
{% endif %}
</span>
</td>
<td class="snapshot-size-cell">
<a href="{{ snapshot_base }}/?files=1" title="View archived files">
{% if link.archive_size %}
{{ link.archive_size|file_size }}
{% else %}
<span class="empty-value">...</span>
{% endif %}
</a>
<small>{{ link.num_outputs }} output{{ link.num_outputs|pluralize }}</small>
</td>
</tr>
{% load core_tags %}{% snapshot_index_row link %}
+9 -5
View File
@@ -1,4 +1,5 @@
<!DOCTYPE html>
{% load core_tags %}
<html lang="en">
<head>
<title>Archived Sites</title>
@@ -8,15 +9,18 @@
<table id="table-bookmarks">
<thead>
<tr class="thead-tr">
<th style="width: 100px;">Bookmarked</th>
<th style="width: 26vw;">Saved Link ({{num_links}})</th>
<th style="width: 50px">Files</th>
<th style="width: 16vw;whitespace:nowrap;overflow-x:hidden;">Original URL</th>
<th class="snapshot-time">Saved</th>
<th class="snapshot-preview-cell">Preview</th>
<th>Snapshot ({{num_links}})</th>
<th class="snapshot-tags-cell">Tags</th>
<th class="snapshot-status-cell">Status</th>
<th class="snapshot-files-cell">Files</th>
<th class="snapshot-size-cell">Size</th>
</tr>
</thead>
<tbody>
{% for link in links %}
{% include "index_row.html" with link=link %}
{% snapshot_index_row link %}
{% endfor %}
</tbody>
</table>
+3 -3
View File
@@ -6,7 +6,7 @@
<a href="{% url 'Home' %}">Snapshots</a> |
<a href="/admin/core/archiveresult/?o=-1">Log</a> |
<a href="/admin/core/tag/">Tags</a> &nbsp; &nbsp;
{% if user.is_authenticated and user.is_superuser %}
{% if user.is_authenticated and user.is_superuser and request.archivebox_config.OPENCODE_ENABLED %}
<a href="/admin/agent">💬 AI</a> |
{% endif %}
<a href="{% url 'Docs' %}" target="_blank" rel="noopener noreferrer">Docs</a> |
@@ -27,10 +27,10 @@
{% elif request.COOKIES.archivebox_admin_logged_in == "1" %}
{% comment %}
Authenticated on the admin host but the session cookie is admin-host-
scoped (security boundary — public.* must NEVER see the session).
scoped (security boundary — web.* must NEVER see the session).
The hint cookie is the only signal that crosses, so we render the
logged-out state's `Account` / `Log out` links pointing at admin host
so the user can still reach those pages from public.*/web.*.
so the user can still reach those pages from web.*.
{% endcomment %}
<a href="/admin/password_change/" title="Change your account password">Account</a> /
<a href="/admin/logout/">{% trans 'Log out' %}</a>
+220 -29
View File
@@ -194,14 +194,29 @@
}
.snapshot-preview-cell {
width: 106px;
width: 116px;
min-width: 116px;
vertical-align: middle;
}
#content .snapshot-preview-cell .snapshot-preview {
width: 86px;
height: 54px;
max-width: 86px;
max-height: 54px;
.snapshot-preview-cell a {
display: inline-flex;
align-items: center;
justify-content: center;
width: 102px;
height: 102px;
line-height: 0;
vertical-align: middle;
}
#content .public-snapshot-list .snapshot-preview-cell .snapshot-preview,
#content .public-snapshot-list .snapshot-preview-cell img.snapshot-preview.screenshot {
box-sizing: border-box;
display: block;
width: 100px;
height: 100px;
max-width: 100px;
max-height: 100px;
margin: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
@@ -210,6 +225,24 @@
object-position: top;
}
#content .snapshot-preview-cell .snapshot-preview-spinner {
display: inline-flex;
align-items: center;
justify-content: center;
object-fit: unset;
}
#content .snapshot-preview-cell .snapshot-preview-spinner img {
display: block;
width: 18px;
height: 18px;
max-width: 18px;
max-height: 18px;
border: 0;
border-radius: 0;
background: transparent;
}
.snapshot-title-cell {
min-width: 0;
}
@@ -457,8 +490,12 @@
<input type="submit" value="Search">
</form>
<div class="public-snapshot-count">
{{ page_obj.start_index }}-{{ page_obj.end_index }}{% if page_obj.has_next %}+{% endif %}
<span>(page {{ page_obj.number }}{% if page_obj.paginator.has_exact_count %} of {{ page_obj.paginator.num_pages }}{% endif %})</span>
{% 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>
{% endif %}
</div>
</div>
<div class="public-snapshot-table-wrap">
@@ -467,7 +504,7 @@
<tr>
<th class="snapshot-time">Saved</th>
<th class="snapshot-preview-cell">Preview</th>
<th>Snapshot{% if page_obj.paginator.has_exact_count %} ({{ page_obj.paginator.count }}){% endif %}</th>
<th>Snapshot ({{ page_obj.paginator.count }})</th>
<th class="snapshot-tags-cell">Tags</th>
<th class="snapshot-status-cell">Status</th>
<th class="snapshot-files-cell">Files</th>
@@ -476,11 +513,13 @@
</thead>
<tbody>
{% for link in object_list %}
{% include 'index_row.html' with link=link compact_public_index=True %}
{% snapshot_index_row link %}
{% empty %}
<tr>
<td colspan="7" class="public-search-empty">
{% if show_search_index_hint %}
{% if public_search_stream_pending %}
Searching {{ search_mode }} for matching snapshots...
{% elif show_search_index_hint %}
0 results from {{ search_mode }}. If this looks wrong, the search index may need to be updated:
<code>archivebox update --index-only</code>
{% else %}
@@ -493,7 +532,7 @@
</table>
</div>
<div class="public-pagination">
Showing {{ page_obj.start_index }}-{{ page_obj.end_index }}{% if page_obj.has_next %}+{% elif page_obj.paginator.has_exact_count %} of {{ page_obj.paginator.count }} total{% endif %}
Showing {{ page_obj.start_index }}-{{ page_obj.end_index }} of {{ page_obj.paginator.count }} total
<br>
<span class="step-links">
{% if page_obj.has_previous %}
@@ -502,13 +541,13 @@
{% endif %}
<span class="current">
Page {{ page_obj.number }}{% if page_obj.paginator.has_exact_count %} of {{ page_obj.paginator.num_pages }}{% endif %}
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?{% url_replace page=page_obj.next_page_number %}">next</a>
{% endif %}
{% if page_obj.has_next and page_obj.paginator.has_exact_count %}
{% if page_obj.has_next %}
<a href="?{% url_replace page=page_obj.paginator.num_pages %}">last &raquo;</a>
{% endif %}
</span>
@@ -516,24 +555,176 @@
</div>
<script>
(function() {
if (window.archiveboxPublicSearchStreamReady) return
window.archiveboxPublicSearchStreamReady = true
const storageKey = 'archivebox-admin-search-mode'
const params = new URLSearchParams(window.location.search)
document.querySelectorAll('.public-search-mode-select').forEach(function(select) {
const values = Array.from(select.options).map(option => option.value)
const stored = localStorage.getItem(storageKey)
if (!params.has('search_mode') && values.includes(stored)) {
select.value = stored
if ((params.get('q') || '').trim()) {
params.set('search_mode', stored)
params.delete('page')
window.location.replace(window.location.pathname + '?' + params.toString() + window.location.hash)
return
let activeSearch = null
function initPublicSearchMode() {
const params = new URLSearchParams(window.location.search)
document.querySelectorAll('.public-search-mode-select').forEach(function(select) {
if (select.dataset.publicSearchModeReady) return
select.dataset.publicSearchModeReady = '1'
const values = Array.from(select.options).map(option => option.value)
const stored = localStorage.getItem(storageKey)
if (!params.has('search_mode') && values.includes(stored)) {
select.value = stored
if ((params.get('q') || '').trim()) {
params.set('search_mode', stored)
params.delete('page')
window.location.replace(window.location.pathname + '?' + params.toString() + window.location.hash)
return
}
}
}
select.addEventListener('change', function() {
localStorage.setItem(storageKey, select.value)
select.addEventListener('change', function() {
localStorage.setItem(storageKey, select.value)
})
})
})
}
function setSearchLoading(controller, text) {
if (activeSearch !== controller) return
const toolbar = document.querySelector('.public-snapshot-toolbar')
if (!toolbar) return
let status = toolbar.querySelector('.archivebox-search-stream-status')
if (!status) {
status = document.createElement('span')
status.className = 'archivebox-search-stream-status'
status.setAttribute('role', 'status')
status.setAttribute('aria-live', 'polite')
const count = toolbar.querySelector('.public-snapshot-count')
if (count) count.appendChild(status)
else toolbar.appendChild(status)
}
status.textContent = text || 'Searching...'
status.hidden = false
toolbar.setAttribute('aria-busy', 'true')
}
function clearSearchLoading(controller) {
if (activeSearch && activeSearch !== controller) return
const toolbar = document.querySelector('.public-snapshot-toolbar')
if (!toolbar) return
const status = toolbar.querySelector('.archivebox-search-stream-status')
if (status) status.hidden = true
toolbar.removeAttribute('aria-busy')
}
function clearStaleSearchResults(mode) {
const tbody = document.querySelector('#table-bookmarks tbody')
if (tbody) {
tbody.innerHTML = '<tr><td colspan="7" class="public-search-empty">Searching ' + mode + ' for matching snapshots...</td></tr>'
}
const snapshotHeading = document.querySelector('#table-bookmarks thead th:nth-child(3)')
if (snapshotHeading) snapshotHeading.textContent = 'Snapshot'
const pagination = document.querySelector('.public-pagination')
if (pagination) pagination.textContent = 'Searching...'
}
function cancelActiveSearch(event) {
if (!event.target || (event.target.name !== 'q' && event.target.name !== 'search_mode')) return
if (activeSearch) {
const controller = activeSearch
activeSearch.abort()
clearSearchLoading(controller)
}
}
function streamPublicSnapshotSearch(eventOrForm) {
const form = eventOrForm && eventOrForm.target ? eventOrForm.target : eventOrForm
if (!form || form.id !== 'changelist-search') return
const formData = new FormData(form)
const mode = String(formData.get('search_mode') || 'meta')
const query = String(formData.get('q') || '').trim()
if (!query) return
if (eventOrForm && eventOrForm.preventDefault) eventOrForm.preventDefault()
if (activeSearch) activeSearch.abort()
const controller = new AbortController()
activeSearch = controller
setSearchLoading(controller, 'Searching...')
let resultVersion = 0
function buildListUrl() {
const url = new URL(window.location.href)
for (const pair of formData.entries()) url.searchParams.set(pair[0], pair[1])
url.searchParams.delete('page')
url.searchParams.delete('p')
return url
}
function replaceRegionFromDocument(doc, selector) {
const next = doc.querySelector(selector)
const current = document.querySelector(selector)
if (next && current) current.innerHTML = next.innerHTML
}
async function renderNow() {
const url = buildListUrl()
const response = await fetch(url, {headers: {'X-Requested-With': 'XMLHttpRequest'}, signal: controller.signal})
const html = await response.text()
const doc = new DOMParser().parseFromString(html, 'text/html')
replaceRegionFromDocument(doc, '.public-snapshot-count')
replaceRegionFromDocument(doc, '#table-bookmarks thead th:nth-child(3)')
replaceRegionFromDocument(doc, '#table-bookmarks tbody')
replaceRegionFromDocument(doc, '.public-pagination')
setSearchLoading(controller, resultVersion ? 'Searching... ' + resultVersion + ' found' : 'Searching...')
}
const listUrl = buildListUrl()
history.replaceState(null, '', listUrl)
clearStaleSearchResults(mode)
const streamUrl = new URL("{% url 'public-search-stream' %}", window.location.href)
streamUrl.searchParams.set('q', query)
streamUrl.searchParams.set('search_mode', mode)
streamUrl.searchParams.set('search_url', listUrl.pathname + listUrl.search)
fetch(streamUrl, {signal: controller.signal})
.then(function(response) {
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
async function read() {
const result = await reader.read()
buffer += decoder.decode(result.value || new Uint8Array(), {stream: !result.done})
const lines = buffer.split('\n')
buffer = lines.pop()
let shouldRender = false
lines.forEach(function(count) {
count = parseInt(count.trim(), 10)
if (Number.isFinite(count)) {
resultVersion = count
if (count > 0) shouldRender = true
} else {
resultVersion += 1
shouldRender = true
}
})
setSearchLoading(controller, resultVersion ? 'Searching... ' + resultVersion + ' found' : 'Searching...')
if (shouldRender || result.done) await renderNow()
if (!result.done) return read()
}
return read()
})
.catch(function(error) {
if (error.name !== 'AbortError') throw error
})
.finally(function() {
clearSearchLoading(controller)
if (activeSearch === controller) activeSearch = null
})
}
initPublicSearchMode()
document.addEventListener('input', cancelActiveSearch)
document.addEventListener('change', cancelActiveSearch)
document.addEventListener('submit', streamPublicSnapshotSearch)
const initialForm = document.getElementById('changelist-search')
if (initialForm && initialForm.tagName === 'FORM' && String(new FormData(initialForm).get('q') || '').trim()) {
streamPublicSnapshotSearch(initialForm)
}
})()
</script>
{% endblock %}
+10 -5
View File
@@ -1137,9 +1137,9 @@
<div class="header-top">
<div class="header-nav">
<div class="header-col header-left" style="line-height: 58px; vertical-align: middle">
{% public_base_url as public_base %}
<a href="{% if public_base %}{{ public_base }}/public/{% else %}/{% endif %}" class="header-archivebox" title="Go to Public Index...">
<img src="{% if public_base %}{{ public_base }}/static/archive.png{% else %}{% static 'archive.png' %}{% endif %}" alt="Archive Icon">
{% web_base_url as web_base %}
<a href="{% if web_base %}{{ web_base }}/public/{% else %}/{% endif %}" class="header-archivebox" title="Go to Public Index...">
<img src="{% if web_base %}{{ web_base }}/static/archive.png{% else %}{% static 'archive.png' %}{% endif %}" alt="Archive Icon">
ArchiveBox
</a>
</div>
@@ -1150,8 +1150,13 @@
</a>
</div>
<div class="header-title-line header-toggle-trigger">
<img src="{% snapshot_url snapshot 'favicon/favicon.ico' %}" onerror="this.onerror=null;this.src=&quot;data:image/svg+xml;utf8,&lt;svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='rgba(255,255,255,0.65)' stroke-width='1.4'&gt;&lt;circle cx='10' cy='10' r='7.25'/&gt;&lt;ellipse cx='10' cy='10' rx='3.25' ry='7.25'/&gt;&lt;line x1='2.75' y1='10' x2='17.25' y2='10'/&gt;&lt;/svg&gt;&quot;" alt="Favicon" class="favicon"/>
<span class="header-title-text">{{title|truncatechars:120|safe}}</span>
{% snapshot_archiveresult_url snapshot 'favicon' 'favicon.ico' as header_favicon_url %}
{% if header_favicon_url %}
<img src="{{ header_favicon_url }}" onerror="this.onerror=null;this.src=&quot;data:image/svg+xml;utf8,&lt;svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='rgba(255,255,255,0.65)' stroke-width='1.4'&gt;&lt;circle cx='10' cy='10' r='7.25'/&gt;&lt;ellipse cx='10' cy='10' rx='3.25' ry='7.25'/&gt;&lt;line x1='2.75' y1='10' x2='17.25' y2='10'/&gt;&lt;/svg&gt;&quot;" alt="Favicon" class="favicon"/>
{% else %}
<img src="data:image/svg+xml;utf8,&lt;svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='rgba(255,255,255,0.65)' stroke-width='1.4'&gt;&lt;circle cx='10' cy='10' r='7.25'/&gt;&lt;ellipse cx='10' cy='10' rx='3.25' ry='7.25'/&gt;&lt;line x1='2.75' y1='10' x2='17.25' y2='10'/&gt;&lt;/svg&gt;" alt="Favicon" class="favicon"/>
{% endif %}
<span class="header-title-text">{{title|truncatechars:120}}</span>
{% if title_tags %}
<span class="header-tags">
<span class="tag-pill permission-pill">{{ snapshot_permissions_icon }} {{ snapshot_permissions }}</span>
+9 -6
View File
@@ -1,4 +1,4 @@
{% load static %}
{% load static core_tags %}
<!DOCTYPE html>
<html lang="en">
@@ -226,15 +226,18 @@
<table id="table-bookmarks">
<thead>
<tr>
<th style="width: 100px;">Bookmarked</th>
<th style="width: 26vw;">Saved Link ({{num_links}})</th>
<th style="width: 50px">Files</th>
<th style="width: 16vw;whitespace:nowrap;overflow-x:hidden;">Original URL</th>
<th class="snapshot-time">Saved</th>
<th class="snapshot-preview-cell">Preview</th>
<th>Snapshot ({{num_links}})</th>
<th class="snapshot-tags-cell">Tags</th>
<th class="snapshot-status-cell">Status</th>
<th class="snapshot-files-cell">Files</th>
<th class="snapshot-size-cell">Size</th>
</tr>
</thead>
<tbody>
{% for link in links %}
{% include 'index_row.html' with link=link %}
{% snapshot_index_row link %}
{% endfor %}
</tbody>
</table>
+1 -8
View File
@@ -2211,8 +2211,7 @@ body.embedded-change-list.model-snapshot.change-list #changelist .actions {
color: #fff7fb;
}
.model-snapshot.change-list #changelist #changelist-form > .actions-top .action-buttons .button[name="set_snapshot_permissions"],
.model-snapshot.change-list #changelist #changelist-form > .actions-top .action-buttons .admin-add-action {
.model-snapshot.change-list #changelist #changelist-form > .actions-top .action-buttons .button[name="set_snapshot_permissions"] {
background: #f6e46f;
color: #1f2937;
text-decoration: none;
@@ -2818,12 +2817,6 @@ body.change-list:not(.model-snapshot) #changelist #toolbar .changelist-search-su
color: #1f2937;
}
.model-crawl.change-list #changelist #changelist-form > .actions-top .action-buttons .admin-add-action {
background: #f6e46f;
color: #1f2937;
text-decoration: none;
}
.model-crawl.change-list #changelist #changelist-form > .actions-top .action-permissions-wrapper {
display: inline-flex;
min-width: 130px;
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more