Removed random comments and replaced em-dashes with hyphens.

This commit is contained in:
sarah committed 2026-09-21 17:37:12 +02:00
1 parent 6083e00504
commit ee7e163837
24 files changed
+198 -363

No files matched your search

-5
View File
@@ -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 {
-12
View File
@@ -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)
-7
View File
@@ -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:
-7
View File
@@ -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):
-1
View File
@@ -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")
-28
View File
@@ -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():
-4
View File
@@ -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
View File
@@ -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,
-47
View File
@@ -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")
-2
View File
@@ -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("-")
-14
View File
@@ -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"
-13
View File
@@ -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)
-1
View File
@@ -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()