Phase 5 Iteration 1: Bảo mật — mã hoá + quyền hạn, gỡ mật khẩu, redaction THẬT, xoá metadata
Engine: - qpdf.rs: Permissions (in/sửa/copy/chú thích) map sang cờ AES-256 của qpdf; encrypt_with_password_perms (API cũ giữ tương thích); decrypt_remove_password (--password --decrypt, sai mật khẩu trả lỗi không ghi file rác) - redact.rs MỚI — redaction thật, nghiêng về xoá thừa (an toàn): text/path/shading/form giao vùng → xoá nguyên object; ảnh giao vùng → đọc pixel gốc qua get_raw_image, BÔI ĐEN đúng phần giao trong dữ liệu ảnh rồi thay ảnh (không đọc được → xoá cả ảnh); vẽ khối đen phủ vùng - meta.rs: strip_metadata — xoá /Info trailer + XMP /Metadata catalog, xoá cả tham chiếu lẫn object đích (lopdf; file trailer phi chuẩn qua qpdf trước) Tauri + UI (thanh 🔒 Bảo mật): - Commands: redact_apply (nhiều trang, chain file tạm), security_encrypt, security_decrypt, security_strip_metadata - Redact 2 bước như Foxit: tool quét vùng (khối đen mờ viền đỏ, bấm để bỏ, giữ tool quét nhiều vùng) → Áp dụng (n) → lưu file mới - Dialog Đặt mật khẩu (user/confirm/owner + 4 checkbox quyền), Gỡ mật khẩu, Xoá metadata — modal chuyên nghiệp, không prompt/alert Test (6 mới, tổng 58/58 ngoài qpdf-fixture): - redact: chuỗi mật biến mất khỏi extract + tâm vùng render đen; đọc lại RAW image từ file kết quả — nửa redact đen trong chính dữ liệu ảnh - encrypt+perms round-trip + qpdf --show-encryption xác nhận cấm in; decrypt gỡ mật khẩu, mật khẩu sai fail; metadata biến mất khỏi BYTES file - qpdf_safety: 5/7 xanh trên Linux; 2 test repair fixture corrupt-truncated fail do khác biệt qpdf 11.9 Linux (bản Windows dự án xử lý được) — ghi chú trong docs/15, cần xác nhận lại trên máy Windows Docs: 15-phase5-summary (kèm kế hoạch Iteration 2: chữ ký số PAdES, lưu tối ưu, redact tỉa ký tự), 16-phase5-user-tests (S1–S12), README, roadmap, 14. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
@@ -836,6 +836,109 @@ fn edit_preview(input: String, page: u16, ops: Vec<EditOpDto>, width: u32, passw
|
||||
result
|
||||
}
|
||||
|
||||
// ---------- Phase 5: Bảo mật ----------
|
||||
|
||||
/// Vùng redact của 1 trang: rects theo [left, bottom, right, top] (điểm PDF).
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RedactPageDto {
|
||||
page: u16,
|
||||
rects: Vec<[f32; 4]>,
|
||||
}
|
||||
|
||||
/// Redact THẬT nhiều trang: áp tuần tự qua file tạm, trả tổng số object đã
|
||||
/// xoá/bôi đen.
|
||||
#[tauri::command]
|
||||
fn redact_apply(
|
||||
input: String,
|
||||
areas: Vec<RedactPageDto>,
|
||||
output: String,
|
||||
password: Option<String>,
|
||||
) -> Result<usize, String> {
|
||||
if areas.is_empty() {
|
||||
return Err("chưa đánh dấu vùng redact nào".into());
|
||||
}
|
||||
let pdfium = pdfium()?;
|
||||
let mut total = 0usize;
|
||||
let mut cur = std::path::PathBuf::from(&input);
|
||||
let mut temps: Vec<std::path::PathBuf> = Vec::new();
|
||||
let n = areas.len();
|
||||
for (i, a) in areas.iter().enumerate() {
|
||||
let rects: Vec<ff_engine::Rect> = a
|
||||
.rects
|
||||
.iter()
|
||||
.map(|r| ff_engine::Rect { left: r[0], bottom: r[1], right: r[2], top: r[3] })
|
||||
.collect();
|
||||
let dst = if i + 1 == n {
|
||||
std::path::PathBuf::from(&output)
|
||||
} else {
|
||||
std::env::temp_dir().join(format!("ff_redact_{}_{}.pdf", std::process::id(), i))
|
||||
};
|
||||
total += ff_engine::redact_areas(&pdfium, &cur, a.page, &rects, &dst, password.as_deref())
|
||||
.map_err(|e| e.to_string())?;
|
||||
if i > 0 {
|
||||
temps.push(cur.clone());
|
||||
}
|
||||
cur = dst;
|
||||
}
|
||||
for t in temps {
|
||||
let _ = std::fs::remove_file(t);
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Đặt mật khẩu + quyền hạn (AES-256 qua qpdf).
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn security_encrypt(
|
||||
input: String,
|
||||
output: String,
|
||||
user_password: String,
|
||||
owner_password: String,
|
||||
allow_print: bool,
|
||||
allow_modify: bool,
|
||||
allow_extract: bool,
|
||||
allow_annotate: bool,
|
||||
) -> Result<(), String> {
|
||||
ff_engine::encrypt_with_password_perms(
|
||||
std::path::Path::new(&input),
|
||||
std::path::Path::new(&output),
|
||||
&user_password,
|
||||
&owner_password,
|
||||
ff_engine::Permissions { allow_print, allow_modify, allow_extract, allow_annotate },
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Gỡ mật khẩu (cần đúng password hiện tại).
|
||||
#[tauri::command]
|
||||
fn security_decrypt(input: String, password: String, output: String) -> Result<(), String> {
|
||||
ff_engine::decrypt_remove_password(
|
||||
std::path::Path::new(&input),
|
||||
&password,
|
||||
std::path::Path::new(&output),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Xoá metadata nhận dạng (/Info + XMP). File lạ (trailer phi chuẩn) được
|
||||
/// chuẩn hoá qua qpdf trước khi lopdf xử lý.
|
||||
#[tauri::command]
|
||||
fn security_strip_metadata(input: String, output: String) -> Result<(), String> {
|
||||
let inp = std::path::Path::new(&input);
|
||||
let out = std::path::Path::new(&output);
|
||||
match ff_engine::strip_metadata(inp, out) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => {
|
||||
let norm = std::env::temp_dir().join(format!("ff_meta_norm_{}.pdf", std::process::id()));
|
||||
ff_engine::repair(inp, &norm).map_err(|e| e.to_string())?;
|
||||
let r = ff_engine::strip_metadata(&norm, out).map_err(|e| e.to_string());
|
||||
let _ = std::fs::remove_file(&norm);
|
||||
r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dọn các file làm việc tạm của chế độ sửa (undo stack). Chỉ xoá file có tên
|
||||
/// `ff_edit_*` nằm đúng trong thư mục temp — không bao giờ đụng file người dùng.
|
||||
#[tauri::command]
|
||||
@@ -896,6 +999,10 @@ fn main() {
|
||||
edit_apply_to_temp,
|
||||
edit_preview,
|
||||
edit_cleanup,
|
||||
redact_apply,
|
||||
security_encrypt,
|
||||
security_decrypt,
|
||||
security_strip_metadata,
|
||||
pick_image
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<span class="sep"></span>
|
||||
<button id="organizeModeBtn" title="Tổ chức trang">🗂 Tổ chức trang</button>
|
||||
<button id="editModeBtn" title="Sửa nội dung trang">✏️ Sửa nội dung</button>
|
||||
<button id="secModeBtn" title="Bảo mật: redact, mật khẩu, metadata">🔒 Bảo mật</button>
|
||||
<div class="spacer"></div>
|
||||
<span id="status" class="status"></span>
|
||||
</header>
|
||||
@@ -102,6 +103,19 @@
|
||||
<button id="edSave" disabled>💾 Lưu thay đổi nội dung</button>
|
||||
</div>
|
||||
|
||||
<div id="secBar" class="editbar hidden">
|
||||
<span class="annolabel">Bảo mật:</span>
|
||||
<button id="secRedactTool" class="atool" data-tool="redact" title="Kéo chuột trên trang để đánh dấu vùng cần bôi đen — nội dung bị XOÁ THẬT khi áp dụng">⬛ Đánh dấu redact</button>
|
||||
<button id="secRedactApply" disabled title="Xoá thật nội dung trong các vùng đã đánh dấu, lưu ra file mới">Áp dụng redact (<span id="redactCount">0</span>)</button>
|
||||
<button id="secRedactClear" disabled title="Bỏ toàn bộ đánh dấu">Bỏ đánh dấu</button>
|
||||
<span class="sep"></span>
|
||||
<button id="secEncrypt" title="Đặt mật khẩu mở file + quyền hạn (AES-256)">🔑 Đặt mật khẩu</button>
|
||||
<button id="secDecrypt" title="Gỡ mật khẩu (cần mật khẩu hiện tại)">🔓 Gỡ mật khẩu</button>
|
||||
<button id="secStripMeta" title="Xoá /Info (tác giả, phần mềm tạo...) và XMP metadata khỏi file">🧹 Xoá metadata</button>
|
||||
<span class="spacer"></span>
|
||||
<span id="secHint" class="status"></span>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<aside id="sidebar" class="sidebar">
|
||||
<div class="tabs">
|
||||
|
||||
+182
-3
@@ -39,6 +39,8 @@ const state = {
|
||||
editPendingImage: null, // đường dẫn ảnh chờ đặt (Thêm ảnh)
|
||||
editColor: [0, 0, 0], // màu chữ áp khi sửa/thêm text
|
||||
editTemps: [], // mọi file tạm đã materialize trong phiên sửa (để dọn)
|
||||
secMode: false, // thanh Bảo mật (Phase 5) đang mở
|
||||
redactMarks: [], // [{page, rect{left,bottom,right,top}}] chờ áp dụng
|
||||
};
|
||||
const UNDO_LIMIT = 50;
|
||||
let annotIdSeq = 1;
|
||||
@@ -71,6 +73,8 @@ async function loadDocument(path) {
|
||||
state.annotSpecs = [];
|
||||
state.undoStack = [];
|
||||
state.redoStack = [];
|
||||
state.redactMarks = [];
|
||||
updateRedactButtons();
|
||||
updateUndoRedoButtons();
|
||||
setTool(null);
|
||||
updateAnnotCount();
|
||||
@@ -599,9 +603,11 @@ function setTool(tool) {
|
||||
$("annotHint").textContent = state.tool
|
||||
? state.tool === "note"
|
||||
? "Bấm lên trang để đặt ghi chú"
|
||||
: isTextMarkupTool(state.tool)
|
||||
? "Kéo chọn văn bản trên trang để tô (bấm lại nút để tắt)"
|
||||
: "Kéo chuột trên trang để vẽ"
|
||||
: state.tool === "redact"
|
||||
? "Kéo chuột quét vùng cần bôi đen — nội dung sẽ bị XOÁ THẬT khi Áp dụng"
|
||||
: isTextMarkupTool(state.tool)
|
||||
? "Kéo chọn văn bản trên trang để tô (bấm lại nút để tắt)"
|
||||
: "Kéo chuột trên trang để vẽ"
|
||||
: "";
|
||||
}
|
||||
|
||||
@@ -758,6 +764,28 @@ function drawAnnotsForPage(idx) {
|
||||
}
|
||||
layer.appendChild(el);
|
||||
}
|
||||
|
||||
// Đánh dấu redact (Phase 5) — vẽ cùng layer để sống sót qua mọi lần redraw.
|
||||
for (const m of state.redactMarks) {
|
||||
if (m.page !== idx) continue;
|
||||
const el = document.createElement("div");
|
||||
el.className = "redact-mark";
|
||||
Object.assign(el.style, {
|
||||
left: m.rect.left * scale + "px",
|
||||
top: (p.heightPt - m.rect.top) * scale + "px",
|
||||
width: (m.rect.right - m.rect.left) * scale + "px",
|
||||
height: (m.rect.top - m.rect.bottom) * scale + "px",
|
||||
});
|
||||
el.title = "Bấm để bỏ đánh dấu redact";
|
||||
el.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const i = state.redactMarks.indexOf(m);
|
||||
if (i >= 0) state.redactMarks.splice(i, 1);
|
||||
drawAnnotsForPage(idx);
|
||||
updateRedactButtons();
|
||||
});
|
||||
layer.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
function selectAnnot(id) {
|
||||
@@ -1195,6 +1223,13 @@ function onPagesMouseUp() {
|
||||
openCropDialog(d.idx, rect);
|
||||
return;
|
||||
}
|
||||
if (tool === "redact") {
|
||||
if (tiny) return;
|
||||
state.redactMarks.push({ page: d.idx, rect });
|
||||
drawAnnotsForPage(d.idx);
|
||||
updateRedactButtons();
|
||||
return; // giữ tool để quét tiếp nhiều vùng (như Foxit Mark for Redaction)
|
||||
}
|
||||
if (tool === "note") {
|
||||
const sz = 18 / scale;
|
||||
rect.right = rect.left + sz;
|
||||
@@ -2640,6 +2675,142 @@ async function saveEdits() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Phase 5: Bảo mật (redact / mật khẩu / metadata) ----------
|
||||
|
||||
function toggleSecMode() {
|
||||
state.secMode = !state.secMode;
|
||||
$("secBar").classList.toggle("hidden", !state.secMode);
|
||||
$("secModeBtn").classList.toggle("active", state.secMode);
|
||||
if (!state.secMode && state.tool === "redact") setTool(null);
|
||||
$("secHint").textContent = "";
|
||||
}
|
||||
|
||||
function updateRedactButtons() {
|
||||
const n = state.redactMarks.length;
|
||||
$("redactCount").textContent = n;
|
||||
$("secRedactApply").disabled = n === 0;
|
||||
$("secRedactClear").disabled = n === 0;
|
||||
}
|
||||
|
||||
function clearRedactMarks() {
|
||||
const pages = [...new Set(state.redactMarks.map((m) => m.page))];
|
||||
state.redactMarks = [];
|
||||
pages.forEach((p) => drawAnnotsForPage(p));
|
||||
updateRedactButtons();
|
||||
}
|
||||
|
||||
// Áp dụng redact: XOÁ THẬT nội dung trong các vùng đã đánh dấu → file mới.
|
||||
async function applyRedactions() {
|
||||
if (!state.redactMarks.length) return;
|
||||
const out = await invoke("pick_save_pdf");
|
||||
if (!out) return;
|
||||
// Gom theo trang: [{page, rects: [[l,b,r,t], ...]}]
|
||||
const byPage = new Map();
|
||||
for (const m of state.redactMarks) {
|
||||
if (!byPage.has(m.page)) byPage.set(m.page, []);
|
||||
byPage.get(m.page).push([m.rect.left, m.rect.bottom, m.rect.right, m.rect.top]);
|
||||
}
|
||||
const areas = [...byPage.entries()].map(([page, rects]) => ({ page, rects }));
|
||||
try {
|
||||
const n = await invoke("redact_apply", { input: state.path, areas, output: out, password: null });
|
||||
clearRedactMarks();
|
||||
setTool(null);
|
||||
$("status").textContent = `Đã redact (xoá thật) ${n} đối tượng → ${shortName(out)}`;
|
||||
loadDocument(out);
|
||||
} catch (e) {
|
||||
$("secHint").textContent = "Lỗi redact: " + e;
|
||||
}
|
||||
}
|
||||
|
||||
function openEncryptDialog() {
|
||||
const box = openModal("Đặt mật khẩu (AES-256)", `
|
||||
<label>Mật khẩu mở file (user password)</label>
|
||||
<input type="password" id="secPw1" autocomplete="new-password">
|
||||
<label>Nhập lại mật khẩu</label>
|
||||
<input type="password" id="secPw2" autocomplete="new-password">
|
||||
<label>Mật khẩu chủ sở hữu (owner — để trống = dùng mật khẩu mở)</label>
|
||||
<input type="password" id="secPwOwner" autocomplete="new-password">
|
||||
<label>Quyền hạn khi mở bằng mật khẩu user</label>
|
||||
<div class="row">
|
||||
<label><input type="checkbox" id="secPermPrint" checked> In</label>
|
||||
<label><input type="checkbox" id="secPermModify" checked> Sửa nội dung</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label><input type="checkbox" id="secPermExtract" checked> Sao chép text/ảnh</label>
|
||||
<label><input type="checkbox" id="secPermAnnotate" checked> Chú thích/điền form</label>
|
||||
</div>
|
||||
<div class="err" id="secEncErr"></div>
|
||||
<div class="foot"><button id="secEncCancel">Huỷ</button><button id="secEncOk" class="primary">Mã hoá…</button></div>
|
||||
`);
|
||||
box.querySelector("#secEncCancel").addEventListener("click", closeModal);
|
||||
box.querySelector("#secEncOk").addEventListener("click", async () => {
|
||||
const p1 = box.querySelector("#secPw1").value;
|
||||
const p2 = box.querySelector("#secPw2").value;
|
||||
const err = box.querySelector("#secEncErr");
|
||||
if (!p1) { err.textContent = "Mật khẩu không được để trống."; return; }
|
||||
if (p1 !== p2) { err.textContent = "Hai lần nhập không khớp."; return; }
|
||||
const out = await invoke("pick_save_pdf");
|
||||
if (!out) return;
|
||||
try {
|
||||
await invoke("security_encrypt", {
|
||||
input: state.path,
|
||||
output: out,
|
||||
userPassword: p1,
|
||||
ownerPassword: box.querySelector("#secPwOwner").value,
|
||||
allowPrint: box.querySelector("#secPermPrint").checked,
|
||||
allowModify: box.querySelector("#secPermModify").checked,
|
||||
allowExtract: box.querySelector("#secPermExtract").checked,
|
||||
allowAnnotate: box.querySelector("#secPermAnnotate").checked,
|
||||
});
|
||||
closeModal();
|
||||
$("status").textContent = `Đã mã hoá AES-256 → ${shortName(out)}`;
|
||||
$("secHint").textContent = "File mã hoá đã lưu riêng — file đang mở giữ nguyên.";
|
||||
} catch (e) {
|
||||
err.textContent = "Lỗi: " + e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openDecryptDialog() {
|
||||
const box = openModal("Gỡ mật khẩu", `
|
||||
<p class="muted">Chọn file PDF đang có mật khẩu, nhập mật khẩu hiện tại, lưu ra bản không mã hoá.</p>
|
||||
<label>Mật khẩu hiện tại</label>
|
||||
<input type="password" id="secDecPw" autocomplete="current-password">
|
||||
<div class="err" id="secDecErr"></div>
|
||||
<div class="foot"><button id="secDecCancel">Huỷ</button><button id="secDecOk" class="primary">Chọn file & gỡ…</button></div>
|
||||
`);
|
||||
box.querySelector("#secDecCancel").addEventListener("click", closeModal);
|
||||
box.querySelector("#secDecOk").addEventListener("click", async () => {
|
||||
const pw = box.querySelector("#secDecPw").value;
|
||||
const err = box.querySelector("#secDecErr");
|
||||
if (!pw) { err.textContent = "Cần nhập mật khẩu hiện tại."; return; }
|
||||
const inp = await invoke("pick_pdf");
|
||||
if (!inp) return;
|
||||
const out = await invoke("pick_save_pdf");
|
||||
if (!out) return;
|
||||
try {
|
||||
await invoke("security_decrypt", { input: inp, password: pw, output: out });
|
||||
closeModal();
|
||||
$("status").textContent = `Đã gỡ mật khẩu → ${shortName(out)}`;
|
||||
loadDocument(out);
|
||||
} catch (e) {
|
||||
err.textContent = "Lỗi (mật khẩu sai?): " + e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function stripMetadataAction() {
|
||||
const out = await invoke("pick_save_pdf");
|
||||
if (!out) return;
|
||||
try {
|
||||
await invoke("security_strip_metadata", { input: state.path, output: out });
|
||||
$("status").textContent = `Đã xoá metadata (/Info + XMP) → ${shortName(out)}`;
|
||||
loadDocument(out);
|
||||
} catch (e) {
|
||||
$("secHint").textContent = "Lỗi xoá metadata: " + e;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Sự kiện ----------
|
||||
|
||||
$("openBtn").addEventListener("click", openFile);
|
||||
@@ -2755,6 +2926,14 @@ $("modalOverlay").addEventListener("click", (e) => {
|
||||
if (e.target.id === "modalOverlay") closeModal();
|
||||
});
|
||||
|
||||
// Bảo mật (Phase 5)
|
||||
$("secModeBtn").addEventListener("click", toggleSecMode);
|
||||
$("secRedactApply").addEventListener("click", applyRedactions);
|
||||
$("secRedactClear").addEventListener("click", clearRedactMarks);
|
||||
$("secEncrypt").addEventListener("click", openEncryptDialog);
|
||||
$("secDecrypt").addEventListener("click", openDecryptDialog);
|
||||
$("secStripMeta").addEventListener("click", stripMetadataAction);
|
||||
|
||||
// Sửa nội dung (Phase 4)
|
||||
$("editModeBtn").addEventListener("click", toggleEditMode);
|
||||
$("edAddText").addEventListener("click", () => {
|
||||
|
||||
@@ -413,6 +413,18 @@ body.tool-active .annotlayer * { pointer-events: none; }
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
/* Đánh dấu redact (Phase 5): khối mờ đen viền đỏ, bấm để bỏ */
|
||||
.redact-mark {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: 1.5px dashed #e33;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
.redact-mark:hover {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Textarea sửa cả đoạn (reflow) — đè white-space:pre của .edit-inline */
|
||||
.edit-inline-block {
|
||||
white-space: pre-wrap;
|
||||
|
||||
Reference in New Issue
Block a user