Initial commit

This commit is contained in:
sarah committed 2026-09-19 14:53:01 +02:00
commit 8aa223bc7b
40 files changed
+4320

No files matched your search

+459
View File
@@ -0,0 +1,459 @@
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")