Files
ad-ds-simple-file-server/app/trash.py
T

428 lines
14 KiB
Python

#!/usr/bin/env python3
"""Safe listing, expiry, download, and restoration for Samba recycle bins."""
import argparse
import base64
import datetime as dt
import os
import re
import stat
from typing import BinaryIO, Dict, List, Optional, Tuple
TRASH_DIRECTORY = ".trash"
DEFAULT_RETENTION_DAYS = 7
VERSION_PREFIX_RE = re.compile(r"^Copy #\d+ of ", re.IGNORECASE)
def excluded_filename(filename: str) -> bool:
"""Return whether a transient file must never be retained."""
unversioned = VERSION_PREFIX_RE.sub("", filename, count=1)
folded = unversioned.casefold()
return folded.endswith(".tmp") or folded.startswith("~$")
def retention_days() -> int:
try:
value = int(os.getenv("TRASH_RETENTION_DAYS", str(DEFAULT_RETENTION_DAYS)))
except ValueError:
value = DEFAULT_RETENTION_DAYS
return max(1, min(365, value))
def share_roots() -> Dict[str, str]:
return {
"Data": os.path.abspath(os.getenv("GROUP_ROOT", "/data/groups/data")),
"Private": os.path.abspath(os.getenv("PRIVATE_ROOT", "/data/private")),
"FSLogix": os.path.abspath(os.getenv("FSLOGIX_ROOT", "/data/fslogix")),
}
def trash_root(share_root: str) -> str:
return os.path.join(share_root, TRASH_DIRECTORY)
def ensure_trash_roots() -> None:
"""Create non-listable sticky repositories users can write through Samba."""
for root in share_roots().values():
os.makedirs(root, exist_ok=True)
repository = trash_root(root)
os.makedirs(repository, exist_ok=True)
try:
os.chown(repository, 0, 0)
except PermissionError:
if os.geteuid() == 0:
raise
os.chmod(repository, 0o1733)
def _share_name(value: str) -> str:
for name in share_roots():
if name.casefold() == value.casefold():
return name
raise ValueError("Unbekannte Freigabe")
def _relative_parts(value: str) -> List[str]:
if not value or value.startswith(("/", "\\")) or "\0" in value:
raise ValueError("Ungültiger Papierkorbpfad")
parts = value.split("/")
if any(part in {"", ".", ".."} for part in parts):
raise ValueError("Ungültiger Papierkorbpfad")
return parts
def encode_item_id(share: str, relative_path: str) -> str:
canonical_share = _share_name(share)
_relative_parts(relative_path)
payload = f"{canonical_share}\0{relative_path}".encode("utf-8")
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
def decode_item_id(item_id: str) -> Tuple[str, str]:
if not item_id or len(item_id) > 8192:
raise ValueError("Ungültige Papierkorb-ID")
try:
padding = "=" * (-len(item_id) % 4)
payload = base64.b64decode(
item_id + padding,
altchars=b"-_",
validate=True,
).decode("utf-8")
share, relative_path = payload.split("\0", 1)
except (ValueError, UnicodeDecodeError) as exc:
raise ValueError("Ungültige Papierkorb-ID") from exc
canonical_share = _share_name(share)
_relative_parts(relative_path)
return canonical_share, relative_path
def _original_relative(relative_path: str) -> str:
parts = _relative_parts(relative_path)
if len(parts) < 2:
raise ValueError("Papierkorbeintrag enthält keinen Originalpfad")
original = parts[1:]
original[-1] = VERSION_PREFIX_RE.sub("", original[-1], count=1)
if not original[-1]:
raise ValueError("Papierkorbeintrag enthält keinen Dateinamen")
return "/".join(original)
def _open_directory_chain(
root: str,
parts: List[str],
*,
create: bool = False,
uid: int = 0,
gid: int = 0,
mode: int = 0o700,
) -> int:
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
current_fd = os.open(root, flags)
try:
for part in parts:
try:
next_fd = os.open(part, flags, dir_fd=current_fd)
except FileNotFoundError:
if not create:
raise
os.mkdir(part, mode=mode, dir_fd=current_fd)
os.chown(
part,
uid,
gid,
dir_fd=current_fd,
follow_symlinks=False,
)
os.chmod(
part,
mode,
dir_fd=current_fd,
follow_symlinks=False,
)
next_fd = os.open(part, flags, dir_fd=current_fd)
os.close(current_fd)
current_fd = next_fd
return current_fd
except Exception:
os.close(current_fd)
raise
def _resolved_item(item_id: str) -> Tuple[str, str, str, List[str], os.stat_result]:
share, relative_path = decode_item_id(item_id)
share_root = share_roots()[share]
repository = trash_root(share_root)
parts = _relative_parts(relative_path)
if excluded_filename(parts[-1]):
raise FileNotFoundError("Papierkorbeintrag ist ausgeschlossen")
parent_fd = _open_directory_chain(repository, parts[:-1])
try:
info = os.stat(parts[-1], dir_fd=parent_fd, follow_symlinks=False)
finally:
os.close(parent_fd)
if not stat.S_ISREG(info.st_mode):
raise ValueError("Nur reguläre Dateien können verarbeitet werden")
cutoff = dt.datetime.now(dt.timezone.utc).timestamp() - retention_days() * 86400
if info.st_mtime < cutoff:
raise FileNotFoundError("Papierkorbeintrag ist abgelaufen")
return share, share_root, relative_path, parts, info
def _iso_timestamp(seconds: float) -> str:
return dt.datetime.fromtimestamp(seconds, dt.timezone.utc).isoformat(
timespec="seconds"
)
def _item_payload(
share: str,
relative_path: str,
info: os.stat_result,
days: int,
) -> Dict[str, object]:
original = _original_relative(relative_path)
deleted_at = float(info.st_mtime)
return {
"id": encode_item_id(share, relative_path),
"share": share,
"path": original,
"name": original.rsplit("/", 1)[-1],
"deletedBy": relative_path.split("/", 1)[0],
"deletedAt": _iso_timestamp(deleted_at),
"expiresAt": _iso_timestamp(deleted_at + days * 86400),
"size": int(info.st_size),
}
def list_items(
*,
share: str = "",
path: str = "",
limit: int = 200,
now: Optional[dt.datetime] = None,
) -> Dict[str, object]:
days = retention_days()
current = now or dt.datetime.now(dt.timezone.utc)
cutoff = current.timestamp() - days * 86400
selected_share = _share_name(share) if share else ""
path_filter = path.strip().casefold()
items: List[Dict[str, object]] = []
for share_name, root in share_roots().items():
if selected_share and share_name != selected_share:
continue
repository = trash_root(root)
try:
walker = os.walk(repository, topdown=True, followlinks=False)
for directory, subdirectories, filenames in walker:
safe_subdirectories = []
for name in subdirectories:
candidate = os.path.join(directory, name)
try:
if not stat.S_ISLNK(os.lstat(candidate).st_mode):
safe_subdirectories.append(name)
except OSError:
continue
subdirectories[:] = safe_subdirectories
for filename in filenames:
if excluded_filename(filename):
continue
candidate = os.path.join(directory, filename)
try:
info = os.lstat(candidate)
except OSError:
continue
if not stat.S_ISREG(info.st_mode) or info.st_mtime < cutoff:
continue
relative_path = os.path.relpath(candidate, repository).replace(
os.sep, "/"
)
try:
payload = _item_payload(
share_name, relative_path, info, days
)
except ValueError:
continue
if path_filter and path_filter not in str(payload["path"]).casefold():
continue
items.append(payload)
except OSError:
continue
items.sort(
key=lambda item: (str(item["deletedAt"]), str(item["id"])),
reverse=True,
)
maximum = max(1, min(500, int(limit)))
return {
"items": items[:maximum],
"matched": len(items),
"truncated": len(items) > maximum,
"retentionDays": days,
"scannedAt": current.isoformat(timespec="seconds"),
}
def open_download(item_id: str) -> Tuple[BinaryIO, Dict[str, object]]:
share, _share_root, relative_path, parts, _info = _resolved_item(item_id)
repository = trash_root(share_roots()[share])
parent_fd = _open_directory_chain(repository, parts[:-1])
try:
file_fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent_fd)
finally:
os.close(parent_fd)
try:
info = os.fstat(file_fd)
if not stat.S_ISREG(info.st_mode):
raise ValueError("Nur reguläre Dateien können heruntergeladen werden")
payload = _item_payload(share, relative_path, info, retention_days())
return os.fdopen(file_fd, "rb"), payload
except Exception:
os.close(file_fd)
raise
def _restore_directory_mode(share: str) -> int:
if share == "Data":
return 0o2770
return 0o700
def _remove_empty_trash_parents(repository: str, relative_path: str) -> None:
current = os.path.dirname(os.path.join(repository, relative_path))
repository = os.path.abspath(repository)
while (
os.path.commonpath((repository, current)) == repository
and current != repository
):
try:
os.rmdir(current)
except OSError:
break
current = os.path.dirname(current)
def restore_item(item_id: str) -> Dict[str, object]:
share, share_root, relative_path, parts, info = _resolved_item(item_id)
original = _original_relative(relative_path)
original_parts = _relative_parts(original)
if original_parts[0] == TRASH_DIRECTORY:
raise ValueError("Ungültiger Wiederherstellungspfad")
source_parent_fd = _open_directory_chain(
trash_root(share_root), parts[:-1]
)
destination_parent_fd = _open_directory_chain(
share_root,
original_parts[:-1],
create=True,
uid=info.st_uid,
gid=info.st_gid,
mode=_restore_directory_mode(share),
)
linked = False
try:
os.link(
parts[-1],
original_parts[-1],
src_dir_fd=source_parent_fd,
dst_dir_fd=destination_parent_fd,
follow_symlinks=False,
)
linked = True
linked_info = os.stat(
original_parts[-1],
dir_fd=destination_parent_fd,
follow_symlinks=False,
)
if (
not stat.S_ISREG(linked_info.st_mode)
or linked_info.st_dev != info.st_dev
or linked_info.st_ino != info.st_ino
):
os.unlink(original_parts[-1], dir_fd=destination_parent_fd)
linked = False
raise RuntimeError("Papierkorbeintrag wurde währenddessen verändert")
try:
os.unlink(parts[-1], dir_fd=source_parent_fd)
except Exception:
os.unlink(original_parts[-1], dir_fd=destination_parent_fd)
linked = False
raise
finally:
os.close(source_parent_fd)
os.close(destination_parent_fd)
if not linked:
raise RuntimeError("Wiederherstellung konnte nicht abgeschlossen werden")
_remove_empty_trash_parents(trash_root(share_root), relative_path)
return {
"restored": True,
"share": share,
"path": original,
}
def cleanup_expired(now: Optional[dt.datetime] = None) -> Dict[str, int]:
days = retention_days()
current = now or dt.datetime.now(dt.timezone.utc)
cutoff = current.timestamp() - days * 86400
removed = 0
removed_bytes = 0
ensure_trash_roots()
for root in share_roots().values():
repository = trash_root(root)
for directory, subdirectories, filenames in os.walk(
repository, topdown=False, followlinks=False
):
for filename in filenames:
candidate = os.path.join(directory, filename)
try:
info = os.lstat(candidate)
if info.st_mtime >= cutoff and not excluded_filename(filename):
continue
if not (
stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode)
):
continue
os.unlink(candidate)
removed += 1
if stat.S_ISREG(info.st_mode):
removed_bytes += int(info.st_size)
except OSError:
continue
for name in subdirectories:
candidate = os.path.join(directory, name)
try:
info = os.lstat(candidate)
if stat.S_ISLNK(info.st_mode):
if info.st_mtime < cutoff:
os.unlink(candidate)
removed += 1
continue
os.rmdir(candidate)
except OSError:
continue
return {"removed": removed, "removedBytes": removed_bytes}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--cleanup", action="store_true")
args = parser.parse_args()
if not args.cleanup:
parser.error("--cleanup is required")
result = cleanup_expired()
print(
f"[trash] Removed {result['removed']} expired or excluded item(s) "
f"({result['removedBytes']} bytes)",
flush=True,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())