client+server: implement HTML caching and URL prefix determination

* Uses 'X-Forwarded-Prefix' header to determine base URL if not
  explicitly defined
* Authentication ignored for HTML generation to improve caching
* Also add logging of 'User-Agent' for HTML requests
This commit is contained in:
Shyam Sunder 2021-09-24 20:04:56 -04:00
parent d5a223652e
commit 636498ad38
8 changed files with 108 additions and 48 deletions

View file

@ -1,5 +1,9 @@
#!/usr/bin/dumb-init /bin/sh
# Create cache directory
mkdir -p /tmp/nginx-cache
chmod a+rwx /tmp/nginx-cache
# Integrate environment variables
sed -i "s|__BACKEND__|${BACKEND_HOST}|" \
/etc/nginx/nginx.conf

View file

@ -19,6 +19,9 @@ http {
server_tokens off;
keepalive_timeout 65;
proxy_cache_path /tmp/nginx-cache
levels=1:2 keys_zone=spa_cache:1m max_size=50m inactive=60m use_temp_path=off;
upstream backend {
server __BACKEND__:6666;
}
@ -50,6 +53,14 @@ http {
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain application/json;
if ($http_x_forwarded_host = '') {
set $http_x_forwarded_host $host;
}
if ($http_x_forwarded_proto = '') {
set $http_x_forwarded_proto 'http';
}
if ($request_uri ~* "/api/(.*)") {
proxy_pass http://backend/$1;
}
@ -85,6 +96,29 @@ http {
location / {
tcp_nodelay on;
proxy_cache spa_cache;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
gzip on;
gzip_comp_level 3;
gzip_min_length 20;
gzip_proxied any;
gzip_types text/plain application/json;
if ( $http_accept ~ "application/json" ) {
return 406 "API requests should be sent to the /api prefix";
}
if ($http_x_forwarded_host = '') {
set $http_x_forwarded_host $host;
}
if ($http_x_forwarded_proto = '') {
set $http_x_forwarded_proto 'http';
}
proxy_pass http://backend;
error_page 500 502 503 504 @badproxy;

View file

@ -74,20 +74,17 @@ def generate_manifest(
"name": config.config["name"],
"icons": [
{
# TODO: Host Header and Proxy Prefix
"src": "img/android-chrome-192x192.png",
"src": f"{ctx.url_prefix}/img/android-chrome-192x192.png",
"type": "image/png",
"sizes": "192x192",
},
{
# TODO: Host Header and Proxy Prefix
"src": "img/android-chrome-512x512.png",
"src": f"{ctx.url_prefix}/img/android-chrome-512x512.png",
"type": "image/png",
"sizes": "512x512",
},
],
# TODO: Host Header and Proxy Prefix
"start_url": "/",
"start_url": f"{ctx.url_prefix}/",
"theme_color": "#24aadd",
"background_color": "#ffffff",
"display": "standalone",

View file

@ -62,7 +62,7 @@ _apple_touch_startup_images = {
def _get_html_template(
meta_tags: Dict = {}, title: str = config.config["name"]
meta_tags: Dict = {}, title: str = config.config["name"], prefix: str = ""
) -> Doc:
doc = Doc()
doc.asis("<!DOCTYPE html>")
@ -73,18 +73,19 @@ def _get_html_template(
doc.stag("meta", name=name, content=content)
with doc.tag("title"):
doc.text(title)
# TODO: Host Header and Proxy Prefix
doc.stag("base", href="/")
doc.stag("link", rel="manifest", href="/manifest.json")
doc.stag("base", href=f"{prefix}/")
doc.stag(
"link", rel="manifest", href=f"{prefix}/api/manifest.json"
)
doc.stag(
"link",
href="css/app.min.css",
href=f"{prefix}/css/app.min.css",
rel="stylesheet",
type="text/css",
)
doc.stag(
"link",
href="css/vendor.min.css",
href=f"{prefix}/css/vendor.min.css",
rel="stylesheet",
type="text/css",
)
@ -92,19 +93,19 @@ def _get_html_template(
"link",
rel="shortcut icon",
type="image/png",
href="img/favicon.png",
href=f"{prefix}/img/favicon.png",
)
doc.stag(
"link",
rel="apple-touch-icon",
sizes="180x180",
href="img/apple-touch-icon.png",
href=f"{prefix}/img/apple-touch-icon.png",
)
for res, media in _apple_touch_startup_images.items():
doc.stag(
"link",
rel="apple-touch-startup-image",
href=f"img/apple-touch-startup-image-{res}.png",
href=f"{prefix}/img/apple-touch-startup-image-{res}.png",
media=" and ".join(
f"({k}: {v})" for k, v in media.items()
),
@ -148,21 +149,25 @@ def get_post_html(
except posts.InvalidPostIdError:
# Return the default template and let the browser JS handle the 404
return _get_html_template()
metadata = {
"og:site_name": config.config["name"],
"og:type": "image",
"og:title": title,
# TODO: Host Header and Proxy Prefix
"og:url": f"{config.config['domain'] or ''}/post/{params['post_id']}",
"og:url": f"{ctx.url_prefix}/post/{params['post_id']}",
}
# Note: ctx.user will always be the anonymous user
if auth.has_privilege(ctx.user, "posts:view"):
# TODO: Host Header and Proxy Prefix
metadata["og:image"] = posts.get_post_content_url(post)
return _get_html_template(meta_tags=metadata, title=title)
return _get_html_template(
meta_tags=metadata, title=title, prefix=ctx.url_prefix
)
@rest.routes.get("/.*", accept="text/html")
def default_route(
ctx: rest.Context, _params: Dict[str, str] = {}
) -> rest.Response:
return _get_html_template()
return _get_html_template(prefix=ctx.url_prefix)

View file

@ -73,6 +73,7 @@ def _get_user(ctx: rest.Context, bump_login: bool) -> Optional[model.User]:
def process_request(ctx: rest.Context) -> None:
""" Bind the user to request. Update last login time if needed. """
if ctx.accept == "application/json":
bump_login = ctx.get_param_as_bool("bump-login", default=False)
auth_user = _get_user(ctx, bump_login)
if auth_user:

View file

@ -13,6 +13,7 @@ def process_request(_ctx: rest.Context) -> None:
@middleware.post_hook
def process_response(ctx: rest.Context) -> None:
if ctx.accept == "application/json":
logger.info(
"%s %s (user=%s, queries=%d)",
ctx.method,
@ -20,3 +21,10 @@ def process_response(ctx: rest.Context) -> None:
ctx.user.name,
db.get_query_count(),
)
elif ctx.accept == "text/html":
logger.info(
"HTML %s (user-agent='%s' queries=%d)",
ctx.url,
ctx.get_header("User-Agent"),
db.get_query_count(),
)

View file

@ -5,7 +5,7 @@ import urllib.parse
from datetime import datetime
from typing import Any, Callable, Dict, Tuple
from szurubooru import db
from szurubooru import config, db
from szurubooru.func import util
from szurubooru.rest import context, errors, middleware, routes
@ -40,6 +40,23 @@ def _create_context(env: Dict[str, Any]) -> context.Context:
path = "/" + env["PATH_INFO"].lstrip("/")
path = path.encode("latin-1").decode("utf-8") # PEP-3333
headers = _get_headers(env)
_raw_accept = headers.get("Accept", "text/html")
if "application/json" in _raw_accept:
accept = "application/json"
elif "text/html" in _raw_accept:
accept = "text/html"
else:
raise errors.HttpNotAcceptable(
"ValidationError",
"This API only supports the following response types: "
"application/json, text/html",
)
if config.config["domain"]:
url_prefix = config.config["domain"].rstrip("/")
else:
url_prefix = headers.get("X-Forwarded-Prefix", "")
files = {}
params = dict(urllib.parse.parse_qsl(env.get("QUERY_STRING", "")))
@ -70,27 +87,17 @@ def _create_context(env: Dict[str, Any]) -> context.Context:
"was incorrect or was not encoded as UTF-8.",
)
return context.Context(env, method, path, headers, params, files)
return context.Context(
env, method, path, headers, accept, url_prefix, params, files
)
def application(
env: Dict[str, Any], start_response: Callable[[str, Any], Any]
) -> Tuple[bytes]:
accept = None
try:
ctx = _create_context(env)
if "application/json" in ctx.get_header("Accept"):
accept = "application/json"
elif "text/html" in (ctx.get_header("Accept") or "text/html"):
accept = "text/html"
else:
raise errors.HttpNotAcceptable(
"ValidationError",
"This API only supports the following response types: "
", ".join(routes.routes.keys()),
)
for url, allowed_methods in routes.routes[accept].items():
for url, allowed_methods in routes.routes[ctx.accept].items():
match = re.fullmatch(url, ctx.url)
if match:
if ctx.method not in allowed_methods:
@ -122,9 +129,9 @@ def application(
finally:
db.session.remove()
start_response("200", [("content-type", accept)])
start_response("200", [("content-type", ctx.accept)])
return (
_serialize_response_body(response, accept).encode("utf-8"),
_serialize_response_body(response, ctx.accept).encode("utf-8"),
)
except Exception as ex:
@ -136,7 +143,7 @@ def application(
except errors.BaseHttpError as ex:
start_response(
"%d %s" % (ex.code, ex.reason),
[("content-type", accept or "application/json")],
[("content-type", "application/json")],
)
blob = {
"name": ex.name,

View file

@ -15,12 +15,16 @@ class Context:
method: str,
url: str,
headers: Dict[str, str] = None,
accept: str = None,
url_prefix: str = None,
params: Request = None,
files: Dict[str, bytes] = None,
) -> None:
self.env = env
self.method = method
self.url = url
self.accept = accept
self.url_prefix = url_prefix
self._headers = headers or {}
self._params = params or {}
self._files = files or {}