Added guests.

This commit is contained in:
sarah committed 2026-09-21 21:09:25 +02:00
1 parent 6743dd7626
commit 9736a55e00
10 files changed
+156 -40

No files matched your search

+39 -8
View File
@@ -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:
+3 -2
View File
@@ -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:
+12 -5
View File
@@ -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()
+27 -13
View File
@@ -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:
+29 -4
View File
@@ -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 <LoginScreen IsBootstrap={false} OnAuthenticated={HandleAuthenticated} />;
return (
<LoginScreen
IsBootstrap={false}
OnAuthenticated={HandleAuthenticated}
OnContinueAsGuest={HandleContinueAsGuest}
/>
);
}
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}
/>
) : (
<PreviewPane
@@ -350,6 +374,7 @@ export default function App() {
ShowGitBlame={Settings ? Settings.ShowGitBlame : false}
ShowBlameAvatars={Settings ? Settings.ShowBlameAvatars : false}
StickyToolbar={Settings ? Settings.StickyToolbar : true}
IsGuest={IsGuest}
/>
)}
{!ViewIsDirectory && !ViewNoteFilePath && (
@@ -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 (
<div className="DirectoryPane">
{!IsGuest && (
<div className="PreviewToolbar">
<button onClick={OpenCreateFileModal}>New File</button>
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
</div>
)}
{LoadError && <div className="SidebarError">{LoadError}</div>}
<div className="DirectoryList">
{Entries.map((Entry) => (
@@ -159,6 +162,7 @@ export default function DirectoryPane({
: GetFileIcon(UseHyphenBullets)}
</span>
<span className="TreeLabel">{Entry.Name}</span>
{!IsGuest && (
<span className="TreeRowActions">
<button
className="TreeRowActionButton"
@@ -182,6 +186,7 @@ export default function DirectoryPane({
✕
</button>
</span>
)}
</div>
))}
{Entries.length === 0 && !LoadError && (
+6 -1
View File
@@ -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 }) {
<button type="submit" disabled={IsSubmitting}>
{IsSubmitting ? "..." : IsBootstrap ? "Create account & log in" : "Log in"}
</button>
{!IsBootstrap && OnContinueAsGuest && (
<button type="button" className="LoginGuestButton" onClick={OnContinueAsGuest}>
Continue as Guest
</button>
)}
</form>
</div>
);
+7 -3
View File
@@ -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 (
<div className="PreviewFrame">
<div className={ToolbarClass}>
<button onClick={() => SetIsEditing(true)}>Edit</button>
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
</div>
<iframe title={FilePath} sandbox="" src={GetRawFileUrl(RootName, FilePath)} />
</div>
@@ -270,7 +271,7 @@ export default function PreviewPane({
return (
<div className="PreviewMarkdown">
<div className={ToolbarClass}>
<button onClick={() => SetIsEditing(true)}>Edit</button>
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
</div>
<div
className="MarkdownBody"
@@ -292,7 +293,7 @@ export default function PreviewPane({
return (
<div className="PreviewText">
<div className={ToolbarClass}>
<button onClick={() => SetIsEditing(true)}>Edit</button>
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
</div>
<pre className="TextBody">{ResolvedContent}</pre>
</div>
@@ -306,14 +307,17 @@ export default function PreviewPane({
{(FileType === "markdown" || FileType === "text" || FileType === "html") && (
<button onClick={() => SetIsEditing(false)}>View</button>
)}
{!IsGuest && (
<button onClick={HandleSave} disabled={IsSaving}>
{IsSaving ? "Saving…" : "Save"}
</button>
)}
{SaveError && <span className="SaveErrorText">{SaveError}</span>}
</div>
<CodeMirror
value={Content}
height="100%"
editable={!IsGuest}
extensions={[
...GetLanguageExtension(FileType),
...(ShowGitBlame ? [BuildBlameGutter(BlameMap, ShowBlameAvatars)] : []),
+23 -4
View File
@@ -30,6 +30,7 @@ function TreeNode({
UseAsciiFolderIcons,
NavigateIntoDirectories,
ConfirmBeforeDelete,
IsGuest,
}) {
const [IsExpanded, SetIsExpanded] = useState(false);
const [Children, SetChildren] = useState(null);
@@ -134,6 +135,7 @@ function TreeNode({
: GetFileIcon(UseHyphenBullets)}
</span>
<span className="TreeLabel">{Entry.Name}</span>
{!IsGuest && (
<span className="TreeRowActions">
<button className="TreeRowActionButton" onClick={HandleLockClick} title="Access control">
🔒
@@ -145,6 +147,7 @@ function TreeNode({
✕
</button>
</span>
)}
</div>
{IsExpanded && Children && (
<div className="TreeChildren">
@@ -167,6 +170,7 @@ function TreeNode({
UseAsciiFolderIcons={UseAsciiFolderIcons}
NavigateIntoDirectories={NavigateIntoDirectories}
ConfirmBeforeDelete={ConfirmBeforeDelete}
IsGuest={IsGuest}
/>
))}
</div>
@@ -175,7 +179,7 @@ function TreeNode({
);
}
function UserBadge({ CurrentUser, OnOpenSettings }) {
function UserBadge({ CurrentUser, OnOpenSettings, OnRequestLogin }) {
const [AvatarFailed, SetAvatarFailed] = useState(false);
useEffect(() => {
@@ -184,6 +188,16 @@ function UserBadge({ CurrentUser, OnOpenSettings }) {
if (!CurrentUser) return null;
if (CurrentUser.IsGuest) {
return (
<span className="UserBadge" onClick={OnRequestLogin} title="Browsing as Guest - click to log in">
<span className="UserBadgeInitial" style={{ background: `var(--${CurrentUser.Colour || "Purple"})` }}>
G
</span>
</span>
);
}
const AvatarUrl = CurrentUser.AvatarNonce
? `${GetAvatarUrl(CurrentUser.Username)}&v=${CurrentUser.AvatarNonce}`
: GetAvatarUrl(CurrentUser.Username);
@@ -221,12 +235,14 @@ export default function Sidebar({
OnEntryRenamed,
OnEntryDeleted,
OnOpenSettings,
OnRequestLogin,
OnEntryCreated,
UseHyphenBullets,
UseAsciiFolderIcons,
NavigateIntoDirectories,
NewEntryButtonsAtTop,
ConfirmBeforeDelete,
IsGuest,
}) {
const [RootEntries, SetRootEntries] = useState([]);
const [LoadError, SetLoadError] = useState(null);
@@ -299,13 +315,15 @@ export default function Sidebar({
SaraView
</span>
<span className="SidebarHeaderRight">
<UserBadge CurrentUser={CurrentUser} OnOpenSettings={OnOpenSettings} />
<UserBadge CurrentUser={CurrentUser} OnOpenSettings={OnOpenSettings} OnRequestLogin={OnRequestLogin} />
{!IsGuest && (
<button className="SettingsButton" onClick={OnOpenSettings}>
⚙
</button>
)}
</span>
</div>
{NewEntryButtonsAtTop && (
{NewEntryButtonsAtTop && !IsGuest && (
<div className="SidebarNewEntry SidebarNewEntryTop">
<button onClick={OpenCreateFileModal}>New File</button>
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
@@ -332,10 +350,11 @@ export default function Sidebar({
UseAsciiFolderIcons={UseAsciiFolderIcons}
NavigateIntoDirectories={NavigateIntoDirectories}
ConfirmBeforeDelete={ConfirmBeforeDelete}
IsGuest={IsGuest}
/>
))}
</div>
{!NewEntryButtonsAtTop && (
{!NewEntryButtonsAtTop && !IsGuest && (
<div className="SidebarNewEntry">
<button onClick={OpenCreateFileModal}>New File</button>
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
+5
View File
@@ -897,3 +897,8 @@ img,
.LoginBox button:hover {
background: var(--Border);
}
.LoginGuestButton {
border-color: var(--Border) !important;
color: var(--TextMuted) !important;
}