diff --git a/client/docker-start.sh b/client/docker-start.sh index 0b6ce37e..335d12a7 100755 --- a/client/docker-start.sh +++ b/client/docker-start.sh @@ -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 diff --git a/client/nginx.conf.docker b/client/nginx.conf.docker index b46f4b5e..36c35363 100644 --- a/client/nginx.conf.docker +++ b/client/nginx.conf.docker @@ -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; diff --git a/server/szurubooru/api/info_api.py b/server/szurubooru/api/info_api.py index d400067e..4dc2e5c7 100644 --- a/server/szurubooru/api/info_api.py +++ b/server/szurubooru/api/info_api.py @@ -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", diff --git a/server/szurubooru/api/opengraph_api.py b/server/szurubooru/api/opengraph_api.py index 3597eb08..2c47326c 100644 --- a/server/szurubooru/api/opengraph_api.py +++ b/server/szurubooru/api/opengraph_api.py @@ -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("") @@ -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) diff --git a/server/szurubooru/middleware/authenticator.py b/server/szurubooru/middleware/authenticator.py index e73b235e..56a5e442 100644 --- a/server/szurubooru/middleware/authenticator.py +++ b/server/szurubooru/middleware/authenticator.py @@ -73,10 +73,11 @@ 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. """ - bump_login = ctx.get_param_as_bool("bump-login", default=False) - auth_user = _get_user(ctx, bump_login) - if auth_user: - ctx.user = auth_user + 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: + ctx.user = auth_user @rest.middleware.pre_hook diff --git a/server/szurubooru/middleware/request_logger.py b/server/szurubooru/middleware/request_logger.py index 79fffbdd..f4ec54f7 100644 --- a/server/szurubooru/middleware/request_logger.py +++ b/server/szurubooru/middleware/request_logger.py @@ -13,10 +13,18 @@ def process_request(_ctx: rest.Context) -> None: @middleware.post_hook def process_response(ctx: rest.Context) -> None: - logger.info( - "%s %s (user=%s, queries=%d)", - ctx.method, - ctx.url, - ctx.user.name, - db.get_query_count(), - ) + if ctx.accept == "application/json": + logger.info( + "%s %s (user=%s, queries=%d)", + ctx.method, + ctx.url, + 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(), + ) diff --git a/server/szurubooru/rest/app.py b/server/szurubooru/rest/app.py index 3f8238dc..a132edd5 100644 --- a/server/szurubooru/rest/app.py +++ b/server/szurubooru/rest/app.py @@ -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, diff --git a/server/szurubooru/rest/context.py b/server/szurubooru/rest/context.py index 40ba0bcb..9f00d0f0 100644 --- a/server/szurubooru/rest/context.py +++ b/server/szurubooru/rest/context.py @@ -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 {}