added the required things for the integration of the pdf editor
This commit is contained in:
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user