Built-in file redaction for local files
This commit is contained in:
1 parent
c490b3d76a
commit
23b9555dcf
11 files changed
+576
-20
No files matched your search
+50
-11
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)})
|
||||
|
||||
Reference in new issue
Block a user