From 9e4f456691451b2d34c9131545719fa9dd247672 Mon Sep 17 00:00:00 2001 From: Don Kingdon Date: Mon, 13 Apr 2026 05:40:12 +0000 Subject: [PATCH] Add deleted file recovery with -recovered naming --- PROJECT_STATE.md | 19 +++-- README.md | 6 ++ VERSION | 2 +- app/main/routes.py | 106 +++++++++++++++++++++++++ app/templates/cloud/deleted_files.html | 15 ++-- 5 files changed, 134 insertions(+), 14 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index d187693..c74078c 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,7 +4,7 @@ OTB Cloud ## Current version -v0.2.2 +v0.2.3 ## Build date 2026-04-12 @@ -25,8 +25,9 @@ Portal-authenticated secure backup and storage platform for customer files, incl - Device add/remove - Browser upload to device originals - Device file browser -- Checkbox selection actions +- Selection actions - Soft-delete to deleted folder +- Recover from deleted folder - Zip workspace staging and zip export - Deleted files page with hard delete - Exports page @@ -34,14 +35,16 @@ Portal-authenticated secure backup and storage platform for customer files, incl ## Retention and safety notes - Original files are stored as immutable originals - Deleted files are retained in the deleted area for up to 24 hours +- Deleted files can be recovered during that hold window - Deleted files can also be hard-deleted immediately by the user +- Recovered files return to originals with `-recovered` appended to filename - Zip staging copies are temporary working copies - Successful zip creation clears staged copies but does not affect original source files ## Immediate next tasks -1. Add single-file download buttons in more places -2. Add basename-only rename flow -3. Add searchable file listing -4. Add bulk folder upload -5. Add media processing jobs -6. Add derived/original filtering +1. Add basename-only rename flow +2. Add searchable file listing +3. Add bulk folder upload +4. Add media processing jobs +5. Add derived/original filtering +6. Add better single-file actions in browser diff --git a/README.md b/README.md index 1280339..f6ffe16 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # OTB Cloud +## v0.2.3 - 2026-04-12 +- Added Recover action for soft-deleted files +- Recovered files now return to the device originals area +- Recovered files append `-recovered` to the user-facing filename +- Added audit logging for file recovery + ## v0.2.2 - 2026-04-12 - Added checkbox selection to device file browser - Added soft-delete selected files workflow diff --git a/VERSION b/VERSION index f0cfd3b..576b777 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.2.2 +v0.2.3 diff --git a/app/main/routes.py b/app/main/routes.py index a3d21ad..2072d5f 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -33,6 +33,16 @@ def _stored_name(original_name: str) -> str: ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") return f"{ts}__{safe}" + +def _recovered_filename(original_name: str) -> tuple[str, str, str]: + base_name = original_name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] + if "." in base_name: + basename, extension = base_name.rsplit(".", 1) + recovered_name = f"{basename}-recovered.{extension}" + return recovered_name, f"{basename}-recovered", extension + recovered_name = f"{base_name}-recovered" + return recovered_name, f"{base_name}-recovered", "" + def _safe_path_from_relative(relative_path: str) -> Path: return _tenant_root() / relative_path @@ -695,6 +705,102 @@ def deleted_files(): files=files, ) + + +@bp.route("/deleted//recover", methods=["POST"]) +@portal_session_required +def recover_deleted_file(file_id: int): + db = get_db() + + with db.cursor() as cur: + cur.execute( + """ + SELECT + f.id, + f.device_id, + f.original_filename, + f.relative_path, + f.is_deleted, + d.device_name, + d.device_type, + d.relative_path AS device_relative_path + FROM files f + LEFT JOIN devices d ON f.device_id = d.id + WHERE f.id = %s + AND f.tenant_id = %s + """, + (file_id, session["otb_tenant_id"]), + ) + file_row = cur.fetchone() + + if not file_row or not file_row["is_deleted"]: + flash("Deleted file not found.", "warning") + return redirect(url_for("main.deleted_files")) + + if not file_row["device_relative_path"]: + flash("Cannot recover this file because its device record is missing.", "warning") + return redirect(url_for("main.deleted_files")) + + source_path = _safe_path_from_relative(file_row["relative_path"]) + if not source_path.exists(): + flash("Deleted file is missing from storage.", "warning") + return redirect(url_for("main.deleted_files")) + + recovered_name, recovered_basename, recovered_extension = _recovered_filename(file_row["original_filename"]) + stored_name = _stored_name(recovered_name) + + target_rel = f"{file_row['device_relative_path']}/originals/{stored_name}" + target_dir = f"{file_row['device_relative_path']}/originals" + target_path = _safe_path_from_relative(target_rel) + target_path.parent.mkdir(parents=True, exist_ok=True) + + shutil.move(str(source_path), str(target_path)) + + cur.execute( + """ + UPDATE files + SET original_filename = %s, + basename = %s, + extension = %s, + relative_path = %s, + directory_path = %s, + is_deleted = 0, + deleted_at = NULL + WHERE id = %s AND tenant_id = %s + """, + ( + recovered_name, + recovered_basename, + recovered_extension, + target_rel, + target_dir, + file_id, + session["otb_tenant_id"], + ), + ) + + cur.execute( + """ + INSERT INTO audit_logs ( + tenant_id, user_id, actor_type, event_type, file_id, ip_address, user_agent, event_detail + ) VALUES (%s, %s, 'user', 'file_recovered', %s, %s, %s, %s) + """, + ( + session["otb_tenant_id"], + session["otb_user_id"], + file_id, + _client_ip(), + request.headers.get("User-Agent", ""), + f"Recovered deleted file as '{recovered_name}' back into originals", + ), + ) + + db.commit() + + flash(f"Recovered file as '{recovered_name}'. You can find it back in the device file browser.", "success") + return redirect(url_for("main.deleted_files")) + + @bp.route("/deleted//hard-delete", methods=["POST"]) @portal_session_required def hard_delete_file(file_id: int): diff --git a/app/templates/cloud/deleted_files.html b/app/templates/cloud/deleted_files.html index c82500e..30fa813 100644 --- a/app/templates/cloud/deleted_files.html +++ b/app/templates/cloud/deleted_files.html @@ -37,7 +37,7 @@

Deleted Files

-

Files here are pending retention expiry or hard delete.

+

Files here are pending retention expiry, recovery, or hard delete.

{{ files|length }} items @@ -52,7 +52,7 @@ Device Size Deleted At - Action + Actions @@ -73,9 +73,14 @@ {{ file.deleted_at }} -
- -
+
+
+ +
+
+ +
+
{% endfor %}