veilrender 0.2.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {veilrender-0.2.0 → veilrender-0.3.1}/PKG-INFO +4 -3
- {veilrender-0.2.0 → veilrender-0.3.1}/pyproject.toml +4 -2
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/__init__.py +1 -1
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/app.py +5 -1
- veilrender-0.3.1/src/veilrender/auth.py +114 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/browser.py +11 -0
- veilrender-0.3.1/src/veilrender/config.py +51 -0
- veilrender-0.3.1/src/veilrender/data/__init__.py +0 -0
- veilrender-0.3.1/src/veilrender/data/blocklist.txt +82621 -0
- veilrender-0.3.1/src/veilrender/filters.py +101 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/routes/render.py +31 -1
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/routes/screenshot.py +12 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/stats.py +2 -0
- veilrender-0.3.1/src/veilrender/storage.py +197 -0
- veilrender-0.3.1/src/veilrender/url_validator.py +123 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender.egg-info/PKG-INFO +4 -3
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender.egg-info/SOURCES.txt +5 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender.egg-info/requires.txt +3 -2
- veilrender-0.2.0/src/veilrender/auth.py +0 -39
- veilrender-0.2.0/src/veilrender/config.py +0 -25
- {veilrender-0.2.0 → veilrender-0.3.1}/README.md +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/setup.cfg +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/__main__.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/_vendor/__init__.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/_vendor/benchmark_compare.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/_vendor/cache.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/_vendor/config.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/_vendor/dotenv.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/_vendor/httpserver.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/_vendor/jsonc.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/_vendor/markdown.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/_vendor/readability.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/_vendor/retry.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/_vendor/soup.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/_vendor/structlog.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/_vendor/useragent.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/_vendor/yaml.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/cdp_proxy.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/models.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/routes/__init__.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/routes/dashboard.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender/routes/health.py +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender.egg-info/dependency_links.txt +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender.egg-info/entry_points.txt +0 -0
- {veilrender-0.2.0 → veilrender-0.3.1}/src/veilrender.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: veilrender
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Headless browser rendering API — self-hostable on HF Spaces, Docker, or bare metal
|
|
5
5
|
Author: Peng Ding
|
|
6
6
|
License-Expression: MIT
|
|
@@ -12,9 +12,10 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
12
12
|
Requires-Python: >=3.10
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
Requires-Dist: cloakbrowser>=0.3.0
|
|
15
|
+
Requires-Dist: minio>=7.2.0
|
|
15
16
|
Provides-Extra: dev
|
|
16
|
-
Requires-Dist: ruff
|
|
17
|
-
Requires-Dist: ty
|
|
17
|
+
Requires-Dist: ruff==0.15.20; extra == "dev"
|
|
18
|
+
Requires-Dist: ty==0.0.54; extra == "dev"
|
|
18
19
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
19
20
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
20
21
|
|
|
@@ -18,12 +18,13 @@ classifiers = [
|
|
|
18
18
|
|
|
19
19
|
dependencies = [
|
|
20
20
|
"cloakbrowser>=0.3.0",
|
|
21
|
+
"minio>=7.2.0",
|
|
21
22
|
]
|
|
22
23
|
|
|
23
24
|
[project.optional-dependencies]
|
|
24
25
|
dev = [
|
|
25
|
-
"ruff
|
|
26
|
-
"ty
|
|
26
|
+
"ruff==0.15.20",
|
|
27
|
+
"ty==0.0.54",
|
|
27
28
|
"pytest>=7.0.0",
|
|
28
29
|
"pytest-asyncio>=0.21.0",
|
|
29
30
|
]
|
|
@@ -43,6 +44,7 @@ version = { attr = "veilrender.__version__" }
|
|
|
43
44
|
|
|
44
45
|
[tool.setuptools.package-data]
|
|
45
46
|
"veilrender" = ["py.typed"]
|
|
47
|
+
"veilrender.data" = ["blocklist.txt"]
|
|
46
48
|
|
|
47
49
|
[tool.ruff]
|
|
48
50
|
target-version = "py310"
|
|
@@ -12,6 +12,7 @@ from veilrender.browser import browser_manager
|
|
|
12
12
|
from veilrender.cdp_proxy import handle_cdp_upgrade, is_websocket_upgrade
|
|
13
13
|
from veilrender.config import settings
|
|
14
14
|
from veilrender.routes import dashboard, health, render, screenshot
|
|
15
|
+
from veilrender.storage import storage_manager
|
|
15
16
|
|
|
16
17
|
logging.basicConfig(
|
|
17
18
|
level=logging.INFO,
|
|
@@ -36,7 +37,9 @@ async def _pipe(source: asyncio.StreamReader, dest: asyncio.StreamReader) -> Non
|
|
|
36
37
|
|
|
37
38
|
def create_app() -> App:
|
|
38
39
|
"""Create and configure the VeilRender application."""
|
|
39
|
-
app = App(
|
|
40
|
+
app = App(
|
|
41
|
+
max_body_size=64 * 1024
|
|
42
|
+
) # 64 KB — render/screenshot payloads are small JSON
|
|
40
43
|
|
|
41
44
|
# Register routes
|
|
42
45
|
health.register(app)
|
|
@@ -53,6 +56,7 @@ def main() -> None:
|
|
|
53
56
|
|
|
54
57
|
async def run_server() -> None:
|
|
55
58
|
await browser_manager.start()
|
|
59
|
+
await storage_manager.ensure_bucket()
|
|
56
60
|
|
|
57
61
|
shutdown_event = asyncio.Event()
|
|
58
62
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Token-based authentication with IP-based rate limiting.
|
|
2
|
+
|
|
3
|
+
Failed auth attempts are tracked per IP. After 3 failures within a
|
|
4
|
+
rolling window, the IP is banned for a configurable duration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
|
|
13
|
+
from veilrender._vendor.httpserver import HTTPException, Request
|
|
14
|
+
from veilrender.config import settings
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Fail2ban settings
|
|
19
|
+
_MAX_FAILURES = 3
|
|
20
|
+
_WINDOW_SECONDS = 300 # 5 minute rolling window
|
|
21
|
+
_BAN_SECONDS = 600 # 10 minute ban
|
|
22
|
+
|
|
23
|
+
# State: per-IP failure timestamps and ban expiry
|
|
24
|
+
_failures: dict[str, list[float]] = defaultdict(list)
|
|
25
|
+
_banned: dict[str, float] = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _get_client_ip(request: Request) -> str:
|
|
29
|
+
"""Extract client IP, respecting X-Forwarded-For behind reverse proxy."""
|
|
30
|
+
forwarded = request.headers.get("x-forwarded-for", "")
|
|
31
|
+
if forwarded:
|
|
32
|
+
# Take the first (leftmost) IP — the original client
|
|
33
|
+
return forwarded.split(",")[0].strip()
|
|
34
|
+
return request.client_addr[0] if hasattr(request, "client_addr") else "unknown"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_banned(ip: str) -> bool:
|
|
38
|
+
"""Check if an IP is currently banned."""
|
|
39
|
+
ban_until = _banned.get(ip)
|
|
40
|
+
if ban_until is None:
|
|
41
|
+
return False
|
|
42
|
+
if time.monotonic() >= ban_until:
|
|
43
|
+
del _banned[ip]
|
|
44
|
+
return False
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _record_failure(ip: str) -> None:
|
|
49
|
+
"""Record an auth failure and ban if threshold exceeded."""
|
|
50
|
+
now = time.monotonic()
|
|
51
|
+
|
|
52
|
+
# Clean old entries outside the window
|
|
53
|
+
_failures[ip] = [t for t in _failures[ip] if now - t < _WINDOW_SECONDS]
|
|
54
|
+
_failures[ip].append(now)
|
|
55
|
+
|
|
56
|
+
if len(_failures[ip]) >= _MAX_FAILURES:
|
|
57
|
+
_banned[ip] = now + _BAN_SECONDS
|
|
58
|
+
_failures.pop(ip, None)
|
|
59
|
+
logger.warning(
|
|
60
|
+
"IP %s banned for %ds after %d auth failures",
|
|
61
|
+
ip,
|
|
62
|
+
_BAN_SECONDS,
|
|
63
|
+
_MAX_FAILURES,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _clear_failures(ip: str) -> None:
|
|
68
|
+
"""Clear failure history on successful auth."""
|
|
69
|
+
_failures.pop(ip, None)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def verify_token(request: Request) -> None:
|
|
73
|
+
"""Verify the API token from header or query param.
|
|
74
|
+
|
|
75
|
+
Checks ``Authorization: Bearer <token>`` header first, then falls
|
|
76
|
+
back to ``?token=<token>`` query parameter.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
HTTPException: 403 if IP is banned, 401 if token is invalid,
|
|
80
|
+
403 if token is missing.
|
|
81
|
+
|
|
82
|
+
If ``VEILRENDER_API_TOKEN`` is not configured, auth is disabled.
|
|
83
|
+
"""
|
|
84
|
+
expected = settings.api_token
|
|
85
|
+
if expected is None:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
ip = _get_client_ip(request)
|
|
89
|
+
|
|
90
|
+
# Check ban first
|
|
91
|
+
if _is_banned(ip):
|
|
92
|
+
raise HTTPException(403, "Temporarily banned due to repeated auth failures")
|
|
93
|
+
|
|
94
|
+
# Check Authorization header
|
|
95
|
+
auth_header = request.headers.get("authorization", "")
|
|
96
|
+
if auth_header.startswith("Bearer "):
|
|
97
|
+
token = auth_header[7:].strip()
|
|
98
|
+
if token == expected:
|
|
99
|
+
_clear_failures(ip)
|
|
100
|
+
return
|
|
101
|
+
_record_failure(ip)
|
|
102
|
+
raise HTTPException(401, "Invalid token")
|
|
103
|
+
|
|
104
|
+
# Check query param
|
|
105
|
+
token_params = request.query_params.get("token", [])
|
|
106
|
+
if token_params:
|
|
107
|
+
if token_params[0] == expected:
|
|
108
|
+
_clear_failures(ip)
|
|
109
|
+
return
|
|
110
|
+
_record_failure(ip)
|
|
111
|
+
raise HTTPException(401, "Invalid token")
|
|
112
|
+
|
|
113
|
+
_record_failure(ip)
|
|
114
|
+
raise HTTPException(403, "Authentication required")
|
|
@@ -12,6 +12,7 @@ from cloakbrowser import ensure_binary, get_default_stealth_args
|
|
|
12
12
|
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
|
13
13
|
|
|
14
14
|
from veilrender.config import settings
|
|
15
|
+
from veilrender.filters import load_blocklist, make_route_handler
|
|
15
16
|
|
|
16
17
|
logger = logging.getLogger(__name__)
|
|
17
18
|
|
|
@@ -34,6 +35,14 @@ class BrowserManager:
|
|
|
34
35
|
self._semaphore = asyncio.Semaphore(settings.max_concurrent)
|
|
35
36
|
self._lock = asyncio.Lock()
|
|
36
37
|
|
|
38
|
+
# Load blocklist once at init if resource filtering is enabled
|
|
39
|
+
if settings.resource_filter:
|
|
40
|
+
self._blocklist = load_blocklist(settings.blocked_domains_extra)
|
|
41
|
+
self._route_handler = make_route_handler(self._blocklist)
|
|
42
|
+
else:
|
|
43
|
+
self._blocklist = frozenset()
|
|
44
|
+
self._route_handler = None
|
|
45
|
+
|
|
37
46
|
async def start(self) -> None:
|
|
38
47
|
"""Launch Chromium with CDP and connect Playwright to it."""
|
|
39
48
|
executable_path = ensure_binary()
|
|
@@ -173,6 +182,8 @@ class BrowserManager:
|
|
|
173
182
|
user_agent=None, # use Playwright default
|
|
174
183
|
)
|
|
175
184
|
page = await context.new_page()
|
|
185
|
+
if self._route_handler:
|
|
186
|
+
await page.route("**/*", self._route_handler)
|
|
176
187
|
yield context, page
|
|
177
188
|
finally:
|
|
178
189
|
if context:
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Application settings loaded from environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Settings:
|
|
9
|
+
"""Configuration from ``VEILRENDER_*`` environment variables."""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self.api_token: str | None = os.environ.get("VEILRENDER_API_TOKEN")
|
|
13
|
+
self.port: int = int(os.environ.get("VEILRENDER_PORT", "7860"))
|
|
14
|
+
self.host: str = os.environ.get("VEILRENDER_HOST", "0.0.0.0")
|
|
15
|
+
self.timeout: int = int(os.environ.get("VEILRENDER_TIMEOUT", "30000"))
|
|
16
|
+
self.viewport_width: int = int(
|
|
17
|
+
os.environ.get("VEILRENDER_VIEWPORT_WIDTH", "1280")
|
|
18
|
+
)
|
|
19
|
+
self.viewport_height: int = int(
|
|
20
|
+
os.environ.get("VEILRENDER_VIEWPORT_HEIGHT", "720")
|
|
21
|
+
)
|
|
22
|
+
self.max_concurrent: int = int(os.environ.get("VEILRENDER_MAX_CONCURRENT", "5"))
|
|
23
|
+
self.resource_filter: bool = (
|
|
24
|
+
os.environ.get("VEILRENDER_RESOURCE_FILTER", "true").lower() == "true"
|
|
25
|
+
)
|
|
26
|
+
_extra = os.environ.get("VEILRENDER_BLOCKED_DOMAINS_EXTRA", "")
|
|
27
|
+
self.blocked_domains_extra: list[str] = (
|
|
28
|
+
[d.strip() for d in _extra.split(",") if d.strip()] if _extra else []
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Cache settings
|
|
32
|
+
self.cache_enabled: bool = (
|
|
33
|
+
os.environ.get("VEILRENDER_CACHE_ENABLED", "false").lower() == "true"
|
|
34
|
+
)
|
|
35
|
+
self.cache_ttl: int = int(os.environ.get("VEILRENDER_CACHE_TTL", "86400"))
|
|
36
|
+
self.cache_l1_maxsize: int = int(
|
|
37
|
+
os.environ.get("VEILRENDER_CACHE_L1_MAXSIZE", "100")
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# S3-compatible storage (Cloudflare R2, Oracle Object Storage, etc.)
|
|
41
|
+
self.s3_endpoint: str | None = os.environ.get("VEILRENDER_S3_ENDPOINT")
|
|
42
|
+
self.s3_access_key: str | None = os.environ.get("VEILRENDER_S3_ACCESS_KEY")
|
|
43
|
+
self.s3_secret_key: str | None = os.environ.get("VEILRENDER_S3_SECRET_KEY")
|
|
44
|
+
self.s3_bucket: str = os.environ.get("VEILRENDER_S3_BUCKET", "veilrender-cache")
|
|
45
|
+
self.s3_region: str = os.environ.get("VEILRENDER_S3_REGION", "auto")
|
|
46
|
+
self.s3_secure: bool = (
|
|
47
|
+
os.environ.get("VEILRENDER_S3_SECURE", "true").lower() == "true"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
settings = Settings()
|
|
File without changes
|