Files
SaraView/Backend/Main.py
T
2026-10-13 12:22:49 +02:00

958 lines
29 KiB
Python

from __future__ import annotations
import json
import mimetypes
import os
import sys
import tempfile
import zipfile
from pathlib import Path
from urllib.parse import quote
ApplicationDirectory = Path(__file__).resolve().parent
if str(ApplicationDirectory) not in sys.path:
sys.path.insert(0, str(ApplicationDirectory))
from fastapi import Depends, FastAPI, File, HTTPException, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, StreamingResponse
from starlette.background import BackgroundTask
from Auth import (
CreateSession,
DeleteSession,
GetCurrentUser,
GetCurrentUserOrGuest,
RequireAdmin,
SessionCookieName,
)
from BookReader import ReadBookForFile
from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave
from Core import NormalizeRelativePath, ResolvePath
from Diagrams import DiagramError, DiagramsUnavailable, RenderDiagram
from Embeds import BuildOgTags
from Editor import WriteTextFile
from Explorer import (
ClassifyFile,
CreateEntry,
DeleteEntry,
EnsureDirectoryNote,
FinishChunkedUpload,
ListDirectory,
ListRoots,
MoveEntry,
SaveUploadChunk,
SaveUploadedFile,
)
from Groups import (
CreateGroup,
DeleteGroup,
GuestGroupName,
ListGroups,
RenameUserInGroups,
SetGroupMembers,
)
from Locks import (
GetInheritedLock,
GetLockForPath,
IsPathAccessible,
LoadLocks,
RemoveLock,
RequirePathAccessible,
SetLock,
)
from Graph import BuildGraph
from Preview import GetMimeType, ReadTextFile, StreamBinaryFile, TextFileTypes
from Settings import (
DefaultSettings,
DeleteTheme,
ListThemeNames,
LoadSettings,
LoadTheme,
PaletteRoles,
RenameUserSettings,
SaveSettings,
SaveTheme,
)
from Users import (
AnyUsersExist,
ChangePassword,
CreateUser,
GetAvatarPath,
ListUsers,
RenameUser,
RenameUserAvatar,
SaveAvatar,
UpdateUserColour,
UserColourOptions,
VerifyLogin,
)
from VarFunctions import ResolvePortals
from pydantic import BaseModel
Application = FastAPI(title="SaraView")
Application.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True,
)
class LoginRequest(BaseModel):
Username: str
Password: str
@Application.get("/api/auth/status")
def AuthStatusEndpoint() -> dict:
return {"SetupComplete": AnyUsersExist()}
@Application.post("/api/auth/bootstrap")
def BootstrapEndpoint(Body: LoginRequest) -> dict:
if AnyUsersExist():
raise HTTPException(status_code=403, detail="Setup already completed.")
try:
CreateUser(Body.Username, Body.Password, True)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
return {"Created": True}
@Application.post("/api/auth/login")
def LoginEndpoint(Body: LoginRequest, response: Response) -> dict:
User = VerifyLogin(Body.Username, Body.Password)
if User is None:
raise HTTPException(status_code=401, detail="Invalid username or password.")
Token = CreateSession(Body.Username)
response.set_cookie(
key=SessionCookieName,
value=Token,
httponly=True,
samesite="lax",
max_age=60 * 60 * 24 * 30,
)
return User
@Application.post("/api/auth/logout")
def LogoutEndpoint(request: Request, response: Response) -> dict:
Token = request.cookies.get(SessionCookieName)
if Token:
DeleteSession(Token)
response.delete_cookie(SessionCookieName)
return {"LoggedOut": True}
@Application.get("/api/auth/me")
def MeEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
return CurrentUser
class CreateUserRequest(BaseModel):
Username: str
Password: str
IsAdmin: bool = False
Colour: str = "Purple"
@Application.get("/api/users")
def GetUsersEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]:
return ListUsers()
@Application.post("/api/users")
def PostUsersEndpoint(Body: CreateUserRequest, CurrentUser: dict = Depends(RequireAdmin)) -> dict:
try:
CreateUser(Body.Username, Body.Password, Body.IsAdmin, Body.Colour)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
return {"Created": True}
@Application.get("/api/profile/colours")
def GetProfileColourOptionsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[str]:
return UserColourOptions
class UpdateColourRequest(BaseModel):
Colour: str
@Application.put("/api/profile/colour")
def PutProfileColourEndpoint(
Body: UpdateColourRequest, CurrentUser: dict = Depends(GetCurrentUser)
) -> dict:
try:
UpdateUserColour(CurrentUser["Username"], Body.Colour)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
return {"Username": CurrentUser["Username"], "IsAdmin": CurrentUser["IsAdmin"], "Colour": Body.Colour}
@Application.post("/api/profile/avatar")
async def PostProfileAvatarEndpoint(
Avatar: UploadFile = File(...), CurrentUser: dict = Depends(GetCurrentUser)
) -> dict:
Extension = Path(Avatar.filename or "").suffix.lower() or ".png"
FileBytes = await Avatar.read()
try:
SaveAvatar(CurrentUser["Username"], FileBytes, Extension)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
return {"Saved": True}
@Application.get("/api/profile/avatar")
def GetProfileAvatarEndpoint(Username: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)):
AvatarPath = GetAvatarPath(Username)
if AvatarPath is None:
raise HTTPException(status_code=404, detail="No avatar set.")
MimeType = mimetypes.guess_type(str(AvatarPath))[0] or "application/octet-stream"
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)
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}
@Application.get("/api/groups")
def GetGroupsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]:
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}
@Application.get("/api/lock")
def GetLockEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
RequirePathAccessible(Root, Path, CurrentUser)
return GetLockForPath(Root, Path) or {}
@Application.get("/api/lock/inherited")
def GetInheritedLockEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
RequirePathAccessible(Root, Path, CurrentUser)
return GetInheritedLock(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]:
return LoadLocks()
@Application.get("/api/roots")
def GetRootsEndpoint(CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> list[dict]:
return ListRoots()
@Application.get("/api/tree")
def GetTreeEndpoint(
Root: str, Path: str = "", CurrentUser: dict = Depends(GetCurrentUserOrGuest)
) -> list[dict]:
RequirePathAccessible(Root, Path, CurrentUser)
try:
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)
except (ValueError, NotADirectoryError, FileNotFoundError) as Error:
raise HTTPException(status_code=400, detail=str(Error))
Username = CurrentUser["Username"]
UserGroups = CurrentUser.get("Groups", [])
return [
Entry
for Entry in Entries
if IsPathAccessible(
Root,
f"{Path}/{Entry['Name']}" if Path else Entry["Name"],
Username,
UserGroups,
)
]
@Application.get("/api/graph")
def GetGraphEndpoint(
Root: str, Path: str = "", CurrentUser: dict = Depends(GetCurrentUserOrGuest)
) -> dict:
"""Directories, files and the links between them under Path (see Graph.py)."""
RequirePathAccessible(Root, Path, CurrentUser)
UserSettings = DefaultSettings if CurrentUser["Username"] is None else LoadSettings(CurrentUser["Username"])
try:
return BuildGraph(
Root,
Path,
UserSettings.get("ShowHiddenFolders", False),
UserSettings.get("ShowSvFiles", False),
CurrentUser["Username"],
CurrentUser.get("Groups", []),
)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
@Application.post("/api/note")
def PostNoteEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
RequirePathAccessible(Root, Path, CurrentUser)
try:
DirectoryPath = ResolvePath(Root, Path)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
if not DirectoryPath.is_dir():
raise HTTPException(status_code=400, detail="Not a directory.")
NotePath = EnsureDirectoryNote(DirectoryPath)
return {"Name": NotePath.name, "FileType": ClassifyFile(NotePath)}
class CreateEntryRequest(BaseModel):
IsDirectory: bool
@Application.post("/api/tree")
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:
raise HTTPException(status_code=400, detail=str(Error))
except FileExistsError as Error:
raise HTTPException(status_code=409, detail=str(Error))
except OSError as Error:
raise HTTPException(status_code=500, detail=f"Could not create entry: {Error}")
return {"Created": True}
@Application.post("/api/upload")
async def PostUploadEndpoint(
Root: str,
Path: str,
Files: list[UploadFile] = File(...),
CurrentUser: dict = Depends(GetCurrentUser),
) -> dict:
RequirePathAccessible(Root, Path, CurrentUser)
SavedNames: list[str] = []
Errors: list[str] = []
for UploadedFile in Files:
DisplayName = UploadedFile.filename or "upload"
try:
FileBytes = await UploadedFile.read()
SavedName = SaveUploadedFile(Root, Path, DisplayName, FileBytes)
SavedNames.append(SavedName)
except (ValueError, FileExistsError, NotADirectoryError) as Error:
Errors.append(f"{DisplayName}: {Error}")
except OSError as Error:
Errors.append(f"{DisplayName}: Could not save file ({Error})")
if Errors and not SavedNames:
raise HTTPException(status_code=400, detail="; ".join(Errors))
return {"Saved": SavedNames, "Errors": Errors}
@Application.post("/api/upload/chunk")
async def PostUploadChunkEndpoint(
Root: str,
Path: str,
UploadId: str,
ChunkIndex: int,
TotalChunks: int,
FileName: str,
Chunk: UploadFile = File(...),
CurrentUser: dict = Depends(GetCurrentUser),
) -> dict:
RequirePathAccessible(Root, Path, CurrentUser)
try:
ChunkBytes = await Chunk.read()
SaveUploadChunk(UploadId, ChunkIndex, ChunkBytes)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
return {"Received": ChunkIndex}
@Application.post("/api/upload/complete")
def PostUploadCompleteEndpoint(
Root: str,
Path: str,
UploadId: str,
TotalChunks: int,
FileName: str,
CurrentUser: dict = Depends(GetCurrentUser),
) -> dict:
RequirePathAccessible(Root, Path, CurrentUser)
try:
SavedName = FinishChunkedUpload(Root, Path, UploadId, TotalChunks, FileName)
except (ValueError, FileExistsError, NotADirectoryError) as Error:
raise HTTPException(status_code=400, detail=str(Error))
except OSError as Error:
raise HTTPException(status_code=400, detail=f"Could not save file ({Error})")
return {"Saved": SavedName}
class MoveEntryRequest(BaseModel):
NewName: str | None = None
DestinationPath: str | None = None
@Application.patch("/api/tree")
def PatchTreeEndpoint(
Root: str, Path: str, Body: MoveEntryRequest, CurrentUser: dict = Depends(GetCurrentUser)
) -> dict:
RequirePathAccessible(Root, Path, CurrentUser)
ParentPath = Path.rsplit("/", 1)[0] if "/" in Path else ""
if Body.DestinationPath:
Destination = NormalizeRelativePath(ParentPath, Body.DestinationPath)
elif Body.NewName:
Destination = f"{ParentPath}/{Body.NewName}" if ParentPath else Body.NewName
else:
raise HTTPException(status_code=400, detail="Provide NewName or DestinationPath.")
RequirePathAccessible(Root, Destination, CurrentUser)
try:
MoveEntry(Root, Path, Destination)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
except FileNotFoundError as Error:
raise HTTPException(status_code=404, detail=str(Error))
except FileExistsError as Error:
raise HTTPException(status_code=409, detail=str(Error))
except OSError as Error:
raise HTTPException(status_code=500, detail=f"Could not move entry: {Error}")
return {"Moved": True, "NewPath": Destination}
@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:
raise HTTPException(status_code=400, detail=str(Error))
except FileNotFoundError as Error:
raise HTTPException(status_code=404, detail=str(Error))
except OSError as Error:
raise HTTPException(status_code=500, detail=f"Could not delete entry: {Error}")
return {"Deleted": True}
@Application.get("/api/file")
def GetFileEndpoint(
Root: str, Path: str, request: Request, CurrentUser: dict = Depends(GetCurrentUserOrGuest)
):
RequirePathAccessible(Root, Path, CurrentUser)
try:
FilePath = ResolvePath(Root, Path)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
if not FilePath.is_file():
raise HTTPException(status_code=404, detail="File not found.")
FileType = ClassifyFile(FilePath)
if FileType in TextFileTypes:
return PlainTextResponse(ReadTextFile(FilePath))
return StreamBinaryFile(FilePath, request.headers.get("range"))
@Application.get("/api/file/resolved")
def GetResolvedFileEndpoint(
Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)
) -> dict:
RequirePathAccessible(Root, Path, CurrentUser)
try:
FilePath = ResolvePath(Root, Path)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
if not FilePath.is_file():
raise HTTPException(status_code=404, detail="File not found.")
FileType = ClassifyFile(FilePath)
if FileType not in TextFileTypes:
raise HTTPException(status_code=400, detail="Not a text file.")
Content = ReadTextFile(FilePath)
Resolved = ResolvePortals(
Root, Path, Content, CurrentUser["Username"], CurrentUser.get("Groups", [])
)
return {"Content": Resolved}
@Application.put("/api/file")
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:
raise HTTPException(status_code=400, detail=str(Error))
OldContent = ""
if FilePath.is_file():
try:
OldContent = FilePath.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
OldContent = ""
Content = (await request.body()).decode("utf-8")
try:
WriteTextFile(FilePath, Content)
except OSError as Error:
raise HTTPException(status_code=500, detail=f"Could not write file: {Error}")
UpdateBlameOnSave(Root, Path, OldContent, Content, CurrentUser["Username"])
return {"Saved": True}
@Application.get("/api/blame")
def GetBlameEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> dict:
RequirePathAccessible(Root, Path, CurrentUser)
try:
ResolvePath(Root, Path)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
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}")
def GetRawFileEndpoint(
RootName: str, FilePath: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)
):
RequirePathAccessible(RootName, FilePath, CurrentUser)
try:
ResolvedPath = ResolvePath(RootName, FilePath)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
if not ResolvedPath.is_file():
raise HTTPException(status_code=404, detail="File not found.")
MimeType = mimetypes.guess_type(str(ResolvedPath))[0] or "application/octet-stream"
def IterateFile():
with ResolvedPath.open("rb") as SourceFile:
while Chunk := SourceFile.read(1024 * 1024):
yield Chunk
return StreamingResponse(IterateFile(), media_type=MimeType)
def _AddDirectoryToZip(
ZipHandle: zipfile.ZipFile,
RootName: str,
RelativePath: str,
Username: str,
UserGroups: list[str],
ArcPrefix: str,
) -> None:
try:
Entries = ListDirectory(RootName, RelativePath, False, False)
except (ValueError, NotADirectoryError, FileNotFoundError):
return
for Entry in Entries:
EntryRelativePath = f"{RelativePath}/{Entry['Name']}" if RelativePath else Entry["Name"]
if not IsPathAccessible(RootName, EntryRelativePath, Username, UserGroups):
continue
ArcName = f"{ArcPrefix}/{Entry['Name']}" if ArcPrefix else Entry["Name"]
if Entry["IsDirectory"]:
_AddDirectoryToZip(ZipHandle, RootName, EntryRelativePath, Username, UserGroups, ArcName)
else:
ZipHandle.write(ResolvePath(RootName, EntryRelativePath), ArcName)
@Application.get("/api/download")
def GetDownloadEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)):
RequirePathAccessible(Root, Path, CurrentUser)
try:
EntryPath = ResolvePath(Root, Path)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
if not EntryPath.exists():
raise HTTPException(status_code=404, detail="Not found.")
Username = CurrentUser["Username"]
UserGroups = CurrentUser.get("Groups", [])
DownloadName = EntryPath.name or Root
if EntryPath.is_dir():
TempZip = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
TempZip.close()
with zipfile.ZipFile(TempZip.name, "w", zipfile.ZIP_DEFLATED) as ZipHandle:
_AddDirectoryToZip(ZipHandle, Root, Path, Username, UserGroups, "")
EncodedName = quote(f"{DownloadName}.zip")
Headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{EncodedName}"}
return FileResponse(
TempZip.name,
media_type="application/zip",
headers=Headers,
background=BackgroundTask(os.remove, TempZip.name),
)
if not EntryPath.is_file():
raise HTTPException(status_code=400, detail="Not a file or directory.")
MimeType = mimetypes.guess_type(str(EntryPath))[0] or "application/octet-stream"
EncodedName = quote(DownloadName)
Headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{EncodedName}"}
def IterateDownload():
with EntryPath.open("rb") as SourceFile:
while Chunk := SourceFile.read(1024 * 1024):
yield Chunk
return StreamingResponse(IterateDownload(), media_type=MimeType, headers=Headers)
@Application.get("/api/book")
def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> dict:
RequirePathAccessible(Root, Path, CurrentUser)
try:
FilePath = ResolvePath(Root, Path)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
Book = ReadBookForFile(FilePath)
return Book or {}
@Application.get("/api/settings")
def GetSettingsEndpoint(CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> dict:
if CurrentUser["Username"] is None:
return dict(DefaultSettings)
return LoadSettings(CurrentUser["Username"])
@Application.put("/api/settings")
def PutSettingsEndpoint(NewSettings: dict, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
return SaveSettings(CurrentUser["Username"], NewSettings)
class DiagramRequest(BaseModel):
Source: str
Palette: dict[str, str] = {}
FontSize: int = 14
# A ```mermaid block as a PNG, for the desktop client (the web view draws its
# own). Palette is the client's theme by role, so it matches the client.
@Application.post("/api/diagram")
async def PostDiagramEndpoint(Body: DiagramRequest, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> Response:
try:
Png = await RenderDiagram(Body.Source, Body.Palette, Body.FontSize)
except DiagramError as Error:
raise HTTPException(status_code=422, detail=str(Error))
except DiagramsUnavailable as Error:
raise HTTPException(status_code=503, detail=str(Error))
return Response(content=Png, media_type="image/png")
@Application.get("/api/themes")
def GetThemesEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[str]:
return ListThemeNames()
@Application.get("/api/theme")
def GetThemeEndpoint(Name: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> dict:
try:
return LoadTheme(Name)
except FileNotFoundError as Error:
raise HTTPException(status_code=404, detail=str(Error))
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
class SaveThemeRequest(BaseModel):
Palette: dict[str, str]
@Application.put("/api/theme")
def PutThemeEndpoint(
Name: str, Body: SaveThemeRequest, CurrentUser: dict = Depends(GetCurrentUser)
) -> dict:
try:
SaveTheme(Name, Body.Palette)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
return {"Saved": True, "Roles": PaletteRoles}
@Application.delete("/api/theme")
def DeleteThemeEndpoint(Name: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
try:
DeleteTheme(Name)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
except FileNotFoundError as Error:
raise HTTPException(status_code=404, detail=str(Error))
return {"Deleted": True}
@Application.get("/public/media/{RootName}/{FilePath:path}")
def GetPublicMediaEndpoint(RootName: str, FilePath: str, request: Request):
if not IsPathAccessible(RootName, FilePath, "", [GuestGroupName]):
raise HTTPException(status_code=404, detail="Not found.")
try:
ResolvedPath = ResolvePath(RootName, FilePath)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
if not ResolvedPath.is_file():
raise HTTPException(status_code=404, detail="File not found.")
return StreamBinaryFile(ResolvedPath, request.headers.get("range"))
def GetForwardedScheme(request: Request) -> str:
ForwardedProto = request.headers.get("x-forwarded-proto")
if ForwardedProto:
return ForwardedProto.split(",")[0].strip()
CfVisitor = request.headers.get("cf-visitor")
if CfVisitor:
try:
ParsedScheme = json.loads(CfVisitor).get("scheme")
if ParsedScheme:
return ParsedScheme
except (json.JSONDecodeError, AttributeError):
pass
return request.url.scheme
FrontendDist = ApplicationDirectory.parent / "Frontend" / "dist"
if FrontendDist.is_dir():
IndexHtmlTemplate = (FrontendDist / "index.html").read_text(encoding="utf-8")
@Application.get("/{FullPath:path}")
def ServeFrontendEndpoint(FullPath: str, request: Request):
CandidatePath = (FrontendDist / FullPath).resolve()
IsWithinDist = CandidatePath == FrontendDist or FrontendDist in CandidatePath.parents
if FullPath and IsWithinDist and CandidatePath.is_file():
return FileResponse(CandidatePath)
OgTags = ""
Segments = [Segment for Segment in FullPath.split("/") if Segment]
if Segments:
Scheme = GetForwardedScheme(request)
Host = request.headers.get("x-forwarded-host", request.url.netloc)
BaseUrl = f"{Scheme}://{Host}"
OgTags = (
BuildOgTags(Segments[0], "/".join(Segments[1:]), FullPath.endswith("/"), BaseUrl)
or ""
)
return HTMLResponse(IndexHtmlTemplate.replace("<!--OgTags-->", OgTags))