diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d298d4b..f78b79d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -64,6 +64,7 @@ function App() { const [inspectorTab, setInspectorTab] = useState('pages'); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); + const [importError, setImportError] = useState(null); const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null); const [signatureModalOpen, setSignatureModalOpen] = useState(false); @@ -113,6 +114,39 @@ function App() { (async () => { try { setIsLoading(true); + const params = new URLSearchParams(window.location.search); + const streamUrl = params.get('stream_url'); + const uploadUrl = params.get('upload_url'); + const token = params.get('token'); + const resourceId = params.get('resource_id'); + + if (streamUrl && uploadUrl && token) { + try { + const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || ''; + const res = await fetch(`${gatewayUrl}/documents/import-remote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + stream_url: streamUrl, + upload_url: uploadUrl, + auth_token: token, + resource_id: resourceId || undefined, + }), + }); + if (res.ok) { + const docInfo = await res.json(); + setDocuments([docInfo]); + openDocument(docInfo.id); + return; + } + console.error('Remote import failed:', res.status, await res.text().catch(() => '')); + setImportError('Failed to open the remote document.'); + } catch (err) { + console.error('Remote import failed:', err); + setImportError('Failed to open the remote document.'); + } + } + const docs = await gatewayService.listDocuments(); setDocuments(docs); if (docs.length > 0) { @@ -515,6 +549,31 @@ function App() { } }; + const urlParams = new URLSearchParams(window.location.search); + const isRemote = urlParams.has('stream_url') && urlParams.has('upload_url') && urlParams.has('token'); + // Grab the token that was injected into the URL when this iframe was opened. + // This is always fresher than whatever the gateway has cached. + const urlToken = urlParams.get('token') || undefined; + + const handleSave = async () => { + if (!activeDoc) return; + setIsSaving(true); + try { + if (isRemote) { + await gatewayService.exportRemoteDocument(selectedDocId, urlToken); + const parentOrigin = urlParams.get('parent_origin') || import.meta.env.VITE_PARENT_ORIGIN || '*'; + window.parent.postMessage({ type: 'REMOTE_SAVE_COMPLETE' }, parentOrigin); + } else { + await gatewayService.exportDocument(selectedDocId, activeDoc.filename); + } + } catch (e) { + console.error('Save failed', e); + alert('Save failed: ' + String(e)); + } finally { + setIsSaving(false); + } + }; + const handleExport = async () => { if (!activeDoc) return; if (!can('canCopy')) { denyToast('Exporting'); return; } @@ -623,6 +682,7 @@ function App() { canAssemble={can('canAssemble')} onUpload={handleUpload} onShowVersionHistory={() => setVersionHistoryModalOpen(true)} + onSave={handleSave} isInspectorOpen={isInspectorOpen} onToggleInspector={toggleInspector} /> @@ -725,6 +785,16 @@ function App() { onPlaceSignature={handlePlaceSignature} onDecorateText={handleDecorateText} /> + ) : importError ? ( +
+
+ + + +
+

Import Error

+

{importError}

+
) : (
diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 70c56f7..f474c89 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -27,6 +27,7 @@ interface TopBarProps { onPrint: () => void; onUpload: (file: File) => void; onShowVersionHistory?: () => void; + onSave?: () => void; isInspectorOpen: boolean; onToggleInspector: () => void; canPrint?: boolean; @@ -39,7 +40,7 @@ const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]; export const TopBar: React.FC = ({ documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth, currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo, - isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onShowVersionHistory, + isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onShowVersionHistory, onSave, canPrint = true, canExport = true, canAssemble = true, }) => { const fileRef = useRef(null); @@ -144,6 +145,16 @@ export const TopBar: React.FC = ({
+ {onSave && ( + + {isSaving ? 'Saving...' : 'Save'} + + )}
diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index ddb8ac3..caa5f1f 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -800,6 +800,19 @@ class GatewayService { URL.revokeObjectURL(url); } + async exportRemoteDocument(documentId: string, freshToken?: string): Promise { + const response = await fetch(`${this.baseUrl}/documents/${documentId}/export-remote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fresh_token: freshToken || null }), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Remote export failed: ${response.status} - ${text}`); + } + return response.json(); + } + async fetchDocumentBytes(documentId: string): Promise { const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`); if (!response.ok) throw new Error(`Failed to fetch document bytes: ${response.statusText}`); diff --git a/gateway/app/routers/documents/crud.py b/gateway/app/routers/documents/crud.py index 3a6f09d..015f09f 100644 --- a/gateway/app/routers/documents/crud.py +++ b/gateway/app/routers/documents/crud.py @@ -1,4 +1,8 @@ +import re +import httpx +from urllib.parse import urlparse from fastapi import APIRouter, File, HTTPException, UploadFile, status +from pydantic import BaseModel from app.schemas.document import ( DocumentInfoResponse, @@ -10,6 +14,83 @@ from app.services.store import document_store router = APIRouter(prefix="/documents", tags=["documents"]) + +class RemoteImportRequest(BaseModel): + stream_url: str + upload_url: str + auth_token: str | None = None + resource_id: str | None = None + + +@router.post("/import-remote", response_model=DocumentInfoResponse, status_code=status.HTTP_201_CREATED) +async def import_remote_document(req: RemoteImportRequest) -> DocumentInfoResponse: + if not engine.is_available(): + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="Engine bridge (bindings/python) not yet available.", + ) + + stream_parsed = urlparse(req.stream_url) + upload_parsed = urlparse(req.upload_url) + if stream_parsed.scheme not in ("http", "https") or upload_parsed.scheme not in ("http", "https"): + raise HTTPException(status_code=400, detail="Invalid URL scheme. Only http/https are allowed.") + + headers = {} + if req.auth_token: + headers["Authorization"] = f"Bearer {req.auth_token}" + + MAX_SIZE = 50 * 1024 * 1024 # 50 MB limit + async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: + try: + async with client.stream("GET", req.stream_url, headers=headers) as res: + if res.status_code != 200: + await res.aread() + raise HTTPException( + status_code=res.status_code, + detail=f"Failed to stream remote document: {res.text[:200]}", + ) + + content_length = res.headers.get("Content-Length") + if content_length and int(content_length) > MAX_SIZE: + raise HTTPException(status_code=400, detail="Document exceeds maximum allowed size (50MB).") + + bytes_data_arr = bytearray() + async for chunk in res.aiter_bytes(): + bytes_data_arr.extend(chunk) + if len(bytes_data_arr) > MAX_SIZE: + raise HTTPException(status_code=400, detail="Document exceeds maximum allowed size (50MB).") + + bytes_data = bytes(bytes_data_arr) + except Exception as err: + if isinstance(err, HTTPException): + raise err + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Failed to connect to remote stream_url: {err!s}", + ) + + cd = res.headers.get("Content-Disposition", "") + match = re.search(r'filename="?([^";]+)"?', cd) + filename = match.group(1) if match else f"remote_document_{req.resource_id or 'file'}.pdf" + + try: + pdfengine = engine.require() + doc = pdfengine.PdfDocument.load_from_memory(bytes_data, "") + info = document_store.add_document(filename, bytes_data, doc) + info["remote_context"] = { + "stream_url": req.stream_url, + "upload_url": req.upload_url, + "auth_token": req.auth_token, + "resource_id": req.resource_id, + } + return make_document_response(info) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Failed to load remote PDF into engine: {e!s}", + ) + + def make_document_response(d: dict) -> DocumentInfoResponse: pages_list = [] if "doc_instance" in d: diff --git a/gateway/app/routers/documents/export.py b/gateway/app/routers/documents/export.py index 90b7e6e..59a5b31 100644 --- a/gateway/app/routers/documents/export.py +++ b/gateway/app/routers/documents/export.py @@ -1,9 +1,18 @@ +import httpx +import logging +import uuid from fastapi import APIRouter, HTTPException, Response, status +from pydantic import BaseModel from app.services import engine from app.services.store import document_store router = APIRouter(tags=["documents"]) +logger = logging.getLogger(__name__) + + +class ExportRemoteRequest(BaseModel): + fresh_token: str | None = None @router.get("/{document_id}/export") def export_document(document_id: str): @@ -41,3 +50,83 @@ def export_document(document_id: str): ) except Exception as e: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) + + +@router.post("/{document_id}/export-remote") +async def export_remote_document(document_id: str, body: ExportRemoteRequest | None = None): + if not engine.is_available(): + raise HTTPException(status_code=501, detail="Engine bridge not available.") + + d = document_store.get_document(document_id) + if not d: + raise HTTPException(status_code=404, detail="Document not found") + + perms = d.get("permissions") or {} + if perms.get("canCopy", True) is False: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Exporting is not permitted by this document's restrictions (canCopy).", + ) + + ctx = d.get("remote_context") + if not ctx or not ctx.get("upload_url"): + print(f"[export-remote] FAILING with 400: ctx={ctx}", flush=True) + raise HTTPException( + status_code=400, + detail="Document does not have a registered remote upload context.", + ) + + try: + doc = d["doc_instance"] + bytes_data = doc.save_full_for_export() + filename = d["filename"] + if not filename.endswith(".pdf"): + filename += ".pdf" + + headers = {} + # Prefer fresh_token sent at save-time over the potentially-stale stored token + token_val = (body.fresh_token if body and body.fresh_token else None) or ctx.get("auth_token") + if token_val: + headers["Authorization"] = f"Bearer {token_val}" + csrf_token = str(uuid.uuid4()) + headers["Cookie"] = f"csrf_token={csrf_token}" + headers["X-CSRF-Token"] = csrf_token + + # Resolve folder_id from remote context or default to 0 + data = {"folder_id": str(ctx.get("folder_id", "0"))} + if ctx.get("resource_id"): + data["file_id"] = str(ctx["resource_id"]) + + upload_url = ctx["upload_url"] + print(f"[export-remote] POST {upload_url} | file_id={data.get('file_id')} | filename={filename} | size={len(bytes_data)}", flush=True) + + files = {"upload": (filename, bytes_data, "application/pdf")} + + async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client: + res = await client.post(upload_url, headers=headers, data=data, files=files) + + print(f"[export-remote] Response status={res.status_code} body={res.text[:500]}", flush=True) + + if res.status_code not in (200, 201): + logger.error("[export-remote] upstream %s: %s", res.status_code, res.text[:500]) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Remote upload failed (upstream status {res.status_code}).", + ) + + try: + remote_response = res.json() if res.content else {} + except ValueError: + remote_response = {} + + return { + "success": True, + "message": "Successfully exported updated document to remote host!", + "remoteResponse": remote_response, + } + except Exception as e: + if isinstance(e, HTTPException): + raise e + logger.error(f"[export-remote] Unexpected error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Remote export failed.") + diff --git a/gateway/app/routers/documents/text_objects.py b/gateway/app/routers/documents/text_objects.py index 007cd8b..2672bce 100644 --- a/gateway/app/routers/documents/text_objects.py +++ b/gateway/app/routers/documents/text_objects.py @@ -94,6 +94,8 @@ def replace_text_object(document_id: str, page_index: int, object_index: int, re filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc, permissions=doc_info.get("permissions"), ) + if "remote_context" in doc_info: + new_info["remote_context"] = doc_info["remote_context"] return {"success": True, "newDocumentId": new_info["id"]} except HTTPException: raise diff --git a/gateway/app/routers/edits.py b/gateway/app/routers/edits.py index 57b7da5..7bffe43 100644 --- a/gateway/app/routers/edits.py +++ b/gateway/app/routers/edits.py @@ -439,6 +439,8 @@ def apply_edits_impl(document_id: str, request: EditsRequest): filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc, permissions=doc_info.get("permissions"), ) + if "remote_context" in doc_info: + new_info["remote_context"] = doc_info["remote_context"] from app.services.render_cache import tile_cache tile_cache.invalidate_doc(doc_info.get("doc_hash", "")) diff --git a/gateway/debug_preview_region.png b/gateway/debug_preview_region.png index a473703..fd7d60a 100644 Binary files a/gateway/debug_preview_region.png and b/gateway/debug_preview_region.png differ diff --git a/gateway/pyproject.toml b/gateway/pyproject.toml index 298a0ee..3c2199a 100644 --- a/gateway/pyproject.toml +++ b/gateway/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "pydantic-settings==2.7.1", "python-multipart==0.0.19", "pillow==10.4.0", + "httpx==0.28.1", ] [project.optional-dependencies]