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