From dfe952ddaf9642dcf14a18e39ced0bddfb7307f3 Mon Sep 17 00:00:00 2001 From: io Date: Thu, 8 Jul 2021 22:07:46 +0000 Subject: [PATCH 1/3] WIP OpenGraph embeds --- client/build.js | 2 +- client/html/index.htm | 3 +- client/nginx.conf.docker | 10 ++- client/requirements.txt | 1 + client/server.py | 130 ++++++++++++++++++++++++++++++++++ server/szurubooru/rest/app.py | 3 +- 6 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 client/requirements.txt create mode 100644 client/server.py diff --git a/client/build.js b/client/build.js index eaf28a54..aad00f22 100755 --- a/client/build.js +++ b/client/build.js @@ -84,7 +84,7 @@ function bundleHtml() { function minifyHtml(html) { return require('html-minifier').minify(html, { - removeComments: true, + removeComments: false, collapseWhitespace: true, conservativeCollapse: true, }).trim(); diff --git a/client/html/index.htm b/client/html/index.htm index 00728903..c2bc7aef 100644 --- a/client/html/index.htm +++ b/client/html/index.htm @@ -1,5 +1,5 @@ - + @@ -10,6 +10,7 @@ Loading... + diff --git a/client/nginx.conf.docker b/client/nginx.conf.docker index 98c18b35..00fc1763 100644 --- a/client/nginx.conf.docker +++ b/client/nginx.conf.docker @@ -23,6 +23,10 @@ http { server __BACKEND__:6666; } + upstream frontend { + server __FRONTEND__:6667; + } + server { listen 80 default_server; @@ -71,7 +75,7 @@ http { location / { root /var/www; - try_files $uri /index.htm; + try_files $uri @frontend; sendfile on; tcp_nopush on; @@ -81,6 +85,10 @@ http { gzip_proxied expired no-cache no-store private auth; } + location @frontend { + proxy_pass http://frontend; + } + location @unauthorized { return 403 "Unauthorized"; default_type text/plain; diff --git a/client/requirements.txt b/client/requirements.txt new file mode 100644 index 00000000..f2293605 --- /dev/null +++ b/client/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/client/server.py b/client/server.py new file mode 100644 index 00000000..a6a8163a --- /dev/null +++ b/client/server.py @@ -0,0 +1,130 @@ +import os +import os.path +import html +import urllib.parse +import itertools +from typing import Dict, Any, Callable, Tuple, List, Iterable +from http import HTTPStatus +from pathlib import Path + +import requests + +FRONTEND_WEB_ROOT = Path(os.environ["SZURUBOORU_WEB_ROOT"]) + +with open(FRONTEND_WEB_ROOT / "index.htm") as f: + INDEX_HTML = f.read() + +BACKEND_BASE_URL = os.environ["SZURUBOORU_BASE_URL"] +PUBLIC_BASE_URL = os.environ["SZURUBOORU_PUBLIC_BASE_URL"] + +Metadata = Iterable[Tuple[str, str]] + +def general_embed(server_info: Dict[str, Any]) -> Metadata: + yield "og:site_name", server_info["config"]["name"] + +def user_embed(username: str) -> Metadata: + yield "og:type", "profile" + yield "profile:username", username + yield "og:title", username + yield "og:image:height", "128" + yield "og:image:width", "128" + user = requests.get(BACKEND_BASE_URL + "/api/user/" + username).json() + yield "og:image:url", urllib.parse.join(PUBLIC_BASE_URL, user["avatarUrl"]) + +def _image_embed(post: Dict[str, Any]) -> Metadata: + url = PUBLIC_BASE_URL + post["contentUrl"] + + if post["type"] == "video": + prefix = "og:video" + yield "og:image", PUBLIC_BASE_URL + post["thumbnailUrl"] + yield "twitter:card", "player" + else: + prefix = "og:image" + yield "twitter:card", "summary_large_image" + + yield prefix + ":width", str(post["canvasWidth"]) + yield prefix + ":height", str(post["canvasHeight"]) + yield prefix, url + if BACKEND_BASE_URL.startswith('https://'): + yield prefix + ":secure_url", url + yield prefix + ":type", post["mimeType"] + +def _author_embed(user: Dict[str, Any]) -> Metadata: + yield "article:author", PUBLIC_BASE_URL + "/user/" + user["name"] + +def post_embed(post_id: int) -> Metadata: + post = requests.get(BACKEND_BASE_URL + f"/api/post/{post_id}").json() + yield "og:type", "article" + yield from _author_embed(post["user"]) + yield "og:title", post["user"]["name"] + if post["tags"]: + value = "Tags: " + ", ".join(tag["names"][0] for tag in post["tags"]) + yield "og:description", value + yield "description", value + yield from _image_embed(post) + +def homepage_embed(server_info: Dict[str, Any]) -> Metadata: + yield "og:title", server_info["config"]["name"] + yield "og:type", "website" + post = server_info["featuredPost"] + if post is not None: + yield from _image_embed(post) + +def render_embed(metadata: Metadata) -> str: + out = [] + for k, v in metadata: + k, v = html.escape(k), html.escape(v) + out.append(f'') + return ''.join(out) + +def application(env: Dict[str, Any], start_response: Callable[[str, Any], Any]) -> Tuple[bytes]: + def serve_file(path): + start_response(str(int(HTTPStatus.OK)), [("X-Accel-Redirect", path)]) + return () + + def serve_without_embed(): + return serve_file("/index.htm") + + method = env["REQUEST_METHOD"] + if method != "GET": + start_response(str(int(HTTPStatus.BAD_REQUEST)), [("Content-Type", "text/plain")]) + return (b"Bad request",) + + path = env["PATH_INFO"].lstrip("/") + path = path.encode("latin-1").decode("utf-8") # PEP-3333 + + if path and (FRONTEND_WEB_ROOT / path).exists(): + return serve_file("/" + path) + + path = "/" + path + path_components = path.split("/") + + if path_components[1] not in {"post", "user", ""}: + # serve index.htm like normal + return serve_without_embed() + + server_info = requests.get(BACKEND_BASE_URL + "/api/info").json() + privileges = server_info["config"]["privileges"] + if path_components[1] == "user": + username = path_components[2] + if privileges["users:view"] != "anonymous" or not username: + return serve_without_embed() + metadata = user_embed(username) + + elif path_components[1] == "post": + try: + post_id = int(path_components[2]) + except ValueError: + return serve_without_embed() + + if privileges["posts:view"] != "anonymous": + return serve_without_embed() + metadata = post_embed(post_id) + + elif path_components[1] == "": + metadata = homepage_embed(server_info) + + metadata = itertools.chain(general_embed(server_info), metadata) + body = INDEX_HTML.replace("", render_embed(metadata)).encode("utf-8") + start_response(str(int(HTTPStatus.OK)), [("Content-Type", "text/html"), ("Content-Length", str(len(body)))]) + return (body,) diff --git a/server/szurubooru/rest/app.py b/server/szurubooru/rest/app.py index a6f10fbc..76b3d8d0 100644 --- a/server/szurubooru/rest/app.py +++ b/server/szurubooru/rest/app.py @@ -74,7 +74,8 @@ def application( ) -> Tuple[bytes]: try: ctx = _create_context(env) - if "application/json" not in ctx.get_header("Accept"): + accept_header = ctx.get_header("Accept") + if accept_header != "*/*" and "application/json" not in accept_header: raise errors.HttpNotAcceptable( "ValidationError", "This API only supports JSON responses." ) From 05074527ed0c5540f644f39e84d9a8bbe99e17ff Mon Sep 17 00:00:00 2001 From: io Date: Fri, 9 Jul 2021 19:42:08 +0000 Subject: [PATCH 2/3] Implement twitter video embeds WIP as they get cut off for tall videos Also do not serve them to Telegram because Telegram prefers twitter:card to og:video --- client/server.py | 54 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/client/server.py b/client/server.py index a6a8163a..35025e51 100644 --- a/client/server.py +++ b/client/server.py @@ -31,13 +31,17 @@ def user_embed(username: str) -> Metadata: user = requests.get(BACKEND_BASE_URL + "/api/user/" + username).json() yield "og:image:url", urllib.parse.join(PUBLIC_BASE_URL, user["avatarUrl"]) -def _image_embed(post: Dict[str, Any]) -> Metadata: +def _image_embed(post: Dict[str, Any], skip_twitter_player=False) -> Metadata: url = PUBLIC_BASE_URL + post["contentUrl"] if post["type"] == "video": prefix = "og:video" yield "og:image", PUBLIC_BASE_URL + post["thumbnailUrl"] - yield "twitter:card", "player" + if not skip_twitter_player: + yield "twitter:card", "player" + yield "twitter:player", PUBLIC_BASE_URL + f"/player/{post['id']}" + yield "twitter:player:width", str(post["canvasWidth"]) + yield "twitter:player:height", str(post["canvasHeight"]) else: prefix = "og:image" yield "twitter:card", "summary_large_image" @@ -52,7 +56,7 @@ def _image_embed(post: Dict[str, Any]) -> Metadata: def _author_embed(user: Dict[str, Any]) -> Metadata: yield "article:author", PUBLIC_BASE_URL + "/user/" + user["name"] -def post_embed(post_id: int) -> Metadata: +def post_embed(post_id: int, *, skip_twitter_player=False) -> Metadata: post = requests.get(BACKEND_BASE_URL + f"/api/post/{post_id}").json() yield "og:type", "article" yield from _author_embed(post["user"]) @@ -61,14 +65,14 @@ def post_embed(post_id: int) -> Metadata: value = "Tags: " + ", ".join(tag["names"][0] for tag in post["tags"]) yield "og:description", value yield "description", value - yield from _image_embed(post) + yield from _image_embed(post, skip_twitter_player) -def homepage_embed(server_info: Dict[str, Any]) -> Metadata: +def homepage_embed(server_info: Dict[str, Any], *, skip_twitter_player=False) -> Metadata: yield "og:title", server_info["config"]["name"] yield "og:type", "website" post = server_info["featuredPost"] if post is not None: - yield from _image_embed(post) + yield from _image_embed(post, skip_twitter_player) def render_embed(metadata: Metadata) -> str: out = [] @@ -77,6 +81,28 @@ def render_embed(metadata: Metadata) -> str: out.append(f'') return ''.join(out) +def serve_twitter_video_player(start_response, post_id: int): + r = requests.get(BACKEND_BASE_URL + f"/api/post/{post_id}") + data = r.json() + if r.status_code != HTTPStatus.OK: + start_response(r.status_code, [("Content-Type", "text/html; charset=utf-8")]) + yield f"

{html.escape(data['title'])}

{html.escape(data['description'])}

".encode("utf-8"), + + start_response(http_status.OK, [("Content-Type", "text/html; charset=utf-8")]) + post = data + yield b"" + yield b"" + yield b"" + def application(env: Dict[str, Any], start_response: Callable[[str, Any], Any]) -> Tuple[bytes]: def serve_file(path): start_response(str(int(HTTPStatus.OK)), [("X-Accel-Redirect", path)]) @@ -99,12 +125,22 @@ def application(env: Dict[str, Any], start_response: Callable[[str, Any], Any]) path = "/" + path path_components = path.split("/") - if path_components[1] not in {"post", "user", ""}: + if path_components[1] not in {"post", "user", "", "player"}: # serve index.htm like normal return serve_without_embed() + if path_components[1] == "player" and path_components[2]: + try: + post_id = int(path_components[2]) + except ValueError: + pass + else: + return serve_twitter_video_player(start_response, post_id) + server_info = requests.get(BACKEND_BASE_URL + "/api/info").json() privileges = server_info["config"]["privileges"] + # Telegram prefers twitter:card to og:video, so we need to skip the former in order for videos to play inline + skip_twitter_player = env["HTTP_USER_AGENT"].startswith("TelegramBot") if path_components[1] == "user": username = path_components[2] if privileges["users:view"] != "anonymous" or not username: @@ -119,10 +155,10 @@ def application(env: Dict[str, Any], start_response: Callable[[str, Any], Any]) if privileges["posts:view"] != "anonymous": return serve_without_embed() - metadata = post_embed(post_id) + metadata = post_embed(post_id, skip_twitter_player=skip_twitter_player) elif path_components[1] == "": - metadata = homepage_embed(server_info) + metadata = homepage_embed(server_info, skip_twitter_player=skip_twitter_player) metadata = itertools.chain(general_embed(server_info), metadata) body = INDEX_HTML.replace("", render_embed(metadata)).encode("utf-8") From ec890f1e69e94509755aebc93d0fd052fb665c37 Mon Sep 17 00:00:00 2001 From: io Date: Fri, 9 Jul 2021 19:42:29 +0000 Subject: [PATCH 3/3] client-server: cleaner way of getting http status codes --- client/server.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/client/server.py b/client/server.py index 35025e51..3f07200c 100644 --- a/client/server.py +++ b/client/server.py @@ -9,6 +9,12 @@ from pathlib import Path import requests +class HttpStatus: + def __getattr__(self, name): + return str(int(getattr(HTTPStatus, name))) + +http_status = HttpStatus() + FRONTEND_WEB_ROOT = Path(os.environ["SZURUBOORU_WEB_ROOT"]) with open(FRONTEND_WEB_ROOT / "index.htm") as f: @@ -105,7 +111,7 @@ def serve_twitter_video_player(start_response, post_id: int): def application(env: Dict[str, Any], start_response: Callable[[str, Any], Any]) -> Tuple[bytes]: def serve_file(path): - start_response(str(int(HTTPStatus.OK)), [("X-Accel-Redirect", path)]) + start_response(http_status.OK, [("X-Accel-Redirect", path)]) return () def serve_without_embed(): @@ -113,7 +119,7 @@ def application(env: Dict[str, Any], start_response: Callable[[str, Any], Any]) method = env["REQUEST_METHOD"] if method != "GET": - start_response(str(int(HTTPStatus.BAD_REQUEST)), [("Content-Type", "text/plain")]) + start_response(http_status.BAD_REQUEST, [("Content-Type", "text/plain")]) return (b"Bad request",) path = env["PATH_INFO"].lstrip("/") @@ -162,5 +168,5 @@ def application(env: Dict[str, Any], start_response: Callable[[str, Any], Any]) metadata = itertools.chain(general_embed(server_info), metadata) body = INDEX_HTML.replace("", render_embed(metadata)).encode("utf-8") - start_response(str(int(HTTPStatus.OK)), [("Content-Type", "text/html"), ("Content-Length", str(len(body)))]) + start_response(http_status.OK, [("Content-Type", "text/html"), ("Content-Length", str(len(body)))]) return (body,)