Built-in file redaction for local files

This commit is contained in:
StyxX65 committed 2026-05-27 14:49:06 +02:00
1 parent c490b3d76a
commit 23b9555dcf
11 files changed
+576 -20

No files matched your search

+50 -11
View File
@@ -344,6 +344,29 @@ def db_import():
return jsonify({"error": str(e)}), 500
def _excerpt_page(excerpt: str, item_meta: dict) -> str:
"""Minimal HTML page showing a stored body excerpt as a preview fallback."""
import html as _html
subject = _html.escape(item_meta.get("name", ""))
modified = item_meta.get("modified", "")
account = _html.escape(item_meta.get("account_name", ""))
body = "<pre style='white-space:pre-wrap;font-family:sans-serif;margin:0'>" + _html.escape(excerpt) + "</pre>"
note = "<p style='font-size:11px;color:#888;margin-top:12px'>Stored excerpt — connect to reload the full message.</p>"
return (
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<style>body{font-family:-apple-system,sans-serif;font-size:13px;"
"padding:12px 16px;background:#fff;color:#111;word-break:break-word}"
".hdr{border-bottom:1px solid #eee;margin-bottom:12px;padding-bottom:10px}"
".hdr-row{color:#555;font-size:12px;margin-bottom:3px}"
".hdr-row b{color:#111}</style></head><body>"
f"<div class='hdr'>"
+ (f"<div class='hdr-row'><b>From:</b> {account}</div>" if account else "")
+ (f"<div class='hdr-row'><b>Date:</b> {_html.escape(modified)}</div>" if modified else "")
+ (f"<div class='hdr-row'><b>Subject:</b> {subject}</div>" if subject else "")
+ f"</div>{body}{note}</body></html>"
)
@bp.route("/api/preview/<item_id>")
def get_preview(item_id):
"""Return a preview URL or HTML for a flagged item."""
@@ -541,7 +564,11 @@ def get_preview(item_id):
try:
if source_type == "email":
excerpt = item_meta.get("body_excerpt", "")
if not state.connector:
if excerpt:
import html as _html
return jsonify({"type": "html", "html": _excerpt_page(excerpt, item_meta)})
return jsonify({"error": "not authenticated"}), 401
uid = account_id
try:
@@ -550,6 +577,8 @@ def get_preview(item_id):
{"$select": "subject,from,receivedDateTime,body"}
)
except Exception as e:
if excerpt:
return jsonify({"type": "html", "html": _excerpt_page(excerpt, item_meta)})
return jsonify({"error": f"Could not load email: {e}"})
sender = msg.get("from", {}).get("emailAddress", {})
@@ -619,23 +648,33 @@ def get_preview(item_id):
return jsonify({"type": "iframe", "url": f"https://drive.google.com/file/d/{fid}/preview"})
# Fallback: generic Drive embed
return jsonify({"type": "iframe", "url": item_url.replace("/view", "/preview")})
# Gmail — not embeddable; show link card
icon = "✉️" if source_type == "gmail" else "☁️"
label = "Open in Gmail" if source_type == "gmail" else "Open in Google Drive"
# Gmail — not embeddable; show link card + stored body excerpt if available
icon = "✉️" if source_type == "gmail" else "☁️"
label = "Open in Gmail" if source_type == "gmail" else "Open in Google Drive"
excerpt = item_meta.get("body_excerpt", "")
link_html = (
f'<a href="{_html_esc(item_url)}" target="_blank" '
f'style="display:inline-block;margin-top:12px;padding:8px 16px;'
f'background:#3b7dd8;color:#fff;border-radius:6px;text-decoration:none;font-size:12px">'
f'{label}</a>'
) if item_url else ""
html_out = (
f'<div style="padding:24px;text-align:center;font-family:sans-serif">'
f'<div style="font-size:40px">{icon}</div>'
f'<div style="font-size:13px;font-weight:600;margin:8px 0">{_html_esc(name)}</div>'
f'<div style="font-size:11px;color:var(--muted)">No inline preview available for this item</div>'
f'{link_html}'
f'</div>'
)
if excerpt and source_type == "gmail":
html_out = _excerpt_page(excerpt, item_meta)
if item_url:
# Inject the "Open in Gmail" link before </body>
html_out = html_out.replace(
"</body>",
f'<div style="margin-top:12px">{link_html}</div></body>'
)
else:
html_out = (
f'<div style="padding:24px;text-align:center;font-family:sans-serif">'
f'<div style="font-size:40px">{icon}</div>'
f'<div style="font-size:13px;font-weight:600;margin:8px 0">{_html_esc(name)}</div>'
f'<div style="font-size:11px;color:var(--muted)">No inline preview available for this item</div>'
f'{link_html}'
f'</div>'
)
return jsonify({"type": "html", "html": html_out})
else:
+99
View File
@@ -1158,6 +1158,7 @@ def export_article30():
return jsonify({"error": str(e)}), 500
@bp.route("/api/delete_item", methods=["POST"])
def delete_item():
"""Delete a single flagged item. Returns {ok, error}."""
if not state.connector:
@@ -1200,6 +1201,104 @@ def delete_item():
return jsonify({"ok": False, "error": str(e)})
_REDACT_EXTS = {".docx", ".xlsx", ".csv", ".txt"}
@bp.route("/api/redact_item", methods=["POST"])
def redact_item():
"""Redact CPR numbers in-place in a local file. Returns {ok, redacted}."""
from pathlib import Path as _Path
import tempfile as _tempfile
import shutil as _shutil
data = request.get_json() or {}
item_id = data.get("id", "")
if not item_id:
return jsonify({"ok": False, "error": "id required"}), 400
# Resolve item meta: in-memory first (active scan), then DB (history)
item_meta = next((x for x in state.flagged_items if x.get("id") == item_id), None)
if item_meta is None:
_db = _get_db() if DB_OK else None
if _db:
row = _db._connect().execute(
"SELECT * FROM flagged_items WHERE id=? LIMIT 1", (item_id,)
).fetchone()
item_meta = dict(row) if row else {}
else:
item_meta = {}
source_type = item_meta.get("source_type", "")
if source_type not in ("local",):
return jsonify({"ok": False, "error": "Redaction is only supported for local files"}), 400
full_path = item_meta.get("full_path", "")
if not full_path:
return jsonify({"ok": False, "error": "File path not available — rescan to enable redaction"}), 400
path = _Path(full_path).expanduser()
if not path.exists():
return jsonify({"ok": False, "error": f"File not found: {full_path}"}), 404
ext = path.suffix.lower()
if ext not in _REDACT_EXTS:
return jsonify({"ok": False, "error": f"Redaction not supported for {ext or 'this'} files. Supported: DOCX, XLSX, CSV, TXT"}), 400
tmp_path = None
try:
from document_scanner import (
scan_docx, redact_docx,
scan_xlsx, redact_xlsx,
redact_csv,
find_pii_spans_in_text,
)
with _tempfile.NamedTemporaryFile(suffix=ext, delete=False, dir=path.parent) as tmp:
tmp_path = _Path(tmp.name)
if ext == ".docx":
results = scan_docx(path)
redacted = redact_docx(path, tmp_path, results, use_ner=False)
elif ext == ".xlsx":
results = scan_xlsx(path)
redacted = redact_xlsx(path, tmp_path, results, use_ner=False)
elif ext == ".csv":
redacted = redact_csv(path, tmp_path, use_ner=False)
else: # .txt
text = path.read_text(encoding="utf-8", errors="replace")
spans = [(s, e, l) for s, e, l in find_pii_spans_in_text(text, use_ner=False) if l == "CPR"]
chars = list(text)
for s, e, _ in sorted(spans, reverse=True):
chars[s:e] = ["█"] * (e - s)
tmp_path.write_text("".join(chars), encoding="utf-8")
redacted = len(spans)
_shutil.move(str(tmp_path), str(path))
tmp_path = None
state.flagged_items[:] = [x for x in state.flagged_items if x.get("id") != item_id]
_db = _get_db() if DB_OK else None
if _db:
try:
_db.log_deletion(item_meta, reason="redacted")
_db.delete_item_record(item_id)
except Exception:
pass
logger.info("[redact] %s — %d CPR span(s) redacted", path.name, redacted)
return jsonify({"ok": True, "redacted": redacted})
except Exception as e:
logger.error("[redact] failed: %s", e)
return jsonify({"ok": False, "error": str(e)})
finally:
if tmp_path and tmp_path.exists():
try:
tmp_path.unlink()
except Exception:
pass
@bp.route("/api/delete_bulk", methods=["POST"])
def delete_bulk():
"""Delete multiple items matching criteria. Streams progress as SSE."""
+9
View File
@@ -255,6 +255,7 @@ def _run_google_scan(options: dict):
"special_category": [],
"face_count": 0,
"exif": {},
"body_excerpt": item_meta.get("_body_excerpt", ""),
}
flagged_items.append(card)
_google_flagged.append(card)
@@ -305,6 +306,14 @@ def _run_google_scan(options: dict):
try:
meta["_account"] = _display_name
meta["_source_type"] = "gmail"
# Extract a plain-text excerpt before scanning (body is discarded after)
try:
import re as _re
_raw = data[:3000].decode("utf-8", errors="replace")
_plain = _re.sub(r"<[^>]+>", " ", _raw)
meta["_body_excerpt"] = " ".join(_plain.split())[:500]
except Exception:
meta["_body_excerpt"] = ""
result = _scan_bytes(data, meta.get("name", "msg.txt"))
except Exception as e:
broadcast("scan_error", {"file": meta.get("name", ""), "error": str(e)})