From d5a223652e3955a7d0739b0f0d0d26a53077702e Mon Sep 17 00:00:00 2001 From: Shyam Sunder Date: Fri, 24 Sep 2021 14:03:36 -0400 Subject: [PATCH 1/6] client+server: implement server-generated Open Graph tags --- client/Dockerfile | 2 +- client/build.js | 31 ----- client/docker-start.sh | 3 - client/nginx.conf.docker | 13 +- server/Dockerfile | 1 + server/requirements.txt | 1 + server/szurubooru/api/__init__.py | 1 + server/szurubooru/api/info_api.py | 28 +++++ server/szurubooru/api/opengraph_api.py | 168 +++++++++++++++++++++++++ server/szurubooru/rest/app.py | 35 ++++-- server/szurubooru/rest/routes.py | 29 +++-- 11 files changed, 256 insertions(+), 56 deletions(-) create mode 100644 server/szurubooru/api/opengraph_api.py diff --git a/client/Dockerfile b/client/Dockerfile index 3ab0016f..92a88219 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -9,7 +9,7 @@ COPY . ./ ARG BUILD_INFO="docker-latest" ARG CLIENT_BUILD_ARGS="" -RUN BASE_URL="__BASEURL__" node build.js --gzip ${CLIENT_BUILD_ARGS} +RUN node build.js --gzip ${CLIENT_BUILD_ARGS} FROM --platform=$BUILDPLATFORM scratch as approot diff --git a/client/build.js b/client/build.js index eaf28a54..649eec6e 100755 --- a/client/build.js +++ b/client/build.js @@ -30,26 +30,6 @@ const external_js = [ 'underscore', ]; -const app_manifest = { - name: 'szurubooru', - icons: [ - { - src: baseUrl() + 'img/android-chrome-192x192.png', - type: 'image/png', - sizes: '192x192' - }, - { - src: baseUrl() + 'img/android-chrome-512x512.png', - type: 'image/png', - sizes: '512x512' - } - ], - start_url: baseUrl(), - theme_color: '#24aadd', - background_color: '#ffffff', - display: 'standalone' -} - // ------------------------------------------------- const fs = require('fs'); @@ -72,10 +52,6 @@ function gzipFile(file) { execSync('gzip -6 -k ' + file); } -function baseUrl() { - return process.env.BASE_URL ? process.env.BASE_URL : '/'; -} - // ------------------------------------------------- function bundleHtml() { @@ -90,10 +66,6 @@ function bundleHtml() { }).trim(); } - const baseHtml = readTextFile('./html/index.htm') - .replace('', ``); - fs.writeFileSync('./public/index.htm', minifyHtml(baseHtml)); - let compiledTemplateJs = [ `'use strict';`, `let _ = require('underscore');`, @@ -266,9 +238,6 @@ function bundleBinaryAssets() { function bundleWebAppFiles() { const Jimp = require('jimp'); - fs.writeFileSync('./public/manifest.json', JSON.stringify(app_manifest)); - console.info('Generated app manifest'); - Promise.all(webapp_icons.map(icon => { return Jimp.read('./img/app.png') .then(file => { diff --git a/client/docker-start.sh b/client/docker-start.sh index 0b2bec8a..0b6ce37e 100755 --- a/client/docker-start.sh +++ b/client/docker-start.sh @@ -3,9 +3,6 @@ # Integrate environment variables sed -i "s|__BACKEND__|${BACKEND_HOST}|" \ /etc/nginx/nginx.conf -sed -i "s|__BASEURL__|${BASE_URL:-/}|g" \ - /var/www/index.htm \ - /var/www/manifest.json # Start server exec nginx diff --git a/client/nginx.conf.docker b/client/nginx.conf.docker index 98c18b35..b46f4b5e 100644 --- a/client/nginx.conf.docker +++ b/client/nginx.conf.docker @@ -69,9 +69,8 @@ http { error_page 404 @notfound; } - location / { + location ~ ^/(js|css|img|fonts)/.*$ { root /var/www; - try_files $uri /index.htm; sendfile on; tcp_nopush on; @@ -79,6 +78,16 @@ http { gzip_static on; gzip_proxied expired no-cache no-store private auth; + + error_page 404 @notfound; + } + + location / { + tcp_nodelay on; + + proxy_pass http://backend; + + error_page 500 502 503 504 @badproxy; } location @unauthorized { diff --git a/server/Dockerfile b/server/Dockerfile index 205c8e4c..73e8a0bd 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -31,6 +31,7 @@ RUN apk --no-cache add \ youtube_dl \ pillow-avif-plugin \ pyheif-pillow-opener \ + yattag \ && apk --no-cache del py3-pip COPY ./ /opt/app/ diff --git a/server/requirements.txt b/server/requirements.txt index 2a09b24b..b2259430 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -11,4 +11,5 @@ pytz>=2018.3 pyRFC3339>=1.0 pillow-avif-plugin>=1.1.0 pyheif-pillow-opener>=0.1.0 +yattag>=1.14.0 youtube_dl diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py index d9b7ecba..0fc9cbc1 100644 --- a/server/szurubooru/api/__init__.py +++ b/server/szurubooru/api/__init__.py @@ -1,5 +1,6 @@ import szurubooru.api.comment_api import szurubooru.api.info_api +import szurubooru.api.opengraph_api import szurubooru.api.password_reset_api import szurubooru.api.pool_api import szurubooru.api.pool_category_api diff --git a/server/szurubooru/api/info_api.py b/server/szurubooru/api/info_api.py index 757b09cf..d400067e 100644 --- a/server/szurubooru/api/info_api.py +++ b/server/szurubooru/api/info_api.py @@ -64,3 +64,31 @@ def get_info(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: ) ret["featuringTime"] = post_feature.time if post_feature else None return ret + + +@rest.routes.get(r"/manifest\.json") +def generate_manifest( + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + return { + "name": config.config["name"], + "icons": [ + { + # TODO: Host Header and Proxy Prefix + "src": "img/android-chrome-192x192.png", + "type": "image/png", + "sizes": "192x192", + }, + { + # TODO: Host Header and Proxy Prefix + "src": "img/android-chrome-512x512.png", + "type": "image/png", + "sizes": "512x512", + }, + ], + # TODO: Host Header and Proxy Prefix + "start_url": "/", + "theme_color": "#24aadd", + "background_color": "#ffffff", + "display": "standalone", + } diff --git a/server/szurubooru/api/opengraph_api.py b/server/szurubooru/api/opengraph_api.py new file mode 100644 index 00000000..3597eb08 --- /dev/null +++ b/server/szurubooru/api/opengraph_api.py @@ -0,0 +1,168 @@ +from typing import Dict + +from yattag import Doc + +from szurubooru import config, model, rest +from szurubooru.func import auth, posts + +_default_meta_tags = { + "viewport": "width=device-width, initial-scale=1, maximum-scale=1", + "theme-color": "#24aadd", + "apple-mobile-web-app-capable": "yes", + "apple-mobile-web-app-status-bar-style": "black", + "msapplication-TileColor": "#ffffff", + "msapplication-TileImage": "/img/mstile-150x150.png", +} + + +_apple_touch_startup_images = { + "640x1136": { + "device-width": "320px", + "device-height": "568px", + "-webkit-device-pixel-ratio": 2, + "orientation": "portrait", + }, + "750x1294": { + "device-width": "375px", + "device-height": "667px", + "-webkit-device-pixel-ratio": 2, + "orientation": "portrait", + }, + "1242x2148": { + "device-width": "414px", + "device-height": "736px", + "-webkit-device-pixel-ratio": 3, + "orientation": "portrait", + }, + "1125x2436": { + "device-width": "375px", + "device-height": "812px", + "-webkit-device-pixel-ratio": 3, + "orientation": "portrait", + }, + "1536x2048": { + "min-device-width": "768px", + "max-device-width": "1024px", + "-webkit-min-device-pixel-ratio": 2, + "orientation": "portrait", + }, + "1668x2224": { + "min-device-width": "834px", + "max-device-width": "834px", + "-webkit-min-device-pixel-ratio": 2, + "orientation": "portrait", + }, + "2048x2732": { + "min-device-width": "1024px", + "max-device-width": "1024px", + "-webkit-min-device-pixel-ratio": 2, + "orientation": "portrait", + }, +} + + +def _get_html_template( + meta_tags: Dict = {}, title: str = config.config["name"] +) -> Doc: + doc = Doc() + doc.asis("") + with doc.tag("html"): + with doc.tag("head"): + doc.stag("meta", charset="utf-8") + for name, content in {**_default_meta_tags, **meta_tags}.items(): + 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( + "link", + href="css/app.min.css", + rel="stylesheet", + type="text/css", + ) + doc.stag( + "link", + href="css/vendor.min.css", + rel="stylesheet", + type="text/css", + ) + doc.stag( + "link", + rel="shortcut icon", + type="image/png", + href="img/favicon.png", + ) + doc.stag( + "link", + rel="apple-touch-icon", + sizes="180x180", + href="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", + media=" and ".join( + f"({k}: {v})" for k, v in media.items() + ), + ) + with doc.tag("body"): + with doc.tag("div", id="top-navigation-holder"): + pass + with doc.tag("div", id="content-holder"): + pass + with doc.tag( + "script", type="text/javascript", src="js/vendor.min.js" + ): + pass + with doc.tag( + "script", type="text/javascript", src="js/app.min.js" + ): + pass + return doc + + +def _get_post_id(params: Dict[str, str]) -> int: + try: + return int(params["post_id"]) + except TypeError: + raise posts.InvalidPostIdError( + "Invalid post ID: %r." % params["post_id"] + ) + + +def _get_post(params: Dict[str, str]) -> model.Post: + return posts.get_post_by_id(_get_post_id(params)) + + +@rest.routes.get("/post/(?P[^/]+)/?", accept="text/html") +def get_post_html( + ctx: rest.Context, params: Dict[str, str] = {} +) -> rest.Response: + try: + post = _get_post(params) + title = f"Post {_get_post_id(params)} - {config.config['name']}" + 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']}", + } + 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) + + +@rest.routes.get("/.*", accept="text/html") +def default_route( + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + return _get_html_template() diff --git a/server/szurubooru/rest/app.py b/server/szurubooru/rest/app.py index a6f10fbc..3f8238dc 100644 --- a/server/szurubooru/rest/app.py +++ b/server/szurubooru/rest/app.py @@ -11,15 +11,19 @@ from szurubooru.rest import context, errors, middleware, routes def _json_serializer(obj: Any) -> str: - """ JSON serializer for objects not serializable by default JSON code """ + """JSON serializer for objects not serializable by default JSON code""" if isinstance(obj, datetime): serial = obj.isoformat("T") + "Z" return serial raise TypeError("Type not serializable") -def _dump_json(obj: Any) -> str: - return json.dumps(obj, default=_json_serializer, indent=2) +def _serialize_response_body(obj: Any, accept: str) -> str: + if accept == "application/json": + return json.dumps(obj, default=_json_serializer, indent=2) + if accept == "text/html": + return obj.getvalue() + raise ValueError("Unhandled response type %s" % accept) def _get_headers(env: Dict[str, Any]) -> Dict[str, str]: @@ -72,14 +76,21 @@ def _create_context(env: Dict[str, Any]) -> context.Context: def application( env: Dict[str, Any], start_response: Callable[[str, Any], Any] ) -> Tuple[bytes]: + accept = None try: ctx = _create_context(env) - if "application/json" not in ctx.get_header("Accept"): + 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 JSON responses." + "ValidationError", + "This API only supports the following response types: " + ", ".join(routes.routes.keys()), ) - for url, allowed_methods in routes.routes.items(): + for url, allowed_methods in routes.routes[accept].items(): match = re.fullmatch(url, ctx.url) if match: if ctx.method not in allowed_methods: @@ -111,8 +122,10 @@ def application( finally: db.session.remove() - start_response("200", [("content-type", "application/json")]) - return (_dump_json(response).encode("utf-8"),) + start_response("200", [("content-type", accept)]) + return ( + _serialize_response_body(response, accept).encode("utf-8"), + ) except Exception as ex: for exception_type, ex_handler in errors.error_handlers.items(): @@ -123,7 +136,7 @@ def application( except errors.BaseHttpError as ex: start_response( "%d %s" % (ex.code, ex.reason), - [("content-type", "application/json")], + [("content-type", accept or "application/json")], ) blob = { "name": ex.name, @@ -133,4 +146,6 @@ def application( if ex.extra_fields is not None: for key, value in ex.extra_fields.items(): blob[key] = value - return (_dump_json(blob).encode("utf-8"),) + return ( + _serialize_response_body(blob, "application/json").encode("utf-8"), + ) diff --git a/server/szurubooru/rest/routes.py b/server/szurubooru/rest/routes.py index b0946fb3..f0c4e7a8 100644 --- a/server/szurubooru/rest/routes.py +++ b/server/szurubooru/rest/routes.py @@ -4,36 +4,47 @@ from typing import Callable, Dict from szurubooru.rest.context import Context, Response RouteHandler = Callable[[Context, Dict[str, str]], Response] -routes = defaultdict(dict) # type: Dict[str, Dict[str, RouteHandler]] +routes = { # type: Dict[Dict[str, Dict[str, RouteHandler]]] + "application/json": defaultdict(dict), + "text/html": defaultdict(dict), +} -def get(url: str) -> Callable[[RouteHandler], RouteHandler]: +def get( + url: str, accept: str = "application/json" +) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: - routes[url]["GET"] = handler + routes[accept][url]["GET"] = handler return handler return wrapper -def put(url: str) -> Callable[[RouteHandler], RouteHandler]: +def put( + url: str, accept: str = "application/json" +) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: - routes[url]["PUT"] = handler + routes[accept][url]["PUT"] = handler return handler return wrapper -def post(url: str) -> Callable[[RouteHandler], RouteHandler]: +def post( + url: str, accept: str = "application/json" +) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: - routes[url]["POST"] = handler + routes[accept][url]["POST"] = handler return handler return wrapper -def delete(url: str) -> Callable[[RouteHandler], RouteHandler]: +def delete( + url: str, accept: str = "application/json" +) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: - routes[url]["DELETE"] = handler + routes[accept][url]["DELETE"] = handler return handler return wrapper From 636498ad3852a81b59b4364aa959283917c2423f Mon Sep 17 00:00:00 2001 From: Shyam Sunder Date: Fri, 24 Sep 2021 20:04:56 -0400 Subject: [PATCH 2/6] 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 --- client/docker-start.sh | 4 ++ client/nginx.conf.docker | 34 +++++++++++++++ server/szurubooru/api/info_api.py | 9 ++-- server/szurubooru/api/opengraph_api.py | 31 +++++++------ server/szurubooru/middleware/authenticator.py | 9 ++-- .../szurubooru/middleware/request_logger.py | 22 +++++++--- server/szurubooru/rest/app.py | 43 +++++++++++-------- server/szurubooru/rest/context.py | 4 ++ 8 files changed, 108 insertions(+), 48 deletions(-) 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 {} From fff0999e6ae79090a300ecf1095ebd293ed15876 Mon Sep 17 00:00:00 2001 From: Shyam Sunder Date: Sat, 25 Sep 2021 12:20:28 -0400 Subject: [PATCH 3/6] client+server: allow for wildcards in Accept headers --- client/html/index.htm | 32 ------------- client/nginx.conf.docker | 18 +------ doc/INSTALL.md | 26 ++++++---- doc/example.env | 4 -- docker-compose.yml | 1 - server/szurubooru/api/info_api.py | 2 +- server/szurubooru/api/opengraph_api.py | 6 +-- server/szurubooru/api/password_reset_api.py | 10 +--- server/szurubooru/rest/app.py | 47 ++++++++++++------- server/szurubooru/rest/routes.py | 16 +++---- server/szurubooru/tests/api/test_info.py | 26 ++++++++++ server/szurubooru/tests/api/test_opengraph.py | 40 ++++++++++++++++ .../tests/api/test_password_reset.py | 4 +- server/szurubooru/tests/conftest.py | 3 +- .../tests/middleware/test_authenticator.py | 30 ++++++++++-- 15 files changed, 156 insertions(+), 109 deletions(-) delete mode 100644 client/html/index.htm create mode 100644 server/szurubooru/tests/api/test_opengraph.py diff --git a/client/html/index.htm b/client/html/index.htm deleted file mode 100644 index 00728903..00000000 --- a/client/html/index.htm +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - Loading... - - - - - - - - - - - - - - - -
-
- - - - diff --git a/client/nginx.conf.docker b/client/nginx.conf.docker index 36c35363..fef96bd1 100644 --- a/client/nginx.conf.docker +++ b/client/nginx.conf.docker @@ -53,14 +53,6 @@ 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; } @@ -111,16 +103,10 @@ http { return 406 "API requests should be sent to the /api prefix"; } - if ($http_x_forwarded_host = '') { - set $http_x_forwarded_host $host; + if ($request_uri ~* "/(.*)") { + proxy_pass http://backend/html/$1; } - 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/doc/INSTALL.md b/doc/INSTALL.md index d978e4a8..90e24c16 100644 --- a/doc/INSTALL.md +++ b/doc/INSTALL.md @@ -89,8 +89,12 @@ user@host:szuru$ docker-compose down Some users may wish to access the service at a different base URI, such as `http://example.com/szuru/`, commonly when sharing multiple HTTP - services on one domain using a reverse proxy. In this case, simply set - `BASE_URL="/szuru/"` in your `.env` file. + services on one domain using a reverse proxy. This can be configured in + either of the following ways: + + - Set the 'domain' value in `config.yaml` to include the prefix, i.e.: + `domain: "http://example.com/szuru" # omit trailing slash` + - Configure the reverse proxy to pass the `X-Forwarded-Prefix` header. Note that this will require a reverse proxy to function. You should set your reverse proxy to proxy `http(s)://example.com/szuru` to @@ -102,14 +106,16 @@ user@host:szuru$ docker-compose down proxy_http_version 1.1; proxy_pass http:///; - proxy_set_header Host $http_host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Scheme $scheme; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Script-Name /szuru; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-Prefix /szuru; + + // optional... + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; } ``` diff --git a/doc/example.env b/doc/example.env index 59e1e859..3ce7d3bc 100644 --- a/doc/example.env +++ b/doc/example.env @@ -10,10 +10,6 @@ BUILD_INFO=latest # otherwise the port specified here will be publicly accessible PORT=8080 -# URL base to run szurubooru under -# See "Additional Features" section in INSTALL.md -BASE_URL=/ - # Directory to store image data MOUNT_DATA=/var/local/szurubooru/data diff --git a/docker-compose.yml b/docker-compose.yml index 1da23bd6..de3e7e69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,7 +31,6 @@ services: - server environment: BACKEND_HOST: server - BASE_URL: volumes: - "${MOUNT_DATA}:/data:ro" ports: diff --git a/server/szurubooru/api/info_api.py b/server/szurubooru/api/info_api.py index 4dc2e5c7..d237631f 100644 --- a/server/szurubooru/api/info_api.py +++ b/server/szurubooru/api/info_api.py @@ -66,7 +66,7 @@ def get_info(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: return ret -@rest.routes.get(r"/manifest\.json") +@rest.routes.get(r"/manifest(?:\.json)?") def generate_manifest( ctx: rest.Context, _params: Dict[str, str] = {} ) -> rest.Response: diff --git a/server/szurubooru/api/opengraph_api.py b/server/szurubooru/api/opengraph_api.py index 2c47326c..5f7017ac 100644 --- a/server/szurubooru/api/opengraph_api.py +++ b/server/szurubooru/api/opengraph_api.py @@ -123,7 +123,7 @@ def _get_html_template( "script", type="text/javascript", src="js/app.min.js" ): pass - return doc + return doc.getvalue() def _get_post_id(params: Dict[str, str]) -> int: @@ -139,7 +139,7 @@ def _get_post(params: Dict[str, str]) -> model.Post: return posts.get_post_by_id(_get_post_id(params)) -@rest.routes.get("/post/(?P[^/]+)/?", accept="text/html") +@rest.routes.get("/html/post/(?P[^/]+)/?", accept="text/html") def get_post_html( ctx: rest.Context, params: Dict[str, str] = {} ) -> rest.Response: @@ -166,7 +166,7 @@ def get_post_html( ) -@rest.routes.get("/.*", accept="text/html") +@rest.routes.get("/html/.*", accept="text/html") def default_route( ctx: rest.Context, _params: Dict[str, str] = {} ) -> rest.Response: diff --git a/server/szurubooru/api/password_reset_api.py b/server/szurubooru/api/password_reset_api.py index e0e31b7d..c0e0d60f 100644 --- a/server/szurubooru/api/password_reset_api.py +++ b/server/szurubooru/api/password_reset_api.py @@ -25,15 +25,7 @@ def start_password_reset( ) token = auth.generate_authentication_token(user) - if config.config["domain"]: - url = config.config["domain"] - elif "HTTP_ORIGIN" in ctx.env: - url = ctx.env["HTTP_ORIGIN"].rstrip("/") - elif "HTTP_REFERER" in ctx.env: - url = ctx.env["HTTP_REFERER"].rstrip("/") - else: - url = "" - url += "/password-reset/%s:%s" % (user.name, token) + url = f"{ctx.url_prefix}/password-reset/{user.name}:{token}" mailer.send_mail( config.config["smtp"]["from"], diff --git a/server/szurubooru/rest/app.py b/server/szurubooru/rest/app.py index a132edd5..5f92f0a1 100644 --- a/server/szurubooru/rest/app.py +++ b/server/szurubooru/rest/app.py @@ -21,8 +21,8 @@ def _json_serializer(obj: Any) -> str: def _serialize_response_body(obj: Any, accept: str) -> str: if accept == "application/json": return json.dumps(obj, default=_json_serializer, indent=2) - if accept == "text/html": - return obj.getvalue() + if "text/" in accept: + return obj raise ValueError("Unhandled response type %s" % accept) @@ -40,18 +40,6 @@ 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("/") @@ -88,7 +76,13 @@ def _create_context(env: Dict[str, Any]) -> context.Context: ) return context.Context( - env, method, path, headers, accept, url_prefix, params, files + env=env, + method=method, + url=path, + headers=headers, + url_prefix=url_prefix, + params=params, + files=files, ) @@ -97,15 +91,32 @@ def application( ) -> Tuple[bytes]: try: ctx = _create_context(env) - for url, allowed_methods in routes.routes[ctx.accept].items(): + for url, allowed_methods in routes.routes.items(): match = re.fullmatch(url, ctx.url) if match: if ctx.method not in allowed_methods: raise errors.HttpMethodNotAllowed( "ValidationError", - "Allowed methods: %r" % allowed_methods, + "Allowed methods: %s" + % ", ".join(allowed_methods.keys()), ) - handler = allowed_methods[ctx.method] + handler, allowed_accept = allowed_methods[ctx.method] + if not any( + map( + lambda a: a in ctx.get_header("Accept"), + [ + allowed_accept, + allowed_accept.split("/")[0] + "/*", + "*/*", + ], + ) + ): + raise errors.HttpNotAcceptable( + "ValidationError", + "This route only supports %s responses." + % allowed_accept, + ) + ctx.accept = allowed_accept break else: raise errors.HttpNotFound( diff --git a/server/szurubooru/rest/routes.py b/server/szurubooru/rest/routes.py index f0c4e7a8..c3815c93 100644 --- a/server/szurubooru/rest/routes.py +++ b/server/szurubooru/rest/routes.py @@ -1,20 +1,18 @@ from collections import defaultdict -from typing import Callable, Dict +from typing import Callable, Dict, Tuple from szurubooru.rest.context import Context, Response RouteHandler = Callable[[Context, Dict[str, str]], Response] -routes = { # type: Dict[Dict[str, Dict[str, RouteHandler]]] - "application/json": defaultdict(dict), - "text/html": defaultdict(dict), -} +routes = defaultdict(dict) +# type: Dict[str, Dict[str, Tuple[RouteHandler, str]]] def get( url: str, accept: str = "application/json" ) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: - routes[accept][url]["GET"] = handler + routes[url]["GET"] = (handler, accept) return handler return wrapper @@ -24,7 +22,7 @@ def put( url: str, accept: str = "application/json" ) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: - routes[accept][url]["PUT"] = handler + routes[url]["PUT"] = (handler, accept) return handler return wrapper @@ -34,7 +32,7 @@ def post( url: str, accept: str = "application/json" ) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: - routes[accept][url]["POST"] = handler + routes[url]["POST"] = (handler, accept) return handler return wrapper @@ -44,7 +42,7 @@ def delete( url: str, accept: str = "application/json" ) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: - routes[accept][url]["DELETE"] = handler + routes[url]["DELETE"] = (handler, accept) return handler return wrapper diff --git a/server/szurubooru/tests/api/test_info.py b/server/szurubooru/tests/api/test_info.py index 37099e8d..3df3958f 100644 --- a/server/szurubooru/tests/api/test_info.py +++ b/server/szurubooru/tests/api/test_info.py @@ -94,3 +94,29 @@ def test_info_api( "serverTime": datetime(2016, 1, 3, 13, 1), "config": expected_config_key, } + + +def test_manifest(config_injector, context_factory): + config_injector({"name": "test installation"}) + ctx = context_factory() + ctx.url_prefix = "/someprefix" + expected_manifest = { + "name": "test installation", + "icons": [ + { + "src": "/someprefix/img/android-chrome-192x192.png", + "type": "image/png", + "sizes": "192x192", + }, + { + "src": "/someprefix/img/android-chrome-512x512.png", + "type": "image/png", + "sizes": "512x512", + }, + ], + "start_url": "/someprefix/", + "theme_color": "#24aadd", + "background_color": "#ffffff", + "display": "standalone", + } + assert api.info_api.generate_manifest(ctx) == expected_manifest diff --git a/server/szurubooru/tests/api/test_opengraph.py b/server/szurubooru/tests/api/test_opengraph.py new file mode 100644 index 00000000..8ab05aeb --- /dev/null +++ b/server/szurubooru/tests/api/test_opengraph.py @@ -0,0 +1,40 @@ +from unittest.mock import patch + +import pytest +import yattag + +from szurubooru import api, db +from szurubooru.func import auth, posts + + +def _make_meta_tag(name, content): + doc = yattag.Doc() + doc.stag("meta", name=name, content=content) + return doc.getvalue() + + +@pytest.mark.parametrize("view_priv", [True, False]) +def test_get_post_html( + config_injector, context_factory, post_factory, view_priv +): + config_injector( + { + "name": "test installation", + "data_url": "data/", + } + ) + ctx = context_factory() + ctx.url_prefix = "/someprefix" + db.session.add(post_factory(id=1)) + db.session.flush() + with patch("szurubooru.func.auth.has_privilege"), patch( + "szurubooru.func.posts.get_post_content_url" + ): + auth.has_privilege.return_value = view_priv + posts.get_post_content_url.return_value = "/content-url" + ret = api.opengraph_api.get_post_html(ctx, {"post_id": 1}) + + assert _make_meta_tag("og:site_name", "test installation") in ret + assert _make_meta_tag("og:title", "Post 1 - test installation") in ret + if view_priv: + assert _make_meta_tag("og:image", "/content-url") in ret diff --git a/server/szurubooru/tests/api/test_password_reset.py b/server/szurubooru/tests/api/test_password_reset.py index bf1ab5c7..d6195880 100644 --- a/server/szurubooru/tests/api/test_password_reset.py +++ b/server/szurubooru/tests/api/test_password_reset.py @@ -27,11 +27,13 @@ def test_reset_sending_email(context_factory, user_factory): ) ) db.session.flush() + ctx = context_factory() + ctx.url_prefix = "http://example.com" for initiating_user in ["u1", "user@example.com"]: with patch("szurubooru.func.mailer.send_mail"): assert ( api.password_reset_api.start_password_reset( - context_factory(), {"user_name": initiating_user} + ctx, {"user_name": initiating_user} ) == {} ) diff --git a/server/szurubooru/tests/conftest.py b/server/szurubooru/tests/conftest.py index 280987ca..fb773498 100644 --- a/server/szurubooru/tests/conftest.py +++ b/server/szurubooru/tests/conftest.py @@ -67,12 +67,13 @@ def nontransacted_session(query_logger, postgresql_db): @pytest.fixture def context_factory(session): - def factory(params=None, files=None, user=None, headers=None): + def factory(params=None, files=None, user=None, headers=None, accept=None): ctx = rest.Context( env={"HTTP_ORIGIN": "http://example.com"}, method=None, url=None, headers=headers or {}, + accept=accept or None, params=params or {}, files=files or {}, ) diff --git a/server/szurubooru/tests/middleware/test_authenticator.py b/server/szurubooru/tests/middleware/test_authenticator.py index 9a4a3cc6..325b27c2 100644 --- a/server/szurubooru/tests/middleware/test_authenticator.py +++ b/server/szurubooru/tests/middleware/test_authenticator.py @@ -9,11 +9,26 @@ from szurubooru.rest import errors def test_process_request_no_header(context_factory): - ctx = context_factory() + ctx = context_factory(accept="application/json") authenticator.process_request(ctx) assert ctx.user.name is None +def test_process_request_non_rest(context_factory, user_factory): + user = user_factory() + ctx = context_factory( + headers={"Authorization": "Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk"}, + accept="text/html", + ) + with patch("szurubooru.func.auth.is_valid_password"), patch( + "szurubooru.func.users.get_user_by_name" + ): + users.get_user_by_name.return_value = user + auth.is_valid_password.return_value = True + authenticator.process_request(ctx) + assert ctx.user.name is None + + def test_process_request_bump_login(context_factory, user_factory): user = user_factory() db.session.add(user) @@ -21,6 +36,7 @@ def test_process_request_bump_login(context_factory, user_factory): ctx = context_factory( headers={"Authorization": "Basic dGVzdFVzZXI6dGVzdFRva2Vu"}, params={"bump-login": "true"}, + accept="application/json", ) with patch("szurubooru.func.auth.is_valid_password"), patch( "szurubooru.func.users.get_user_by_name" @@ -40,6 +56,7 @@ def test_process_request_bump_login_with_token( ctx = context_factory( headers={"Authorization": "Token dGVzdFVzZXI6dGVzdFRva2Vu"}, params={"bump-login": "true"}, + accept="application/json", ) with patch("szurubooru.func.auth.is_valid_token"), patch( "szurubooru.func.users.get_user_by_name" @@ -55,7 +72,8 @@ def test_process_request_bump_login_with_token( def test_process_request_basic_auth_valid(context_factory, user_factory): user = user_factory() ctx = context_factory( - headers={"Authorization": "Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk"} + headers={"Authorization": "Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk"}, + accept="application/json", ) with patch("szurubooru.func.auth.is_valid_password"), patch( "szurubooru.func.users.get_user_by_name" @@ -69,7 +87,8 @@ def test_process_request_basic_auth_valid(context_factory, user_factory): def test_process_request_token_auth_valid(context_factory, user_token_factory): user_token = user_token_factory() ctx = context_factory( - headers={"Authorization": "Token dGVzdFVzZXI6dGVzdFRva2Vu"} + headers={"Authorization": "Token dGVzdFVzZXI6dGVzdFRva2Vu"}, + accept="application/json", ) with patch("szurubooru.func.auth.is_valid_token"), patch( "szurubooru.func.users.get_user_by_name" @@ -82,6 +101,9 @@ def test_process_request_token_auth_valid(context_factory, user_token_factory): def test_process_request_bad_header(context_factory): - ctx = context_factory(headers={"Authorization": "Secret SuperSecretValue"}) + ctx = context_factory( + headers={"Authorization": "Secret SuperSecretValue"}, + accept="application/json", + ) with pytest.raises(errors.HttpBadRequest): authenticator.process_request(ctx) From 299ebfc4c8a51ca68765ba79d5f067541b18282a Mon Sep 17 00:00:00 2001 From: Shyam Sunder Date: Mon, 27 Sep 2021 16:10:41 -0400 Subject: [PATCH 4/6] server/opengraph: add remaining post tags --- server/szurubooru/api/opengraph_api.py | 34 ++++++++-- server/szurubooru/tests/api/test_opengraph.py | 62 ++++++++++++++++--- 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/server/szurubooru/api/opengraph_api.py b/server/szurubooru/api/opengraph_api.py index 5f7017ac..f71bc853 100644 --- a/server/szurubooru/api/opengraph_api.py +++ b/server/szurubooru/api/opengraph_api.py @@ -145,22 +145,44 @@ def get_post_html( ) -> rest.Response: try: post = _get_post(params) - title = f"Post {_get_post_id(params)} - {config.config['name']}" + title = f"{config.config['name']} - post {_get_post_id(params)}" 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, "og:url": f"{ctx.url_prefix}/post/{params['post_id']}", + "og:title": title, + "twitter:title": title, + "og:type": "article", } # 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) - + metadata["og:article:published_time"] = post.creation_time.isoformat() + if post.last_edit_time: + metadata[ + "og:article:modified_time" + ] = post.last_edit_time.isoformat() + metadata["og:image:alt"] = " ".join( + tag.first_name for tag in post.tags + ) + if post.type in (model.Post.TYPE_VIDEO): + metadata["twitter:card"] = "player" + metadata["og:video:url"] = posts.get_post_content_url(post) + metadata["twitter:player:stream"] = posts.get_post_content_url( + post + ) + metadata["og:image:url"] = posts.get_post_thumbnail_url(post) + if post.canvas_width and post.canvas_height: + metadata["og:video:width"] = str(post.canvas_width) + metadata["og:video:height"] = str(post.canvas_height) + metadata["twitter:player:width"] = str(post.canvas_width) + metadata["twitter:player:height"] = str(post.canvas_height) + else: + metadata["twitter:card"] = "summary_large_image" + metadata["og:image:url"] = posts.get_post_content_url(post) + metadata["twitter:image"] = posts.get_post_content_url(post) return _get_html_template( meta_tags=metadata, title=title, prefix=ctx.url_prefix ) diff --git a/server/szurubooru/tests/api/test_opengraph.py b/server/szurubooru/tests/api/test_opengraph.py index 8ab05aeb..4a63ecbc 100644 --- a/server/szurubooru/tests/api/test_opengraph.py +++ b/server/szurubooru/tests/api/test_opengraph.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest import yattag -from szurubooru import api, db +from szurubooru import api, db, model from szurubooru.func import auth, posts @@ -14,8 +14,11 @@ def _make_meta_tag(name, content): @pytest.mark.parametrize("view_priv", [True, False]) +@pytest.mark.parametrize( + "post_type", [model.Post.TYPE_IMAGE, model.Post.TYPE_VIDEO] +) def test_get_post_html( - config_injector, context_factory, post_factory, view_priv + config_injector, context_factory, post_factory, view_priv, post_type ): config_injector( { @@ -25,16 +28,61 @@ def test_get_post_html( ) ctx = context_factory() ctx.url_prefix = "/someprefix" - db.session.add(post_factory(id=1)) + post = post_factory(id=1, type=post_type) + post.canvas_width = 1920 + post.canvas_height = 1080 + db.session.add(post) db.session.flush() with patch("szurubooru.func.auth.has_privilege"), patch( "szurubooru.func.posts.get_post_content_url" - ): + ), patch("szurubooru.func.posts.get_post_thumbnail_url"): auth.has_privilege.return_value = view_priv posts.get_post_content_url.return_value = "/content-url" + posts.get_post_thumbnail_url.return_value = "/thumbnail-url" ret = api.opengraph_api.get_post_html(ctx, {"post_id": 1}) assert _make_meta_tag("og:site_name", "test installation") in ret - assert _make_meta_tag("og:title", "Post 1 - test installation") in ret - if view_priv: - assert _make_meta_tag("og:image", "/content-url") in ret + assert _make_meta_tag("og:url", "/someprefix/post/1") in ret + assert _make_meta_tag("og:title", "test installation - post 1") in ret + assert _make_meta_tag("twitter:title", "test installation - post 1") in ret + assert _make_meta_tag("og:type", "article") in ret + assert ( + bool( + _make_meta_tag("og:article:published_time", "1996-01-01T00:00:00") + in ret + ) + == view_priv + ) + if post_type == model.Post.TYPE_VIDEO: + assert ( + bool(_make_meta_tag("twitter:card", "player") in ret) == view_priv + ) + assert ( + bool( + _make_meta_tag("twitter:player:stream", "/content-url") in ret + ) + == view_priv + ) + assert ( + bool(_make_meta_tag("og:video:url", "/content-url") in ret) + == view_priv + ) + assert ( + bool(_make_meta_tag("og:image:url", "/thumbnail-url") in ret) + == view_priv + ) + assert ( + bool(_make_meta_tag("og:video:width", "1920") in ret) == view_priv + ) + assert ( + bool(_make_meta_tag("og:video:height", "1080") in ret) == view_priv + ) + else: + assert ( + bool(_make_meta_tag("twitter:card", "summary_large_image") in ret) + == view_priv + ) + assert ( + bool(_make_meta_tag("twitter:image", "/content-url") in ret) + == view_priv + ) From 36a614d9549e88cf94bf0bc54da663782659a400 Mon Sep 17 00:00:00 2001 From: Shyam Sunder Date: Thu, 30 Sep 2021 17:21:29 -0400 Subject: [PATCH 5/6] server/posts: generate appropriate URIs using config parameters only --- client/nginx.conf.docker | 9 +- doc/INSTALL.md | 15 ++-- server/config.yaml.dist | 22 ++++- server/szurubooru/api/info_api.py | 8 +- server/szurubooru/api/opengraph_api.py | 57 +++++++----- server/szurubooru/api/password_reset_api.py | 5 +- server/szurubooru/api/pool_api.py | 3 +- server/szurubooru/api/post_api.py | 4 +- server/szurubooru/config.py | 1 - server/szurubooru/facade.py | 8 +- server/szurubooru/func/pools.py | 6 +- server/szurubooru/func/posts.py | 42 ++++----- server/szurubooru/func/util.py | 34 ++++++- server/szurubooru/rest/app.py | 6 -- server/szurubooru/rest/context.py | 2 - server/szurubooru/tests/api/test_info.py | 9 +- server/szurubooru/tests/api/test_opengraph.py | 39 +++++--- .../tests/api/test_password_reset.py | 6 +- .../tests/api/test_pool_updating.py | 22 +++-- .../tests/api/test_post_creating.py | 55 ++++++------ server/szurubooru/tests/func/test_posts.py | 58 +++++------- server/szurubooru/tests/func/test_util.py | 90 +++++++++++++++++++ 22 files changed, 314 insertions(+), 187 deletions(-) diff --git a/client/nginx.conf.docker b/client/nginx.conf.docker index fef96bd1..1ce531d7 100644 --- a/client/nginx.conf.docker +++ b/client/nginx.conf.docker @@ -20,7 +20,11 @@ http { 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; + levels=1:2 + keys_zone=spa_cache:4m + max_size=50m + inactive=60m + use_temp_path=off; upstream backend { server __BACKEND__:6666; @@ -88,6 +92,9 @@ http { location / { tcp_nodelay on; + # remove unneeded auth headers to improve caching + proxy_set_header Authorization ""; + proxy_cache spa_cache; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; proxy_cache_background_update on; diff --git a/doc/INSTALL.md b/doc/INSTALL.md index 90e24c16..fda1e9be 100644 --- a/doc/INSTALL.md +++ b/doc/INSTALL.md @@ -80,8 +80,8 @@ user@host:szuru$ docker-compose down If you want to host your website on, (`http://example.com/`) but want to serve the images on a different domain, (`http://static.example.com/`) - then you can run the backend container with an additional environment - variable `DATA_URL=http://static.example.com/`. Make sure that this + then you can configure the `data_url` variable in your `config.yaml` + (ex: `data_url: http://static.example.com/`). Make sure that this additional host has access contents to the `/data` volume mounted in the backend. @@ -89,12 +89,9 @@ user@host:szuru$ docker-compose down Some users may wish to access the service at a different base URI, such as `http://example.com/szuru/`, commonly when sharing multiple HTTP - services on one domain using a reverse proxy. This can be configured in - either of the following ways: - - - Set the 'domain' value in `config.yaml` to include the prefix, i.e.: - `domain: "http://example.com/szuru" # omit trailing slash` - - Configure the reverse proxy to pass the `X-Forwarded-Prefix` header. + services on one domain using a reverse proxy. For szurubooru to handle + links properly, you must configure the reverse proxy to pass the new + URL prefix (in this case `/szuru`) in the `X-Forwarded-Prefix` header. Note that this will require a reverse proxy to function. You should set your reverse proxy to proxy `http(s)://example.com/szuru` to @@ -111,7 +108,7 @@ user@host:szuru$ docker-compose down proxy_set_header Connection "upgrade"; proxy_set_header X-Forwarded-Prefix /szuru; - // optional... + # optional... proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Scheme $scheme; proxy_set_header X-Real-IP $remote_addr; diff --git a/server/config.yaml.dist b/server/config.yaml.dist index bc4e3630..6936423f 100644 --- a/server/config.yaml.dist +++ b/server/config.yaml.dist @@ -3,11 +3,28 @@ # shown in the website title and on the front page name: szurubooru -# full url to the homepage of this szurubooru site, with no trailing slash -domain: # example: http://example.com # used to salt the users' password hashes and generate filenames for static content secret: change +# set to the root web address for your instance +# example values: +# - `/` (default) is used when the domain is unknown +# - `https://szuru.example.com/` if you know the specific domain +# and is required if you want email-based password reset +# - `/baseprefix` if you want to host szurubooru on a specific +# prefix and share the domain with other applications +# - `https://www.example.com/szuru` combines both of the above +# also see: "Setting a specific base URI for proxying" in INSTALL.md +base_url: / + +# !!should not be changed for the normal docker installation!! +# set to the root web address for static image content +# if it is a relative path with no leading `/`, then this will be +# appended to the base url. +# see: "Using a seperate domain to host static files" in INSTALL.md +# for more info on when to modify +data_url: data/ + # Delete thumbnails and source files on post delete # Original functionality is no, to mitigate the impacts of admins going # on unchecked post purges. @@ -171,7 +188,6 @@ privileges: ## ONLY SET THESE IF DEPLOYING OUTSIDE OF DOCKER #debug: 0 # generate server logs? #show_sql: 0 # show sql in server logs? -#data_url: /data/ #data_dir: /var/www/data ## usage: schema://user:password@host:port/database_name ## example: postgres://szuru:dog@localhost:5432/szuru_test diff --git a/server/szurubooru/api/info_api.py b/server/szurubooru/api/info_api.py index d237631f..e73a6e5c 100644 --- a/server/szurubooru/api/info_api.py +++ b/server/szurubooru/api/info_api.py @@ -45,7 +45,7 @@ def get_info(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: "defaultUserRank": config.config["default_rank"], "enableSafety": config.config["enable_safety"], "contactEmail": config.config["contact_email"], - "canSendMails": bool(config.config["smtp"]["host"]), + "canSendMails": util.can_send_mail(), "privileges": util.snake_case_to_lower_camel_case_keys( config.config["privileges"] ), @@ -74,17 +74,17 @@ def generate_manifest( "name": config.config["name"], "icons": [ { - "src": f"{ctx.url_prefix}/img/android-chrome-192x192.png", + "src": util.add_url_prefix("/img/android-chrome-192x192.png"), "type": "image/png", "sizes": "192x192", }, { - "src": f"{ctx.url_prefix}/img/android-chrome-512x512.png", + "src": util.add_url_prefix("/img/android-chrome-512x512.png"), "type": "image/png", "sizes": "512x512", }, ], - "start_url": f"{ctx.url_prefix}/", + "start_url": util.add_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 f71bc853..3002dd67 100644 --- a/server/szurubooru/api/opengraph_api.py +++ b/server/szurubooru/api/opengraph_api.py @@ -1,9 +1,9 @@ -from typing import Dict +from typing import Callable, Dict from yattag import Doc from szurubooru import config, model, rest -from szurubooru.func import auth, posts +from szurubooru.func import auth, posts, util _default_meta_tags = { "viewport": "width=device-width, initial-scale=1, maximum-scale=1", @@ -62,7 +62,8 @@ _apple_touch_startup_images = { def _get_html_template( - meta_tags: Dict = {}, title: str = config.config["name"], prefix: str = "" + title: str, + meta_tags: Dict = {}, ) -> Doc: doc = Doc() doc.asis("") @@ -73,19 +74,21 @@ def _get_html_template( doc.stag("meta", name=name, content=content) with doc.tag("title"): doc.text(title) - doc.stag("base", href=f"{prefix}/") + doc.stag("base", href=util.add_url_prefix()) doc.stag( - "link", rel="manifest", href=f"{prefix}/api/manifest.json" + "link", + rel="manifest", + href=util.add_url_prefix("/api/manifest.json"), ) doc.stag( "link", - href=f"{prefix}/css/app.min.css", + href=util.add_url_prefix("/css/app.min.css"), rel="stylesheet", type="text/css", ) doc.stag( "link", - href=f"{prefix}/css/vendor.min.css", + href=util.add_url_prefix("/css/vendor.min.css"), rel="stylesheet", type="text/css", ) @@ -93,19 +96,21 @@ def _get_html_template( "link", rel="shortcut icon", type="image/png", - href=f"{prefix}/img/favicon.png", + href=util.add_url_prefix("/img/favicon.png"), ) doc.stag( "link", rel="apple-touch-icon", sizes="180x180", - href=f"{prefix}/img/apple-touch-icon.png", + href=util.add_url_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"{prefix}/img/apple-touch-startup-image-{res}.png", + href=util.add_url_prefix( + f"/img/apple-touch-startup-image-{res}.png" + ), media=" and ".join( f"({k}: {v})" for k, v in media.items() ), @@ -116,11 +121,15 @@ def _get_html_template( with doc.tag("div", id="content-holder"): pass with doc.tag( - "script", type="text/javascript", src="js/vendor.min.js" + "script", + type="text/javascript", + src=util.add_url_prefix("js/vendor.min.js"), ): pass with doc.tag( - "script", type="text/javascript", src="js/app.min.js" + "script", + type="text/javascript", + src=util.add_url_prefix("js/app.min.js"), ): pass return doc.getvalue() @@ -152,13 +161,17 @@ def get_post_html( metadata = { "og:site_name": config.config["name"], - "og:url": f"{ctx.url_prefix}/post/{params['post_id']}", + "og:url": util.add_url_prefix(f"post/{params['post_id']}"), "og:title": title, "twitter:title": title, "og:type": "article", } # Note: ctx.user will always be the anonymous user if auth.has_privilege(ctx.user, "posts:view"): + content_url = util.add_data_prefix(posts.get_post_content_path(post)) + thumbnail_url = util.add_data_prefix( + posts.get_post_thumbnail_path(post) + ) metadata["og:article:published_time"] = post.creation_time.isoformat() if post.last_edit_time: metadata[ @@ -169,11 +182,9 @@ def get_post_html( ) if post.type in (model.Post.TYPE_VIDEO): metadata["twitter:card"] = "player" - metadata["og:video:url"] = posts.get_post_content_url(post) - metadata["twitter:player:stream"] = posts.get_post_content_url( - post - ) - metadata["og:image:url"] = posts.get_post_thumbnail_url(post) + metadata["og:video:url"] = content_url + metadata["twitter:player:stream"] = content_url + metadata["og:image:url"] = thumbnail_url if post.canvas_width and post.canvas_height: metadata["og:video:width"] = str(post.canvas_width) metadata["og:video:height"] = str(post.canvas_height) @@ -181,15 +192,13 @@ def get_post_html( metadata["twitter:player:height"] = str(post.canvas_height) else: metadata["twitter:card"] = "summary_large_image" - metadata["og:image:url"] = posts.get_post_content_url(post) - metadata["twitter:image"] = posts.get_post_content_url(post) - return _get_html_template( - meta_tags=metadata, title=title, prefix=ctx.url_prefix - ) + metadata["og:image:url"] = content_url + metadata["twitter:image"] = content_url + return _get_html_template(title=title, meta_tags=metadata) @rest.routes.get("/html/.*", accept="text/html") def default_route( ctx: rest.Context, _params: Dict[str, str] = {} ) -> rest.Response: - return _get_html_template(prefix=ctx.url_prefix) + return _get_html_template(title=config.config["name"]) diff --git a/server/szurubooru/api/password_reset_api.py b/server/szurubooru/api/password_reset_api.py index c0e0d60f..40b50e96 100644 --- a/server/szurubooru/api/password_reset_api.py +++ b/server/szurubooru/api/password_reset_api.py @@ -2,7 +2,7 @@ from hashlib import md5 from typing import Dict from szurubooru import config, errors, rest -from szurubooru.func import auth, mailer, users, versions +from szurubooru.func import auth, mailer, users, util, versions MAIL_SUBJECT = "Password reset for {name}" MAIL_BODY = ( @@ -24,8 +24,7 @@ def start_password_reset( % (user_name) ) token = auth.generate_authentication_token(user) - - url = f"{ctx.url_prefix}/password-reset/{user.name}:{token}" + url = util.add_url_prefix(f"password-reset/{user.name}:{token}") mailer.send_mail( config.config["smtp"]["from"], diff --git a/server/szurubooru/api/pool_api.py b/server/szurubooru/api/pool_api.py index a2fb716b..52168325 100644 --- a/server/szurubooru/api/pool_api.py +++ b/server/szurubooru/api/pool_api.py @@ -9,7 +9,8 @@ _search_executor = search.Executor(search.configs.PoolSearchConfig()) def _serialize(ctx: rest.Context, pool: model.Pool) -> rest.Response: return pools.serialize_pool( - pool, options=serialization.get_serialization_options(ctx) + pool, + options=serialization.get_serialization_options(ctx), ) diff --git a/server/szurubooru/api/post_api.py b/server/szurubooru/api/post_api.py index daba7f7e..6d5e43c3 100644 --- a/server/szurubooru/api/post_api.py +++ b/server/szurubooru/api/post_api.py @@ -35,7 +35,9 @@ def _serialize_post( ctx: rest.Context, post: Optional[model.Post] ) -> rest.Response: return posts.serialize_post( - post, ctx.user, options=serialization.get_serialization_options(ctx) + post, + ctx.user, + options=serialization.get_serialization_options(ctx), ) diff --git a/server/szurubooru/config.py b/server/szurubooru/config.py index 1515a54f..37d37a89 100644 --- a/server/szurubooru/config.py +++ b/server/szurubooru/config.py @@ -31,7 +31,6 @@ def _docker_config() -> Dict: return { "debug": True, "show_sql": int(os.getenv("LOG_SQL", 0)), - "data_url": os.getenv("DATA_URL", "data/"), "data_dir": "/data/", "database": "postgres://%(user)s:%(pass)s@%(host)s:%(port)d/%(db)s" % { diff --git a/server/szurubooru/facade.py b/server/szurubooru/facade.py index a7e48449..9d0a446d 100644 --- a/server/szurubooru/facade.py +++ b/server/szurubooru/facade.py @@ -85,13 +85,7 @@ def validate_config() -> None: % (config.config["default_rank"]) ) - for key in ["data_url", "data_dir"]: - if not config.config[key]: - raise errors.ConfigError( - "Service is not configured: %r is missing" % key - ) - - if not os.path.isabs(config.config["data_dir"]): + if not os.path.isabs(config.config["data_dir"] or ""): raise errors.ConfigError("data_dir must be an absolute path") if not config.config["database"]: diff --git a/server/szurubooru/func/pools.py b/server/szurubooru/func/pools.py index c3ea9f0f..4accc423 100644 --- a/server/szurubooru/func/pools.py +++ b/server/szurubooru/func/pools.py @@ -145,7 +145,8 @@ class PoolSerializer(serialization.BaseSerializer): def serialize_pool( - pool: model.Pool, options: List[str] = [] + pool: model.Pool, + options: List[str] = [], ) -> Optional[rest.Response]: if not pool: return None @@ -154,7 +155,8 @@ def serialize_pool( def serialize_micro_pool(pool: model.Pool) -> Optional[rest.Response]: return serialize_pool( - pool, options=["id", "names", "category", "description", "postCount"] + pool, + options=["id", "names", "category", "description", "postCount"], ) diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index be2259cf..b0645ec8 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -44,7 +44,9 @@ class PostAlreadyUploadedError(errors.ValidationError): super().__init__( "Post already uploaded (%d)" % other_post.post_id, { - "otherPostUrl": get_post_content_url(other_post), + "otherPostUrl": util.add_data_prefix( + get_post_content_path(other_post) + ), "otherPostId": other_post.post_id, }, ) @@ -105,25 +107,6 @@ def get_post_security_hash(id: int) -> str: ).hexdigest()[0:16] -def get_post_content_url(post: model.Post) -> str: - assert post - return "%s/posts/%d_%s.%s" % ( - config.config["data_url"].rstrip("/"), - post.post_id, - get_post_security_hash(post.post_id), - mime.get_extension(post.mime_type) or "dat", - ) - - -def get_post_thumbnail_url(post: model.Post) -> str: - assert post - return "%s/generated-thumbnails/%d_%s.jpg" % ( - config.config["data_url"].rstrip("/"), - post.post_id, - get_post_security_hash(post.post_id), - ) - - def get_post_content_path(post: model.Post) -> str: assert post assert post.post_id @@ -159,7 +142,11 @@ def serialize_note(note: model.PostNote) -> rest.Response: class PostSerializer(serialization.BaseSerializer): - def __init__(self, post: model.Post, auth_user: model.User) -> None: + def __init__( + self, + post: model.Post, + auth_user: model.User, + ) -> None: self.post = post self.auth_user = auth_user @@ -241,10 +228,10 @@ class PostSerializer(serialization.BaseSerializer): return self.post.canvas_height def serialize_content_url(self) -> Any: - return get_post_content_url(self.post) + return util.add_data_prefix(get_post_content_path(self.post)) def serialize_thumbnail_url(self) -> Any: - return get_post_thumbnail_url(self.post) + return util.add_data_prefix(get_post_thumbnail_path(self.post)) def serialize_flags(self) -> Any: return self.post.flags @@ -264,7 +251,7 @@ class PostSerializer(serialization.BaseSerializer): { post["id"]: post for post in [ - serialize_micro_post(rel, self.auth_user) + serialize_micro_post(rel, self.auth_user, self.url_prefix) for rel in self.post.relations ] }.values(), @@ -346,7 +333,9 @@ class PostSerializer(serialization.BaseSerializer): def serialize_post( - post: Optional[model.Post], auth_user: model.User, options: List[str] = [] + post: Optional[model.Post], + auth_user: model.User, + options: List[str] = [], ) -> Optional[rest.Response]: if not post: return None @@ -354,7 +343,8 @@ def serialize_post( def serialize_micro_post( - post: model.Post, auth_user: model.User + post: model.Post, + auth_user: model.User, ) -> Optional[rest.Response]: return serialize_post( post, auth_user=auth_user, options=["id", "thumbnailUrl"] diff --git a/server/szurubooru/func/util.py b/server/szurubooru/func/util.py index f8391365..112ed390 100644 --- a/server/szurubooru/func/util.py +++ b/server/szurubooru/func/util.py @@ -5,8 +5,9 @@ import tempfile from contextlib import contextmanager from datetime import datetime, timedelta from typing import Any, Dict, Generator, List, Optional, Tuple, TypeVar, Union +from urllib.parse import urlparse, urlunparse -from szurubooru import errors +from szurubooru import config, errors T = TypeVar("T") @@ -176,3 +177,34 @@ def get_column_size(column: Any) -> Optional[int]: def chunks(source_list: List[Any], part_size: int) -> Generator: for i in range(0, len(source_list), part_size): yield source_list[i : i + part_size] + + +def _get_url_prefix_parts() -> str: + parsed_base_url = list(urlparse(config.config["base_url"])) + if not all(parsed_base_url[0:2]): + parsed_base_url[0:2] = ["", ""] + parsed_base_url[2] = parsed_base_url[2].rstrip("/") + return parsed_base_url[0:3] + ["", "", ""] + + +def _get_data_prefix_parts() -> str: + parsed_base_url = _get_url_prefix_parts() + parsed_data_url = list(urlparse(config.config["data_url"])) + if not all(parsed_data_url[0:2]): + parsed_data_url[0:2] = parsed_base_url[0:2] + if not parsed_data_url[2].startswith("/"): + parsed_data_url[2] = parsed_base_url[2] + "/" + parsed_data_url[2] + parsed_data_url[2] = parsed_data_url[2].rstrip("/") + return parsed_data_url[0:3] + ["", "", ""] + + +def add_url_prefix(url: str = "") -> str: + return urlunparse(_get_url_prefix_parts()) + "/" + url.lstrip("/") + + +def add_data_prefix(url: str = "") -> str: + return urlunparse(_get_data_prefix_parts()) + "/" + url.lstrip("/") + + +def can_send_mail() -> bool: + return bool(config.config["smtp"]["host"] and _get_url_prefix_parts()[1]) diff --git a/server/szurubooru/rest/app.py b/server/szurubooru/rest/app.py index 5f92f0a1..54f16f37 100644 --- a/server/szurubooru/rest/app.py +++ b/server/szurubooru/rest/app.py @@ -41,11 +41,6 @@ def _create_context(env: Dict[str, Any]) -> context.Context: path = path.encode("latin-1").decode("utf-8") # PEP-3333 headers = _get_headers(env) - 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", ""))) @@ -80,7 +75,6 @@ def _create_context(env: Dict[str, Any]) -> context.Context: method=method, url=path, headers=headers, - url_prefix=url_prefix, params=params, files=files, ) diff --git a/server/szurubooru/rest/context.py b/server/szurubooru/rest/context.py index 9f00d0f0..2fc4b10e 100644 --- a/server/szurubooru/rest/context.py +++ b/server/szurubooru/rest/context.py @@ -16,7 +16,6 @@ class Context: url: str, headers: Dict[str, str] = None, accept: str = None, - url_prefix: str = None, params: Request = None, files: Dict[str, bytes] = None, ) -> None: @@ -24,7 +23,6 @@ class Context: 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 {} diff --git a/server/szurubooru/tests/api/test_info.py b/server/szurubooru/tests/api/test_info.py index 3df3958f..113f198c 100644 --- a/server/szurubooru/tests/api/test_info.py +++ b/server/szurubooru/tests/api/test_info.py @@ -18,6 +18,7 @@ def test_info_api( config_injector( { "name": "test installation", + "base_url": "https://www.example.com", "contact_email": "test@example.com", "enable_safety": True, "data_dir": str(directory), @@ -97,9 +98,13 @@ def test_info_api( def test_manifest(config_injector, context_factory): - config_injector({"name": "test installation"}) + config_injector( + { + "name": "test installation", + "base_url": "/someprefix", + } + ) ctx = context_factory() - ctx.url_prefix = "/someprefix" expected_manifest = { "name": "test installation", "icons": [ diff --git a/server/szurubooru/tests/api/test_opengraph.py b/server/szurubooru/tests/api/test_opengraph.py index 4a63ecbc..5dc2ea52 100644 --- a/server/szurubooru/tests/api/test_opengraph.py +++ b/server/szurubooru/tests/api/test_opengraph.py @@ -23,23 +23,24 @@ def test_get_post_html( config_injector( { "name": "test installation", - "data_url": "data/", + "base_url": "/someprefix", + "data_url": "data", } ) - ctx = context_factory() - ctx.url_prefix = "/someprefix" post = post_factory(id=1, type=post_type) post.canvas_width = 1920 post.canvas_height = 1080 db.session.add(post) db.session.flush() with patch("szurubooru.func.auth.has_privilege"), patch( - "szurubooru.func.posts.get_post_content_url" - ), patch("szurubooru.func.posts.get_post_thumbnail_url"): + "szurubooru.func.posts.get_post_content_path" + ), patch("szurubooru.func.posts.get_post_thumbnail_path"): auth.has_privilege.return_value = view_priv - posts.get_post_content_url.return_value = "/content-url" - posts.get_post_thumbnail_url.return_value = "/thumbnail-url" - ret = api.opengraph_api.get_post_html(ctx, {"post_id": 1}) + posts.get_post_content_path.return_value = "content-url" + posts.get_post_thumbnail_path.return_value = "thumbnail-url" + ret = api.opengraph_api.get_post_html( + context_factory(), {"post_id": 1} + ) assert _make_meta_tag("og:site_name", "test installation") in ret assert _make_meta_tag("og:url", "/someprefix/post/1") in ret @@ -59,16 +60,27 @@ def test_get_post_html( ) assert ( bool( - _make_meta_tag("twitter:player:stream", "/content-url") in ret + _make_meta_tag( + "twitter:player:stream", "/someprefix/data/content-url" + ) + in ret ) == view_priv ) assert ( - bool(_make_meta_tag("og:video:url", "/content-url") in ret) + bool( + _make_meta_tag("og:video:url", "/someprefix/data/content-url") + in ret + ) == view_priv ) assert ( - bool(_make_meta_tag("og:image:url", "/thumbnail-url") in ret) + bool( + _make_meta_tag( + "og:image:url", "/someprefix/data/thumbnail-url" + ) + in ret + ) == view_priv ) assert ( @@ -83,6 +95,9 @@ def test_get_post_html( == view_priv ) assert ( - bool(_make_meta_tag("twitter:image", "/content-url") in ret) + bool( + _make_meta_tag("twitter:image", "/someprefix/data/content-url") + in ret + ) == view_priv ) diff --git a/server/szurubooru/tests/api/test_password_reset.py b/server/szurubooru/tests/api/test_password_reset.py index d6195880..effa2e46 100644 --- a/server/szurubooru/tests/api/test_password_reset.py +++ b/server/szurubooru/tests/api/test_password_reset.py @@ -11,7 +11,7 @@ def inject_config(config_injector): config_injector( { "secret": "x", - "domain": "http://example.com", + "base_url": "http://example.com", "name": "Test instance", "smtp": { "from": "noreply@example.com", @@ -27,13 +27,11 @@ def test_reset_sending_email(context_factory, user_factory): ) ) db.session.flush() - ctx = context_factory() - ctx.url_prefix = "http://example.com" for initiating_user in ["u1", "user@example.com"]: with patch("szurubooru.func.mailer.send_mail"): assert ( api.password_reset_api.start_password_reset( - ctx, {"user_name": initiating_user} + context_factory(), {"user_name": initiating_user} ) == {} ) diff --git a/server/szurubooru/tests/api/test_pool_updating.py b/server/szurubooru/tests/api/test_pool_updating.py index 507289f6..745c3a0d 100644 --- a/server/szurubooru/tests/api/test_pool_updating.py +++ b/server/szurubooru/tests/api/test_pool_updating.py @@ -41,19 +41,17 @@ def test_simple_updating(user_factory, pool_factory, context_factory): ): posts.get_posts_by_ids.return_value = ([], []) pools.serialize_pool.return_value = "serialized pool" - result = api.pool_api.update_pool( - context_factory( - params={ - "version": 1, - "names": ["pool3"], - "category": "series", - "description": "desc", - "posts": [1, 2], - }, - user=auth_user, - ), - {"pool_id": 1}, + ctx = context_factory( + params={ + "version": 1, + "names": ["pool3"], + "category": "series", + "description": "desc", + "posts": [1, 2], + }, + user=auth_user, ) + result = api.pool_api.update_pool(ctx, {"pool_id": 1}) assert result == "serialized pool" pools.create_pool.assert_not_called() pools.update_pool_names.assert_called_once_with(pool, ["pool3"]) diff --git a/server/szurubooru/tests/api/test_post_creating.py b/server/szurubooru/tests/api/test_post_creating.py index a1ad4de7..d6f7ab8d 100644 --- a/server/szurubooru/tests/api/test_post_creating.py +++ b/server/szurubooru/tests/api/test_post_creating.py @@ -45,19 +45,18 @@ def test_creating_minimal_posts(context_factory, post_factory, user_factory): posts.create_post.return_value = (post, []) posts.serialize_post.return_value = "serialized post" - result = api.post_api.create_post( - context_factory( - params={ - "safety": "safe", - "tags": ["tag1", "tag2"], - }, - files={ - "content": "post-content", - "thumbnail": "post-thumbnail", - }, - user=auth_user, - ) + ctx = context_factory( + params={ + "safety": "safe", + "tags": ["tag1", "tag2"], + }, + files={ + "content": "post-content", + "thumbnail": "post-thumbnail", + }, + user=auth_user, ) + result = api.post_api.create_post(ctx) assert result == "serialized post" posts.create_post.assert_called_once_with( @@ -102,22 +101,21 @@ def test_creating_full_posts(context_factory, post_factory, user_factory): posts.create_post.return_value = (post, []) posts.serialize_post.return_value = "serialized post" - result = api.post_api.create_post( - context_factory( - params={ - "safety": "safe", - "tags": ["tag1", "tag2"], - "relations": [1, 2], - "source": "source", - "notes": ["note1", "note2"], - "flags": ["flag1", "flag2"], - }, - files={ - "content": "post-content", - }, - user=auth_user, - ) + ctx = context_factory( + params={ + "safety": "safe", + "tags": ["tag1", "tag2"], + "relations": [1, 2], + "source": "source", + "notes": ["note1", "note2"], + "flags": ["flag1", "flag2"], + }, + files={ + "content": "post-content", + }, + user=auth_user, ) + result = api.post_api.create_post(ctx) assert result == "serialized post" posts.create_post.assert_called_once_with( @@ -333,7 +331,8 @@ def test_errors_not_spending_ids( config_injector( { "data_dir": str(tmpdir.mkdir("data")), - "data_url": "example.com", + "base_url": "https://example.com/", + "data_url": "https://example.com/data", "thumbnails": { "post_width": 300, "post_height": 300, diff --git a/server/szurubooru/tests/func/test_posts.py b/server/szurubooru/tests/func/test_posts.py index fa1b3bb6..acc108bc 100644 --- a/server/szurubooru/tests/func/test_posts.py +++ b/server/szurubooru/tests/func/test_posts.py @@ -17,34 +17,6 @@ from szurubooru.func import ( ) -@pytest.mark.parametrize( - "input_mime_type,expected_url", - [ - ("image/jpeg", "http://example.com/posts/1_244c8840887984c4.jpg"), - ("image/gif", "http://example.com/posts/1_244c8840887984c4.gif"), - ("totally/unknown", "http://example.com/posts/1_244c8840887984c4.dat"), - ], -) -def test_get_post_url(input_mime_type, expected_url, config_injector): - config_injector({"data_url": "http://example.com/", "secret": "test"}) - post = model.Post() - post.post_id = 1 - post.mime_type = input_mime_type - assert posts.get_post_content_url(post) == expected_url - - -@pytest.mark.parametrize("input_mime_type", ["image/jpeg", "image/gif"]) -def test_get_post_thumbnail_url(input_mime_type, config_injector): - config_injector({"data_url": "http://example.com/", "secret": "test"}) - post = model.Post() - post.post_id = 1 - post.mime_type = input_mime_type - assert ( - posts.get_post_thumbnail_url(post) - == "http://example.com/generated-thumbnails/1_244c8840887984c4.jpg" - ) - - @pytest.mark.parametrize( "input_mime_type,expected_path", [ @@ -53,7 +25,10 @@ def test_get_post_thumbnail_url(input_mime_type, config_injector): ("totally/unknown", "posts/1_244c8840887984c4.dat"), ], ) -def test_get_post_content_path(input_mime_type, expected_path): +def test_get_post_content_path( + input_mime_type, expected_path, config_injector +): + config_injector({"secret": "test"}) post = model.Post() post.post_id = 1 post.mime_type = input_mime_type @@ -61,7 +36,8 @@ def test_get_post_content_path(input_mime_type, expected_path): @pytest.mark.parametrize("input_mime_type", ["image/jpeg", "image/gif"]) -def test_get_post_thumbnail_path(input_mime_type): +def test_get_post_thumbnail_path(input_mime_type, config_injector): + config_injector({"secret": "test"}) post = model.Post() post.post_id = 1 post.mime_type = input_mime_type @@ -72,7 +48,8 @@ def test_get_post_thumbnail_path(input_mime_type): @pytest.mark.parametrize("input_mime_type", ["image/jpeg", "image/gif"]) -def test_get_post_thumbnail_backup_path(input_mime_type): +def test_get_post_thumbnail_backup_path(input_mime_type, config_injector): + config_injector({"secret": "test"}) post = model.Post() post.post_id = 1 post.mime_type = input_mime_type @@ -105,7 +82,13 @@ def test_serialize_post( pool_category_factory, config_injector, ): - config_injector({"data_url": "http://example.com/", "secret": "test"}) + config_injector( + { + "secret": "test", + "base_url": "http://example.com/", + "data_url": "http://example.com/", + } + ) with patch("szurubooru.func.comments.serialize_comment"), patch( "szurubooru.func.users.serialize_micro_user" ), patch("szurubooru.func.posts.files.has"): @@ -277,17 +260,15 @@ def test_serialize_post( def test_serialize_micro_post(post_factory, user_factory): - with patch("szurubooru.func.posts.get_post_thumbnail_url"): - posts.get_post_thumbnail_url.return_value = ( - "https://example.com/thumb.png" - ) + with patch("szurubooru.func.posts.get_post_thumbnail_path"): + posts.get_post_thumbnail_path.return_value = "thumb.png" auth_user = user_factory() post = post_factory() db.session.add(post) db.session.flush() assert posts.serialize_micro_post(post, auth_user) == { "id": post.post_id, - "thumbnailUrl": "https://example.com/thumb.png", + "thumbnailUrl": "http://example.com/thumb.png", } @@ -519,7 +500,8 @@ def test_update_post_content_to_existing_content( config_injector( { "data_dir": str(tmpdir.mkdir("data")), - "data_url": "example.com", + "base_url": "https://example.com/", + "data_url": "https://example.com/data", "thumbnails": { "post_width": 300, "post_height": 300, diff --git a/server/szurubooru/tests/func/test_util.py b/server/szurubooru/tests/func/test_util.py index f42ba29e..359fab35 100644 --- a/server/szurubooru/tests/func/test_util.py +++ b/server/szurubooru/tests/func/test_util.py @@ -45,3 +45,93 @@ def test_parsing_date_time(fake_datetime, input, output): ) def test_icase_unique(input, output): assert util.icase_unique(input) == output + + +def test_url_generation(config_injector): + config_injector( + { + "base_url": "https://www.example.com/", + "data_url": "data/", + } + ) + assert util.add_url_prefix() == "https://www.example.com/" + assert util.add_url_prefix("/post/1") == "https://www.example.com/post/1" + assert util.add_url_prefix("post/1") == "https://www.example.com/post/1" + assert util.add_data_prefix() == "https://www.example.com/data/" + assert ( + util.add_data_prefix("posts/1.jpg") + == "https://www.example.com/data/posts/1.jpg" + ) + assert ( + util.add_data_prefix("/posts/1.jpg") + == "https://www.example.com/data/posts/1.jpg" + ) + config_injector( + { + "base_url": "https://www.example.com/szuru/", + "data_url": "data/", + } + ) + assert util.add_url_prefix() == "https://www.example.com/szuru/" + assert ( + util.add_url_prefix("/post/1") + == "https://www.example.com/szuru/post/1" + ) + assert ( + util.add_url_prefix("post/1") == "https://www.example.com/szuru/post/1" + ) + assert util.add_data_prefix() == "https://www.example.com/szuru/data/" + assert ( + util.add_data_prefix("posts/1.jpg") + == "https://www.example.com/szuru/data/posts/1.jpg" + ) + assert ( + util.add_data_prefix("/posts/1.jpg") + == "https://www.example.com/szuru/data/posts/1.jpg" + ) + config_injector( + { + "base_url": "https://www.example.com/szuru/", + "data_url": "/data/", + } + ) + assert util.add_url_prefix() == "https://www.example.com/szuru/" + assert ( + util.add_url_prefix("/post/1") + == "https://www.example.com/szuru/post/1" + ) + assert ( + util.add_url_prefix("post/1") == "https://www.example.com/szuru/post/1" + ) + assert util.add_data_prefix() == "https://www.example.com/data/" + assert ( + util.add_data_prefix("posts/1.jpg") + == "https://www.example.com/data/posts/1.jpg" + ) + assert ( + util.add_data_prefix("/posts/1.jpg") + == "https://www.example.com/data/posts/1.jpg" + ) + config_injector( + { + "base_url": "https://www.example.com/szuru", + "data_url": "https://static.example.com/", + } + ) + assert util.add_url_prefix() == "https://www.example.com/szuru/" + assert ( + util.add_url_prefix("/post/1") + == "https://www.example.com/szuru/post/1" + ) + assert ( + util.add_url_prefix("post/1") == "https://www.example.com/szuru/post/1" + ) + assert util.add_data_prefix() == "https://static.example.com/" + assert ( + util.add_data_prefix("posts/1.jpg") + == "https://static.example.com/posts/1.jpg" + ) + assert ( + util.add_data_prefix("/posts/1.jpg") + == "https://static.example.com/posts/1.jpg" + ) From 17152f68a4b8594c8c72874936e2ed7028846d10 Mon Sep 17 00:00:00 2001 From: Shyam Sunder Date: Fri, 1 Oct 2021 11:59:03 -0400 Subject: [PATCH 6/6] server/posts: add preload directive to post HTML --- server/szurubooru/api/opengraph_api.py | 105 +++++++++++------- server/szurubooru/func/auth.py | 10 ++ server/szurubooru/middleware/authenticator.py | 9 +- server/szurubooru/tests/api/test_opengraph.py | 12 +- .../tests/middleware/test_authenticator.py | 15 --- 5 files changed, 86 insertions(+), 65 deletions(-) diff --git a/server/szurubooru/api/opengraph_api.py b/server/szurubooru/api/opengraph_api.py index 3002dd67..e47177d3 100644 --- a/server/szurubooru/api/opengraph_api.py +++ b/server/szurubooru/api/opengraph_api.py @@ -63,18 +63,17 @@ _apple_touch_startup_images = { def _get_html_template( title: str, - meta_tags: Dict = {}, + header_content: str = "", ) -> Doc: doc = Doc() doc.asis("") with doc.tag("html"): with doc.tag("head"): doc.stag("meta", charset="utf-8") - for name, content in {**_default_meta_tags, **meta_tags}.items(): + for name, content in _default_meta_tags.items(): doc.stag("meta", name=name, content=content) with doc.tag("title"): doc.text(title) - doc.stag("base", href=util.add_url_prefix()) doc.stag( "link", rel="manifest", @@ -115,6 +114,8 @@ def _get_html_template( f"({k}: {v})" for k, v in media.items() ), ) + doc.stag("base", href=util.add_url_prefix()) + doc.asis(header_content) with doc.tag("body"): with doc.tag("div", id="top-navigation-holder"): pass @@ -154,47 +155,73 @@ def get_post_html( ) -> rest.Response: try: post = _get_post(params) - title = f"{config.config['name']} - post {_get_post_id(params)}" + title = f"{config.config['name']} - Post #{_get_post_id(params)}" 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:url": util.add_url_prefix(f"post/{params['post_id']}"), - "og:title": title, - "twitter:title": title, - "og:type": "article", - } - # Note: ctx.user will always be the anonymous user - if auth.has_privilege(ctx.user, "posts:view"): - content_url = util.add_data_prefix(posts.get_post_content_path(post)) - thumbnail_url = util.add_data_prefix( - posts.get_post_thumbnail_path(post) + doc = Doc() + doc.stag("meta", name="og:site_name", content=config.config["name"]) + doc.stag( + "meta", + name="og:url", + content=util.add_url_prefix(f"post/{params['post_id']}"), + ) + doc.stag("meta", name="og:title", content=title), + doc.stag("meta", name="twitter:title", content=title), + doc.stag("meta", name="og:type", content="article"), + + if not auth.anon_has_privilege("posts:view"): + return _get_html_template(title=title, header_content=doc.getvalue()) + + content_url = util.add_data_prefix(posts.get_post_content_path(post)) + thumbnail_url = util.add_data_prefix(posts.get_post_thumbnail_path(post)) + tag_string = " ".join(tag.first_name for tag in post.tags) + + doc.stag("meta", name="og:image:alt", content=tag_string) + doc.stag( + "meta", + name="og:article:published_time", + content=post.creation_time.isoformat(), + ) + if post.last_edit_time: + doc.stag( + "meta", + name="og:article:modified_time", + content=post.last_edit_time.isoformat(), ) - metadata["og:article:published_time"] = post.creation_time.isoformat() - if post.last_edit_time: - metadata[ - "og:article:modified_time" - ] = post.last_edit_time.isoformat() - metadata["og:image:alt"] = " ".join( - tag.first_name for tag in post.tags - ) - if post.type in (model.Post.TYPE_VIDEO): - metadata["twitter:card"] = "player" - metadata["og:video:url"] = content_url - metadata["twitter:player:stream"] = content_url - metadata["og:image:url"] = thumbnail_url - if post.canvas_width and post.canvas_height: - metadata["og:video:width"] = str(post.canvas_width) - metadata["og:video:height"] = str(post.canvas_height) - metadata["twitter:player:width"] = str(post.canvas_width) - metadata["twitter:player:height"] = str(post.canvas_height) - else: - metadata["twitter:card"] = "summary_large_image" - metadata["og:image:url"] = content_url - metadata["twitter:image"] = content_url - return _get_html_template(title=title, meta_tags=metadata) + for tag in post.tags: + doc.stag("meta", name="article:tag", content=tag.first_name) + if post.type in (model.Post.TYPE_VIDEO,): + doc.stag("meta", name="twitter:card", content="player") + doc.stag("meta", name="og:video:url", content=content_url) + doc.stag("meta", name="twitter:player:stream", content=content_url) + doc.stag("meta", name="og:image:url", content=thumbnail_url) + if post.canvas_width and post.canvas_height: + doc.stag( + "meta", name="og:video:width", content=str(post.canvas_width) + ) + doc.stag( + "meta", name="og:video:height", content=str(post.canvas_height) + ) + doc.stag( + "meta", + name="twitter:player:width", + content=str(post.canvas_width), + ) + doc.stag( + "meta", + name="twitter:player:height", + content=str(post.canvas_height), + ) + doc.stag("link", name="preload", href=content_url, **{"as": "video"}) + else: + doc.stag("meta", name="twitter:card", content="summary_large_image") + doc.stag("meta", name="og:image:url", content=content_url) + doc.stag("meta", name="twitter:image", content=content_url) + doc.stag("link", name="preload", href=content_url, **{"as": "image"}) + + return _get_html_template(title=title, header_content=doc.getvalue()) @rest.routes.get("/html/.*", accept="text/html") diff --git a/server/szurubooru/func/auth.py b/server/szurubooru/func/auth.py index d0137756..6251efe2 100644 --- a/server/szurubooru/func/auth.py +++ b/server/szurubooru/func/auth.py @@ -106,6 +106,16 @@ def is_valid_token(user_token: Optional[model.UserToken]) -> bool: return True +def anon_has_privilege(privilege_name: str) -> bool: + all_ranks = list(RANK_MAP.keys()) + assert privilege_name in config.config["privileges"] + minimal_rank = util.flip(RANK_MAP)[ + config.config["privileges"][privilege_name] + ] + good_ranks = all_ranks[all_ranks.index(minimal_rank) :] + return model.User.RANK_ANONYMOUS in good_ranks + + def has_privilege(user: model.User, privilege_name: str) -> bool: assert user all_ranks = list(RANK_MAP.keys()) diff --git a/server/szurubooru/middleware/authenticator.py b/server/szurubooru/middleware/authenticator.py index 56a5e442..e73b235e 100644 --- a/server/szurubooru/middleware/authenticator.py +++ b/server/szurubooru/middleware/authenticator.py @@ -73,11 +73,10 @@ 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: - ctx.user = auth_user + 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/tests/api/test_opengraph.py b/server/szurubooru/tests/api/test_opengraph.py index 5dc2ea52..4a50a057 100644 --- a/server/szurubooru/tests/api/test_opengraph.py +++ b/server/szurubooru/tests/api/test_opengraph.py @@ -22,7 +22,7 @@ def test_get_post_html( ): config_injector( { - "name": "test installation", + "name": "testing", "base_url": "/someprefix", "data_url": "data", } @@ -32,20 +32,20 @@ def test_get_post_html( post.canvas_height = 1080 db.session.add(post) db.session.flush() - with patch("szurubooru.func.auth.has_privilege"), patch( + with patch("szurubooru.func.auth.anon_has_privilege"), patch( "szurubooru.func.posts.get_post_content_path" ), patch("szurubooru.func.posts.get_post_thumbnail_path"): - auth.has_privilege.return_value = view_priv + auth.anon_has_privilege.return_value = view_priv posts.get_post_content_path.return_value = "content-url" posts.get_post_thumbnail_path.return_value = "thumbnail-url" ret = api.opengraph_api.get_post_html( context_factory(), {"post_id": 1} ) - assert _make_meta_tag("og:site_name", "test installation") in ret + assert _make_meta_tag("og:site_name", "testing") in ret assert _make_meta_tag("og:url", "/someprefix/post/1") in ret - assert _make_meta_tag("og:title", "test installation - post 1") in ret - assert _make_meta_tag("twitter:title", "test installation - post 1") in ret + assert _make_meta_tag("og:title", "testing - Post #1") in ret + assert _make_meta_tag("twitter:title", "testing - Post #1") in ret assert _make_meta_tag("og:type", "article") in ret assert ( bool( diff --git a/server/szurubooru/tests/middleware/test_authenticator.py b/server/szurubooru/tests/middleware/test_authenticator.py index 325b27c2..93189e66 100644 --- a/server/szurubooru/tests/middleware/test_authenticator.py +++ b/server/szurubooru/tests/middleware/test_authenticator.py @@ -14,21 +14,6 @@ def test_process_request_no_header(context_factory): assert ctx.user.name is None -def test_process_request_non_rest(context_factory, user_factory): - user = user_factory() - ctx = context_factory( - headers={"Authorization": "Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk"}, - accept="text/html", - ) - with patch("szurubooru.func.auth.is_valid_password"), patch( - "szurubooru.func.users.get_user_by_name" - ): - users.get_user_by_name.return_value = user - auth.is_valid_password.return_value = True - authenticator.process_request(ctx) - assert ctx.user.name is None - - def test_process_request_bump_login(context_factory, user_factory): user = user_factory() db.session.add(user)