added the required things for the integration of the pdf editor

This commit is contained in:
momorew
2026-08-03 15:17:31 +05:30
parent 33ecf9be84
commit bcdc1fca14
9 changed files with 270 additions and 1 deletions
+70
View File
@@ -64,6 +64,7 @@ function App() {
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('pages');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [importError, setImportError] = useState<string | null>(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 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#98a1ad]">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-50 text-red-500">
<svg width="30" height="30" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<p className="text-[14px] font-semibold text-[#18212e]">Import Error</p>
<p className="text-[13px]">{importError}</p>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#98a1ad]">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#edeff2]">
+12 -1
View File
@@ -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<TopBarProps> = ({
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<HTMLInputElement>(null);
@@ -144,6 +145,16 @@ export const TopBar: React.FC<TopBarProps> = ({
</div>
<div className="flex shrink-0 items-center gap-2">
{onSave && (
<CustomButton
variant="primary"
onClick={onSave}
disabled={isSaving || !documentName}
className="h-8 px-3 text-[13px] font-semibold"
>
{isSaving ? 'Saving...' : 'Save'}
</CustomButton>
)}
<HealthChip healthy={backendHealthy} engineReady={!!engineReady} />
</div>
+13
View File
@@ -800,6 +800,19 @@ class GatewayService {
URL.revokeObjectURL(url);
}
async exportRemoteDocument(documentId: string, freshToken?: string): Promise<any> {
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<ArrayBuffer> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`);
if (!response.ok) throw new Error(`Failed to fetch document bytes: ${response.statusText}`);
+81
View File
@@ -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:
+89
View File
@@ -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.")
@@ -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
+2
View File
@@ -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", ""))
Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 147 KiB

+1
View File
@@ -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]