diff --git a/Backend/Embeds.py b/Backend/Embeds.py new file mode 100644 index 0000000..cff20ee --- /dev/null +++ b/Backend/Embeds.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import html +import re + +from pathlib import Path +from urllib.parse import quote + +from Core import ResolvePath +from Explorer import ClassifyFile, GetDirectoryNoteInfo +from Locks import IsPathAccessible +from Portals import ResolvePortals +from Preview import GetMimeType, ReadTextFile, TextFileTypes + + +DescriptionExcerptLength = 200 + +DefaultDescriptions = { + "directory": "A directory on SaraView.", + "video": "A video on SaraView.", + "image": "An image on SaraView.", + "note": "A note on SaraView.", + "file": "A file on SaraView.", +} + + +def _StripMarkdownForExcerpt(Text: str) -> str: + Cleaned = re.sub(r"[#*_`>~\[\]]", "", Text) + Cleaned = re.sub(r"\s+", " ", Cleaned).strip() + return Cleaned[:DescriptionExcerptLength] + + +def _DescribeTextFile(RootName: str, RelativePath: str, FilePath: Path, Fallback: str) -> str: + try: + Content = ReadTextFile(FilePath) + Resolved = ResolvePortals(RootName, RelativePath, Content, "", []) + Excerpt = _StripMarkdownForExcerpt(Resolved) + return Excerpt or Fallback + except (OSError, UnicodeDecodeError): + return Fallback + + +def _MetaTag(Property: str, Content: str) -> str: + return f'' + + +def BuildOgTags(RootName: str, RelativePath: str, IsDirectoryUrl: bool, BaseUrl: str) -> str | None: + if not IsPathAccessible(RootName, RelativePath, "", []): + return None + + try: + EntryPath = ResolvePath(RootName, RelativePath) + except ValueError: + return None + + if not EntryPath.exists(): + return None + + EncodedPath = "/".join(quote(Segment) for Segment in RelativePath.split("/") if Segment) + PageUrl = f"{BaseUrl}/{quote(RootName)}/{EncodedPath}{'/' if IsDirectoryUrl else ''}" + Tags = [_MetaTag("og:url", PageUrl)] + + if EntryPath.is_dir(): + NoteInfo = GetDirectoryNoteInfo(EntryPath) + Description = DefaultDescriptions["directory"] + + if NoteInfo is not None and NoteInfo["FileType"] in TextFileTypes: + NotePath = EntryPath / NoteInfo["Name"] + NoteRelativePath = f"{RelativePath}/{NoteInfo['Name']}" if RelativePath else NoteInfo["Name"] + Description = _DescribeTextFile( + RootName, NoteRelativePath, NotePath, DefaultDescriptions["directory"] + ) + + Tags.append(_MetaTag("og:title", EntryPath.name)) + Tags.append(_MetaTag("og:description", Description)) + Tags.append(_MetaTag("og:type", "website")) + return "\n".join(Tags) + + FileType = ClassifyFile(EntryPath) + MediaUrl = f"{BaseUrl}/public/media/{quote(RootName)}/{EncodedPath}" + + Tags.append(_MetaTag("og:title", EntryPath.name)) + + if FileType == "video": + Tags.append(_MetaTag("og:type", "video.other")) + Tags.append(_MetaTag("og:video", MediaUrl)) + Tags.append(_MetaTag("og:video:url", MediaUrl)) + Tags.append(_MetaTag("og:video:secure_url", MediaUrl)) + Tags.append(_MetaTag("og:video:type", GetMimeType(EntryPath))) + Tags.append(_MetaTag("og:description", DefaultDescriptions["video"])) + elif FileType == "image": + Tags.append(_MetaTag("og:type", "website")) + Tags.append(_MetaTag("og:image", MediaUrl)) + Tags.append(_MetaTag("og:image:type", GetMimeType(EntryPath))) + Tags.append(_MetaTag("og:description", DefaultDescriptions["image"])) + elif FileType in TextFileTypes: + Description = _DescribeTextFile(RootName, RelativePath, EntryPath, DefaultDescriptions["note"]) + Tags.append(_MetaTag("og:type", "article")) + Tags.append(_MetaTag("og:description", Description)) + else: + Tags.append(_MetaTag("og:type", "website")) + Tags.append(_MetaTag("og:description", DefaultDescriptions["file"])) + + return "\n".join(Tags) diff --git a/Backend/Main.py b/Backend/Main.py index 4512fd8..8684ce2 100644 --- a/Backend/Main.py +++ b/Backend/Main.py @@ -10,12 +10,13 @@ if str(ApplicationDirectory) not in sys.path: from fastapi import Depends, FastAPI, File, HTTPException, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, PlainTextResponse, StreamingResponse +from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, StreamingResponse from Auth import CreateSession, DeleteSession, GetCurrentUser, RequireAdmin, SessionCookieName from BookReader import ReadBookForFile from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave from Core import ResolvePath +from Embeds import BuildOgTags from Editor import WriteTextFile from Explorer import ( ClassifyFile, @@ -42,7 +43,7 @@ from Locks import ( SetLock, ) from Portals import ResolvePortals -from Preview import GetFileSize, GetMimeType, ParseRangeHeader, ReadTextFile, TextFileTypes +from Preview import GetMimeType, ReadTextFile, StreamBinaryFile, TextFileTypes from Settings import ( DeleteTheme, ListThemeNames, @@ -486,35 +487,7 @@ def GetFileEndpoint( 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) + return StreamBinaryFile(FilePath, request.headers.get("range")) @Application.get("/api/file/resolved") @@ -702,16 +675,43 @@ def DeleteThemeEndpoint(Name: str, CurrentUser: dict = Depends(GetCurrentUser)) return {"Deleted": True} +@Application.get("/public/media/{RootName}/{FilePath:path}") +def GetPublicMediaEndpoint(RootName: str, FilePath: str, request: Request): + if not IsPathAccessible(RootName, FilePath, "", []): + 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")) + + 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): + 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) - return FileResponse(FrontendDist / "index.html") + OgTags = "" + Segments = [Segment for Segment in FullPath.split("/") if Segment] + + if Segments: + BaseUrl = f"{request.url.scheme}://{request.url.netloc}" + OgTags = ( + BuildOgTags(Segments[0], "/".join(Segments[1:]), FullPath.endswith("/"), BaseUrl) + or "" + ) + + return HTMLResponse(IndexHtmlTemplate.replace("", OgTags)) diff --git a/Backend/Preview.py b/Backend/Preview.py index 4ed3ae9..8dafecd 100644 --- a/Backend/Preview.py +++ b/Backend/Preview.py @@ -2,6 +2,8 @@ from __future__ import annotations from pathlib import Path +from fastapi.responses import StreamingResponse + TextFileTypes = {"markdown", "text", "config", "html"} @@ -40,3 +42,34 @@ def ParseRangeHeader(RangeHeader: str, FileSize: int) -> tuple[int, int]: End = min(End, FileSize - 1) return Start, End + + +def StreamBinaryFile(FilePath: Path, RangeHeader: str | None) -> StreamingResponse: + FileSize = GetFileSize(FilePath) + MimeType = GetMimeType(FilePath) + + 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) diff --git a/Frontend/index.html b/Frontend/index.html index bf8b8d0..cb01f94 100644 --- a/Frontend/index.html +++ b/Frontend/index.html @@ -4,6 +4,8 @@ SaraView + +