From 2df90ce28087d02e7b1dbd0e8efd5d5f99407793 Mon Sep 17 00:00:00 2001 From: Kevin Morris Date: Mon, 29 Mar 2021 15:20:04 -0700 Subject: [PATCH] port over base HTML layout from PHP to FastAPI+Jinja2 + Mounted static files (at web/html) to /static. + Added AURWEB_VERSION to aurweb.config (this is used around HTML to refer back to aurweb's release on git.archlinux.org), so we need it easily accessible in the Python codebase. + Implemented basic Jinja2 partials to put together whole aurweb pages. This may be missing some things currently and is a WIP until this set is ready to be merged. + Added config [options] aurwebdir = YOUR_AUR_ROOT; this configuration option should specify the root directory of the aurweb project. It is used by various parts of the FastAPI codebase to target project directories. Added routes via aurweb.routers.html: * POST /language: Set your session language. * GET /favicon.ico: Redirect to /static/images/favicon.ico. * Some browsers always look for $ROOT/favicon.ico to get an icon for the page being loaded, regardless of a specified "shortcut icon" given in a directive. * GET /: Home page; WIP. * Updated aurweb.routers.html.language passes query parameters to its next redirection. When calling aurweb.templates.render_template, the context passed should be formed via the aurweb.templates.make_context. See aurweb.routers.html.index for an example of this. Signed-off-by: Kevin Morris --- .coveragerc | 1 + INSTALL | 4 +- aurweb/asgi.py | 23 ++++++++- aurweb/config.py | 5 ++ aurweb/routers/html.py | 50 +++++++++++++++++++ aurweb/templates.py | 57 +++++++++++++++++++++ conf/config.dev | 1 + templates/index.html | 4 ++ templates/partials/archdev-navbar.html | 8 +++ templates/partials/body.html | 10 ++++ templates/partials/footer.html | 5 ++ templates/partials/head.html | 16 ++++++ templates/partials/layout.html | 10 ++++ templates/partials/meta.html | 1 + templates/partials/navbar.html | 19 +++++++ templates/partials/set_lang.html | 28 +++++++++++ templates/partials/typeahead.html | 30 +++++++++++ test/test_routes.py | 69 ++++++++++++++++++++++++++ 18 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 aurweb/routers/html.py create mode 100644 aurweb/templates.py create mode 100644 templates/index.html create mode 100644 templates/partials/archdev-navbar.html create mode 100644 templates/partials/body.html create mode 100644 templates/partials/footer.html create mode 100644 templates/partials/head.html create mode 100644 templates/partials/layout.html create mode 100644 templates/partials/meta.html create mode 100644 templates/partials/navbar.html create mode 100644 templates/partials/set_lang.html create mode 100644 templates/partials/typeahead.html create mode 100644 test/test_routes.py diff --git a/.coveragerc b/.coveragerc index 144a9f5c..9dcfca18 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,5 +3,6 @@ disable_warnings = already-imported [report] include = aurweb/* +fail_under = 85 exclude_lines = if __name__ == .__main__.: diff --git a/INSTALL b/INSTALL index 8607b07f..e4c52480 100644 --- a/INSTALL +++ b/INSTALL @@ -49,7 +49,9 @@ read the instructions below. # pacman -S python-mysql-connector python-pygit2 python-srcinfo python-sqlalchemy \ python-bleach python-markdown python-alembic hypercorn \ - python-itsdangerous python-authlib python-httpx + python-itsdangerous python-authlib python-httpx \ + python-jinja python-aiofiles python-python-multipart \ + python-requests # python3 setup.py install 5) Create a new MySQL database and a user and import the aurweb SQL schema: diff --git a/aurweb/asgi.py b/aurweb/asgi.py index 9293ed77..00d7c595 100644 --- a/aurweb/asgi.py +++ b/aurweb/asgi.py @@ -2,13 +2,26 @@ import http from fastapi import FastAPI, HTTPException from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware import aurweb.config -from aurweb.routers import sso +from aurweb.routers import html, sso +routes = set() + +# Setup the FastAPI app. app = FastAPI() +app.mount("/static/css", + StaticFiles(directory="web/html/css"), + name="static_css") +app.mount("/static/js", + StaticFiles(directory="web/html/js"), + name="static_js") +app.mount("/static/images", + StaticFiles(directory="web/html/images"), + name="static_images") session_secret = aurweb.config.get("fastapi", "session_secret") if not session_secret: @@ -17,6 +30,14 @@ if not session_secret: app.add_middleware(SessionMiddleware, secret_key=session_secret) app.include_router(sso.router) +app.include_router(html.router) + +# NOTE: Always keep this dictionary updated with all routes +# that the application contains. We use this to check for +# parameter value verification. +routes = {route.path for route in app.routes} +routes.update({route.path for route in sso.router.routes}) +routes.update({route.path for route in html.router.routes}) @app.exception_handler(HTTPException) diff --git a/aurweb/config.py b/aurweb/config.py index 52ec461e..020c3b80 100644 --- a/aurweb/config.py +++ b/aurweb/config.py @@ -1,6 +1,11 @@ import configparser import os +# Publicly visible version of aurweb. This is used to display +# aurweb versioning in the footer and must be maintained. +# Todo: Make this dynamic/automated. +AURWEB_VERSION = "v5.0.0" + _parser = None diff --git a/aurweb/routers/html.py b/aurweb/routers/html.py new file mode 100644 index 00000000..ae08c764 --- /dev/null +++ b/aurweb/routers/html.py @@ -0,0 +1,50 @@ +""" AURWeb's primary routing module. Define all routes via @app.app.{get,post} +decorators in some way; more complex routes should be defined in their +own modules and imported here. """ +from http import HTTPStatus +from urllib.parse import unquote + +from fastapi import APIRouter, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +from aurweb.templates import make_context, render_template + +router = APIRouter() + + +@router.get("/favicon.ico") +async def favicon(request: Request): + """ Some browsers attempt to find a website's favicon via root uri at + /favicon.ico, so provide a redirection here to our static icon. """ + return RedirectResponse("/static/images/favicon.ico") + + +@router.post("/language", response_class=RedirectResponse) +async def language(request: Request, + set_lang: str = Form(...), + next: str = Form(...), + q: str = Form(default=None)): + """ A POST route used to set a session's language. + + Return a 303 See Other redirect to {next}?next={next}. If we are + setting the language on any page, we want to preserve query + parameters across the redirect. + """ + from aurweb.asgi import routes + if unquote(next) not in routes: + return HTMLResponse( + b"Invalid 'next' parameter.", + status_code=400) + + query_string = "?" + q if q else str() + response = RedirectResponse(url=f"{next}{query_string}", + status_code=int(HTTPStatus.SEE_OTHER)) + response.set_cookie("AURLANG", set_lang) + return response + + +@router.get("/", response_class=HTMLResponse) +async def index(request: Request): + """ Homepage route. """ + context = make_context(request, "Home") + return render_template("index.html", context) diff --git a/aurweb/templates.py b/aurweb/templates.py new file mode 100644 index 00000000..c05dce79 --- /dev/null +++ b/aurweb/templates.py @@ -0,0 +1,57 @@ +import copy +import os + +from datetime import datetime +from http import HTTPStatus + +import jinja2 + +from fastapi import Request +from fastapi.responses import HTMLResponse + +import aurweb.config + +from aurweb import l10n + +# Prepare jinja2 objects. +loader = jinja2.FileSystemLoader(os.path.join( + aurweb.config.get("options", "aurwebdir"), "templates")) +env = jinja2.Environment(loader=loader, autoescape=True, + extensions=["jinja2.ext.i18n"]) + +# Add tr translation filter. +env.filters["tr"] = l10n.tr + + +def make_context(request: Request, title: str, next: str = None): + """ Create a context for a jinja2 TemplateResponse. """ + + return { + "request": request, + "language": l10n.get_request_language(request), + "languages": l10n.SUPPORTED_LANGUAGES, + "title": title, + # The 'now' context variable will not show proper datetimes + # until we've implemented timezone support here. + "now": datetime.now(), + "config": aurweb.config, + "next": next if next else request.url.path + } + + +def render_template(path: str, context: dict, status_code=int(HTTPStatus.OK)): + """ Render a Jinja2 multi-lingual template with some context. """ + + # Create a deep copy of our jinja2 environment. The environment in + # total by itself is 48 bytes large (according to sys.getsizeof). + # This is done so we can install gettext translations on the template + # environment being rendered without installing them into a global + # which is reused in this function. + templates = copy.copy(env) + + translator = l10n.get_raw_translator_for_request(context.get("request")) + templates.install_gettext_translations(translator) + + template = templates.get_template(path) + rendered = template.render(context) + return HTMLResponse(rendered, status_code=status_code) diff --git a/conf/config.dev b/conf/config.dev index ef7b5ed7..ccb01f4f 100644 --- a/conf/config.dev +++ b/conf/config.dev @@ -16,6 +16,7 @@ name = YOUR_AUR_ROOT/aurweb.sqlite3 ;password = aur [options] +aurwebdir = YOUR_AUR_ROOT aur_location = http://127.0.0.1:8080 disable_http_login = 0 enable-maintenance = 0 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 00000000..27d3375d --- /dev/null +++ b/templates/index.html @@ -0,0 +1,4 @@ +{% extends 'partials/layout.html' %} + +{% block pageContent %} +{% endblock %} diff --git a/templates/partials/archdev-navbar.html b/templates/partials/archdev-navbar.html new file mode 100644 index 00000000..55338bc4 --- /dev/null +++ b/templates/partials/archdev-navbar.html @@ -0,0 +1,8 @@ + diff --git a/templates/partials/body.html b/templates/partials/body.html new file mode 100644 index 00000000..ccae0fe3 --- /dev/null +++ b/templates/partials/body.html @@ -0,0 +1,10 @@ +
+ {% include 'partials/set_lang.html' %} + {% include 'partials/archdev-navbar.html' %} + + {% block pageContent %} + + {% endblock %} + + {% include 'partials/footer.html' %} +
diff --git a/templates/partials/footer.html b/templates/partials/footer.html new file mode 100644 index 00000000..0ac4d089 --- /dev/null +++ b/templates/partials/footer.html @@ -0,0 +1,5 @@ + diff --git a/templates/partials/head.html b/templates/partials/head.html new file mode 100644 index 00000000..0351fd6e --- /dev/null +++ b/templates/partials/head.html @@ -0,0 +1,16 @@ + + {% include 'partials/meta.html' %} + + + + + + + + + + + + AUR ({{ language }}) - {{ title | tr }} + diff --git a/templates/partials/layout.html b/templates/partials/layout.html new file mode 100644 index 00000000..d30208a9 --- /dev/null +++ b/templates/partials/layout.html @@ -0,0 +1,10 @@ + + + {% include 'partials/head.html' %} + + + {% include 'partials/navbar.html' %} + {% extends 'partials/body.html' %} + {% include 'partials/typeahead.html' %} + + diff --git a/templates/partials/meta.html b/templates/partials/meta.html new file mode 100644 index 00000000..727100b9 --- /dev/null +++ b/templates/partials/meta.html @@ -0,0 +1 @@ + diff --git a/templates/partials/navbar.html b/templates/partials/navbar.html new file mode 100644 index 00000000..199b2067 --- /dev/null +++ b/templates/partials/navbar.html @@ -0,0 +1,19 @@ + diff --git a/templates/partials/set_lang.html b/templates/partials/set_lang.html new file mode 100644 index 00000000..e9590050 --- /dev/null +++ b/templates/partials/set_lang.html @@ -0,0 +1,28 @@ +
+
+
+
+ + + + + + + + + +
+
+
+
diff --git a/templates/partials/typeahead.html b/templates/partials/typeahead.html new file mode 100644 index 00000000..d943dbc4 --- /dev/null +++ b/templates/partials/typeahead.html @@ -0,0 +1,30 @@ + + + diff --git a/test/test_routes.py b/test/test_routes.py new file mode 100644 index 00000000..46ba39f5 --- /dev/null +++ b/test/test_routes.py @@ -0,0 +1,69 @@ +import urllib.parse + +from http import HTTPStatus + +import pytest + +from fastapi.testclient import TestClient + +from aurweb.asgi import app +from aurweb.testing import setup_test_db + +client = TestClient(app) + + +@pytest.fixture +def setup(): + setup_test_db("Users", "Session") + + +def test_index(): + """ Test the index route at '/'. """ + # Use `with` to trigger FastAPI app events. + with client as req: + response = req.get("/") + assert response.status_code == int(HTTPStatus.OK) + + +def test_favicon(): + """ Test the favicon route at '/favicon.ico'. """ + response1 = client.get("/static/images/favicon.ico") + response2 = client.get("/favicon.ico") + assert response1.status_code == int(HTTPStatus.OK) + assert response1.content == response2.content + + +def test_language(): + """ Test the language post route at '/language'. """ + post_data = { + "set_lang": "de", + "next": "/" + } + with client as req: + response = req.post("/language", data=post_data) + assert response.status_code == int(HTTPStatus.SEE_OTHER) + + +def test_language_invalid_next(): + """ Test an invalid next route at '/language'. """ + post_data = { + "set_lang": "de", + "next": "/BLAHBLAHFAKE" + } + with client as req: + response = req.post("/language", data=post_data) + assert response.status_code == int(HTTPStatus.BAD_REQUEST) + +def test_language_query_params(): + """ Test the language post route with query params. """ + next = urllib.parse.quote_plus("/") + post_data = { + "set_lang": "de", + "next": "/", + "q": f"next={next}" + } + q = post_data.get("q") + with client as req: + response = req.post("/language", data=post_data) + assert response.headers.get("location") == f"/?{q}" + assert response.status_code == int(HTTPStatus.SEE_OTHER)