diff --git a/app/config.py b/app/config.py index 993d5e0..dfd6ec9 100644 --- a/app/config.py +++ b/app/config.py @@ -14,6 +14,7 @@ from itsdangerous import URLSafeTimedSerializer from loguru import logger from mistletoe import markdown # type: ignore +from app.customization import _CUSTOM_ROUTES from app.utils.emoji import _load_emojis from app.utils.version import get_version_commit @@ -184,6 +185,31 @@ CODE_HIGHLIGHTING_THEME = CONFIG.code_highlighting_theme MOVED_TO = _get_moved_to() +_NavBarItem = tuple[str, str] + + +class NavBarItems: + EXTRA_NAVBAR_ITEMS: list[_NavBarItem] = [] + INDEX_NAVBAR_ITEM: _NavBarItem | None = None + NOTES_PATH = "/" + + +def load_custom_routes() -> None: + try: + from data import custom_routes # type: ignore # noqa: F401 + except ImportError: + pass + + for path, custom_handler in _CUSTOM_ROUTES.items(): + # If a handler wants to replace the root, move the index to /notes + if path == "/": + NavBarItems.NOTES_PATH = "/notes" + NavBarItems.INDEX_NAVBAR_ITEM = (path, custom_handler.title) + else: + if custom_handler.show_in_navbar: + NavBarItems.EXTRA_NAVBAR_ITEMS.append((path, custom_handler.title)) + + session_serializer = URLSafeTimedSerializer( CONFIG.secret, salt=f"{ID}.session", diff --git a/app/customization.py b/app/customization.py new file mode 100644 index 0000000..abb9062 --- /dev/null +++ b/app/customization.py @@ -0,0 +1,112 @@ +from pathlib import Path +from typing import Any +from typing import Callable + +from fastapi import APIRouter +from fastapi import Depends +from fastapi import Request +from starlette.responses import JSONResponse + +_DATA_DIR = Path().parent.resolve() / "data" +_Handler = Callable[..., Any] + + +class HTMLPage: + def __init__( + self, + title: str, + html_file: str, + show_in_navbar: bool, + ) -> None: + self.title = title + self.html_file = _DATA_DIR / html_file + self.show_in_navbar = show_in_navbar + + +class RawHandler: + def __init__( + self, + title: str, + handler: Any, + show_in_navbar: bool, + ) -> None: + self.title = title + self.handler = handler + self.show_in_navbar = show_in_navbar + + +_CUSTOM_ROUTES: dict[str, HTMLPage | RawHandler] = {} + + +def register_html_page( + path: str, + *, + title: str, + html_file: str, + show_in_navbar: bool = True, +) -> None: + if path in _CUSTOM_ROUTES: + raise ValueError(f"{path} is already registered") + + _CUSTOM_ROUTES[path] = HTMLPage(title, html_file, show_in_navbar) + + +def register_raw_handler( + path: str, + *, + title: str, + handler: _Handler, + show_in_navbar: bool = True, +) -> None: + if path in _CUSTOM_ROUTES: + raise ValueError(f"{path} is already registered") + + _CUSTOM_ROUTES[path] = RawHandler(title, handler, show_in_navbar) + + +class ActivityPubResponse(JSONResponse): + media_type = "application/activity+json" + + +def _custom_page_handler(path: str, html_page: HTMLPage) -> Any: + from app import templates + from app.actor import LOCAL_ACTOR + from app.config import is_activitypub_requested + from app.database import AsyncSession + from app.database import get_db_session + + async def _handler( + request: Request, + db_session: AsyncSession = Depends(get_db_session), + ) -> templates.TemplateResponse | ActivityPubResponse: + if path == "/" and is_activitypub_requested(request): + return ActivityPubResponse(LOCAL_ACTOR.ap_actor) + + return await templates.render_template( + db_session, + request, + "custom_page.html", + { + "page_content": html_page.html_file.read_text(), + "title": html_page.title, + }, + ) + + return _handler + + +def get_custom_router() -> APIRouter | None: + if not _CUSTOM_ROUTES: + return None + + router = APIRouter() + + for path, handler in _CUSTOM_ROUTES.items(): + if isinstance(handler, HTMLPage): + router.add_api_route( + path, _custom_page_handler(path, handler), methods=["GET"] + ) + else: + router.add_api_route(path, handler.handler) + + return router diff --git a/app/main.py b/app/main.py index 8cce9b3..7caf870 100644 --- a/app/main.py +++ b/app/main.py @@ -63,6 +63,7 @@ from app.config import USER_AGENT from app.config import USERNAME from app.config import is_activitypub_requested from app.config import verify_csrf_token +from app.customization import get_custom_router from app.database import AsyncSession from app.database import async_session from app.database import get_db_session @@ -192,6 +193,9 @@ app.include_router(admin.unauthenticated_router, prefix="/admin") app.include_router(indieauth.router) app.include_router(micropub.router) app.include_router(webmentions.router) +config.load_custom_routes() +if custom_router := get_custom_router(): + app.include_router(custom_router) # XXX: order matters, the proxy middleware needs to be last app.add_middleware(CustomMiddleware) @@ -243,7 +247,7 @@ class ActivityPubResponse(JSONResponse): media_type = "application/activity+json" -@app.get("/") +@app.get(config.NavBarItems.NOTES_PATH) async def index( request: Request, db_session: AsyncSession = Depends(get_db_session), diff --git a/app/templates.py b/app/templates.py index 07c4708..d75e506 100644 --- a/app/templates.py +++ b/app/templates.py @@ -429,3 +429,4 @@ _templates.env.globals["CSS_HASH"] = config.CSS_HASH _templates.env.globals["BASE_URL"] = config.BASE_URL _templates.env.globals["HIDES_FOLLOWERS"] = config.HIDES_FOLLOWERS _templates.env.globals["HIDES_FOLLOWING"] = config.HIDES_FOLLOWING +_templates.env.globals["NAVBAR_ITEMS"] = config.NavBarItems diff --git a/app/templates/custom_page.html b/app/templates/custom_page.html new file mode 100644 index 0000000..0797dd9 --- /dev/null +++ b/app/templates/custom_page.html @@ -0,0 +1,30 @@ +{%- import "utils.html" as utils with context -%} +{% extends "layout.html" %} + +{% block head %} +