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)