Browse Source

Add deleted file recovery with -recovered naming

master
Don Kingdon 3 weeks ago
parent
commit
9e4f456691
  1. 19
      PROJECT_STATE.md
  2. 6
      README.md
  3. 2
      VERSION
  4. 106
      app/main/routes.py
  5. 15
      app/templates/cloud/deleted_files.html

19
PROJECT_STATE.md

@ -4,7 +4,7 @@
OTB Cloud OTB Cloud
## Current version ## Current version
v0.2.2 v0.2.3
## Build date ## Build date
2026-04-12 2026-04-12
@ -25,8 +25,9 @@ Portal-authenticated secure backup and storage platform for customer files, incl
- Device add/remove - Device add/remove
- Browser upload to device originals - Browser upload to device originals
- Device file browser - Device file browser
- Checkbox selection actions - Selection actions
- Soft-delete to deleted folder - Soft-delete to deleted folder
- Recover from deleted folder
- Zip workspace staging and zip export - Zip workspace staging and zip export
- Deleted files page with hard delete - Deleted files page with hard delete
- Exports page - Exports page
@ -34,14 +35,16 @@ Portal-authenticated secure backup and storage platform for customer files, incl
## Retention and safety notes ## Retention and safety notes
- Original files are stored as immutable originals - Original files are stored as immutable originals
- Deleted files are retained in the deleted area for up to 24 hours - 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 - 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 - Zip staging copies are temporary working copies
- Successful zip creation clears staged copies but does not affect original source files - Successful zip creation clears staged copies but does not affect original source files
## Immediate next tasks ## Immediate next tasks
1. Add single-file download buttons in more places 1. Add basename-only rename flow
2. Add basename-only rename flow 2. Add searchable file listing
3. Add searchable file listing 3. Add bulk folder upload
4. Add bulk folder upload 4. Add media processing jobs
5. Add media processing jobs 5. Add derived/original filtering
6. Add derived/original filtering 6. Add better single-file actions in browser

6
README.md

@ -1,5 +1,11 @@
# OTB Cloud # 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 ## v0.2.2 - 2026-04-12
- Added checkbox selection to device file browser - Added checkbox selection to device file browser
- Added soft-delete selected files workflow - Added soft-delete selected files workflow

2
VERSION

@ -1 +1 @@
v0.2.2 v0.2.3

106
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") ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
return f"{ts}__{safe}" 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: def _safe_path_from_relative(relative_path: str) -> Path:
return _tenant_root() / relative_path return _tenant_root() / relative_path
@ -695,6 +705,102 @@ def deleted_files():
files=files, files=files,
) )
@bp.route("/deleted/<int:file_id>/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/<int:file_id>/hard-delete", methods=["POST"]) @bp.route("/deleted/<int:file_id>/hard-delete", methods=["POST"])
@portal_session_required @portal_session_required
def hard_delete_file(file_id: int): def hard_delete_file(file_id: int):

15
app/templates/cloud/deleted_files.html

@ -37,7 +37,7 @@
<div class="service-card-header"> <div class="service-card-header">
<div> <div>
<h2>Deleted Files</h2> <h2>Deleted Files</h2>
<p>Files here are pending retention expiry or hard delete.</p> <p>Files here are pending retention expiry, recovery, or hard delete.</p>
</div> </div>
<div> <div>
<span class="service-badge service-badge-beta">{{ files|length }} items</span> <span class="service-badge service-badge-beta">{{ files|length }} items</span>
@ -52,7 +52,7 @@
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Device</th> <th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Device</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th> <th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Size</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Deleted At</th> <th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Deleted At</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Action</th> <th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -73,9 +73,14 @@
{{ file.deleted_at }} {{ file.deleted_at }}
</td> </td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;"> <td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<form method="post" action="{{ url_for('main.hard_delete_file', file_id=file.id) }}"> <div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="portal-btn" type="submit" onclick="return confirm('Permanently delete {{ file.original_filename|e }} now? This cannot be undone.');">Hard Delete</button> <form method="post" action="{{ url_for('main.recover_deleted_file', file_id=file.id) }}">
</form> <button class="portal-btn primary" type="submit" onclick="return confirm('Recover {{ file.original_filename|e }}? It will return to originals with -recovered appended to the filename.');">Recover</button>
</form>
<form method="post" action="{{ url_for('main.hard_delete_file', file_id=file.id) }}">
<button class="portal-btn" type="submit" onclick="return confirm('Permanently delete {{ file.original_filename|e }} now? This cannot be undone.');">Hard Delete</button>
</form>
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

Loading…
Cancel
Save