from __future__ import annotations import tomllib from typing import Any import tomli_w from fastapi import HTTPException from Settings import GetDataDirectory def GetLocksPath(): return GetDataDirectory() / "LockedDirectories.toml" def LoadLocks() -> list[dict[str, Any]]: LocksPath = GetLocksPath() if not LocksPath.is_file(): return [] with LocksPath.open("rb") as LocksFile: Loaded = tomllib.load(LocksFile) return Loaded.get("Locks", []) def SaveLocks(Locks: list[dict[str, Any]]) -> None: GetDataDirectory().mkdir(parents=True, exist_ok=True) with GetLocksPath().open("wb") as LocksFile: tomli_w.dump({"Locks": Locks}, LocksFile) def GetLockForPath(RootName: str, RelativePath: str) -> dict[str, Any] | None: for Lock in LoadLocks(): if Lock.get("Root") == RootName and Lock.get("Path") == RelativePath: return Lock return None def SetLock(RootName: str, RelativePath: str, Usernames: list[str], GroupNames: list[str]) -> None: """Restrict a file or directory to specific users and/or groups. Passing both empty lists is the same as removing the lock entirely. """ if not RelativePath: raise ValueError("Can't lock an entire root — pick a specific file or directory.") if not Usernames and not GroupNames: RemoveLock(RootName, RelativePath) return Locks = LoadLocks() for Lock in Locks: if Lock.get("Root") == RootName and Lock.get("Path") == RelativePath: Lock["Users"] = Usernames Lock["Groups"] = GroupNames SaveLocks(Locks) return Locks.append({"Root": RootName, "Path": RelativePath, "Users": Usernames, "Groups": GroupNames}) SaveLocks(Locks) def RemoveLock(RootName: str, RelativePath: str) -> None: Locks = [ Lock for Lock in LoadLocks() if not (Lock.get("Root") == RootName and Lock.get("Path") == RelativePath) ] SaveLocks(Locks) def IsPathAccessible( RootName: str, RelativePath: str, Username: str, UserGroups: list[str] ) -> bool: """A path is inaccessible if it IS a locked file/directory, or lies anywhere underneath a locked directory, and the user isn't permitted (by username or group) on every applicable lock. Locks don't propagate to subdirectories as separate records — there's only ever one lock entry per locked directory — but access to anything inside still requires being permitted on that ancestor lock, so you always have to "enter the parent first." Admins do NOT bypass locks - Admin is a management group (create users, manage groups, and so on), not a way to view everyone's private directories. Only direct server/filesystem access can see everything. """ for Lock in LoadLocks(): if Lock.get("Root") != RootName: continue LockPath = Lock.get("Path", "") if not LockPath: continue if RelativePath == LockPath or RelativePath.startswith(f"{LockPath}/"): PermittedUsers = Lock.get("Users", []) PermittedGroups = Lock.get("Groups", []) IsPermittedByUser = Username in PermittedUsers IsPermittedByGroup = any(Group in PermittedGroups for Group in UserGroups) if not IsPermittedByUser and not IsPermittedByGroup: return False return True def RequirePathAccessible(RootName: str, RelativePath: str, CurrentUser: dict[str, Any]) -> None: """Raises a 404 (never a distinguishing "access denied") for a path the current user isn't permitted to see, so a locked directory's existence isn't revealed to anyone not on its list. """ if not IsPathAccessible( RootName, RelativePath, CurrentUser["Username"], CurrentUser.get("Groups", []), ): raise HTTPException(status_code=404, detail="Not found.")