diff --git a/client/html/index.htm b/client/html/index.htm
deleted file mode 100644
index 00728903..00000000
--- a/client/html/index.htm
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Loading...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/client/nginx.conf.docker b/client/nginx.conf.docker
index 36c35363..fef96bd1 100644
--- a/client/nginx.conf.docker
+++ b/client/nginx.conf.docker
@@ -53,14 +53,6 @@ http {
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain application/json;
- if ($http_x_forwarded_host = '') {
- set $http_x_forwarded_host $host;
- }
-
- if ($http_x_forwarded_proto = '') {
- set $http_x_forwarded_proto 'http';
- }
-
if ($request_uri ~* "/api/(.*)") {
proxy_pass http://backend/$1;
}
@@ -111,16 +103,10 @@ http {
return 406 "API requests should be sent to the /api prefix";
}
- if ($http_x_forwarded_host = '') {
- set $http_x_forwarded_host $host;
+ if ($request_uri ~* "/(.*)") {
+ proxy_pass http://backend/html/$1;
}
- if ($http_x_forwarded_proto = '') {
- set $http_x_forwarded_proto 'http';
- }
-
- proxy_pass http://backend;
-
error_page 500 502 503 504 @badproxy;
}
diff --git a/doc/INSTALL.md b/doc/INSTALL.md
index d978e4a8..90e24c16 100644
--- a/doc/INSTALL.md
+++ b/doc/INSTALL.md
@@ -89,8 +89,12 @@ user@host:szuru$ docker-compose down
Some users may wish to access the service at a different base URI, such
as `http://example.com/szuru/`, commonly when sharing multiple HTTP
- services on one domain using a reverse proxy. In this case, simply set
- `BASE_URL="/szuru/"` in your `.env` file.
+ services on one domain using a reverse proxy. This can be configured in
+ either of the following ways:
+
+ - Set the 'domain' value in `config.yaml` to include the prefix, i.e.:
+ `domain: "http://example.com/szuru" # omit trailing slash`
+ - Configure the reverse proxy to pass the `X-Forwarded-Prefix` header.
Note that this will require a reverse proxy to function. You should set
your reverse proxy to proxy `http(s)://example.com/szuru` to
@@ -102,14 +106,16 @@ user@host:szuru$ docker-compose down
proxy_http_version 1.1;
proxy_pass http:///;
- proxy_set_header Host $http_host;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Scheme $scheme;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-Proto $scheme;
- proxy_set_header X-Script-Name /szuru;
+ proxy_set_header Host $http_host;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header X-Forwarded-Prefix /szuru;
+
+ // optional...
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Scheme $scheme;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
}
```
diff --git a/doc/example.env b/doc/example.env
index 59e1e859..3ce7d3bc 100644
--- a/doc/example.env
+++ b/doc/example.env
@@ -10,10 +10,6 @@ BUILD_INFO=latest
# otherwise the port specified here will be publicly accessible
PORT=8080
-# URL base to run szurubooru under
-# See "Additional Features" section in INSTALL.md
-BASE_URL=/
-
# Directory to store image data
MOUNT_DATA=/var/local/szurubooru/data
diff --git a/docker-compose.yml b/docker-compose.yml
index 1da23bd6..de3e7e69 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -31,7 +31,6 @@ services:
- server
environment:
BACKEND_HOST: server
- BASE_URL:
volumes:
- "${MOUNT_DATA}:/data:ro"
ports:
diff --git a/server/szurubooru/api/info_api.py b/server/szurubooru/api/info_api.py
index 4dc2e5c7..d237631f 100644
--- a/server/szurubooru/api/info_api.py
+++ b/server/szurubooru/api/info_api.py
@@ -66,7 +66,7 @@ def get_info(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response:
return ret
-@rest.routes.get(r"/manifest\.json")
+@rest.routes.get(r"/manifest(?:\.json)?")
def generate_manifest(
ctx: rest.Context, _params: Dict[str, str] = {}
) -> rest.Response:
diff --git a/server/szurubooru/api/opengraph_api.py b/server/szurubooru/api/opengraph_api.py
index 2c47326c..5f7017ac 100644
--- a/server/szurubooru/api/opengraph_api.py
+++ b/server/szurubooru/api/opengraph_api.py
@@ -123,7 +123,7 @@ def _get_html_template(
"script", type="text/javascript", src="js/app.min.js"
):
pass
- return doc
+ return doc.getvalue()
def _get_post_id(params: Dict[str, str]) -> int:
@@ -139,7 +139,7 @@ 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")
+@rest.routes.get("/html/post/(?P[^/]+)/?", accept="text/html")
def get_post_html(
ctx: rest.Context, params: Dict[str, str] = {}
) -> rest.Response:
@@ -166,7 +166,7 @@ def get_post_html(
)
-@rest.routes.get("/.*", accept="text/html")
+@rest.routes.get("/html/.*", accept="text/html")
def default_route(
ctx: rest.Context, _params: Dict[str, str] = {}
) -> rest.Response:
diff --git a/server/szurubooru/api/password_reset_api.py b/server/szurubooru/api/password_reset_api.py
index e0e31b7d..c0e0d60f 100644
--- a/server/szurubooru/api/password_reset_api.py
+++ b/server/szurubooru/api/password_reset_api.py
@@ -25,15 +25,7 @@ def start_password_reset(
)
token = auth.generate_authentication_token(user)
- if config.config["domain"]:
- url = config.config["domain"]
- elif "HTTP_ORIGIN" in ctx.env:
- url = ctx.env["HTTP_ORIGIN"].rstrip("/")
- elif "HTTP_REFERER" in ctx.env:
- url = ctx.env["HTTP_REFERER"].rstrip("/")
- else:
- url = ""
- url += "/password-reset/%s:%s" % (user.name, token)
+ url = f"{ctx.url_prefix}/password-reset/{user.name}:{token}"
mailer.send_mail(
config.config["smtp"]["from"],
diff --git a/server/szurubooru/rest/app.py b/server/szurubooru/rest/app.py
index a132edd5..5f92f0a1 100644
--- a/server/szurubooru/rest/app.py
+++ b/server/szurubooru/rest/app.py
@@ -21,8 +21,8 @@ def _json_serializer(obj: Any) -> str:
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()
+ if "text/" in accept:
+ return obj
raise ValueError("Unhandled response type %s" % accept)
@@ -40,18 +40,6 @@ def _create_context(env: Dict[str, Any]) -> context.Context:
path = "/" + env["PATH_INFO"].lstrip("/")
path = path.encode("latin-1").decode("utf-8") # PEP-3333
headers = _get_headers(env)
- _raw_accept = headers.get("Accept", "text/html")
-
- if "application/json" in _raw_accept:
- accept = "application/json"
- elif "text/html" in _raw_accept:
- accept = "text/html"
- else:
- raise errors.HttpNotAcceptable(
- "ValidationError",
- "This API only supports the following response types: "
- "application/json, text/html",
- )
if config.config["domain"]:
url_prefix = config.config["domain"].rstrip("/")
@@ -88,7 +76,13 @@ def _create_context(env: Dict[str, Any]) -> context.Context:
)
return context.Context(
- env, method, path, headers, accept, url_prefix, params, files
+ env=env,
+ method=method,
+ url=path,
+ headers=headers,
+ url_prefix=url_prefix,
+ params=params,
+ files=files,
)
@@ -97,15 +91,32 @@ def application(
) -> Tuple[bytes]:
try:
ctx = _create_context(env)
- for url, allowed_methods in routes.routes[ctx.accept].items():
+ for url, allowed_methods in routes.routes.items():
match = re.fullmatch(url, ctx.url)
if match:
if ctx.method not in allowed_methods:
raise errors.HttpMethodNotAllowed(
"ValidationError",
- "Allowed methods: %r" % allowed_methods,
+ "Allowed methods: %s"
+ % ", ".join(allowed_methods.keys()),
)
- handler = allowed_methods[ctx.method]
+ handler, allowed_accept = allowed_methods[ctx.method]
+ if not any(
+ map(
+ lambda a: a in ctx.get_header("Accept"),
+ [
+ allowed_accept,
+ allowed_accept.split("/")[0] + "/*",
+ "*/*",
+ ],
+ )
+ ):
+ raise errors.HttpNotAcceptable(
+ "ValidationError",
+ "This route only supports %s responses."
+ % allowed_accept,
+ )
+ ctx.accept = allowed_accept
break
else:
raise errors.HttpNotFound(
diff --git a/server/szurubooru/rest/routes.py b/server/szurubooru/rest/routes.py
index f0c4e7a8..c3815c93 100644
--- a/server/szurubooru/rest/routes.py
+++ b/server/szurubooru/rest/routes.py
@@ -1,20 +1,18 @@
from collections import defaultdict
-from typing import Callable, Dict
+from typing import Callable, Dict, Tuple
from szurubooru.rest.context import Context, Response
RouteHandler = Callable[[Context, Dict[str, str]], Response]
-routes = { # type: Dict[Dict[str, Dict[str, RouteHandler]]]
- "application/json": defaultdict(dict),
- "text/html": defaultdict(dict),
-}
+routes = defaultdict(dict)
+# type: Dict[str, Dict[str, Tuple[RouteHandler, str]]]
def get(
url: str, accept: str = "application/json"
) -> Callable[[RouteHandler], RouteHandler]:
def wrapper(handler: RouteHandler) -> RouteHandler:
- routes[accept][url]["GET"] = handler
+ routes[url]["GET"] = (handler, accept)
return handler
return wrapper
@@ -24,7 +22,7 @@ def put(
url: str, accept: str = "application/json"
) -> Callable[[RouteHandler], RouteHandler]:
def wrapper(handler: RouteHandler) -> RouteHandler:
- routes[accept][url]["PUT"] = handler
+ routes[url]["PUT"] = (handler, accept)
return handler
return wrapper
@@ -34,7 +32,7 @@ def post(
url: str, accept: str = "application/json"
) -> Callable[[RouteHandler], RouteHandler]:
def wrapper(handler: RouteHandler) -> RouteHandler:
- routes[accept][url]["POST"] = handler
+ routes[url]["POST"] = (handler, accept)
return handler
return wrapper
@@ -44,7 +42,7 @@ def delete(
url: str, accept: str = "application/json"
) -> Callable[[RouteHandler], RouteHandler]:
def wrapper(handler: RouteHandler) -> RouteHandler:
- routes[accept][url]["DELETE"] = handler
+ routes[url]["DELETE"] = (handler, accept)
return handler
return wrapper
diff --git a/server/szurubooru/tests/api/test_info.py b/server/szurubooru/tests/api/test_info.py
index 37099e8d..3df3958f 100644
--- a/server/szurubooru/tests/api/test_info.py
+++ b/server/szurubooru/tests/api/test_info.py
@@ -94,3 +94,29 @@ def test_info_api(
"serverTime": datetime(2016, 1, 3, 13, 1),
"config": expected_config_key,
}
+
+
+def test_manifest(config_injector, context_factory):
+ config_injector({"name": "test installation"})
+ ctx = context_factory()
+ ctx.url_prefix = "/someprefix"
+ expected_manifest = {
+ "name": "test installation",
+ "icons": [
+ {
+ "src": "/someprefix/img/android-chrome-192x192.png",
+ "type": "image/png",
+ "sizes": "192x192",
+ },
+ {
+ "src": "/someprefix/img/android-chrome-512x512.png",
+ "type": "image/png",
+ "sizes": "512x512",
+ },
+ ],
+ "start_url": "/someprefix/",
+ "theme_color": "#24aadd",
+ "background_color": "#ffffff",
+ "display": "standalone",
+ }
+ assert api.info_api.generate_manifest(ctx) == expected_manifest
diff --git a/server/szurubooru/tests/api/test_opengraph.py b/server/szurubooru/tests/api/test_opengraph.py
new file mode 100644
index 00000000..8ab05aeb
--- /dev/null
+++ b/server/szurubooru/tests/api/test_opengraph.py
@@ -0,0 +1,40 @@
+from unittest.mock import patch
+
+import pytest
+import yattag
+
+from szurubooru import api, db
+from szurubooru.func import auth, posts
+
+
+def _make_meta_tag(name, content):
+ doc = yattag.Doc()
+ doc.stag("meta", name=name, content=content)
+ return doc.getvalue()
+
+
+@pytest.mark.parametrize("view_priv", [True, False])
+def test_get_post_html(
+ config_injector, context_factory, post_factory, view_priv
+):
+ config_injector(
+ {
+ "name": "test installation",
+ "data_url": "data/",
+ }
+ )
+ ctx = context_factory()
+ ctx.url_prefix = "/someprefix"
+ db.session.add(post_factory(id=1))
+ db.session.flush()
+ with patch("szurubooru.func.auth.has_privilege"), patch(
+ "szurubooru.func.posts.get_post_content_url"
+ ):
+ auth.has_privilege.return_value = view_priv
+ posts.get_post_content_url.return_value = "/content-url"
+ ret = api.opengraph_api.get_post_html(ctx, {"post_id": 1})
+
+ assert _make_meta_tag("og:site_name", "test installation") in ret
+ assert _make_meta_tag("og:title", "Post 1 - test installation") in ret
+ if view_priv:
+ assert _make_meta_tag("og:image", "/content-url") in ret
diff --git a/server/szurubooru/tests/api/test_password_reset.py b/server/szurubooru/tests/api/test_password_reset.py
index bf1ab5c7..d6195880 100644
--- a/server/szurubooru/tests/api/test_password_reset.py
+++ b/server/szurubooru/tests/api/test_password_reset.py
@@ -27,11 +27,13 @@ def test_reset_sending_email(context_factory, user_factory):
)
)
db.session.flush()
+ ctx = context_factory()
+ ctx.url_prefix = "http://example.com"
for initiating_user in ["u1", "user@example.com"]:
with patch("szurubooru.func.mailer.send_mail"):
assert (
api.password_reset_api.start_password_reset(
- context_factory(), {"user_name": initiating_user}
+ ctx, {"user_name": initiating_user}
)
== {}
)
diff --git a/server/szurubooru/tests/conftest.py b/server/szurubooru/tests/conftest.py
index 280987ca..fb773498 100644
--- a/server/szurubooru/tests/conftest.py
+++ b/server/szurubooru/tests/conftest.py
@@ -67,12 +67,13 @@ def nontransacted_session(query_logger, postgresql_db):
@pytest.fixture
def context_factory(session):
- def factory(params=None, files=None, user=None, headers=None):
+ def factory(params=None, files=None, user=None, headers=None, accept=None):
ctx = rest.Context(
env={"HTTP_ORIGIN": "http://example.com"},
method=None,
url=None,
headers=headers or {},
+ accept=accept or None,
params=params or {},
files=files or {},
)
diff --git a/server/szurubooru/tests/middleware/test_authenticator.py b/server/szurubooru/tests/middleware/test_authenticator.py
index 9a4a3cc6..325b27c2 100644
--- a/server/szurubooru/tests/middleware/test_authenticator.py
+++ b/server/szurubooru/tests/middleware/test_authenticator.py
@@ -9,11 +9,26 @@ from szurubooru.rest import errors
def test_process_request_no_header(context_factory):
- ctx = context_factory()
+ ctx = context_factory(accept="application/json")
authenticator.process_request(ctx)
assert ctx.user.name is None
+def test_process_request_non_rest(context_factory, user_factory):
+ user = user_factory()
+ ctx = context_factory(
+ headers={"Authorization": "Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk"},
+ accept="text/html",
+ )
+ with patch("szurubooru.func.auth.is_valid_password"), patch(
+ "szurubooru.func.users.get_user_by_name"
+ ):
+ users.get_user_by_name.return_value = user
+ auth.is_valid_password.return_value = True
+ authenticator.process_request(ctx)
+ assert ctx.user.name is None
+
+
def test_process_request_bump_login(context_factory, user_factory):
user = user_factory()
db.session.add(user)
@@ -21,6 +36,7 @@ def test_process_request_bump_login(context_factory, user_factory):
ctx = context_factory(
headers={"Authorization": "Basic dGVzdFVzZXI6dGVzdFRva2Vu"},
params={"bump-login": "true"},
+ accept="application/json",
)
with patch("szurubooru.func.auth.is_valid_password"), patch(
"szurubooru.func.users.get_user_by_name"
@@ -40,6 +56,7 @@ def test_process_request_bump_login_with_token(
ctx = context_factory(
headers={"Authorization": "Token dGVzdFVzZXI6dGVzdFRva2Vu"},
params={"bump-login": "true"},
+ accept="application/json",
)
with patch("szurubooru.func.auth.is_valid_token"), patch(
"szurubooru.func.users.get_user_by_name"
@@ -55,7 +72,8 @@ def test_process_request_bump_login_with_token(
def test_process_request_basic_auth_valid(context_factory, user_factory):
user = user_factory()
ctx = context_factory(
- headers={"Authorization": "Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk"}
+ headers={"Authorization": "Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk"},
+ accept="application/json",
)
with patch("szurubooru.func.auth.is_valid_password"), patch(
"szurubooru.func.users.get_user_by_name"
@@ -69,7 +87,8 @@ def test_process_request_basic_auth_valid(context_factory, user_factory):
def test_process_request_token_auth_valid(context_factory, user_token_factory):
user_token = user_token_factory()
ctx = context_factory(
- headers={"Authorization": "Token dGVzdFVzZXI6dGVzdFRva2Vu"}
+ headers={"Authorization": "Token dGVzdFVzZXI6dGVzdFRva2Vu"},
+ accept="application/json",
)
with patch("szurubooru.func.auth.is_valid_token"), patch(
"szurubooru.func.users.get_user_by_name"
@@ -82,6 +101,9 @@ def test_process_request_token_auth_valid(context_factory, user_token_factory):
def test_process_request_bad_header(context_factory):
- ctx = context_factory(headers={"Authorization": "Secret SuperSecretValue"})
+ ctx = context_factory(
+ headers={"Authorization": "Secret SuperSecretValue"},
+ accept="application/json",
+ )
with pytest.raises(errors.HttpBadRequest):
authenticator.process_request(ctx)