mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +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
@@ -1,19 +1,5 @@
|
||||
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:
|
||||
push:
|
||||
branches: ["main"]
|
||||
@@ -32,9 +18,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ function JsonBody(Body) {
|
||||
return { headers: { "Content-Type": "application/json" }, body: JSON.stringify(Body) };
|
||||
}
|
||||
|
||||
// --- Auth -------------------------------------------------------------
|
||||
|
||||
export function FetchAuthStatus() {
|
||||
return FetchJson("/auth/status");
|
||||
}
|
||||
@@ -92,8 +90,6 @@ export function ChangePassword(CurrentPassword, NewPassword) {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Groups ---------------------------------------------------------------
|
||||
|
||||
export function FetchGroups() {
|
||||
return FetchJson("/groups");
|
||||
}
|
||||
@@ -112,8 +108,6 @@ export function SetGroupMembersApi(Name, Members) {
|
||||
return FetchJson(`/groups/members?${Params}`, { method: "PUT", ...JsonBody({ Members }) });
|
||||
}
|
||||
|
||||
// --- Locked files/directories -----------------------------------------
|
||||
|
||||
export function FetchLocks() {
|
||||
return FetchJson("/locks");
|
||||
}
|
||||
@@ -133,8 +127,6 @@ export function RemoveLock(RootName, RelativePath) {
|
||||
return FetchJson(`/lock?${Params}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// --- Files --------------------------------------------------------------
|
||||
|
||||
export function FetchRoots() {
|
||||
return FetchJson("/roots");
|
||||
}
|
||||
@@ -199,8 +191,6 @@ export function EnsureDirectoryNote(RootName, RelativePath) {
|
||||
return FetchJson(`/note?${Params}`, { method: "POST" });
|
||||
}
|
||||
|
||||
// --- Settings / themes -------------------------------------------------
|
||||
|
||||
export function FetchSettings() {
|
||||
return FetchJson("/settings");
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ function GetParentPath(FilePath) {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [AuthState, SetAuthState] = useState("Loading"); // Loading | Bootstrap | LoggedOut | LoggedIn
|
||||
const [AuthState, SetAuthState] = useState("Loading");
|
||||
const [CurrentUser, SetCurrentUser] = useState(null);
|
||||
|
||||
const [Roots, SetRoots] = useState([]);
|
||||
@@ -128,9 +128,6 @@ export default function App() {
|
||||
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) {
|
||||
SetViewPath(DirectoryPath);
|
||||
SetViewIsDirectory(false);
|
||||
@@ -169,8 +166,6 @@ export default function App() {
|
||||
RefreshSidebar();
|
||||
|
||||
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);
|
||||
} else if (!ViewIsDirectory && ViewPath === OldPath) {
|
||||
HandleSelectFile(NewPath, ViewFileType);
|
||||
@@ -203,8 +198,6 @@ export default function App() {
|
||||
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 ConfirmBeforeDelete = Settings ? Settings.ConfirmBeforeDelete : true;
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function LoginScreen({ IsBootstrap, OnAuthenticated }) {
|
||||
</div>
|
||||
{IsBootstrap && (
|
||||
<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 className="SettingsRow">
|
||||
|
||||
@@ -141,7 +141,7 @@ function BuildBlameGutter(BlameMap, ShowAvatars) {
|
||||
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) {
|
||||
return new BlameMarker("avatar", GetAvatarUrl(Entry.Username), null, Title);
|
||||
@@ -242,9 +242,6 @@ export default function PreviewPane({
|
||||
<div className={ToolbarClass}>
|
||||
<button onClick={() => SetIsEditing(true)}>Edit</button>
|
||||
</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)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -488,7 +488,7 @@ export default function SettingsPanel({
|
||||
</div>
|
||||
{UsernameError && <div className="SettingsSaveError">{UsernameError}</div>}
|
||||
<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.
|
||||
</div>
|
||||
</div>
|
||||
@@ -573,14 +573,14 @@ export default function SettingsPanel({
|
||||
<div className="SettingsSectionTitle">Locked Directories (admin)</div>
|
||||
{Locks && Locks.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
{Locks &&
|
||||
Locks.map((Lock, Index) => (
|
||||
<div className="SettingsRowInline" key={`${Lock.Root}:${Lock.Path}:${Index}`}>
|
||||
<span className="SettingsLabel">
|
||||
{Lock.Root}/{Lock.Path} —{" "}
|
||||
{Lock.Root}/{Lock.Path} -{" "}
|
||||
{[...(Lock.Users || []), ...(Lock.Groups || []).map((G) => `@${G}`)].join(
|
||||
", "
|
||||
)}
|
||||
|
||||
@@ -4,12 +4,6 @@
|
||||
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 {
|
||||
--Background: #1e1e2e;
|
||||
--Surface: #181825;
|
||||
@@ -62,8 +56,6 @@ img,
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar / file tree */
|
||||
|
||||
.Sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
@@ -246,8 +238,6 @@ img,
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
/* Main column */
|
||||
|
||||
.MainColumn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -481,8 +471,6 @@ img,
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Tag panel — bordered box style, mirroring the Statistics panel look */
|
||||
|
||||
.TagPanel {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
@@ -530,8 +518,6 @@ img,
|
||||
color: var(--Orange);
|
||||
}
|
||||
|
||||
/* Settings modal */
|
||||
|
||||
.SettingsOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -831,8 +817,6 @@ img,
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Login screen */
|
||||
|
||||
.LoginScreen {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
|
||||
@@ -1,54 +1,104 @@
|
||||
# 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
|
||||
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.
|
||||
`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.
|
||||
Example use-cases:
|
||||
|
||||
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
|
||||
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).
|
||||
- Previewing images and videos that live next to your notes.
|
||||
|
||||
## 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
|
||||
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:
|
||||
|
||||
@@ -58,7 +108,7 @@ pip install -r requirements.txt
|
||||
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
|
||||
cd Frontend
|
||||
@@ -66,112 +116,151 @@ npm install
|
||||
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
|
||||
package) so tag resolution always matches what SaraTag itself would show.
|
||||
Editing tags from SaraView isn't implemented yet; SaraTag's own TUI remains
|
||||
package), so tag resolution always matches what SaraTag itself would show.
|
||||
|
||||
Editing tags from SaraView isn't implemented yet. SaraTag's own TUI remains
|
||||
the place to do that for now.
|
||||
|
||||
## Settings and themes
|
||||
---
|
||||
|
||||
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.
|
||||
## Settings and Themes
|
||||
|
||||
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
|
||||
user logs in. Themes themselves are shared across everyone (there's only one
|
||||
copy of each theme file), but which theme is active is a per-user choice.
|
||||
user logs in. Themes themselves are shared across everyone (there's only
|
||||
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
|
||||
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 and Login
|
||||
|
||||
Accounts live in `Users.toml` in the same `saraview_data` volume as Settings
|
||||
and Themes (passwords are hashed with PBKDF2-SHA256, never stored in plain
|
||||
text). Admins get a "Users" section inside Settings to create additional
|
||||
accounts, including other admins.
|
||||
On first launch, with no accounts yet, SaraView shows a one-time "create
|
||||
the 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.
|
||||
|
||||
Sessions are currently in-memory only — restarting the container logs
|
||||
everyone out. Fine for now with a small, trusted set of tailnet users; worth
|
||||
revisiting if that stops being true.
|
||||
Accounts live in `Users.toml` in the same `saraview_data` volume as
|
||||
Settings and Themes. Passwords are hashed with PBKDF2-SHA256, never stored
|
||||
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
|
||||
`.github/workflows/docker-publish.yml`, so ZimaOS (or any other machine) can
|
||||
just pull and run it instead of building from source every time.
|
||||
`.github/workflows/docker-publish.yml`, so ZimaOS (or any other machine)
|
||||
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
|
||||
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. Until you do this, pulling the image from ZimaOS will
|
||||
fail with an authentication error.
|
||||
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. Until you do this, pulling the image from
|
||||
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
|
||||
(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
|
||||
for local `docker-compose.yml` — real paths on the left of each `:`.
|
||||
2. Edit the volume paths in `docker-compose.ghcr.yml` the same way you
|
||||
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
|
||||
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
|
||||
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
|
||||
rather do it from a terminal on the box.
|
||||
|
||||
**Updating later:** push to `main` (or tag a release like `v1.1.0`) to build
|
||||
a fresh image, then on ZimaOS re-pull and recreate the container — either
|
||||
through its UI's update/recreate action, or `docker compose -f
|
||||
docker-compose.ghcr.yml pull && docker compose -f docker-compose.ghcr.yml up -d`.
|
||||
**Updating later:** push to `main` (or tag a release like `v1.1.0`) to
|
||||
build a fresh image, then on ZimaOS re-pull and recreate the container.
|
||||
Either through its UI's update/recreate action, or:
|
||||
|
||||
This gets SaraView pullable as a plain image; it doesn't yet show up with its
|
||||
own icon/entry in ZimaOS's app store the way bundled apps do — that would
|
||||
need a CasaOS-format AppStore manifest (icon, category, metadata) submitted
|
||||
separately, which is a reasonable next step if you want it to feel fully
|
||||
native rather than "a compose file I pasted in."
|
||||
```bash
|
||||
docker compose -f docker-compose.ghcr.yml pull
|
||||
docker compose -f docker-compose.ghcr.yml up -d
|
||||
```
|
||||
|
||||
## 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
|
||||
(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
|
||||
creation time (the New File/New Directory dialog includes a search-and-
|
||||
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
|
||||
see something, you're trusted to change who else can. Admins do not bypass
|
||||
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.
|
||||
see something, you're trusted to change who else can.
|
||||
|
||||
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.
|
||||
Admins do not bypass 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
asked not to be shown. You can always see your own attribution regardless of
|
||||
this setting. 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.
|
||||
asked not to be shown. You can always see your own attribution regardless
|
||||
of this setting.
|
||||
|
||||
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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
saraview:
|
||||
image: ghcr.io/srhtnin/saraview:latest
|
||||
container_name: SaraView
|
||||
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"
|
||||
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
|
||||
# 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
|
||||
# One mount per root you declare in SaraViewConf.toml's [Roots] table.
|
||||
- /path/to/your/notes:/data/notes:ro
|
||||
# SaraTag's internal folder, for read-only Book resolution.
|
||||
- /path/to/SaraTag/.saratag:/data/SaraTag/.saratag:ro
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -3,21 +3,11 @@ services:
|
||||
build: .
|
||||
container_name: SaraView
|
||||
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"
|
||||
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
|
||||
# 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
|
||||
# One mount per root you declare in SaraViewConf.toml's [Roots] table.
|
||||
- /path/to/your/notes:/data/notes:ro
|
||||
# SaraTag's internal folder, for read-only Book resolution.
|
||||
- /path/to/SaraTag/.saratag:/data/SaraTag/.saratag:ro
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
Reference in new issue
Block a user