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

-17
View File
@@ -1,19 +1,5 @@
name: Build and publish Docker image name: Build and publish Docker image
# Builds SaraView's image (Dockerfile at the repo root) and pushes it to
# GitHub Container Registry (ghcr.io) so it can be pulled directly on
# ZimaOS instead of building locally every time.
#
# Runs on every push to main (tagged "latest" + the short commit SHA) and
# on version tags like v1.0.0 (tagged with that version). Also runnable
# by hand from the Actions tab ("workflow_dispatch").
#
# One manual, one-time step after this first runs: GHCR packages built via
# a workflow's built-in token default to PRIVATE, even on a public repo.
# Go to your GitHub profile -> Packages -> saraview -> Package settings ->
# Change visibility -> Public, or ZimaOS won't be able to pull it without
# a login.
on: on:
push: push:
branches: ["main"] branches: ["main"]
@@ -32,9 +18,6 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Compute lowercase image name - name: Compute lowercase image name
# GHCR requires an all-lowercase image path even though GitHub
# usernames/repos themselves are case-insensitive (e.g. SRHTNIN
# becomes srhtnin here).
run: echo "IMAGE=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" run: echo "IMAGE=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
-5
View File
@@ -10,9 +10,6 @@ from Groups import AdminGroupName, GetGroupsForUser, IsUserInGroup
from Users import GetUser 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] = {} ActiveSessions: dict[str, str] = {}
SessionCookieName = "SaraViewSession" SessionCookieName = "SaraViewSession"
@@ -40,8 +37,6 @@ def GetCurrentUser(request: Request) -> dict[str, Any]:
if User is None: if User is None:
raise HTTPException(status_code=401, detail="Session user no longer exists.") 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) UserGroups = GetGroupsForUser(Username)
return { return {
-12
View File
@@ -17,10 +17,6 @@ def GetBlameDirectory() -> Path:
def GetBlamePath(RootName: str, RelativePath: str) -> 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" 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: 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() BlameDirectory = GetBlameDirectory()
if not BlameDirectory.is_dir(): if not BlameDirectory.is_dir():
@@ -74,10 +66,6 @@ def RenameUserInBlame(OldUsername: str, NewUsername: str) -> None:
def UpdateBlameOnSave( def UpdateBlameOnSave(
RootName: str, RelativePath: str, OldContent: str, NewContent: str, Username: str RootName: str, RelativePath: str, OldContent: str, NewContent: str, Username: str
) -> None: ) -> 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() OldLines = OldContent.splitlines()
NewLines = NewContent.splitlines() NewLines = NewContent.splitlines()
PreviousBlame = LoadBlame(RootName, RelativePath) PreviousBlame = LoadBlame(RootName, RelativePath)
-7
View File
@@ -15,10 +15,6 @@ CachedSaraTagCoreLoaded = False
def GetSaraTagCore() -> ModuleType | None: 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 global CachedSaraTagCore, CachedSaraTagCoreLoaded
if CachedSaraTagCoreLoaded: if CachedSaraTagCoreLoaded:
@@ -54,9 +50,6 @@ def GetSaraTagCore() -> ModuleType | None:
def ReadBookForFile(FilePath: Path) -> dict[str, Any] | 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() SaraTagCore = GetSaraTagCore()
if SaraTagCore is None: if SaraTagCore is None:
-7
View File
@@ -12,7 +12,6 @@ ConfigPath = ApplicationDirectory / "SaraViewConf.toml"
def LoadConfig() -> dict[str, Any]: def LoadConfig() -> dict[str, Any]:
"""Load SaraView's TOML configuration from disk."""
if not ConfigPath.is_file(): if not ConfigPath.is_file():
raise FileNotFoundError(f"Missing configuration file: {ConfigPath}") raise FileNotFoundError(f"Missing configuration file: {ConfigPath}")
@@ -24,7 +23,6 @@ Config = LoadConfig()
def GetRoots() -> dict[str, Path]: def GetRoots() -> dict[str, Path]:
"""Return the configured named roots as resolved, absolute paths."""
RawRoots = Config.get("Roots", {}) RawRoots = Config.get("Roots", {})
Roots: dict[str, Path] = {} Roots: dict[str, Path] = {}
@@ -35,10 +33,6 @@ def GetRoots() -> dict[str, Path]:
def ResolvePath(RootName: str, RelativePath: 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() Roots = GetRoots()
Root = Roots.get(RootName) Root = Roots.get(RootName)
@@ -54,7 +48,6 @@ def ResolvePath(RootName: str, RelativePath: str) -> Path:
def IsInternalPath(EntryPath: Path) -> bool: def IsInternalPath(EntryPath: Path) -> bool:
"""Return True if an entry should be hidden as a SaraTag internal file."""
SaraTagConfig = Config.get("SaraTag", {}) SaraTagConfig = Config.get("SaraTag", {})
if not SaraTagConfig.get("HideInternalFiles", True): if not SaraTagConfig.get("HideInternalFiles", True):
-1
View File
@@ -4,5 +4,4 @@ from pathlib import Path
def WriteTextFile(FilePath: Path, Content: str) -> None: def WriteTextFile(FilePath: Path, Content: str) -> None:
"""Overwrite a text file's contents on disk."""
FilePath.write_text(Content, encoding="utf-8") FilePath.write_text(Content, encoding="utf-8")
-28
View File
@@ -34,7 +34,6 @@ FileTypeMap = {
def ClassifyFile(EntryPath: Path) -> str: def ClassifyFile(EntryPath: Path) -> str:
"""Return a coarse file-type label used to pick a preview renderer."""
return FileTypeMap.get(EntryPath.suffix.lower(), "unknown") return FileTypeMap.get(EntryPath.suffix.lower(), "unknown")
@@ -43,14 +42,10 @@ DefaultNoteExtension = ".md"
def IsNoteFile(EntryName: str) -> bool: 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) return EntryName.startswith(NoteFilePrefix)
def FindDirectoryNote(DirectoryPath: Path) -> Path | None: def FindDirectoryNote(DirectoryPath: Path) -> Path | None:
"""Find a directory's note file, whatever extension it currently has."""
Prefix = f"{NoteFilePrefix}{DirectoryPath.name}." Prefix = f"{NoteFilePrefix}{DirectoryPath.name}."
for Candidate in DirectoryPath.iterdir(): for Candidate in DirectoryPath.iterdir():
@@ -61,9 +56,6 @@ def FindDirectoryNote(DirectoryPath: Path) -> Path | None:
def EnsureDirectoryNote(DirectoryPath: Path) -> Path: 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) Existing = FindDirectoryNote(DirectoryPath)
if Existing is not None: if Existing is not None:
return Existing return Existing
@@ -74,7 +66,6 @@ def EnsureDirectoryNote(DirectoryPath: Path) -> Path:
def GetDirectoryNoteInfo(DirectoryPath: Path) -> dict[str, Any] | None: def GetDirectoryNoteInfo(DirectoryPath: Path) -> dict[str, Any] | None:
"""Read-only lookup for listings - never creates a note."""
NotePath = FindDirectoryNote(DirectoryPath) NotePath = FindDirectoryNote(DirectoryPath)
if NotePath is None: if NotePath is None:
@@ -84,9 +75,6 @@ def GetDirectoryNoteInfo(DirectoryPath: Path) -> dict[str, Any] | None:
def RenameDirectoryNote(DirectoryPath: Path, OldDirectoryName: str) -> 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}." OldPrefix = f"{NoteFilePrefix}{OldDirectoryName}."
for Candidate in DirectoryPath.iterdir(): for Candidate in DirectoryPath.iterdir():
@@ -97,12 +85,10 @@ def RenameDirectoryNote(DirectoryPath: Path, OldDirectoryName: str) -> None:
def ListRoots() -> list[dict[str, Any]]: def ListRoots() -> list[dict[str, Any]]:
"""Return the configured roots as a flat list for the frontend."""
return [{"Name": Name} for Name in GetRoots()] return [{"Name": Name} for Name in GetRoots()]
def CreateEntry(RootName: str, RelativePath: str, IsDirectory: bool) -> None: def CreateEntry(RootName: str, RelativePath: str, IsDirectory: bool) -> None:
"""Create a new empty file or directory at the given path."""
EntryPath = ResolvePath(RootName, RelativePath) EntryPath = ResolvePath(RootName, RelativePath)
if EntryPath.exists(): 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: 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) SourcePath = ResolvePath(RootName, SourceRelativePath)
DestinationPath = ResolvePath(RootName, DestinationRelativePath) DestinationPath = ResolvePath(RootName, DestinationRelativePath)
@@ -135,7 +118,6 @@ def MoveEntry(RootName: str, SourceRelativePath: str, DestinationRelativePath: s
try: try:
SourcePath.rename(DestinationPath) SourcePath.rename(DestinationPath)
except OSError: except OSError:
# Cross-device or other rename failures — fall back to a copy+delete.
shutil.move(str(SourcePath), str(DestinationPath)) shutil.move(str(SourcePath), str(DestinationPath))
if DestinationPath.is_dir(): if DestinationPath.is_dir():
@@ -143,7 +125,6 @@ def MoveEntry(RootName: str, SourceRelativePath: str, DestinationRelativePath: s
def DeleteEntry(RootName: str, RelativePath: str) -> None: def DeleteEntry(RootName: str, RelativePath: str) -> None:
"""Delete a file, or an entire directory tree."""
EntryPath = ResolvePath(RootName, RelativePath) EntryPath = ResolvePath(RootName, RelativePath)
if not EntryPath.exists(): if not EntryPath.exists():
@@ -158,15 +139,6 @@ def DeleteEntry(RootName: str, RelativePath: str) -> None:
def ListDirectory( def ListDirectory(
RootName: str, RelativePath: str, ShowHidden: bool = False, ShowSvFiles: bool = False RootName: str, RelativePath: str, ShowHidden: bool = False, ShowSvFiles: bool = False
) -> list[dict[str, Any]]: ) -> 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) DirectoryPath = ResolvePath(RootName, RelativePath)
if not DirectoryPath.is_dir(): if not DirectoryPath.is_dir():
-4
View File
@@ -20,9 +20,6 @@ def LoadGroups() -> dict[str, list[str]]:
GroupsPath = GetGroupsPath() GroupsPath = GetGroupsPath()
if not GroupsPath.is_file(): 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 from Users import LoadUsers
SeededAdmins = [ SeededAdmins = [
@@ -90,7 +87,6 @@ def SetGroupMembers(Name: str, Members: list[str]) -> None:
if Name not in Groups: if Name not in Groups:
raise ValueError(f"Group '{Name}' does not exist.") raise ValueError(f"Group '{Name}' does not exist.")
# De-duplicate while keeping things predictable.
Groups[Name] = sorted(set(Members)) Groups[Name] = sorted(set(Members))
SaveGroups(Groups) 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: 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: 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: if not Usernames and not GroupNames:
RemoveLock(RootName, RelativePath) RemoveLock(RootName, RelativePath)
@@ -77,17 +74,6 @@ def RemoveLock(RootName: str, RelativePath: str) -> None:
def IsPathAccessible( def IsPathAccessible(
RootName: str, RelativePath: str, Username: str, UserGroups: list[str] RootName: str, RelativePath: str, Username: str, UserGroups: list[str]
) -> bool: ) -> 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(): for Lock in LoadLocks():
if Lock.get("Root") != RootName: if Lock.get("Root") != RootName:
continue continue
@@ -110,10 +96,6 @@ def IsPathAccessible(
def RequirePathAccessible(RootName: str, RelativePath: str, CurrentUser: dict[str, Any]) -> None: 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( if not IsPathAccessible(
RootName, RootName,
RelativePath, RelativePath,
-47
View File
@@ -80,9 +80,6 @@ Application.add_middleware(
) )
# --- Auth ---------------------------------------------------------------
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
Username: str Username: str
Password: str Password: str
@@ -95,9 +92,6 @@ def AuthStatusEndpoint() -> dict:
@Application.post("/api/auth/bootstrap") @Application.post("/api/auth/bootstrap")
def BootstrapEndpoint(Body: LoginRequest) -> dict: 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(): if AnyUsersExist():
raise HTTPException(status_code=403, detail="Setup already completed.") raise HTTPException(status_code=403, detail="Setup already completed.")
@@ -163,9 +157,6 @@ def PostUsersEndpoint(Body: CreateUserRequest, CurrentUser: dict = Depends(Requi
return {"Created": True} return {"Created": True}
# --- Profile (colour, avatar, username, password) --------------------------
@Application.get("/api/profile/colours") @Application.get("/api/profile/colours")
def GetProfileColourOptionsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[str]: def GetProfileColourOptionsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[str]:
return UserColourOptions return UserColourOptions
@@ -238,10 +229,6 @@ def PutProfileUsernameEndpoint(
RenameUserInBlame(OldUsername, NewUsername) RenameUserInBlame(OldUsername, NewUsername)
RenameUserInGroups(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) OldToken = request.cookies.get(SessionCookieName)
if OldToken: if OldToken:
DeleteSession(OldToken) DeleteSession(OldToken)
@@ -275,13 +262,8 @@ def PutProfilePasswordEndpoint(
return {"Changed": True} return {"Changed": True}
# --- Groups -----------------------------------------------------------------
@Application.get("/api/groups") @Application.get("/api/groups")
def GetGroupsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]: 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() return ListGroups()
@@ -325,13 +307,6 @@ def PutGroupMembersEndpoint(
return {"Saved": True} 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") @Application.get("/api/lock")
def GetLockEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: def GetLockEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
RequirePathAccessible(Root, Path, CurrentUser) RequirePathAccessible(Root, Path, CurrentUser)
@@ -366,14 +341,9 @@ def DeleteLockEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurr
@Application.get("/api/locks") @Application.get("/api/locks")
def GetLocksEndpoint(CurrentUser: dict = Depends(RequireAdmin)) -> list[dict]: 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() return LoadLocks()
# --- Files ----------------------------------------------------------------
@Application.get("/api/roots") @Application.get("/api/roots")
def GetRootsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]: def GetRootsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]:
return ListRoots() return ListRoots()
@@ -396,8 +366,6 @@ def GetTreeEndpoint(
Username = CurrentUser["Username"] Username = CurrentUser["Username"]
UserGroups = CurrentUser.get("Groups", []) 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 [ return [
Entry Entry
for Entry in Entries for Entry in Entries
@@ -412,9 +380,6 @@ def GetTreeEndpoint(
@Application.post("/api/note") @Application.post("/api/note")
def PostNoteEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: 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) RequirePathAccessible(Root, Path, CurrentUser)
try: try:
@@ -625,11 +590,6 @@ def GetBlameEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurren
def GetRawFileEndpoint( def GetRawFileEndpoint(
RootName: str, FilePath: str, CurrentUser: dict = Depends(GetCurrentUser) 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) RequirePathAccessible(RootName, FilePath, CurrentUser)
try: try:
@@ -663,9 +623,6 @@ def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrent
return Book or {} return Book or {}
# --- Settings / themes ------------------------------------------------------
@Application.get("/api/settings") @Application.get("/api/settings")
def GetSettingsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> dict: def GetSettingsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
return LoadSettings(CurrentUser["Username"]) return LoadSettings(CurrentUser["Username"])
@@ -719,10 +676,6 @@ def DeleteThemeEndpoint(Name: str, CurrentUser: dict = Depends(GetCurrentUser))
return {"Deleted": True} 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" FrontendDist = ApplicationDirectory.parent / "Frontend" / "dist"
if FrontendDist.is_dir(): if FrontendDist.is_dir():
Application.mount("/", StaticFiles(directory=str(FrontendDist), html=True), name="Frontend") Application.mount("/", StaticFiles(directory=str(FrontendDist), html=True), name="Frontend")
-2
View File
@@ -20,7 +20,6 @@ MimeTypeMap = {
def ReadTextFile(FilePath: Path) -> str: def ReadTextFile(FilePath: Path) -> str:
"""Read a text-classified file as UTF-8."""
return FilePath.read_text(encoding="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]: 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() RangeValue = RangeHeader.replace("bytes=", "").strip()
StartText, _, EndText = RangeValue.partition("-") 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] [SaraView]
ConfigVersion = 1 ConfigVersion = 1
ApplicationVersion = "0.1.0-beta" ApplicationVersion = "0.1.0-beta"
@@ -10,22 +6,12 @@ ApplicationVersion = "0.1.0-beta"
Host = "0.0.0.0" Host = "0.0.0.0"
Port = 8420 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] [Storage]
DataDirectory = "/data/SaraViewData" 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] [Roots]
Notes = "/data/notes" 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] [SaraTag]
Enabled = true Enabled = true
CoreModulePath = "/data/SaraTag/.saratag" CoreModulePath = "/data/SaraTag/.saratag"
-13
View File
@@ -51,7 +51,6 @@ DefaultThemesDirectory = ApplicationDirectory / "DefaultThemes"
def SanitizeThemeName(ThemeName: str) -> str: 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 (".", ".."): if not ThemeName or "/" in ThemeName or "\\" in ThemeName or ThemeName in (".", ".."):
raise ValueError(f"Invalid theme name: {ThemeName}") raise ValueError(f"Invalid theme name: {ThemeName}")
@@ -59,9 +58,6 @@ def SanitizeThemeName(ThemeName: str) -> str:
def SanitizeUsernameForPath(Username: 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): if not Username or not re.fullmatch(r"[A-Za-z0-9_.-]+", Username):
raise ValueError(f"Invalid username: {Username}") raise ValueError(f"Invalid username: {Username}")
@@ -88,7 +84,6 @@ def GetThemesDirectory() -> Path:
def EnsureDataDirectory() -> None: def EnsureDataDirectory() -> None:
"""Create the data directory and seed default themes, if missing."""
DataDirectory = GetDataDirectory() DataDirectory = GetDataDirectory()
ThemesDirectory = GetThemesDirectory() ThemesDirectory = GetThemesDirectory()
DataDirectory.mkdir(parents=True, exist_ok=True) DataDirectory.mkdir(parents=True, exist_ok=True)
@@ -103,10 +98,6 @@ def EnsureDataDirectory() -> None:
def LoadSettings(Username: str) -> dict[str, Any]: 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() EnsureDataDirectory()
SettingsPath = GetSettingsPath(Username) SettingsPath = GetSettingsPath(Username)
@@ -176,10 +167,6 @@ def LoadTheme(ThemeName: str) -> dict[str, str]:
def SaveTheme(ThemeName: str, Palette: dict[str, str]) -> None: 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() EnsureDataDirectory()
ThemeName = SanitizeThemeName(ThemeName) ThemeName = SanitizeThemeName(ThemeName)
-1
View File
@@ -193,7 +193,6 @@ def SaveAvatar(Username: str, FileBytes: bytes, Extension: str) -> None:
Directory = GetAvatarDirectory() Directory = GetAvatarDirectory()
Directory.mkdir(parents=True, exist_ok=True) 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}.*"): for ExistingPath in Directory.glob(f"{Username}.*"):
ExistingPath.unlink() ExistingPath.unlink()
-10
View File
@@ -27,8 +27,6 @@ function JsonBody(Body) {
return { headers: { "Content-Type": "application/json" }, body: JSON.stringify(Body) }; return { headers: { "Content-Type": "application/json" }, body: JSON.stringify(Body) };
} }
// --- Auth -------------------------------------------------------------
export function FetchAuthStatus() { export function FetchAuthStatus() {
return FetchJson("/auth/status"); return FetchJson("/auth/status");
} }
@@ -92,8 +90,6 @@ export function ChangePassword(CurrentPassword, NewPassword) {
}); });
} }
// --- Groups ---------------------------------------------------------------
export function FetchGroups() { export function FetchGroups() {
return FetchJson("/groups"); return FetchJson("/groups");
} }
@@ -112,8 +108,6 @@ export function SetGroupMembersApi(Name, Members) {
return FetchJson(`/groups/members?${Params}`, { method: "PUT", ...JsonBody({ Members }) }); return FetchJson(`/groups/members?${Params}`, { method: "PUT", ...JsonBody({ Members }) });
} }
// --- Locked files/directories -----------------------------------------
export function FetchLocks() { export function FetchLocks() {
return FetchJson("/locks"); return FetchJson("/locks");
} }
@@ -133,8 +127,6 @@ export function RemoveLock(RootName, RelativePath) {
return FetchJson(`/lock?${Params}`, { method: "DELETE" }); return FetchJson(`/lock?${Params}`, { method: "DELETE" });
} }
// --- Files --------------------------------------------------------------
export function FetchRoots() { export function FetchRoots() {
return FetchJson("/roots"); return FetchJson("/roots");
} }
@@ -199,8 +191,6 @@ export function EnsureDirectoryNote(RootName, RelativePath) {
return FetchJson(`/note?${Params}`, { method: "POST" }); return FetchJson(`/note?${Params}`, { method: "POST" });
} }
// --- Settings / themes -------------------------------------------------
export function FetchSettings() { export function FetchSettings() {
return FetchJson("/settings"); return FetchJson("/settings");
} }
+1 -8
View File
@@ -40,7 +40,7 @@ function GetParentPath(FilePath) {
} }
export default function App() { export default function App() {
const [AuthState, SetAuthState] = useState("Loading"); // Loading | Bootstrap | LoggedOut | LoggedIn const [AuthState, SetAuthState] = useState("Loading");
const [CurrentUser, SetCurrentUser] = useState(null); const [CurrentUser, SetCurrentUser] = useState(null);
const [Roots, SetRoots] = useState([]); const [Roots, SetRoots] = useState([]);
@@ -128,9 +128,6 @@ export default function App() {
SetCurrentDirectoryPath(DirectoryPath); SetCurrentDirectoryPath(DirectoryPath);
} }
// A directory is opened the same way a file is - via its own note file -
// rather than by drilling into its listing. Browsing a directory's
// contents happens separately, through the sidebar tree.
function HandleSelectDirectoryNote(DirectoryPath, NoteName, NoteFileType) { function HandleSelectDirectoryNote(DirectoryPath, NoteName, NoteFileType) {
SetViewPath(DirectoryPath); SetViewPath(DirectoryPath);
SetViewIsDirectory(false); SetViewIsDirectory(false);
@@ -169,8 +166,6 @@ export default function App() {
RefreshSidebar(); RefreshSidebar();
if (ViewNoteFilePath && ViewPath === OldPath) { if (ViewNoteFilePath && ViewPath === OldPath) {
// Viewing this directory's own note - the server already renamed the
// note file to match, so just re-resolve it under the new path.
OpenDirectoryNote(NewPath, null); OpenDirectoryNote(NewPath, null);
} else if (!ViewIsDirectory && ViewPath === OldPath) { } else if (!ViewIsDirectory && ViewPath === OldPath) {
HandleSelectFile(NewPath, ViewFileType); HandleSelectFile(NewPath, ViewFileType);
@@ -203,8 +198,6 @@ export default function App() {
return <LoginScreen IsBootstrap={false} OnAuthenticated={HandleAuthenticated} />; return <LoginScreen IsBootstrap={false} OnAuthenticated={HandleAuthenticated} />;
} }
// Only take up a TopBar row at all if there's something to put in it —
// otherwise turning breadcrumbs off with a single root left a blank strip.
const ShowTopBar = (Settings && Settings.ShowBreadcrumbs) || Roots.length > 1; const ShowTopBar = (Settings && Settings.ShowBreadcrumbs) || Roots.length > 1;
const ConfirmBeforeDelete = Settings ? Settings.ConfirmBeforeDelete : true; const ConfirmBeforeDelete = Settings ? Settings.ConfirmBeforeDelete : true;
+1 -1
View File
@@ -40,7 +40,7 @@ export default function LoginScreen({ IsBootstrap, OnAuthenticated }) {
</div> </div>
{IsBootstrap && ( {IsBootstrap && (
<div className="LoginSubtitle"> <div className="LoginSubtitle">
No accounts exist yet — this first one becomes an admin. No accounts exist yet - this first one becomes an admin.
</div> </div>
)} )}
<div className="SettingsRow"> <div className="SettingsRow">
+1 -4
View File
@@ -141,7 +141,7 @@ function BuildBlameGutter(BlameMap, ShowAvatars) {
return new BlameMarker("hidden", "•••", null, Title); return new BlameMarker("hidden", "•••", null, Title);
} }
const Title = `${Entry.Username} — ${new Date(Entry.Timestamp).toLocaleString()}`; const Title = `${Entry.Username} - ${new Date(Entry.Timestamp).toLocaleString()}`;
if (ShowAvatars && Entry.HasAvatar) { if (ShowAvatars && Entry.HasAvatar) {
return new BlameMarker("avatar", GetAvatarUrl(Entry.Username), null, Title); return new BlameMarker("avatar", GetAvatarUrl(Entry.Username), null, Title);
@@ -242,9 +242,6 @@ export default function PreviewPane({
<div className={ToolbarClass}> <div className={ToolbarClass}>
<button onClick={() => SetIsEditing(true)}>Edit</button> <button onClick={() => SetIsEditing(true)}>Edit</button>
</div> </div>
{/* Raw, path-shaped URL (not the query-param /api/file form) so
relative <link>/<script>/<img> references inside the HTML
resolve against their real sibling files. */}
<iframe title={FilePath} sandbox="" src={GetRawFileUrl(RootName, FilePath)} /> <iframe title={FilePath} sandbox="" src={GetRawFileUrl(RootName, FilePath)} />
</div> </div>
); );
+3 -3
View File
@@ -488,7 +488,7 @@ export default function SettingsPanel({
</div> </div>
{UsernameError && <div className="SettingsSaveError">{UsernameError}</div>} {UsernameError && <div className="SettingsSaveError">{UsernameError}</div>}
<div className="SettingsHint"> <div className="SettingsHint">
Renaming logs out any other devices/browsers you're signed in on — this one Renaming logs out any other devices/browsers you're signed in on - this one
stays signed in under the new name. stays signed in under the new name.
</div> </div>
</div> </div>
@@ -573,14 +573,14 @@ export default function SettingsPanel({
<div className="SettingsSectionTitle">Locked Directories (admin)</div> <div className="SettingsSectionTitle">Locked Directories (admin)</div>
{Locks && Locks.length === 0 && ( {Locks && Locks.length === 0 && (
<div className="SettingsHint"> <div className="SettingsHint">
Nothing locked yet — use the 🔒 button next to any directory to lock it. Nothing locked yet - use the 🔒 button next to any directory to lock it.
</div> </div>
)} )}
{Locks && {Locks &&
Locks.map((Lock, Index) => ( Locks.map((Lock, Index) => (
<div className="SettingsRowInline" key={`${Lock.Root}:${Lock.Path}:${Index}`}> <div className="SettingsRowInline" key={`${Lock.Root}:${Lock.Path}:${Index}`}>
<span className="SettingsLabel"> <span className="SettingsLabel">
{Lock.Root}/{Lock.Path} —{" "} {Lock.Root}/{Lock.Path} -{" "}
{[...(Lock.Users || []), ...(Lock.Groups || []).map((G) => `@${G}`)].join( {[...(Lock.Users || []), ...(Lock.Groups || []).map((G) => `@${G}`)].join(
", " ", "
)} )}
-16
View File
@@ -4,12 +4,6 @@
font-display: swap; font-display: swap;
} }
/*
* Palette roles below are DEFAULTS ONLY. The active theme's actual values
* are applied at runtime (see App.jsx), which sets these same custom
* property names on :root via JS after fetching the chosen theme from the
* backend. Any theme file just needs to supply this same set of roles.
*/
:root { :root {
--Background: #1e1e2e; --Background: #1e1e2e;
--Surface: #181825; --Surface: #181825;
@@ -62,8 +56,6 @@ img,
height: 100vh; height: 100vh;
} }
/* Sidebar / file tree */
.Sidebar { .Sidebar {
width: 260px; width: 260px;
flex-shrink: 0; flex-shrink: 0;
@@ -246,8 +238,6 @@ img,
padding-left: 14px; padding-left: 14px;
} }
/* Main column */
.MainColumn { .MainColumn {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -481,8 +471,6 @@ img,
background: white; background: white;
} }
/* Tag panel — bordered box style, mirroring the Statistics panel look */
.TagPanel { .TagPanel {
width: 280px; width: 280px;
flex-shrink: 0; flex-shrink: 0;
@@ -530,8 +518,6 @@ img,
color: var(--Orange); color: var(--Orange);
} }
/* Settings modal */
.SettingsOverlay { .SettingsOverlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -831,8 +817,6 @@ img,
margin-left: 8px; margin-left: 8px;
} }
/* Login screen */
.LoginScreen { .LoginScreen {
height: 100vh; height: 100vh;
display: flex; display: flex;
+191 -102
View File
@@ -1,54 +1,104 @@
# SaraView # SaraView
A self-hosted web file previewer/editor — Obsidian's look, without the parts A self-hosted web file previewer/editor. Obsidian's look, without the parts
you don't use, plus image and video previewing. Read-only integration with you don't use, plus image and video previewing. Read-only integration with
SaraTag's per-file Book tags is included. SaraTag's per-file Book tags is included.
## Project layout It's made by Sarah, hence the name. :3
``` ---
SaraView/
├── Backend/ FastAPI app (Core, Explorer, Preview, Editor, BookReader, Main)
├── Frontend/ React app (Vite)
├── SaraViewConf.toml Your editable config, mounted into the container
├── Dockerfile
└── docker-compose.yml
```
## Configure before deploying ## Usage
Edit `SaraViewConf.toml` at the project root: SaraView is for people who want an Obsidian-style notes app they host
themselves, people who want to preview and edit files from any browser, and
people who want multiple accounts with their own private directories.
- `[Roots]` — add one entry per top-level folder you want browsable, e.g. Example use-cases:
`Notes = "/data/notes"`. The path here is the path *inside the container*;
map it to a real folder via a volume mount in `docker-compose.yml`.
- `[SaraTag]` — set `CoreModulePath` to wherever your SaraTag installation's
`.saratag` folder ends up mounted inside the container. Set `Enabled = false`
if you don't want the Book/tag panel at all.
- `[Storage]` — `DataDirectory` is where Settings.toml and the Themes/ folder
live. Leave this pointing at the `saraview_data` named volume already set
up in `docker-compose.yml` — it's intentionally not one of your `[Roots]`,
so it's unreachable from the file browser no matter what.
Then in `docker-compose.yml`: - Browsing and editing markdown notes from your phone, without installing
anything.
- Add a `volumes:` line for every root you declared, mapping your real - Previewing images and videos that live next to your notes.
ZimaOS path to the container path used in `SaraViewConf.toml`.
- Mount your SaraTag `.saratag` folder read-only the same way.
- Bind the port to your Tailscale interface's IP rather than `0.0.0.0` if you
want it reachable only over the tailnet (same pattern as your other
tailnet-only services).
## Running it - Giving a friend or family member their own login, with a directory only
they can see.
```bash ---
docker compose up --build -d
``` ## Dependencies
Docker + Docker Compose. That's it for running it.
For local development instead: Python 3.11+, and Node.js + npm.
---
## Installation
Here's a step-by-step tutorial.
1. Clone or download this repository.
2. Edit `SaraViewConf.toml` at the project root. Set your `[Roots]`,
`[SaraTag]`, and `[Storage]` paths. (See Config Info below for what each
field does.)
3. Edit `docker-compose.yml`. Add a `volumes:` line for every root you
declared, mount your SaraTag `.saratag` folder read-only, and bind the
port to your Tailscale interface's IP instead of `0.0.0.0` if you want it
reachable only over the tailnet.
4. Run ```docker compose up --build -d```.
The backend serves the built frontend directly, so the whole app is on one The backend serves the built frontend directly, so the whole app is on one
port (`8420` by default). port (`8420` by default).
## Local development (without Docker) ---
## Config Info
Paths in `SaraViewConf.toml` are resolved as given. Use absolute container
paths when running via Docker (see `docker-compose.yml`), matching whatever
you mount the volumes to.
```toml
[SaraView]
ConfigVersion = 1
ApplicationVersion = "0.1.0-beta"
[Server]
Host = "0.0.0.0"
Port = 8420
[Storage]
DataDirectory = "/data/SaraViewData"
[Roots]
Notes = "/data/notes"
[SaraTag]
Enabled = true
CoreModulePath = "/data/SaraTag/.saratag"
HideInternalFiles = true
InternalPatterns = [".saratag", ".SaraTag*", "*.book.toml"]
```
- `[Storage] DataDirectory` is where SaraView keeps its own data - Settings,
Users, Themes, and so on. This is NOT one of the `[Roots]` below, so it's
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.
- `[Roots]` lists the 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.
- `[SaraTag]`, if `Enabled`, makes SaraView import 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.
---
## Local Development
Backend: Backend:
@@ -58,7 +108,7 @@ pip install -r requirements.txt
uvicorn Main:Application --reload --port 8420 uvicorn Main:Application --reload --port 8420
``` ```
Frontend (in a second terminal — Vite proxies `/api` to the backend): Frontend, in a second terminal. Vite proxies `/api` to the backend.
```bash ```bash
cd Frontend cd Frontend
@@ -66,112 +116,151 @@ npm install
npm run dev npm run dev
``` ```
## Notes on the SaraTag integration ---
SaraView never writes to Book or Lexicon files — it only reads them, by ## Directories as Files
Every directory gets its own note file. It's a hidden `.sv_{DirectoryName}.md`,
created automatically the moment you make the directory. Any extension works
if you rename the note later; markdown is just the default.
Click a directory the same way you'd click a file, and its note opens with
the same edit/view/save toolbar. There's no separate "directory mode."
Renaming or moving a directory keeps its note's name in sync automatically.
Deleting a directory takes its note with it.
Browsing what's actually inside a directory is a sidebar-only action.
Expand a folder there to see its contents. The "Open directory file on
click" setting in Settings decides whether expanding a directory in the
sidebar also opens its note in the main pane, or leaves the main pane alone.
Note files are hidden from listings by default, since their name starts
with `.sv_`. This is independent of the regular "show hidden directories"
setting. Flip on "Show directory note files (.sv_*)" in Settings if you
want to see and manage them directly, like any other file.
---
## SaraTag Integration
SaraView never writes to Book or Lexicon files. It only reads them, by
loading SaraTag's own `Core.py` directly (by file path, not as an installed loading SaraTag's own `Core.py` directly (by file path, not as an installed
package) so tag resolution always matches what SaraTag itself would show. package), so tag resolution always matches what SaraTag itself would show.
Editing tags from SaraView isn't implemented yet; SaraTag's own TUI remains
Editing tags from SaraView isn't implemented yet. SaraTag's own TUI remains
the place to do that for now. the place to do that for now.
## Settings and themes ---
Settings (font, font size, active theme, hidden-folder visibility, breadcrumb ## Settings and Themes
visibility) and themes live in `Settings.toml` and `Themes/*.toml` inside the
`saraview_data` volume — not in any of your `[Roots]`, so they're never
browsable from the app itself. Three themes ship built in: CatppuccinMocha,
Gruvbox, and Forest. Every theme is just a flat set of named color roles
(Background, Surface, Text, Red, Orange, ... Purple), so creating a custom
theme from Settings — or hand-writing a new file in `Themes/` — only needs
those same role names filled in.
This is all per-user now — each account gets its own `Settings/<Username>.toml` Settings (font, font size, active theme, hidden-folder visibility,
breadcrumb visibility) and themes live in `Settings.toml` and
`Themes/*.toml` inside the `saraview_data` volume. Not in any of your
`[Roots]`, so they're never browsable from the app itself.
Three themes ship built in: CatppuccinMocha, Gruvbox, and Forest. Every
theme is just a flat set of named color roles (Background, Surface, Text,
Red, Orange, ... Purple), so creating a custom theme from Settings, or
hand-writing a new file in `Themes/`, only needs those same role names
filled in.
This is all per-user. Each account gets its own `Settings/<Username>.toml`
inside the data volume, seeded from the same defaults the first time that inside the data volume, seeded from the same defaults the first time that
user logs in. Themes themselves are shared across everyone (there's only one user logs in. Themes themselves are shared across everyone (there's only
copy of each theme file), but which theme is active is a per-user choice. one copy of each theme file), but which theme is active is a per-user
choice.
## Accounts and login ---
On first launch, with no accounts yet, SaraView shows a one-time "create the ## Accounts and Login
admin account" screen instead of a normal login — whoever sets that up first
becomes an admin. After that, it's a normal username/password login screen
for everyone.
Accounts live in `Users.toml` in the same `saraview_data` volume as Settings On first launch, with no accounts yet, SaraView shows a one-time "create
and Themes (passwords are hashed with PBKDF2-SHA256, never stored in plain the admin account" screen instead of a normal login. Whoever sets that up
text). Admins get a "Users" section inside Settings to create additional first becomes an admin. After that, it's a normal username/password login
accounts, including other admins. screen for everyone.
Sessions are currently in-memory only — restarting the container logs Accounts live in `Users.toml` in the same `saraview_data` volume as
everyone out. Fine for now with a small, trusted set of tailnet users; worth Settings and Themes. Passwords are hashed with PBKDF2-SHA256, never stored
revisiting if that stops being true. in plain text. Admins get a "Users" section inside Settings to create
additional accounts, including other admins.
## Running on ZimaOS without building locally Sessions are currently in-memory only, so restarting the container logs
everyone out. Fine for now with a small, trusted set of tailnet users.
---
## Running on ZimaOS Without Building Locally
The repo publishes a built image to GitHub Container Registry via The repo publishes a built image to GitHub Container Registry via
`.github/workflows/docker-publish.yml`, so ZimaOS (or any other machine) can `.github/workflows/docker-publish.yml`, so ZimaOS (or any other machine)
just pull and run it instead of building from source every time. can just pull and run it instead of building from source every time.
**One-time setup after this workflow first runs:** GHCR packages built by a **One-time setup after this workflow first runs:** GHCR packages built by a
workflow's built-in token default to **private**, even on a public repo. Go workflow's built-in token default to **private**, even on a public repo.
to your GitHub profile → Packages → `saraview` → Package settings → Change Go to your GitHub profile → Packages → `saraview` → Package settings →
visibility → Public. Until you do this, pulling the image from ZimaOS will Change visibility → Public. Until you do this, pulling the image from
fail with an authentication error. ZimaOS will fail with an authentication error.
**Installing on ZimaOS:** Here's a step-by-step tutorial for installing on ZimaOS.
1. Copy `docker-compose.ghcr.yml` and `SaraViewConf.toml` to the ZimaOS box 1. Copy `docker-compose.ghcr.yml` and `SaraViewConf.toml` to the ZimaOS box
(or wherever you're running `docker compose` from for this). (or wherever you're running `docker compose` from for this).
2. Edit the volume paths in `docker-compose.ghcr.yml` the same way you would 2. Edit the volume paths in `docker-compose.ghcr.yml` the same way you
for local `docker-compose.yml` — real paths on the left of each `:`. would for local `docker-compose.yml`. Real paths on the left of each `:`.
3. In ZimaOS's dashboard, use **Install a customized app** (its Docker 3. In ZimaOS's dashboard, use **Install a customized app** (its Docker
Compose import option) and point it at that compose file — or just run Compose import option) and point it at that compose file. Or just run
`docker compose -f docker-compose.ghcr.yml up -d` directly if you'd ```docker compose -f docker-compose.ghcr.yml up -d``` directly if you'd
rather do it from a terminal on the box. rather do it from a terminal on the box.
**Updating later:** push to `main` (or tag a release like `v1.1.0`) to build **Updating later:** push to `main` (or tag a release like `v1.1.0`) to
a fresh image, then on ZimaOS re-pull and recreate the container — either build a fresh image, then on ZimaOS re-pull and recreate the container.
through its UI's update/recreate action, or `docker compose -f Either through its UI's update/recreate action, or:
docker-compose.ghcr.yml pull && docker compose -f docker-compose.ghcr.yml up -d`.
This gets SaraView pullable as a plain image; it doesn't yet show up with its ```bash
own icon/entry in ZimaOS's app store the way bundled apps do — that would docker compose -f docker-compose.ghcr.yml pull
need a CasaOS-format AppStore manifest (icon, category, metadata) submitted docker compose -f docker-compose.ghcr.yml up -d
separately, which is a reasonable next step if you want it to feel fully ```
native rather than "a compose file I pasted in."
## Groups and access control ---
Admin isn't a hardcoded flag — it's the built-in "Admin" group, which can't ## Groups and Access Control
Admin isn't a hardcoded flag. It's the built-in "Admin" group, which can't
be deleted but is otherwise just a group like any other. Manage groups be deleted but is otherwise just a group like any other. Manage groups
(create, delete, edit membership) from Settings → Groups (admin-only). (create, delete, edit membership) from Settings → Groups (admin-only).
Setting access on a file or directory is **not** admin-only — anyone can Setting access on a file or directory is **not** admin-only. Anyone can
restrict a file or directory to specific users and/or groups, whether at restrict a file or directory to specific users and/or groups, whether at
creation time (the New File/New Directory dialog includes a search-and- creation time (the New File/New Directory dialog includes a search-and-
checkbox picker) or afterwards via the 🔒 button next to any entry, in checkbox picker) or afterwards via the 🔒 button next to any entry, in
either the sidebar tree or the directory-browse pane. If you can currently either the sidebar tree or the directory-browse pane. If you can currently
see something, you're trusted to change who else can. Admins do not bypass see something, you're trusted to change who else can.
restrictions - Admin is a management group (creating users, managing groups,
and so on), not a way to view everyone's private directories. Only direct
access to the server's files can see everything.
Restricting is per-file-or-directory — locking a directory covers everything Admins do not bypass restrictions. Admin is a management group (creating
inside it automatically (there's only ever one restriction record per locked users, managing groups, and so on), not a way to view everyone's private
directory; reaching anything underneath still requires being permitted on directories. Only direct access to the server's files can see everything.
that ancestor), while locking an individual file affects only that file.
Restricting is per-file-or-directory. Locking a directory covers everything
inside it automatically (there's only ever one restriction record per
locked directory; reaching anything underneath still requires being
permitted on that ancestor), while locking an individual file affects only
that file.
Anyone without access gets a plain 404, both for direct requests and for Anyone without access gets a plain 404, both for direct requests and for
that entry showing up in a directory listing — a restricted item's existence that entry showing up in a directory listing. A restricted item's existence
isn't revealed to people who can't see it. Admins get a global overview of isn't revealed to people who can't see it. Admins get a global overview of
every current restriction from Settings → Locked Directories. every current restriction from Settings → Locked Directories.
## Blame privacy ---
## Blame Privacy
Each person can turn off "Show my edits in others' blame view" in their own Each person can turn off "Show my edits in others' blame view" in their own
Settings. When that's off, other users still see that lines were changed — Settings. When that's off, other users still see that lines were changed,
just with the identity redacted, rather than attributing it to someone who just with the identity redacted, rather than attributing it to someone who
asked not to be shown. You can always see your own attribution regardless of asked not to be shown. You can always see your own attribution regardless
this setting. There's also a per-viewer toggle for showing avatars instead of this setting.
of usernames in the blame gutter, using each author's chosen colour either
way. There's also a per-viewer toggle for showing avatars instead of usernames
in the blame gutter, using each author's chosen colour either way.
-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] [SaraView]
ConfigVersion = 1 ConfigVersion = 1
ApplicationVersion = "0.1.0-beta" ApplicationVersion = "0.1.0-beta"
@@ -10,22 +6,12 @@ ApplicationVersion = "0.1.0-beta"
Host = "0.0.0.0" Host = "0.0.0.0"
Port = 8420 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] [Storage]
DataDirectory = "/data/SaraViewData" 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] [Roots]
Notes = "/data/notes" 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] [SaraTag]
Enabled = true Enabled = true
CoreModulePath = "/data/SaraTag/.saratag" CoreModulePath = "/data/SaraTag/.saratag"
-18
View File
@@ -1,31 +1,13 @@
# For ZimaOS (or any server that should just pull-and-run rather than
# build): install this via ZimaOS's "Install a customized app" /
# Docker Compose option, filling in your real paths below first.
#
# This is the "no local build" counterpart to docker-compose.yml, which
# still builds from source for local dev/testing.
services: services:
saraview: saraview:
image: ghcr.io/srhtnin/saraview:latest image: ghcr.io/srhtnin/saraview:latest
container_name: SaraView container_name: SaraView
ports: ports:
# Bind to your Tailscale IP only, e.g. "100.x.x.x:8420:8420",
# so the app is reachable over the tailnet and nothing else.
- "8420:8420" - "8420:8420"
volumes: volumes:
# Your own config, overriding the one baked into the image — edit
# Roots and SaraTag.CoreModulePath to match your real paths below.
- ./SaraViewConf.toml:/App/Backend/SaraViewConf.toml:ro - ./SaraViewConf.toml:/App/Backend/SaraViewConf.toml:ro
# Persistent app data: Settings/, Users.toml, Avatars/, Themes/,
# Blame/. A named volume (not a host path) is fine here — nothing
# outside SaraView needs to touch it, and this is what keeps it
# structurally unreachable from the file browser regardless of any
# hidden-file setting.
- saraview_data:/data/SaraViewData - saraview_data:/data/SaraViewData
# One mount per root you declare in SaraViewConf.toml's [Roots] table.
- /path/to/your/notes:/data/notes:ro - /path/to/your/notes:/data/notes:ro
# SaraTag's internal folder, for read-only Book resolution.
- /path/to/SaraTag/.saratag:/data/SaraTag/.saratag:ro - /path/to/SaraTag/.saratag:/data/SaraTag/.saratag:ro
restart: unless-stopped restart: unless-stopped
-10
View File
@@ -3,21 +3,11 @@ services:
build: . build: .
container_name: SaraView container_name: SaraView
ports: ports:
# Bind to your Tailscale IP only, e.g. "100.x.x.x:8420:8420",
# so the app is reachable over the tailnet and nothing else.
- "8420:8420" - "8420:8420"
volumes: volumes:
# Your own config, overriding the one baked into the image — edit
# Roots and SaraTag.CoreModulePath to match your real paths below.
- ./SaraViewConf.toml:/App/Backend/SaraViewConf.toml:ro - ./SaraViewConf.toml:/App/Backend/SaraViewConf.toml:ro
# Persistent app data: Settings.toml + Themes/. A named volume (not a
# host path) is fine here — nothing outside SaraView needs to touch it,
# and this is what keeps it structurally unreachable from the file
# browser regardless of any hidden-file setting.
- saraview_data:/data/SaraViewData - saraview_data:/data/SaraViewData
# One mount per root you declare in SaraViewConf.toml's [Roots] table.
- /path/to/your/notes:/data/notes:ro - /path/to/your/notes:/data/notes:ro
# SaraTag's internal folder, for read-only Book resolution.
- /path/to/SaraTag/.saratag:/data/SaraTag/.saratag:ro - /path/to/SaraTag/.saratag:/data/SaraTag/.saratag:ro
restart: unless-stopped restart: unless-stopped