website-agent-server 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """Website Agent Server package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ from pathlib import Path
6
+
7
+ import uvicorn
8
+
9
+ from .config import settings
10
+ from .url_policy import HostAccessPolicy
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="website-agent-server",
16
+ description="Run the server-side browser proxy.",
17
+ )
18
+ parser.add_argument("--host", default=settings.host, help="Server bind host.")
19
+ parser.add_argument("--port", type=int, default=settings.port, help="Server port.")
20
+ parser.add_argument(
21
+ "--headed",
22
+ action="store_true",
23
+ help="Run Chromium with a visible browser window.",
24
+ )
25
+ parser.add_argument(
26
+ "--ignore-https-errors",
27
+ action="store_true",
28
+ help="Ignore remote TLS certificate errors.",
29
+ )
30
+ parser.add_argument(
31
+ "--allow-private-hosts",
32
+ action="store_true",
33
+ help="Allow private, local, and reserved network targets.",
34
+ )
35
+ parser.add_argument(
36
+ "--session-ttl-seconds",
37
+ type=int,
38
+ default=settings.session_ttl_seconds,
39
+ help="Idle session lifetime in seconds.",
40
+ )
41
+ parser.add_argument(
42
+ "--navigation-timeout-ms",
43
+ type=int,
44
+ default=settings.navigation_timeout_ms,
45
+ help="Navigation timeout in milliseconds.",
46
+ )
47
+ parser.add_argument(
48
+ "--frame-interval-seconds",
49
+ type=float,
50
+ default=settings.frame_interval_seconds,
51
+ help="Screenshot streaming interval in seconds.",
52
+ )
53
+ parser.add_argument(
54
+ "--screenshot-quality",
55
+ type=int,
56
+ default=settings.screenshot_quality,
57
+ help="Screenshot quality from 1 to 100. Values below 100 use JPEG; 100 uses PNG.",
58
+ )
59
+ parser.add_argument(
60
+ "--media-frame-interval-seconds",
61
+ type=float,
62
+ default=settings.media_frame_interval_seconds,
63
+ help="Screenshot streaming interval while remote media is playing.",
64
+ )
65
+ parser.add_argument(
66
+ "--media-screenshot-quality",
67
+ type=int,
68
+ default=settings.media_screenshot_quality,
69
+ help="JPEG screenshot quality while remote media is playing. Ignored when screenshot quality is 100.",
70
+ )
71
+ parser.add_argument(
72
+ "--min-viewport-width",
73
+ type=int,
74
+ default=settings.min_viewport_width,
75
+ help="Minimum remote viewport width.",
76
+ )
77
+ parser.add_argument(
78
+ "--min-viewport-height",
79
+ type=int,
80
+ default=settings.min_viewport_height,
81
+ help="Minimum remote viewport height.",
82
+ )
83
+ parser.add_argument(
84
+ "--max-viewport-width",
85
+ type=int,
86
+ default=settings.max_viewport_width,
87
+ help="Maximum remote viewport width.",
88
+ )
89
+ parser.add_argument(
90
+ "--max-viewport-height",
91
+ type=int,
92
+ default=settings.max_viewport_height,
93
+ help="Maximum remote viewport height.",
94
+ )
95
+ parser.add_argument(
96
+ "--data-dir",
97
+ type=Path,
98
+ default=settings.data_dir,
99
+ help="Runtime downloads and temporary uploads directory.",
100
+ )
101
+ parser.add_argument(
102
+ "--pin",
103
+ default=None,
104
+ help="Require this PIN before clients can use the proxy.",
105
+ )
106
+ parser.add_argument(
107
+ "--lock-url",
108
+ default=None,
109
+ help="Lock the UI to this initial URL and disable browser option controls.",
110
+ )
111
+ return parser
112
+
113
+
114
+ async def apply_args(args: argparse.Namespace) -> None:
115
+ settings.host = args.host
116
+ settings.port = args.port
117
+ settings.headless = not args.headed
118
+ settings.ignore_https_errors = args.ignore_https_errors
119
+ settings.allow_private_hosts = args.allow_private_hosts
120
+ settings.session_ttl_seconds = args.session_ttl_seconds
121
+ settings.navigation_timeout_ms = args.navigation_timeout_ms
122
+ settings.frame_interval_seconds = args.frame_interval_seconds
123
+ settings.screenshot_quality = max(1, min(100, args.screenshot_quality))
124
+ settings.media_frame_interval_seconds = max(0.05, args.media_frame_interval_seconds)
125
+ settings.media_screenshot_quality = max(1, min(99, args.media_screenshot_quality))
126
+ settings.min_viewport_width = args.min_viewport_width
127
+ settings.min_viewport_height = args.min_viewport_height
128
+ settings.max_viewport_width = args.max_viewport_width
129
+ settings.max_viewport_height = args.max_viewport_height
130
+ settings.data_dir = args.data_dir.resolve()
131
+ settings.pin = args.pin
132
+ if args.lock_url:
133
+ lock_url_policy = HostAccessPolicy(settings.allow_private_hosts)
134
+ settings.lock_url = await lock_url_policy.ensure_navigation_url_allowed(
135
+ args.lock_url,
136
+ verify_https=not settings.ignore_https_errors,
137
+ )
138
+ else:
139
+ settings.lock_url = None
140
+
141
+
142
+ def main() -> None:
143
+ parser = build_parser()
144
+ args = parser.parse_args()
145
+ try:
146
+ asyncio.run(apply_args(args))
147
+ except ValueError as exc:
148
+ parser.error(str(exc))
149
+ uvicorn.run(
150
+ "website_agent_server.main:app",
151
+ host=settings.host,
152
+ port=settings.port,
153
+ reload=False,
154
+ ws_ping_interval=None,
155
+ )
156
+
157
+
158
+ if __name__ == "__main__":
159
+ main()
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import hmac
4
+ import secrets
5
+ from hashlib import sha256
6
+
7
+ from fastapi import HTTPException, Request, WebSocket, status
8
+ from starlette.responses import Response
9
+
10
+ from .config import Settings
11
+
12
+
13
+ class PinAuth:
14
+ def __init__(self, settings: Settings) -> None:
15
+ self.settings = settings
16
+ self._secret = secrets.token_urlsafe(32)
17
+
18
+ @property
19
+ def enabled(self) -> bool:
20
+ return bool(self.settings.pin)
21
+
22
+ def verify_pin(self, pin: str) -> bool:
23
+ expected = self.settings.pin or ""
24
+ return hmac.compare_digest(pin, expected)
25
+
26
+ def token(self) -> str:
27
+ if not self.settings.pin:
28
+ return ""
29
+ digest = hmac.new(
30
+ self._secret.encode("utf-8"),
31
+ self.settings.pin.encode("utf-8"),
32
+ sha256,
33
+ ).hexdigest()
34
+ return digest
35
+
36
+ def is_request_allowed(self, request: Request) -> bool:
37
+ if not self.enabled:
38
+ return True
39
+ token = request.cookies.get(self.settings.auth_cookie_name, "")
40
+ return hmac.compare_digest(token, self.token())
41
+
42
+ def require_request(self, request: Request) -> None:
43
+ if not self.is_request_allowed(request):
44
+ raise HTTPException(
45
+ status_code=status.HTTP_401_UNAUTHORIZED,
46
+ detail="PIN required.",
47
+ )
48
+
49
+ def is_websocket_allowed(self, websocket: WebSocket) -> bool:
50
+ if not self.enabled:
51
+ return True
52
+ token = websocket.cookies.get(self.settings.auth_cookie_name, "")
53
+ return hmac.compare_digest(token, self.token())
54
+
55
+ def set_cookie(self, response: Response) -> None:
56
+ response.set_cookie(
57
+ self.settings.auth_cookie_name,
58
+ self.token(),
59
+ max_age=self.settings.auth_cookie_max_age,
60
+ httponly=True,
61
+ samesite="lax",
62
+ secure=False,
63
+ path="/",
64
+ )
65
+
66
+ def clear_cookie(self, response: Response) -> None:
67
+ response.delete_cookie(self.settings.auth_cookie_name, path="/")