This commit is contained in:
kstyagi23
2026-09-05 06:07:25 +05:30
commit 07b953fed2
22 changed files with 5899 additions and 0 deletions
+22
View File
@@ -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
+103
View File
@@ -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
```
+1
View File
@@ -0,0 +1 @@
"""SiteHarbor application package."""
+302
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+684
View File
@@ -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
View File
@@ -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()
+31
View File
@@ -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)
+96
View File
@@ -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)
+121
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+679
View File
@@ -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();
+238
View File
@@ -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">-&gt;</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>
+109
View File
@@ -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
+44
View File
@@ -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"]
+91
View File
@@ -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)
+101
View File
@@ -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
+161
View File
@@ -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,
}
+67
View File
@@ -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
+31
View File
@@ -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)
+38
View File
@@ -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())