mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-15 02:43:17 +02:00
Added groups and restricted access.
This commit is contained in:
1 parent
8aa223bc7b
commit
9a5b9530a3
17 files changed
+1406
-66
No files matched your search
+247
-6
@@ -15,10 +15,25 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from Auth import CreateSession, DeleteSession, GetCurrentUser, RequireAdmin, SessionCookieName
|
||||
from BookReader import ReadBookForFile
|
||||
from Blame import LoadBlame, UpdateBlameOnSave
|
||||
from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave
|
||||
from Core import ResolvePath
|
||||
from Editor import WriteTextFile
|
||||
from Explorer import ClassifyFile, CreateEntry, DeleteEntry, ListDirectory, ListRoots, MoveEntry
|
||||
from Groups import (
|
||||
CreateGroup,
|
||||
DeleteGroup,
|
||||
ListGroups,
|
||||
RenameUserInGroups,
|
||||
SetGroupMembers,
|
||||
)
|
||||
from Locks import (
|
||||
GetLockForPath,
|
||||
IsPathAccessible,
|
||||
LoadLocks,
|
||||
RemoveLock,
|
||||
RequirePathAccessible,
|
||||
SetLock,
|
||||
)
|
||||
from Preview import GetFileSize, GetMimeType, ParseRangeHeader, ReadTextFile, TextFileTypes
|
||||
from Settings import (
|
||||
DeleteTheme,
|
||||
@@ -26,18 +41,22 @@ from Settings import (
|
||||
LoadSettings,
|
||||
LoadTheme,
|
||||
PaletteRoles,
|
||||
RenameUserSettings,
|
||||
SaveSettings,
|
||||
SaveTheme,
|
||||
)
|
||||
from Users import (
|
||||
AnyUsersExist,
|
||||
ChangePassword,
|
||||
CreateUser,
|
||||
GetAvatarPath,
|
||||
ListUsers,
|
||||
RenameUser,
|
||||
RenameUserAvatar,
|
||||
SaveAvatar,
|
||||
UpdateUserColour,
|
||||
UserColourOptions,
|
||||
VerifyLogin,
|
||||
SaveAvatar,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -122,7 +141,7 @@ class CreateUserRequest(BaseModel):
|
||||
|
||||
|
||||
@Application.get("/api/users")
|
||||
def GetUsersEndpoint(CurrentUser: dict = Depends(RequireAdmin)) -> list[dict]:
|
||||
def GetUsersEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]:
|
||||
return ListUsers()
|
||||
|
||||
|
||||
@@ -136,7 +155,7 @@ def PostUsersEndpoint(Body: CreateUserRequest, CurrentUser: dict = Depends(Requi
|
||||
return {"Created": True}
|
||||
|
||||
|
||||
# --- Profile (colour + avatar) --------------------------------------------
|
||||
# --- Profile (colour, avatar, username, password) --------------------------
|
||||
|
||||
|
||||
@Application.get("/api/profile/colours")
|
||||
@@ -186,6 +205,163 @@ def GetProfileAvatarEndpoint(Username: str, CurrentUser: dict = Depends(GetCurre
|
||||
return StreamingResponse(iter([AvatarPath.read_bytes()]), media_type=MimeType)
|
||||
|
||||
|
||||
class ChangeUsernameRequest(BaseModel):
|
||||
NewUsername: str
|
||||
|
||||
|
||||
@Application.put("/api/profile/username")
|
||||
def PutProfileUsernameEndpoint(
|
||||
Body: ChangeUsernameRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
CurrentUser: dict = Depends(GetCurrentUser),
|
||||
) -> dict:
|
||||
OldUsername = CurrentUser["Username"]
|
||||
NewUsername = Body.NewUsername.strip()
|
||||
|
||||
try:
|
||||
RenameUser(OldUsername, NewUsername)
|
||||
except ValueError as Error:
|
||||
raise HTTPException(status_code=400, detail=str(Error))
|
||||
|
||||
if NewUsername != OldUsername:
|
||||
RenameUserSettings(OldUsername, NewUsername)
|
||||
RenameUserAvatar(OldUsername, NewUsername)
|
||||
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)
|
||||
|
||||
NewToken = CreateSession(NewUsername)
|
||||
response.set_cookie(
|
||||
key=SessionCookieName,
|
||||
value=NewToken,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
)
|
||||
|
||||
return {"Username": NewUsername, "IsAdmin": CurrentUser["IsAdmin"], "Colour": CurrentUser["Colour"]}
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
CurrentPassword: str
|
||||
NewPassword: str
|
||||
|
||||
|
||||
@Application.put("/api/profile/password")
|
||||
def PutProfilePasswordEndpoint(
|
||||
Body: ChangePasswordRequest, CurrentUser: dict = Depends(GetCurrentUser)
|
||||
) -> dict:
|
||||
try:
|
||||
ChangePassword(CurrentUser["Username"], Body.CurrentPassword, Body.NewPassword)
|
||||
except ValueError as Error:
|
||||
raise HTTPException(status_code=400, detail=str(Error))
|
||||
|
||||
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()
|
||||
|
||||
|
||||
class CreateGroupRequest(BaseModel):
|
||||
Name: str
|
||||
|
||||
|
||||
@Application.post("/api/groups")
|
||||
def PostGroupsEndpoint(Body: CreateGroupRequest, CurrentUser: dict = Depends(RequireAdmin)) -> dict:
|
||||
try:
|
||||
CreateGroup(Body.Name)
|
||||
except ValueError as Error:
|
||||
raise HTTPException(status_code=400, detail=str(Error))
|
||||
|
||||
return {"Created": True}
|
||||
|
||||
|
||||
@Application.delete("/api/groups")
|
||||
def DeleteGroupsEndpoint(Name: str, CurrentUser: dict = Depends(RequireAdmin)) -> dict:
|
||||
try:
|
||||
DeleteGroup(Name)
|
||||
except ValueError as Error:
|
||||
raise HTTPException(status_code=400, detail=str(Error))
|
||||
|
||||
return {"Deleted": True}
|
||||
|
||||
|
||||
class SetGroupMembersRequest(BaseModel):
|
||||
Members: list[str]
|
||||
|
||||
|
||||
@Application.put("/api/groups/members")
|
||||
def PutGroupMembersEndpoint(
|
||||
Name: str, Body: SetGroupMembersRequest, CurrentUser: dict = Depends(RequireAdmin)
|
||||
) -> dict:
|
||||
try:
|
||||
SetGroupMembers(Name, Body.Members)
|
||||
except ValueError as Error:
|
||||
raise HTTPException(status_code=400, detail=str(Error))
|
||||
|
||||
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 additionally bypass every lock (see Locks.IsPathAccessible).
|
||||
|
||||
|
||||
@Application.get("/api/lock")
|
||||
def GetLockEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
return GetLockForPath(Root, Path) or {}
|
||||
|
||||
|
||||
class SetLockRequest(BaseModel):
|
||||
Users: list[str] = []
|
||||
Groups: list[str] = []
|
||||
|
||||
|
||||
@Application.put("/api/lock")
|
||||
def PutLockEndpoint(
|
||||
Root: str, Path: str, Body: SetLockRequest, CurrentUser: dict = Depends(GetCurrentUser)
|
||||
) -> dict:
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
|
||||
try:
|
||||
SetLock(Root, Path, Body.Users, Body.Groups)
|
||||
except ValueError as Error:
|
||||
raise HTTPException(status_code=400, detail=str(Error))
|
||||
|
||||
return {"Locked": True}
|
||||
|
||||
|
||||
@Application.delete("/api/lock")
|
||||
def DeleteLockEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
RemoveLock(Root, Path)
|
||||
return {"Unlocked": True}
|
||||
|
||||
|
||||
@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 ----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -198,12 +374,32 @@ def GetRootsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]:
|
||||
def GetTreeEndpoint(
|
||||
Root: str, Path: str = "", CurrentUser: dict = Depends(GetCurrentUser)
|
||||
) -> list[dict]:
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
|
||||
try:
|
||||
ShowHidden = LoadSettings(CurrentUser["Username"]).get("ShowHiddenFolders", False)
|
||||
return ListDirectory(Root, Path, ShowHidden)
|
||||
Entries = ListDirectory(Root, Path, ShowHidden)
|
||||
except (ValueError, NotADirectoryError, FileNotFoundError) as Error:
|
||||
raise HTTPException(status_code=400, detail=str(Error))
|
||||
|
||||
Username = CurrentUser["Username"]
|
||||
UserGroups = CurrentUser.get("Groups", [])
|
||||
IsAdmin = CurrentUser.get("IsAdmin", False)
|
||||
|
||||
# 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
|
||||
if IsPathAccessible(
|
||||
Root,
|
||||
f"{Path}/{Entry['Name']}" if Path else Entry["Name"],
|
||||
Username,
|
||||
UserGroups,
|
||||
IsAdmin,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class CreateEntryRequest(BaseModel):
|
||||
IsDirectory: bool
|
||||
@@ -213,6 +409,8 @@ class CreateEntryRequest(BaseModel):
|
||||
def PostTreeEndpoint(
|
||||
Root: str, Path: str, Body: CreateEntryRequest, CurrentUser: dict = Depends(GetCurrentUser)
|
||||
) -> dict:
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
|
||||
try:
|
||||
CreateEntry(Root, Path, Body.IsDirectory)
|
||||
except ValueError as Error:
|
||||
@@ -234,6 +432,8 @@ class MoveEntryRequest(BaseModel):
|
||||
def PatchTreeEndpoint(
|
||||
Root: str, Path: str, Body: MoveEntryRequest, CurrentUser: dict = Depends(GetCurrentUser)
|
||||
) -> dict:
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
|
||||
if Body.DestinationPath:
|
||||
Destination = Body.DestinationPath
|
||||
elif Body.NewName:
|
||||
@@ -242,6 +442,8 @@ def PatchTreeEndpoint(
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Provide NewName or DestinationPath.")
|
||||
|
||||
RequirePathAccessible(Root, Destination, CurrentUser)
|
||||
|
||||
try:
|
||||
MoveEntry(Root, Path, Destination)
|
||||
except ValueError as Error:
|
||||
@@ -258,6 +460,8 @@ def PatchTreeEndpoint(
|
||||
|
||||
@Application.delete("/api/tree")
|
||||
def DeleteTreeEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
|
||||
try:
|
||||
DeleteEntry(Root, Path)
|
||||
except ValueError as Error:
|
||||
@@ -274,6 +478,8 @@ def DeleteTreeEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurr
|
||||
def GetFileEndpoint(
|
||||
Root: str, Path: str, request: Request, CurrentUser: dict = Depends(GetCurrentUser)
|
||||
):
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
|
||||
try:
|
||||
FilePath = ResolvePath(Root, Path)
|
||||
except ValueError as Error:
|
||||
@@ -322,6 +528,8 @@ def GetFileEndpoint(
|
||||
async def PutFileEndpoint(
|
||||
Root: str, Path: str, request: Request, CurrentUser: dict = Depends(GetCurrentUser)
|
||||
):
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
|
||||
try:
|
||||
FilePath = ResolvePath(Root, Path)
|
||||
except ValueError as Error:
|
||||
@@ -348,12 +556,41 @@ async def PutFileEndpoint(
|
||||
|
||||
@Application.get("/api/blame")
|
||||
def GetBlameEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
|
||||
try:
|
||||
ResolvePath(Root, Path)
|
||||
except ValueError as Error:
|
||||
raise HTTPException(status_code=400, detail=str(Error))
|
||||
|
||||
return LoadBlame(Root, Path)
|
||||
RawBlame = LoadBlame(Root, Path)
|
||||
AllUsers = {User["Username"]: User for User in ListUsers()}
|
||||
ViewerUsername = CurrentUser["Username"]
|
||||
Enriched: dict[str, dict] = {}
|
||||
|
||||
for LineNumber, Entry in RawBlame.items():
|
||||
AuthorUsername = Entry.get("Username")
|
||||
Timestamp = Entry.get("Timestamp")
|
||||
|
||||
if AuthorUsername == ViewerUsername:
|
||||
IsVisible = True
|
||||
elif AuthorUsername in AllUsers:
|
||||
IsVisible = LoadSettings(AuthorUsername).get("VisibleInBlame", True)
|
||||
else:
|
||||
IsVisible = True
|
||||
|
||||
if IsVisible:
|
||||
AuthorRecord = AllUsers.get(AuthorUsername, {})
|
||||
Enriched[LineNumber] = {
|
||||
"Username": AuthorUsername,
|
||||
"Timestamp": Timestamp,
|
||||
"Colour": AuthorRecord.get("Colour", "Purple"),
|
||||
"HasAvatar": AuthorUsername is not None and GetAvatarPath(AuthorUsername) is not None,
|
||||
}
|
||||
else:
|
||||
Enriched[LineNumber] = {"Username": None, "Hidden": True, "Timestamp": Timestamp}
|
||||
|
||||
return Enriched
|
||||
|
||||
|
||||
@Application.get("/api/raw/{RootName}/{FilePath:path}")
|
||||
@@ -365,6 +602,8 @@ def GetRawFileEndpoint(
|
||||
and scripts INSIDE a previewed HTML document resolve correctly against
|
||||
sibling files in the same directory.
|
||||
"""
|
||||
RequirePathAccessible(RootName, FilePath, CurrentUser)
|
||||
|
||||
try:
|
||||
ResolvedPath = ResolvePath(RootName, FilePath)
|
||||
except ValueError as Error:
|
||||
@@ -385,6 +624,8 @@ def GetRawFileEndpoint(
|
||||
|
||||
@Application.get("/api/book")
|
||||
def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
|
||||
try:
|
||||
FilePath = ResolvePath(Root, Path)
|
||||
except ValueError as Error:
|
||||
|
||||
Reference in new issue
Block a user