From d47895c990ed3d1141103f8688ca9197f428c5d9 Mon Sep 17 00:00:00 2001 From: Furqan-14 Date: Fri, 12 Jun 2026 15:04:09 +0530 Subject: [PATCH] feat: implemented base editing, underline and striking --- bindings/python/pdfengine_py.cpp | 15 ++ corpus/edge-cases/restricted.pdf | 41 +++++ engine/include/pdfengine/pdf_document.hpp | 20 +++ engine/src/parser/pdfium_document.cpp | 152 ++++++++++++------ engine/src/parser/pdfium_document.hpp | 1 + frontend/src/App.tsx | 82 ++++++++-- frontend/src/components/InspectorPanel.tsx | 47 +++++- frontend/src/components/PasswordModal.tsx | 96 +++++++++++ frontend/src/components/ToolRail.tsx | 21 ++- frontend/src/components/TopBar.tsx | 12 +- frontend/src/lib/gatewayService.ts | 52 +++++- frontend/src/viewer/PDFViewer.tsx | 6 + gateway/app/routers/documents.py | 26 +++ gateway/app/routers/edits.py | 40 ++++- gateway/app/services/store.py | 38 ++++- tests/edits/test_decorations.py | 73 +++++++++ .../edge-cases__restricted.pdf__p0.png | Bin 0 -> 15378 bytes tests/security/test_permissions.py | 99 ++++++++++++ 18 files changed, 730 insertions(+), 91 deletions(-) create mode 100644 corpus/edge-cases/restricted.pdf create mode 100644 frontend/src/components/PasswordModal.tsx create mode 100644 tests/edits/test_decorations.py create mode 100644 tests/regression/baseline/edge-cases__restricted.pdf__p0.png create mode 100644 tests/security/test_permissions.py diff --git a/bindings/python/pdfengine_py.cpp b/bindings/python/pdfengine_py.cpp index 10921a5..e920050 100644 --- a/bindings/python/pdfengine_py.cpp +++ b/bindings/python/pdfengine_py.cpp @@ -80,6 +80,20 @@ PYBIND11_MODULE(pdfengine, m) { return "DocumentMetadata(title='" + self.title + "', author='" + self.author + "')"; }); + py::class_(m, "DocumentPermissions") + .def_readonly("is_encrypted", &pdfengine::DocumentPermissions::isEncrypted) + .def_readonly("encryption", &pdfengine::DocumentPermissions::encryption) + .def_readonly("security_revision", &pdfengine::DocumentPermissions::securityRevision) + .def_readonly("owner_unlocked", &pdfengine::DocumentPermissions::ownerUnlocked) + .def_readonly("can_print", &pdfengine::DocumentPermissions::canPrint) + .def_readonly("can_print_high_res", &pdfengine::DocumentPermissions::canPrintHighRes) + .def_readonly("can_modify", &pdfengine::DocumentPermissions::canModify) + .def_readonly("can_copy", &pdfengine::DocumentPermissions::canCopy) + .def_readonly("can_annotate", &pdfengine::DocumentPermissions::canAnnotate) + .def_readonly("can_fill_forms", &pdfengine::DocumentPermissions::canFillForms) + .def_readonly("can_extract_for_accessibility", &pdfengine::DocumentPermissions::canExtractForAccessibility) + .def_readonly("can_assemble", &pdfengine::DocumentPermissions::canAssemble); + py::class_(m, "PageImage") .def_readonly("width", &pdfengine::PageImage::width) .def_readonly("height", &pdfengine::PageImage::height) @@ -270,6 +284,7 @@ PYBIND11_MODULE(pdfengine, m) { }, py::arg("data"), py::arg("password") = "") .def_property_readonly("page_count", &pdfengine::PdfDocument::pageCount) .def_property_readonly("metadata", &pdfengine::PdfDocument::metadata) + .def_property_readonly("permissions", &pdfengine::PdfDocument::permissions) .def("extract_outline", [](const pdfengine::PdfDocument& self) { auto res = get_or_throw(self.extractOutline()); py::list out; diff --git a/corpus/edge-cases/restricted.pdf b/corpus/edge-cases/restricted.pdf new file mode 100644 index 0000000..a1d389e --- /dev/null +++ b/corpus/edge-cases/restricted.pdf @@ -0,0 +1,41 @@ +%PDF-1.7 +% +1 0 obj +<< /Extensions << /ADBE << /BaseVersion /1.7 /ExtensionLevel 8 >> >> /Pages 2 0 R /Type /Catalog >> +endobj +2 0 obj +<< /Count 1 /Kids [ 3 0 R ] /Type /Pages >> +endobj +3 0 obj +<< /Contents 4 0 R /MediaBox [ 0 0 612 792 ] /Parent 2 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> /Type /Page >> +endobj +4 0 obj +<< /Length 352 /Filter /FlateDecode >> +stream +yL4W{ +\ӂ˼`S814vn˳?D<#6J? 3 g&ekAp@Q>ȃ' l:*S@5xw^uvvY\B&Ť.ռf(+KN.IUxu&Tt7"eMqU]濗ʸ=%dɮ (yM7v_X PO\'f_ Yof7}/taCInh \VwR[pq5X3;| stJՠ +endstream +endobj +5 0 obj +<< /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Subtype /Type1 /Type /Font >> +endobj +6 0 obj +<< /BaseFont /Times-Roman /Encoding /WinAnsiEncoding /Subtype /Type1 /Type /Font >> +endobj +7 0 obj +<< /CF << /StdCF << /AuthEvent /DocOpen /CFM /AESV3 /Length 32 >> >> /Filter /Standard /Length 256 /O /OE <33dc073711e1735085e2efd64928fa75af518dcf25ec7ff7d3ae22976ab8ded4> /P -3136 /Perms /R 6 /StmF /StdCF /StrF /StdCF /U <0d6d86441425cdaee0cda2eec22acd2391b314ff4c0bb60c90e9955907889824f87b528709af1246ab59dd722a89cb2a> /UE <2da7189a149f6fcbdf38688a565f280f8e6a4b727a3f528dbb9e014a88ebd88f> /V 5 >> +endobj +xref +0 8 +0000000000 65535 f +0000000015 00000 n +0000000130 00000 n +0000000189 00000 n +0000000327 00000 n +0000000751 00000 n +0000000848 00000 n +0000000947 00000 n +trailer << /Root 1 0 R /Size 8 /ID [] /Encrypt 7 0 R >> +startxref +1497 +%%EOF diff --git a/engine/include/pdfengine/pdf_document.hpp b/engine/include/pdfengine/pdf_document.hpp index 444c81b..f3105fe 100644 --- a/engine/include/pdfengine/pdf_document.hpp +++ b/engine/include/pdfengine/pdf_document.hpp @@ -28,6 +28,25 @@ struct DocumentMetadata { std::string modificationDate; }; +// Encryption + permission state of a loaded document. Booleans are the EFFECTIVE +// permissions for how the doc was opened (an owner-unlocked doc reports all true). +// The engine only surfaces these — enforcement happens in the gateway/app layers. +struct DocumentPermissions { + bool isEncrypted = false; + std::string encryption = "None"; // "None", "RC4-40", "RC4-128", "AES-128", "AES-256" + int securityRevision = -1; // FPDF_GetSecurityHandlerRevision (-1 if unencrypted) + bool ownerUnlocked = false; // encrypted but opened with full (owner) access + + bool canPrint = true; + bool canPrintHighRes = true; + bool canModify = true; + bool canCopy = true; // extract text / graphics + bool canAnnotate = true; // add/modify annotations + bool canFillForms = true; + bool canExtractForAccessibility = true; + bool canAssemble = true; // insert / rotate / delete pages +}; + struct PageImage { int width; int height; @@ -214,6 +233,7 @@ public: [[nodiscard]] virtual int pageCount() const noexcept = 0; [[nodiscard]] virtual DocumentMetadata metadata() const noexcept = 0; + [[nodiscard]] virtual DocumentPermissions permissions() const noexcept = 0; // A single entry in the document outline (bookmarks), flattened with a depth level. struct OutlineItem { diff --git a/engine/src/parser/pdfium_document.cpp b/engine/src/parser/pdfium_document.cpp index 83d922b..28f36b2 100644 --- a/engine/src/parser/pdfium_document.cpp +++ b/engine/src/parser/pdfium_document.cpp @@ -1299,6 +1299,10 @@ std::expected, EngineError> PdfiumPage::ext info.type = "ink"; } else if (subtype == FPDF_ANNOT_STRIKEOUT) { info.type = "strikeout"; + } else if (subtype == FPDF_ANNOT_UNDERLINE) { + info.type = "underline"; + } else if (subtype == FPDF_ANNOT_SQUIGGLY) { + info.type = "squiggly"; } else if (subtype == FPDF_ANNOT_WIDGET) { info.type = "widget"; @@ -1605,6 +1609,49 @@ DocumentMetadata PdfiumDocument::metadata() const noexcept { return meta; } +DocumentPermissions PdfiumDocument::permissions() const noexcept { + DocumentPermissions perms; // defaults: unencrypted, everything allowed +#ifdef PDFENGINE_WITH_PDFIUM + if (!doc_) return perms; + + const int rev = FPDF_GetSecurityHandlerRevision(doc_); + perms.securityRevision = rev; + perms.isEncrypted = (rev != -1); + if (!perms.isEncrypted) { + return perms; // "None", all true + } + + switch (rev) { + case 2: perms.encryption = "RC4-40"; break; + case 3: perms.encryption = "RC4-128"; break; + case 4: perms.encryption = "AES-128"; break; // R4 may be RC4-128; PDFium hides /CFM + case 5: + case 6: perms.encryption = "AES-256"; break; + default: perms.encryption = "Unknown"; break; + } + + // FPDF_GetDocPermissions returns the EFFECTIVE /P flags for how the doc was + // opened; FPDF_GetDocUserPermissions always returns the user-level flags. When + // they differ, the doc was unlocked with the owner password (full access). + // (PDFium grants full effective perms without always returning exactly + // 0xFFFFFFFF, so a direct equality check is unreliable.) + const unsigned long p = FPDF_GetDocPermissions(doc_); + const unsigned long up = FPDF_GetDocUserPermissions(doc_); + perms.ownerUnlocked = (p != up); + + auto allowed = [p](unsigned long bit) { return (p & bit) != 0ul; }; + perms.canPrint = allowed(0x4); // bit 3 + perms.canModify = allowed(0x8); // bit 4 + perms.canCopy = allowed(0x10); // bit 5 + perms.canAnnotate = allowed(0x20); // bit 6 + perms.canFillForms = allowed(0x100); // bit 9 + perms.canExtractForAccessibility = allowed(0x200);// bit 10 + perms.canAssemble = allowed(0x400); // bit 11 + perms.canPrintHighRes = allowed(0x800); // bit 12 +#endif + return perms; +} + #ifdef PDFENGINE_WITH_PDFIUM namespace { // Depth-first flatten of the bookmark tree into OutlineItems with a depth level. @@ -2269,13 +2316,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::InvalidFormat); } auto data = op["data"]; - double x = data.value("x", 0.0); - double y = data.value("y", 0.0); - double width = data.value("width", 0.0); - double thickness = data.value("thickness", 1.0); - std::string color = data.value("color", "#000000"); - - spdlog::info("Parsed {} operation: x={}, y={}, width={}", type, x, y, width); FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex); if (!page) { @@ -2283,59 +2323,67 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::Unknown); } - pdfengine::Path path; - if (type == "underline") { - path = DecorationBuilder::buildUnderline(x, y, width, thickness); - } else if (type == "strikeout") { - path = DecorationBuilder::buildStrikeout(x, y, width, thickness); - } else if (type == "squiggly") { - path = DecorationBuilder::buildSquiggly(x, y, width); + // Adobe-grade: create a real text-markup annotation (editable, deletable, + // round-trips, and positioned within the text quad by PDFium's appearance + // generator) instead of baking a fixed-offset path. Mirrors the highlight handler. + int subtype = FPDF_ANNOT_UNDERLINE; + if (type == "strikeout") subtype = FPDF_ANNOT_STRIKEOUT; + else if (type == "squiggly") subtype = FPDF_ANNOT_SQUIGGLY; + + FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, subtype); + if (!annot) { + spdlog::error("Failed to create {} annotation", type); + FPDF_ClosePage(page); + return std::unexpected(EngineError::Unknown); } - if (!path.empty()) { - const auto& segments = path.segments(); - float startX = segments[0].points[0].x; - float startY = segments[0].points[0].y; - FPDF_PAGEOBJECT pathObj = FPDFPageObj_CreateNewPath(startX, startY); + double pageHeight = FPDF_GetPageHeightF(page); + unsigned int r = 0, g = 0, b = 0; + parseHexColor(data.value("color", "#000000"), r, g, b); + FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255); - bool isFill = (type != "squiggly"); - bool isStroke = (type == "squiggly"); + if (data.contains("quadPoints") && data["quadPoints"].is_array()) { + FS_RECTF boundingBox; + boundingBox.left = 99999.0f; + boundingBox.right = -99999.0f; + boundingBox.top = -99999.0f; + boundingBox.bottom = 99999.0f; - for (size_t i = 1; i < segments.size(); ++i) { // Start from 1 to skip first MoveTo - const auto& seg = segments[i]; - if (seg.verb == Path::Verb::MoveTo) { - FPDFPath_MoveTo(pathObj, seg.points[0].x, seg.points[0].y); - } else if (seg.verb == Path::Verb::LineTo) { - FPDFPath_LineTo(pathObj, seg.points[0].x, seg.points[0].y); - } else if (seg.verb == Path::Verb::CubicBezierTo) { - FPDFPath_BezierTo(pathObj, seg.points[0].x, seg.points[0].y, - seg.points[1].x, seg.points[1].y, - seg.points[2].x, seg.points[2].y); - } else if (seg.verb == Path::Verb::Close) { - FPDFPath_Close(pathObj); - } + for (const auto& quad : data["quadPoints"]) { + FS_QUADPOINTSF points; + // Frontend is top-down; backend is bottom-up. + points.x1 = static_cast(quad.value("x1", 0.0)); + points.y1 = static_cast(pageHeight - quad.value("y1", 0.0)); + points.x2 = static_cast(quad.value("x2", 0.0)); + points.y2 = static_cast(pageHeight - quad.value("y2", 0.0)); + points.x3 = static_cast(quad.value("x3", 0.0)); + points.y3 = static_cast(pageHeight - quad.value("y3", 0.0)); + points.x4 = static_cast(quad.value("x4", 0.0)); + points.y4 = static_cast(pageHeight - quad.value("y4", 0.0)); + + FPDFAnnot_AppendAttachmentPoints(annot, &points); + + float minX = (std::min)({points.x1, points.x2, points.x3, points.x4}); + float maxX = (std::max)({points.x1, points.x2, points.x3, points.x4}); + float minY = (std::min)({points.y1, points.y2, points.y3, points.y4}); + float maxY = (std::max)({points.y1, points.y2, points.y3, points.y4}); + if (minX < boundingBox.left) boundingBox.left = minX; + if (maxX > boundingBox.right) boundingBox.right = maxX; + if (minY < boundingBox.bottom) boundingBox.bottom = minY; + if (maxY > boundingBox.top) boundingBox.top = maxY; } - - FPDFPath_SetDrawMode(pathObj, isFill ? FPDF_FILLMODE_ALTERNATE : FPDF_FILLMODE_NONE, isStroke); - - unsigned int r = 0, g = 0, b = 0; - parseHexColor(color, r, g, b); - - if (isFill) { - FPDFPageObj_SetFillColor(pathObj, r, g, b, 255); - } - if (isStroke) { - FPDFPageObj_SetStrokeColor(pathObj, r, g, b, 255); - FPDFPageObj_SetStrokeWidth(pathObj, thickness); - } - - FPDFPage_InsertObject(page, pathObj); - - if (!FPDFPage_GenerateContent(page)) { - spdlog::error("Failed to generate page content after adding decoration"); + if (boundingBox.left <= boundingBox.right) { + FPDFAnnot_SetRect(annot, &boundingBox); } } + std::string author = data.value("author", ""); + if (!author.empty()) { + auto utf16 = utf8_to_utf16le(author); + FPDFAnnot_SetStringValue(annot, "T", reinterpret_cast(utf16.data())); + } + + FPDFPage_CloseAnnot(annot); FPDF_ClosePage(page); } else if (type == "redaction") { if (!op.contains("data") || !op["data"].is_object()) { diff --git a/engine/src/parser/pdfium_document.hpp b/engine/src/parser/pdfium_document.hpp index fde4c54..6f0bf97 100644 --- a/engine/src/parser/pdfium_document.hpp +++ b/engine/src/parser/pdfium_document.hpp @@ -85,6 +85,7 @@ public: int pageCount() const noexcept override; DocumentMetadata metadata() const noexcept override; + DocumentPermissions permissions() const noexcept override; std::expected, EngineError> extractOutline() const override; std::expected, EngineError> getPage(int pageIndex) override; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 17d2198..9b69ec6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,8 +13,9 @@ import { PDFViewer } from './viewer/PDFViewer'; import type { PDFViewerRef } from './viewer/PDFViewer'; import type { Annotation } from './viewer/AnnotationLayer'; import type { EditableRun } from './viewer/TextEditLayer'; -import { gatewayService } from './lib/gatewayService'; -import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem } from './lib/gatewayService'; +import { gatewayService, PasswordError } from './lib/gatewayService'; +import { PasswordModal } from './components/PasswordModal'; +import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from './lib/gatewayService'; import { viewportRectToPdf } from './lib/coordinateMapping'; import type { Rect } from './lib/coordinateMapping'; import { wasmLoader } from './lib/wasmLoader'; @@ -37,6 +38,20 @@ function App() { const canRedo = hist.index < hist.stack.length - 1; const preservePageRef = useRef(false); + // Effective PDF permissions for the active document (carried forward across edits). + // `can` defaults to allowed when permissions are unknown / unencrypted. + const permissions = activeDoc?.permissions ?? null; + const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false; + const denyToast = (label: string) => + toast(`${label} is not permitted by this document's restrictions`, 'error'); + // Tools the active document's permissions forbid (greyed out in the rail). + const disabledTools = new Set(); + if (!can('canAnnotate')) + (['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t)); + if (!can('canModify')) (['edit_text', 'redact'] as ToolId[]).forEach((t) => disabledTools.add(t)); + const disabledToolsRef = useRef(disabledTools); + disabledToolsRef.current = disabledTools; + // View / tools const [zoom, setZoom] = useState(1.0); const [activeTool, setActiveTool] = useState('select'); @@ -59,6 +74,7 @@ function App() { const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null); const [signatureModalOpen, setSignatureModalOpen] = useState(false); const [aboutModalOpen, setAboutModalOpen] = useState(false); + const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null); const [activeStamp, setActiveStamp] = useState<{ label: string; color: string } | null>(null); const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null); @@ -200,7 +216,11 @@ function App() { if (typing || mod) return; const tool = TOOL_SHORTCUTS[e.key.toLowerCase()]; - if (tool) { setActiveTool(tool); if (tool === 'signature' && !pendingSignature) setSignatureModalOpen(true); } + if (tool) { + if (disabledToolsRef.current.has(tool)) { denyToast('This tool'); return; } + setActiveTool(tool); + if (tool === 'signature' && !pendingSignature) setSignatureModalOpen(true); + } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); @@ -231,6 +251,7 @@ function App() { /* -------------------------------------- annotation creation (optimistic) */ const handleAnnotationAdded = (a: Annotation) => { + if (!can('canAnnotate')) { denyToast('Annotations'); return; } setAnnotations((prev) => [...prev, a]); if (inspectorTab !== 'notes') setInspectorTab('notes'); const page = a.pageIndex ?? currentPage; @@ -264,15 +285,15 @@ function App() { /* ---------------------------------------------- new overlay placements */ const handleDecorateText = (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => { - const ops: EditOperation[] = lines.map(line => { - const pdf = viewportRectToPdf(line, zoom, pageHeightPts(pageIndex)); - return { - id: rid('decor'), type, pageIndex, - data: { x: pdf.x, y: pdf.y, width: pdf.width, thickness: 1.5, color } - }; + if (!can('canAnnotate')) { denyToast('Text decorations'); return; } + // One markup annotation covering all selected lines. quadPoints are in top-left + // points (line px ÷ zoom); the engine flips to bottom-up, mirroring highlight. + const quadPoints = lines.map((line) => { + const lx = line.x / zoom, ly = line.y / zoom, lw = line.width / zoom, lh = line.height / zoom; + return { x1: lx, y1: ly + lh, x2: lx + lw, y2: ly + lh, x3: lx + lw, y3: ly, x4: lx, y4: ly }; }); - // Optimistically show local overlays + // Optimistically show local overlays until the edit round-trips. const newAnnos = lines.map(line => ({ id: rid('locdec'), type, @@ -281,12 +302,13 @@ function App() { color, author: 'Current User', } as Annotation)); - setAnnotations(prev => [...prev, ...newAnnos]); - applyOps(ops); + + applyOps([{ id: rid('decor'), type, pageIndex, data: { quadPoints, color, author: 'Current User' } }]); }; const handlePlaceText = (pageIndex: number, rectPts: Rect, text: string) => { + if (!can('canAnnotate')) { denyToast('Adding text'); setActiveTool('select'); return; } const pdf = viewportRectToPdf(rectPts, 1, pageHeightPts(pageIndex)); applyOps([{ id: rid('txt'), type: 'text_overlay', pageIndex, @@ -298,6 +320,7 @@ function App() { // Rewrite existing page text in place via replace_text, which targets stable // page-object indices (no coordinates) and reflows the rest of the line. const handleEditText = (pageIndex: number, run: EditableRun, newText: string) => { + if (!can('canModify')) { denyToast('Editing text'); setActiveTool('select'); return; } applyOps([{ id: rid('edit'), type: 'replace_text', pageIndex, data: { @@ -312,6 +335,7 @@ function App() { const handlePlaceStamp = (pageIndex: number, point: { x: number; y: number }) => { if (!activeStamp) return; + if (!can('canAnnotate')) { denyToast('Stamping'); return; } const fontSize = 22; const width = Math.max(60, activeStamp.label.length * fontSize * 0.62); const height = fontSize * 1.5; @@ -324,6 +348,7 @@ function App() { const handlePlaceSignature = (pageIndex: number, point: { x: number; y: number }) => { if (!pendingSignature) return; + if (!can('canAnnotate')) { denyToast('Signing'); setActiveTool('select'); return; } const width = 160; const height = width / (pendingSignature.aspect || 3); const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex)); @@ -337,11 +362,13 @@ function App() { /* ---------------------------------------------------------- page ops */ const handleRotate = () => { if (!activeDoc) return; + if (!can('canAssemble')) { denyToast('Rotating pages'); return; } applyOps([{ id: rid('rot'), type: 'page_rotation', pageIndex: currentPage, data: { rotation: 90 } }], 'Page rotated'); }; const handleDeletePage = (pageIndex: number) => { if (!activeDoc) return; + if (!can('canAssemble')) { denyToast('Deleting pages'); return; } if (activeDoc.totalPages <= 1) { toast('Cannot delete the only page', 'error'); return; } setConfirmState({ title: 'Delete page?', @@ -356,12 +383,14 @@ function App() { const handleReorderPage = (from: number, to: number) => { if (!activeDoc || to < 0 || to >= activeDoc.totalPages) return; + if (!can('canAssemble')) { denyToast('Reordering pages'); return; } applyOps([{ id: rid('reorder'), type: 'page_reorder', pageIndex: from, data: { destPageIndex: to } }], 'Page moved'); setCurrentPage(to); }; const handleRedactArea = (pageIndex: number, bounds: Rect) => { if (!activeDoc) return; + if (!can('canModify')) { denyToast('Redaction'); return; } const pdf = viewportRectToPdf(bounds, zoom, pageHeightPts(pageIndex)); setConfirmState({ title: 'Redact area?', @@ -378,16 +407,22 @@ function App() { }; /* ----------------------------------------------------------- doc-level */ - const handleUpload = async (file: File) => { + const handleUpload = async (file: File, password = '') => { try { setIsLoading(true); - const newDoc = await gatewayService.uploadDocument(file); + const newDoc = await gatewayService.uploadDocument(file, password); setDocuments((prev) => [newDoc, ...prev]); openDocument(newDoc.id); toast(`Opened ${newDoc.filename}`, 'success'); + setPasswordPrompt(null); } catch (e) { - console.error('Upload failed', e); - toast('Upload failed', 'error'); + if (e instanceof PasswordError) { + // Needs a password (missing or wrong) — prompt and retry. + setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined }); + } else { + console.error('Upload failed', e); + toast('Upload failed', 'error'); + } } finally { setIsLoading(false); } @@ -395,6 +430,7 @@ function App() { const handleExport = async () => { if (!activeDoc) return; + if (!can('canCopy')) { denyToast('Exporting'); return; } try { await gatewayService.exportDocument(selectedDocId, activeDoc.filename); toast('Exported', 'success'); @@ -406,6 +442,7 @@ function App() { const handlePrint = async () => { if (!activeDoc) return; + if (!can('canPrint')) { denyToast('Printing'); return; } try { toast('Preparing print...', 'info'); const bytes = await gatewayService.fetchDocumentBytes(selectedDocId); @@ -504,6 +541,9 @@ function App() { onRotate={handleRotate} onExport={handleExport} onPrint={handlePrint} + canPrint={can('canPrint')} + canExport={can('canCopy')} + canAssemble={can('canAssemble')} onUpload={handleUpload} isInspectorOpen={isInspectorOpen} onToggleInspector={toggleInspector} @@ -516,6 +556,7 @@ function App() { hasSignature={!!pendingSignature} onOpenSignature={() => setSignatureModalOpen(true)} onOpenAbout={() => setAboutModalOpen(true)} + disabledTools={disabledTools} />
@@ -549,7 +590,9 @@ function App() { hasSignature={!!pendingSignature} activeStamp={activeStamp?.label ?? null} annotations={annotations} + canCopy={can('canCopy')} onFieldChange={(id, value, i) => { + if (!can('canFillForms')) { denyToast('Filling form fields'); return; } applyOps([{ id, type: 'update_field', @@ -623,6 +666,7 @@ function App() { searchCurrentMatch={searchCurrentMatch} onSelectSearchMatch={selectSearchMatch} metadata={metadata} + permissions={permissions ?? undefined} fonts={fonts} /> )} @@ -645,6 +689,12 @@ function App() { /> setConfirmState(null)} /> + + { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }} + onClose={() => setPasswordPrompt(null)} + />
); diff --git a/frontend/src/components/InspectorPanel.tsx b/frontend/src/components/InspectorPanel.tsx index 4953db5..b824d8f 100644 --- a/frontend/src/components/InspectorPanel.tsx +++ b/frontend/src/components/InspectorPanel.tsx @@ -1,6 +1,6 @@ import { CustomButton } from './custom/CustomButton'; import React from 'react'; -import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo, OutlineItem } from '../lib/gatewayService'; +import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from '../lib/gatewayService'; import type { Annotation } from '../viewer/AnnotationLayer'; import { Thumbnail } from './Thumbnail'; import { EmptyState, Popover } from './ui'; @@ -46,6 +46,7 @@ interface InspectorPanelProps { onSelectSearchMatch: (i: number) => void; metadata: DocumentMetadata | null; + permissions?: PDFPermissions; fonts: FontInfo[]; } @@ -140,7 +141,7 @@ export const InspectorPanel: React.FC = (p) => { {p.activeTab === 'pages' && } {p.activeTab === 'notes' && a.type !== 'widget')} onNavigate={p.onNavigateAnnotation} onDelete={p.onDeleteAnnotation} onUpdate={p.onUpdateAnnotation} />} {p.activeTab === 'search' && } - {p.activeTab === 'properties' && } + {p.activeTab === 'properties' && } {p.activeTab === 'fonts' && } {p.activeTab === 'outline' && } {p.activeTab === 'forms' && a.type === 'widget')} onNavigate={p.onNavigateAnnotation} />} @@ -363,7 +364,16 @@ const SearchTab: React.FC = (p) => { ); }; -const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; sizeBytes?: number; totalPages: number; filename?: string }> = ({ metadata, sizeBytes, totalPages, filename }) => { +const PERM_LABELS: [keyof PDFPermissions, string][] = [ + ['canPrint', 'Print'], + ['canCopy', 'Copy / extract'], + ['canModify', 'Modify content'], + ['canAnnotate', 'Annotate'], + ['canFillForms', 'Fill forms'], + ['canAssemble', 'Assemble pages'], +]; + +const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?: PDFPermissions; sizeBytes?: number; totalPages: number; filename?: string }> = ({ metadata, permissions, sizeBytes, totalPages, filename }) => { const rows: [string, string | undefined][] = [ ['File name', filename], ['Title', metadata?.title], @@ -385,7 +395,36 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; sizeBytes?: n {v && v.trim() ? v : } ))} -

Document properties are read-only.

+ + {/* Security / permissions */} +
+
+ Security + {permissions?.isEncrypted + ? {permissions.encryption} + : Unencrypted} + {permissions?.ownerUnlocked && Owner} +
+ {permissions?.isEncrypted && !permissions.ownerUnlocked ? ( +
+ {PERM_LABELS.map(([key, label]) => { + const allowed = permissions[key] !== false; + return ( +
+ {label} + + {allowed ? 'Allowed' : 'Restricted'} + +
+ ); + })} +
+ ) : ( +

No usage restrictions.

+ )} +
+ +

Document properties are read-only.

); }; diff --git a/frontend/src/components/PasswordModal.tsx b/frontend/src/components/PasswordModal.tsx new file mode 100644 index 0000000..213f761 --- /dev/null +++ b/frontend/src/components/PasswordModal.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { CustomButton } from './custom/CustomButton'; + +export interface PasswordPromptState { + filename: string; + error?: string; // shown on a failed attempt ("Incorrect password…") +} + +interface PasswordModalProps { + state: PasswordPromptState | null; + onSubmit: (password: string) => void; + onClose: () => void; +} + +export const PasswordModal: React.FC = ({ state, onSubmit, onClose }) => { + const [value, setValue] = useState(''); + const inputRef = useRef(null); + + // Reset the field and focus whenever the prompt (re)opens. + useEffect(() => { + if (!state) return; + setValue(''); + const t = setTimeout(() => inputRef.current?.focus(), 30); + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', onKey); + return () => { clearTimeout(t); document.removeEventListener('keydown', onKey); }; + }, [state, onClose]); + + if (!state) return null; + + const submit = () => { if (value) onSubmit(value); }; + + return ( +
+
+ +
e.stopPropagation()} + > +
+
+
+ + + + +
+
+

