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
{ResolvedContent}