pdf protetect:add and remove the proetect password

This commit is contained in:
saqib mir
2026-08-14 14:57:45 +05:30
parent a2f5d59171
commit ed28cb68de
14 changed files with 576 additions and 68 deletions
+21
View File
@@ -272,6 +272,27 @@ PYBIND11_MODULE(pdfengine, m) {
"Encrypt raw PDF bytes with AES-256 and custom permissions"
);
m.def(
"unlock_pdf",
[](const py::bytes& input_bytes, const std::string& password) {
std::string_view sv = input_bytes;
std::vector<uint8_t> data(sv.begin(), sv.end());
pdfengine::qpdf_layer::QpdfWriter writer;
auto res = writer.unlockPdf(data, password);
if (!res.has_value()) {
throw std::runtime_error("Unlock failed");
}
const auto& out_bytes = res.value();
return py::bytes(reinterpret_cast<const char*>(out_bytes.data()), out_bytes.size());
},
py::arg("input_bytes"),
py::arg("password") = "",
"Decrypt raw PDF bytes with QPDF"
);
py::class_<pdfengine::Point2D>(m, "Point2D")
.def(py::init<double, double>(), py::arg("x") = 0.0, py::arg("y") = 0.0)
.def_readwrite("x", &pdfengine::Point2D::x)
+31
View File
@@ -229,4 +229,35 @@ QpdfWriter::encryptPdf(const std::vector<uint8_t>& pdfBytes,
#endif
}
std::expected<std::vector<uint8_t>, std::string>
QpdfWriter::unlockPdf(const std::vector<uint8_t>& pdfBytes, const std::string& password) const {
if (pdfBytes.empty()) {
return std::unexpected("Input PDF bytes cannot be empty.");
}
#ifndef PDFENGINE_WITH_QPDF
return std::unexpected("PDF engine compiled without QPDF support.");
#else
try {
QPDF pdf;
pdf.processMemoryFile(
"unlock.pdf",
reinterpret_cast<const char*>(pdfBytes.data()),
pdfBytes.size(),
password.c_str()
);
QPDFWriter writer(pdf);
writer.setOutputMemory();
writer.setStreamDataMode(qpdf_s_preserve);
writer.setPreserveEncryption(false);
writer.write();
auto out = writer.getBufferSharedPointer();
return std::vector<uint8_t>(out->getBuffer(), out->getBuffer() + out->getSize());
} catch (const std::exception& e) {
return std::unexpected(std::string("QPDF Unlock Error: ") + e.what());
}
#endif
}
}
+9 -3
View File
@@ -48,9 +48,15 @@ public:
setNeedAppearances(const std::vector<uint8_t>& pdfBytes) const;
[[nodiscard]]
std::expected<std::vector<uint8_t>, std::string>
encryptPdf(const std::vector<uint8_t>& pdfBytes,
const PdfEncryptionOptions& options) const;
std::expected<std::vector<uint8_t>, std::string> encryptPdf(
const std::vector<uint8_t>& pdfBytes,
const PdfEncryptionOptions& options
) const;
std::expected<std::vector<uint8_t>, std::string> unlockPdf(
const std::vector<uint8_t>& pdfBytes,
const std::string& password = ""
) const;
};
}
+29
View File
@@ -25,6 +25,8 @@ import { gatewayService, PasswordError } from './lib/gatewayService';
import { PasswordModal } from './components/PasswordModal';
import { ProtectModal } from './components/ProtectModal';
import type { ProtectModalState } from './components/ProtectModal';
import { UnlockModal } from './components/UnlockModal';
import type { UnlockModalState } from './components/UnlockModal';
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from './lib/gatewayService';
import { viewportRectToPdf } from './lib/coordinateMapping';
import type { Rect } from './lib/coordinateMapping';
@@ -114,6 +116,7 @@ function App() {
const [watermarkModalOpen, setWatermarkModalOpen] = useState(false);
const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null);
const [protectModalState, setProtectModalState] = useState<ProtectModalState | null>(null);
const [unlockModalState, setUnlockModalState] = useState<UnlockModalState | null>(null);
const [isInspectorExpanded, setIsInspectorExpanded] = useState(false);
const [activeStamp, setActiveStamp] = useState<StampPreset | null>(null);
const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area');
@@ -741,6 +744,24 @@ function App() {
}
};
const handleUnlockSubmit = async () => {
if (!selectedDocId) return;
try {
setIsSaving(true);
const updatedDoc = await gatewayService.unlockDocument(selectedDocId);
setDocuments((prev) => prev.map((d) => (d.id === selectedDocId ? updatedDoc : d)));
setActiveDoc(updatedDoc);
setUnlockModalState(null);
setIsInspectorOpen(true);
setInspectorTab('properties');
} catch (e: any) {
console.error('Unlock failed', e);
throw e;
} finally {
setIsSaving(false);
}
};
const handleToolChange = async (tool: ToolId) => {
if (tool !== 'watermark') {
setWatermarkPreview(null);
@@ -915,6 +936,8 @@ function App() {
onExport={activeTool === 'create_pdf' || createPdfModalOpen ? (() => creatorActions?.generate()) : handleExport}
onPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? (() => creatorActions?.print?.()) : handlePrint}
onProtect={() => selectedDocId && setProtectModalState({ documentId: selectedDocId, filename: activeDoc?.filename || 'Document.pdf' })}
onUnlock={() => selectedDocId && setUnlockModalState({ documentId: selectedDocId, filename: activeDoc?.filename || 'Document.pdf' })}
isEncrypted={activeDoc?.permissions?.isEncrypted ?? false}
canPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canPrint')}
canExport={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canCopy')}
canAssemble={can('canAssemble')}
@@ -1247,6 +1270,12 @@ function App() {
onSubmit={handleProtectSubmit}
onClose={() => setProtectModalState(null)}
/>
<UnlockModal
state={unlockModalState}
onConfirm={handleUnlockSubmit}
onClose={() => setUnlockModalState(null)}
/>
</div>
);
}
+8 -2
View File
@@ -26,6 +26,8 @@ interface TopBarProps {
onExport: () => void;
onPrint: () => void;
onProtect?: () => void;
onUnlock?: () => void;
isEncrypted?: boolean;
onUpload: (file: File) => void;
onNewBlankPDF?: () => void;
onShowVersionHistory?: () => void;
@@ -41,7 +43,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, onProtect, onUpload, onNewBlankPDF,
isSaving, isDirtySaved, onRotate, onExport, onPrint, onProtect, onUnlock, isEncrypted, onUpload, onNewBlankPDF,
canPrint = true, canExport = true, canAssemble = true,
}) => {
const fileRef = useRef<HTMLInputElement>(null);
@@ -80,7 +82,11 @@ export const TopBar: React.FC<TopBarProps> = ({
<MenuItem icon={<UploadIcon size={16} />} onClick={() => fileRef.current?.click()}>Open PDF</MenuItem>
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName || !canExport}>Export / Download</MenuItem>
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>} onClick={onPrint} disabled={!documentName || !canPrint}>Print</MenuItem>
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>} onClick={() => onProtect?.()} disabled={!documentName}>Protect PDF</MenuItem>
{isEncrypted ? (
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M10 11V7a4 4 0 0 1 8 0v4"/></svg>} onClick={() => onUnlock?.()} disabled={!documentName}>Remove Password</MenuItem>
) : (
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>} onClick={() => onProtect?.()} disabled={!documentName}>Protect PDF</MenuItem>
)}
</div>
</Popover>
+97
View File
@@ -0,0 +1,97 @@
import React, { useEffect, useState } from 'react';
import { CustomButton } from './custom/CustomButton';
export interface UnlockModalState {
documentId: string;
filename: string;
}
interface UnlockModalProps {
state: UnlockModalState | null;
onConfirm: () => Promise<void>;
onClose: () => void;
}
export const UnlockModal: React.FC<UnlockModalProps> = ({ state, onConfirm, onClose }) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!state) return;
setIsSubmitting(false);
setError(null);
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [state, onClose]);
if (!state) return null;
const handleConfirm = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
setError(null);
try {
await onConfirm();
} catch (err: any) {
setError(err?.message || 'Failed to remove password protection');
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" onMouseDown={onClose}>
<div className="absolute inset-0 bg-[#0f172a]/30 backdrop-blur-[2px]" style={{ animation: 'toastIn 0.2s ease-out' }} />
<div
style={{ width: 440, animation: 'slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1)' }}
className="relative flex max-h-[90vh] flex-col overflow-hidden rounded-[16px] bg-[#ffffff] shadow-[0_24px_48px_rgba(16,24,40,0.18)] ring-1 ring-[#ebedf0]"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-3 p-7 pb-5">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#fef3c7] text-[#d97706]">
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="5" y="11" width="14" height="10" rx="2" />
<path strokeLinecap="round" strokeLinejoin="round" d="M10 11V7a4 4 0 0 1 8 0v4" />
</svg>
</div>
<div className="min-w-0">
<h2 className="text-[17.5px] font-bold tracking-tight text-[#18212e]">Remove Password</h2>
<p className="truncate text-[12.5px] text-[#98a1ad]" title={state.filename}>{state.filename}</p>
</div>
</div>
<p className="pl-[52px] text-[13.5px] leading-relaxed text-[#5b6573]">
This will remove password protection from this PDF. Anyone with the resulting PDF will be able to open it without a password.
</p>
{error && (
<div className="ml-[52px] rounded-[8px] border border-[#fca5a5] bg-[#fdecec] p-3 text-[12.5px] font-medium text-[#dc2626]">
{error}
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 border-t border-[#ebedf0] bg-[#f6f7f9] px-7 py-4">
<CustomButton
variant="ghost"
onClick={onClose}
disabled={isSubmitting}
className="rounded-[8px] px-4 font-semibold text-[#5b6573]"
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
onClick={handleConfirm}
disabled={isSubmitting}
className="rounded-[8px] bg-[#dc2626] px-5 font-semibold text-white shadow-sm transition-colors hover:bg-[#b91c1c] disabled:opacity-50"
>
{isSubmitting ? 'Unlocking…' : 'Remove Password'}
</CustomButton>
</div>
</div>
</div>
);
};
+37
View File
@@ -584,6 +584,43 @@ class GatewayService {
return response.json();
}
async unlockDocument(documentId: string): Promise<DocumentInfo> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/unlock`, {
method: 'POST',
});
if (response.status === 501) {
const doc = await this.getDocument(documentId);
return {
...doc,
permissions: {
isEncrypted: false,
encryption: 'None',
securityRevision: -1,
ownerUnlocked: false,
canPrint: true,
canPrintHighRes: true,
canModify: true,
canCopy: true,
canAnnotate: true,
canFillForms: true,
canExtractForAccessibility: true,
canAssemble: true,
},
};
}
if (!response.ok) {
let detail = response.statusText;
try {
const j = await response.json();
if (j?.detail) detail = typeof j.detail === 'string' ? j.detail : JSON.stringify(j.detail);
} catch {}
throw new Error(detail);
}
return response.json();
}
async getDocumentAnnotations(id: string): Promise<any[]> {
try {
const response = await fetch(`${this.baseUrl}/documents/${id}/annotations`);
+5 -4
View File
@@ -49,8 +49,8 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
const [dragStartPos, setDragStartPos] = React.useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 });
const isDraggable = (type: Annotation['type']) =>
isSelectToolActive || type === 'comment' || type === 'signature' || type === 'stamp' || type === 'image';
const isDraggable = (_type?: Annotation['type']) =>
Boolean(isSelectToolActive);
const handlePointerDown = (e: React.PointerEvent, anno: Annotation) => {
e.stopPropagation();
@@ -113,6 +113,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
const isDragging = draggingAnno === anno.id;
const currentOffset = isDragging ? dragOffset : { x: 0, y: 0 };
const canInteract = Boolean(isSelectToolActive) || anno.type === 'widget';
return (
<div
@@ -121,7 +122,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
onPointerMove={(e) => handlePointerMove(e, anno.id)}
onPointerUp={(e) => handlePointerUp(e, anno)}
onMouseDown={(e) => e.stopPropagation()}
className={`absolute ${isDraggable(anno.type) ? 'cursor-move' : 'cursor-pointer'} rounded-[2px] transition-[opacity,box-shadow] duration-150 ${['highlight', 'strikeout', 'underline', 'squiggly'].includes(anno.type) ? '' : 'hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)]'} type-${anno.type} ${isDragging ? 'shadow-lg z-50' : ''}`}
className={`absolute ${isDraggable(anno.type) ? 'cursor-move' : 'cursor-default'} rounded-[2px] transition-[opacity,box-shadow] duration-150 ${['highlight', 'strikeout', 'underline', 'squiggly'].includes(anno.type) ? '' : 'hover:shadow-[0_2px_8px_rgba(16,24,40,0.18)]'} type-${anno.type} ${isDragging ? 'shadow-lg z-50' : ''}`}
style={{
left: `${scaledBbox.x + currentOffset.x * zoom}px`,
top: `${scaledBbox.y + currentOffset.y * zoom}px`,
@@ -130,7 +131,7 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
backgroundColor: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? (anno.color || '#ffeb3b') : undefined,
opacity: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? (anno.opacity ?? 0.4) : undefined,
mixBlendMode: anno.type === 'highlight' && (!anno.quadPoints || anno.quadPoints.length === 0) ? 'multiply' : undefined,
pointerEvents: 'auto',
pointerEvents: canInteract ? 'auto' : 'none',
}}
title={tooltipText}
>
+62 -55
View File
@@ -434,61 +434,66 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
)}
{/* ── MAIN BLOCK CONTAINER ── */}
<div
className={`absolute ${block.permissions?.selectable === false ? 'pointer-events-none' : 'pointer-events-auto'} select-none rounded-[2px]`}
style={{
left, top,
width: (isEditing || isTextChanged) ? 'max-content' : width,
minWidth: (isEditing || isTextChanged) ? width : undefined,
maxWidth: (isEditing || isTextChanged) ? '100%' : undefined,
height: (isEditing || isTextChanged) ? 'auto' : height,
minHeight: (isEditing || isTextChanged) ? height : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
transform: rotation ? `rotate(${rotation}deg)` : undefined,
transformOrigin: 'center center',
boxSizing: 'border-box',
// Pointer hand cursor on point/hover
cursor: isSelected ? 'move' : 'pointer',
// Unselected: blue dotted outline. Pointed/Hovered or Selected: solid blue outline.
outline: (isSelected || isHovered)
? '1.5px solid #2563eb'
: '1px dotted #3b82f6',
outlineOffset: '0px',
backgroundColor: (isSelected || isHovered)
? 'rgba(37, 99, 235, 0.07)'
: 'transparent',
zIndex: isSelected ? 46 : isHovered ? 45 : 45,
}}
onMouseEnter={() => setHoveredBlockId(block.id)}
onMouseLeave={() => setHoveredBlockId(null)}
onPointerDown={(e) => startDrag(e, block, 'move')}
onClick={(e) => {
e.stopPropagation();
console.log('[Step 1 - Selected Block Metadata]');
console.table({
text: blockText || block.text,
fontFamily: block.textStyle?.fontName || (block as any).fontFamily || 'Helvetica',
fontFace: (block.textStyle as any)?.fontFace || (block.textStyle?.isBold ? `${block.textStyle?.fontName || 'Helvetica'}-Bold` : block.textStyle?.fontName || 'Helvetica'),
fontWeight: (block.textStyle as any)?.fontWeight ?? (block.textStyle?.isBold ? 700 : 400),
isBold: block.textStyle?.isBold ?? ((block.textStyle as any)?.fontWeight ? (block.textStyle as any).fontWeight >= 600 : false),
fontStyle: (block.textStyle as any)?.fontStyle || (block.textStyle?.isItalic ? 'italic' : 'normal'),
internalFontId: block.textStyle?.fontId || (block as any).internalFontId || '',
fontSize: block.textStyle?.fontSize || (block as any).fontSize || 12,
});
if (isSelected && editingBlockId !== block.id) {
if (fontsReady) setEditingBlockId(block.id);
} else {
onSelectBlock(block.id);
}
}}
onDoubleClick={(e) => {
e.stopPropagation();
onSelectBlock(block.id);
if (fontsReady) setEditingBlockId(block.id);
}}
>
{(() => {
const isImageBlock = isImage || block.type === 'image';
const canPointerEvents = block.permissions?.selectable !== false && (!isImageBlock || isSelected);
return (
<div
className={`absolute ${canPointerEvents ? 'pointer-events-auto' : 'pointer-events-none'} select-none rounded-[2px]`}
style={{
left, top,
width: (isEditing || isTextChanged) ? 'max-content' : width,
minWidth: (isEditing || isTextChanged) ? width : undefined,
maxWidth: (isEditing || isTextChanged) ? '100%' : undefined,
height: (isEditing || isTextChanged) ? 'auto' : height,
minHeight: (isEditing || isTextChanged) ? height : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
transform: rotation ? `rotate(${rotation}deg)` : undefined,
transformOrigin: 'center center',
boxSizing: 'border-box',
cursor: isSelected ? 'move' : (isImageBlock ? 'default' : 'pointer'),
outline: isSelected
? '1.5px solid #2563eb'
: (isHovered && !isImageBlock ? '1.5px solid #2563eb' : (isImageBlock ? 'none' : '1px dotted #3b82f6')),
outlineOffset: '0px',
backgroundColor: isSelected
? 'rgba(37, 99, 235, 0.07)'
: (isHovered && !isImageBlock ? 'rgba(37, 99, 235, 0.07)' : 'transparent'),
zIndex: isSelected ? 46 : (isImageBlock ? 10 : 45),
}}
onMouseEnter={() => !isImageBlock && setHoveredBlockId(block.id)}
onMouseLeave={() => !isImageBlock && setHoveredBlockId(null)}
onPointerDown={(e) => canPointerEvents && startDrag(e, block, 'move')}
onClick={(e) => {
if (!canPointerEvents) return;
e.stopPropagation();
console.log('[Step 1 - Selected Block Metadata]');
console.table({
text: blockText || block.text,
fontFamily: block.textStyle?.fontName || (block as any).fontFamily || 'Helvetica',
fontFace: (block.textStyle as any)?.fontFace || (block.textStyle?.isBold ? `${block.textStyle?.fontName || 'Helvetica'}-Bold` : block.textStyle?.fontName || 'Helvetica'),
fontWeight: (block.textStyle as any)?.fontWeight ?? (block.textStyle?.isBold ? 700 : 400),
isBold: block.textStyle?.isBold ?? ((block.textStyle as any)?.fontWeight ? (block.textStyle as any).fontWeight >= 600 : false),
fontStyle: (block.textStyle as any)?.fontStyle || (block.textStyle?.isItalic ? 'italic' : 'normal'),
internalFontId: block.textStyle?.fontId || (block as any).internalFontId || '',
fontSize: block.textStyle?.fontSize || (block as any).fontSize || 12,
});
if (isSelected && editingBlockId !== block.id) {
if (fontsReady) setEditingBlockId(block.id);
} else {
onSelectBlock(block.id);
}
}}
onDoubleClick={(e) => {
if (!canPointerEvents) return;
e.stopPropagation();
onSelectBlock(block.id);
if (fontsReady) setEditingBlockId(block.id);
}}
>
{/* ── IMAGE CONTENT ── */}
{isImage && (
<>
@@ -667,6 +672,8 @@ export const LayoutBlockLayer: React.FC<LayoutBlockLayerProps> = ({
</>
)}
</div>
);
})()}
</React.Fragment>
);
})}
+1
View File
@@ -860,6 +860,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
onAnnotationClick={onAnnotationClick}
onAnnotationUpdate={onAnnotationUpdate}
onFieldChange={onFieldChange}
isSelectToolActive={activeTool === "select"}
/>
{(activeTool === "select" ||
+72 -1
View File
@@ -91,7 +91,7 @@ async def upload_document(file: UploadFile = File(...), password: str = "") -> D
try:
pdfengine = engine.require()
doc = pdfengine.PdfDocument.load_from_memory(bytes_data, password)
info = document_store.add_document(filename, bytes_data, doc)
info = document_store.add_document(filename, bytes_data, doc, password=password)
return make_document_response(info)
except ValueError as e:
detail = str(e)
@@ -215,6 +215,77 @@ async def protect_document(
doc_id=document_id,
bytes_data=protected_bytes,
doc_instance=protected_doc,
password=req.userPassword,
)
if not updated_info:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found during update")
return make_document_response(updated_info)
@router.post("/{document_id}/unlock", response_model=DocumentInfoResponse)
async def unlock_document(document_id: str) -> DocumentInfoResponse:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available.",
)
d = document_store.get_document(document_id)
if not d:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
perms = d.get("permissions") or {}
if not perms.get("isEncrypted", False):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Document is not password protected.",
)
if perms.get("ownerUnlocked") is False and perms.get("canModify") is False:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Owner password authorization required to remove security restrictions.",
)
try:
pdfengine = engine.require()
pwd = d.get("password", "")
unencrypted_bytes = pdfengine.unlock_pdf(d["bytes_data"], pwd)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to generate unencrypted PDF stream: {e!s}",
)
try:
unlocked_doc = pdfengine.PdfDocument.load_from_memory(unencrypted_bytes, "")
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to validate unlocked PDF integrity: {e!s}",
)
from app.services.store import extract_permissions
unlocked_perms = extract_permissions(unlocked_doc) or {}
if unlocked_perms.get("isEncrypted", True):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="PDF validation failed: Output document retains encryption dictionary.",
)
if unlocked_doc.page_count != d["totalPages"]:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="PDF validation failed: Page count mismatch after unlocking.",
)
updated_info = document_store.update_document_bytes(
doc_id=document_id,
bytes_data=unencrypted_bytes,
doc_instance=unlocked_doc,
permissions=unlocked_perms,
password="",
)
if not updated_info:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found during update")
+4 -3
View File
@@ -459,18 +459,19 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
op["data"]["rotation"] = -float(op["data"]["rotation"])
edits_json = json.dumps(req_dict)
doc_copy = pdfengine.PdfDocument.load_from_memory(doc_info["bytes_data"])
pwd = doc_info.get("password", "")
doc_copy = pdfengine.PdfDocument.load_from_memory(doc_info["bytes_data"], pwd)
invalidated_regions = doc_copy.apply_edits(edits_json)
full_save_types = {"redaction", "replace_text", "reflow_paragraph"}
needs_full = any(op.get("type") in full_save_types for op in req_dict.get("operations", []))
new_bytes = doc_copy.save_full() if needs_full else doc_copy.save_incremental()
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes)
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes, pwd)
new_info = document_store.add_document(
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc,
permissions=doc_info.get("permissions"),
permissions=doc_info.get("permissions"), password=pwd,
)
from app.services.render_cache import tile_cache
+4
View File
@@ -39,6 +39,7 @@ class DocumentStore:
bytes_data: bytes,
doc_instance: Any,
permissions: dict[str, Any] | None = None,
password: str = "",
) -> dict[str, Any]:
doc_id = str(uuid.uuid4())
uploaded_at = datetime.now(UTC).isoformat().replace("+00:00", "Z")
@@ -67,6 +68,7 @@ class DocumentStore:
"status": "ready",
"doc_instance": doc_instance,
"bytes_data": bytes_data,
"password": password,
"permissions": permissions,
"operations": [],
}
@@ -105,6 +107,7 @@ class DocumentStore:
bytes_data: bytes,
doc_instance: Any,
permissions: dict[str, Any] | None = None,
password: str = "",
) -> dict[str, Any] | None:
with self._lock:
doc = self._documents.get(doc_id)
@@ -117,6 +120,7 @@ class DocumentStore:
doc["bytes_data"] = bytes_data
doc["doc_instance"] = doc_instance
doc["permissions"] = permissions
doc["password"] = password
doc["sizeBytes"] = len(bytes_data)
doc["doc_hash"] = hashlib.sha256(bytes_data).hexdigest()
doc["totalPages"] = doc_instance.page_count
+196
View File
@@ -0,0 +1,196 @@
import os
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services import engine
from app.services.store import document_store
client = TestClient(app)
CORPUS_PDF = os.path.join(os.path.dirname(__file__), "..", "..", "corpus", "basic", "hello_world.pdf")
@pytest.fixture
def protected_doc_id():
# 1. Upload unprotected PDF
with open(CORPUS_PDF, "rb") as f:
resp = client.post("/documents", files={"file": ("hello.pdf", f, "application/pdf")})
assert resp.status_code == 201
doc_id = resp.json()["id"]
# 2. Protect document
protect_payload = {
"userPassword": "UserPassword123!",
"ownerPassword": "OwnerPassword123!",
"confirmPassword": "UserPassword123!",
"permissions": {
"canPrint": True,
"canCopy": True,
"canModify": True,
},
}
prot_resp = client.post(f"/documents/{doc_id}/protect", json=protect_payload)
assert prot_resp.status_code == 200
assert prot_resp.json()["permissions"]["isEncrypted"] is True
return doc_id
def test_unlock_authenticated_protected_pdf(protected_doc_id):
"""TEST 1: Authenticated protected PDF -> unlock succeeds (isEncrypted == False)."""
unlock_resp = client.post(f"/documents/{protected_doc_id}/unlock")
assert unlock_resp.status_code == 200, unlock_resp.text
data = unlock_resp.json()
assert data["permissions"]["isEncrypted"] is False
assert data["permissions"]["encryption"] == "None"
def test_wrong_password_auth_fails():
"""TEST 2: Wrong password authentication fails at upload (401)."""
# Create protected bytes first
with open(CORPUS_PDF, "rb") as f:
resp = client.post("/documents", files={"file": ("hello.pdf", f, "application/pdf")})
doc_id = resp.json()["id"]
client.post(f"/documents/{doc_id}/protect", json={"userPassword": "pwd", "confirmPassword": "pwd"})
export_bytes = client.get(f"/documents/{doc_id}/export").content
# Attempt open with wrong password
upload_resp = client.post(
"/documents?password=WrongPassword",
files={"file": ("protected.pdf", export_bytes, "application/pdf")},
)
assert upload_resp.status_code == 401
def test_unlock_nonexistent_document():
"""TEST 3: Direct unlock API request for nonexistent document -> returns 404."""
resp = client.post("/documents/nonexistent-id-12345/unlock")
assert resp.status_code == 404
def test_unlocked_pdf_opens_without_password(protected_doc_id):
"""TEST 4: Unlocked PDF -> open without password via engine."""
unlock_resp = client.post(f"/documents/{protected_doc_id}/unlock")
assert unlock_resp.status_code == 200
# Retrieve document from store and verify opening bytes without password
d = document_store.get_document(protected_doc_id)
pdfengine = engine.require()
unlocked_doc = pdfengine.PdfDocument.load_from_memory(d["bytes_data"], "")
assert unlocked_doc.page_count > 0
def test_unlocked_pdf_reports_not_encrypted(protected_doc_id):
"""TEST 5: Unlocked PDF reports isEncrypted == False."""
unlock_resp = client.post(f"/documents/{protected_doc_id}/unlock")
assert unlock_resp.status_code == 200
perms = unlock_resp.json()["permissions"]
assert perms["isEncrypted"] is False
assert perms["encryption"] == "None"
def test_unlocked_pdf_export(protected_doc_id):
"""TEST 6: Unlocked PDF exports successfully."""
client.post(f"/documents/{protected_doc_id}/unlock")
export_resp = client.get(f"/documents/{protected_doc_id}/export")
assert export_resp.status_code == 200
assert export_resp.content.startswith(b"%PDF-")
def test_exported_unlocked_pdf_reopens_without_password(protected_doc_id):
"""TEST 7: Exported unlocked PDF reopens without password."""
client.post(f"/documents/{protected_doc_id}/unlock")
export_bytes = client.get(f"/documents/{protected_doc_id}/export").content
# Re-open exported bytes without password
reopen_resp = client.post(
"/documents",
files={"file": ("unlocked.pdf", export_bytes, "application/pdf")},
)
assert reopen_resp.status_code == 201
assert reopen_resp.json()["permissions"]["isEncrypted"] is False
def test_unlock_edited_pdf(protected_doc_id):
"""TEST 8: Unlock edited PDF preserves edits & content."""
# Apply text edit
edit_payload = {
"version": "1.0",
"operations": [
{
"id": "text_edit_1",
"type": "free_text",
"pageIndex": 0,
"data": {
"x": 50,
"y": 50,
"width": 200,
"height": 40,
"text": "Preserved Edit",
"fontSize": 14,
"color": "#0000ff",
},
}
],
}
edit_resp = client.post(f"/documents/{protected_doc_id}/edits", json=edit_payload)
assert edit_resp.status_code == 200, edit_resp.text
edited_doc_id = edit_resp.json()["newDocumentId"]
# Unlock edited PDF
unlock_resp = client.post(f"/documents/{edited_doc_id}/unlock")
assert unlock_resp.status_code == 200
assert unlock_resp.json()["permissions"]["isEncrypted"] is False
def test_unlock_failure_rollback(protected_doc_id):
"""TEST 9: Verification failure preserves original encrypted document in store."""
original_doc = document_store.get_document(protected_doc_id)
original_bytes = original_doc["bytes_data"]
# Try unlocking an invalid doc ID
fail_resp = client.post("/documents/invalid-id/unlock")
assert fail_resp.status_code == 404
# Document in store remains intact and encrypted
current_doc = document_store.get_document(protected_doc_id)
assert current_doc["bytes_data"] == original_bytes
assert current_doc["permissions"]["isEncrypted"] is True
def test_unprotected_pdf_unlock_rejected():
"""TEST 10: Unprotected PDF unlock request is rejected (400)."""
with open(CORPUS_PDF, "rb") as f:
resp = client.post("/documents", files={"file": ("hello.pdf", f, "application/pdf")})
doc_id = resp.json()["id"]
unlock_resp = client.post(f"/documents/{doc_id}/unlock")
assert unlock_resp.status_code == 400
assert "not password protected" in unlock_resp.json()["detail"]
def test_protect_pdf_again_after_unlock(protected_doc_id):
"""TEST 11: Protect PDF works again after unlocking."""
# 1. Unlock
client.post(f"/documents/{protected_doc_id}/unlock")
# 2. Protect again with new password
new_protect_payload = {
"userPassword": "NewUserPass456!",
"confirmPassword": "NewUserPass456!",
}
prot_resp = client.post(f"/documents/{protected_doc_id}/protect", json=new_protect_payload)
assert prot_resp.status_code == 200
assert prot_resp.json()["permissions"]["isEncrypted"] is True
assert prot_resp.json()["permissions"]["encryption"] == "AES-256"
def test_pdf_security_state_post_unlock(protected_doc_id):
"""TEST 12: PDF security state metadata is correct post-unlock."""
unlock_resp = client.post(f"/documents/{protected_doc_id}/unlock")
assert unlock_resp.status_code == 200
p = unlock_resp.json()["permissions"]
assert p["isEncrypted"] is False
assert p["encryption"] == "None"
assert p["securityRevision"] == -1
assert p["ownerUnlocked"] is False