wrolpi/docker/archive/main.py
lrnselfreliance f1270b4e6c
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
archive: raise archive service request limit to 500MB (#692)
Uploaded singlefiles are base64-encoded into JSON before being sent to the
archive container, so an 88MB upload becomes a ~118MB request.  Sanic's default
REQUEST_MAX_SIZE of 100MB rejected it with a 413 before the handler ran, and the
API surfaced this as KeyError: 'singlefile'.

Only the Docker deployment is affected; Debian/RPi extract in-process.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 20:09:35 -06:00

394 lines
15 KiB
Python

#! /usr/bin/env python3
"""
This file is a simple Python/Sanic wrapper around the single-file CLI command.
"""
import asyncio
import base64
import gzip
import io
import json
import logging
import os
import os.path
import pathlib
import signal
import subprocess
import sys
import tempfile
import traceback
import zipfile
from json import JSONDecodeError
from sanic import Sanic, response
from sanic.request import Request
# Log using datetime and log level. Log to stdout.
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
ch = logging.StreamHandler()
formatter = logging.Formatter('[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s')
ch.setFormatter(formatter)
logger.addHandler(ch)
def find_singlefile_deno_script() -> pathlib.Path:
"""Find the Deno entry script of the single-file-cli package.
single-file-cli is designed for Deno; the npm bin (single-file-node.js) runs under node which
cannot reach the browser CDP on IPv6-enabled hosts and never exits after a successful save."""
candidates = (
'/usr/src/app/node_modules/single-file-cli/single-file',
'/usr/local/lib/node_modules/single-file-cli/single-file',
'/usr/lib/node_modules/single-file-cli/single-file',
# Resolve the bin symlink back to the package directory.
'/usr/src/.nvm/versions/node/v18.19.0/bin/single-file',
'/usr/local/bin/single-file',
)
for candidate in candidates:
path = pathlib.Path(candidate)
if path.is_file():
script = path.resolve().parent / 'single-file'
try:
with script.open('rb') as fh:
if b'deno run' in fh.readline():
return script
except OSError:
pass
raise FileNotFoundError("Can't find single-file Deno script!")
SINGLEFILE_PATH = find_singlefile_deno_script()
DENO_PATH = pathlib.Path('/usr/local/bin/deno')
if not DENO_PATH.is_file():
raise FileNotFoundError("Can't find deno executable!")
# single-file pipes the browser's stderr but never reads it; under Deno >= 2.3 that kills the
# browser during startup. The wrapper discards the browser's stderr.
BROWSER_WRAPPER = pathlib.Path('/app/singlefile_browser_wrapper.sh')
if not BROWSER_WRAPPER.is_file():
raise FileNotFoundError("Can't find singlefile_browser_wrapper.sh!")
BROWSER_EXEC = pathlib.Path('/usr/bin/google-chrome')
if not BROWSER_EXEC.is_file():
print('Unable to find browser!', file=sys.stderr)
sys.exit(1)
# Increase response timeout, archiving can take several minutes.
RESPONSE_TIMEOUT = 10 * 60
# The API sends uploaded singlefiles base64-encoded in JSON (a 33% size increase). Sanic's default
# 100MB limit rejects large uploads with a 413 before the handler runs.
REQUEST_MAX_SIZE = 500 * 1024 * 1024
config = {
'RESPONSE_TIMEOUT': RESPONSE_TIMEOUT,
'REQUEST_MAX_SIZE': REQUEST_MAX_SIZE,
}
app = Sanic('archive')
app.update_config(config)
index_html = '''
<html>
<body>
<h2>This is the WROLPi archiving service.</h2>
<p>
This is not meant to be used directly, it should be called by the
archive module.
<p>
</body>
</html>
'''
@app.get('/')
async def index(_):
return response.html(index_html)
async def check_output(cmd, always_log_stderr: bool = False, cwd=None, timeout: float = None):
proc = None
try:
# start_new_session so the whole process group can be killed; single-file does not close
# the browser it spawns.
proc = await asyncio.create_subprocess_shell(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd,
start_new_session=True)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
if always_log_stderr is True or proc.returncode != 0:
# Always log when requested, or when the call failed.
for line in stderr.decode().splitlines():
logger.error(line)
return stdout, stderr, proc.returncode
except asyncio.TimeoutError:
proc.kill()
await proc.wait() # Wait for the process to fully terminate
raise TimeoutError(f"Command '{cmd}' timed out after {timeout} seconds")
finally:
if proc is not None:
# Kill anything the command left behind (e.g. the browser single-file spawns).
try:
os.killpg(proc.pid, signal.SIGKILL)
logger.info(f'Killed lingering process group {proc.pid}')
except ProcessLookupError:
pass # Nothing left behind; the common case.
except PermissionError as e:
# Do not let the kill mask the real failure (e.g. a TimeoutError in flight).
logger.error(f'Failed to kill process group {proc.pid}', exc_info=e)
async def run_single_file(input_url: str, output_path: pathlib.Path, compress: bool):
"""Run single-file, return (return_code, output_bytes, stderr).
single-file writes the result to a file; Deno >= 2.3 truncates large --dump-content output
at exit, which corrupted compressed (ZIP) singlefiles."""
cmd = f'WROLPI_BROWSER={BROWSER_EXEC}' \
f' {DENO_PATH} run --node-modules-dir=manual' \
' --allow-read --allow-write --allow-net --allow-env --allow-run' \
f' {SINGLEFILE_PATH}' \
f' --browser-executable-path {BROWSER_WRAPPER}' \
f'{" --compress-content " if compress else ""}' \
f' "{input_url}"' \
f' "{output_path}"'
logger.debug(f'archive cmd: {cmd}')
stdout, stderr, return_code = await check_output(cmd, always_log_stderr=True, timeout=3 * 60)
output = output_path.read_bytes() if output_path.is_file() else b''
return return_code, output, stderr
async def call_single_file(url, compress: bool = False) -> (bytes, bytes):
"""
Call the CLI command for SingleFile. Returns (singlefile, page_html): the singlefile to
store, and the uncompressed page for readability/screenshot.
The page is always captured uncompressed; when compress is set, a second single-file run
converts the captured file into a compressed (SingleFileZ) self-extracting file (a local
file:// operation, the page is not fetched again). If the conversion fails, the
uncompressed singlefile is returned instead.
See https://github.com/gildas-lormeau/SingleFile
"""
logger.info(f'archiving {url}')
with tempfile.TemporaryDirectory(prefix='singlefile-') as tmp_dir:
page_path = pathlib.Path(tmp_dir) / 'page.html'
return_code, page_html, stderr = await run_single_file(url, page_path, compress=False)
if return_code != 0 or not page_html:
raise RuntimeError(f'Failed to single-file {url} got the following error: {stderr}')
singlefile = page_html
if compress:
compressed_path = pathlib.Path(tmp_dir) / 'compressed.html'
return_code, compressed, stderr = await run_single_file(
f'file://{page_path}', compressed_path, compress=True)
if return_code == 0 and zipfile.is_zipfile(io.BytesIO(compressed)):
singlefile = compressed
else:
logger.error(f'Failed to compress singlefile ({return_code=}), storing it uncompressed: {url}')
logger.debug(f'done archiving for {url}')
return singlefile, page_html
async def extract_readability(path: str, url: str) -> dict:
"""
Call the CLI command for readability-extractor.
See https://github.com/ArchiveBox/readability-extractor
"""
logger.info(f'readability for {url}')
cmd = f'readability-extractor {path} "{url}"'
logger.debug(f'readability cmd: {cmd}')
stdout, stderr, return_code = await check_output(cmd, timeout=3 * 60)
try:
output = json.loads(stdout)
except JSONDecodeError as e:
stderr = stderr.decode() if stderr else None
raise RuntimeError(f'Failed to extract readability. {stderr}') from e
logger.debug(f'done readability for {url}')
return output
async def take_screenshot(url: str) -> bytes:
cmd = f'{BROWSER_EXEC}' \
' --headless' \
' --disable-gpu' \
' --no-sandbox' \
' --screenshot' \
' --window-size=1280x720' \
f' "{url}"'
logger.debug(f'Screenshot cmd: {cmd}')
with tempfile.TemporaryDirectory() as tmp_dir:
try:
stdout, stderr, return_code = await check_output(cmd, cwd=tmp_dir, timeout=1 * 60)
if return_code != 0:
raise ValueError(f'Screenshot failed {return_code=}')
except Exception as e:
logger.error(f'Failed to screenshot {url}', exc_info=e)
return b''
path = pathlib.Path(f'{tmp_dir}/screenshot.png')
if not path.is_file():
logger.warning(f'Screenshot command did not create screenshot of {url}')
return b''
size = os.path.getsize(path)
logger.info(f'Successful screenshot of {url} ({size} bytes) at {path}')
png = path.read_bytes()
return png
def extract_compressed_singlefile(singlefile: bytes, tmp_dir: str) -> str:
"""A compressed (SingleFileZ) singlefile is also a valid ZIP containing the uncompressed page as
index.html (plus its resources). Extract it so readability/screenshot can use the real HTML.
Returns the path to the extracted index.html, or an empty string if the singlefile is not compressed.
"""
buf = io.BytesIO(singlefile)
if not zipfile.is_zipfile(buf):
return ''
try:
with zipfile.ZipFile(buf) as zip_:
if 'index.html' not in zip_.namelist():
return ''
extracted = pathlib.Path(tmp_dir) / 'extracted'
extracted.mkdir(exist_ok=True)
zip_.extractall(extracted)
return str(extracted / 'index.html')
except zipfile.BadZipFile:
return ''
def prepare_bytes(b: bytes) -> str:
"""
Compress and encode bytes for smaller response.
"""
b = gzip.compress(b)
b = base64.b64encode(b)
b = b.decode()
return b
@app.post('/screenshot')
async def post_screenshot(request: Request):
"""Generate a screenshot for the provided singlefile."""
url = request.json['url']
singlefile = request.json.get('singlefile')
try:
logger.info(f'Generating screenshot for {url}')
# Decode and decompress the singlefile
if singlefile:
singlefile = base64.b64decode(singlefile)
singlefile = gzip.decompress(singlefile)
if not singlefile:
raise ValueError(f'No singlefile provided for {url}')
# Write singlefile to temp file and screenshot it
# Use html suffix so chrome screenshot recognizes it as an HTML file
with tempfile.TemporaryDirectory() as tmp_dir:
singlefile_path = pathlib.Path(tmp_dir) / 'singlefile.html'
singlefile_path.write_bytes(singlefile)
# A compressed (SingleFileZ) singlefile must be extracted before it can be screenshot.
extractable = extract_compressed_singlefile(singlefile, tmp_dir) or str(singlefile_path)
screenshot = None
try:
# Screenshot the local singlefile
screenshot = await take_screenshot(f'file://{extractable}')
except Exception as e:
logger.error(f'Failed to take screenshot of {extractable}', exc_info=e)
# Fall back to URL if local screenshot failed
if not screenshot:
logger.warning(f'Failed to screenshot local singlefile, attempting to screenshot URL: {url}')
try:
screenshot = await take_screenshot(url)
except Exception as e:
logger.error(f'Failed to take screenshot of {url}', exc_info=e)
if not screenshot:
raise ValueError(f'Failed to generate screenshot for {url}')
# Compress for smaller response
screenshot = prepare_bytes(screenshot)
ret = dict(
url=url,
screenshot=screenshot,
)
return response.json(ret)
except Exception as e:
logger.error(f'Failed to generate screenshot for {url}', exc_info=e)
error = str(traceback.format_exc())
return response.json({'error': f'Failed to generate screenshot for {url} traceback is below... \n\n {error}'})
@app.post('/json')
async def post_archive(request: Request):
url = request.json['url']
try:
# May have been passed the singlefile contents, do the extractions and return.
singlefile = request.json.get('singlefile')
singlefile = base64.b64decode(singlefile) if singlefile else None
compress = bool(request.json.get('compress'))
if singlefile:
# A provided singlefile may be compressed (SingleFileZ); readability and screenshot
# must use the extracted index.html.
page_html = None
else:
# Fetch and create from the internet; readability/screenshot use the uncompressed page.
singlefile, page_html = await call_single_file(url, compress=compress)
# Use html suffix so chrome screenshot recognizes it as an HTML file.
with tempfile.TemporaryDirectory() as tmp_dir:
page_path = pathlib.Path(tmp_dir) / 'page.html'
if page_html:
page_path.write_bytes(page_html)
extractable = str(page_path)
else:
page_path.write_bytes(singlefile)
extractable = extract_compressed_singlefile(singlefile, tmp_dir) or str(page_path)
readability = None
try:
readability = await extract_readability(extractable, url)
except Exception as e:
# Readability had error, but its is not required.
logger.error(f'Failed to get readability', exc_info=e)
screenshot = None
try:
# Screenshot the local singlefile, if that fails try the URL.
screenshot = await take_screenshot(f'file://{extractable}')
except Exception as e:
logger.error(f'Failed to take screenshot of {extractable}', exc_info=e)
if not screenshot:
logger.warning(f'Failed to screenshot local singlefile attempting to screenshot: {url}')
try:
screenshot = await take_screenshot(url)
except Exception as e:
logger.error(f'Failed to take screenshot of {url}', exc_info=e)
# Compress for smaller response.
singlefile = prepare_bytes(singlefile)
screenshot = prepare_bytes(screenshot)
ret = dict(
url=url,
singlefile=singlefile,
readability=readability,
screenshot=screenshot,
)
return response.json(ret)
except Exception as e:
logger.error(f'Failed to archive {url}', exc_info=e)
error = str(traceback.format_exc())
return response.json({'error': f'Failed to archive {url} traceback is below... \n\n {error}'})
if __name__ == '__main__':
app.run('0.0.0.0', 8080, workers=4, auto_reload=True)