diff --git a/.coveragerc b/.coveragerc index 144a9f5c10e99bd58fcc2d5f4f669f0b7fc3ad0f..9dcfca18fd55ecc58b1b893b8db44563de3d8ea1 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 8607b07f3ff75aa0ac54ea27a2e79a496c3c7e64..e4c52480c09f7c7f72fc5b619e1210022c004afc 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 9293ed7754a419b7c42bd540a833338fbfd27638..00d7c595669d34c6d1f50ec6ed4f8575dc11379f 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 52ec461edb93c33a3436eb76530928ab6b09bfd3..020c3b807d0726cb44628b7a4b5ff40b294cb6b4 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 0000000000000000000000000000000000000000..ae08c764becfbfb5c91d54d253bdf6e24f3be9ae --- /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 0000000000000000000000000000000000000000..c05dce79edc31aadaf2fb9e821ff21b84e685c95 --- /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 ef7b5ed7407cdc6f8c56a9637e299cbc8c7e5f8b..ccb01f4f0007893820a3e9e2f8b8a447efbdfff8 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 0000000000000000000000000000000000000000..27d3375d16b5f18a58863728b272db93e29acf35 --- /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 0000000000000000000000000000000000000000..55338bc48ff044605bbaa55071ea438aa609dcef --- /dev/null +++ b/templates/partials/archdev-navbar.html @@ -0,0 +1,8 @@ +<div id="archdev-navbar"> + <ul> + <li><a href="/">AUR {% trans %}Home{% endtrans %}</a></li> + <li><a href="/packages/">{% trans %}Packages{% endtrans %}</a></li> + <li><a href="/register/">{% trans %}Register{% endtrans %}</a></li> + <li><a href="/login/">{% trans %}Login{% endtrans %}</a></li> + </ul> +</div> diff --git a/templates/partials/body.html b/templates/partials/body.html new file mode 100644 index 0000000000000000000000000000000000000000..ccae0fe3fbc02f5abd63de921b992f13b8f154be --- /dev/null +++ b/templates/partials/body.html @@ -0,0 +1,10 @@ +<div id="content"> + {% include 'partials/set_lang.html' %} + {% include 'partials/archdev-navbar.html' %} + + {% block pageContent %} + <!-- Content block to be defined by extender. --> + {% endblock %} + + {% include 'partials/footer.html' %} +</div> diff --git a/templates/partials/footer.html b/templates/partials/footer.html new file mode 100644 index 0000000000000000000000000000000000000000..0ac4d089b796738a563eabc40fa46a72fccce55f --- /dev/null +++ b/templates/partials/footer.html @@ -0,0 +1,5 @@ +<div id="footer"> + <p>aurweb <a href="https://git.archlinux.org/aurweb.git/log/?h={{ config.AURWEB_VERSION }}">{{ config.AURWEB_VERSION }}</a></p> + <p>Copyright © 2004-{{ now.strftime("%Y") }} aurweb Development Team.</p> + <p>{% trans %}AUR packages are user produced content. Any use of the provided files is at your own risk.{% endtrans %}</p> +</div> diff --git a/templates/partials/head.html b/templates/partials/head.html new file mode 100644 index 0000000000000000000000000000000000000000..0351fd6ef4ce4ebb74d0f0e6d97ae4be55d34975 --- /dev/null +++ b/templates/partials/head.html @@ -0,0 +1,16 @@ +<head> + {% include 'partials/meta.html' %} + + <!-- CSS --> + <link rel="stylesheet" href="/static/css/archweb.css"> + <link rel="stylesheet" href="/static/css/aurweb.css"> + + <!-- Resources --> + <link rel="shortcut icon" href="/static/images/favicon.ico"> + + <!-- Alternate resources --> + <link rel="alternate" type="application/rss+xml" + title="Newest Packages RSS" href="/rss/"> + + <title>AUR ({{ language }}) - {{ title | tr }}</title> +</head> diff --git a/templates/partials/layout.html b/templates/partials/layout.html new file mode 100644 index 0000000000000000000000000000000000000000..d30208a9939dd12df632faa9bdda0ec644a25e25 --- /dev/null +++ b/templates/partials/layout.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html lang="{{ language }}"> + {% include 'partials/head.html' %} + + <body> + {% include 'partials/navbar.html' %} + {% extends 'partials/body.html' %} + {% include 'partials/typeahead.html' %} + </body> +</html> diff --git a/templates/partials/meta.html b/templates/partials/meta.html new file mode 100644 index 0000000000000000000000000000000000000000..727100b9a9cbaa9744d12a5d31882bc682572ca9 --- /dev/null +++ b/templates/partials/meta.html @@ -0,0 +1 @@ +<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> diff --git a/templates/partials/navbar.html b/templates/partials/navbar.html new file mode 100644 index 0000000000000000000000000000000000000000..199b206714d817c45e1a964dd2b314327becc7be --- /dev/null +++ b/templates/partials/navbar.html @@ -0,0 +1,19 @@ +<div id="archnavbar" class="anb-aur"> + <div id="archnavbarlogo"> + <h1> + <a href="/" title="Return to the main page">{% trans %}Arch Linux User Repository{% endtrans %}</a> + </h1> + </div> + <div id="archnavbarmenu"> + <ul id="archnavbarlist"> + <li id="anb-home"><a href="https://www.archlinux.org/" title="Arch news, packages, projects and more">{% trans %}Home{% endtrans %}</a></li> + <li id="anb-packages"><a href="https://www.archlinux.org/packages/" title="Arch Package Database">{% trans %}Packages{% endtrans %}</a></li> + <li id="anb-forums"><a href="https://bbs.archlinux.org/" title="Community forums">{% trans %}Forums{% endtrans %}</a></li> + <li id="anb-wiki"><a href="https://wiki.archlinux.org/" title="Community documentation">{% trans %}Wiki{% endtrans %}</a></li> + <li id="anb-bugs"><a href="https://bugs.archlinux.org/" title="Report and track bugs">{% trans %}Bugs{% endtrans %}</a></li> + <li id="anb-security"><a href="https://security.archlinux.org/" title="Arch Linux Security Tracker">{% trans %}Security{% endtrans %}</a></li> + <li id="anb-aur"><a href="/" title="Arch Linux User Repository">AUR</a></li> + <li id="anb-download"><a href="https://www.archlinux.org/download/" title="Get Arch Linux">{% trans %}Download{% endtrans %}</a></li> + </ul> + </div> +</div> diff --git a/templates/partials/set_lang.html b/templates/partials/set_lang.html new file mode 100644 index 0000000000000000000000000000000000000000..e95900507881a2db1d466711e4298204f950f0fa --- /dev/null +++ b/templates/partials/set_lang.html @@ -0,0 +1,28 @@ +<div id="lang_sub"> + <form method="post" action="/language"> + <fieldset> + <div> + <select id="id_setlang" name="set_lang"> + {% for domain, display in languages.items() %} + <option + value="{{ domain }}" + {% if language == domain %} + selected="selected" + {% endif %} + > + {{ display }} + </option> + {% endfor %} + </select> + + <!-- Pass our current url path as next. --> + <input type="hidden" name="next" value="{{ request.url.path }}"> + + <!-- Pass query_params over to /language via POST. --> + <input type="hidden" name="q" value="{{ request.query_params }}"> + + <input type="submit" value="Go"> + </div> + </fieldset> + </form> +</div> diff --git a/templates/partials/typeahead.html b/templates/partials/typeahead.html new file mode 100644 index 0000000000000000000000000000000000000000..d943dbc4344ad57b19ef909c3a12d928aee33e87 --- /dev/null +++ b/templates/partials/typeahead.html @@ -0,0 +1,30 @@ +<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js"></script> +<script type="text/javascript" src="/static/js/bootstrap-typeahead.min.js"></script> +<script type="text/javascript"> + $(document).ready(function() { + $('#pkgsearch-field').typeahead({ + source: function(query, callback) { + $.getJSON('/rpc', {type: "suggest", arg: query}, function(data) { + callback(data); + }); + }, + matcher: function(item) { return true; }, + sorter: function(items) { return items; }, + menu: '<ul class="pkgsearch-typeahead"></ul>', + items: 20, + updater: function(item) { + document.location = '/packages/' + item; + return item; + } + }).attr('autocomplete', 'off'); + + $('#pkgsearch-field').keydown(function(e) { + if (e.keyCode == 13) { + var selectedItem = $('ul.pkgsearch-typeahead li.active'); + if (selectedItem.length == 0) { + $('#pkgsearch-form').submit(); + } + } + }); + }); +</script> diff --git a/test/test_routes.py b/test/test_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..46ba39f5077a709102b325aa1d5290887b91aa7c --- /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)