Merge branch 'fix_issue' of https://gitea.maskantech.in/gitea_admin/pdf into ribai
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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`);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -860,6 +860,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
onAnnotationUpdate={onAnnotationUpdate}
|
||||
onFieldChange={onFieldChange}
|
||||
isSelectToolActive={activeTool === "select"}
|
||||
/>
|
||||
|
||||
{(activeTool === "select" ||
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user