mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 18:38:11 +02:00
Initial commit
This commit is contained in:
commit
8aa223bc7b
40 files changed
+4320
No files matched your search
+459
@@ -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")
|
||||
Reference in new issue
Block a user