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