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 BUILD_INFO="docker-latest"
|
||||||
ARG CLIENT_BUILD_ARGS=""
|
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
|
FROM --platform=$BUILDPLATFORM scratch as approot
|
||||||
|
|
|
@ -30,26 +30,6 @@ const external_js = [
|
||||||
'underscore',
|
'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');
|
const fs = require('fs');
|
||||||
|
@ -72,10 +52,6 @@ function gzipFile(file) {
|
||||||
execSync('gzip -6 -k ' + file);
|
execSync('gzip -6 -k ' + file);
|
||||||
}
|
}
|
||||||
|
|
||||||
function baseUrl() {
|
|
||||||
return process.env.BASE_URL ? process.env.BASE_URL : '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------
|
// -------------------------------------------------
|
||||||
|
|
||||||
function bundleHtml() {
|
function bundleHtml() {
|
||||||
|
@ -90,10 +66,6 @@ function bundleHtml() {
|
||||||
}).trim();
|
}).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseHtml = readTextFile('./html/index.htm')
|
|
||||||
.replace('<!-- Base HTML Placeholder -->', `<base href="${baseUrl()}"/>`);
|
|
||||||
fs.writeFileSync('./public/index.htm', minifyHtml(baseHtml));
|
|
||||||
|
|
||||||
let compiledTemplateJs = [
|
let compiledTemplateJs = [
|
||||||
`'use strict';`,
|
`'use strict';`,
|
||||||
`let _ = require('underscore');`,
|
`let _ = require('underscore');`,
|
||||||
|
@ -266,9 +238,6 @@ function bundleBinaryAssets() {
|
||||||
function bundleWebAppFiles() {
|
function bundleWebAppFiles() {
|
||||||
const Jimp = require('jimp');
|
const Jimp = require('jimp');
|
||||||
|
|
||||||
fs.writeFileSync('./public/manifest.json', JSON.stringify(app_manifest));
|
|
||||||
console.info('Generated app manifest');
|
|
||||||
|
|
||||||
Promise.all(webapp_icons.map(icon => {
|
Promise.all(webapp_icons.map(icon => {
|
||||||
return Jimp.read('./img/app.png')
|
return Jimp.read('./img/app.png')
|
||||||
.then(file => {
|
.then(file => {
|
||||||
|
|
|
@ -3,9 +3,6 @@
|
||||||
# Integrate environment variables
|
# Integrate environment variables
|
||||||
sed -i "s|__BACKEND__|${BACKEND_HOST}|" \
|
sed -i "s|__BACKEND__|${BACKEND_HOST}|" \
|
||||||
/etc/nginx/nginx.conf
|
/etc/nginx/nginx.conf
|
||||||
sed -i "s|__BASEURL__|${BASE_URL:-/}|g" \
|
|
||||||
/var/www/index.htm \
|
|
||||||
/var/www/manifest.json
|
|
||||||
|
|
||||||
# Start server
|
# Start server
|
||||||
exec nginx
|
exec nginx
|
||||||
|
|
|
@ -69,9 +69,8 @@ http {
|
||||||
error_page 404 @notfound;
|
error_page 404 @notfound;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location ~ ^/(js|css|img|fonts)/.*$ {
|
||||||
root /var/www;
|
root /var/www;
|
||||||
try_files $uri /index.htm;
|
|
||||||
|
|
||||||
sendfile on;
|
sendfile on;
|
||||||
tcp_nopush on;
|
tcp_nopush on;
|
||||||
|
@ -79,6 +78,16 @@ http {
|
||||||
|
|
||||||
gzip_static on;
|
gzip_static on;
|
||||||
gzip_proxied expired no-cache no-store private auth;
|
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 {
|
location @unauthorized {
|
||||||
|
|
|
@ -31,6 +31,7 @@ RUN apk --no-cache add \
|
||||||
youtube_dl \
|
youtube_dl \
|
||||||
pillow-avif-plugin \
|
pillow-avif-plugin \
|
||||||
pyheif-pillow-opener \
|
pyheif-pillow-opener \
|
||||||
|
yattag \
|
||||||
&& apk --no-cache del py3-pip
|
&& apk --no-cache del py3-pip
|
||||||
|
|
||||||
COPY ./ /opt/app/
|
COPY ./ /opt/app/
|
||||||
|
|
|
@ -11,4 +11,5 @@ pytz>=2018.3
|
||||||
pyRFC3339>=1.0
|
pyRFC3339>=1.0
|
||||||
pillow-avif-plugin>=1.1.0
|
pillow-avif-plugin>=1.1.0
|
||||||
pyheif-pillow-opener>=0.1.0
|
pyheif-pillow-opener>=0.1.0
|
||||||
|
yattag>=1.14.0
|
||||||
youtube_dl
|
youtube_dl
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import szurubooru.api.comment_api
|
import szurubooru.api.comment_api
|
||||||
import szurubooru.api.info_api
|
import szurubooru.api.info_api
|
||||||
|
import szurubooru.api.opengraph_api
|
||||||
import szurubooru.api.password_reset_api
|
import szurubooru.api.password_reset_api
|
||||||
import szurubooru.api.pool_api
|
import szurubooru.api.pool_api
|
||||||
import szurubooru.api.pool_category_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
|
ret["featuringTime"] = post_feature.time if post_feature else None
|
||||||
return ret
|
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:
|
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):
|
if isinstance(obj, datetime):
|
||||||
serial = obj.isoformat("T") + "Z"
|
serial = obj.isoformat("T") + "Z"
|
||||||
return serial
|
return serial
|
||||||
raise TypeError("Type not serializable")
|
raise TypeError("Type not serializable")
|
||||||
|
|
||||||
|
|
||||||
def _dump_json(obj: Any) -> str:
|
def _serialize_response_body(obj: Any, accept: str) -> str:
|
||||||
return json.dumps(obj, default=_json_serializer, indent=2)
|
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]:
|
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(
|
def application(
|
||||||
env: Dict[str, Any], start_response: Callable[[str, Any], Any]
|
env: Dict[str, Any], start_response: Callable[[str, Any], Any]
|
||||||
) -> Tuple[bytes]:
|
) -> Tuple[bytes]:
|
||||||
|
accept = None
|
||||||
try:
|
try:
|
||||||
ctx = _create_context(env)
|
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(
|
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)
|
match = re.fullmatch(url, ctx.url)
|
||||||
if match:
|
if match:
|
||||||
if ctx.method not in allowed_methods:
|
if ctx.method not in allowed_methods:
|
||||||
|
@ -111,8 +122,10 @@ def application(
|
||||||
finally:
|
finally:
|
||||||
db.session.remove()
|
db.session.remove()
|
||||||
|
|
||||||
start_response("200", [("content-type", "application/json")])
|
start_response("200", [("content-type", accept)])
|
||||||
return (_dump_json(response).encode("utf-8"),)
|
return (
|
||||||
|
_serialize_response_body(response, accept).encode("utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
for exception_type, ex_handler in errors.error_handlers.items():
|
for exception_type, ex_handler in errors.error_handlers.items():
|
||||||
|
@ -123,7 +136,7 @@ def application(
|
||||||
except errors.BaseHttpError as ex:
|
except errors.BaseHttpError as ex:
|
||||||
start_response(
|
start_response(
|
||||||
"%d %s" % (ex.code, ex.reason),
|
"%d %s" % (ex.code, ex.reason),
|
||||||
[("content-type", "application/json")],
|
[("content-type", accept or "application/json")],
|
||||||
)
|
)
|
||||||
blob = {
|
blob = {
|
||||||
"name": ex.name,
|
"name": ex.name,
|
||||||
|
@ -133,4 +146,6 @@ def application(
|
||||||
if ex.extra_fields is not None:
|
if ex.extra_fields is not None:
|
||||||
for key, value in ex.extra_fields.items():
|
for key, value in ex.extra_fields.items():
|
||||||
blob[key] = value
|
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
|
from szurubooru.rest.context import Context, Response
|
||||||
|
|
||||||
RouteHandler = Callable[[Context, Dict[str, str]], 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:
|
def wrapper(handler: RouteHandler) -> RouteHandler:
|
||||||
routes[url]["GET"] = handler
|
routes[accept][url]["GET"] = handler
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
return wrapper
|
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:
|
def wrapper(handler: RouteHandler) -> RouteHandler:
|
||||||
routes[url]["PUT"] = handler
|
routes[accept][url]["PUT"] = handler
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
return wrapper
|
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:
|
def wrapper(handler: RouteHandler) -> RouteHandler:
|
||||||
routes[url]["POST"] = handler
|
routes[accept][url]["POST"] = handler
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
return wrapper
|
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:
|
def wrapper(handler: RouteHandler) -> RouteHandler:
|
||||||
routes[url]["DELETE"] = handler
|
routes[accept][url]["DELETE"] = handler
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
Reference in a new issue