mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 18:38:11 +02:00
Removed random comments and replaced em-dashes with hyphens.
This commit is contained in:
1 parent
6083e00504
commit
ee7e163837
24 files changed
+198
-363
No files matched your search
@@ -10,9 +10,6 @@ from Groups import AdminGroupName, GetGroupsForUser, IsUserInGroup
|
||||
from Users import GetUser
|
||||
|
||||
|
||||
# In-memory only for now: sessions don't survive a container restart, so
|
||||
# everyone just logs in again. Fine for a handful of trusted tailnet users;
|
||||
# worth revisiting if that stops being true.
|
||||
ActiveSessions: dict[str, str] = {}
|
||||
|
||||
SessionCookieName = "SaraViewSession"
|
||||
@@ -40,8 +37,6 @@ def GetCurrentUser(request: Request) -> dict[str, Any]:
|
||||
if User is None:
|
||||
raise HTTPException(status_code=401, detail="Session user no longer exists.")
|
||||
|
||||
# Admin isn't a stored flag — it's membership in the built-in Admin
|
||||
# group, computed live, so it behaves exactly like any other group.
|
||||
UserGroups = GetGroupsForUser(Username)
|
||||
|
||||
return {
|
||||
|
||||
@@ -17,10 +17,6 @@ def GetBlameDirectory() -> Path:
|
||||
|
||||
|
||||
def GetBlamePath(RootName: str, RelativePath: str) -> Path:
|
||||
# Mirrors the file's own relative path under the Blame/ folder, keyed by
|
||||
# root so files with the same relative path in different roots don't
|
||||
# collide. Validity of RelativePath itself is checked by the caller via
|
||||
# Core.ResolvePath before this is ever used.
|
||||
return GetBlameDirectory() / RootName / f"{RelativePath}.blame.toml"
|
||||
|
||||
|
||||
@@ -45,10 +41,6 @@ def SaveBlame(RootName: str, RelativePath: str, Blame: dict[str, dict[str, Any]]
|
||||
|
||||
|
||||
def RenameUserInBlame(OldUsername: str, NewUsername: str) -> None:
|
||||
"""Rewrite every recorded blame attribution from OldUsername to
|
||||
NewUsername across every file's blame history — called when a user
|
||||
renames their account, so past attributions stay coherent.
|
||||
"""
|
||||
BlameDirectory = GetBlameDirectory()
|
||||
|
||||
if not BlameDirectory.is_dir():
|
||||
@@ -74,10 +66,6 @@ def RenameUserInBlame(OldUsername: str, NewUsername: str) -> None:
|
||||
def UpdateBlameOnSave(
|
||||
RootName: str, RelativePath: str, OldContent: str, NewContent: str, Username: str
|
||||
) -> None:
|
||||
"""Recompute line attribution after a save. Unchanged lines (matched via
|
||||
the same diff algorithm git's own blame uses) keep their prior author and
|
||||
timestamp; changed or new lines are stamped with the current user & now.
|
||||
"""
|
||||
OldLines = OldContent.splitlines()
|
||||
NewLines = NewContent.splitlines()
|
||||
PreviousBlame = LoadBlame(RootName, RelativePath)
|
||||
|
||||
@@ -15,10 +15,6 @@ CachedSaraTagCoreLoaded = False
|
||||
|
||||
|
||||
def GetSaraTagCore() -> ModuleType | None:
|
||||
"""Load SaraTag's Core.py directly by file path, under a private module
|
||||
name, so it never collides with SaraView's own Core module in sys.modules.
|
||||
Cached after the first successful load.
|
||||
"""
|
||||
global CachedSaraTagCore, CachedSaraTagCoreLoaded
|
||||
|
||||
if CachedSaraTagCoreLoaded:
|
||||
@@ -54,9 +50,6 @@ def GetSaraTagCore() -> ModuleType | None:
|
||||
|
||||
|
||||
def ReadBookForFile(FilePath: Path) -> dict[str, Any] | None:
|
||||
"""Return a file's resolved Book (tag values + Lexicon definitions), or
|
||||
None if SaraTag integration is disabled or the file has no Book.
|
||||
"""
|
||||
SaraTagCore = GetSaraTagCore()
|
||||
|
||||
if SaraTagCore is None:
|
||||
|
||||
@@ -12,7 +12,6 @@ ConfigPath = ApplicationDirectory / "SaraViewConf.toml"
|
||||
|
||||
|
||||
def LoadConfig() -> dict[str, Any]:
|
||||
"""Load SaraView's TOML configuration from disk."""
|
||||
if not ConfigPath.is_file():
|
||||
raise FileNotFoundError(f"Missing configuration file: {ConfigPath}")
|
||||
|
||||
@@ -24,7 +23,6 @@ Config = LoadConfig()
|
||||
|
||||
|
||||
def GetRoots() -> dict[str, Path]:
|
||||
"""Return the configured named roots as resolved, absolute paths."""
|
||||
RawRoots = Config.get("Roots", {})
|
||||
Roots: dict[str, Path] = {}
|
||||
|
||||
@@ -35,10 +33,6 @@ def GetRoots() -> dict[str, Path]:
|
||||
|
||||
|
||||
def ResolvePath(RootName: str, RelativePath: str) -> Path:
|
||||
"""Resolve a client-supplied relative path against a named root.
|
||||
|
||||
Raises ValueError if the root is unknown or the resolved path escapes it.
|
||||
"""
|
||||
Roots = GetRoots()
|
||||
Root = Roots.get(RootName)
|
||||
|
||||
@@ -54,7 +48,6 @@ def ResolvePath(RootName: str, RelativePath: str) -> Path:
|
||||
|
||||
|
||||
def IsInternalPath(EntryPath: Path) -> bool:
|
||||
"""Return True if an entry should be hidden as a SaraTag internal file."""
|
||||
SaraTagConfig = Config.get("SaraTag", {})
|
||||
|
||||
if not SaraTagConfig.get("HideInternalFiles", True):
|
||||
|
||||
@@ -4,5 +4,4 @@ from pathlib import Path
|
||||
|
||||
|
||||
def WriteTextFile(FilePath: Path, Content: str) -> None:
|
||||
"""Overwrite a text file's contents on disk."""
|
||||
FilePath.write_text(Content, encoding="utf-8")
|
||||
@@ -34,7 +34,6 @@ FileTypeMap = {
|
||||
|
||||
|
||||
def ClassifyFile(EntryPath: Path) -> str:
|
||||
"""Return a coarse file-type label used to pick a preview renderer."""
|
||||
return FileTypeMap.get(EntryPath.suffix.lower(), "unknown")
|
||||
|
||||
|
||||
@@ -43,14 +42,10 @@ DefaultNoteExtension = ".md"
|
||||
|
||||
|
||||
def IsNoteFile(EntryName: str) -> bool:
|
||||
"""A directory's own "file" - editable/viewable like any other file, but
|
||||
hidden from normal listings behind the ShowSvFiles setting.
|
||||
"""
|
||||
return EntryName.startswith(NoteFilePrefix)
|
||||
|
||||
|
||||
def FindDirectoryNote(DirectoryPath: Path) -> Path | None:
|
||||
"""Find a directory's note file, whatever extension it currently has."""
|
||||
Prefix = f"{NoteFilePrefix}{DirectoryPath.name}."
|
||||
|
||||
for Candidate in DirectoryPath.iterdir():
|
||||
@@ -61,9 +56,6 @@ def FindDirectoryNote(DirectoryPath: Path) -> Path | None:
|
||||
|
||||
|
||||
def EnsureDirectoryNote(DirectoryPath: Path) -> Path:
|
||||
"""Find a directory's note file, creating one (defaulting to markdown)
|
||||
if it doesn't have one yet - e.g. a directory that predates this feature.
|
||||
"""
|
||||
Existing = FindDirectoryNote(DirectoryPath)
|
||||
if Existing is not None:
|
||||
return Existing
|
||||
@@ -74,7 +66,6 @@ def EnsureDirectoryNote(DirectoryPath: Path) -> Path:
|
||||
|
||||
|
||||
def GetDirectoryNoteInfo(DirectoryPath: Path) -> dict[str, Any] | None:
|
||||
"""Read-only lookup for listings - never creates a note."""
|
||||
NotePath = FindDirectoryNote(DirectoryPath)
|
||||
|
||||
if NotePath is None:
|
||||
@@ -84,9 +75,6 @@ def GetDirectoryNoteInfo(DirectoryPath: Path) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
def RenameDirectoryNote(DirectoryPath: Path, OldDirectoryName: str) -> None:
|
||||
"""After a directory is renamed/moved, keep its note file's name (which
|
||||
embeds the directory's own name) in sync, preserving its extension.
|
||||
"""
|
||||
OldPrefix = f"{NoteFilePrefix}{OldDirectoryName}."
|
||||
|
||||
for Candidate in DirectoryPath.iterdir():
|
||||
@@ -97,12 +85,10 @@ def RenameDirectoryNote(DirectoryPath: Path, OldDirectoryName: str) -> None:
|
||||
|
||||
|
||||
def ListRoots() -> list[dict[str, Any]]:
|
||||
"""Return the configured roots as a flat list for the frontend."""
|
||||
return [{"Name": Name} for Name in GetRoots()]
|
||||
|
||||
|
||||
def CreateEntry(RootName: str, RelativePath: str, IsDirectory: bool) -> None:
|
||||
"""Create a new empty file or directory at the given path."""
|
||||
EntryPath = ResolvePath(RootName, RelativePath)
|
||||
|
||||
if EntryPath.exists():
|
||||
@@ -117,9 +103,6 @@ def CreateEntry(RootName: str, RelativePath: str, IsDirectory: bool) -> None:
|
||||
|
||||
|
||||
def MoveEntry(RootName: str, SourceRelativePath: str, DestinationRelativePath: str) -> None:
|
||||
"""Rename or move a file/directory within the same root. Used for both
|
||||
plain renames (same parent, new name) and full moves.
|
||||
"""
|
||||
SourcePath = ResolvePath(RootName, SourceRelativePath)
|
||||
DestinationPath = ResolvePath(RootName, DestinationRelativePath)
|
||||
|
||||
@@ -135,7 +118,6 @@ def MoveEntry(RootName: str, SourceRelativePath: str, DestinationRelativePath: s
|
||||
try:
|
||||
SourcePath.rename(DestinationPath)
|
||||
except OSError:
|
||||
# Cross-device or other rename failures — fall back to a copy+delete.
|
||||
shutil.move(str(SourcePath), str(DestinationPath))
|
||||
|
||||
if DestinationPath.is_dir():
|
||||
@@ -143,7 +125,6 @@ def MoveEntry(RootName: str, SourceRelativePath: str, DestinationRelativePath: s
|
||||
|
||||
|
||||
def DeleteEntry(RootName: str, RelativePath: str) -> None:
|
||||
"""Delete a file, or an entire directory tree."""
|
||||
EntryPath = ResolvePath(RootName, RelativePath)
|
||||
|
||||
if not EntryPath.exists():
|
||||
@@ -158,15 +139,6 @@ def DeleteEntry(RootName: str, RelativePath: str) -> None:
|
||||
def ListDirectory(
|
||||
RootName: str, RelativePath: str, ShowHidden: bool = False, ShowSvFiles: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List the immediate contents of a directory.
|
||||
|
||||
SaraTag's internal files are always hidden, regardless of ShowHidden.
|
||||
Note files (.sv_*) are hidden unless ShowSvFiles is True - independently
|
||||
of ShowHidden, since they're an implementation detail rather than a
|
||||
normal dotfile. Every directory's Note metadata is still attached
|
||||
regardless of ShowSvFiles, so clicking the directory can open it.
|
||||
Other dotfiles/dotfolders are hidden unless ShowHidden is True.
|
||||
"""
|
||||
DirectoryPath = ResolvePath(RootName, RelativePath)
|
||||
|
||||
if not DirectoryPath.is_dir():
|
||||
|
||||
@@ -20,9 +20,6 @@ def LoadGroups() -> dict[str, list[str]]:
|
||||
GroupsPath = GetGroupsPath()
|
||||
|
||||
if not GroupsPath.is_file():
|
||||
# First run after upgrading from the old boolean-flag admin model:
|
||||
# seed the Admin group from whoever already had IsAdmin set, so
|
||||
# nobody silently loses admin access.
|
||||
from Users import LoadUsers
|
||||
|
||||
SeededAdmins = [
|
||||
@@ -90,7 +87,6 @@ def SetGroupMembers(Name: str, Members: list[str]) -> None:
|
||||
if Name not in Groups:
|
||||
raise ValueError(f"Group '{Name}' does not exist.")
|
||||
|
||||
# De-duplicate while keeping things predictable.
|
||||
Groups[Name] = sorted(set(Members))
|
||||
SaveGroups(Groups)
|
||||
|
||||
|
||||
+1
-19
@@ -42,11 +42,8 @@ def GetLockForPath(RootName: str, RelativePath: str) -> dict[str, Any] | 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.")
|
||||
raise ValueError("Can't lock an entire root - pick a specific file or directory.")
|
||||
|
||||
if not Usernames and not GroupNames:
|
||||
RemoveLock(RootName, RelativePath)
|
||||
@@ -77,17 +74,6 @@ def RemoveLock(RootName: str, RelativePath: str) -> None:
|
||||
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
|
||||
@@ -110,10 +96,6 @@ def IsPathAccessible(
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -80,9 +80,6 @@ Application.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
# --- Auth ---------------------------------------------------------------
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
Username: str
|
||||
Password: str
|
||||
@@ -95,9 +92,6 @@ def AuthStatusEndpoint() -> dict:
|
||||
|
||||
@Application.post("/api/auth/bootstrap")
|
||||
def BootstrapEndpoint(Body: LoginRequest) -> dict:
|
||||
"""Create the first (admin) account. Only usable while no users exist yet,
|
||||
so there's no default/baked-in password sitting in the image.
|
||||
"""
|
||||
if AnyUsersExist():
|
||||
raise HTTPException(status_code=403, detail="Setup already completed.")
|
||||
|
||||
@@ -163,9 +157,6 @@ def PostUsersEndpoint(Body: CreateUserRequest, CurrentUser: dict = Depends(Requi
|
||||
return {"Created": True}
|
||||
|
||||
|
||||
# --- Profile (colour, avatar, username, password) --------------------------
|
||||
|
||||
|
||||
@Application.get("/api/profile/colours")
|
||||
def GetProfileColourOptionsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[str]:
|
||||
return UserColourOptions
|
||||
@@ -238,10 +229,6 @@ def PutProfileUsernameEndpoint(
|
||||
RenameUserInBlame(OldUsername, NewUsername)
|
||||
RenameUserInGroups(OldUsername, NewUsername)
|
||||
|
||||
# Re-authenticate this same request under the new name so the
|
||||
# person doesn't get logged out by their own rename. Any OTHER
|
||||
# active session for the old name is left to fail naturally on its
|
||||
# next request — a re-login there is the simplest correct outcome.
|
||||
OldToken = request.cookies.get(SessionCookieName)
|
||||
if OldToken:
|
||||
DeleteSession(OldToken)
|
||||
@@ -275,13 +262,8 @@ def PutProfilePasswordEndpoint(
|
||||
return {"Changed": True}
|
||||
|
||||
|
||||
# --- Groups -----------------------------------------------------------------
|
||||
|
||||
|
||||
@Application.get("/api/groups")
|
||||
def GetGroupsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]:
|
||||
# Readable by anyone — picking an existing group as a lock target is
|
||||
# open to everyone, same as picking a user.
|
||||
return ListGroups()
|
||||
|
||||
|
||||
@@ -325,13 +307,6 @@ def PutGroupMembersEndpoint(
|
||||
return {"Saved": True}
|
||||
|
||||
|
||||
# --- Locked files/directories -----------------------------------------------
|
||||
# Setting a lock is open to anyone who currently has access to the path -
|
||||
# not admin-only. If you can see it, you're trusted to decide who else can.
|
||||
# Admins do NOT bypass locks (see Locks.IsPathAccessible) - Admin is a
|
||||
# management group, not a way to view everyone's private directories.
|
||||
|
||||
|
||||
@Application.get("/api/lock")
|
||||
def GetLockEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
@@ -366,14 +341,9 @@ def DeleteLockEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurr
|
||||
|
||||
@Application.get("/api/locks")
|
||||
def GetLocksEndpoint(CurrentUser: dict = Depends(RequireAdmin)) -> list[dict]:
|
||||
# The full list (across every root/path) stays admin-only — it's a
|
||||
# global overview, not a per-item action.
|
||||
return LoadLocks()
|
||||
|
||||
|
||||
# --- Files ----------------------------------------------------------------
|
||||
|
||||
|
||||
@Application.get("/api/roots")
|
||||
def GetRootsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]:
|
||||
return ListRoots()
|
||||
@@ -396,8 +366,6 @@ def GetTreeEndpoint(
|
||||
Username = CurrentUser["Username"]
|
||||
UserGroups = CurrentUser.get("Groups", [])
|
||||
|
||||
# A locked subdirectory (or anything inside one) shouldn't even appear
|
||||
# in its parent's listing for someone without access to it.
|
||||
return [
|
||||
Entry
|
||||
for Entry in Entries
|
||||
@@ -412,9 +380,6 @@ def GetTreeEndpoint(
|
||||
|
||||
@Application.post("/api/note")
|
||||
def PostNoteEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
|
||||
"""Find (or create, for a directory that predates this feature) the
|
||||
directory's own note file, so it can be opened like any other file.
|
||||
"""
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
|
||||
try:
|
||||
@@ -625,11 +590,6 @@ def GetBlameEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurren
|
||||
def GetRawFileEndpoint(
|
||||
RootName: str, FilePath: str, CurrentUser: dict = Depends(GetCurrentUser)
|
||||
):
|
||||
"""Serves a file at a URL that mirrors its real directory structure
|
||||
(unlike /api/file's query-param form), so relative links, stylesheets,
|
||||
and scripts INSIDE a previewed HTML document resolve correctly against
|
||||
sibling files in the same directory.
|
||||
"""
|
||||
RequirePathAccessible(RootName, FilePath, CurrentUser)
|
||||
|
||||
try:
|
||||
@@ -663,9 +623,6 @@ def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrent
|
||||
return Book or {}
|
||||
|
||||
|
||||
# --- Settings / themes ------------------------------------------------------
|
||||
|
||||
|
||||
@Application.get("/api/settings")
|
||||
def GetSettingsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
|
||||
return LoadSettings(CurrentUser["Username"])
|
||||
@@ -719,10 +676,6 @@ def DeleteThemeEndpoint(Name: str, CurrentUser: dict = Depends(GetCurrentUser))
|
||||
return {"Deleted": True}
|
||||
|
||||
|
||||
# In production, the frontend's built assets are copied alongside this file
|
||||
# under Frontend/dist (see Dockerfile) and served directly by the backend.
|
||||
# This stays unauthenticated — the SPA itself needs to load before it can
|
||||
# even show a login screen.
|
||||
FrontendDist = ApplicationDirectory.parent / "Frontend" / "dist"
|
||||
if FrontendDist.is_dir():
|
||||
Application.mount("/", StaticFiles(directory=str(FrontendDist), html=True), name="Frontend")
|
||||
@@ -20,7 +20,6 @@ MimeTypeMap = {
|
||||
|
||||
|
||||
def ReadTextFile(FilePath: Path) -> str:
|
||||
"""Read a text-classified file as UTF-8."""
|
||||
return FilePath.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -33,7 +32,6 @@ def GetMimeType(FilePath: Path) -> str:
|
||||
|
||||
|
||||
def ParseRangeHeader(RangeHeader: str, FileSize: int) -> tuple[int, int]:
|
||||
"""Parse a single-range HTTP Range header into an inclusive (Start, End)."""
|
||||
RangeValue = RangeHeader.replace("bytes=", "").strip()
|
||||
StartText, _, EndText = RangeValue.partition("-")
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
# SaraView configuration.
|
||||
# Paths here are resolved as given — use absolute container paths when running
|
||||
# via Docker (see docker-compose.yml), matching whatever you mount the volumes to.
|
||||
|
||||
[SaraView]
|
||||
ConfigVersion = 1
|
||||
ApplicationVersion = "0.1.0-beta"
|
||||
@@ -10,22 +6,12 @@ ApplicationVersion = "0.1.0-beta"
|
||||
Host = "0.0.0.0"
|
||||
Port = 8420
|
||||
|
||||
# Where SaraView keeps its own data — Settings.toml and the Themes/ folder.
|
||||
# This is NOT one of the [Roots] below, so it is structurally unreachable
|
||||
# from the file browser regardless of any hidden-file setting. Give it its
|
||||
# own persistent volume (see docker-compose.yml) so it survives rebuilds.
|
||||
[Storage]
|
||||
DataDirectory = "/data/SaraViewData"
|
||||
|
||||
# Named roots the browser can navigate. Add as many as you like; each becomes
|
||||
# a top-level entry point in the frontend's root switcher.
|
||||
[Roots]
|
||||
Notes = "/data/notes"
|
||||
|
||||
# Integration with SaraTag: if Enabled, SaraView imports SaraTag's own Core.py
|
||||
# directly (by file path, not by installing it as a package) to resolve Book
|
||||
# tag files exactly the way SaraTag itself does. This is read-only — SaraView
|
||||
# never writes Book or Lexicon files.
|
||||
[SaraTag]
|
||||
Enabled = true
|
||||
CoreModulePath = "/data/SaraTag/.saratag"
|
||||
|
||||
@@ -51,7 +51,6 @@ DefaultThemesDirectory = ApplicationDirectory / "DefaultThemes"
|
||||
|
||||
|
||||
def SanitizeThemeName(ThemeName: str) -> str:
|
||||
"""Reject theme names that could escape the Themes directory."""
|
||||
if not ThemeName or "/" in ThemeName or "\\" in ThemeName or ThemeName in (".", ".."):
|
||||
raise ValueError(f"Invalid theme name: {ThemeName}")
|
||||
|
||||
@@ -59,9 +58,6 @@ def SanitizeThemeName(ThemeName: str) -> str:
|
||||
|
||||
|
||||
def SanitizeUsernameForPath(Username: str) -> str:
|
||||
"""Usernames are validated at account-creation time, but this is a second
|
||||
line of defense against a settings path ever escaping its directory.
|
||||
"""
|
||||
if not Username or not re.fullmatch(r"[A-Za-z0-9_.-]+", Username):
|
||||
raise ValueError(f"Invalid username: {Username}")
|
||||
|
||||
@@ -88,7 +84,6 @@ def GetThemesDirectory() -> Path:
|
||||
|
||||
|
||||
def EnsureDataDirectory() -> None:
|
||||
"""Create the data directory and seed default themes, if missing."""
|
||||
DataDirectory = GetDataDirectory()
|
||||
ThemesDirectory = GetThemesDirectory()
|
||||
DataDirectory.mkdir(parents=True, exist_ok=True)
|
||||
@@ -103,10 +98,6 @@ def EnsureDataDirectory() -> None:
|
||||
|
||||
|
||||
def LoadSettings(Username: str) -> dict[str, Any]:
|
||||
"""Load one user's settings, seeding their file with the defaults on
|
||||
first use. Settings are per-user; there is no shared/global settings
|
||||
file anymore.
|
||||
"""
|
||||
EnsureDataDirectory()
|
||||
SettingsPath = GetSettingsPath(Username)
|
||||
|
||||
@@ -176,10 +167,6 @@ def LoadTheme(ThemeName: str) -> dict[str, str]:
|
||||
|
||||
|
||||
def SaveTheme(ThemeName: str, Palette: dict[str, str]) -> None:
|
||||
"""Create or overwrite a custom theme. Missing roles fall back to the
|
||||
active Catppuccin Mocha default so a partially-specified custom theme
|
||||
still renders sensibly.
|
||||
"""
|
||||
EnsureDataDirectory()
|
||||
ThemeName = SanitizeThemeName(ThemeName)
|
||||
|
||||
|
||||
@@ -193,7 +193,6 @@ def SaveAvatar(Username: str, FileBytes: bytes, Extension: str) -> None:
|
||||
Directory = GetAvatarDirectory()
|
||||
Directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Remove any previous avatar for this user under a different extension.
|
||||
for ExistingPath in Directory.glob(f"{Username}.*"):
|
||||
ExistingPath.unlink()
|
||||
|
||||
|
||||
Reference in new issue
Block a user