434 lines
14 KiB
Python
434 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, prepare=None) -> 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:
|
|
if prepare is not None:
|
|
source_fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=source_parent_fd)
|
|
try:
|
|
prepare(original, source_fd)
|
|
finally:
|
|
os.close(source_fd)
|
|
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())
|