from __future__ import annotations import mimetypes import sys from pathlib import Path 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 PlainTextResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from Auth import CreateSession, DeleteSession, GetCurrentUser, RequireAdmin, SessionCookieName from BookReader import ReadBookForFile from Blame import LoadBlame, UpdateBlameOnSave from Core import ResolvePath from Editor import WriteTextFile from Explorer import ClassifyFile, CreateEntry, DeleteEntry, ListDirectory, ListRoots, MoveEntry from Preview import GetFileSize, GetMimeType, ParseRangeHeader, ReadTextFile, TextFileTypes from Settings import ( DeleteTheme, ListThemeNames, LoadSettings, LoadTheme, PaletteRoles, SaveSettings, SaveTheme, ) from Users import ( AnyUsersExist, CreateUser, GetAvatarPath, ListUsers, UpdateUserColour, UserColourOptions, VerifyLogin, SaveAvatar, ) from pydantic import BaseModel Application = FastAPI(title="SaraView") Application.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], allow_credentials=True, ) # --- Auth --------------------------------------------------------------- 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: """Create the first (admin) account. Only usable while no users exist yet, so there's no default/baked-in password sitting in the image. """ 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(RequireAdmin)) -> 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} # --- Profile (colour + avatar) -------------------------------------------- @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(GetCurrentUser)): 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) # --- Files ---------------------------------------------------------------- @Application.get("/api/roots") def GetRootsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]: return ListRoots() @Application.get("/api/tree") def GetTreeEndpoint( Root: str, Path: str = "", CurrentUser: dict = Depends(GetCurrentUser) ) -> list[dict]: try: ShowHidden = LoadSettings(CurrentUser["Username"]).get("ShowHiddenFolders", False) return ListDirectory(Root, Path, ShowHidden) except (ValueError, NotADirectoryError, FileNotFoundError) as Error: raise HTTPException(status_code=400, detail=str(Error)) class CreateEntryRequest(BaseModel): IsDirectory: bool @Application.post("/api/tree") def PostTreeEndpoint( Root: str, Path: str, Body: CreateEntryRequest, CurrentUser: dict = Depends(GetCurrentUser) ) -> dict: 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} 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: if Body.DestinationPath: Destination = Body.DestinationPath elif Body.NewName: ParentPath = Path.rsplit("/", 1)[0] if "/" in Path else "" Destination = f"{ParentPath}/{Body.NewName}" if ParentPath else Body.NewName else: raise HTTPException(status_code=400, detail="Provide NewName or DestinationPath.") 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: 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(GetCurrentUser) ): 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)) FileSize = GetFileSize(FilePath) MimeType = GetMimeType(FilePath) RangeHeader = request.headers.get("range") if RangeHeader: Start, End = ParseRangeHeader(RangeHeader, FileSize) ChunkLength = End - Start + 1 def IterateChunk(): with FilePath.open("rb") as SourceFile: SourceFile.seek(Start) yield SourceFile.read(ChunkLength) Headers = { "Content-Range": f"bytes {Start}-{End}/{FileSize}", "Accept-Ranges": "bytes", "Content-Length": str(ChunkLength), } return StreamingResponse( IterateChunk(), status_code=206, media_type=MimeType, headers=Headers ) def IterateFile(): with FilePath.open("rb") as SourceFile: while Chunk := SourceFile.read(1024 * 1024): yield Chunk Headers = {"Accept-Ranges": "bytes", "Content-Length": str(FileSize)} return StreamingResponse(IterateFile(), media_type=MimeType, headers=Headers) @Application.put("/api/file") async def PutFileEndpoint( Root: str, Path: str, request: Request, CurrentUser: dict = Depends(GetCurrentUser) ): 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(GetCurrentUser)) -> dict: try: ResolvePath(Root, Path) except ValueError as Error: raise HTTPException(status_code=400, detail=str(Error)) return LoadBlame(Root, Path) @Application.get("/api/raw/{RootName}/{FilePath:path}") def GetRawFileEndpoint( RootName: str, FilePath: str, CurrentUser: dict = Depends(GetCurrentUser) ): """Serves a file at a URL that mirrors its real directory structure (unlike /api/file's query-param form), so relative links, stylesheets, and scripts INSIDE a previewed HTML document resolve correctly against sibling files in the same directory. """ 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) @Application.get("/api/book") def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: try: FilePath = ResolvePath(Root, Path) except ValueError as Error: raise HTTPException(status_code=400, detail=str(Error)) Book = ReadBookForFile(FilePath) return Book or {} # --- Settings / themes ------------------------------------------------------ @Application.get("/api/settings") def GetSettingsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> dict: return LoadSettings(CurrentUser["Username"]) @Application.put("/api/settings") def PutSettingsEndpoint(NewSettings: dict, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: return SaveSettings(CurrentUser["Username"], NewSettings) @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(GetCurrentUser)) -> 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} # In production, the frontend's built assets are copied alongside this file # under Frontend/dist (see Dockerfile) and served directly by the backend. # This stays unauthenticated — the SPA itself needs to load before it can # even show a login screen. FrontendDist = ApplicationDirectory.parent / "Frontend" / "dist" if FrontendDist.is_dir(): Application.mount("/", StaticFiles(directory=str(FrontendDist), html=True), name="Frontend")