Added embed support.

This commit is contained in:
sarah committed 2026-09-21 20:05:52 +02:00
1 parent 571dfd5378
commit ea1c444c49
4 files changed
+172 -33

No files matched your search

+104
View File
@@ -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'<meta property="{html.escape(Property)}" content="{html.escape(Content)}" />'
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)
+33 -33
View File
@@ -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-->", OgTags))
+33
View File
@@ -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)