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. 9
      app/templates/cloud/deleted_files.html

19
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

6
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

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")
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/<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"])
@portal_session_required
def hard_delete_file(file_id: int):

9
app/templates/cloud/deleted_files.html

@ -37,7 +37,7 @@
<div class="service-card-header">
<div>
<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>
<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);">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);">Action</th>
<th style="text-align:left;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.12);">Actions</th>
</tr>
</thead>
<tbody>
@ -73,9 +73,14 @@
{{ file.deleted_at }}
</td>
<td style="padding:12px 8px;border-bottom:1px solid rgba(255,255,255,0.08);vertical-align:top;">
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<form method="post" action="{{ url_for('main.recover_deleted_file', file_id=file.id) }}">
<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>
</tr>
{% endfor %}

Loading…
Cancel
Save