Merge branch 'fix_issue' of https://gitea.maskantech.in/gitea_admin/pdf into ribai

This commit is contained in:
momorew
2026-08-14 15:40:42 +05:30
18 changed files with 1529 additions and 66 deletions
+72
View File
@@ -221,6 +221,78 @@ PYBIND11_MODULE(pdfengine, m) {
m.def("engine_has_skia", &pdfengine::engineHasSkia,
"Check if the engine was built with Skia support");
m.def(
"protect_pdf",
[](const py::bytes& input_bytes,
const std::string& user_password,
const std::string& owner_password,
const py::dict& perms_dict) {
if (user_password.empty()) {
throw py::value_error("User password cannot be empty");
}
std::string_view sv = input_bytes;
std::vector<uint8_t> data(sv.begin(), sv.end());
pdfengine::qpdf_layer::PdfEncryptionOptions opts;
opts.userPassword = user_password;
opts.ownerPassword = owner_password.empty() ? user_password : owner_password;
auto get_bool = [&](const char* key, bool dflt) {
if (perms_dict.contains(key) && !perms_dict[key].is_none()) {
try { return perms_dict[key].cast<bool>(); } catch (...) {}
}
return dflt;
};
opts.allowPrint = get_bool("canPrint", true);
opts.allowPrintHighRes = get_bool("canPrintHighRes", true);
opts.allowModify = get_bool("canModify", true);
opts.allowCopy = get_bool("canCopy", true);
opts.allowAnnotate = get_bool("canAnnotate", true);
opts.allowFillForms = get_bool("canFillForms", true);
opts.allowAccessibility = get_bool("canExtractForAccessibility", true);
opts.allowAssemble = get_bool("canAssemble", true);
opts.keyLengthBits = 256;
pdfengine::qpdf_layer::QpdfWriter writer;
auto res = writer.encryptPdf(data, opts);
if (!res.has_value()) {
throw std::runtime_error("Encryption 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("user_password"),
py::arg("owner_password") = "",
py::arg("permissions") = py::dict(),
"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)
+95
View File
@@ -165,4 +165,99 @@ QpdfWriter::setNeedAppearances(const std::vector<uint8_t>& pdfBytes) const {
#endif
}
std::expected<std::vector<uint8_t>, std::string>
QpdfWriter::encryptPdf(const std::vector<uint8_t>& pdfBytes,
const PdfEncryptionOptions& options) const {
#ifndef PDFENGINE_WITH_QPDF
(void)pdfBytes; (void)options;
return std::unexpected("QPDF support is not enabled in this build.");
#else
try {
if (pdfBytes.empty()) {
return std::unexpected("Input PDF buffer is empty.");
}
if (options.userPassword.empty()) {
return std::unexpected("User password cannot be empty.");
}
QPDF pdf;
pdf.processMemoryFile("protect.pdf",
reinterpret_cast<const char*>(pdfBytes.data()),
pdfBytes.size());
qpdf_r3_print_e printMode = qpdf_r3p_none;
if (options.allowPrint) {
printMode = options.allowPrintHighRes ? qpdf_r3p_full : qpdf_r3p_low;
}
qpdf_r3_modify_e modifyMode = qpdf_r3m_none;
if (options.allowModify) {
modifyMode = qpdf_r3m_all;
} else if (options.allowAnnotate) {
modifyMode = qpdf_r3m_annotate;
} else if (options.allowFillForms) {
modifyMode = qpdf_r3m_form;
} else if (options.allowAssemble) {
modifyMode = qpdf_r3m_assembly;
}
std::string ownerPwd = options.ownerPassword.empty() ? options.userPassword : options.ownerPassword;
QPDFWriter writer(pdf);
writer.setOutputMemory();
writer.setStreamDataMode(qpdf_s_preserve);
writer.setR6EncryptionParameters(
options.userPassword.c_str(),
ownerPwd.c_str(),
options.allowAccessibility,
options.allowCopy,
options.allowAssemble,
options.allowAnnotate,
options.allowFillForms,
options.allowModify,
printMode,
options.encryptMetadata
);
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 Encryption Error: ") + e.what());
}
#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
}
}
+28
View File
@@ -10,6 +10,23 @@ class QPDF;
namespace pdfengine::qpdf_layer {
struct PdfEncryptionOptions {
std::string userPassword;
std::string ownerPassword;
bool allowPrint = true;
bool allowPrintHighRes = true;
bool allowModify = true;
bool allowCopy = true;
bool allowAnnotate = true;
bool allowFillForms = true;
bool allowAccessibility = true;
bool allowAssemble = true;
int keyLengthBits = 256;
bool encryptMetadata = true;
};
class QpdfWriter {
public:
QpdfWriter() = default;
@@ -29,6 +46,17 @@ public:
[[nodiscard]]
std::expected<std::vector<uint8_t>, std::string>
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> unlockPdf(
const std::vector<uint8_t>& pdfBytes,
const std::string& password = ""
) const;
};
}
+37
View File
@@ -78,3 +78,40 @@ TEST(QpdfWriterTest, IntegrationReadModifyWrite) {
std::filesystem::remove(destPath);
}
#include <pdfengine/pdf_document.hpp>
TEST(QpdfWriterTest, EncryptPdfMemory) {
std::filesystem::path sourcePath = std::filesystem::path(TEST_CORPUS_DIR) / "basic" / "hello_world.pdf";
std::ifstream file(sourcePath, std::ios::binary);
ASSERT_TRUE(file.is_open());
std::vector<uint8_t> pdfBytes((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
file.close();
QpdfWriter writer;
PdfEncryptionOptions opts;
opts.userPassword = "user123";
opts.ownerPassword = "owner123";
opts.allowPrint = false;
opts.allowCopy = false;
auto res = writer.encryptPdf(pdfBytes, opts);
ASSERT_TRUE(res.has_value()) << res.error();
EXPECT_FALSE(res.value().empty());
auto docRes = PdfDocument::loadFromMemory(res.value(), "user123");
ASSERT_TRUE(docRes.has_value());
auto doc = docRes.value();
EXPECT_TRUE(doc->permissions().isEncrypted);
EXPECT_EQ(doc->permissions().encryption, "AES-256");
EXPECT_FALSE(doc->permissions().canPrint);
EXPECT_FALSE(doc->permissions().canCopy);
auto wrongRes = PdfDocument::loadFromMemory(res.value(), "wrongpass");
EXPECT_FALSE(wrongRes.has_value());
EXPECT_EQ(wrongRes.error(), EngineError::InvalidPassword);
auto emptyRes = PdfDocument::loadFromMemory(res.value(), "");
EXPECT_FALSE(emptyRes.has_value());
EXPECT_EQ(emptyRes.error(), EngineError::PasswordRequired);
}
+62
View File
@@ -23,6 +23,10 @@ import type { Annotation } from './viewer/AnnotationLayer';
import type { EditableRun, ReflowParagraphPayload } from './viewer/TextEditLayer';
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';
@@ -112,6 +116,8 @@ function App() {
const [exportModalOpen, setExportModalOpen] = useState(false);
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');
@@ -779,6 +785,47 @@ function App() {
}
};
const handleProtectSubmit = async (payload: {
userPassword: string;
ownerPassword?: string;
confirmPassword: string;
permissions: any;
}) => {
if (!selectedDocId) return;
try {
setIsSaving(true);
const updatedDoc = await gatewayService.protectDocument(selectedDocId, payload);
setDocuments((prev) => prev.map((d) => (d.id === selectedDocId ? updatedDoc : d)));
setActiveDoc(updatedDoc);
setProtectModalState(null);
setIsInspectorOpen(true);
setInspectorTab('properties');
} catch (e: any) {
console.error('Protection failed', e);
throw e;
} finally {
setIsSaving(false);
}
};
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);
@@ -952,6 +999,9 @@ function App() {
onRotate={handleRotate}
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')}
@@ -1289,6 +1339,18 @@ function App() {
onSubmit={(pw) => { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }}
onClose={() => setPasswordPrompt(null)}
/>
<ProtectModal
state={protectModalState}
onSubmit={handleProtectSubmit}
onClose={() => setProtectModalState(null)}
/>
<UnlockModal
state={unlockModalState}
onConfirm={handleUnlockSubmit}
onClose={() => setUnlockModalState(null)}
/>
</div>
);
}
+325
View File
@@ -0,0 +1,325 @@
import React, { useEffect, useRef, useState } from 'react';
import { CustomButton } from './custom/CustomButton';
export interface ProtectModalState {
documentId: string;
filename: string;
}
interface ProtectModalProps {
state: ProtectModalState | null;
onSubmit: (payload: {
userPassword: string;
ownerPassword?: string;
confirmPassword: string;
permissions: {
canPrint: boolean;
canPrintHighRes: boolean;
canCopy: boolean;
canModify: boolean;
canAnnotate: boolean;
canFillForms: boolean;
canExtractForAccessibility: boolean;
canAssemble: boolean;
};
}) => Promise<void>;
onClose: () => void;
}
export const ProtectModal: React.FC<ProtectModalProps> = ({ state, onSubmit, onClose }) => {
const [userPassword, setUserPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [useOwnerPassword, setUseOwnerPassword] = useState(false);
const [ownerPassword, setOwnerPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [canPrint, setCanPrint] = useState(true);
const [canPrintHighRes, setCanPrintHighRes] = useState(true);
const [canCopy, setCanCopy] = useState(true);
const [canModify, setCanModify] = useState(true);
const [canAnnotate, setCanAnnotate] = useState(true);
const [canFillForms, setCanFillForms] = useState(true);
const [canExtractForAccessibility, setCanExtractForAccessibility] = useState(true);
const [canAssemble, setCanAssemble] = useState(true);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!state) return;
setUserPassword('');
setConfirmPassword('');
setUseOwnerPassword(false);
setOwnerPassword('');
setError(null);
setIsSubmitting(false);
setCanPrint(true);
setCanPrintHighRes(true);
setCanCopy(true);
setCanModify(true);
setCanAnnotate(true);
setCanFillForms(true);
setCanExtractForAccessibility(true);
setCanAssemble(true);
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 handlePrintToggle = (val: boolean) => {
setCanPrint(val);
if (!val) {
setCanPrintHighRes(false);
}
};
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!userPassword) {
setError('User password cannot be empty');
return;
}
if (userPassword !== confirmPassword) {
setError('User password and confirmation password do not match');
return;
}
if (useOwnerPassword && !ownerPassword) {
setError('Owner password cannot be empty if separate owner password is checked');
return;
}
setError(null);
setIsSubmitting(true);
try {
await onSubmit({
userPassword,
confirmPassword,
ownerPassword: useOwnerPassword ? ownerPassword : undefined,
permissions: {
canPrint,
canPrintHighRes: canPrint ? canPrintHighRes : false,
canCopy,
canModify,
canAnnotate,
canFillForms,
canExtractForAccessibility,
canAssemble,
},
});
} catch (err: any) {
setError(err?.message || 'Failed to protect document');
} finally {
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: 500, 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()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-[#ebedf0] px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#eef4ff] text-[#2563eb]">
<svg width="20" height="20" 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="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
</div>
<div className="min-w-0">
<h2 className="text-[16px] font-bold tracking-tight text-[#18212e]">Protect PDF</h2>
<p className="truncate text-[12px] text-[#98a1ad]" title={state.filename}>{state.filename}</p>
</div>
</div>
<span className="rounded-full bg-[#eef4ff] px-2.5 py-1 text-[10px] font-extrabold uppercase text-[#2563eb]">
AES-256
</span>
</div>
{/* Content Body */}
<form onSubmit={handleFormSubmit} className="custom-scrollbar flex-1 overflow-y-auto p-6 flex flex-col gap-5">
{error && (
<div className="rounded-[8px] border border-[#fca5a5] bg-[#fdecec] p-3 text-[12.5px] font-medium text-[#dc2626]">
{error}
</div>
)}
{/* Passwords Section */}
<div className="flex flex-col gap-3">
<h3 className="text-[12px] font-bold uppercase tracking-wider text-[#98a1ad]">Passwords</h3>
<div>
<label className="block text-[12.5px] font-semibold text-[#344054] mb-1">User Password</label>
<input
ref={inputRef}
type="password"
value={userPassword}
onChange={(e) => setUserPassword(e.target.value)}
placeholder="Required to open PDF"
autoComplete="off"
className="w-full rounded-[8px] border border-[#d6dae0] bg-white px-3 py-2 text-[13px] outline-none focus:border-[#2563eb] focus:ring-2 focus:ring-[#eef4ff]"
/>
</div>
<div>
<label className="block text-[12.5px] font-semibold text-[#344054] mb-1">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter user password"
autoComplete="off"
className="w-full rounded-[8px] border border-[#d6dae0] bg-white px-3 py-2 text-[13px] outline-none focus:border-[#2563eb] focus:ring-2 focus:ring-[#eef4ff]"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer pt-1">
<input
type="checkbox"
checked={useOwnerPassword}
onChange={(e) => setUseOwnerPassword(e.target.checked)}
className="rounded border-[#d6dae0] text-[#2563eb] focus:ring-[#2563eb]"
/>
<span className="text-[12.5px] font-medium text-[#475467]">Use separate owner password</span>
</label>
{useOwnerPassword && (
<div>
<label className="block text-[12.5px] font-semibold text-[#344054] mb-1">Owner Password</label>
<input
type="password"
value={ownerPassword}
onChange={(e) => setOwnerPassword(e.target.value)}
placeholder="Required for permissions modification"
autoComplete="off"
className="w-full rounded-[8px] border border-[#d6dae0] bg-white px-3 py-2 text-[13px] outline-none focus:border-[#2563eb] focus:ring-2 focus:ring-[#eef4ff]"
/>
</div>
)}
</div>
<div className="h-px bg-[#ebedf0]" />
{/* Permissions Section */}
<div className="flex flex-col gap-2.5">
<h3 className="text-[12px] font-bold uppercase tracking-wider text-[#98a1ad]">Permissions</h3>
<div className="grid grid-cols-1 gap-2.5">
<label className="flex items-center gap-2.5 cursor-pointer text-[13px] text-[#344054]">
<input
type="checkbox"
checked={canPrint}
onChange={(e) => handlePrintToggle(e.target.checked)}
className="rounded border-[#d6dae0] text-[#2563eb] focus:ring-[#2563eb]"
/>
<span className="font-medium">Allow printing</span>
</label>
<label className={`flex items-center gap-2.5 text-[13px] pl-6 ${canPrint ? 'cursor-pointer text-[#344054]' : 'cursor-not-allowed text-[#98a1ad]'}`}>
<input
type="checkbox"
checked={canPrintHighRes}
disabled={!canPrint}
onChange={(e) => setCanPrintHighRes(e.target.checked)}
className="rounded border-[#d6dae0] text-[#2563eb] focus:ring-[#2563eb] disabled:opacity-50"
/>
<span className="font-medium">High-quality printing</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer text-[13px] text-[#344054]">
<input
type="checkbox"
checked={canCopy}
onChange={(e) => setCanCopy(e.target.checked)}
className="rounded border-[#d6dae0] text-[#2563eb] focus:ring-[#2563eb]"
/>
<span className="font-medium">Copy text and graphics</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer text-[13px] text-[#344054]">
<input
type="checkbox"
checked={canModify}
onChange={(e) => setCanModify(e.target.checked)}
className="rounded border-[#d6dae0] text-[#2563eb] focus:ring-[#2563eb]"
/>
<span className="font-medium">Modify document content</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer text-[13px] text-[#344054]">
<input
type="checkbox"
checked={canAnnotate}
onChange={(e) => setCanAnnotate(e.target.checked)}
className="rounded border-[#d6dae0] text-[#2563eb] focus:ring-[#2563eb]"
/>
<span className="font-medium">Annotate & comment</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer text-[13px] text-[#344054]">
<input
type="checkbox"
checked={canFillForms}
onChange={(e) => setCanFillForms(e.target.checked)}
className="rounded border-[#d6dae0] text-[#2563eb] focus:ring-[#2563eb]"
/>
<span className="font-medium">Fill form fields</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer text-[13px] text-[#344054]">
<input
type="checkbox"
checked={canExtractForAccessibility}
onChange={(e) => setCanExtractForAccessibility(e.target.checked)}
className="rounded border-[#d6dae0] text-[#2563eb] focus:ring-[#2563eb]"
/>
<span className="font-medium">Accessibility content extraction</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer text-[13px] text-[#344054]">
<input
type="checkbox"
checked={canAssemble}
onChange={(e) => setCanAssemble(e.target.checked)}
className="rounded border-[#d6dae0] text-[#2563eb] focus:ring-[#2563eb]"
/>
<span className="font-medium">Document assembly (page reordering/deletion)</span>
</label>
</div>
</div>
</form>
{/* Actions Footer */}
<div className="flex items-center justify-end gap-3 border-t border-[#ebedf0] bg-[#f6f7f9] px-6 py-4">
<CustomButton
variant="ghost"
onClick={onClose}
disabled={isSubmitting}
className="rounded-[8px] px-4 font-semibold text-[#5b6573]"
>
Cancel
</CustomButton>
<CustomButton
variant="primary"
onClick={handleFormSubmit}
disabled={!userPassword || isSubmitting}
className="rounded-[8px] bg-[#2563eb] px-5 font-semibold text-white shadow-sm transition-colors hover:bg-[#1d4ed8] disabled:opacity-50"
>
{isSubmitting ? 'Encrypting…' : 'Protect PDF'}
</CustomButton>
</div>
</div>
</div>
);
};
+9 -1
View File
@@ -25,6 +25,9 @@ interface TopBarProps {
onRotate: () => void;
onExport: () => void;
onPrint: () => void;
onProtect?: () => void;
onUnlock?: () => void;
isEncrypted?: boolean;
onUpload: (file: File) => void;
onNewBlankPDF?: () => void;
onShowVersionHistory?: () => void;
@@ -41,7 +44,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, onNewBlankPDF, onSave,
isSaving, isDirtySaved, onRotate, onExport, onPrint, onProtect, onUnlock, isEncrypted, onUpload, onNewBlankPDF, onSave,
canPrint = true, canExport = true, canAssemble = true,
}) => {
const fileRef = useRef<HTMLInputElement>(null);
@@ -80,6 +83,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>
{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>
);
};
+84
View File
@@ -537,6 +537,90 @@ class GatewayService {
return response.json();
}
async protectDocument(
documentId: string,
payload: {
userPassword: string;
ownerPassword?: string;
confirmPassword?: string;
permissions: Partial<PDFPermissions>;
}
): Promise<DocumentInfo> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/protect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (response.status === 501) {
const doc = await this.getDocument(documentId);
return {
...doc,
permissions: {
isEncrypted: true,
encryption: 'AES-256',
securityRevision: 6,
ownerUnlocked: true,
canPrint: payload.permissions.canPrint ?? true,
canPrintHighRes: payload.permissions.canPrintHighRes ?? true,
canModify: payload.permissions.canModify ?? true,
canCopy: payload.permissions.canCopy ?? true,
canAnnotate: payload.permissions.canAnnotate ?? true,
canFillForms: payload.permissions.canFillForms ?? true,
canExtractForAccessibility: payload.permissions.canExtractForAccessibility ?? true,
canAssemble: payload.permissions.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 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" ||
+145 -2
View File
@@ -8,6 +8,7 @@ from app.schemas.document import (
DocumentInfoResponse,
PageInfoResponse,
PermissionsResponse,
ProtectDocumentRequest,
)
from app.services import engine
from app.services.store import document_store
@@ -171,7 +172,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)
@@ -228,4 +229,146 @@ def delete_document(document_id: str):
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
return {"success": True}
return {"success": True}
@router.post("/{document_id}/protect", response_model=DocumentInfoResponse)
async def protect_document(
document_id: str,
req: ProtectDocumentRequest,
) -> 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")
if not req.userPassword or not req.userPassword.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User password cannot be empty.",
)
if req.confirmPassword is not None and req.userPassword != req.confirmPassword:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User password and confirmation password do not match.",
)
owner_pwd = req.ownerPassword if req.ownerPassword else req.userPassword
try:
doc_instance = d["doc_instance"]
unencrypted_bytes = doc_instance.save_full_for_export()
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to extract document bytes for encryption: {e!s}",
)
try:
pdfengine = engine.require()
protected_bytes = pdfengine.protect_pdf(
unencrypted_bytes,
req.userPassword,
owner_pwd,
req.permissions.model_dump(),
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to encrypt document: {e!s}",
)
try:
protected_doc = pdfengine.PdfDocument.load_from_memory(protected_bytes, req.userPassword)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to verify encrypted PDF integrity: {e!s}",
)
updated_info = document_store.update_document_bytes(
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")
return make_document_response(updated_info)
+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,
)
if "remote_context" in doc_info:
new_info["remote_context"] = doc_info["remote_context"]
+19 -1
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field
class PageInfoResponse(BaseModel):
@@ -48,3 +48,21 @@ class OutlineItemResponse(BaseModel):
title: str
pageIndex: int
level: int
class DocumentPermissionsPayload(BaseModel):
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 ProtectDocumentRequest(BaseModel):
userPassword: str = Field(..., description="Password required to open the PDF")
ownerPassword: str | None = Field(None, description="Optional owner password")
confirmPassword: str | None = Field(None, description="Confirmation of user password")
permissions: DocumentPermissionsPayload = Field(default_factory=DocumentPermissionsPayload)
+27
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": [],
}
@@ -99,5 +101,30 @@ class DocumentStore:
return True
return False
def update_document_bytes(
self,
doc_id: str,
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)
if not doc:
return None
if permissions is None:
permissions = extract_permissions(doc_instance)
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
return doc
document_store = DocumentStore()
+261
View File
@@ -0,0 +1,261 @@
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 uploaded_doc_id():
with open(CORPUS_PDF, "rb") as f:
response = client.post("/documents", files={"file": ("hello.pdf", f, "application/pdf")})
assert response.status_code == 201
return response.json()["id"]
def test_protect_unprotected_pdf(uploaded_doc_id):
"""TEST 1: Protect an unprotected PDF and verify metadata & security revision."""
protect_payload = {
"userPassword": "UserPass123!",
"ownerPassword": "OwnerPass123!",
"confirmPassword": "UserPass123!",
"permissions": {
"canPrint": True,
"canPrintHighRes": True,
"canCopy": True,
"canModify": True,
"canAnnotate": True,
"canFillForms": True,
"canExtractForAccessibility": True,
"canAssemble": True,
},
}
resp = client.post(f"/documents/{uploaded_doc_id}/protect", json=protect_payload)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["permissions"]["isEncrypted"] is True
assert data["permissions"]["encryption"] == "AES-256"
assert data["permissions"]["securityRevision"] == 6
def test_open_protected_pdf_without_password(uploaded_doc_id):
"""TEST 2: Re-open protected PDF bytes without password -> PasswordRequired (401)."""
protect_payload = {
"userPassword": "SecretPassword",
"confirmPassword": "SecretPassword",
}
resp = client.post(f"/documents/{uploaded_doc_id}/protect", json=protect_payload)
assert resp.status_code == 200
# Get exported protected bytes
export_resp = client.get(f"/documents/{uploaded_doc_id}/export")
assert export_resp.status_code == 200
pdf_bytes = export_resp.content
# Attempt upload without password
upload_resp = client.post("/documents", files={"file": ("protected.pdf", pdf_bytes, "application/pdf")})
assert upload_resp.status_code == 401
assert "Password required" in upload_resp.json()["detail"]
def test_open_protected_pdf_with_correct_password(uploaded_doc_id):
"""TEST 3: Open protected PDF bytes with correct password -> Success (201)."""
password = "CorrectHorseBatteryStaple"
protect_payload = {
"userPassword": password,
"confirmPassword": password,
}
client.post(f"/documents/{uploaded_doc_id}/protect", json=protect_payload)
export_resp = client.get(f"/documents/{uploaded_doc_id}/export")
pdf_bytes = export_resp.content
upload_resp = client.post(
f"/documents?password={password}",
files={"file": ("protected.pdf", pdf_bytes, "application/pdf")},
)
assert upload_resp.status_code == 201
assert upload_resp.json()["permissions"]["isEncrypted"] is True
def test_open_protected_pdf_with_wrong_password(uploaded_doc_id):
"""TEST 4: Open protected PDF bytes with wrong password -> Rejected (401)."""
password = "CorrectPassword"
protect_payload = {
"userPassword": password,
"confirmPassword": password,
}
client.post(f"/documents/{uploaded_doc_id}/protect", json=protect_payload)
export_resp = client.get(f"/documents/{uploaded_doc_id}/export")
pdf_bytes = export_resp.content
upload_resp = client.post(
"/documents?password=WrongPassword",
files={"file": ("protected.pdf", pdf_bytes, "application/pdf")},
)
assert upload_resp.status_code == 401
assert "Invalid password" in upload_resp.json()["detail"]
def test_disable_copy_permission(uploaded_doc_id):
"""TEST 5: Disable copying -> canCopy=false & Export returned HTTP 403."""
protect_payload = {
"userPassword": "user_pwd",
"ownerPassword": "owner_pwd",
"confirmPassword": "user_pwd",
"permissions": {
"canPrint": True,
"canCopy": False,
},
}
resp = client.post(f"/documents/{uploaded_doc_id}/protect", json=protect_payload)
assert resp.status_code == 200
assert resp.json()["permissions"]["canCopy"] is False
# Attempting to export document with restricted copy permission returns 403 Forbidden
export_resp = client.get(f"/documents/{uploaded_doc_id}/export")
assert export_resp.status_code == 403
def test_disable_print_permission(uploaded_doc_id):
"""TEST 6: Disable printing -> canPrint=false."""
protect_payload = {
"userPassword": "user_pwd",
"ownerPassword": "owner_pwd",
"confirmPassword": "user_pwd",
"permissions": {
"canPrint": False,
"canCopy": True,
},
}
resp = client.post(f"/documents/{uploaded_doc_id}/protect", json=protect_payload)
assert resp.status_code == 200
assert resp.json()["permissions"]["canPrint"] is False
def test_disable_modify_permission(uploaded_doc_id):
"""TEST 7: Disable modification -> canModify=false."""
protect_payload = {
"userPassword": "user_pwd",
"ownerPassword": "owner_pwd",
"confirmPassword": "user_pwd",
"permissions": {
"canModify": False,
},
}
resp = client.post(f"/documents/{uploaded_doc_id}/protect", json=protect_payload)
assert resp.status_code == 200
assert resp.json()["permissions"]["canModify"] is False
def test_permission_combinations(uploaded_doc_id):
"""TEST 8: Verify permission combinations do not grant unintended rights."""
protect_payload = {
"userPassword": "user_pwd",
"ownerPassword": "owner_pwd",
"confirmPassword": "user_pwd",
"permissions": {
"canPrint": True,
"canPrintHighRes": False,
"canCopy": False,
"canModify": False,
"canAnnotate": True,
"canFillForms": False,
"canExtractForAccessibility": True,
"canAssemble": False,
},
}
resp = client.post(f"/documents/{uploaded_doc_id}/protect", json=protect_payload)
assert resp.status_code == 200
p = resp.json()["permissions"]
assert p["canPrint"] is True
assert p["canPrintHighRes"] is False
assert p["canCopy"] is False
assert p["canModify"] is False
assert p["canAnnotate"] is True
assert p["canFillForms"] is False
assert p["canExtractForAccessibility"] is True
assert p["canAssemble"] is False
def test_protect_edited_pdf(uploaded_doc_id):
"""TEST 9: Protect an edited PDF and verify edits remain intact."""
# Apply an edit first
edit_payload = {
"version": "1.0",
"operations": [
{
"id": "annot_1",
"type": "free_text",
"pageIndex": 0,
"data": {
"x": 100,
"y": 100,
"width": 150,
"height": 30,
"text": "Watermark Edit",
"fontSize": 12,
"color": "#ff0000",
},
}
],
}
edit_resp = client.post(f"/documents/{uploaded_doc_id}/edits", json=edit_payload)
assert edit_resp.status_code == 200
edited_doc_id = edit_resp.json()["newDocumentId"]
# Now protect the edited document
protect_payload = {
"userPassword": "EditedPassword",
"confirmPassword": "EditedPassword",
}
prot_resp = client.post(f"/documents/{edited_doc_id}/protect", json=protect_payload)
assert prot_resp.status_code == 200
assert prot_resp.json()["permissions"]["isEncrypted"] is True
def test_normal_unprotected_pdf_export(uploaded_doc_id):
"""TEST 10: Normal unprotected PDF export remains unchanged."""
export_resp = client.get(f"/documents/{uploaded_doc_id}/export")
assert export_resp.status_code == 200
assert export_resp.content.startswith(b"%PDF-")
def test_protection_failure_rollback(uploaded_doc_id):
"""TEST 11: Protection failure (password mismatch) leaves DocumentStore document untouched."""
original_doc = document_store.get_document(uploaded_doc_id)
original_bytes = original_doc["bytes_data"]
bad_payload = {
"userPassword": "Password1",
"confirmPassword": "Password2", # Mismatch!
}
resp = client.post(f"/documents/{uploaded_doc_id}/protect", json=bad_payload)
assert resp.status_code == 400
# Ensure document in store remains untouched
current_doc = document_store.get_document(uploaded_doc_id)
assert current_doc["bytes_data"] == original_bytes
assert current_doc["permissions"]["isEncrypted"] is False
def test_password_security_no_leakage(uploaded_doc_id):
"""TEST 12: Verify passwords are never leaked in response payloads."""
secret_pass = "UltraSuperSecret123"
protect_payload = {
"userPassword": secret_pass,
"ownerPassword": secret_pass + "_owner",
"confirmPassword": secret_pass,
}
resp = client.post(f"/documents/{uploaded_doc_id}/protect", json=protect_payload)
assert resp.status_code == 200
json_str = resp.text
assert secret_pass not in json_str
assert secret_pass + "_owner" not in json_str
+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