client+server: implement server-generated Open Graph tags
This commit is contained in:
parent
d699979d35
commit
d5a223652e
11 changed files with 256 additions and 56 deletions
|
@ -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
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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/
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
168
server/szurubooru/api/opengraph_api.py
Normal file
168
server/szurubooru/api/opengraph_api.py
Normal 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()
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
Reference in a new issue