views.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  1. __package__ = 'archivebox.core'
  2. import os
  3. import sys
  4. from django.utils import timezone
  5. import inspect
  6. from typing import Callable, get_type_hints
  7. from pathlib import Path
  8. from django.shortcuts import render, redirect
  9. from django.http import HttpRequest, HttpResponse, Http404
  10. from django.utils.html import format_html, mark_safe
  11. from django.views import View
  12. from django.views.generic.list import ListView
  13. from django.views.generic import FormView
  14. from django.db.models import Q
  15. from django.contrib import messages
  16. from django.contrib.auth.mixins import UserPassesTestMixin
  17. from django.views.decorators.csrf import csrf_exempt
  18. from django.utils.decorators import method_decorator
  19. from admin_data_views.typing import TableContext, ItemContext
  20. from admin_data_views.utils import render_with_table_view, render_with_item_view, ItemLink
  21. import archivebox
  22. from archivebox.config import CONSTANTS, CONSTANTS_CONFIG, DATA_DIR, VERSION
  23. from archivebox.config.common import SHELL_CONFIG, SERVER_CONFIG
  24. from archivebox.misc.util import base_url, htmlencode, ts_to_date_str
  25. from archivebox.misc.serve_static import serve_static_with_byterange_support
  26. from archivebox.misc.logging_util import printable_filesize
  27. from archivebox.search import query_search_index
  28. from core.models import Snapshot
  29. from core.forms import AddLinkForm
  30. from crawls.models import Seed, Crawl
  31. class HomepageView(View):
  32. def get(self, request):
  33. if request.user.is_authenticated:
  34. return redirect('/admin/core/snapshot/')
  35. if SERVER_CONFIG.PUBLIC_INDEX:
  36. return redirect('/public')
  37. return redirect(f'/admin/login/?next={request.path}')
  38. class SnapshotView(View):
  39. # render static html index from filesystem archive/<timestamp>/index.html
  40. @staticmethod
  41. def render_live_index(request, snapshot):
  42. TITLE_LOADING_MSG = 'Not yet archived...'
  43. HIDDEN_RESULTS = ('favicon', 'headers', 'title', 'htmltotext', 'warc', 'archive_org')
  44. archiveresults = {}
  45. results = snapshot.archiveresult_set.all()
  46. for result in results:
  47. embed_path = result.embed_path()
  48. abs_path = result.snapshot_dir / (embed_path or 'None')
  49. if (result.status == 'succeeded'
  50. and (result.extractor not in HIDDEN_RESULTS)
  51. and embed_path
  52. and os.access(abs_path, os.R_OK)
  53. and abs_path.exists()):
  54. if os.path.isdir(abs_path) and not any(abs_path.glob('*.*')):
  55. continue
  56. result_info = {
  57. 'name': result.extractor,
  58. 'path': embed_path,
  59. 'ts': ts_to_date_str(result.end_ts),
  60. 'size': abs_path.stat().st_size or '?',
  61. }
  62. archiveresults[result.extractor] = result_info
  63. existing_files = {result['path'] for result in archiveresults.values()}
  64. min_size_threshold = 10_000 # bytes
  65. allowed_extensions = {
  66. 'txt',
  67. 'html',
  68. 'htm',
  69. 'png',
  70. 'jpg',
  71. 'jpeg',
  72. 'gif',
  73. 'webp'
  74. 'svg',
  75. 'webm',
  76. 'mp4',
  77. 'mp3',
  78. 'opus',
  79. 'pdf',
  80. 'md',
  81. }
  82. # iterate through all the files in the snapshot dir and add the biggest ones to1 the result list
  83. snap_dir = Path(snapshot.link_dir)
  84. if not os.path.isdir(snap_dir) and os.access(snap_dir, os.R_OK):
  85. return {}
  86. for result_file in (*snap_dir.glob('*'), *snap_dir.glob('*/*')):
  87. extension = result_file.suffix.lstrip('.').lower()
  88. if result_file.is_dir() or result_file.name.startswith('.') or extension not in allowed_extensions:
  89. continue
  90. if result_file.name in existing_files or result_file.name == 'index.html':
  91. continue
  92. file_size = result_file.stat().st_size or 0
  93. if file_size > min_size_threshold:
  94. archiveresults[result_file.name] = {
  95. 'name': result_file.stem,
  96. 'path': result_file.relative_to(snap_dir),
  97. 'ts': ts_to_date_str(result_file.stat().st_mtime or 0),
  98. 'size': file_size,
  99. }
  100. preferred_types = ('singlefile', 'screenshot', 'wget', 'dom', 'media', 'pdf', 'readability', 'mercury')
  101. all_types = preferred_types + tuple(result_type for result_type in archiveresults.keys() if result_type not in preferred_types)
  102. best_result = {'path': 'None'}
  103. for result_type in preferred_types:
  104. if result_type in archiveresults:
  105. best_result = archiveresults[result_type]
  106. break
  107. link = snapshot.as_link()
  108. link_info = link._asdict(extended=True)
  109. try:
  110. warc_path = 'warc/' + list(Path(snap_dir).glob('warc/*.warc.*'))[0].name
  111. except IndexError:
  112. warc_path = 'warc/'
  113. context = {
  114. **link_info,
  115. **link_info['canonical'],
  116. 'title': htmlencode(
  117. link.title
  118. or (link.base_url if link.is_archived else TITLE_LOADING_MSG)
  119. ),
  120. 'extension': link.extension or 'html',
  121. 'tags': link.tags or 'untagged',
  122. 'size': printable_filesize(link.archive_size) if link.archive_size else 'pending',
  123. 'status': 'archived' if link.is_archived else 'not yet archived',
  124. 'status_color': 'success' if link.is_archived else 'danger',
  125. 'oldest_archive_date': ts_to_date_str(link.oldest_archive_date),
  126. 'warc_path': warc_path,
  127. 'SAVE_ARCHIVE_DOT_ORG': archivebox.pm.hook.get_FLAT_CONFIG().SAVE_ARCHIVE_DOT_ORG,
  128. 'PREVIEW_ORIGINALS': SERVER_CONFIG.PREVIEW_ORIGINALS,
  129. 'archiveresults': sorted(archiveresults.values(), key=lambda r: all_types.index(r['name']) if r['name'] in all_types else -r['size']),
  130. 'best_result': best_result,
  131. # 'tags_str': 'somealskejrewlkrjwer,werlmwrwlekrjewlkrjwer324m532l,4m32,23m324234',
  132. }
  133. return render(template_name='core/snapshot_live.html', request=request, context=context)
  134. def get(self, request, path):
  135. if not request.user.is_authenticated and not SERVER_CONFIG.PUBLIC_SNAPSHOTS:
  136. return redirect(f'/admin/login/?next={request.path}')
  137. snapshot = None
  138. try:
  139. slug, archivefile = path.split('/', 1)
  140. except (IndexError, ValueError):
  141. slug, archivefile = path.split('/', 1)[0], 'index.html'
  142. # slug is a timestamp
  143. if slug.replace('.','').isdigit():
  144. # missing trailing slash -> redirect to index
  145. if '/' not in path:
  146. return redirect(f'{path}/index.html')
  147. try:
  148. try:
  149. snapshot = Snapshot.objects.get(Q(timestamp=slug) | Q(id__startswith=slug))
  150. if archivefile == 'index.html':
  151. # if they requested snapshot index, serve live rendered template instead of static html
  152. response = self.render_live_index(request, snapshot)
  153. else:
  154. response = serve_static_with_byterange_support(
  155. request, archivefile, document_root=snapshot.link_dir, show_indexes=True,
  156. )
  157. response["Link"] = f'<{snapshot.url}>; rel="canonical"'
  158. return response
  159. except Snapshot.DoesNotExist:
  160. if Snapshot.objects.filter(timestamp__startswith=slug).exists():
  161. raise Snapshot.MultipleObjectsReturned
  162. else:
  163. raise
  164. except Snapshot.DoesNotExist:
  165. # Snapshot does not exist
  166. return HttpResponse(
  167. format_html(
  168. (
  169. '<center><br/><br/><br/>'
  170. 'No Snapshot directories match the given timestamp/ID/ABID: <code>{}</code><br/><br/>'
  171. 'You can <a href="/add/" target="_top">add a new Snapshot</a>, or return to the <a href="/" target="_top">Main Index</a>'
  172. '</center>'
  173. ),
  174. slug,
  175. path,
  176. ),
  177. content_type="text/html",
  178. status=404,
  179. )
  180. except Snapshot.MultipleObjectsReturned:
  181. snapshot_hrefs = mark_safe('<br/>').join(
  182. format_html(
  183. '{} <a href="/archive/{}/index.html"><b><code>{}</code></b></a> {} <b>{}</b>',
  184. snap.bookmarked_at.strftime('%Y-%m-%d %H:%M:%S'),
  185. snap.timestamp,
  186. snap.timestamp,
  187. snap.url,
  188. snap.title_stripped[:64] or '',
  189. )
  190. for snap in Snapshot.objects.filter(timestamp__startswith=slug).only('url', 'timestamp', 'title', 'bookmarked_at').order_by('-bookmarked_at')
  191. )
  192. return HttpResponse(
  193. format_html(
  194. (
  195. 'Multiple Snapshots match the given timestamp/ID/ABID <code>{}</code><br/><pre>'
  196. ),
  197. slug,
  198. ) + snapshot_hrefs + format_html(
  199. (
  200. '</pre><br/>'
  201. 'Choose a Snapshot to proceed or go back to the <a href="/" target="_top">Main Index</a>'
  202. )
  203. ),
  204. content_type="text/html",
  205. status=404,
  206. )
  207. except Http404:
  208. assert snapshot # (Snapshot.DoesNotExist is already handled above)
  209. # Snapshot dir exists but file within does not e.g. 124235.324234/screenshot.png
  210. return HttpResponse(
  211. format_html(
  212. (
  213. '<html><head>'
  214. '<title>Snapshot Not Found</title>'
  215. #'<script>'
  216. #'setTimeout(() => { window.location.reload(); }, 5000);'
  217. #'</script>'
  218. '</head><body>'
  219. '<center><br/><br/><br/>'
  220. f'Snapshot <a href="/archive/{snapshot.timestamp}/index.html" target="_top"><b><code>[{snapshot.timestamp}]</code></b></a>: <a href="{snapshot.url}" target="_blank" rel="noreferrer">{snapshot.url}</a><br/>'
  221. f'was queued on {str(snapshot.bookmarked_at).split(".")[0]}, '
  222. f'but no files have been saved yet in:<br/><b><a href="/archive/{snapshot.timestamp}/" target="_top"><code>{snapshot.timestamp}</code></a><code>/'
  223. '{}'
  224. f'</code></b><br/><br/>'
  225. 'It\'s possible {} '
  226. f'during the last capture on {str(snapshot.bookmarked_at).split(".")[0]},<br/>or that the archiving process has not completed yet.<br/>'
  227. f'<pre><code># run this cmd to finish/retry archiving this Snapshot</code><br/>'
  228. f'<code style="user-select: all; color: #333">archivebox update -t timestamp {snapshot.timestamp}</code></pre><br/><br/>'
  229. '<div class="text-align: left; width: 100%; max-width: 400px">'
  230. '<i><b>Next steps:</i></b><br/>'
  231. f'- list all the <a href="/archive/{snapshot.timestamp}/" target="_top">Snapshot files <code>.*</code></a><br/>'
  232. f'- view the <a href="/archive/{snapshot.timestamp}/index.html" target="_top">Snapshot <code>./index.html</code></a><br/>'
  233. f'- go to the <a href="/admin/core/snapshot/{snapshot.pk}/change/" target="_top">Snapshot admin</a> to edit<br/>'
  234. f'- go to the <a href="/admin/core/snapshot/?id__exact={snapshot.id}" target="_top">Snapshot actions</a> to re-archive<br/>'
  235. '- or return to <a href="/" target="_top">the main index...</a></div>'
  236. '</center>'
  237. '</body></html>'
  238. ),
  239. archivefile if str(archivefile) != 'None' else '',
  240. f'the {archivefile} resource could not be fetched' if str(archivefile) != 'None' else 'the original site was not available',
  241. ),
  242. content_type="text/html",
  243. status=404,
  244. )
  245. # # slud is an ID
  246. # ulid = slug.split('_', 1)[-1]
  247. # try:
  248. # try:
  249. # snapshot = snapshot or Snapshot.objects.get(Q(abid=ulid) | Q(id=ulid))
  250. # except Snapshot.DoesNotExist:
  251. # pass
  252. # try:
  253. # snapshot = Snapshot.objects.get(Q(abid__startswith=slug) | Q(abid__startswith=Snapshot.abid_prefix + slug) | Q(id__startswith=slug))
  254. # except (Snapshot.DoesNotExist, Snapshot.MultipleObjectsReturned):
  255. # pass
  256. # try:
  257. # snapshot = snapshot or Snapshot.objects.get(Q(abid__icontains=snapshot_id) | Q(id__icontains=snapshot_id))
  258. # except Snapshot.DoesNotExist:
  259. # pass
  260. # return redirect(f'/archive/{snapshot.timestamp}/index.html')
  261. # except Snapshot.DoesNotExist:
  262. # pass
  263. # slug is a URL
  264. try:
  265. try:
  266. # try exact match on full url / ABID first
  267. snapshot = Snapshot.objects.get(
  268. Q(url='http://' + path) | Q(url='https://' + path) | Q(id__startswith=path)
  269. | Q(abid__icontains=path) | Q(id__icontains=path)
  270. )
  271. except Snapshot.DoesNotExist:
  272. # fall back to match on exact base_url
  273. try:
  274. snapshot = Snapshot.objects.get(
  275. Q(url='http://' + base_url(path)) | Q(url='https://' + base_url(path))
  276. )
  277. except Snapshot.DoesNotExist:
  278. # fall back to matching base_url as prefix
  279. snapshot = Snapshot.objects.get(
  280. Q(url__startswith='http://' + base_url(path)) | Q(url__startswith='https://' + base_url(path))
  281. )
  282. return redirect(f'/archive/{snapshot.timestamp}/index.html')
  283. except Snapshot.DoesNotExist:
  284. return HttpResponse(
  285. format_html(
  286. (
  287. '<center><br/><br/><br/>'
  288. 'No Snapshots match the given url: <code>{}</code><br/><br/><br/>'
  289. 'Return to the <a href="/" target="_top">Main Index</a>, or:<br/><br/>'
  290. '+ <i><a href="/add/?url={}" target="_top">Add a new Snapshot for <code>{}</code></a><br/><br/></i>'
  291. '</center>'
  292. ),
  293. base_url(path),
  294. path if '://' in path else f'https://{path}',
  295. path,
  296. ),
  297. content_type="text/html",
  298. status=404,
  299. )
  300. except Snapshot.MultipleObjectsReturned:
  301. snapshot_hrefs = mark_safe('<br/>').join(
  302. format_html(
  303. '{} <code style="font-size: 0.8em">{}</code> <a href="/archive/{}/index.html"><b><code>{}</code></b></a> {} <b>{}</b>',
  304. snap.bookmarked_at.strftime('%Y-%m-%d %H:%M:%S'),
  305. snap.abid,
  306. snap.timestamp,
  307. snap.timestamp,
  308. snap.url,
  309. snap.title_stripped[:64] or '',
  310. )
  311. for snap in Snapshot.objects.filter(
  312. Q(url__startswith='http://' + base_url(path)) | Q(url__startswith='https://' + base_url(path))
  313. | Q(abid__icontains=path) | Q(id__icontains=path)
  314. ).only('url', 'timestamp', 'title', 'bookmarked_at').order_by('-bookmarked_at')
  315. )
  316. return HttpResponse(
  317. format_html(
  318. (
  319. 'Multiple Snapshots match the given URL <code>{}</code><br/><pre>'
  320. ),
  321. base_url(path),
  322. ) + snapshot_hrefs + format_html(
  323. (
  324. '</pre><br/>'
  325. 'Choose a Snapshot to proceed or go back to the <a href="/" target="_top">Main Index</a>'
  326. )
  327. ),
  328. content_type="text/html",
  329. status=404,
  330. )
  331. class PublicIndexView(ListView):
  332. template_name = 'public_index.html'
  333. model = Snapshot
  334. paginate_by = SERVER_CONFIG.SNAPSHOTS_PER_PAGE
  335. ordering = ['-bookmarked_at', '-created_at']
  336. def get_context_data(self, **kwargs):
  337. return {
  338. **super().get_context_data(**kwargs),
  339. 'VERSION': VERSION,
  340. 'COMMIT_HASH': SHELL_CONFIG.COMMIT_HASH,
  341. 'FOOTER_INFO': SERVER_CONFIG.FOOTER_INFO,
  342. }
  343. def get_queryset(self, **kwargs):
  344. qs = super().get_queryset(**kwargs)
  345. query = self.request.GET.get('q', default = '').strip()
  346. if not query:
  347. return qs.distinct()
  348. query_type = self.request.GET.get('query_type')
  349. if not query_type or query_type == 'all':
  350. qs = qs.filter(Q(title__icontains=query) | Q(url__icontains=query) | Q(timestamp__icontains=query) | Q(tags__name__icontains=query))
  351. try:
  352. qs = qs | query_search_index(query)
  353. except Exception as err:
  354. print(f'[!] Error while using search backend: {err.__class__.__name__} {err}')
  355. elif query_type == 'fulltext':
  356. try:
  357. qs = qs | query_search_index(query)
  358. except Exception as err:
  359. print(f'[!] Error while using search backend: {err.__class__.__name__} {err}')
  360. elif query_type == 'meta':
  361. qs = qs.filter(Q(title__icontains=query) | Q(url__icontains=query) | Q(timestamp__icontains=query) | Q(tags__name__icontains=query))
  362. elif query_type == 'url':
  363. qs = qs.filter(Q(url__icontains=query))
  364. elif query_type == 'title':
  365. qs = qs.filter(Q(title__icontains=query))
  366. elif query_type == 'timestamp':
  367. qs = qs.filter(Q(timestamp__icontains=query))
  368. elif query_type == 'tags':
  369. qs = qs.filter(Q(tags__name__icontains=query))
  370. else:
  371. print(f'[!] Unknown value for query_type: "{query_type}"')
  372. return qs.distinct()
  373. def get(self, *args, **kwargs):
  374. if SERVER_CONFIG.PUBLIC_INDEX or self.request.user.is_authenticated:
  375. response = super().get(*args, **kwargs)
  376. return response
  377. else:
  378. return redirect(f'/admin/login/?next={self.request.path}')
  379. @method_decorator(csrf_exempt, name='dispatch')
  380. class AddView(UserPassesTestMixin, FormView):
  381. template_name = "add.html"
  382. form_class = AddLinkForm
  383. def get_initial(self):
  384. """Prefill the AddLinkForm with the 'url' GET parameter"""
  385. if self.request.method == 'GET':
  386. url = self.request.GET.get('url', None)
  387. if url:
  388. return {'url': url if '://' in url else f'https://{url}'}
  389. return super().get_initial()
  390. def test_func(self):
  391. return SERVER_CONFIG.PUBLIC_ADD_VIEW or self.request.user.is_authenticated
  392. def get_context_data(self, **kwargs):
  393. return {
  394. **super().get_context_data(**kwargs),
  395. 'title': "Add URLs",
  396. # We can't just call request.build_absolute_uri in the template, because it would include query parameters
  397. 'absolute_add_path': self.request.build_absolute_uri(self.request.path),
  398. 'VERSION': VERSION,
  399. 'FOOTER_INFO': SERVER_CONFIG.FOOTER_INFO,
  400. 'stdout': '',
  401. }
  402. def form_valid(self, form):
  403. urls = form.cleaned_data["url"]
  404. print(f'[+] Adding URL: {urls}')
  405. parser = form.cleaned_data["parser"]
  406. tag = form.cleaned_data["tag"]
  407. depth = 0 if form.cleaned_data["depth"] == "0" else 1
  408. extractors = ','.join(form.cleaned_data["archive_methods"])
  409. input_kwargs = {
  410. "urls": urls,
  411. "tag": tag,
  412. "depth": depth,
  413. "parser": parser,
  414. "update_all": False,
  415. "out_dir": DATA_DIR,
  416. "created_by_id": self.request.user.pk,
  417. }
  418. if extractors:
  419. input_kwargs.update({"extractors": extractors})
  420. from archivebox.config.permissions import HOSTNAME
  421. # 1. save the provided urls to sources/2024-11-05__23-59-59__web_ui_add_by_user_<user_pk>.txt
  422. sources_file = CONSTANTS.SOURCES_DIR / f'{timezone.now().strftime("%Y-%m-%d__%H-%M-%S")}__web_ui_add_by_user_{self.request.user.pk}.txt'
  423. sources_file.write_text(urls if isinstance(urls, str) else '\n'.join(urls))
  424. # 2. create a new Seed pointing to the sources/2024-11-05__23-59-59__web_ui_add_by_user_<user_pk>.txt
  425. seed = Seed.from_file(
  426. sources_file,
  427. label=f'{self.request.user.username}@{HOSTNAME}{self.request.path}',
  428. parser=parser,
  429. tag=tag,
  430. created_by=self.request.user.pk,
  431. config={
  432. # 'ONLY_NEW': not update,
  433. # 'INDEX_ONLY': index_only,
  434. # 'OVERWRITE': False,
  435. 'DEPTH': depth,
  436. 'EXTRACTORS': parser,
  437. # 'DEFAULT_PERSONA': persona or 'Default',
  438. })
  439. # 3. create a new Crawl pointing to the Seed
  440. crawl = Crawl.from_seed(seed, max_depth=depth)
  441. # 4. start the Orchestrator & wait until it completes
  442. # ... orchestrator will create the root Snapshot, which creates pending ArchiveResults, which gets run by the ArchiveResultActors ...
  443. # from crawls.actors import CrawlActor
  444. # from core.actors import SnapshotActor, ArchiveResultActor
  445. rough_url_count = urls.count('://')
  446. messages.success(
  447. self.request,
  448. mark_safe(f"Adding {rough_url_count} URLs in the background. (refresh in a minute start seeing results) {crawl.admin_change_url}"),
  449. )
  450. # if not bg:
  451. # from workers.orchestrator import Orchestrator
  452. # orchestrator = Orchestrator(exit_on_idle=True, max_concurrent_actors=4)
  453. # orchestrator.start()
  454. return redirect(crawl.admin_change_url)
  455. class HealthCheckView(View):
  456. """
  457. A Django view that renders plain text "OK" for service discovery tools
  458. """
  459. def get(self, request):
  460. """
  461. Handle a GET request
  462. """
  463. return HttpResponse(
  464. 'OK',
  465. content_type='text/plain',
  466. status=200
  467. )
  468. def find_config_section(key: str) -> str:
  469. CONFIGS = archivebox.pm.hook.get_CONFIGS()
  470. if key in CONSTANTS_CONFIG:
  471. return 'CONSTANT'
  472. matching_sections = [
  473. section_id for section_id, section in CONFIGS.items() if key in dict(section)
  474. ]
  475. section = matching_sections[0] if matching_sections else 'DYNAMIC'
  476. return section
  477. def find_config_default(key: str) -> str:
  478. CONFIGS = archivebox.pm.hook.get_CONFIGS()
  479. if key in CONSTANTS_CONFIG:
  480. return str(CONSTANTS_CONFIG[key])
  481. default_val = None
  482. for config in CONFIGS.values():
  483. if key in dict(config):
  484. default_field = getattr(config, 'model_fields', dict(config))[key]
  485. default_val = default_field.default if hasattr(default_field, 'default') else default_field
  486. break
  487. if isinstance(default_val, Callable):
  488. default_val = inspect.getsource(default_val).split('lambda', 1)[-1].split(':', 1)[-1].replace('\n', ' ').strip()
  489. if default_val.count(')') > default_val.count('('):
  490. default_val = default_val[:-1]
  491. else:
  492. default_val = str(default_val)
  493. return default_val
  494. def find_config_type(key: str) -> str:
  495. CONFIGS = archivebox.pm.hook.get_CONFIGS()
  496. for config in CONFIGS.values():
  497. if hasattr(config, key):
  498. type_hints = get_type_hints(config)
  499. try:
  500. return str(type_hints[key].__name__)
  501. except AttributeError:
  502. return str(type_hints[key])
  503. return 'str'
  504. def key_is_safe(key: str) -> bool:
  505. for term in ('key', 'password', 'secret', 'token'):
  506. if term in key.lower():
  507. return False
  508. return True
  509. @render_with_table_view
  510. def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
  511. CONFIGS = archivebox.pm.hook.get_CONFIGS()
  512. assert request.user.is_superuser, 'Must be a superuser to view configuration settings.'
  513. rows = {
  514. "Section": [],
  515. "Key": [],
  516. "Type": [],
  517. "Value": [],
  518. "Default": [],
  519. # "Documentation": [],
  520. # "Aliases": [],
  521. }
  522. for section_id, section in reversed(list(CONFIGS.items())):
  523. for key in dict(section).keys():
  524. rows['Section'].append(section_id) # section.replace('_', ' ').title().replace(' Config', '')
  525. rows['Key'].append(ItemLink(key, key=key))
  526. rows['Type'].append(format_html('<code>{}</code>', find_config_type(key)))
  527. rows['Value'].append(mark_safe(f'<code>{getattr(section, key)}</code>') if key_is_safe(key) else '******** (redacted)')
  528. 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>'))
  529. # rows['Documentation'].append(mark_safe(f'Wiki: <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#{key.lower()}">{key}</a>'))
  530. # rows['Aliases'].append(', '.join(find_config_aliases(key)))
  531. section = 'CONSTANT'
  532. for key in CONSTANTS_CONFIG.keys():
  533. rows['Section'].append(section) # section.replace('_', ' ').title().replace(' Config', '')
  534. rows['Key'].append(ItemLink(key, key=key))
  535. rows['Type'].append(format_html('<code>{}</code>', getattr(type(CONSTANTS_CONFIG[key]), '__name__', str(CONSTANTS_CONFIG[key]))))
  536. rows['Value'].append(format_html('<code>{}</code>', CONSTANTS_CONFIG[key]) if key_is_safe(key) else '******** (redacted)')
  537. 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>'))
  538. # rows['Documentation'].append(mark_safe(f'Wiki: <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#{key.lower()}">{key}</a>'))
  539. # rows['Aliases'].append('')
  540. return TableContext(
  541. title="Computed Configuration Values",
  542. table=rows,
  543. )
  544. @render_with_item_view
  545. def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemContext:
  546. CONFIGS = archivebox.pm.hook.get_CONFIGS()
  547. FLAT_CONFIG = archivebox.pm.hook.get_FLAT_CONFIG()
  548. assert request.user.is_superuser, 'Must be a superuser to view configuration settings.'
  549. # aliases = USER_CONFIG.get(key, {}).get("aliases", [])
  550. aliases = []
  551. if key in CONSTANTS_CONFIG:
  552. section_header = mark_safe(f'[CONSTANTS] &nbsp; <b><code style="color: lightgray">{key}</code></b> &nbsp; <small>(read-only, hardcoded by ArchiveBox)</small>')
  553. elif key in FLAT_CONFIG:
  554. section_header = mark_safe(f'data / ArchiveBox.conf &nbsp; [{find_config_section(key)}] &nbsp; <b><code style="color: lightgray">{key}</code></b>')
  555. else:
  556. section_header = mark_safe(f'[DYNAMIC CONFIG] &nbsp; <b><code style="color: lightgray">{key}</code></b> &nbsp; <small>(read-only, calculated at runtime)</small>')
  557. return ItemContext(
  558. slug=key,
  559. title=key,
  560. data=[
  561. {
  562. "name": section_header,
  563. "description": None,
  564. "fields": {
  565. 'Key': key,
  566. 'Type': find_config_type(key),
  567. 'Value': FLAT_CONFIG.get(key, CONFIGS.get(key, None)) if key_is_safe(key) else '********',
  568. },
  569. "help_texts": {
  570. 'Key': mark_safe(f'''
  571. <a href="https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#{key.lower()}">Documentation</a> &nbsp;
  572. <span style="display: {"inline" if aliases else "none"}">
  573. Aliases: {", ".join(aliases)}
  574. </span>
  575. '''),
  576. 'Type': mark_safe(f'''
  577. <a href="https://github.com/search?q=repo%3AArchiveBox%2FArchiveBox+path%3Aconfig+{key}&type=code">
  578. See full definition in <code>archivebox/config</code>...
  579. </a>
  580. '''),
  581. 'Value': mark_safe(f'''
  582. {'<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 not key_is_safe(key) else ''}
  583. <br/><hr/><br/>
  584. Default: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
  585. <a href="https://github.com/search?q=repo%3AArchiveBox%2FArchiveBox+path%3Aconfig+{key}&type=code">
  586. <code>{find_config_default(key) or '↗️ See in ArchiveBox source code...'}</code>
  587. </a>
  588. <br/><br/>
  589. <p style="display: {"block" if key in FLAT_CONFIG and key not in CONSTANTS_CONFIG else "none"}">
  590. <i>To change this value, edit <code>data/ArchiveBox.conf</code> or run:</i>
  591. <br/><br/>
  592. <code>archivebox config --set {key}="{
  593. val.strip("'")
  594. if (val := find_config_default(key)) else
  595. (str(FLAT_CONFIG[key] if key_is_safe(key) else '********')).strip("'")
  596. }"</code>
  597. </p>
  598. '''),
  599. },
  600. },
  601. ],
  602. )