client+server: implement server-generated Open Graph tags

This commit is contained in:
Shyam Sunder 2021-09-24 14:03:36 -04:00
parent d699979d35
commit d5a223652e
11 changed files with 256 additions and 56 deletions

View file

@ -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

View file

@ -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('<!-- Base HTML Placeholder -->', `<base href="${baseUrl()}"/>`);
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 => {

View file

@ -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

View file

@ -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 {

View file

@ -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/

View file

@ -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

View file

@ -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

View file

@ -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",
}

View file

@ -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("<!DOCTYPE html>")
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<post_id>[^/]+)/?", 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()

View file

@ -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"),
)

View file

@ -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