diff --git a/README.md b/README.md
index e2e26d0..bd7c20e 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,9 @@ python -m uvicorn app.main:app --host 127.0.0.1 --port 8787 --workers 2
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.
+Completed ZIP archives and their reports are retained for at most two days after a capture finishes.
+The maintenance worker removes expired files and marks the capture as expired.
+
## Configuration
All configuration uses environment variables prefixed with `SITEHARBOR_`.
@@ -75,7 +78,7 @@ All configuration uses environment variables prefixed with `SITEHARBOR_`.
| `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_DEFAULT_RETENTION_DAYS` | `2` | Default retention period for completed artifacts; values cannot exceed two days. |
| `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:
diff --git a/app/config.py b/app/config.py
index f05f1c4..33b33e6 100644
--- a/app/config.py
+++ b/app/config.py
@@ -4,6 +4,8 @@ import os
from dataclasses import dataclass
from pathlib import Path
+MAX_ARCHIVE_RETENTION_DAYS = 2
+
def _as_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
@@ -59,6 +61,14 @@ class Settings:
data_dir = Path(os.getenv("SITEHARBOR_DATA_DIR", project_root / "data")).expanduser()
if not data_dir.is_absolute():
data_dir = project_root / data_dir
+ max_retention_days = min(
+ _as_int("SITEHARBOR_MAX_RETENTION_DAYS", MAX_ARCHIVE_RETENTION_DAYS),
+ MAX_ARCHIVE_RETENTION_DAYS,
+ )
+ default_retention_days = min(
+ _as_int("SITEHARBOR_DEFAULT_RETENTION_DAYS", MAX_ARCHIVE_RETENTION_DAYS),
+ max_retention_days,
+ )
return cls(
project_root=project_root,
@@ -77,8 +87,8 @@ class Settings:
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),
+ default_retention_days=default_retention_days,
+ max_retention_days=max_retention_days,
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),
diff --git a/app/db.py b/app/db.py
index 0ae8562..41972e3 100644
--- a/app/db.py
+++ b/app/db.py
@@ -9,6 +9,8 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
+from app.config import MAX_ARCHIVE_RETENTION_DAYS
+
def utcnow() -> datetime:
return datetime.now(UTC)
@@ -494,6 +496,7 @@ class Database:
) -> bool:
now_datetime = utcnow()
now = timestamp(now_datetime)
+ retention_days = min(retention_days, MAX_ARCHIVE_RETENTION_DAYS)
with self.connection() as connection:
cursor = connection.execute(
"""
@@ -644,10 +647,22 @@ class Database:
)
def expired_ready_jobs(self) -> list[dict[str, Any]]:
- now = timestamp()
+ now_datetime = utcnow()
+ now = timestamp(now_datetime)
+ maximum_completion_age = timestamp(
+ now_datetime - timedelta(days=MAX_ARCHIVE_RETENTION_DAYS)
+ )
with self.connection() as connection:
rows = connection.execute(
- "SELECT * FROM jobs WHERE state = 'ready' AND expires_at <= ?", (now,)
+ """
+ SELECT * FROM jobs
+ WHERE state = 'ready'
+ AND (
+ expires_at <= ?
+ OR COALESCE(completed_at, created_at) <= ?
+ )
+ """,
+ (now, maximum_completion_age),
).fetchall()
return [self._row_to_job(row) for row in rows]
diff --git a/app/models.py b/app/models.py
index 366fa42..46d2807 100644
--- a/app/models.py
+++ b/app/models.py
@@ -4,6 +4,8 @@ from typing import Literal
from pydantic import BaseModel, Field
+from app.config import MAX_ARCHIVE_RETENTION_DAYS
+
AssetType = Literal["styles", "scripts", "images", "fonts", "media", "other"]
SUPPORTED_ASSET_TYPES: tuple[AssetType, ...] = (
"styles",
@@ -28,4 +30,6 @@ class CaptureRequest(BaseModel):
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)
+ retention_days: int = Field(
+ default=MAX_ARCHIVE_RETENTION_DAYS, ge=1, le=MAX_ARCHIVE_RETENTION_DAYS
+ )
diff --git a/app/static/app.css b/app/static/app.css
index ddbf301..f5188f8 100644
--- a/app/static/app.css
+++ b/app/static/app.css
@@ -77,75 +77,156 @@ a:focus-visible {
margin: 0 auto;
}
-.topbar {
- display: flex;
+.site-header {
+ position: relative;
+ display: grid;
+ grid-template-columns: minmax(220px, 1fr) auto minmax(180px, 1fr);
align-items: center;
- justify-content: space-between;
- min-height: 92px;
+ gap: 28px;
+ min-height: 108px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
+.site-header::after {
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ width: 118px;
+ height: 2px;
+ content: "";
+ background: var(--mint);
+}
+
.brand {
- display: inline-flex;
+ display: inline-grid;
+ grid-template-columns: auto 1fr;
align-items: center;
- gap: 11px;
+ justify-self: start;
+ gap: 12px;
color: var(--ink);
- font-size: 1.06rem;
- font-weight: 780;
- letter-spacing: -0.045em;
text-decoration: none;
}
.brand-mark {
position: relative;
- display: block;
- width: 24px;
- height: 24px;
- transform: rotate(45deg);
- border: 1px solid rgba(152, 242, 187, 0.74);
- border-radius: 7px;
- background: rgba(79, 212, 138, 0.1);
+ display: grid;
+ width: 38px;
+ height: 38px;
+ place-items: center;
+ overflow: hidden;
+ border: 1px solid rgba(152, 242, 187, 0.48);
+ border-radius: 11px;
+ background:
+ linear-gradient(135deg, rgba(142, 234, 184, 0.22), rgba(6, 59, 44, 0.45)),
+ #0a2118;
+ box-shadow: inset 0 1px 0 rgba(238, 255, 244, 0.16);
+}
+
+.brand-mark::before {
+ position: absolute;
+ width: 23px;
+ height: 23px;
+ border: 1px solid rgba(197, 255, 218, 0.28);
+ border-radius: 50%;
+ content: "";
}
.brand-mark i {
position: absolute;
display: block;
- width: 5px;
- height: 5px;
+ z-index: 1;
+ width: 4px;
+ height: 4px;
border-radius: 50%;
background: var(--mint);
+ box-shadow: 0 0 8px rgba(142, 234, 184, 0.7);
}
.brand-mark i:nth-child(1) {
- top: 4px;
- left: 4px;
+ top: 10px;
+ left: 10px;
}
.brand-mark i:nth-child(2) {
- right: 4px;
- bottom: 4px;
+ right: 10px;
+ bottom: 10px;
opacity: 0.75;
}
.brand-mark i:nth-child(3) {
- top: 4px;
- right: 4px;
+ top: 10px;
+ right: 10px;
width: 3px;
height: 3px;
opacity: 0.55;
}
-.topbar-meta,
+.brand-copy {
+ display: grid;
+ gap: 3px;
+}
+
+.brand-kicker,
+.service-panel-label,
+.footer-kicker,
+.footer-index {
+ color: var(--quiet);
+ font-size: 0.58rem;
+ font-weight: 780;
+ letter-spacing: 0.14em;
+}
+
+.brand strong {
+ color: var(--paper);
+ font-size: 1.16rem;
+ font-weight: 760;
+ letter-spacing: -0.05em;
+}
+
+.site-nav {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 5px;
+ border: 1px solid rgba(235, 246, 235, 0.1);
+ border-radius: 999px;
+ background: rgba(1, 4, 2, 0.3);
+}
+
+.site-nav a {
+ padding: 7px 10px;
+ border-radius: 999px;
+ color: #aeb6ad;
+ font-size: 0.68rem;
+ font-weight: 650;
+ text-decoration: none;
+ transition: color 160ms ease, background 160ms ease;
+}
+
+.site-nav a:hover {
+ color: var(--mint-bright);
+ background: rgba(142, 234, 184, 0.1);
+}
+
+.service-panel {
+ display: grid;
+ justify-items: end;
+ gap: 6px;
+}
+
.service-status {
display: flex;
align-items: center;
+ gap: 8px;
+ color: #c3cbc2;
+ font-size: 0.72rem;
}
-.topbar-meta {
- gap: 20px;
+.service-status[data-online="false"] > i {
+ background: var(--danger);
+ box-shadow: 0 0 0 4px rgba(240, 151, 142, 0.08), 0 0 13px rgba(240, 151, 142, 0.55);
}
-.public-label,
.eyebrow {
color: var(--mint);
font-size: 0.65rem;
@@ -153,16 +234,6 @@ a:focus-visible {
letter-spacing: 0.16em;
}
-.public-label {
- color: var(--quiet);
-}
-
-.service-status {
- gap: 8px;
- color: #b7bdb7;
- font-size: 0.72rem;
-}
-
.service-status > i,
.pulse-dot {
display: inline-block;
@@ -1129,19 +1200,89 @@ h3 {
opacity: 0.56;
}
-.footer-note {
- display: flex;
- justify-content: space-between;
- gap: 22px;
- padding: 24px 0 31px;
+.site-footer {
+ margin-top: 7px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
- color: #7c827b;
- font-size: 0.68rem;
- line-height: 1.48;
}
-.footer-note span:last-child {
- max-width: 535px;
+.footer-top {
+ display: grid;
+ grid-template-columns: minmax(0, 0.86fr) minmax(0, 1.54fr);
+ gap: clamp(34px, 6vw, 88px);
+ padding: 43px 0 37px;
+}
+
+.footer-kicker {
+ margin: 0 0 13px;
+ color: var(--mint);
+}
+
+.footer-intro h2 {
+ max-width: 390px;
+ margin: 0 0 14px;
+ font-size: clamp(1.55rem, 2.6vw, 2.2rem);
+ line-height: 1.03;
+}
+
+.footer-intro > p:last-child {
+ max-width: 420px;
+ margin: 0;
+ color: #99a19a;
+ font-size: 0.78rem;
+ line-height: 1.62;
+}
+
+.footer-points {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.footer-point {
+ min-width: 0;
+ padding: 0 20px;
+}
+
+.footer-point:first-child {
+ padding-left: 0;
+}
+
+.footer-point + .footer-point {
+ border-left: 1px solid var(--line);
+}
+
+.footer-index {
+ display: block;
+ margin-bottom: 15px;
+ color: #768177;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+.footer-point h3 {
+ margin: 0 0 8px;
+ color: #e4e9e2;
+ font-size: 0.87rem;
+ letter-spacing: -0.025em;
+}
+
+.footer-point p {
+ margin: 0;
+ color: #838b83;
+ font-size: 0.7rem;
+ line-height: 1.58;
+}
+
+.footer-bottom {
+ display: flex;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 16px 0 27px;
+ border-top: 1px solid var(--line);
+ color: #687168;
+ font-size: 0.64rem;
+ line-height: 1.5;
+}
+
+.footer-bottom span:last-child {
text-align: right;
}
@@ -1261,6 +1402,15 @@ h3 {
}
@media (max-width: 930px) {
+ .site-header {
+ grid-template-columns: minmax(0, 1fr) auto;
+ min-height: 90px;
+ }
+
+ .site-nav {
+ display: none;
+ }
+
.workbench {
grid-template-columns: 1fr;
}
@@ -1272,6 +1422,11 @@ h3 {
.capture-console {
min-height: 0;
}
+
+ .footer-top {
+ grid-template-columns: 1fr;
+ gap: 33px;
+ }
}
@media (max-width: 680px) {
@@ -1279,18 +1434,40 @@ h3 {
width: min(100% - 28px, 1220px);
}
- .topbar {
- min-height: 72px;
+ .site-header {
+ min-height: 80px;
}
- .public-label {
+ .brand-mark {
+ width: 33px;
+ height: 33px;
+ }
+
+ .brand-mark::before {
+ width: 19px;
+ height: 19px;
+ }
+
+ .brand-mark i:nth-child(1) {
+ top: 8px;
+ left: 8px;
+ }
+
+ .brand-mark i:nth-child(2) {
+ right: 8px;
+ bottom: 8px;
+ }
+
+ .brand-mark i:nth-child(3) {
+ top: 8px;
+ right: 8px;
+ }
+
+ .brand-kicker,
+ .service-panel-label {
display: none;
}
- .topbar-meta {
- gap: 0;
- }
-
.service-status {
font-size: 0.66rem;
}
@@ -1338,14 +1515,12 @@ h3 {
}
.console-actions,
- .section-heading,
- .footer-note {
+ .section-heading {
align-items: flex-start;
flex-direction: column;
}
- .console-actions p,
- .footer-note span:last-child {
+ .console-actions p {
max-width: none;
text-align: left;
}
@@ -1365,6 +1540,39 @@ h3 {
.report-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
+
+ .footer-top {
+ padding: 34px 0 30px;
+ }
+
+ .footer-points {
+ grid-template-columns: 1fr;
+ gap: 0;
+ }
+
+ .footer-point,
+ .footer-point:first-child {
+ padding: 18px 0;
+ }
+
+ .footer-point:first-child {
+ padding-top: 0;
+ }
+
+ .footer-point + .footer-point {
+ border-top: 1px solid var(--line);
+ border-left: 0;
+ }
+
+ .footer-bottom {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 5px;
+ }
+
+ .footer-bottom span:last-child {
+ text-align: left;
+ }
}
@media (max-width: 390px) {
diff --git a/app/static/app.js b/app/static/app.js
index 1d96e12..9c0fe83 100644
--- a/app/static/app.js
+++ b/app/static/app.js
@@ -608,7 +608,7 @@ form.addEventListener("submit", async (event) => {
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),
+ retention_days: readNumber(controls.retentionDays, 2),
}),
});
upsertJob(job);
diff --git a/app/templates/index.html b/app/templates/index.html
index 5264e5e..570a3b5 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -4,38 +4,65 @@
+
+
-
{{ app_name }} - Public web capture
+
+
+
+
+
+ Website Downloader & Offline Archive Tool | {{ app_name }}
-