From 9736a55e00887e0274b39849d6e007f4f9f0d046 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Mon, 21 Sep 2026 21:09:25 +0200 Subject: [PATCH] Added guests. --- Backend/Auth.py | 47 +++++++++++++++--- Backend/Embeds.py | 5 +- Backend/Groups.py | 17 +++++-- Backend/Main.py | 40 ++++++++++----- Frontend/src/App.jsx | 33 +++++++++++-- Frontend/src/Components/DirectoryPane.jsx | 59 ++++++++++++----------- Frontend/src/Components/LoginScreen.jsx | 7 ++- Frontend/src/Components/PreviewPane.jsx | 16 +++--- Frontend/src/Components/Sidebar.jsx | 55 ++++++++++++++------- Frontend/src/Styles/Theme.css | 5 ++ 10 files changed, 200 insertions(+), 84 deletions(-) diff --git a/Backend/Auth.py b/Backend/Auth.py index 73471c2..6c8330a 100644 --- a/Backend/Auth.py +++ b/Backend/Auth.py @@ -6,7 +6,7 @@ from typing import Any from fastapi import Depends, HTTPException, Request -from Groups import AdminGroupName, GetGroupsForUser, IsUserInGroup +from Groups import AdminGroupName, GetGroupsForUser, GuestGroupName, IsUserInGroup from Users import GetUser @@ -14,6 +14,14 @@ ActiveSessions: dict[str, str] = {} SessionCookieName = "SaraViewSession" +GuestUser: dict[str, Any] = { + "Username": None, + "IsAdmin": False, + "IsGuest": True, + "Groups": [GuestGroupName], + "Colour": "Purple", +} + def CreateSession(Username: str) -> str: Token = secrets.token_urlsafe(32) @@ -25,6 +33,21 @@ def DeleteSession(Token: str) -> None: ActiveSessions.pop(Token, None) +def _BuildUserRecord(Username: str, User: dict[str, Any]) -> dict[str, Any]: + UserGroups = GetGroupsForUser(Username) + + if GuestGroupName not in UserGroups: + UserGroups = [*UserGroups, GuestGroupName] + + return { + "Username": Username, + "IsAdmin": AdminGroupName in UserGroups, + "IsGuest": False, + "Groups": UserGroups, + "Colour": User.get("Colour", "Purple"), + } + + def GetCurrentUser(request: Request) -> dict[str, Any]: Token = request.cookies.get(SessionCookieName) Username = ActiveSessions.get(Token) if Token else None @@ -37,14 +60,22 @@ def GetCurrentUser(request: Request) -> dict[str, Any]: if User is None: raise HTTPException(status_code=401, detail="Session user no longer exists.") - UserGroups = GetGroupsForUser(Username) + return _BuildUserRecord(Username, User) - return { - "Username": Username, - "IsAdmin": AdminGroupName in UserGroups, - "Groups": UserGroups, - "Colour": User.get("Colour", "Purple"), - } + +def GetCurrentUserOrGuest(request: Request) -> dict[str, Any]: + Token = request.cookies.get(SessionCookieName) + Username = ActiveSessions.get(Token) if Token else None + + if Username is None: + return dict(GuestUser) + + User = GetUser(Username) + + if User is None: + return dict(GuestUser) + + return _BuildUserRecord(Username, User) def RequireAdmin(CurrentUser: dict = Depends(GetCurrentUser)) -> dict: diff --git a/Backend/Embeds.py b/Backend/Embeds.py index cff20ee..1848413 100644 --- a/Backend/Embeds.py +++ b/Backend/Embeds.py @@ -8,6 +8,7 @@ from urllib.parse import quote from Core import ResolvePath from Explorer import ClassifyFile, GetDirectoryNoteInfo +from Groups import GuestGroupName from Locks import IsPathAccessible from Portals import ResolvePortals from Preview import GetMimeType, ReadTextFile, TextFileTypes @@ -33,7 +34,7 @@ def _StripMarkdownForExcerpt(Text: str) -> str: def _DescribeTextFile(RootName: str, RelativePath: str, FilePath: Path, Fallback: str) -> str: try: Content = ReadTextFile(FilePath) - Resolved = ResolvePortals(RootName, RelativePath, Content, "", []) + Resolved = ResolvePortals(RootName, RelativePath, Content, "", [GuestGroupName]) Excerpt = _StripMarkdownForExcerpt(Resolved) return Excerpt or Fallback except (OSError, UnicodeDecodeError): @@ -45,7 +46,7 @@ def _MetaTag(Property: str, Content: str) -> str: def BuildOgTags(RootName: str, RelativePath: str, IsDirectoryUrl: bool, BaseUrl: str) -> str | None: - if not IsPathAccessible(RootName, RelativePath, "", []): + if not IsPathAccessible(RootName, RelativePath, "", [GuestGroupName]): return None try: diff --git a/Backend/Groups.py b/Backend/Groups.py index 19bc62e..34d6790 100644 --- a/Backend/Groups.py +++ b/Backend/Groups.py @@ -10,6 +10,8 @@ from Settings import GetDataDirectory AdminGroupName = "Admin" +GuestGroupName = "Guest" +BuiltinGroupNames = (AdminGroupName, GuestGroupName) def GetGroupsPath(): @@ -25,7 +27,7 @@ def LoadGroups() -> dict[str, list[str]]: SeededAdmins = [ Username for Username, Data in LoadUsers().items() if Data.get("IsAdmin", False) ] - Groups = {AdminGroupName: SeededAdmins} + Groups = {AdminGroupName: SeededAdmins, GuestGroupName: []} SaveGroups(Groups) return Groups @@ -33,9 +35,14 @@ def LoadGroups() -> dict[str, list[str]]: Loaded = tomllib.load(GroupsFile) Groups = Loaded.get("Groups", {}) + Changed = False - if AdminGroupName not in Groups: - Groups[AdminGroupName] = [] + for BuiltinName in BuiltinGroupNames: + if BuiltinName not in Groups: + Groups[BuiltinName] = [] + Changed = True + + if Changed: SaveGroups(Groups) return Groups @@ -69,8 +76,8 @@ def CreateGroup(Name: str) -> None: def DeleteGroup(Name: str) -> None: - if Name == AdminGroupName: - raise ValueError("The Admin group can't be deleted.") + if Name in BuiltinGroupNames: + raise ValueError(f"The {Name} group can't be deleted.") Groups = LoadGroups() diff --git a/Backend/Main.py b/Backend/Main.py index 2827329..6513892 100644 --- a/Backend/Main.py +++ b/Backend/Main.py @@ -13,7 +13,14 @@ from fastapi import Depends, FastAPI, File, HTTPException, Request, Response, Up from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, StreamingResponse -from Auth import CreateSession, DeleteSession, GetCurrentUser, RequireAdmin, SessionCookieName +from Auth import ( + CreateSession, + DeleteSession, + GetCurrentUser, + GetCurrentUserOrGuest, + RequireAdmin, + SessionCookieName, +) from BookReader import ReadBookForFile from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave from Core import ResolvePath @@ -31,6 +38,7 @@ from Explorer import ( from Groups import ( CreateGroup, DeleteGroup, + GuestGroupName, ListGroups, RenameUserInGroups, SetGroupMembers, @@ -46,6 +54,7 @@ from Locks import ( from Portals import ResolvePortals from Preview import GetMimeType, ReadTextFile, StreamBinaryFile, TextFileTypes from Settings import ( + DefaultSettings, DeleteTheme, ListThemeNames, LoadSettings, @@ -196,7 +205,7 @@ async def PostProfileAvatarEndpoint( @Application.get("/api/profile/avatar") -def GetProfileAvatarEndpoint(Username: str, CurrentUser: dict = Depends(GetCurrentUser)): +def GetProfileAvatarEndpoint(Username: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)): AvatarPath = GetAvatarPath(Username) if AvatarPath is None: @@ -347,18 +356,20 @@ def GetLocksEndpoint(CurrentUser: dict = Depends(RequireAdmin)) -> list[dict]: @Application.get("/api/roots") -def GetRootsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]: +def GetRootsEndpoint(CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> list[dict]: return ListRoots() @Application.get("/api/tree") def GetTreeEndpoint( - Root: str, Path: str = "", CurrentUser: dict = Depends(GetCurrentUser) + Root: str, Path: str = "", CurrentUser: dict = Depends(GetCurrentUserOrGuest) ) -> list[dict]: RequirePathAccessible(Root, Path, CurrentUser) try: - UserSettings = LoadSettings(CurrentUser["Username"]) + UserSettings = ( + DefaultSettings if CurrentUser["Username"] is None else LoadSettings(CurrentUser["Username"]) + ) ShowHidden = UserSettings.get("ShowHiddenFolders", False) ShowSvFiles = UserSettings.get("ShowSvFiles", False) Entries = ListDirectory(Root, Path, ShowHidden, ShowSvFiles) @@ -471,7 +482,7 @@ def DeleteTreeEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurr @Application.get("/api/file") def GetFileEndpoint( - Root: str, Path: str, request: Request, CurrentUser: dict = Depends(GetCurrentUser) + Root: str, Path: str, request: Request, CurrentUser: dict = Depends(GetCurrentUserOrGuest) ): RequirePathAccessible(Root, Path, CurrentUser) @@ -493,7 +504,7 @@ def GetFileEndpoint( @Application.get("/api/file/resolved") def GetResolvedFileEndpoint( - Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser) + Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest) ) -> dict: RequirePathAccessible(Root, Path, CurrentUser) @@ -548,7 +559,7 @@ async def PutFileEndpoint( @Application.get("/api/blame") -def GetBlameEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: +def GetBlameEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> dict: RequirePathAccessible(Root, Path, CurrentUser) try: @@ -588,7 +599,7 @@ def GetBlameEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurren @Application.get("/api/raw/{RootName}/{FilePath:path}") def GetRawFileEndpoint( - RootName: str, FilePath: str, CurrentUser: dict = Depends(GetCurrentUser) + RootName: str, FilePath: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest) ): RequirePathAccessible(RootName, FilePath, CurrentUser) @@ -611,7 +622,7 @@ def GetRawFileEndpoint( @Application.get("/api/book") -def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: +def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> dict: RequirePathAccessible(Root, Path, CurrentUser) try: @@ -624,7 +635,10 @@ def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrent @Application.get("/api/settings") -def GetSettingsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> dict: +def GetSettingsEndpoint(CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> dict: + if CurrentUser["Username"] is None: + return dict(DefaultSettings) + return LoadSettings(CurrentUser["Username"]) @@ -639,7 +653,7 @@ def GetThemesEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[str]: @Application.get("/api/theme") -def GetThemeEndpoint(Name: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: +def GetThemeEndpoint(Name: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> dict: try: return LoadTheme(Name) except FileNotFoundError as Error: @@ -678,7 +692,7 @@ def DeleteThemeEndpoint(Name: str, CurrentUser: dict = Depends(GetCurrentUser)) @Application.get("/public/media/{RootName}/{FilePath:path}") def GetPublicMediaEndpoint(RootName: str, FilePath: str, request: Request): - if not IsPathAccessible(RootName, FilePath, "", []): + if not IsPathAccessible(RootName, FilePath, "", [GuestGroupName]): raise HTTPException(status_code=404, detail="Not found.") try: diff --git a/Frontend/src/App.jsx b/Frontend/src/App.jsx index 1d8b69a..730bb83 100644 --- a/Frontend/src/App.jsx +++ b/Frontend/src/App.jsx @@ -53,6 +53,8 @@ function ParseLocationPath(Pathname) { return { RootName: RootName || null, Path: RestSegments.join("/"), IsDirectory }; } +const GuestUser = { Username: null, IsAdmin: false, IsGuest: true, Colour: "Purple" }; + export default function App() { const [AuthState, SetAuthState] = useState("Loading"); const [CurrentUser, SetCurrentUser] = useState(null); @@ -84,8 +86,10 @@ export default function App() { }); }, []); + const IsUsable = AuthState === "LoggedIn" || AuthState === "Guest"; + useEffect(() => { - if (AuthState !== "LoggedIn") return; + if (!IsUsable) return; FetchRoots().then((Fetched) => { SetRoots(Fetched); @@ -100,7 +104,7 @@ export default function App() { }, [AuthState]); useEffect(() => { - if (AuthState !== "LoggedIn" || !ActiveRoot) return; + if (!IsUsable || !ActiveRoot) return; const Url = BuildLocationPath(ActiveRoot, ViewPath, ViewIsDirectory); if (window.location.pathname !== Url) { @@ -109,7 +113,7 @@ export default function App() { }, [AuthState, ActiveRoot, ViewPath, ViewIsDirectory]); useEffect(() => { - if (AuthState !== "LoggedIn") return; + if (!IsUsable) return; function HandlePopState() { RestoreFromLocation(Roots); @@ -174,6 +178,16 @@ export default function App() { SetAuthState("LoggedOut"); } + function HandleContinueAsGuest() { + SetCurrentUser(GuestUser); + SetAuthState("Guest"); + } + + function HandleRequestLogin() { + SetCurrentUser(null); + SetAuthState("LoggedOut"); + } + function HandleProfileUpdated(UpdatedUser) { SetCurrentUser((Existing) => ({ ...Existing, ...UpdatedUser })); } @@ -272,9 +286,16 @@ export default function App() { } if (AuthState === "LoggedOut") { - return ; + return ( + + ); } + const IsGuest = AuthState === "Guest"; const ShowTopBar = (Settings && Settings.ShowBreadcrumbs) || Roots.length > 1; const ConfirmBeforeDelete = Settings ? Settings.ConfirmBeforeDelete : true; @@ -286,6 +307,7 @@ export default function App() { SelectedFile={ViewIsDirectory ? null : ViewPath} CurrentDirectoryPath={CurrentDirectoryPath} CurrentUser={CurrentUser} + IsGuest={IsGuest} OnSelectFile={HandleSelectFile} OnSelectDirectory={OpenDirectoryNote} OnNavigateDirectory={HandleNavigateDirectory} @@ -293,6 +315,7 @@ export default function App() { OnEntryRenamed={HandleEntryRenamed} OnEntryDeleted={HandleEntryDeleted} OnOpenSettings={() => SetIsSettingsOpen(true)} + OnRequestLogin={HandleRequestLogin} OnEntryCreated={RefreshSidebar} UseHyphenBullets={Settings ? Settings.UseHyphenBullets : true} UseAsciiFolderIcons={Settings ? Settings.UseAsciiFolderIcons : true} @@ -340,6 +363,7 @@ export default function App() { UseHyphenBullets={Settings ? Settings.UseHyphenBullets : true} UseAsciiFolderIcons={Settings ? Settings.UseAsciiFolderIcons : true} ConfirmBeforeDelete={ConfirmBeforeDelete} + IsGuest={IsGuest} /> ) : ( )} {!ViewIsDirectory && !ViewNoteFilePath && ( diff --git a/Frontend/src/Components/DirectoryPane.jsx b/Frontend/src/Components/DirectoryPane.jsx index 5e9fb1c..365b4c8 100644 --- a/Frontend/src/Components/DirectoryPane.jsx +++ b/Frontend/src/Components/DirectoryPane.jsx @@ -22,6 +22,7 @@ export default function DirectoryPane({ UseHyphenBullets, UseAsciiFolderIcons, ConfirmBeforeDelete, + IsGuest, }) { const [Entries, SetEntries] = useState([]); const [LoadError, SetLoadError] = useState(null); @@ -139,10 +140,12 @@ export default function DirectoryPane({ return (
-
- - -
+ {!IsGuest && ( +
+ + +
+ )} {LoadError &&
{LoadError}
}
{Entries.map((Entry) => ( @@ -159,29 +162,31 @@ export default function DirectoryPane({ : GetFileIcon(UseHyphenBullets)} {Entry.Name} - - - - - + {!IsGuest && ( + + + + + + )}
))} {Entries.length === 0 && !LoadError && ( diff --git a/Frontend/src/Components/LoginScreen.jsx b/Frontend/src/Components/LoginScreen.jsx index 75538f3..1c3dd6a 100644 --- a/Frontend/src/Components/LoginScreen.jsx +++ b/Frontend/src/Components/LoginScreen.jsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { Bootstrap, Login } from "../Api.js"; -export default function LoginScreen({ IsBootstrap, OnAuthenticated }) { +export default function LoginScreen({ IsBootstrap, OnAuthenticated, OnContinueAsGuest }) { const [Username, SetUsername] = useState(""); const [Password, SetPassword] = useState(""); const [ConfirmPassword, SetConfirmPassword] = useState(""); @@ -74,6 +74,11 @@ export default function LoginScreen({ IsBootstrap, OnAuthenticated }) { + {!IsBootstrap && OnContinueAsGuest && ( + + )}
); diff --git a/Frontend/src/Components/PreviewPane.jsx b/Frontend/src/Components/PreviewPane.jsx index a4da8f0..8964ca9 100644 --- a/Frontend/src/Components/PreviewPane.jsx +++ b/Frontend/src/Components/PreviewPane.jsx @@ -170,6 +170,7 @@ export default function PreviewPane({ ShowGitBlame, ShowBlameAvatars, StickyToolbar, + IsGuest, }) { const [Content, SetContent] = useState(""); const [ResolvedContent, SetResolvedContent] = useState(""); @@ -259,7 +260,7 @@ export default function PreviewPane({ return (
- + {!IsGuest && }