__init__
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# SiteHarbor runtime state
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Editor and operating-system files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# SiteHarbor
|
||||||
|
|
||||||
|
SiteHarbor is a standalone Python web-capture service. It creates a bounded offline archive of a public website, preserves discovered static assets, rewrites supported local links, records a manifest, and keeps completed ZIP files outside the public web root.
|
||||||
|
|
||||||
|
It is intentionally designed as a local or trusted-network application. It uses SQLite for durable job state and does not require Redis, a queue server, or object storage.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser -> FastAPI -> SQLite job queue -> local capture worker
|
||||||
|
-> private local ZIP and JSON report -> browser-scoped download route
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser creates an opaque local session key on first use. It is stored in local storage and
|
||||||
|
scopes capture status, cancellation, reports, and downloads to that browser. The worker and SQLite
|
||||||
|
job continue independently when the page disconnects; reopening the page reconnects to locally
|
||||||
|
recorded sessions. Clearing browser storage intentionally removes access to those sessions.
|
||||||
|
|
||||||
|
The capture engine:
|
||||||
|
|
||||||
|
- Crawls same-site documents within configured page, depth, size, and time budgets.
|
||||||
|
- Captures linked CSS, JavaScript, image, font, media, manifest, SVG, iframe, and CSS-import resources.
|
||||||
|
- Can include linked third-party assets while avoiding third-party document crawling.
|
||||||
|
- Rewrites supported HTML, CSS, and static JavaScript references to local archive paths.
|
||||||
|
- Produces `siteharbor-manifest.json` and `README.txt` inside each archive.
|
||||||
|
- Reports skipped resources, limits, failures, redirects, and static-capture caveats.
|
||||||
|
|
||||||
|
No static crawler can guarantee a fully functioning offline copy of every modern web application. Dynamic API calls, authenticated content, service workers, CAPTCHAs, backend state, and runtime-generated resources need a separately isolated browser-rendered mode in a later release.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Use Python 3.11 or newer.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
py -3.11 -m venv .venv
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install -e .[dev]
|
||||||
|
siteharbor
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://127.0.0.1:8787`.
|
||||||
|
|
||||||
|
To use Uvicorn directly:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m uvicorn app.main:app --host 127.0.0.1 --port 8787
|
||||||
|
```
|
||||||
|
|
||||||
|
The default bind address is loopback. Do not expose SiteHarbor directly to the public Internet without authentication, a network egress policy, rate limits, and operational controls.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration uses environment variables prefixed with `SITEHARBOR_`.
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SITEHARBOR_HOST` | `127.0.0.1` | Bind address used by `siteharbor`. |
|
||||||
|
| `SITEHARBOR_PORT` | `8787` | Bind port used by `siteharbor`. |
|
||||||
|
| `SITEHARBOR_DATA_DIR` | `./data` | SQLite database, work directories, artifacts, and reports. |
|
||||||
|
| `SITEHARBOR_WORKER_CONCURRENCY` | `1` | Capture workers per application process. |
|
||||||
|
| `SITEHARBOR_FETCH_CONCURRENCY` | `6` | Maximum parallel fetches a visitor may select for one capture. |
|
||||||
|
| `SITEHARBOR_LEASE_SECONDS` | `120` | SQLite worker lease duration; values below 15 seconds are rejected. |
|
||||||
|
| `SITEHARBOR_ALLOW_PRIVATE_NETWORKS` | `false` | Development-only override that permits loopback and private hosts. |
|
||||||
|
| `SITEHARBOR_ALLOW_NONSTANDARD_PORTS` | `false` | Development-only override for ports other than 80 and 443. |
|
||||||
|
| `SITEHARBOR_RESPECT_ROBOTS` | `true` | Respect robots rules during capture. |
|
||||||
|
| `SITEHARBOR_DEFAULT_RETENTION_DAYS` | `7` | Default retention period for completed artifacts. |
|
||||||
|
| `SITEHARBOR_PROXY_URL` | unset | Operator-approved HTTP(S) proxy route. Visitors can only opt into this configured route; they cannot supply proxy endpoints. |
|
||||||
|
|
||||||
|
For a local fixture site on `localhost:8000`, use both development overrides:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:SITEHARBOR_ALLOW_PRIVATE_NETWORKS = "true"
|
||||||
|
$env:SITEHARBOR_ALLOW_NONSTANDARD_PORTS = "true"
|
||||||
|
siteharbor
|
||||||
|
```
|
||||||
|
|
||||||
|
## SQLite and Uvicorn Workers
|
||||||
|
|
||||||
|
SiteHarbor uses SQLite WAL mode and a lease-based database claim to run jobs without Redis. A single Uvicorn process with `SITEHARBOR_WORKER_CONCURRENCY=1` is the recommended standalone configuration.
|
||||||
|
|
||||||
|
Multiple Uvicorn processes can claim jobs from the same local SQLite database, but SQLite still permits only one writer at a time. Use modest concurrency and shared local storage only. This design is intentionally for standalone or small trusted deployments, not high-scale public crawling.
|
||||||
|
|
||||||
|
## Safety Boundaries
|
||||||
|
|
||||||
|
The application blocks non-HTTP(S) targets, URL credentials, nonstandard ports by default, and private/reserved network destinations. It revalidates and pins each request target to validated numeric addresses during crawling and redirects, avoiding an uncontrolled second DNS lookup at connection time.
|
||||||
|
|
||||||
|
If the service is ever exposed beyond localhost, still route worker traffic through a controlled public-only egress gateway and add authentication, quotas, and rate limiting. The standalone safeguards are intentionally strong, but a network boundary remains defense in depth for a public service.
|
||||||
|
|
||||||
|
## Browser Sessions and Public Metrics
|
||||||
|
|
||||||
|
- No account, login, or signup flow is required.
|
||||||
|
- The public page exposes aggregate counts only: successful captures, data collected, active crawls, and files preserved.
|
||||||
|
- Private capture routes require the opaque browser-local session key. A different browser cannot list, inspect, cancel, delete, download, or open events for another browser's captures.
|
||||||
|
- This local session key provides browser ownership, not public-service abuse protection. A public deployment still needs rate limits, quotas, egress controls, and monitoring.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pytest
|
||||||
|
python -m ruff check .
|
||||||
|
python -m compileall app
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""SiteHarbor application package."""
|
||||||
+302
@@ -0,0 +1,302 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.crawler import (
|
||||||
|
DEFAULT_ASSET_TYPES,
|
||||||
|
CaptureCancelled,
|
||||||
|
CaptureError,
|
||||||
|
CaptureOptions,
|
||||||
|
SiteCrawler,
|
||||||
|
)
|
||||||
|
from app.db import Database
|
||||||
|
from app.url_policy import NetworkRules, UrlPolicyError, resolve_and_validate
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_tree(path: Path, allowed_root: Path) -> None:
|
||||||
|
"""Delete only a child path beneath the configured work root."""
|
||||||
|
root = allowed_root.resolve()
|
||||||
|
resolved = path.resolve()
|
||||||
|
if resolved == root or not resolved.is_relative_to(root):
|
||||||
|
raise RuntimeError("Refusing to remove a path outside SiteHarbor's work directory.")
|
||||||
|
shutil.rmtree(resolved, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_archive(
|
||||||
|
source_dir: Path,
|
||||||
|
destination: Path,
|
||||||
|
archive_root: str,
|
||||||
|
cancelled: Callable[[], bool],
|
||||||
|
deadline_exceeded: Callable[[], bool],
|
||||||
|
heartbeat: Callable[[], None],
|
||||||
|
) -> int:
|
||||||
|
partial = destination.with_suffix(".zip.partial")
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
|
last_heartbeat = time.monotonic()
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(
|
||||||
|
partial,
|
||||||
|
mode="w",
|
||||||
|
compression=zipfile.ZIP_DEFLATED,
|
||||||
|
compresslevel=6,
|
||||||
|
strict_timestamps=False,
|
||||||
|
) as archive:
|
||||||
|
for source_path in sorted(source_dir.rglob("*")):
|
||||||
|
if cancelled():
|
||||||
|
raise CaptureCancelled("Capture cancelled while packaging the archive")
|
||||||
|
if deadline_exceeded():
|
||||||
|
raise CaptureError(
|
||||||
|
"The configured capture time limit was reached while packaging"
|
||||||
|
)
|
||||||
|
if time.monotonic() - last_heartbeat >= 5:
|
||||||
|
heartbeat()
|
||||||
|
last_heartbeat = time.monotonic()
|
||||||
|
if not source_path.is_file() or source_path.is_symlink():
|
||||||
|
continue
|
||||||
|
relative = source_path.relative_to(source_dir).as_posix()
|
||||||
|
with source_path.open("rb") as source_file:
|
||||||
|
with archive.open(f"{archive_root}/{relative}", mode="w") as archive_file:
|
||||||
|
while chunk := source_file.read(1_024 * 1_024):
|
||||||
|
if cancelled():
|
||||||
|
raise CaptureCancelled(
|
||||||
|
"Capture cancelled while packaging the archive"
|
||||||
|
)
|
||||||
|
if deadline_exceeded():
|
||||||
|
raise CaptureError(
|
||||||
|
"The configured capture time limit was reached while packaging"
|
||||||
|
)
|
||||||
|
if time.monotonic() - last_heartbeat >= 5:
|
||||||
|
heartbeat()
|
||||||
|
last_heartbeat = time.monotonic()
|
||||||
|
archive_file.write(chunk)
|
||||||
|
os.replace(partial, destination)
|
||||||
|
return destination.stat().st_size
|
||||||
|
except Exception:
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
|
destination.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureService:
|
||||||
|
"""Runs one leased capture and persists a terminal SQLite job state."""
|
||||||
|
|
||||||
|
def __init__(self, database: Database, settings: Settings, worker_id: str) -> None:
|
||||||
|
self.database = database
|
||||||
|
self.settings = settings
|
||||||
|
self.worker_id = worker_id
|
||||||
|
|
||||||
|
def run(self, job: dict[str, Any]) -> None:
|
||||||
|
job_id = str(job["id"])
|
||||||
|
config = dict(job["config"])
|
||||||
|
attempt_id = uuid.uuid4().hex[:12]
|
||||||
|
work_dir = self.settings.work_dir / f"{job_id}-{attempt_id}"
|
||||||
|
artifact_path = self.settings.artifacts_dir / f"{job_id}-{attempt_id}.zip"
|
||||||
|
report_path = self.settings.reports_dir / f"{job_id}-{attempt_id}.json"
|
||||||
|
heartbeat_stop = threading.Event()
|
||||||
|
lease_lost = threading.Event()
|
||||||
|
heartbeat_thread: threading.Thread | None = None
|
||||||
|
capture_deadline = time.monotonic() + int(config["max_duration_seconds"])
|
||||||
|
|
||||||
|
def capture_cancelled() -> bool:
|
||||||
|
return (
|
||||||
|
lease_lost.is_set()
|
||||||
|
or self.database.is_cancel_requested(job_id)
|
||||||
|
or not self.database.owns_lease(job_id, self.worker_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def renew_lease() -> None:
|
||||||
|
interval = min(20, max(3, self.settings.lease_seconds // 3))
|
||||||
|
while not heartbeat_stop.wait(interval):
|
||||||
|
try:
|
||||||
|
if not self.database.renew_lease(
|
||||||
|
job_id, self.worker_id, self.settings.lease_seconds
|
||||||
|
):
|
||||||
|
lease_lost.set()
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
# Ownership checks stop this attempt if the lease eventually expires.
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
_remove_tree_if_present(work_dir, self.settings.work_dir)
|
||||||
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
if capture_cancelled():
|
||||||
|
raise CaptureCancelled("Capture cancelled before it started")
|
||||||
|
|
||||||
|
heartbeat_thread = threading.Thread(
|
||||||
|
target=renew_lease,
|
||||||
|
name=f"siteharbor-lease-{job_id[-8:]}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
heartbeat_thread.start()
|
||||||
|
|
||||||
|
rules = NetworkRules(
|
||||||
|
allow_private_networks=self.settings.allow_private_networks,
|
||||||
|
allow_nonstandard_ports=self.settings.allow_nonstandard_ports,
|
||||||
|
)
|
||||||
|
resolve_and_validate(str(job["normalized_url"]), rules)
|
||||||
|
remaining_seconds = int(capture_deadline - time.monotonic())
|
||||||
|
if remaining_seconds <= 0:
|
||||||
|
raise CaptureError(
|
||||||
|
"The configured capture time limit was reached during validation"
|
||||||
|
)
|
||||||
|
options = CaptureOptions(
|
||||||
|
source_url=str(job["normalized_url"]),
|
||||||
|
include_external_assets=bool(config["include_external_assets"]),
|
||||||
|
max_pages=int(config["max_pages"]),
|
||||||
|
max_depth=int(config["max_depth"]),
|
||||||
|
max_bytes=int(config["max_bytes"]),
|
||||||
|
max_duration_seconds=remaining_seconds,
|
||||||
|
parallel_connections=int(
|
||||||
|
config.get("parallel_connections", self.settings.fetch_concurrency)
|
||||||
|
),
|
||||||
|
download_asset_types=tuple(
|
||||||
|
config.get("download_asset_types", DEFAULT_ASSET_TYPES)
|
||||||
|
),
|
||||||
|
skip_file_types=tuple(config.get("skip_file_types", ())),
|
||||||
|
use_proxy=bool(config.get("use_proxy", False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update(
|
||||||
|
phase: str, message: str, stats: dict[str, int], warnings: list[str]
|
||||||
|
) -> None:
|
||||||
|
renewed = self.database.update_progress(
|
||||||
|
job_id,
|
||||||
|
self.worker_id,
|
||||||
|
self.settings.lease_seconds,
|
||||||
|
phase=phase,
|
||||||
|
message=message,
|
||||||
|
stats=stats,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
if not renewed:
|
||||||
|
raise CaptureCancelled("Capture lease was reassigned")
|
||||||
|
|
||||||
|
if not self.database.update_progress(
|
||||||
|
job_id,
|
||||||
|
self.worker_id,
|
||||||
|
self.settings.lease_seconds,
|
||||||
|
phase="Validating",
|
||||||
|
message="Checking capture policy and target reachability",
|
||||||
|
stats=_job_stats(job),
|
||||||
|
warnings=[],
|
||||||
|
):
|
||||||
|
raise CaptureCancelled("Capture lease was reassigned")
|
||||||
|
|
||||||
|
crawler = SiteCrawler(
|
||||||
|
settings=self.settings,
|
||||||
|
options=options,
|
||||||
|
cancelled=capture_cancelled,
|
||||||
|
on_progress=update,
|
||||||
|
)
|
||||||
|
result = crawler.run(work_dir)
|
||||||
|
|
||||||
|
if capture_cancelled():
|
||||||
|
raise CaptureCancelled("Capture cancelled before packaging")
|
||||||
|
if time.monotonic() >= capture_deadline:
|
||||||
|
raise CaptureError("The configured capture time limit was reached before packaging")
|
||||||
|
|
||||||
|
update("Packaging", "Creating a portable ZIP archive", result.stats, result.warnings)
|
||||||
|
archive_size = _create_archive(
|
||||||
|
result.site_dir,
|
||||||
|
artifact_path,
|
||||||
|
archive_root=f"siteharbor-{job_id}",
|
||||||
|
cancelled=capture_cancelled,
|
||||||
|
deadline_exceeded=lambda: time.monotonic() >= capture_deadline,
|
||||||
|
heartbeat=lambda: update(
|
||||||
|
"Packaging",
|
||||||
|
"Creating a portable ZIP archive",
|
||||||
|
result.stats,
|
||||||
|
result.warnings,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
report = {
|
||||||
|
**result.report,
|
||||||
|
"job_id": job_id,
|
||||||
|
"archive": {
|
||||||
|
"filename": artifact_path.name,
|
||||||
|
"size_bytes": archive_size,
|
||||||
|
"archive_root": f"siteharbor-{job_id}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if time.monotonic() >= capture_deadline:
|
||||||
|
raise CaptureError(
|
||||||
|
"The configured capture time limit was reached before completion"
|
||||||
|
)
|
||||||
|
report_path.write_text(
|
||||||
|
json.dumps(report, indent=2, ensure_ascii=True), encoding="utf-8"
|
||||||
|
)
|
||||||
|
marked_ready = self.database.mark_ready(
|
||||||
|
job_id,
|
||||||
|
self.worker_id,
|
||||||
|
artifact_path=str(artifact_path),
|
||||||
|
report_path=str(report_path),
|
||||||
|
archive_size=archive_size,
|
||||||
|
entry_point=result.entry_point,
|
||||||
|
stats=result.stats,
|
||||||
|
warnings=result.warnings,
|
||||||
|
retention_days=int(config["retention_days"]),
|
||||||
|
)
|
||||||
|
if not marked_ready:
|
||||||
|
artifact_path.unlink(missing_ok=True)
|
||||||
|
report_path.unlink(missing_ok=True)
|
||||||
|
if self.database.is_cancel_requested(job_id):
|
||||||
|
self.database.mark_cancelled(
|
||||||
|
job_id,
|
||||||
|
self.worker_id,
|
||||||
|
"Capture cancelled before the archive became available",
|
||||||
|
)
|
||||||
|
except CaptureCancelled as error:
|
||||||
|
artifact_path.unlink(missing_ok=True)
|
||||||
|
report_path.unlink(missing_ok=True)
|
||||||
|
if self.database.is_cancel_requested(job_id):
|
||||||
|
self.database.mark_cancelled(job_id, self.worker_id, str(error))
|
||||||
|
except UrlPolicyError as error:
|
||||||
|
artifact_path.unlink(missing_ok=True)
|
||||||
|
self.database.mark_failed(job_id, self.worker_id, "target_blocked", str(error))
|
||||||
|
except CaptureError as error:
|
||||||
|
artifact_path.unlink(missing_ok=True)
|
||||||
|
self.database.mark_failed(job_id, self.worker_id, "capture_failed", str(error))
|
||||||
|
except Exception as error:
|
||||||
|
artifact_path.unlink(missing_ok=True)
|
||||||
|
self.database.mark_failed(
|
||||||
|
job_id,
|
||||||
|
self.worker_id,
|
||||||
|
"internal_error",
|
||||||
|
f"Capture failed: {error}",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
heartbeat_stop.set()
|
||||||
|
if heartbeat_thread:
|
||||||
|
heartbeat_thread.join(timeout=1)
|
||||||
|
try:
|
||||||
|
_remove_tree_if_present(work_dir, self.settings.work_dir)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _job_stats(job: dict[str, Any]) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"pages_found": int(job.get("pages_found", 0)),
|
||||||
|
"pages_fetched": int(job.get("pages_fetched", 0)),
|
||||||
|
"assets_found": int(job.get("assets_found", 0)),
|
||||||
|
"assets_fetched": int(job.get("assets_fetched", 0)),
|
||||||
|
"bytes_downloaded": int(job.get("bytes_downloaded", 0)),
|
||||||
|
"files_written": int(job.get("files_written", 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_tree_if_present(path: Path, root: Path) -> None:
|
||||||
|
if path.exists():
|
||||||
|
_remove_tree(path, root)
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bool(name: str, default: bool) -> bool:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int(name: str, default: int, minimum: int = 1) -> int:
|
||||||
|
value = os.getenv(name)
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return max(minimum, int(value))
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Settings:
|
||||||
|
project_root: Path
|
||||||
|
data_dir: Path
|
||||||
|
database_path: Path
|
||||||
|
work_dir: Path
|
||||||
|
artifacts_dir: Path
|
||||||
|
reports_dir: Path
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
worker_enabled: bool
|
||||||
|
worker_concurrency: int
|
||||||
|
fetch_concurrency: int
|
||||||
|
queue_poll_seconds: float
|
||||||
|
lease_seconds: int
|
||||||
|
allow_private_networks: bool
|
||||||
|
allow_nonstandard_ports: bool
|
||||||
|
respect_robots: bool
|
||||||
|
default_retention_days: int
|
||||||
|
max_retention_days: int
|
||||||
|
max_pages_cap: int
|
||||||
|
max_depth_cap: int
|
||||||
|
max_bytes_cap: int
|
||||||
|
max_duration_seconds_cap: int
|
||||||
|
max_response_bytes: int
|
||||||
|
max_files: int
|
||||||
|
max_external_hosts: int
|
||||||
|
max_redirects: int
|
||||||
|
user_agent: str
|
||||||
|
proxy_url: str | None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_environment(cls) -> Settings:
|
||||||
|
project_root = Path(__file__).resolve().parents[1]
|
||||||
|
data_dir = Path(os.getenv("SITEHARBOR_DATA_DIR", project_root / "data")).expanduser()
|
||||||
|
if not data_dir.is_absolute():
|
||||||
|
data_dir = project_root / data_dir
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
project_root=project_root,
|
||||||
|
data_dir=data_dir,
|
||||||
|
database_path=data_dir / "siteharbor.sqlite3",
|
||||||
|
work_dir=data_dir / "work",
|
||||||
|
artifacts_dir=data_dir / "artifacts",
|
||||||
|
reports_dir=data_dir / "reports",
|
||||||
|
host=os.getenv("SITEHARBOR_HOST", "127.0.0.1"),
|
||||||
|
port=_as_int("SITEHARBOR_PORT", 8787),
|
||||||
|
worker_enabled=_as_bool("SITEHARBOR_WORKER_ENABLED", True),
|
||||||
|
worker_concurrency=_as_int("SITEHARBOR_WORKER_CONCURRENCY", 1),
|
||||||
|
fetch_concurrency=_as_int("SITEHARBOR_FETCH_CONCURRENCY", 6),
|
||||||
|
queue_poll_seconds=max(0.1, float(os.getenv("SITEHARBOR_QUEUE_POLL_SECONDS", "0.75"))),
|
||||||
|
lease_seconds=_as_int("SITEHARBOR_LEASE_SECONDS", 120, minimum=15),
|
||||||
|
allow_private_networks=_as_bool("SITEHARBOR_ALLOW_PRIVATE_NETWORKS", False),
|
||||||
|
allow_nonstandard_ports=_as_bool("SITEHARBOR_ALLOW_NONSTANDARD_PORTS", False),
|
||||||
|
respect_robots=_as_bool("SITEHARBOR_RESPECT_ROBOTS", True),
|
||||||
|
default_retention_days=_as_int("SITEHARBOR_DEFAULT_RETENTION_DAYS", 7),
|
||||||
|
max_retention_days=_as_int("SITEHARBOR_MAX_RETENTION_DAYS", 30),
|
||||||
|
max_pages_cap=_as_int("SITEHARBOR_MAX_PAGES", 1_000),
|
||||||
|
max_depth_cap=_as_int("SITEHARBOR_MAX_DEPTH", 12),
|
||||||
|
max_bytes_cap=_as_int("SITEHARBOR_MAX_BYTES", 1024 * 1024 * 1024),
|
||||||
|
max_duration_seconds_cap=_as_int("SITEHARBOR_MAX_DURATION_SECONDS", 3_600),
|
||||||
|
max_response_bytes=_as_int("SITEHARBOR_MAX_RESPONSE_BYTES", 32 * 1024 * 1024),
|
||||||
|
max_files=_as_int("SITEHARBOR_MAX_FILES", 5_000),
|
||||||
|
max_external_hosts=_as_int("SITEHARBOR_MAX_EXTERNAL_HOSTS", 20),
|
||||||
|
max_redirects=_as_int("SITEHARBOR_MAX_REDIRECTS", 10),
|
||||||
|
user_agent=os.getenv(
|
||||||
|
"SITEHARBOR_USER_AGENT",
|
||||||
|
"SiteHarbor/0.1 (+local standalone capture; respect robots.txt)",
|
||||||
|
),
|
||||||
|
proxy_url=os.getenv("SITEHARBOR_PROXY_URL", "").strip() or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def ensure_directories(self) -> None:
|
||||||
|
for directory in (self.data_dir, self.work_dir, self.artifacts_dir, self.reports_dir):
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings.from_environment()
|
||||||
+1101
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,684 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def timestamp(value: datetime | None = None) -> str:
|
||||||
|
return (value or utcnow()).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""Small SQLite persistence layer with lease-based job claiming."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connection(self) -> Iterator[sqlite3.Connection]:
|
||||||
|
connection = sqlite3.connect(self.path, timeout=15, isolation_level=None)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
connection.execute("PRAGMA busy_timeout = 15000")
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def initialize(self) -> None:
|
||||||
|
with self.connection() as connection:
|
||||||
|
connection.execute("PRAGMA journal_mode = WAL")
|
||||||
|
connection.execute("PRAGMA synchronous = NORMAL")
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
normalized_url TEXT NOT NULL,
|
||||||
|
owner_token TEXT NOT NULL DEFAULT '',
|
||||||
|
config_json TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
phase TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
error_code TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
warnings_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
pages_found INTEGER NOT NULL DEFAULT 0,
|
||||||
|
pages_fetched INTEGER NOT NULL DEFAULT 0,
|
||||||
|
assets_found INTEGER NOT NULL DEFAULT 0,
|
||||||
|
assets_fetched INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes_downloaded INTEGER NOT NULL DEFAULT 0,
|
||||||
|
files_written INTEGER NOT NULL DEFAULT 0,
|
||||||
|
artifact_path TEXT,
|
||||||
|
report_path TEXT,
|
||||||
|
archive_size INTEGER,
|
||||||
|
entry_point TEXT,
|
||||||
|
cancel_requested INTEGER NOT NULL DEFAULT 0,
|
||||||
|
worker_id TEXT,
|
||||||
|
lease_expires_at TEXT,
|
||||||
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
started_at TEXT,
|
||||||
|
completed_at TEXT,
|
||||||
|
expires_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_state_created_idx
|
||||||
|
ON jobs(state, created_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_lease_idx
|
||||||
|
ON jobs(lease_expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS job_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS job_events_job_id_idx
|
||||||
|
ON job_events(job_id, id);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
columns = {
|
||||||
|
str(row["name"])
|
||||||
|
for row in connection.execute("PRAGMA table_info(jobs)").fetchall()
|
||||||
|
}
|
||||||
|
if "owner_token" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
"ALTER TABLE jobs ADD COLUMN owner_token TEXT NOT NULL DEFAULT ''"
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS jobs_owner_created_idx "
|
||||||
|
"ON jobs(owner_token, created_at DESC)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_job(
|
||||||
|
self,
|
||||||
|
source_url: str,
|
||||||
|
normalized_url: str,
|
||||||
|
config: dict[str, Any],
|
||||||
|
owner_token: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
job_id = f"cap_{uuid.uuid4().hex}"
|
||||||
|
now = timestamp()
|
||||||
|
payload = json.dumps(config, separators=(",", ":"))
|
||||||
|
with self.connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO jobs (
|
||||||
|
id, source_url, normalized_url, owner_token, config_json, state, phase, message,
|
||||||
|
created_at, updated_at, expires_at
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?, 'queued', 'Queued', 'Waiting for a local worker', ?, ?, NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
job_id,
|
||||||
|
source_url,
|
||||||
|
normalized_url,
|
||||||
|
owner_token,
|
||||||
|
payload,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._append_event(
|
||||||
|
connection,
|
||||||
|
job_id,
|
||||||
|
"state",
|
||||||
|
{"state": "queued", "phase": "Queued", "message": "Waiting for a local worker"},
|
||||||
|
)
|
||||||
|
return self.get_job(job_id) or {}
|
||||||
|
|
||||||
|
def get_job(self, job_id: str) -> dict[str, Any] | None:
|
||||||
|
with self.connection() as connection:
|
||||||
|
row = connection.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
||||||
|
return self._row_to_job(row) if row else None
|
||||||
|
|
||||||
|
def get_owned_job(self, job_id: str, owner_token: str) -> dict[str, Any] | None:
|
||||||
|
with self.connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT * FROM jobs WHERE id = ? AND owner_token = ?", (job_id, owner_token)
|
||||||
|
).fetchone()
|
||||||
|
return self._row_to_job(row) if row else None
|
||||||
|
|
||||||
|
def list_jobs(self, limit: int = 30) -> list[dict[str, Any]]:
|
||||||
|
with self.connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?", (limit,)
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_job(row) for row in rows]
|
||||||
|
|
||||||
|
def list_owned_jobs(self, owner_token: str, limit: int = 30) -> list[dict[str, Any]]:
|
||||||
|
with self.connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM jobs
|
||||||
|
WHERE owner_token = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(owner_token, limit),
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_job(row) for row in rows]
|
||||||
|
|
||||||
|
def public_stats(self) -> dict[str, int]:
|
||||||
|
with self.connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(CASE WHEN state = 'ready' THEN 1 END) AS websites_cloned,
|
||||||
|
COALESCE(SUM(bytes_downloaded), 0) AS bytes_scraped,
|
||||||
|
COUNT(CASE WHEN state IN ('queued', 'running') THEN 1 END) AS active_crawls,
|
||||||
|
COALESCE(SUM(files_written), 0) AS files_archived
|
||||||
|
FROM jobs
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
return {
|
||||||
|
"websites_cloned": int(row["websites_cloned"] or 0),
|
||||||
|
"bytes_scraped": int(row["bytes_scraped"] or 0),
|
||||||
|
"active_crawls": int(row["active_crawls"] or 0),
|
||||||
|
"files_archived": int(row["files_archived"] or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_events(self, job_id: str, after_id: int = 0) -> list[dict[str, Any]]:
|
||||||
|
with self.connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, kind, payload_json, created_at
|
||||||
|
FROM job_events
|
||||||
|
WHERE job_id = ? AND id > ?
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 100
|
||||||
|
""",
|
||||||
|
(job_id, after_id),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"kind": row["kind"],
|
||||||
|
"payload": json.loads(row["payload_json"]),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def claim_next(self, worker_id: str, lease_seconds: int) -> dict[str, Any] | None:
|
||||||
|
now = utcnow()
|
||||||
|
now_text = timestamp(now)
|
||||||
|
lease = timestamp(now + timedelta(seconds=lease_seconds))
|
||||||
|
with self.connection() as connection:
|
||||||
|
try:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
cancelled_rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM jobs
|
||||||
|
WHERE cancel_requested = 1
|
||||||
|
AND (
|
||||||
|
state = 'queued'
|
||||||
|
OR (
|
||||||
|
state = 'running'
|
||||||
|
AND lease_expires_at IS NOT NULL
|
||||||
|
AND lease_expires_at < ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(now_text,),
|
||||||
|
).fetchall()
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE jobs
|
||||||
|
SET state = 'cancelled', phase = 'Cancelled',
|
||||||
|
message = 'Cancelled before starting',
|
||||||
|
completed_at = ?, updated_at = ?
|
||||||
|
WHERE cancel_requested = 1
|
||||||
|
AND (
|
||||||
|
state = 'queued'
|
||||||
|
OR (
|
||||||
|
state = 'running'
|
||||||
|
AND lease_expires_at IS NOT NULL
|
||||||
|
AND lease_expires_at < ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(now_text, now_text, now_text),
|
||||||
|
)
|
||||||
|
for cancelled_row in cancelled_rows:
|
||||||
|
self._append_event(
|
||||||
|
connection,
|
||||||
|
cancelled_row["id"],
|
||||||
|
"state",
|
||||||
|
{
|
||||||
|
"state": "cancelled",
|
||||||
|
"phase": "Cancelled",
|
||||||
|
"message": "Cancelled before starting",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM jobs
|
||||||
|
WHERE cancel_requested = 0
|
||||||
|
AND (
|
||||||
|
state = 'queued'
|
||||||
|
OR (
|
||||||
|
state = 'running'
|
||||||
|
AND lease_expires_at IS NOT NULL
|
||||||
|
AND lease_expires_at < ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(now_text,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
connection.execute("COMMIT")
|
||||||
|
return None
|
||||||
|
|
||||||
|
job_id = row["id"]
|
||||||
|
message = (
|
||||||
|
"Recovering a leased capture"
|
||||||
|
if row["state"] == "running"
|
||||||
|
else "Preparing capture"
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE jobs
|
||||||
|
SET state = 'running', phase = 'Preparing', message = ?, worker_id = ?,
|
||||||
|
lease_expires_at = ?, attempt_count = attempt_count + 1,
|
||||||
|
started_at = COALESCE(started_at, ?), updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(message, worker_id, lease, now_text, now_text, job_id),
|
||||||
|
)
|
||||||
|
self._append_event(
|
||||||
|
connection,
|
||||||
|
job_id,
|
||||||
|
"state",
|
||||||
|
{"state": "running", "phase": "Preparing", "message": message},
|
||||||
|
)
|
||||||
|
claimed = connection.execute(
|
||||||
|
"SELECT * FROM jobs WHERE id = ?", (job_id,)
|
||||||
|
).fetchone()
|
||||||
|
connection.execute("COMMIT")
|
||||||
|
return self._row_to_job(claimed)
|
||||||
|
except Exception:
|
||||||
|
connection.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def update_progress(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
lease_seconds: int,
|
||||||
|
*,
|
||||||
|
phase: str,
|
||||||
|
message: str,
|
||||||
|
stats: dict[str, int],
|
||||||
|
warnings: list[str] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
now = utcnow()
|
||||||
|
values = {
|
||||||
|
"phase": phase,
|
||||||
|
"message": message,
|
||||||
|
"pages_found": stats.get("pages_found", 0),
|
||||||
|
"pages_fetched": stats.get("pages_fetched", 0),
|
||||||
|
"assets_found": stats.get("assets_found", 0),
|
||||||
|
"assets_fetched": stats.get("assets_fetched", 0),
|
||||||
|
"bytes_downloaded": stats.get("bytes_downloaded", 0),
|
||||||
|
"files_written": stats.get("files_written", 0),
|
||||||
|
"warnings_json": json.dumps(warnings or []),
|
||||||
|
"worker_id": worker_id,
|
||||||
|
"lease_expires_at": timestamp(now + timedelta(seconds=lease_seconds)),
|
||||||
|
"updated_at": timestamp(now),
|
||||||
|
"current_time": timestamp(now),
|
||||||
|
}
|
||||||
|
with self.connection() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE jobs SET
|
||||||
|
phase = :phase, message = :message,
|
||||||
|
pages_found = :pages_found, pages_fetched = :pages_fetched,
|
||||||
|
assets_found = :assets_found, assets_fetched = :assets_fetched,
|
||||||
|
bytes_downloaded = :bytes_downloaded, files_written = :files_written,
|
||||||
|
warnings_json = :warnings_json, worker_id = :worker_id,
|
||||||
|
lease_expires_at = :lease_expires_at, updated_at = :updated_at
|
||||||
|
WHERE id = :job_id
|
||||||
|
AND state = 'running'
|
||||||
|
AND worker_id = :worker_id
|
||||||
|
AND lease_expires_at >= :current_time
|
||||||
|
""",
|
||||||
|
{**values, "job_id": job_id},
|
||||||
|
)
|
||||||
|
if cursor.rowcount:
|
||||||
|
self._append_event(
|
||||||
|
connection,
|
||||||
|
job_id,
|
||||||
|
"progress",
|
||||||
|
{
|
||||||
|
"phase": phase,
|
||||||
|
"message": message,
|
||||||
|
"stats": stats,
|
||||||
|
"warnings": warnings or [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def renew_lease(self, job_id: str, worker_id: str, lease_seconds: int) -> bool:
|
||||||
|
now = utcnow()
|
||||||
|
now_text = timestamp(now)
|
||||||
|
with self.connection() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE jobs
|
||||||
|
SET lease_expires_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND state = 'running'
|
||||||
|
AND worker_id = ?
|
||||||
|
AND lease_expires_at >= ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
timestamp(now + timedelta(seconds=lease_seconds)),
|
||||||
|
now_text,
|
||||||
|
job_id,
|
||||||
|
worker_id,
|
||||||
|
now_text,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return bool(cursor.rowcount)
|
||||||
|
|
||||||
|
def is_cancel_requested(self, job_id: str) -> bool:
|
||||||
|
with self.connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT cancel_requested FROM jobs WHERE id = ?", (job_id,)
|
||||||
|
).fetchone()
|
||||||
|
return bool(row and row["cancel_requested"])
|
||||||
|
|
||||||
|
def owns_lease(self, job_id: str, worker_id: str) -> bool:
|
||||||
|
now = timestamp()
|
||||||
|
with self.connection() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT state, worker_id, lease_expires_at
|
||||||
|
FROM jobs
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(job_id,),
|
||||||
|
).fetchone()
|
||||||
|
return bool(
|
||||||
|
row
|
||||||
|
and row["state"] == "running"
|
||||||
|
and row["worker_id"] == worker_id
|
||||||
|
and row["lease_expires_at"]
|
||||||
|
and row["lease_expires_at"] >= now
|
||||||
|
)
|
||||||
|
|
||||||
|
def request_cancel(self, job_id: str) -> dict[str, Any] | None:
|
||||||
|
now = timestamp()
|
||||||
|
with self.connection() as connection:
|
||||||
|
try:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT state FROM jobs WHERE id = ?", (job_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
connection.execute("COMMIT")
|
||||||
|
return None
|
||||||
|
if row["state"] in {"ready", "failed", "cancelled", "expired"}:
|
||||||
|
connection.execute("COMMIT")
|
||||||
|
return self.get_job(job_id)
|
||||||
|
|
||||||
|
if row["state"] == "queued":
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE jobs
|
||||||
|
SET cancel_requested = 1, state = 'cancelled', phase = 'Cancelled',
|
||||||
|
message = 'Cancelled before starting', completed_at = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND state = 'queued'
|
||||||
|
""",
|
||||||
|
(now, now, job_id),
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"state": "cancelled",
|
||||||
|
"phase": "Cancelled",
|
||||||
|
"message": "Cancelled before starting",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE jobs
|
||||||
|
SET cancel_requested = 1, message = 'Cancellation requested', updated_at = ?
|
||||||
|
WHERE id = ? AND state = 'running' AND cancel_requested = 0
|
||||||
|
""",
|
||||||
|
(now, job_id),
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"state": "running",
|
||||||
|
"phase": "Cancelling",
|
||||||
|
"message": "Cancellation requested",
|
||||||
|
}
|
||||||
|
if cursor.rowcount:
|
||||||
|
self._append_event(connection, job_id, "state", payload)
|
||||||
|
connection.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
connection.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
return self.get_job(job_id)
|
||||||
|
|
||||||
|
def mark_ready(
|
||||||
|
self,
|
||||||
|
job_id: str,
|
||||||
|
worker_id: str,
|
||||||
|
*,
|
||||||
|
artifact_path: str,
|
||||||
|
report_path: str,
|
||||||
|
archive_size: int,
|
||||||
|
entry_point: str,
|
||||||
|
stats: dict[str, int],
|
||||||
|
warnings: list[str],
|
||||||
|
retention_days: int,
|
||||||
|
) -> bool:
|
||||||
|
now_datetime = utcnow()
|
||||||
|
now = timestamp(now_datetime)
|
||||||
|
with self.connection() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE jobs
|
||||||
|
SET state = 'ready', phase = 'Ready', message = 'Archive is ready to download',
|
||||||
|
artifact_path = ?, report_path = ?, archive_size = ?, entry_point = ?,
|
||||||
|
pages_found = ?, pages_fetched = ?, assets_found = ?, assets_fetched = ?,
|
||||||
|
bytes_downloaded = ?, files_written = ?, warnings_json = ?,
|
||||||
|
worker_id = NULL, lease_expires_at = NULL, expires_at = ?,
|
||||||
|
completed_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND state = 'running'
|
||||||
|
AND worker_id = ?
|
||||||
|
AND cancel_requested = 0
|
||||||
|
AND lease_expires_at >= ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
artifact_path,
|
||||||
|
report_path,
|
||||||
|
archive_size,
|
||||||
|
entry_point,
|
||||||
|
stats.get("pages_found", 0),
|
||||||
|
stats.get("pages_fetched", 0),
|
||||||
|
stats.get("assets_found", 0),
|
||||||
|
stats.get("assets_fetched", 0),
|
||||||
|
stats.get("bytes_downloaded", 0),
|
||||||
|
stats.get("files_written", 0),
|
||||||
|
json.dumps(warnings),
|
||||||
|
timestamp(now_datetime + timedelta(days=retention_days)),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
job_id,
|
||||||
|
worker_id,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cursor.rowcount:
|
||||||
|
self._append_event(
|
||||||
|
connection,
|
||||||
|
job_id,
|
||||||
|
"state",
|
||||||
|
{"state": "ready", "phase": "Ready", "message": "Archive is ready to download"},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def mark_cancelled(
|
||||||
|
self, job_id: str, worker_id: str, message: str = "Capture cancelled"
|
||||||
|
) -> bool:
|
||||||
|
now = timestamp()
|
||||||
|
with self.connection() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE jobs
|
||||||
|
SET state = 'cancelled', phase = 'Cancelled', message = ?, worker_id = NULL,
|
||||||
|
lease_expires_at = NULL, completed_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND state = 'running'
|
||||||
|
AND worker_id = ?
|
||||||
|
AND cancel_requested = 1
|
||||||
|
AND lease_expires_at >= ?
|
||||||
|
""",
|
||||||
|
(message, now, now, job_id, worker_id, now),
|
||||||
|
)
|
||||||
|
if cursor.rowcount:
|
||||||
|
self._append_event(
|
||||||
|
connection,
|
||||||
|
job_id,
|
||||||
|
"state",
|
||||||
|
{"state": "cancelled", "phase": "Cancelled", "message": message},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def mark_failed(self, job_id: str, worker_id: str, code: str, message: str) -> bool:
|
||||||
|
now = timestamp()
|
||||||
|
with self.connection() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE jobs
|
||||||
|
SET state = 'failed', phase = 'Failed', message = ?, error_code = ?,
|
||||||
|
error_message = ?, worker_id = NULL, lease_expires_at = NULL,
|
||||||
|
completed_at = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND state = 'running'
|
||||||
|
AND worker_id = ?
|
||||||
|
AND cancel_requested = 0
|
||||||
|
AND lease_expires_at >= ?
|
||||||
|
""",
|
||||||
|
(message, code, message, now, now, job_id, worker_id, now),
|
||||||
|
)
|
||||||
|
if cursor.rowcount:
|
||||||
|
self._append_event(
|
||||||
|
connection,
|
||||||
|
job_id,
|
||||||
|
"state",
|
||||||
|
{"state": "failed", "phase": "Failed", "message": message, "code": code},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def mark_expired(self, job_id: str) -> None:
|
||||||
|
now = timestamp()
|
||||||
|
with self.connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE jobs
|
||||||
|
SET state = 'expired', phase = 'Expired',
|
||||||
|
message = 'Artifact retention period ended',
|
||||||
|
artifact_path = NULL, report_path = NULL, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(now, job_id),
|
||||||
|
)
|
||||||
|
self._append_event(
|
||||||
|
connection,
|
||||||
|
job_id,
|
||||||
|
"state",
|
||||||
|
{
|
||||||
|
"state": "expired",
|
||||||
|
"phase": "Expired",
|
||||||
|
"message": "Artifact retention period ended",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def mark_deleted(self, job_id: str) -> None:
|
||||||
|
now = timestamp()
|
||||||
|
with self.connection() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE jobs
|
||||||
|
SET state = 'expired', phase = 'Deleted', message = 'Artifact deleted by user',
|
||||||
|
artifact_path = NULL, report_path = NULL, updated_at = ?
|
||||||
|
WHERE id = ? AND state = 'ready'
|
||||||
|
""",
|
||||||
|
(now, job_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount:
|
||||||
|
self._append_event(
|
||||||
|
connection,
|
||||||
|
job_id,
|
||||||
|
"state",
|
||||||
|
{
|
||||||
|
"state": "expired",
|
||||||
|
"phase": "Deleted",
|
||||||
|
"message": "Artifact deleted by user",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def expired_ready_jobs(self) -> list[dict[str, Any]]:
|
||||||
|
now = timestamp()
|
||||||
|
with self.connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT * FROM jobs WHERE state = 'ready' AND expires_at <= ?", (now,)
|
||||||
|
).fetchall()
|
||||||
|
return [self._row_to_job(row) for row in rows]
|
||||||
|
|
||||||
|
def referenced_data_paths(self) -> set[str]:
|
||||||
|
with self.connection() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT artifact_path, report_path
|
||||||
|
FROM jobs
|
||||||
|
WHERE state = 'ready'
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
path
|
||||||
|
for row in rows
|
||||||
|
for path in (row["artifact_path"], row["report_path"])
|
||||||
|
if isinstance(path, str) and path
|
||||||
|
}
|
||||||
|
|
||||||
|
def _append_event(
|
||||||
|
self, connection: sqlite3.Connection, job_id: str, kind: str, payload: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO job_events (job_id, kind, payload_json, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(job_id, kind, json.dumps(payload, separators=(",", ":")), timestamp()),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_to_job(row: sqlite3.Row) -> dict[str, Any]:
|
||||||
|
data = dict(row)
|
||||||
|
data["config"] = json.loads(data.pop("config_json"))
|
||||||
|
data["warnings"] = json.loads(data.pop("warnings_json"))
|
||||||
|
data["cancel_requested"] = bool(data["cancel_requested"])
|
||||||
|
return data
|
||||||
+395
@@ -0,0 +1,395 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, HTTPException, Request, Response, status
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import Database
|
||||||
|
from app.models import SUPPORTED_ASSET_TYPES, CaptureRequest
|
||||||
|
from app.scheduler import JobRunner
|
||||||
|
from app.url_policy import NetworkRules, UrlPolicyError, normalize_url
|
||||||
|
|
||||||
|
settings.ensure_directories()
|
||||||
|
database = Database(settings.database_path)
|
||||||
|
runner = JobRunner(database, settings)
|
||||||
|
templates = Jinja2Templates(directory=str(settings.project_root / "app" / "templates"))
|
||||||
|
OWNER_TOKEN_HEADER = "X-SiteHarbor-Session"
|
||||||
|
OWNER_TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{24,200}")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||||
|
database.initialize()
|
||||||
|
runner.start()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
runner.stop()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="SiteHarbor",
|
||||||
|
version="0.1.0",
|
||||||
|
description="Standalone, bounded website capture for offline archives.",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
app.mount(
|
||||||
|
"/static", StaticFiles(directory=str(settings.project_root / "app" / "static")), name="static"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def secure_headers(_: Request, call_next: Any) -> Response:
|
||||||
|
response = await call_next(_)
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
response.headers.setdefault(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; "
|
||||||
|
"form-action 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; "
|
||||||
|
"script-src 'self'",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request) -> Response:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="index.html",
|
||||||
|
context={"app_name": "SiteHarbor"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"worker_enabled": settings.worker_enabled,
|
||||||
|
"worker_concurrency": settings.worker_concurrency,
|
||||||
|
"storage": "local-sqlite",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/capabilities")
|
||||||
|
async def capabilities() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"defaults": {
|
||||||
|
"max_pages": min(150, settings.max_pages_cap),
|
||||||
|
"max_depth": min(5, settings.max_depth_cap),
|
||||||
|
"max_bytes_mb": min(100, max(1, settings.max_bytes_cap // (1024 * 1024))),
|
||||||
|
"max_duration_seconds": min(300, settings.max_duration_seconds_cap),
|
||||||
|
"retention_days": min(settings.default_retention_days, settings.max_retention_days),
|
||||||
|
"include_external_assets": True,
|
||||||
|
"parallel_connections": min(6, settings.fetch_concurrency),
|
||||||
|
"download_asset_types": list(SUPPORTED_ASSET_TYPES),
|
||||||
|
"skip_file_types": [],
|
||||||
|
"use_proxy": False,
|
||||||
|
},
|
||||||
|
"limits": {
|
||||||
|
"max_pages": settings.max_pages_cap,
|
||||||
|
"max_depth": settings.max_depth_cap,
|
||||||
|
"max_bytes_mb": settings.max_bytes_cap // (1024 * 1024),
|
||||||
|
"max_duration_seconds": settings.max_duration_seconds_cap,
|
||||||
|
"max_retention_days": settings.max_retention_days,
|
||||||
|
"max_parallel_connections": settings.fetch_concurrency,
|
||||||
|
},
|
||||||
|
"proxy_available": bool(settings.proxy_url),
|
||||||
|
"local_only_warning": not settings.allow_private_networks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/stats")
|
||||||
|
async def public_stats() -> dict[str, int]:
|
||||||
|
return database.public_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/captures", status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
async def create_capture(payload: CaptureRequest, request: Request) -> dict[str, Any]:
|
||||||
|
owner_token = _owner_token(request)
|
||||||
|
config = _validated_config(payload)
|
||||||
|
rules = NetworkRules(
|
||||||
|
allow_private_networks=settings.allow_private_networks,
|
||||||
|
allow_nonstandard_ports=settings.allow_nonstandard_ports,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
normalized_url = normalize_url(payload.url, rules)
|
||||||
|
except UrlPolicyError as error:
|
||||||
|
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||||
|
|
||||||
|
job = database.create_job(payload.url.strip(), normalized_url, config, owner_token)
|
||||||
|
return _public_job(job)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/captures")
|
||||||
|
async def list_captures(request: Request) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
owner_token = _owner_token(request)
|
||||||
|
return {"items": [_public_job(job) for job in database.list_owned_jobs(owner_token)]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/captures/{job_id}")
|
||||||
|
async def get_capture(job_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
return _require_owned_job(job_id, _owner_token(request))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/captures/{job_id}/cancel")
|
||||||
|
async def cancel_capture(job_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
_require_owned_job(job_id, _owner_token(request))
|
||||||
|
job = database.request_cancel(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Capture not found.")
|
||||||
|
return _public_job(job)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/captures/{job_id}")
|
||||||
|
async def delete_capture(job_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
job = _owned_job(job_id, _owner_token(request))
|
||||||
|
if job["state"] in {"queued", "running"}:
|
||||||
|
cancelled = database.request_cancel(job_id)
|
||||||
|
return _public_job(cancelled or job)
|
||||||
|
if job["state"] == "ready":
|
||||||
|
artifact_path = _safe_data_file(str(job.get("artifact_path") or ""), settings.artifacts_dir)
|
||||||
|
report_path = _safe_data_file(str(job.get("report_path") or ""), settings.reports_dir)
|
||||||
|
if artifact_path:
|
||||||
|
artifact_path.unlink(missing_ok=True)
|
||||||
|
if report_path:
|
||||||
|
report_path.unlink(missing_ok=True)
|
||||||
|
database.mark_deleted(job_id)
|
||||||
|
updated = database.get_owned_job(job_id, _owner_token(request))
|
||||||
|
return _public_job(updated or job)
|
||||||
|
return _public_job(job)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/captures/{job_id}/report")
|
||||||
|
async def capture_report(job_id: str, request: Request) -> JSONResponse:
|
||||||
|
job = _owned_job(job_id, _owner_token(request))
|
||||||
|
if job["state"] != "ready" or not job.get("report_path"):
|
||||||
|
raise HTTPException(status_code=409, detail="A capture report is not available yet.")
|
||||||
|
report_path = _safe_data_file(str(job["report_path"]), settings.reports_dir)
|
||||||
|
if not report_path or not report_path.is_file():
|
||||||
|
raise HTTPException(status_code=410, detail="The capture report has expired.")
|
||||||
|
return JSONResponse(json.loads(report_path.read_text(encoding="utf-8")))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/captures/{job_id}/download")
|
||||||
|
async def download_capture(job_id: str, request: Request) -> FileResponse:
|
||||||
|
job = _owned_job(job_id, _owner_token(request))
|
||||||
|
if job["state"] != "ready" or not job.get("artifact_path"):
|
||||||
|
raise HTTPException(status_code=409, detail="The archive is not available yet.")
|
||||||
|
archive_path = _safe_data_file(str(job["artifact_path"]), settings.artifacts_dir)
|
||||||
|
if not archive_path or not archive_path.is_file():
|
||||||
|
raise HTTPException(status_code=410, detail="The archive has expired.")
|
||||||
|
return FileResponse(
|
||||||
|
archive_path,
|
||||||
|
media_type="application/zip",
|
||||||
|
filename=f"siteharbor-{job_id}.zip",
|
||||||
|
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/captures/{job_id}/events")
|
||||||
|
async def capture_events(request: Request, job_id: str) -> StreamingResponse:
|
||||||
|
_owned_job(job_id, _owner_token(request, allow_query=True))
|
||||||
|
after_header = request.headers.get("last-event-id", "0")
|
||||||
|
try:
|
||||||
|
after_id = max(0, int(after_header))
|
||||||
|
except ValueError:
|
||||||
|
after_id = 0
|
||||||
|
return StreamingResponse(
|
||||||
|
_event_stream(request, job_id, after_id),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _event_stream(request: Request, job_id: str, after_id: int) -> AsyncIterator[str]:
|
||||||
|
terminal_states = {"ready", "failed", "cancelled", "expired"}
|
||||||
|
last_id = after_id
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
return
|
||||||
|
events = database.get_events(job_id, last_id)
|
||||||
|
for event in events:
|
||||||
|
last_id = int(event["id"])
|
||||||
|
payload = json.dumps(event["payload"], separators=(",", ":"))
|
||||||
|
yield f"id: {last_id}\nevent: {event['kind']}\ndata: {payload}\n\n"
|
||||||
|
|
||||||
|
job = database.get_job(job_id)
|
||||||
|
if not job or job["state"] in terminal_states:
|
||||||
|
terminal = json.dumps({"state": job["state"] if job else "missing"})
|
||||||
|
yield f"event: terminal\ndata: {terminal}\n\n"
|
||||||
|
return
|
||||||
|
yield ": keepalive\n\n"
|
||||||
|
await asyncio.sleep(0.8)
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_config(payload: CaptureRequest) -> dict[str, Any]:
|
||||||
|
max_bytes = payload.max_bytes_mb * 1024 * 1024
|
||||||
|
limits = {
|
||||||
|
"max_pages": (payload.max_pages, settings.max_pages_cap, "page"),
|
||||||
|
"max_depth": (payload.max_depth, settings.max_depth_cap, "depth"),
|
||||||
|
"max_bytes": (max_bytes, settings.max_bytes_cap, "download size"),
|
||||||
|
"max_duration_seconds": (
|
||||||
|
payload.max_duration_seconds,
|
||||||
|
settings.max_duration_seconds_cap,
|
||||||
|
"capture duration",
|
||||||
|
),
|
||||||
|
"retention_days": (payload.retention_days, settings.max_retention_days, "retention"),
|
||||||
|
}
|
||||||
|
for _, (value, maximum, label) in limits.items():
|
||||||
|
if value > maximum:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail=f"Requested {label} exceeds this instance's limit."
|
||||||
|
)
|
||||||
|
if payload.parallel_connections > settings.fetch_concurrency:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Requested parallel connections exceed this instance's limit.",
|
||||||
|
)
|
||||||
|
if payload.use_proxy and not settings.proxy_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="This service does not have an approved proxy route configured.",
|
||||||
|
)
|
||||||
|
skip_file_types = _normalized_file_types(payload.skip_file_types)
|
||||||
|
return {
|
||||||
|
"include_external_assets": payload.include_external_assets,
|
||||||
|
"parallel_connections": payload.parallel_connections,
|
||||||
|
"download_asset_types": list(dict.fromkeys(payload.download_asset_types)),
|
||||||
|
"skip_file_types": skip_file_types,
|
||||||
|
"use_proxy": payload.use_proxy,
|
||||||
|
"max_pages": payload.max_pages,
|
||||||
|
"max_depth": payload.max_depth,
|
||||||
|
"max_bytes": max_bytes,
|
||||||
|
"max_duration_seconds": payload.max_duration_seconds,
|
||||||
|
"retention_days": payload.retention_days,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_file_types(values: list[str]) -> list[str]:
|
||||||
|
normalized: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
for item in value.split(","):
|
||||||
|
extension = item.strip().lower().lstrip(".")
|
||||||
|
if not extension:
|
||||||
|
continue
|
||||||
|
if not re.fullmatch(r"[a-z0-9]{1,16}", extension):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=(
|
||||||
|
"File rules must be comma-separated extensions such as pdf, zip, or mp4."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if extension not in normalized:
|
||||||
|
normalized.append(extension)
|
||||||
|
if len(normalized) > 24:
|
||||||
|
raise HTTPException(status_code=422, detail="At most 24 file types can be skipped.")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _owner_token(request: Request, *, allow_query: bool = False) -> str:
|
||||||
|
value = request.headers.get(OWNER_TOKEN_HEADER)
|
||||||
|
if not value and allow_query:
|
||||||
|
value = request.query_params.get("session")
|
||||||
|
token = (value or "").strip()
|
||||||
|
if not OWNER_TOKEN_RE.fullmatch(token):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="A browser-local session is required to access captures.",
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _owned_job(job_id: str, owner_token: str) -> dict[str, Any]:
|
||||||
|
job = database.get_owned_job(job_id, owner_token)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Capture not found.")
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _require_owned_job(job_id: str, owner_token: str) -> dict[str, Any]:
|
||||||
|
return _public_job(_owned_job(job_id, owner_token))
|
||||||
|
|
||||||
|
|
||||||
|
def _public_job(job: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
config = job["config"]
|
||||||
|
return {
|
||||||
|
"id": job["id"],
|
||||||
|
"url": job["normalized_url"],
|
||||||
|
"state": job["state"],
|
||||||
|
"phase": job["phase"],
|
||||||
|
"message": job["message"],
|
||||||
|
"error_code": job["error_code"],
|
||||||
|
"error_message": job["error_message"],
|
||||||
|
"warnings": job["warnings"],
|
||||||
|
"created_at": job["created_at"],
|
||||||
|
"updated_at": job["updated_at"],
|
||||||
|
"started_at": job["started_at"],
|
||||||
|
"completed_at": job["completed_at"],
|
||||||
|
"expires_at": job["expires_at"],
|
||||||
|
"cancel_requested": job["cancel_requested"],
|
||||||
|
"entry_point": job["entry_point"],
|
||||||
|
"stats": {
|
||||||
|
"pages_found": job["pages_found"],
|
||||||
|
"pages_fetched": job["pages_fetched"],
|
||||||
|
"assets_found": job["assets_found"],
|
||||||
|
"assets_fetched": job["assets_fetched"],
|
||||||
|
"bytes_downloaded": job["bytes_downloaded"],
|
||||||
|
"files_written": job["files_written"],
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"include_external_assets": config.get("include_external_assets", True),
|
||||||
|
"parallel_connections": config.get(
|
||||||
|
"parallel_connections", settings.fetch_concurrency
|
||||||
|
),
|
||||||
|
"download_asset_types": config.get(
|
||||||
|
"download_asset_types", list(SUPPORTED_ASSET_TYPES)
|
||||||
|
),
|
||||||
|
"skip_file_types": config.get("skip_file_types", []),
|
||||||
|
"use_proxy": config.get("use_proxy", False),
|
||||||
|
"max_pages": config["max_pages"],
|
||||||
|
"max_depth": config["max_depth"],
|
||||||
|
"max_bytes": config["max_bytes"],
|
||||||
|
"max_duration_seconds": config["max_duration_seconds"],
|
||||||
|
"retention_days": config["retention_days"],
|
||||||
|
},
|
||||||
|
"archive": {
|
||||||
|
"available": job["state"] == "ready" and bool(job["artifact_path"]),
|
||||||
|
"size_bytes": job["archive_size"],
|
||||||
|
"download_url": f"/api/captures/{job['id']}/download"
|
||||||
|
if job["state"] == "ready"
|
||||||
|
else None,
|
||||||
|
"report_url": f"/api/captures/{job['id']}/report" if job["state"] == "ready" else None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_data_file(path_value: str, root: Path) -> Path | None:
|
||||||
|
path = Path(path_value)
|
||||||
|
try:
|
||||||
|
resolved = path.resolve()
|
||||||
|
if resolved.is_relative_to(root.resolve()):
|
||||||
|
return resolved
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
uvicorn.run("app.main:app", host=settings.host, port=settings.port, reload=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
AssetType = Literal["styles", "scripts", "images", "fonts", "media", "other"]
|
||||||
|
SUPPORTED_ASSET_TYPES: tuple[AssetType, ...] = (
|
||||||
|
"styles",
|
||||||
|
"scripts",
|
||||||
|
"images",
|
||||||
|
"fonts",
|
||||||
|
"media",
|
||||||
|
"other",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureRequest(BaseModel):
|
||||||
|
url: str = Field(min_length=1, max_length=2_048)
|
||||||
|
include_external_assets: bool = True
|
||||||
|
parallel_connections: int = Field(default=6, ge=1)
|
||||||
|
download_asset_types: list[AssetType] = Field(
|
||||||
|
default_factory=lambda: list(SUPPORTED_ASSET_TYPES), max_length=len(SUPPORTED_ASSET_TYPES)
|
||||||
|
)
|
||||||
|
skip_file_types: list[str] = Field(default_factory=list, max_length=24)
|
||||||
|
use_proxy: bool = False
|
||||||
|
max_pages: int = Field(default=150, ge=1)
|
||||||
|
max_depth: int = Field(default=5, ge=0)
|
||||||
|
max_bytes_mb: int = Field(default=100, ge=1)
|
||||||
|
max_duration_seconds: int = Field(default=300, ge=10)
|
||||||
|
retention_days: int = Field(default=7, ge=1)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
import httpcore
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class PinnedAddressBook:
|
||||||
|
"""Thread-safe, short-lived host-to-validated-IP mapping for direct HTTP connections."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._addresses: dict[tuple[str, int], tuple[str, ...]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def pin(self, host: str, port: int, addresses: Iterable[str]) -> None:
|
||||||
|
values = tuple(addresses)
|
||||||
|
if not values:
|
||||||
|
raise ValueError("At least one validated address is required.")
|
||||||
|
with self._lock:
|
||||||
|
self._addresses[(host.rstrip(".").lower(), port)] = values
|
||||||
|
|
||||||
|
def addresses_for(self, host: str, port: int) -> tuple[str, ...]:
|
||||||
|
with self._lock:
|
||||||
|
addresses = self._addresses.get((host.rstrip(".").lower(), port))
|
||||||
|
if not addresses:
|
||||||
|
raise httpcore.ConnectError(f"No validated address is available for {host}:{port}")
|
||||||
|
return addresses
|
||||||
|
|
||||||
|
|
||||||
|
class PinnedNetworkBackend(httpcore.NetworkBackend):
|
||||||
|
"""Dials only validated numeric addresses while httpcore retains the logical hostname."""
|
||||||
|
|
||||||
|
def __init__(self, address_book: PinnedAddressBook) -> None:
|
||||||
|
self._address_book = address_book
|
||||||
|
self._backend = httpcore.SyncBackend()
|
||||||
|
|
||||||
|
def connect_tcp(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
timeout: float | None = None,
|
||||||
|
local_address: str | None = None,
|
||||||
|
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
|
||||||
|
) -> httpcore.NetworkStream:
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for address in self._address_book.addresses_for(host, port):
|
||||||
|
try:
|
||||||
|
return self._backend.connect_tcp(
|
||||||
|
address,
|
||||||
|
port,
|
||||||
|
timeout=timeout,
|
||||||
|
local_address=local_address,
|
||||||
|
socket_options=socket_options,
|
||||||
|
)
|
||||||
|
except (httpcore.ConnectError, httpcore.ConnectTimeout) as error:
|
||||||
|
last_error = error
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
raise httpcore.ConnectError(f"Could not connect to a validated address for {host}:{port}")
|
||||||
|
|
||||||
|
def connect_unix_socket(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
timeout: float | None = None,
|
||||||
|
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
|
||||||
|
) -> httpcore.NetworkStream:
|
||||||
|
return self._backend.connect_unix_socket(
|
||||||
|
path, timeout=timeout, socket_options=socket_options
|
||||||
|
)
|
||||||
|
|
||||||
|
def sleep(self, seconds: float) -> None:
|
||||||
|
self._backend.sleep(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
class PinnedHTTPTransport(httpx.HTTPTransport):
|
||||||
|
"""HTTPX transport that prevents a second, uncontrolled DNS lookup at connect time."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
address_book: PinnedAddressBook,
|
||||||
|
limits: httpx.Limits,
|
||||||
|
proxy: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
verify=True,
|
||||||
|
trust_env=False,
|
||||||
|
http1=True,
|
||||||
|
http2=False,
|
||||||
|
limits=limits,
|
||||||
|
proxy=proxy,
|
||||||
|
retries=0,
|
||||||
|
)
|
||||||
|
# HTTPX constructs a direct httpcore ConnectionPool for this transport.
|
||||||
|
self._pool._network_backend = PinnedNetworkBackend(address_book)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.capture import CaptureService
|
||||||
|
from app.config import Settings
|
||||||
|
from app.db import Database
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class JobRunner:
|
||||||
|
"""SQLite-backed local workers. Every process safely claims jobs through a database lease."""
|
||||||
|
|
||||||
|
def __init__(self, database: Database, settings: Settings) -> None:
|
||||||
|
self.database = database
|
||||||
|
self.settings = settings
|
||||||
|
self.stop_event = threading.Event()
|
||||||
|
self.threads: list[threading.Thread] = []
|
||||||
|
self.maintenance_thread: threading.Thread | None = None
|
||||||
|
self._last_expiry_sweep = 0.0
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if not self.maintenance_thread:
|
||||||
|
self.maintenance_thread = threading.Thread(
|
||||||
|
target=self._run_maintenance,
|
||||||
|
name="siteharbor-maintenance",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
self.maintenance_thread.start()
|
||||||
|
if self.settings.worker_enabled and not self.threads:
|
||||||
|
for index in range(self.settings.worker_concurrency):
|
||||||
|
worker_id = f"{socket.gethostname()}-{os.getpid()}-{index}-{uuid.uuid4().hex[:8]}"
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=self._run_worker,
|
||||||
|
args=(worker_id,),
|
||||||
|
name=f"siteharbor-worker-{index}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
self.threads.append(thread)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.stop_event.set()
|
||||||
|
for thread in self.threads:
|
||||||
|
thread.join(timeout=5)
|
||||||
|
if self.maintenance_thread:
|
||||||
|
self.maintenance_thread.join(timeout=2)
|
||||||
|
|
||||||
|
def _run_worker(self, worker_id: str) -> None:
|
||||||
|
service = CaptureService(self.database, self.settings, worker_id)
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
try:
|
||||||
|
job = self.database.claim_next(worker_id, self.settings.lease_seconds)
|
||||||
|
if not job:
|
||||||
|
self.stop_event.wait(self.settings.queue_poll_seconds)
|
||||||
|
continue
|
||||||
|
service.run(job)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("SiteHarbor worker loop failed")
|
||||||
|
self.stop_event.wait(self.settings.queue_poll_seconds)
|
||||||
|
|
||||||
|
def _run_maintenance(self) -> None:
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
try:
|
||||||
|
self._expire_artifacts_if_due()
|
||||||
|
self._sweep_orphaned_attempts()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("SiteHarbor maintenance loop failed")
|
||||||
|
self.stop_event.wait(10)
|
||||||
|
|
||||||
|
def _expire_artifacts_if_due(self) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self._last_expiry_sweep < 60:
|
||||||
|
return
|
||||||
|
self._last_expiry_sweep = now
|
||||||
|
for job in self.database.expired_ready_jobs():
|
||||||
|
self._unlink_under_root(job.get("artifact_path"), self.settings.artifacts_dir)
|
||||||
|
self._unlink_under_root(job.get("report_path"), self.settings.reports_dir)
|
||||||
|
self.database.mark_expired(str(job["id"]))
|
||||||
|
|
||||||
|
def _sweep_orphaned_attempts(self) -> None:
|
||||||
|
cutoff = time.time() - max(7_200, self.settings.max_duration_seconds_cap + 3_600)
|
||||||
|
referenced_paths = {Path(path).resolve() for path in self.database.referenced_data_paths()}
|
||||||
|
|
||||||
|
for path in self.settings.work_dir.iterdir():
|
||||||
|
try:
|
||||||
|
if path.stat().st_mtime >= cutoff:
|
||||||
|
continue
|
||||||
|
if path.is_dir():
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Could not remove orphaned SiteHarbor work directory: %s", path)
|
||||||
|
|
||||||
|
for root in (self.settings.artifacts_dir, self.settings.reports_dir):
|
||||||
|
for path in root.iterdir():
|
||||||
|
try:
|
||||||
|
if path.resolve() in referenced_paths or path.stat().st_mtime >= cutoff:
|
||||||
|
continue
|
||||||
|
if path.is_file():
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Could not remove orphaned SiteHarbor output: %s", path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _unlink_under_root(path_value: object, root: Path) -> None:
|
||||||
|
if not isinstance(path_value, str) or not path_value:
|
||||||
|
return
|
||||||
|
path = Path(path_value)
|
||||||
|
try:
|
||||||
|
if path.resolve().is_relative_to(root.resolve()):
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Could not remove expired SiteHarbor artifact: %s", path)
|
||||||
+1382
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,679 @@
|
|||||||
|
const form = document.querySelector("#capture-form");
|
||||||
|
const urlInput = document.querySelector("#capture-url");
|
||||||
|
const submitButton = document.querySelector("#capture-submit");
|
||||||
|
const formMessage = document.querySelector("#form-message");
|
||||||
|
const jobGrid = document.querySelector("#job-grid");
|
||||||
|
const emptyState = document.querySelector("#empty-state");
|
||||||
|
const refreshButton = document.querySelector("#refresh-jobs");
|
||||||
|
const serviceStatus = document.querySelector("#service-status");
|
||||||
|
const reportDialog = document.querySelector("#report-dialog");
|
||||||
|
const reportContent = document.querySelector("#report-content");
|
||||||
|
const closeReportButton = document.querySelector("#close-report");
|
||||||
|
|
||||||
|
const controls = {
|
||||||
|
externalAssets: document.querySelector("#external-assets"),
|
||||||
|
parallelConnections: document.querySelector("#parallel-connections"),
|
||||||
|
parallelValue: document.querySelector("#parallel-value"),
|
||||||
|
maxPages: document.querySelector("#max-pages"),
|
||||||
|
maxDepth: document.querySelector("#max-depth"),
|
||||||
|
maxBytes: document.querySelector("#max-bytes"),
|
||||||
|
maxDuration: document.querySelector("#max-duration"),
|
||||||
|
retentionDays: document.querySelector("#retention-days"),
|
||||||
|
budgetSummary: document.querySelector("#budget-summary"),
|
||||||
|
assetTypes: [...document.querySelectorAll("[data-asset-type]")],
|
||||||
|
skipFileTypes: document.querySelector("#skip-file-types"),
|
||||||
|
useProxy: document.querySelector("#use-proxy"),
|
||||||
|
proxySummary: document.querySelector("#proxy-summary"),
|
||||||
|
proxyDetail: document.querySelector("#proxy-detail"),
|
||||||
|
proxyRow: document.querySelector(".proxy-row"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const publicStats = {
|
||||||
|
websites: document.querySelector("#stat-websites"),
|
||||||
|
data: document.querySelector("#stat-data"),
|
||||||
|
active: document.querySelector("#stat-active"),
|
||||||
|
files: document.querySelector("#stat-files"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const OWNER_STORAGE_KEY = "siteharbor.owner-token.v1";
|
||||||
|
const SESSION_STORAGE_KEY = "siteharbor.sessions.v1";
|
||||||
|
const OWNER_HEADER = "X-SiteHarbor-Session";
|
||||||
|
const MAX_STORED_SESSIONS = 40;
|
||||||
|
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{24,200}$/;
|
||||||
|
|
||||||
|
const jobs = new Map();
|
||||||
|
const streams = new Map();
|
||||||
|
let storageAvailable = true;
|
||||||
|
const ownerToken = getOwnerToken();
|
||||||
|
|
||||||
|
function createOwnerToken() {
|
||||||
|
if (window.crypto?.randomUUID) return window.crypto.randomUUID();
|
||||||
|
if (window.crypto?.getRandomValues) {
|
||||||
|
const bytes = new Uint8Array(24);
|
||||||
|
window.crypto.getRandomValues(bytes);
|
||||||
|
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
return `browser-${Date.now()}-${Math.random().toString(36).slice(2).padEnd(16, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOwnerToken() {
|
||||||
|
try {
|
||||||
|
const existing = window.localStorage.getItem(OWNER_STORAGE_KEY);
|
||||||
|
if (existing && TOKEN_PATTERN.test(existing)) return existing;
|
||||||
|
const token = createOwnerToken();
|
||||||
|
window.localStorage.setItem(OWNER_STORAGE_KEY, token);
|
||||||
|
return token;
|
||||||
|
} catch {
|
||||||
|
storageAvailable = false;
|
||||||
|
return createOwnerToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredSessions() {
|
||||||
|
if (!storageAvailable) return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const sessions = Array.isArray(parsed) ? parsed : parsed.sessions;
|
||||||
|
if (!Array.isArray(sessions)) return [];
|
||||||
|
return sessions.filter((session) => typeof session?.id === "string" && session.id.length > 0);
|
||||||
|
} catch {
|
||||||
|
storageAvailable = false;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredSessions(sessions) {
|
||||||
|
if (!storageAvailable) return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
SESSION_STORAGE_KEY,
|
||||||
|
JSON.stringify({ version: 1, sessions: sessions.slice(0, MAX_STORED_SESSIONS) }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
storageAvailable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotJob(job) {
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
url: job.url,
|
||||||
|
state: job.state,
|
||||||
|
phase: job.phase,
|
||||||
|
message: job.message,
|
||||||
|
error_message: job.error_message,
|
||||||
|
warnings: Array.isArray(job.warnings) ? job.warnings.slice(0, 5) : [],
|
||||||
|
created_at: job.created_at,
|
||||||
|
updated_at: job.updated_at,
|
||||||
|
started_at: job.started_at,
|
||||||
|
completed_at: job.completed_at,
|
||||||
|
expires_at: job.expires_at,
|
||||||
|
cancel_requested: job.cancel_requested,
|
||||||
|
stats: job.stats || {},
|
||||||
|
options: job.options || {},
|
||||||
|
archive: job.archive || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberJob(job) {
|
||||||
|
const sessions = readStoredSessions();
|
||||||
|
const record = { id: job.id, created_at: job.created_at, job: snapshotJob(job) };
|
||||||
|
const existingIndex = sessions.findIndex((session) => session.id === job.id);
|
||||||
|
if (existingIndex >= 0) sessions[existingIndex] = { ...sessions[existingIndex], ...record };
|
||||||
|
else sessions.unshift(record);
|
||||||
|
sessions.sort((left, right) => sortByCreated(right.job || right, left.job || left));
|
||||||
|
writeStoredSessions(sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgetJob(jobId) {
|
||||||
|
jobs.delete(jobId);
|
||||||
|
closeEvents(jobId);
|
||||||
|
writeStoredSessions(readStoredSessions().filter((session) => session.id !== jobId));
|
||||||
|
renderJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByCreated(left, right) {
|
||||||
|
return String(left.created_at || "").localeCompare(String(right.created_at || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value) {
|
||||||
|
const amount = Number(value) || 0;
|
||||||
|
if (!amount) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const index = Math.min(Math.floor(Math.log(amount) / Math.log(1024)), units.length - 1);
|
||||||
|
const rounded = amount / (1024 ** index);
|
||||||
|
return `${rounded >= 10 || index === 0 ? rounded.toFixed(0) : rounded.toFixed(1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(value) {
|
||||||
|
return new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(
|
||||||
|
Number(value) || 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(value) {
|
||||||
|
if (!value) return "--";
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime())
|
||||||
|
? "--"
|
||||||
|
: date.toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostname(url) {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMessage(message = "", kind = "") {
|
||||||
|
formMessage.textContent = message;
|
||||||
|
formMessage.className = `form-message ${kind}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setServiceStatus(message, online = false) {
|
||||||
|
serviceStatus.querySelector("span").textContent = message;
|
||||||
|
serviceStatus.dataset.online = String(online);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(control, fallback) {
|
||||||
|
const value = Number.parseInt(control.value, 10);
|
||||||
|
return Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
const value = Number(seconds) || 0;
|
||||||
|
if (value >= 60 && value % 60 === 0) return `${value / 60} min`;
|
||||||
|
return `${value} sec`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBudgetSummary() {
|
||||||
|
controls.budgetSummary.textContent = [
|
||||||
|
`${readNumber(controls.maxPages, 150)} pages`,
|
||||||
|
`${readNumber(controls.maxBytes, 100)} MB`,
|
||||||
|
formatDuration(readNumber(controls.maxDuration, 300)),
|
||||||
|
].join(" / ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateParallelValue() {
|
||||||
|
controls.parallelValue.value = controls.parallelConnections.value;
|
||||||
|
controls.parallelValue.textContent = controls.parallelConnections.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProxySummary() {
|
||||||
|
if (controls.useProxy.disabled) {
|
||||||
|
controls.proxySummary.textContent = "Not configured";
|
||||||
|
} else if (controls.useProxy.checked) {
|
||||||
|
controls.proxySummary.textContent = "Approved proxy";
|
||||||
|
} else {
|
||||||
|
controls.proxySummary.textContent = "Direct connection";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setControlLimits(capabilities) {
|
||||||
|
const defaults = capabilities.defaults || {};
|
||||||
|
const limits = capabilities.limits || {};
|
||||||
|
const bindings = [
|
||||||
|
[controls.maxPages, "max_pages"],
|
||||||
|
[controls.maxDepth, "max_depth"],
|
||||||
|
[controls.maxBytes, "max_bytes_mb"],
|
||||||
|
[controls.maxDuration, "max_duration_seconds"],
|
||||||
|
[controls.retentionDays, "max_retention_days"],
|
||||||
|
];
|
||||||
|
bindings.forEach(([control, key]) => {
|
||||||
|
if (limits[key] !== undefined) control.max = limits[key];
|
||||||
|
});
|
||||||
|
if (limits.max_parallel_connections !== undefined) {
|
||||||
|
controls.parallelConnections.max = limits.max_parallel_connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
controls.maxPages.value = defaults.max_pages ?? controls.maxPages.value;
|
||||||
|
controls.maxDepth.value = defaults.max_depth ?? controls.maxDepth.value;
|
||||||
|
controls.maxBytes.value = defaults.max_bytes_mb ?? controls.maxBytes.value;
|
||||||
|
controls.maxDuration.value = defaults.max_duration_seconds ?? controls.maxDuration.value;
|
||||||
|
controls.retentionDays.value = defaults.retention_days ?? controls.retentionDays.value;
|
||||||
|
controls.externalAssets.checked = defaults.include_external_assets ?? true;
|
||||||
|
controls.parallelConnections.value = defaults.parallel_connections ?? controls.parallelConnections.value;
|
||||||
|
|
||||||
|
const selectedTypes = new Set(defaults.download_asset_types || []);
|
||||||
|
controls.assetTypes.forEach((control) => {
|
||||||
|
control.checked = selectedTypes.has(control.dataset.assetType);
|
||||||
|
});
|
||||||
|
controls.skipFileTypes.value = (defaults.skip_file_types || []).join(", ");
|
||||||
|
controls.useProxy.checked = Boolean(defaults.use_proxy);
|
||||||
|
controls.useProxy.disabled = !capabilities.proxy_available;
|
||||||
|
controls.proxyRow.classList.toggle("is-unavailable", !capabilities.proxy_available);
|
||||||
|
controls.proxyDetail.textContent = capabilities.proxy_available
|
||||||
|
? "Use the operator-approved route for this capture."
|
||||||
|
: "No operator-approved proxy route is available on this service.";
|
||||||
|
|
||||||
|
updateBudgetSummary();
|
||||||
|
updateParallelValue();
|
||||||
|
updateProxySummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, options = {}, { session = true } = {}) {
|
||||||
|
const headers = new Headers(options.headers || {});
|
||||||
|
if (session) headers.set(OWNER_HEADER, ownerToken);
|
||||||
|
if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
||||||
|
|
||||||
|
const response = await fetch(path, { cache: "no-store", ...options, headers });
|
||||||
|
const body = response.status === 204 ? null : await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(body?.detail || "SiteHarbor could not complete that request.");
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(job) {
|
||||||
|
return ["queued", "running"].includes(job.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertJob(job, { persist = true, connect = true } = {}) {
|
||||||
|
jobs.set(job.id, job);
|
||||||
|
if (persist) rememberJob(job);
|
||||||
|
renderJobs();
|
||||||
|
if (connect && isActive(job)) connectEvents(job.id);
|
||||||
|
else if (!isActive(job)) closeEvents(job.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJobs() {
|
||||||
|
const orderedJobs = [...jobs.values()].sort((left, right) => sortByCreated(right, left));
|
||||||
|
emptyState.hidden = orderedJobs.length > 0;
|
||||||
|
jobGrid.replaceChildren(...orderedJobs.map(createJobCard));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createElement(tag, className, text) {
|
||||||
|
const element = document.createElement(tag);
|
||||||
|
if (className) element.className = className;
|
||||||
|
if (text !== undefined) element.textContent = text;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobProgress(job) {
|
||||||
|
if (job.state === "ready") return 100;
|
||||||
|
if (["failed", "cancelled", "expired"].includes(job.state)) return 100;
|
||||||
|
const phase = String(job.phase || "").toLowerCase();
|
||||||
|
if (phase.includes("packag")) return 94;
|
||||||
|
if (phase.includes("rewrit")) return 88;
|
||||||
|
if (phase.includes("validat") || phase.includes("prepar")) return 6;
|
||||||
|
const maximum = Number(job.options?.max_pages) || 0;
|
||||||
|
const fetched = Number(job.stats?.pages_fetched) || 0;
|
||||||
|
if (!maximum) return 16;
|
||||||
|
return Math.max(10, Math.min(82, Math.round((fetched / maximum) * 82)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createJobCard(job) {
|
||||||
|
const card = createElement("article", "job-card");
|
||||||
|
card.dataset.state = job.state;
|
||||||
|
|
||||||
|
const topLine = createElement("div", "job-topline");
|
||||||
|
topLine.append(
|
||||||
|
createElement("div", "job-host", hostname(job.url)),
|
||||||
|
createElement("span", `state-pill ${job.state}`, job.phase || job.state),
|
||||||
|
);
|
||||||
|
card.append(topLine);
|
||||||
|
card.append(createElement("p", "job-url", job.url));
|
||||||
|
card.append(createElement("p", "job-phase", job.error_message || job.message || "Waiting for status"));
|
||||||
|
|
||||||
|
const progress = createElement("div", "job-progress");
|
||||||
|
progress.append(createElement("span"));
|
||||||
|
progress.firstElementChild.style.setProperty("--progress", `${jobProgress(job)}%`);
|
||||||
|
card.append(progress);
|
||||||
|
|
||||||
|
const stats = createElement("div", "job-stats");
|
||||||
|
const statItems = [
|
||||||
|
[job.stats?.pages_fetched || 0, "pages"],
|
||||||
|
[job.stats?.files_written || 0, "files"],
|
||||||
|
[formatBytes(job.stats?.bytes_downloaded || 0), "collected"],
|
||||||
|
];
|
||||||
|
statItems.forEach(([value, label]) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.append(createElement("strong", "", String(value)), createElement("span", "", label));
|
||||||
|
stats.append(item);
|
||||||
|
});
|
||||||
|
card.append(stats);
|
||||||
|
|
||||||
|
const meta = createElement("div", "job-meta");
|
||||||
|
meta.append(
|
||||||
|
createElement("span", "", `Created ${formatTime(job.created_at)}`),
|
||||||
|
createElement(
|
||||||
|
"span",
|
||||||
|
"",
|
||||||
|
job.archive?.available
|
||||||
|
? `Archive ${formatBytes(job.archive.size_bytes)}`
|
||||||
|
: `${job.options?.max_pages || "--"} page budget`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
card.append(meta);
|
||||||
|
|
||||||
|
const tags = createElement("div", "job-tags");
|
||||||
|
const parallel = job.options?.parallel_connections;
|
||||||
|
if (parallel) tags.append(createElement("span", "", `${parallel} connections`));
|
||||||
|
tags.append(
|
||||||
|
createElement(
|
||||||
|
"span",
|
||||||
|
"",
|
||||||
|
job.options?.include_external_assets ? "external assets" : "first-party only",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (job.options?.skip_file_types?.length) {
|
||||||
|
tags.append(createElement("span", "", `skip .${job.options.skip_file_types[0]}`));
|
||||||
|
}
|
||||||
|
card.append(tags);
|
||||||
|
|
||||||
|
if (job.warnings?.length) card.append(createElement("p", "job-warning", job.warnings[0]));
|
||||||
|
|
||||||
|
const actions = createElement("div", "job-actions");
|
||||||
|
if (job.archive?.available) {
|
||||||
|
const download = createElement("button", "job-action primary", "Download ZIP");
|
||||||
|
download.type = "button";
|
||||||
|
download.addEventListener("click", () => downloadArchive(job, download));
|
||||||
|
actions.append(download);
|
||||||
|
|
||||||
|
const report = createElement("button", "job-action", "View report");
|
||||||
|
report.type = "button";
|
||||||
|
report.addEventListener("click", () => showReport(job.id));
|
||||||
|
actions.append(report);
|
||||||
|
|
||||||
|
const remove = createElement("button", "job-action danger", "Delete archive");
|
||||||
|
remove.type = "button";
|
||||||
|
remove.addEventListener("click", () => deleteJob(job.id));
|
||||||
|
actions.append(remove);
|
||||||
|
}
|
||||||
|
if (isActive(job)) {
|
||||||
|
const cancel = createElement("button", "job-action danger", job.cancel_requested ? "Cancelling" : "Cancel");
|
||||||
|
cancel.type = "button";
|
||||||
|
cancel.disabled = Boolean(job.cancel_requested);
|
||||||
|
cancel.addEventListener("click", () => cancelJob(job.id));
|
||||||
|
actions.append(cancel);
|
||||||
|
}
|
||||||
|
if (!isActive(job) && !job.archive?.available) {
|
||||||
|
const forget = createElement("button", "job-action", "Forget session");
|
||||||
|
forget.type = "button";
|
||||||
|
forget.addEventListener("click", () => forgetJob(job.id));
|
||||||
|
actions.append(forget);
|
||||||
|
}
|
||||||
|
card.append(actions);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectEvents(jobId) {
|
||||||
|
if (streams.has(jobId)) return;
|
||||||
|
const eventUrl = `/api/captures/${encodeURIComponent(jobId)}/events?session=${encodeURIComponent(ownerToken)}`;
|
||||||
|
const stream = new EventSource(eventUrl);
|
||||||
|
streams.set(jobId, stream);
|
||||||
|
["state", "progress"].forEach((eventName) => {
|
||||||
|
stream.addEventListener(eventName, () => {
|
||||||
|
void refreshJob(jobId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
stream.addEventListener("terminal", () => {
|
||||||
|
closeEvents(jobId);
|
||||||
|
void refreshJob(jobId);
|
||||||
|
void loadPublicStats();
|
||||||
|
});
|
||||||
|
stream.onerror = () => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (streams.get(jobId) === stream) void refreshJob(jobId);
|
||||||
|
}, 1_000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEvents(jobId) {
|
||||||
|
const stream = streams.get(jobId);
|
||||||
|
if (stream) stream.close();
|
||||||
|
streams.delete(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshJob(jobId) {
|
||||||
|
try {
|
||||||
|
upsertJob(await request(`/api/captures/${encodeURIComponent(jobId)}`));
|
||||||
|
} catch (error) {
|
||||||
|
closeEvents(jobId);
|
||||||
|
if (error.status === 404) forgetJob(jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshKnownJobs() {
|
||||||
|
const storedIds = readStoredSessions().map((session) => session.id);
|
||||||
|
const knownIds = [...new Set([...storedIds, ...jobs.keys()])];
|
||||||
|
await Promise.all(knownIds.map((jobId) => refreshJob(jobId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStoredJobs() {
|
||||||
|
const sessions = readStoredSessions();
|
||||||
|
sessions.forEach((session) => {
|
||||||
|
if (session.job?.id && !jobs.has(session.id)) jobs.set(session.id, session.job);
|
||||||
|
});
|
||||||
|
renderJobs();
|
||||||
|
await refreshKnownJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPublicStats() {
|
||||||
|
try {
|
||||||
|
const stats = await request("/api/stats", {}, { session: false });
|
||||||
|
publicStats.websites.textContent = formatCount(stats.websites_cloned);
|
||||||
|
publicStats.data.textContent = formatBytes(stats.bytes_scraped);
|
||||||
|
publicStats.active.textContent = formatCount(stats.active_crawls);
|
||||||
|
publicStats.files.textContent = formatCount(stats.files_archived);
|
||||||
|
setServiceStatus("Capture service online", true);
|
||||||
|
} catch {
|
||||||
|
setServiceStatus("Service unavailable", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelJob(jobId) {
|
||||||
|
try {
|
||||||
|
upsertJob(await request(`/api/captures/${encodeURIComponent(jobId)}/cancel`, { method: "POST" }));
|
||||||
|
void loadPublicStats();
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteJob(jobId) {
|
||||||
|
try {
|
||||||
|
const updated = await request(`/api/captures/${encodeURIComponent(jobId)}`, { method: "DELETE" });
|
||||||
|
if (updated.state === "expired") forgetJob(jobId);
|
||||||
|
else upsertJob(updated);
|
||||||
|
void loadPublicStats();
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadArchive(job, button) {
|
||||||
|
if (!job.archive?.download_url) return;
|
||||||
|
const originalText = button.textContent;
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = "Preparing download";
|
||||||
|
try {
|
||||||
|
const response = await fetch(job.archive.download_url, {
|
||||||
|
headers: { [OWNER_HEADER]: ownerToken },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => null);
|
||||||
|
const error = new Error(body?.detail || "The archive could not be downloaded.");
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const objectUrl = URL.createObjectURL(await response.blob());
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = objectUrl;
|
||||||
|
anchor.download = `siteharbor-${job.id}.zip`;
|
||||||
|
document.body.append(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1_000);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error.message);
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showReport(jobId) {
|
||||||
|
try {
|
||||||
|
const report = await request(`/api/captures/${encodeURIComponent(jobId)}/report`);
|
||||||
|
renderReport(report);
|
||||||
|
reportDialog.showModal();
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReport(report) {
|
||||||
|
reportContent.replaceChildren();
|
||||||
|
const summary = createElement("div", "report-summary");
|
||||||
|
const stats = report.stats || {};
|
||||||
|
[
|
||||||
|
[stats.pages_fetched || 0, "pages"],
|
||||||
|
[stats.assets_fetched || 0, "assets"],
|
||||||
|
[formatBytes(stats.bytes_downloaded || 0), "downloaded"],
|
||||||
|
[report.quality || "unknown", "capture quality"],
|
||||||
|
].forEach(([value, label]) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.append(createElement("strong", "", String(value)), createElement("span", "", label));
|
||||||
|
summary.append(item);
|
||||||
|
});
|
||||||
|
reportContent.append(summary);
|
||||||
|
|
||||||
|
const details = createElement("ul", "report-list");
|
||||||
|
[
|
||||||
|
`Entry point: ${report.entry_point || "not available"}`,
|
||||||
|
`Capture duration: ${report.elapsed_seconds || 0} seconds`,
|
||||||
|
`Captured resources: ${(report.resources || []).length}`,
|
||||||
|
].forEach((line) => details.append(createElement("li", "", line)));
|
||||||
|
reportContent.append(details);
|
||||||
|
|
||||||
|
if (report.warnings?.length) {
|
||||||
|
reportContent.append(createElement("h3", "report-subhead", "Warnings"));
|
||||||
|
const warnings = createElement("ul", "report-list");
|
||||||
|
report.warnings.forEach((warning) => warnings.append(createElement("li", "", warning)));
|
||||||
|
reportContent.append(warnings);
|
||||||
|
}
|
||||||
|
if (report.errors?.length) {
|
||||||
|
reportContent.append(createElement("h3", "report-subhead", "Resource failures"));
|
||||||
|
const errors = createElement("ul", "report-list");
|
||||||
|
report.errors.slice(0, 20).forEach((error) => {
|
||||||
|
errors.append(createElement("li", "", `${error.url}: ${error.message}`));
|
||||||
|
});
|
||||||
|
reportContent.append(errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedAssetTypes() {
|
||||||
|
return controls.assetTypes
|
||||||
|
.filter((control) => control.checked)
|
||||||
|
.map((control) => control.dataset.assetType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function skippedFileTypes() {
|
||||||
|
return controls.skipFileTypes.value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const url = urlInput.value.trim();
|
||||||
|
if (!url) {
|
||||||
|
setMessage("Enter a public website URL to begin.");
|
||||||
|
urlInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage("");
|
||||||
|
submitButton.disabled = true;
|
||||||
|
submitButton.querySelector("span").textContent = "Creating crawl";
|
||||||
|
try {
|
||||||
|
const job = await request("/api/captures", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
url,
|
||||||
|
include_external_assets: controls.externalAssets.checked,
|
||||||
|
parallel_connections: readNumber(controls.parallelConnections, 6),
|
||||||
|
download_asset_types: selectedAssetTypes(),
|
||||||
|
skip_file_types: skippedFileTypes(),
|
||||||
|
use_proxy: controls.useProxy.checked && !controls.useProxy.disabled,
|
||||||
|
max_pages: readNumber(controls.maxPages, 150),
|
||||||
|
max_depth: readNumber(controls.maxDepth, 5),
|
||||||
|
max_bytes_mb: readNumber(controls.maxBytes, 100),
|
||||||
|
max_duration_seconds: readNumber(controls.maxDuration, 300),
|
||||||
|
retention_days: readNumber(controls.retentionDays, 7),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
upsertJob(job);
|
||||||
|
setMessage("Crawl queued. This browser will reconnect to it automatically.", "success");
|
||||||
|
urlInput.value = "";
|
||||||
|
void loadPublicStats();
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error.message);
|
||||||
|
} finally {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
submitButton.querySelector("span").textContent = "Start private crawl";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
[
|
||||||
|
controls.maxPages,
|
||||||
|
controls.maxDepth,
|
||||||
|
controls.maxBytes,
|
||||||
|
controls.maxDuration,
|
||||||
|
controls.retentionDays,
|
||||||
|
].forEach((control) => control.addEventListener("input", updateBudgetSummary));
|
||||||
|
controls.parallelConnections.addEventListener("input", updateParallelValue);
|
||||||
|
controls.useProxy.addEventListener("change", updateProxySummary);
|
||||||
|
|
||||||
|
refreshButton.addEventListener("click", async () => {
|
||||||
|
refreshButton.disabled = true;
|
||||||
|
try {
|
||||||
|
await Promise.all([refreshKnownJobs(), loadPublicStats()]);
|
||||||
|
} finally {
|
||||||
|
refreshButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
closeReportButton.addEventListener("click", () => reportDialog.close());
|
||||||
|
reportDialog.addEventListener("click", (event) => {
|
||||||
|
if (event.target === reportDialog) reportDialog.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
void refreshKnownJobs();
|
||||||
|
void loadPublicStats();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener("online", () => {
|
||||||
|
void refreshKnownJobs();
|
||||||
|
void loadPublicStats();
|
||||||
|
});
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
streams.forEach((stream) => stream.close());
|
||||||
|
});
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
if (!storageAvailable) {
|
||||||
|
setMessage("Browser storage is unavailable, so this session cannot be restored after this tab closes.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setControlLimits(await request("/api/capabilities", {}, { session: false }));
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error.message);
|
||||||
|
}
|
||||||
|
await Promise.all([loadStoredJobs(), loadPublicStats()]);
|
||||||
|
window.setInterval(() => {
|
||||||
|
void refreshKnownJobs();
|
||||||
|
void loadPublicStats();
|
||||||
|
}, 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
void initialize();
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#151715">
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Create browser-owned offline archives of public websites with SiteHarbor."
|
||||||
|
>
|
||||||
|
<title>{{ app_name }} - Public web capture</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='app.css') }}">
|
||||||
|
<script defer src="{{ url_for('static', path='app.js') }}"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="site-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<a class="brand" href="/" aria-label="SiteHarbor home">
|
||||||
|
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
|
||||||
|
<span>siteharbor</span>
|
||||||
|
</a>
|
||||||
|
<div class="topbar-meta">
|
||||||
|
<span class="public-label">PUBLIC WEB CAPTURE</span>
|
||||||
|
<span class="service-status" id="service-status" aria-live="polite">
|
||||||
|
<i></i><span>Checking service</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="workbench" aria-label="Website capture workspace">
|
||||||
|
<div class="observatory">
|
||||||
|
<div class="observatory-copy">
|
||||||
|
<p class="eyebrow">LIVE NETWORK SIGNAL</p>
|
||||||
|
<h1>Copy the web.<br><em>Keep it close.</em></h1>
|
||||||
|
<p class="lede">
|
||||||
|
Build an offline-ready snapshot of a public site. The service runs the crawl;
|
||||||
|
this browser keeps the key to its own sessions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-grid" aria-live="polite" aria-label="Public service statistics">
|
||||||
|
<article class="metric-card metric-primary">
|
||||||
|
<span class="metric-value" id="stat-websites">--</span>
|
||||||
|
<span class="metric-label">Websites cloned</span>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-value" id="stat-data">--</span>
|
||||||
|
<span class="metric-label">Data collected</span>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-value" id="stat-active">--</span>
|
||||||
|
<span class="metric-label">Crawls running</span>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-value" id="stat-files">--</span>
|
||||||
|
<span class="metric-label">Files preserved</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="observatory-foot">
|
||||||
|
<span class="pulse-dot" aria-hidden="true"></span>
|
||||||
|
<p>Metrics are shared totals. Crawl URLs and archives stay private to the browser session that created them.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="capture-console" id="capture-form" novalidate>
|
||||||
|
<div class="console-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">NEW SESSION</p>
|
||||||
|
<h2>Set your crawl.</h2>
|
||||||
|
</div>
|
||||||
|
<span class="console-step">01 / 01</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="url-field" for="capture-url">
|
||||||
|
<span class="field-label">Public website URL</span>
|
||||||
|
<span class="url-input-wrap">
|
||||||
|
<span aria-hidden="true">//</span>
|
||||||
|
<input
|
||||||
|
id="capture-url"
|
||||||
|
name="url"
|
||||||
|
type="url"
|
||||||
|
inputmode="url"
|
||||||
|
autocomplete="url"
|
||||||
|
placeholder="https://example.com"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="quick-settings">
|
||||||
|
<label class="switch-row" for="external-assets">
|
||||||
|
<span>
|
||||||
|
<strong>Include external assets</strong>
|
||||||
|
<small>Keep linked CDN styles, scripts, fonts, and media.</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch">
|
||||||
|
<input id="external-assets" type="checkbox" checked>
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="connection-control" for="parallel-connections">
|
||||||
|
<span>
|
||||||
|
<strong>Parallel connections</strong>
|
||||||
|
<small>Balance capture speed with a site's capacity.</small>
|
||||||
|
</span>
|
||||||
|
<span class="connection-value"><output id="parallel-value" for="parallel-connections">6</output> streams</span>
|
||||||
|
<input id="parallel-connections" type="range" min="1" max="6" value="6">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="advanced-settings">
|
||||||
|
<details class="setting-panel">
|
||||||
|
<summary>
|
||||||
|
<span>Crawl limits</span>
|
||||||
|
<span class="summary-value" id="budget-summary">150 pages / 100 MB / 5 min</span>
|
||||||
|
</summary>
|
||||||
|
<div class="limit-grid">
|
||||||
|
<label>
|
||||||
|
<span>Pages</span>
|
||||||
|
<input id="max-pages" type="number" min="1" step="1" value="150">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Depth</span>
|
||||||
|
<input id="max-depth" type="number" min="0" step="1" value="5">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Size (MB)</span>
|
||||||
|
<input id="max-bytes" type="number" min="1" step="1" value="100">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Time (sec)</span>
|
||||||
|
<input id="max-duration" type="number" min="10" step="10" value="300">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Keep (days)</span>
|
||||||
|
<input id="retention-days" type="number" min="1" step="1" value="7">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="setting-panel">
|
||||||
|
<summary>
|
||||||
|
<span>File rules</span>
|
||||||
|
<span class="summary-value">Choose what comes aboard</span>
|
||||||
|
</summary>
|
||||||
|
<div class="file-rules">
|
||||||
|
<p>Pages are always copied. Select the asset groups that should be downloaded.</p>
|
||||||
|
<div class="asset-choices" role="group" aria-label="Asset types to download">
|
||||||
|
<label><input data-asset-type="styles" type="checkbox" checked><span>Styles</span></label>
|
||||||
|
<label><input data-asset-type="scripts" type="checkbox" checked><span>Scripts</span></label>
|
||||||
|
<label><input data-asset-type="images" type="checkbox" checked><span>Images</span></label>
|
||||||
|
<label><input data-asset-type="fonts" type="checkbox" checked><span>Fonts</span></label>
|
||||||
|
<label><input data-asset-type="media" type="checkbox" checked><span>Media</span></label>
|
||||||
|
<label><input data-asset-type="other" type="checkbox" checked><span>Other files</span></label>
|
||||||
|
</div>
|
||||||
|
<label class="text-rule" for="skip-file-types">
|
||||||
|
<span>Skip specific extensions</span>
|
||||||
|
<input
|
||||||
|
id="skip-file-types"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="pdf, zip, mp4"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="setting-panel">
|
||||||
|
<summary>
|
||||||
|
<span>Network route</span>
|
||||||
|
<span class="summary-value" id="proxy-summary">Direct connection</span>
|
||||||
|
</summary>
|
||||||
|
<label class="switch-row proxy-row" for="use-proxy">
|
||||||
|
<span>
|
||||||
|
<strong>Use service proxy</strong>
|
||||||
|
<small id="proxy-detail">An operator-approved proxy can be used for this crawl.</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch">
|
||||||
|
<input id="use-proxy" type="checkbox">
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="form-message" id="form-message" aria-live="polite"></p>
|
||||||
|
<div class="console-actions">
|
||||||
|
<button class="capture-button" id="capture-submit" type="submit">
|
||||||
|
<span>Start private crawl</span>
|
||||||
|
<span aria-hidden="true">-></span>
|
||||||
|
</button>
|
||||||
|
<p>Only crawl sites you are allowed to archive. Private networks and unsafe ports are blocked.</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="activity-section" aria-labelledby="activity-title">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">THIS BROWSER</p>
|
||||||
|
<h2 id="activity-title">Your local sessions</h2>
|
||||||
|
<p>Session records stay in this browser's local storage. Reopen this page to reconnect to an active crawl.</p>
|
||||||
|
</div>
|
||||||
|
<button class="quiet-button" id="refresh-jobs" type="button">Refresh sessions</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state" id="empty-state">
|
||||||
|
<span class="empty-mark" aria-hidden="true">+</span>
|
||||||
|
<div>
|
||||||
|
<h3>No local sessions yet</h3>
|
||||||
|
<p>Start a crawl above. Its status and archive will remain available from this browser after a reconnect.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="job-grid" id="job-grid" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer-note">
|
||||||
|
<span>SiteHarbor captures browser-delivered static content.</span>
|
||||||
|
<span>Dynamic APIs, logins, service workers, and server behavior may not work offline.</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog class="report-dialog" id="report-dialog" aria-labelledby="report-title">
|
||||||
|
<div class="dialog-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">CAPTURE REPORT</p>
|
||||||
|
<h2 id="report-title">Archive details</h2>
|
||||||
|
</div>
|
||||||
|
<button class="icon-button" id="close-report" type="button" aria-label="Close report">x</button>
|
||||||
|
</div>
|
||||||
|
<div class="report-content" id="report-content"></div>
|
||||||
|
</dialog>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import quote, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
|
||||||
|
class UrlPolicyError(ValueError):
|
||||||
|
"""Raised when a URL is structurally unsafe or points at a blocked network."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class NetworkRules:
|
||||||
|
allow_private_networks: bool = False
|
||||||
|
allow_nonstandard_ports: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_url(value: str, rules: NetworkRules) -> str:
|
||||||
|
raw = value.strip()
|
||||||
|
if not raw:
|
||||||
|
raise UrlPolicyError("Enter a website address.")
|
||||||
|
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = f"https://{raw}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(raw)
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError as error:
|
||||||
|
raise UrlPolicyError("The website address has an invalid port.") from error
|
||||||
|
|
||||||
|
if parsed.scheme.lower() not in {"http", "https"}:
|
||||||
|
raise UrlPolicyError("Only HTTP and HTTPS websites can be captured.")
|
||||||
|
if not parsed.hostname:
|
||||||
|
raise UrlPolicyError("The website address needs a hostname.")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise UrlPolicyError("Website addresses with embedded credentials are not allowed.")
|
||||||
|
|
||||||
|
host = parsed.hostname.rstrip(".").lower()
|
||||||
|
try:
|
||||||
|
host = host.encode("idna").decode("ascii")
|
||||||
|
except UnicodeError as error:
|
||||||
|
raise UrlPolicyError("The website hostname is not valid.") from error
|
||||||
|
|
||||||
|
default_port = 443 if parsed.scheme.lower() == "https" else 80
|
||||||
|
if port and port != default_port and not rules.allow_nonstandard_ports:
|
||||||
|
raise UrlPolicyError("Only ports 80 and 443 are allowed by this SiteHarbor instance.")
|
||||||
|
|
||||||
|
host_for_netloc = f"[{host}]" if ":" in host else host
|
||||||
|
netloc = host_for_netloc if not port or port == default_port else f"{host_for_netloc}:{port}"
|
||||||
|
path_value = parsed.path or "/"
|
||||||
|
if re.search(r"%(?![0-9A-Fa-f]{2})", path_value):
|
||||||
|
raise UrlPolicyError("The website address has an invalid percent escape.")
|
||||||
|
# Keep encoded delimiters such as %2F intact: decoding them changes resource identity.
|
||||||
|
path = quote(path_value, safe="/%:@!$&'()*+,;=-._~")
|
||||||
|
return urlunsplit((parsed.scheme.lower(), netloc, path, parsed.query, ""))
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_url(value: str, rules: NetworkRules) -> str:
|
||||||
|
"""Return a stable URL key with its fragment removed."""
|
||||||
|
return normalize_url(value, rules)
|
||||||
|
|
||||||
|
|
||||||
|
def hostname(value: str) -> str:
|
||||||
|
host = urlsplit(value).hostname
|
||||||
|
if not host:
|
||||||
|
raise UrlPolicyError("The URL has no hostname.")
|
||||||
|
return host.rstrip(".").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def is_public_address(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||||
|
return bool(
|
||||||
|
address.is_global
|
||||||
|
and not address.is_loopback
|
||||||
|
and not address.is_link_local
|
||||||
|
and not address.is_multicast
|
||||||
|
and not address.is_unspecified
|
||||||
|
and not address.is_reserved
|
||||||
|
and not address.is_private
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_and_validate(value: str, rules: NetworkRules) -> tuple[str, ...]:
|
||||||
|
"""Resolve a host and reject non-public destinations unless explicitly in dev mode."""
|
||||||
|
host = hostname(value)
|
||||||
|
try:
|
||||||
|
results = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
||||||
|
except socket.gaierror as error:
|
||||||
|
raise UrlPolicyError(f"Could not resolve {host}.") from error
|
||||||
|
|
||||||
|
addresses: set[str] = set()
|
||||||
|
for _, _, _, _, sockaddr in results:
|
||||||
|
address = ipaddress.ip_address(sockaddr[0])
|
||||||
|
addresses.add(str(address))
|
||||||
|
if not rules.allow_private_networks and not is_public_address(address):
|
||||||
|
raise UrlPolicyError("Private, local, and reserved network targets are blocked.")
|
||||||
|
|
||||||
|
if not addresses:
|
||||||
|
raise UrlPolicyError(f"Could not resolve {host}.")
|
||||||
|
return tuple(sorted(addresses))
|
||||||
|
|
||||||
|
|
||||||
|
def same_site_host(left: str, right: str) -> bool:
|
||||||
|
"""Allow an exact host or the common example.com/www.example.com redirect pair."""
|
||||||
|
left = left.lower().removeprefix("www.")
|
||||||
|
right = right.lower().removeprefix("www.")
|
||||||
|
return left == right
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling>=1.25"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "siteharbor"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A standalone, bounded web capture service for offline archives."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
authors = [{ name = "SiteHarbor" }]
|
||||||
|
dependencies = [
|
||||||
|
"beautifulsoup4>=4.12,<5",
|
||||||
|
"fastapi>=0.115,<1",
|
||||||
|
"httpcore>=1,<2",
|
||||||
|
"httpx>=0.27,<1",
|
||||||
|
"jinja2>=3.1,<4",
|
||||||
|
"uvicorn[standard]>=0.30,<1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3,<9",
|
||||||
|
"pytest-cov>=5,<7",
|
||||||
|
"ruff>=0.8,<1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
siteharbor = "app.main:run"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["app"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-q"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B"]
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import replace
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
from app.capture import CaptureService
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import Database
|
||||||
|
|
||||||
|
|
||||||
|
class MinimalSiteHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
if self.path == "/":
|
||||||
|
body = (
|
||||||
|
b"<html><head><link rel='stylesheet' href='/site.css'></head>"
|
||||||
|
b"<body>Home</body></html>"
|
||||||
|
)
|
||||||
|
content_type = "text/html"
|
||||||
|
status = 200
|
||||||
|
elif self.path == "/site.css":
|
||||||
|
body = b"body { color: #123456; }"
|
||||||
|
content_type = "text/css"
|
||||||
|
status = 200
|
||||||
|
else:
|
||||||
|
body = b"not found"
|
||||||
|
content_type = "text/plain"
|
||||||
|
status = 404
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args) -> None: # type: ignore[override]
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_service_creates_private_archive(tmp_path) -> None:
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), MinimalSiteHandler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
port = server.server_address[1]
|
||||||
|
|
||||||
|
test_settings = replace(
|
||||||
|
settings,
|
||||||
|
data_dir=tmp_path,
|
||||||
|
database_path=tmp_path / "siteharbor.sqlite3",
|
||||||
|
work_dir=tmp_path / "work",
|
||||||
|
artifacts_dir=tmp_path / "artifacts",
|
||||||
|
reports_dir=tmp_path / "reports",
|
||||||
|
allow_private_networks=True,
|
||||||
|
allow_nonstandard_ports=True,
|
||||||
|
respect_robots=False,
|
||||||
|
)
|
||||||
|
test_settings.ensure_directories()
|
||||||
|
database = Database(test_settings.database_path)
|
||||||
|
database.initialize()
|
||||||
|
source_url = f"http://127.0.0.1:{port}/"
|
||||||
|
job = database.create_job(
|
||||||
|
source_url,
|
||||||
|
source_url,
|
||||||
|
{
|
||||||
|
"include_external_assets": False,
|
||||||
|
"max_pages": 10,
|
||||||
|
"max_depth": 2,
|
||||||
|
"max_bytes": 2_000_000,
|
||||||
|
"max_duration_seconds": 30,
|
||||||
|
"retention_days": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
claimed = database.claim_next("test-worker", lease_seconds=120)
|
||||||
|
assert claimed is not None
|
||||||
|
|
||||||
|
try:
|
||||||
|
CaptureService(database, test_settings, "test-worker").run(claimed)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
completed = database.get_job(job["id"])
|
||||||
|
assert completed is not None
|
||||||
|
assert completed["state"] == "ready"
|
||||||
|
assert completed["artifact_path"]
|
||||||
|
assert completed["report_path"]
|
||||||
|
|
||||||
|
with zipfile.ZipFile(completed["artifact_path"]) as archive:
|
||||||
|
names = archive.namelist()
|
||||||
|
assert any(name.endswith("siteharbor-manifest.json") for name in names)
|
||||||
|
assert any(name.endswith("site.css") for name in names)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import replace
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.crawler import CaptureOptions, SiteCrawler
|
||||||
|
|
||||||
|
|
||||||
|
class FixtureHandler(BaseHTTPRequestHandler):
|
||||||
|
fixtures = {
|
||||||
|
"/robots.txt": ("text/plain", b"User-agent: *\nAllow: /\n"),
|
||||||
|
"/": (
|
||||||
|
"text/html",
|
||||||
|
b"""<!doctype html><html><head>
|
||||||
|
<link rel='stylesheet' href='/assets/site.css'>
|
||||||
|
<script src='/assets/app.js'></script>
|
||||||
|
</head><body>
|
||||||
|
<img src='/assets/logo.svg' srcset='/assets/logo.svg 1x, /assets/hero.png 2x'>
|
||||||
|
<a href='/about/'>About</a>
|
||||||
|
</body></html>""",
|
||||||
|
),
|
||||||
|
"/about/": ("text/html", b"<html><body><a href='/'>Home</a></body></html>"),
|
||||||
|
"/assets/site.css": (
|
||||||
|
"text/css",
|
||||||
|
b"@import '/assets/nested.css'; .hero { background: url('/assets/hero.png'); }",
|
||||||
|
),
|
||||||
|
"/assets/nested.css": ("text/css", b".logo { background: url('/assets/logo.svg'); }"),
|
||||||
|
"/assets/app.js": ("application/javascript", b"import('/assets/chunk.js');"),
|
||||||
|
"/assets/chunk.js": ("application/javascript", b"export const loaded = true;"),
|
||||||
|
"/assets/logo.svg": ("image/svg+xml", b"<svg xmlns='http://www.w3.org/2000/svg'></svg>"),
|
||||||
|
"/assets/hero.png": ("image/png", b"not-a-real-png-but-a-static-asset"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
content_type, body = self.fixtures.get(self.path, ("text/plain", b"not found"))
|
||||||
|
status = 200 if self.path in self.fixtures else 404
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args) -> None: # type: ignore[override]
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def test_crawler_captures_and_rewrites_static_fixture(tmp_path) -> None:
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), FixtureHandler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
port = server.server_address[1]
|
||||||
|
|
||||||
|
test_settings = replace(
|
||||||
|
settings,
|
||||||
|
data_dir=tmp_path,
|
||||||
|
work_dir=tmp_path / "work",
|
||||||
|
artifacts_dir=tmp_path / "artifacts",
|
||||||
|
reports_dir=tmp_path / "reports",
|
||||||
|
allow_private_networks=True,
|
||||||
|
allow_nonstandard_ports=True,
|
||||||
|
respect_robots=True,
|
||||||
|
fetch_concurrency=3,
|
||||||
|
)
|
||||||
|
events: list[tuple[str, str]] = []
|
||||||
|
crawler = SiteCrawler(
|
||||||
|
settings=test_settings,
|
||||||
|
options=CaptureOptions(
|
||||||
|
source_url=f"http://127.0.0.1:{port}/",
|
||||||
|
include_external_assets=False,
|
||||||
|
max_pages=10,
|
||||||
|
max_depth=3,
|
||||||
|
max_bytes=2_000_000,
|
||||||
|
max_duration_seconds=30,
|
||||||
|
),
|
||||||
|
cancelled=lambda: False,
|
||||||
|
on_progress=lambda phase, message, stats, warnings: events.append((phase, message)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = crawler.run(tmp_path / "work")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
host_dir = result.site_dir / "127.0.0.1"
|
||||||
|
root_html = (host_dir / "index.html").read_text(encoding="utf-8")
|
||||||
|
css = (host_dir / "assets" / "site.css").read_text(encoding="utf-8")
|
||||||
|
javascript = (host_dir / "assets" / "app.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert result.entry_point == "127.0.0.1/index.html"
|
||||||
|
assert result.stats["pages_fetched"] == 2
|
||||||
|
assert result.stats["assets_fetched"] >= 6
|
||||||
|
assert "about/index.html" in root_html
|
||||||
|
assert "assets/site.css" in root_html
|
||||||
|
assert "hero.png" in css
|
||||||
|
assert "import('chunk.js')" in javascript
|
||||||
|
assert (host_dir / "assets" / "chunk.js").is_file()
|
||||||
|
assert (result.site_dir / "siteharbor-manifest.json").is_file()
|
||||||
|
assert events
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.db import Database
|
||||||
|
|
||||||
|
|
||||||
|
def capture_config() -> dict[str, int | bool]:
|
||||||
|
return {
|
||||||
|
"include_external_assets": True,
|
||||||
|
"max_pages": 20,
|
||||||
|
"max_depth": 3,
|
||||||
|
"max_bytes": 1_000_000,
|
||||||
|
"max_duration_seconds": 60,
|
||||||
|
"retention_days": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sqlite_lease_claims_one_queued_job(tmp_path) -> None:
|
||||||
|
database = Database(tmp_path / "siteharbor.sqlite3")
|
||||||
|
database.initialize()
|
||||||
|
job = database.create_job(
|
||||||
|
"https://example.com",
|
||||||
|
"https://example.com/",
|
||||||
|
capture_config(),
|
||||||
|
)
|
||||||
|
|
||||||
|
claimed = database.claim_next("worker-a", lease_seconds=30)
|
||||||
|
|
||||||
|
assert claimed is not None
|
||||||
|
assert claimed["id"] == job["id"]
|
||||||
|
assert claimed["state"] == "running"
|
||||||
|
assert database.claim_next("worker-b", lease_seconds=30) is None
|
||||||
|
|
||||||
|
assert database.update_progress(
|
||||||
|
job["id"],
|
||||||
|
"worker-a",
|
||||||
|
30,
|
||||||
|
phase="Packaging",
|
||||||
|
message="Creating archive",
|
||||||
|
stats={
|
||||||
|
"pages_found": 1,
|
||||||
|
"pages_fetched": 1,
|
||||||
|
"assets_found": 0,
|
||||||
|
"assets_fetched": 0,
|
||||||
|
"bytes_downloaded": 10,
|
||||||
|
"files_written": 1,
|
||||||
|
},
|
||||||
|
warnings=[],
|
||||||
|
)
|
||||||
|
assert database.mark_ready(
|
||||||
|
job["id"],
|
||||||
|
"worker-a",
|
||||||
|
artifact_path=str(tmp_path / "capture.zip"),
|
||||||
|
report_path=str(tmp_path / "capture.json"),
|
||||||
|
archive_size=10,
|
||||||
|
entry_point="example.com/index.html",
|
||||||
|
stats={
|
||||||
|
"pages_found": 1,
|
||||||
|
"pages_fetched": 1,
|
||||||
|
"assets_found": 0,
|
||||||
|
"assets_fetched": 0,
|
||||||
|
"bytes_downloaded": 10,
|
||||||
|
"files_written": 1,
|
||||||
|
},
|
||||||
|
warnings=[],
|
||||||
|
retention_days=1,
|
||||||
|
)
|
||||||
|
ready = database.get_job(job["id"])
|
||||||
|
assert ready is not None
|
||||||
|
assert ready["state"] == "ready"
|
||||||
|
assert ready["expires_at"] > ready["completed_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_cannot_cancel_without_a_user_cancellation_request(tmp_path) -> None:
|
||||||
|
database = Database(tmp_path / "siteharbor.sqlite3")
|
||||||
|
database.initialize()
|
||||||
|
job = database.create_job(
|
||||||
|
"https://example.com",
|
||||||
|
"https://example.com/",
|
||||||
|
{
|
||||||
|
**capture_config(),
|
||||||
|
"include_external_assets": False,
|
||||||
|
"max_pages": 1,
|
||||||
|
"max_depth": 0,
|
||||||
|
"max_bytes": 1_000,
|
||||||
|
"max_duration_seconds": 30,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert database.claim_next("worker-a", lease_seconds=30)
|
||||||
|
|
||||||
|
assert not database.mark_cancelled(job["id"], "worker-a", "Lease was lost")
|
||||||
|
running = database.get_job(job["id"])
|
||||||
|
assert running is not None
|
||||||
|
assert running["state"] == "running"
|
||||||
|
|
||||||
|
database.request_cancel(job["id"])
|
||||||
|
assert database.mark_cancelled(job["id"], "worker-a", "Cancelled by user")
|
||||||
|
cancelled = database.get_job(job["id"])
|
||||||
|
assert cancelled is not None
|
||||||
|
assert cancelled["state"] == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_cancel_requested_job_is_terminalized_before_reclaim(tmp_path) -> None:
|
||||||
|
database = Database(tmp_path / "siteharbor.sqlite3")
|
||||||
|
database.initialize()
|
||||||
|
job = database.create_job(
|
||||||
|
"https://example.com",
|
||||||
|
"https://example.com/",
|
||||||
|
{
|
||||||
|
**capture_config(),
|
||||||
|
"include_external_assets": False,
|
||||||
|
"max_pages": 1,
|
||||||
|
"max_depth": 0,
|
||||||
|
"max_bytes": 1_000,
|
||||||
|
"max_duration_seconds": 30,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert database.claim_next("worker-a", lease_seconds=30)
|
||||||
|
with database.connection() as connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE jobs
|
||||||
|
SET cancel_requested = 1, lease_expires_at = '2000-01-01T00:00:00+00:00'
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(job["id"],),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert database.claim_next("worker-b", lease_seconds=30) is None
|
||||||
|
cancelled = database.get_job(job["id"])
|
||||||
|
assert cancelled is not None
|
||||||
|
assert cancelled["state"] == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_token_scopes_jobs_and_public_stats(tmp_path) -> None:
|
||||||
|
database = Database(tmp_path / "siteharbor.sqlite3")
|
||||||
|
database.initialize()
|
||||||
|
first_owner = "a" * 32
|
||||||
|
second_owner = "b" * 32
|
||||||
|
first = database.create_job(
|
||||||
|
"https://one.example",
|
||||||
|
"https://one.example/",
|
||||||
|
capture_config(),
|
||||||
|
first_owner,
|
||||||
|
)
|
||||||
|
second = database.create_job(
|
||||||
|
"https://two.example",
|
||||||
|
"https://two.example/",
|
||||||
|
capture_config(),
|
||||||
|
second_owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert database.get_owned_job(first["id"], first_owner) is not None
|
||||||
|
assert database.get_owned_job(first["id"], second_owner) is None
|
||||||
|
assert [job["id"] for job in database.list_owned_jobs(first_owner)] == [first["id"]]
|
||||||
|
assert [job["id"] for job in database.list_owned_jobs(second_owner)] == [second["id"]]
|
||||||
|
assert database.public_stats() == {
|
||||||
|
"websites_cloned": 0,
|
||||||
|
"bytes_scraped": 0,
|
||||||
|
"active_crawls": 2,
|
||||||
|
"files_archived": 0,
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.main as main
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import Database
|
||||||
|
from app.scheduler import JobRunner
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_routes_are_scoped_to_the_browser_session(tmp_path, monkeypatch) -> None:
|
||||||
|
test_settings = replace(
|
||||||
|
settings,
|
||||||
|
data_dir=tmp_path,
|
||||||
|
database_path=tmp_path / "siteharbor.sqlite3",
|
||||||
|
work_dir=tmp_path / "work",
|
||||||
|
artifacts_dir=tmp_path / "artifacts",
|
||||||
|
reports_dir=tmp_path / "reports",
|
||||||
|
worker_enabled=False,
|
||||||
|
)
|
||||||
|
test_settings.ensure_directories()
|
||||||
|
test_database = Database(test_settings.database_path)
|
||||||
|
monkeypatch.setattr(main, "settings", test_settings)
|
||||||
|
monkeypatch.setattr(main, "database", test_database)
|
||||||
|
monkeypatch.setattr(main, "runner", JobRunner(test_database, test_settings))
|
||||||
|
|
||||||
|
owner_a = "a" * 32
|
||||||
|
owner_b = "b" * 32
|
||||||
|
with TestClient(main.app) as client:
|
||||||
|
homepage = client.get("/")
|
||||||
|
assert homepage.status_code == 200
|
||||||
|
assert "Copy the web." in homepage.text
|
||||||
|
assert client.get("/api/captures").status_code == 401
|
||||||
|
|
||||||
|
created = client.post(
|
||||||
|
"/api/captures",
|
||||||
|
headers={"X-SiteHarbor-Session": owner_a},
|
||||||
|
json={
|
||||||
|
"url": "https://example.com",
|
||||||
|
"parallel_connections": 2,
|
||||||
|
"download_asset_types": ["styles", "images"],
|
||||||
|
"skip_file_types": ["pdf, zip"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert created.status_code == 202
|
||||||
|
created_job = created.json()
|
||||||
|
job_id = created_job["id"]
|
||||||
|
assert created_job["options"]["parallel_connections"] == 2
|
||||||
|
assert created_job["options"]["download_asset_types"] == ["styles", "images"]
|
||||||
|
assert created_job["options"]["skip_file_types"] == ["pdf", "zip"]
|
||||||
|
|
||||||
|
assert client.get(
|
||||||
|
f"/api/captures/{job_id}", headers={"X-SiteHarbor-Session": owner_b}
|
||||||
|
).status_code == 404
|
||||||
|
assert client.post(
|
||||||
|
f"/api/captures/{job_id}/cancel", headers={"X-SiteHarbor-Session": owner_b}
|
||||||
|
).status_code == 404
|
||||||
|
|
||||||
|
owned_jobs = client.get("/api/captures", headers={"X-SiteHarbor-Session": owner_a})
|
||||||
|
assert owned_jobs.status_code == 200
|
||||||
|
assert [job["id"] for job in owned_jobs.json()["items"]] == [job_id]
|
||||||
|
|
||||||
|
stats = client.get("/api/stats")
|
||||||
|
assert stats.status_code == 200
|
||||||
|
assert stats.json()["active_crawls"] == 1
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.pinned_transport import PinnedAddressBook, PinnedNetworkBackend
|
||||||
|
|
||||||
|
|
||||||
|
def test_pinned_backend_dials_the_validated_address_not_hostname(monkeypatch) -> None:
|
||||||
|
address_book = PinnedAddressBook()
|
||||||
|
address_book.pin("example.com", 443, ["93.184.216.34"])
|
||||||
|
backend = PinnedNetworkBackend(address_book)
|
||||||
|
called: dict[str, object] = {}
|
||||||
|
|
||||||
|
class StubBackend:
|
||||||
|
def connect_tcp(self, host, port, **kwargs):
|
||||||
|
called["host"] = host
|
||||||
|
called["port"] = port
|
||||||
|
return object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(backend, "_backend", StubBackend())
|
||||||
|
|
||||||
|
backend.connect_tcp("example.com", 443)
|
||||||
|
|
||||||
|
assert called == {"host": "93.184.216.34", "port": 443}
|
||||||
|
|
||||||
|
|
||||||
|
def test_pinned_backend_rejects_unvalidated_host() -> None:
|
||||||
|
backend = PinnedNetworkBackend(PinnedAddressBook())
|
||||||
|
|
||||||
|
with pytest.raises(Exception, match="No validated address"):
|
||||||
|
backend.connect_tcp("example.com", 443)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.url_policy import NetworkRules, UrlPolicyError, normalize_url, resolve_and_validate
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_url_adds_https_and_removes_fragment() -> None:
|
||||||
|
result = normalize_url("example.com/docs#section", NetworkRules())
|
||||||
|
|
||||||
|
assert result == "https://example.com/docs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_url_preserves_encoded_path_delimiters() -> None:
|
||||||
|
result = normalize_url("https://example.com/a%2Fb", NetworkRules())
|
||||||
|
|
||||||
|
assert result == "https://example.com/a%2Fb"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_url_rejects_credentials_and_unsafe_ports() -> None:
|
||||||
|
with pytest.raises(UrlPolicyError, match="credentials"):
|
||||||
|
normalize_url("https://user:secret@example.com", NetworkRules())
|
||||||
|
|
||||||
|
with pytest.raises(UrlPolicyError, match="ports 80 and 443"):
|
||||||
|
normalize_url("https://example.com:8443", NetworkRules())
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_blocks_loopback_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
socket,
|
||||||
|
"getaddrinfo",
|
||||||
|
lambda *args, **kwargs: [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 0))],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(UrlPolicyError, match="Private, local, and reserved"):
|
||||||
|
resolve_and_validate("https://example.com", NetworkRules())
|
||||||
Reference in New Issue
Block a user