@agentsdock/server 1.0.7-beta.5
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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Opt-in chat snapshots: authenticated management, isolated public HTML.
|
|
2
|
+
|
|
3
|
+
No background tasks, live streams, provider calls, or public session APIs.
|
|
4
|
+
All disk/HTML work runs in bounded worker admissions off the server event loop.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import hmac
|
|
10
|
+
import ipaddress
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import re
|
|
15
|
+
import threading
|
|
16
|
+
from urllib.parse import parse_qs, urlsplit
|
|
17
|
+
|
|
18
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
19
|
+
from fastapi.responses import JSONResponse, Response
|
|
20
|
+
|
|
21
|
+
from public_chat_shares import (
|
|
22
|
+
PublicChatShareStore, PublicChatShareUnavailable, PublicChatShareValidationError,
|
|
23
|
+
SHARE_ID_PATTERN, TOKEN_PATTERN, public_chat_share_headers, render_public_chat_html,
|
|
24
|
+
render_public_chat_unlock_html,
|
|
25
|
+
)
|
|
26
|
+
from public_chat_transcript import PublicTranscriptError
|
|
27
|
+
from shared_chat_video_stream import SharedVideoResponse
|
|
28
|
+
from shared_chat_videos import SharedVideoUnavailable
|
|
29
|
+
|
|
30
|
+
PUBLIC_SHARE_PATH_RE = re.compile(r'''(/share/)[^/?\s"']+''')
|
|
31
|
+
SNAPSHOT_COOKIE = "__Secure-AgentsDock-View"
|
|
32
|
+
HTTP_SNAPSHOT_COOKIE = "AgentsDock-View"
|
|
33
|
+
WARNING = (
|
|
34
|
+
"Anyone with this link and its access token can read and copy this snapshot. "
|
|
35
|
+
"The optional token-in-link URL opens it directly. Review it for secrets "
|
|
36
|
+
"before sharing. New messages are not added. Explicitly attached or published videos are included; "
|
|
37
|
+
"other files, tools, and private runtime instructions are excluded. Revocation cannot erase copies already saved."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def redact_public_share_path(value: str) -> str:
|
|
42
|
+
return PUBLIC_SHARE_PATH_RE.sub(r"\1<redacted>", value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def chat_share_origin(base: str) -> str:
|
|
46
|
+
"""Use the browser's canonical origin spelling when binding capabilities."""
|
|
47
|
+
if not isinstance(base, str) or not base:
|
|
48
|
+
raise PublicChatShareValidationError("Chat link must use an HTTP or HTTPS origin")
|
|
49
|
+
try:
|
|
50
|
+
parsed = urlsplit(base)
|
|
51
|
+
parsed.port # Reject malformed/out-of-range ports before persistence.
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
raise PublicChatShareValidationError("Chat link must use an HTTP or HTTPS origin") from None
|
|
54
|
+
if (parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username is not None
|
|
55
|
+
or parsed.password is not None or parsed.query or parsed.fragment
|
|
56
|
+
or parsed.path not in {"", "/"} or "\\" in base or any(c.isspace() for c in base)):
|
|
57
|
+
raise PublicChatShareValidationError("Chat link must use an HTTP or HTTPS origin")
|
|
58
|
+
try:
|
|
59
|
+
hostname = parsed.hostname.encode("idna").decode("ascii").lower()
|
|
60
|
+
except UnicodeError:
|
|
61
|
+
raise PublicChatShareValidationError("Invalid chat link hostname") from None
|
|
62
|
+
if ":" in hostname:
|
|
63
|
+
try:
|
|
64
|
+
if "%" in hostname:
|
|
65
|
+
raise ValueError("Scoped IPv6 is not a browser origin")
|
|
66
|
+
hostname = ipaddress.IPv6Address(hostname).compressed
|
|
67
|
+
except ValueError:
|
|
68
|
+
raise PublicChatShareValidationError("Invalid chat link hostname") from None
|
|
69
|
+
host = f"[{hostname}]"
|
|
70
|
+
else:
|
|
71
|
+
host = hostname
|
|
72
|
+
port = parsed.port
|
|
73
|
+
suffix = f":{port}" if port is not None and port != (443 if parsed.scheme == "https" else 80) else ""
|
|
74
|
+
return f"{parsed.scheme}://{host}{suffix}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def public_share_url(base: str, token: str) -> str | None:
|
|
78
|
+
return chat_share_origin(base) + "/share/" + token if base else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def create_public_chat_share_router(
|
|
82
|
+
*, storage_root, authorize, session_exists, load_transcript, public_base_url, open_video=None,
|
|
83
|
+
) -> APIRouter:
|
|
84
|
+
"""Callbacks are explicit: no access to a global server/token on import."""
|
|
85
|
+
router = APIRouter()
|
|
86
|
+
active_management = 0
|
|
87
|
+
active_views = 0
|
|
88
|
+
cached_store = None
|
|
89
|
+
store_lock = threading.Lock()
|
|
90
|
+
|
|
91
|
+
async def worker(operation, *, public=False):
|
|
92
|
+
nonlocal active_management, active_views
|
|
93
|
+
if (active_views if public else active_management) >= (4 if public else 1):
|
|
94
|
+
raise HTTPException(503, "Sharing is busy; retry shortly", headers={"Retry-After": "2"})
|
|
95
|
+
if public:
|
|
96
|
+
active_views += 1
|
|
97
|
+
else:
|
|
98
|
+
active_management += 1
|
|
99
|
+
# Shield worker completion on disconnect: a cancelled caller must not
|
|
100
|
+
# release admission while its filesystem thread is still running.
|
|
101
|
+
task = asyncio.create_task(asyncio.to_thread(operation))
|
|
102
|
+
def finished(_):
|
|
103
|
+
nonlocal active_management, active_views
|
|
104
|
+
if public:
|
|
105
|
+
active_views -= 1
|
|
106
|
+
else:
|
|
107
|
+
active_management -= 1
|
|
108
|
+
# Retrieve an exception even when the requesting client disconnected.
|
|
109
|
+
if not task.cancelled():
|
|
110
|
+
task.exception()
|
|
111
|
+
task.add_done_callback(finished)
|
|
112
|
+
return await asyncio.shield(task)
|
|
113
|
+
|
|
114
|
+
def guard(request, session_id):
|
|
115
|
+
authorize(request)
|
|
116
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", session_id) or not session_exists(session_id):
|
|
117
|
+
raise HTTPException(404, "Chat not found")
|
|
118
|
+
|
|
119
|
+
async def request_bytes(request, *, limit=8192):
|
|
120
|
+
async def collect():
|
|
121
|
+
data = bytearray()
|
|
122
|
+
async for chunk in request.stream():
|
|
123
|
+
if len(data) + len(chunk) > limit:
|
|
124
|
+
raise HTTPException(413, "Share request is too large")
|
|
125
|
+
data.extend(chunk)
|
|
126
|
+
return data
|
|
127
|
+
try:
|
|
128
|
+
# asyncio.timeout is unavailable on supported Python 3.10 hosts.
|
|
129
|
+
return await asyncio.wait_for(collect(), timeout=5)
|
|
130
|
+
except asyncio.TimeoutError:
|
|
131
|
+
raise HTTPException(408, "Share request body was not received") from None
|
|
132
|
+
|
|
133
|
+
async def body(request):
|
|
134
|
+
if request.headers.get("content-type", "").split(";")[0].strip().lower() != "application/json":
|
|
135
|
+
raise HTTPException(415, "Use application/json")
|
|
136
|
+
data = await request_bytes(request)
|
|
137
|
+
try:
|
|
138
|
+
value = json.loads(data)
|
|
139
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
140
|
+
raise HTTPException(400, "Invalid JSON") from None
|
|
141
|
+
if not isinstance(value, dict):
|
|
142
|
+
raise HTTPException(400, "Expected a JSON object")
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
def store(*, create=False):
|
|
146
|
+
nonlocal cached_store
|
|
147
|
+
with store_lock:
|
|
148
|
+
if cached_store is None:
|
|
149
|
+
# Anonymous guesses must never initialize storage on a server
|
|
150
|
+
# that has not created a share. Initialize an existing store
|
|
151
|
+
# once, then use its read-only connections for public views.
|
|
152
|
+
if not create and not (Path(storage_root) / "snapshots.sqlite3").is_file():
|
|
153
|
+
raise PublicChatShareUnavailable()
|
|
154
|
+
cached_store = (
|
|
155
|
+
PublicChatShareStore(storage_root) if create
|
|
156
|
+
else PublicChatShareStore.open_existing(storage_root)
|
|
157
|
+
)
|
|
158
|
+
return cached_store
|
|
159
|
+
|
|
160
|
+
def result(value, status=200):
|
|
161
|
+
return JSONResponse(value, status_code=status, headers={"Cache-Control": "no-store"})
|
|
162
|
+
|
|
163
|
+
@router.post("/api/admin/chat-shares/{session_id}/preview")
|
|
164
|
+
async def preview(session_id: str, request: Request):
|
|
165
|
+
guard(request, session_id)
|
|
166
|
+
if await body(request):
|
|
167
|
+
raise HTTPException(400, "Preview takes an empty JSON object")
|
|
168
|
+
try:
|
|
169
|
+
snapshot = await worker(lambda: load_transcript(session_id, None))
|
|
170
|
+
except PublicTranscriptError as exc:
|
|
171
|
+
raise HTTPException(409, str(exc)) from None
|
|
172
|
+
return result({**snapshot, "warning": WARNING})
|
|
173
|
+
|
|
174
|
+
@router.post("/api/admin/chat-shares/{session_id}")
|
|
175
|
+
async def create(session_id: str, request: Request):
|
|
176
|
+
guard(request, session_id)
|
|
177
|
+
value = await body(request)
|
|
178
|
+
if set(value) - {"confirmed_public", "through_bytes", "digest", "title", "expires_at", "base_url"}:
|
|
179
|
+
raise HTTPException(400, "Unknown share option")
|
|
180
|
+
if value.get("confirmed_public") is not True:
|
|
181
|
+
raise HTTPException(400, "Public sharing must be explicitly confirmed")
|
|
182
|
+
reviewed = "through_bytes" in value or "digest" in value
|
|
183
|
+
if reviewed and (type(value.get("through_bytes")) is not int or not re.fullmatch(r"[a-f0-9]{64}", str(value.get("digest", "")))):
|
|
184
|
+
raise HTTPException(400, "Snapshot boundary and digest must be supplied together")
|
|
185
|
+
if "base_url" in value and (not isinstance(value["base_url"], str) or not value["base_url"]):
|
|
186
|
+
raise HTTPException(400, "Invalid chat link origin")
|
|
187
|
+
def publish():
|
|
188
|
+
# Validate configuration before persisting any public capability.
|
|
189
|
+
base = chat_share_origin(value.get("base_url") or public_base_url() or str(request.base_url).rstrip("/"))
|
|
190
|
+
def capture(message_sink):
|
|
191
|
+
snapshot = load_transcript(session_id, value.get("through_bytes"), message_sink=message_sink)
|
|
192
|
+
if reviewed and not hmac.compare_digest(snapshot["digest"], value["digest"]):
|
|
193
|
+
raise PublicTranscriptError("Chat changed; preview it again")
|
|
194
|
+
share = store(create=True).create_streamed_share(session_id, capture,
|
|
195
|
+
title=value.get("title"), expires_at=value.get("expires_at"))
|
|
196
|
+
token = share.pop("token")
|
|
197
|
+
share.pop("session_id", None)
|
|
198
|
+
path = "/shared-chat/" + share["share_id"]
|
|
199
|
+
return {**share, "path": path, "url": base + path, "access_token": token,
|
|
200
|
+
"token_url": public_share_url(base, token), "warning": WARNING}
|
|
201
|
+
try:
|
|
202
|
+
return result(await worker(publish), 201)
|
|
203
|
+
except PublicTranscriptError as exc:
|
|
204
|
+
raise HTTPException(409, str(exc)) from None
|
|
205
|
+
except PublicChatShareValidationError as exc:
|
|
206
|
+
raise HTTPException(400, str(exc)) from None
|
|
207
|
+
|
|
208
|
+
@router.get("/api/admin/chat-shares/{session_id}")
|
|
209
|
+
async def list_shares(session_id: str, request: Request):
|
|
210
|
+
# Listing/revocation remain available after deleting the original chat.
|
|
211
|
+
authorize(request)
|
|
212
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", session_id):
|
|
213
|
+
raise HTTPException(404, "Chat not found")
|
|
214
|
+
try:
|
|
215
|
+
shares = await worker(lambda: store().list_shares(session_id))
|
|
216
|
+
except PublicChatShareUnavailable:
|
|
217
|
+
shares = []
|
|
218
|
+
return result({"shares": shares})
|
|
219
|
+
|
|
220
|
+
@router.delete("/api/admin/chat-shares/{session_id}/{share_id}")
|
|
221
|
+
async def revoke(session_id: str, share_id: str, request: Request):
|
|
222
|
+
authorize(request)
|
|
223
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", session_id):
|
|
224
|
+
raise HTTPException(404, "Chat not found")
|
|
225
|
+
try:
|
|
226
|
+
revoked = await worker(lambda: store().revoke_share(share_id, session_id=session_id))
|
|
227
|
+
except PublicChatShareUnavailable:
|
|
228
|
+
revoked = False
|
|
229
|
+
if not revoked:
|
|
230
|
+
raise HTTPException(404, "Share not found")
|
|
231
|
+
return result({"revoked": True})
|
|
232
|
+
|
|
233
|
+
def unlock_page(share_id, *, invalid=False, status=200):
|
|
234
|
+
return Response(render_public_chat_unlock_html(share_id, invalid_token=invalid), status_code=status,
|
|
235
|
+
headers=public_chat_share_headers(allow_unlock_form=True))
|
|
236
|
+
|
|
237
|
+
def cookie_name(request):
|
|
238
|
+
return SNAPSHOT_COOKIE if request.url.scheme == "https" else HTTP_SNAPSHOT_COOKIE
|
|
239
|
+
|
|
240
|
+
def cookie_token(request):
|
|
241
|
+
cookies = [part.strip().partition("=")[2]
|
|
242
|
+
for header in request.headers.getlist("cookie") for part in header.split(";")
|
|
243
|
+
if part.strip().partition("=")[0] == cookie_name(request)]
|
|
244
|
+
if len(cookies) != 1 or TOKEN_PATTERN.fullmatch(cookies[0]) is None:
|
|
245
|
+
raise PublicChatShareUnavailable()
|
|
246
|
+
return cookies[0]
|
|
247
|
+
|
|
248
|
+
def requested_page(request):
|
|
249
|
+
if not request.url.query:
|
|
250
|
+
return None
|
|
251
|
+
value = parse_qs(request.url.query, keep_blank_values=True, strict_parsing=True, max_num_fields=1)
|
|
252
|
+
if set(value) != {"page"} or len(value["page"]) != 1 or re.fullmatch(r"[0-9]{1,10}", value["page"][0]) is None:
|
|
253
|
+
raise ValueError("Invalid page")
|
|
254
|
+
page = int(value["page"][0])
|
|
255
|
+
if page > 2**31 - 1:
|
|
256
|
+
raise ValueError("Invalid page")
|
|
257
|
+
return page
|
|
258
|
+
|
|
259
|
+
def render_page(value, path):
|
|
260
|
+
return render_public_chat_html(value["snapshot"], page=value["page"], page_count=value["page_count"],
|
|
261
|
+
message_count=value["message_count"], navigation_base=path)
|
|
262
|
+
|
|
263
|
+
def last_page_redirect(path, page):
|
|
264
|
+
return Response(status_code=303, headers={**public_chat_share_headers(),
|
|
265
|
+
"Location": f"{path}?page={page}#conversation-end"})
|
|
266
|
+
|
|
267
|
+
@router.api_route("/shared-chat/{share_id}", methods=["GET", "HEAD"], include_in_schema=False)
|
|
268
|
+
async def token_entry(share_id: str, request: Request):
|
|
269
|
+
headers = public_chat_share_headers()
|
|
270
|
+
try:
|
|
271
|
+
page = requested_page(request)
|
|
272
|
+
except ValueError:
|
|
273
|
+
return Response("Shared conversation unavailable.", status_code=404, headers=headers)
|
|
274
|
+
if SHARE_ID_PATTERN.fullmatch(share_id) is None:
|
|
275
|
+
return Response("Shared conversation unavailable.", status_code=404, headers=headers)
|
|
276
|
+
cookies = [part.strip().partition("=")[2]
|
|
277
|
+
for header in request.headers.getlist("cookie") for part in header.split(";")
|
|
278
|
+
if part.strip().partition("=")[0] == cookie_name(request)]
|
|
279
|
+
value = None
|
|
280
|
+
if len(cookies) == 1 and TOKEN_PATTERN.fullmatch(cookies[0]):
|
|
281
|
+
try:
|
|
282
|
+
value = await worker(lambda: store().get_snapshot_page(cookies[0], share_id=share_id, page=page), public=True)
|
|
283
|
+
except PublicChatShareUnavailable:
|
|
284
|
+
pass
|
|
285
|
+
except Exception:
|
|
286
|
+
return Response("Shared conversation temporarily unavailable.", status_code=503, headers=headers)
|
|
287
|
+
if value is None:
|
|
288
|
+
response = unlock_page(share_id)
|
|
289
|
+
if request.method == "HEAD":
|
|
290
|
+
response.body = b""
|
|
291
|
+
return response
|
|
292
|
+
if page is None:
|
|
293
|
+
return last_page_redirect(request.url.path, value["page"])
|
|
294
|
+
try:
|
|
295
|
+
content = await worker(lambda: render_page(value, request.url.path), public=True)
|
|
296
|
+
except Exception:
|
|
297
|
+
return Response("Shared conversation temporarily unavailable.", status_code=503, headers=headers)
|
|
298
|
+
return Response(content if request.method == "GET" else b"", headers=headers)
|
|
299
|
+
|
|
300
|
+
@router.post("/shared-chat/{share_id}/unlock", include_in_schema=False)
|
|
301
|
+
async def unlock(share_id: str, request: Request):
|
|
302
|
+
headers = public_chat_share_headers()
|
|
303
|
+
if SHARE_ID_PATTERN.fullmatch(share_id) is None or request.url.query:
|
|
304
|
+
return Response("Shared conversation unavailable.", status_code=404, headers=headers)
|
|
305
|
+
# Native form submissions carry Origin. Reject missing/null/ambiguous
|
|
306
|
+
# origins and cross-site requests before reading a submitted token.
|
|
307
|
+
try:
|
|
308
|
+
expected = chat_share_origin(str(request.base_url))
|
|
309
|
+
except PublicChatShareValidationError:
|
|
310
|
+
return unlock_page(share_id, invalid=True, status=403)
|
|
311
|
+
if (request.headers.getlist("origin") != [expected]
|
|
312
|
+
or request.headers.get("sec-fetch-site") in {"cross-site", "same-site"}):
|
|
313
|
+
return unlock_page(share_id, invalid=True, status=403)
|
|
314
|
+
if request.headers.get("content-type", "").split(";")[0].strip().lower() != "application/x-www-form-urlencoded":
|
|
315
|
+
return unlock_page(share_id, invalid=True, status=415)
|
|
316
|
+
try:
|
|
317
|
+
value = parse_qs(bytes(await request_bytes(request, limit=1024)).decode("utf-8"),
|
|
318
|
+
keep_blank_values=True, strict_parsing=True, max_num_fields=2)
|
|
319
|
+
if (set(value) - {"access_token", "remember"} or len(value.get("access_token", [])) != 1
|
|
320
|
+
or "remember" in value and value["remember"] != ["1"]):
|
|
321
|
+
raise ValueError("Invalid unlock fields")
|
|
322
|
+
token = value["access_token"][0]
|
|
323
|
+
if TOKEN_PATTERN.fullmatch(token) is None:
|
|
324
|
+
raise ValueError("Invalid token")
|
|
325
|
+
page = await worker(lambda: store().get_snapshot_page(token, share_id=share_id), public=True)
|
|
326
|
+
except (ValueError, UnicodeError, PublicChatShareUnavailable):
|
|
327
|
+
return unlock_page(share_id, invalid=True, status=403)
|
|
328
|
+
except HTTPException as exc:
|
|
329
|
+
return unlock_page(share_id, invalid=True, status=exc.status_code)
|
|
330
|
+
except Exception:
|
|
331
|
+
return Response("Shared conversation temporarily unavailable.", status_code=503, headers=headers)
|
|
332
|
+
path = "/shared-chat/" + share_id
|
|
333
|
+
response = last_page_redirect(path, page["page"])
|
|
334
|
+
response.set_cookie(cookie_name(request), token, secure=request.url.scheme == "https", httponly=True,
|
|
335
|
+
samesite="strict", path=path, max_age=30 * 24 * 60 * 60 if "remember" in value else None)
|
|
336
|
+
return response
|
|
337
|
+
|
|
338
|
+
@router.api_route("/share/{token}", methods=["GET", "HEAD"], include_in_schema=False)
|
|
339
|
+
async def view(token: str, request: Request):
|
|
340
|
+
headers = public_chat_share_headers()
|
|
341
|
+
try:
|
|
342
|
+
page = requested_page(request)
|
|
343
|
+
except ValueError:
|
|
344
|
+
return Response("Shared conversation unavailable.", status_code=404, headers=headers)
|
|
345
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]{43}", token):
|
|
346
|
+
return Response("Shared conversation unavailable.", status_code=404, headers=headers)
|
|
347
|
+
try:
|
|
348
|
+
value = await worker(lambda: store().get_snapshot_page(token, page=page), public=True)
|
|
349
|
+
if page is None:
|
|
350
|
+
return last_page_redirect(request.url.path, value["page"])
|
|
351
|
+
content = await worker(lambda: render_page(value, request.url.path), public=True)
|
|
352
|
+
except PublicChatShareUnavailable:
|
|
353
|
+
return Response("Shared conversation unavailable.", status_code=404, headers=headers)
|
|
354
|
+
except HTTPException as exc:
|
|
355
|
+
# In particular, keep CSP/no-store on bounded-admission 503s.
|
|
356
|
+
if exc.headers and "Retry-After" in exc.headers:
|
|
357
|
+
headers["Retry-After"] = exc.headers["Retry-After"]
|
|
358
|
+
return Response("Shared conversation temporarily unavailable.", status_code=exc.status_code, headers=headers)
|
|
359
|
+
except Exception:
|
|
360
|
+
# Never expose filesystem, database, or implementation details on
|
|
361
|
+
# the unauthenticated viewer, including an unexpected failure.
|
|
362
|
+
return Response("Shared conversation temporarily unavailable.", status_code=503, headers=headers)
|
|
363
|
+
return Response(content if request.method == "GET" else b"", media_type="text/html", headers=headers)
|
|
364
|
+
|
|
365
|
+
async def video_response(request, token, page, message_index, video_index, *, share_id=None):
|
|
366
|
+
headers = {**public_chat_share_headers(), "Cross-Origin-Resource-Policy": "same-origin"}
|
|
367
|
+
file_fd = None
|
|
368
|
+
try:
|
|
369
|
+
if open_video is None or request.url.query:
|
|
370
|
+
raise PublicChatShareUnavailable()
|
|
371
|
+
expected_origin = chat_share_origin(str(request.base_url))
|
|
372
|
+
origins = request.headers.getlist("origin")
|
|
373
|
+
fetch_sites = request.headers.getlist("sec-fetch-site")
|
|
374
|
+
if (origins and origins != [expected_origin] or len(fetch_sites) > 1
|
|
375
|
+
or any(site in {"cross-site", "same-site"} for site in fetch_sites)):
|
|
376
|
+
raise PublicChatShareUnavailable()
|
|
377
|
+
indices = (page, message_index, video_index)
|
|
378
|
+
if any(re.fullmatch(r"[0-9]{1,10}", index) is None for index in indices):
|
|
379
|
+
raise PublicChatShareUnavailable()
|
|
380
|
+
value = await worker(lambda: store().get_snapshot_video(token, share_id=share_id,
|
|
381
|
+
page=int(page), message_index=int(message_index), video_index=int(video_index)), public=True)
|
|
382
|
+
descriptor = value["video"]
|
|
383
|
+
opened = await open_video(value["session_id"], descriptor["id"])
|
|
384
|
+
file_fd = opened.get("file_fd")
|
|
385
|
+
if (type(file_fd) is not int or file_fd < 0
|
|
386
|
+
or any(opened.get(key) != descriptor[key] for key in ("size", "content_type", "filename"))):
|
|
387
|
+
raise PublicChatShareUnavailable()
|
|
388
|
+
|
|
389
|
+
async def reauthorize():
|
|
390
|
+
try:
|
|
391
|
+
await worker(lambda: store().authorize_access(token, share_id=share_id), public=True)
|
|
392
|
+
except PublicChatShareUnavailable:
|
|
393
|
+
raise HTTPException(404, "Shared video unavailable") from None
|
|
394
|
+
|
|
395
|
+
response_fd, file_fd = file_fd, None # Constructor owns it even on validation failure.
|
|
396
|
+
response = SharedVideoResponse(response_fd, byte_size=opened["size"],
|
|
397
|
+
content_type=opened["content_type"], filename=opened["filename"], request=request,
|
|
398
|
+
reauthorize=reauthorize, file_revision=opened.get("file_revision"),
|
|
399
|
+
extra_headers={key: value for key, value in headers.items() if key != "Content-Type"})
|
|
400
|
+
return response
|
|
401
|
+
except (PublicChatShareUnavailable, PublicChatShareValidationError, SharedVideoUnavailable):
|
|
402
|
+
return Response("Shared video unavailable.", status_code=404, headers=headers)
|
|
403
|
+
except HTTPException as exc:
|
|
404
|
+
status = exc.status_code if exc.status_code in {404, 416, 503} else 404
|
|
405
|
+
return Response("Shared video unavailable.", status_code=status, headers={**headers, **(exc.headers or {})})
|
|
406
|
+
except Exception:
|
|
407
|
+
return Response("Shared video temporarily unavailable.", status_code=503, headers=headers)
|
|
408
|
+
finally:
|
|
409
|
+
if type(file_fd) is int and file_fd >= 0:
|
|
410
|
+
os.close(file_fd)
|
|
411
|
+
|
|
412
|
+
@router.api_route("/shared-chat/{share_id}/media/{page}/{message_index}/{video_index}",
|
|
413
|
+
methods=["GET", "HEAD"], include_in_schema=False)
|
|
414
|
+
async def common_video(share_id: str, page: str, message_index: str, video_index: str, request: Request):
|
|
415
|
+
try:
|
|
416
|
+
token = cookie_token(request)
|
|
417
|
+
except PublicChatShareUnavailable:
|
|
418
|
+
return Response("Shared video unavailable.", status_code=404, headers=public_chat_share_headers())
|
|
419
|
+
return await video_response(request, token, page, message_index, video_index, share_id=share_id)
|
|
420
|
+
|
|
421
|
+
@router.api_route("/share/{token}/media/{page}/{message_index}/{video_index}",
|
|
422
|
+
methods=["GET", "HEAD"], include_in_schema=False)
|
|
423
|
+
async def bearer_video(token: str, page: str, message_index: str, video_index: str, request: Request):
|
|
424
|
+
return await video_response(request, token, page, message_index, video_index)
|
|
425
|
+
|
|
426
|
+
return router
|