Password required

+

{state.filename}

+
+
+

+ This document is protected. Enter the password to open it. +

+ +
{ e.preventDefault(); submit(); }} + > + setValue(e.target.value)} + placeholder="Document password" + autoComplete="off" + className={`w-full rounded-[8px] border bg-white px-3 py-2 text-[13.5px] outline-none transition-colors focus:ring-2 ${ + state.error + ? 'border-[#dc2626] focus:border-[#dc2626] focus:ring-[#fdecec]' + : 'border-[#d6dae0] focus:border-[#2563eb] focus:ring-[#eef4ff]' + }`} + /> + {state.error &&

{state.error}

} +
+
+ +
+ + Cancel + + + Open + +
+
+
+ ); +}; diff --git a/frontend/src/components/ToolRail.tsx b/frontend/src/components/ToolRail.tsx index 75504a8..085011f 100644 --- a/frontend/src/components/ToolRail.tsx +++ b/frontend/src/components/ToolRail.tsx @@ -1,6 +1,7 @@ import { CustomButton } from './custom/CustomButton'; import React from 'react'; import type { ToolId } from '../lib/tools'; +import { toast } from '../lib/toast'; import { Popover } from './ui'; import { SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon, @@ -44,28 +45,36 @@ interface ToolRailProps { hasSignature: boolean; onOpenSignature: () => void; onOpenAbout: () => void; + disabledTools?: Set; } -const RailButton: React.FC<{ t: ToolDef; active: boolean; onClick: () => void }> = ({ t, active, onClick }) => ( +const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; onClick: () => void }> = ({ t, active, disabled, onClick }) => ( - {active && } + {active && !disabled && } {React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 22 }) : t.icon} ); -export const ToolRail: React.FC = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout }) => { +export const ToolRail: React.FC = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools }) => { const pickTool = (id: ToolId) => { + if (disabledTools?.has(id)) { + toast("This tool is not permitted by this document's restrictions", 'error'); + return; + } onToolChange(id); if (id === 'signature' && !hasSignature) onOpenSignature(); }; @@ -75,7 +84,7 @@ export const ToolRail: React.FC = ({ activeTool, onToolChange, ha {TOOLS.map((t, i) => t === 'divider' ?
- : pickTool(t.id)} />, + : pickTool(t.id)} />, )}
diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 58b444f..83511d1 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -28,6 +28,9 @@ interface TopBarProps { onUpload: (file: File) => void; isInspectorOpen: boolean; onToggleInspector: () => void; + canPrint?: boolean; + canExport?: boolean; + canAssemble?: boolean; } const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]; @@ -37,6 +40,7 @@ export const TopBar: React.FC = ({ currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo, isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, isInspectorOpen, onToggleInspector, + canPrint = true, canExport = true, canAssemble = true, }) => { const fileRef = useRef(null); const handleFile = (e: React.ChangeEvent) => { @@ -72,8 +76,8 @@ export const TopBar: React.FC = ({ >
} onClick={() => fileRef.current?.click()}>Open PDF… - } onClick={onExport} disabled={!documentName}>Export / Download - } onClick={onPrint} disabled={!documentName}>Print + } onClick={onExport} disabled={!documentName || !canExport}>Export / Download + } onClick={onPrint} disabled={!documentName || !canPrint}>Print
@@ -119,7 +123,7 @@ export const TopBar: React.FC = ({ onZoomChange(Math.min(5, zoom + 0.1))}>
- + {documentName && (
@@ -143,7 +147,7 @@ export const TopBar: React.FC = ({
- Export + Export { + async uploadDocument(file: File, password = ''): Promise { const formData = new FormData(); formData.append('file', file); - const response = await fetch(`${this.baseUrl}/documents`, { + const url = password + ? `${this.baseUrl}/documents?password=${encodeURIComponent(password)}` + : `${this.baseUrl}/documents`; + const response = await fetch(url, { method: 'POST', body: formData, }); + // 401 = the PDF needs a password (missing or wrong) — surface a typed error + // so the UI can prompt and retry. + if (response.status === 401) { + const body = await response.json().catch(() => ({ detail: 'Password required' })); + throw new PasswordError(body.detail || 'Password required'); + } + if (response.status === 501) { // Simulate upload for Phase 0 scaffolding return new Promise((resolve) => { diff --git a/frontend/src/viewer/PDFViewer.tsx b/frontend/src/viewer/PDFViewer.tsx index 20bed4e..a9e8791 100644 --- a/frontend/src/viewer/PDFViewer.tsx +++ b/frontend/src/viewer/PDFViewer.tsx @@ -40,6 +40,7 @@ interface PDFViewerProps { onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void; onDecorateText?: (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => void; onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void; + canCopy?: boolean; } interface PageLayout { @@ -81,6 +82,7 @@ export const PDFViewer = React.forwardRef(({ onPlaceSignature, onDecorateText, onFieldChange, + canCopy = true, }, ref) => { const containerRef = useRef(null); const [scrollPosition, setScrollPosition] = useState({ scrollLeft: 0, scrollTop: 0 }); @@ -293,6 +295,10 @@ export const PDFViewer = React.forwardRef(({ const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => { if (activeTool === 'select') { + if (!canCopy) { + toast("Copying is not permitted by this document's restrictions", 'error'); + return; + } if (text.trim()) { navigator.clipboard?.writeText(text).then( () => toast(`Copied ${text.length} character${text.length > 1 ? 's' : ''}`, 'success'), diff --git a/gateway/app/routers/documents.py b/gateway/app/routers/documents.py index 657ca24..7be2655 100644 --- a/gateway/app/routers/documents.py +++ b/gateway/app/routers/documents.py @@ -13,6 +13,21 @@ class PageInfoResponse(BaseModel): height: float +class PermissionsResponse(BaseModel): + isEncrypted: bool = False + encryption: str = "None" + securityRevision: int = -1 + ownerUnlocked: bool = False + canPrint: bool = True + canPrintHighRes: bool = True + canModify: bool = True + canCopy: bool = True + canAnnotate: bool = True + canFillForms: bool = True + canExtractForAccessibility: bool = True + canAssemble: bool = True + + class DocumentInfoResponse(BaseModel): id: str filename: str @@ -23,6 +38,7 @@ class DocumentInfoResponse(BaseModel): uploadedAt: str status: str pages: list[PageInfoResponse] = [] + permissions: PermissionsResponse = PermissionsResponse() def make_document_response(d: dict) -> DocumentInfoResponse: @@ -35,6 +51,7 @@ def make_document_response(d: dict) -> DocumentInfoResponse: pages_list.append(PageInfoResponse(index=i, width=page.width, height=page.height)) except Exception: pass + perms = d.get("permissions") return DocumentInfoResponse( id=d["id"], filename=d["filename"], @@ -45,6 +62,7 @@ def make_document_response(d: dict) -> DocumentInfoResponse: uploadedAt=d["uploadedAt"], status=d["status"], pages=pages_list, + permissions=PermissionsResponse(**perms) if perms else PermissionsResponse(), ) @@ -598,6 +616,14 @@ def export_document(document_id: str): if not d: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found") + # Exporting a decrypted copy is a form of content extraction — gate on copy permission. + 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).", + ) + try: doc = d["doc_instance"] bytes_data = doc.save_full() diff --git a/gateway/app/routers/edits.py b/gateway/app/routers/edits.py index fb2ee79..b7552ac 100644 --- a/gateway/app/routers/edits.py +++ b/gateway/app/routers/edits.py @@ -224,11 +224,12 @@ class ReplaceTextOperation(BaseModel): class DecorationData(BaseModel): - x: float - y: float - width: float - thickness: float | None = None - color: str | None = None + # Text-markup annotation (underline/strikeout/squiggly) over one or more text + # lines — quadpoints in PDF top-down space, mirroring HighlightData. + quadPoints: list[HighlightQuadPoint] + color: str = "#000000" + author: str = "User" + content: str | None = None class UnderlineOperation(BaseModel): id: str @@ -275,6 +276,19 @@ class EditsRequest(BaseModel): operations: list[EditOperation] +# Which PDF permission each edit operation requires. Unencrypted / owner-unlocked +# docs report every flag True (in the engine), so this never blocks them. +_OP_PERMISSION = { + "highlight": "canAnnotate", "underline": "canAnnotate", "strikeout": "canAnnotate", + "squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate", + "free_text": "canAnnotate", "text_overlay": "canAnnotate", "image_overlay": "canAnnotate", + "delete_annotation": "canAnnotate", "update_annotation": "canAnnotate", + "replace_text": "canModify", "redaction": "canModify", + "update_field": "canFillForms", + "page_rotation": "canAssemble", "page_deletion": "canAssemble", "page_reorder": "canAssemble", +} + + def apply_edits_impl(document_id: str, request: EditsRequest): if not engine.is_available(): raise HTTPException( @@ -286,6 +300,17 @@ def apply_edits_impl(document_id: str, request: EditsRequest): if not doc_info: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found") + # Enforce document permissions (defense-in-depth; the UI also gates these). + # Done before the try so the 403 isn't rewritten to 400 by the broad handler. + perms = doc_info.get("permissions") or {} + for op in request.operations: + required = _OP_PERMISSION.get(op.type) + if required and perms.get(required, True) is False: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Operation '{op.type}' is not permitted by this document's restrictions ({required}).", + ) + created_temp_files = [] try: pdfengine = engine.require() @@ -347,8 +372,11 @@ def apply_edits_impl(document_id: str, request: EditsRequest): new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes) + # Carry the original permissions forward — the saved bytes are decrypted, so + # a fresh load would report full access and defeat enforcement. new_info = document_store.add_document( - filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc + filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc, + permissions=doc_info.get("permissions"), ) return {"success": True, "newDocumentId": new_info["id"]} diff --git a/gateway/app/services/store.py b/gateway/app/services/store.py index cb299b4..70b6ffa 100644 --- a/gateway/app/services/store.py +++ b/gateway/app/services/store.py @@ -4,12 +4,41 @@ from datetime import UTC, datetime from typing import Any +def extract_permissions(doc_instance: Any) -> dict[str, Any] | None: + """Read the engine's DocumentPermissions off a doc into a plain dict, or None + if unavailable (treated downstream as unrestricted).""" + try: + p = doc_instance.permissions + return { + "isEncrypted": p.is_encrypted, + "encryption": p.encryption, + "securityRevision": p.security_revision, + "ownerUnlocked": p.owner_unlocked, + "canPrint": p.can_print, + "canPrintHighRes": p.can_print_high_res, + "canModify": p.can_modify, + "canCopy": p.can_copy, + "canAnnotate": p.can_annotate, + "canFillForms": p.can_fill_forms, + "canExtractForAccessibility": p.can_extract_for_accessibility, + "canAssemble": p.can_assemble, + } + except Exception: + return None + + class DocumentStore: def __init__(self): self._lock = threading.Lock() self._documents: dict[str, dict[str, Any]] = {} - def add_document(self, filename: str, bytes_data: bytes, doc_instance: Any) -> dict[str, Any]: + def add_document( + self, + filename: str, + bytes_data: bytes, + doc_instance: Any, + permissions: dict[str, Any] | None = None, + ) -> dict[str, Any]: doc_id = str(uuid.uuid4()) uploaded_at = datetime.now(UTC).isoformat().replace("+00:00", "Z") page_width = 612.0 @@ -22,6 +51,12 @@ class DocumentStore: except Exception: pass + # Permissions are computed from the freshly-loaded doc at upload; on derived + # docs (post-edit, which are saved decrypted) the caller passes the original's + # permissions forward so enforcement stays consistent. + if permissions is None: + permissions = extract_permissions(doc_instance) + info = { "id": doc_id, "filename": filename, @@ -33,6 +68,7 @@ class DocumentStore: "status": "ready", "doc_instance": doc_instance, "bytes_data": bytes_data, + "permissions": permissions, } with self._lock: diff --git a/tests/edits/test_decorations.py b/tests/edits/test_decorations.py new file mode 100644 index 0000000..9bff314 --- /dev/null +++ b/tests/edits/test_decorations.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Text-markup decoration tests (underline / strikeout / squiggly). + +Decorations are real PDF text-markup annotations (not baked paths): they round-trip, +are extractable with the correct subtype + geometry, and are deletable like any +annotation. PDFium positions them within the text quad. + +Run: gateway/.venv/Scripts/python.exe tests/edits/test_decorations.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "gateway")) +import pdfengine # noqa: E402 + +CORPUS = ROOT / "corpus" / "fonts" / "utf-8.pdf" + + +def _load(): + return pdfengine.PdfDocument.load_from_memory(CORPUS.read_bytes(), "") + + +def _quad(run, page_height): + top, bot = page_height - (run.y + run.h), page_height - run.y + return {"x1": run.x, "y1": top, "x2": run.x + run.w, "y2": top, + "x3": run.x + run.w, "y3": bot, "x4": run.x, "y4": bot} + + +def _op(deco, run, page_height): + return {"version": "1.0", "operations": [{ + "id": deco, "type": deco, "pageIndex": 0, + "data": {"quadPoints": [_quad(run, page_height)], "color": "#ff0000", "author": "Me"}}]} + + +def test_decorations_are_annotations_and_deletable(): + for deco in ("underline", "strikeout", "squiggly"): + d = _load() + m = d.get_page(0).extract_document_model() + run = m.paragraphs[0].lines[0].runs[0] + d.apply_edits(json.dumps(_op(deco, run, m.height))) + doc2 = pdfengine.PdfDocument.load_from_memory(d.save_full(), "") + annots = doc2.get_page(0).extract_annotations() + match = [a for a in annots if a.type == deco] + assert match, f"{deco}: expected a '{deco}' annotation, got {[a.type for a in annots]}" + a = match[0] + # geometry roughly covers the run (top-left frame) + assert abs(a.x - run.x) < 3 and a.width > 10, f"{deco}: bbox off ({a.x},{a.width})" + + # deletable like any annotation + doc2.apply_edits(json.dumps({"version": "1.0", "operations": [ + {"id": "del", "type": "delete_annotation", "pageIndex": 0, "data": {"annotationId": a.id}}]})) + after = [x.type for x in pdfengine.PdfDocument.load_from_memory(doc2.save_full(), "").get_page(0).extract_annotations()] + assert deco not in after, f"{deco}: still present after delete ({after})" + print(f" ok {deco}: round-trips as annotation (bbox x={a.x:.0f} w={a.width:.0f}) and deletes cleanly") + + +def main() -> int: + try: + test_decorations_are_annotations_and_deletable() + except AssertionError as exc: + print(f" FAIL: {exc}") + return 1 + print("\n1/1 passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/regression/baseline/edge-cases__restricted.pdf__p0.png b/tests/regression/baseline/edge-cases__restricted.pdf__p0.png new file mode 100644 index 0000000000000000000000000000000000000000..a254a3ba18fff6027dbfbf5e757d919baa8ae1a0 GIT binary patch literal 15378 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 Ik=^V60k(CR!~g&Q literal 0 HcmV?d00001 diff --git a/tests/security/test_permissions.py b/tests/security/test_permissions.py new file mode 100644 index 0000000..2aa3bd5 --- /dev/null +++ b/tests/security/test_permissions.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Permission surfacing + enforcement tests for encrypted PDFs. + +- Engine surfaces encryption + permission flags (FPDF_GetDocPermissions / revision). +- Gateway exposes them on the document response and ENFORCES forbidden edit ops (403). + +Run: gateway/.venv/Scripts/python.exe tests/security/test_permissions.py +Requires the restricted fixture corpus/edge-cases/restricted.pdf (generated below if missing). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "gateway")) +import pdfengine # noqa: E402 + +RESTRICTED = ROOT / "corpus" / "edge-cases" / "restricted.pdf" +NORMAL = ROOT / "corpus" / "fonts" / "utf-8.pdf" + + +def _ensure_fixture(): + if RESTRICTED.exists(): + return + import pikepdf # noqa: PLC0415 + src = pikepdf.open(NORMAL) + perm = pikepdf.Permissions(extract=False, print_highres=False, print_lowres=False, + modify_other=False, modify_annotation=False) + src.save(RESTRICTED, encryption=pikepdf.Encryption(owner="owner", user="", R=6, allow=perm)) + + +def test_engine_surfaces_permissions(): + p = pdfengine.PdfDocument.load_from_memory(RESTRICTED.read_bytes(), "").permissions + assert p.is_encrypted and p.encryption == "AES-256" and p.security_revision == 6 + assert not p.owner_unlocked + assert p.can_copy is False and p.can_print is False and p.can_modify is False and p.can_annotate is False + assert p.can_fill_forms is True # not denied in the fixture + + n = pdfengine.PdfDocument.load_from_memory(NORMAL.read_bytes(), "").permissions + assert not n.is_encrypted and n.encryption == "None" + assert n.can_copy and n.can_print and n.can_modify and n.can_annotate + print(" ok engine surfaces AES-256 + correct flags (restricted) and all-true (normal)") + + +def test_gateway_surface_and_enforce(): + from app.services.store import document_store # noqa: PLC0415 + from app.routers.documents import make_document_response # noqa: PLC0415 + from app.routers.edits import apply_edits_impl, EditsRequest # noqa: PLC0415 + from fastapi import HTTPException # noqa: PLC0415 + + data = RESTRICTED.read_bytes() + doc = pdfengine.PdfDocument.load_from_memory(data, "") + info = document_store.add_document("restricted.pdf", data, doc) + + # surface + resp = make_document_response(info) + assert resp.permissions.isEncrypted and resp.permissions.encryption == "AES-256" + assert resp.permissions.canModify is False and resp.permissions.canAnnotate is False + + def apply(op): + apply_edits_impl(info["id"], EditsRequest.model_validate({"version": "1.0", "operations": [op]})) + + # forbidden -> 403 + forbidden = {"id": "a", "type": "replace_text", "pageIndex": 0, + "data": {"objectIndices": [0], "text": "x", "internalFontId": "F", "fontSize": 12.0}} + try: + apply(forbidden) + raise AssertionError("forbidden replace_text should have been rejected") + except HTTPException as ex: + assert ex.status_code == 403, f"expected 403, got {ex.status_code}" + + # allowed (fill forms) -> not a 403 + allowed = {"id": "b", "type": "update_field", "pageIndex": 0, + "data": {"value": "hi", "annotationId": "missing"}} + try: + apply(allowed) + except HTTPException as ex: + assert ex.status_code != 403, "fill-forms is allowed; should not be 403" + print(" ok gateway surfaces permissions + enforces 403 on forbidden op, allows permitted op") + + +def main() -> int: + _ensure_fixture() + tests = [test_engine_surfaces_permissions, test_gateway_surface_and_enforce] + failed = 0 + for t in tests: + try: + t() + except AssertionError as exc: + print(f" FAIL {t.__name__}: {exc}") + failed += 1 + print(f"\n{len(tests) - failed}/{len(tests)} passed.") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main())