diff --git a/docs/src/generated/settings.json b/docs/src/generated/settings.json index fcb47dbfb23..ccdb1bb04ae 100644 --- a/docs/src/generated/settings.json +++ b/docs/src/generated/settings.json @@ -92,6 +92,28 @@ "type": "typing.Optional[pathlib.Path]", "validation": {} }, + { + "category": "WEB", + "default": null, + "description": "Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Required when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`); optional when the proxy strips it (set it anyway so openapi/docs URLs are correct). Leave unset when serving at the domain root. Normalized to a single leading slash with no trailing slash.", + "env_var": "INVOKEAI_BASE_URL", + "literal_values": [], + "name": "base_url", + "required": false, + "type": "typing.Optional[str]", + "validation": {} + }, + { + "category": "WEB", + "default": "127.0.0.1", + "description": "Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set.", + "env_var": "INVOKEAI_FORWARDED_ALLOW_IPS", + "literal_values": [], + "name": "forwarded_allow_ips", + "required": false, + "type": "", + "validation": {} + }, { "category": "MISC FEATURES", "default": false, diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index 4b79e1eeb0c..0f90233029f 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -11,6 +11,7 @@ from fastapi_events.handlers.local import local_handler from fastapi_events.middleware import EventHandlerASGIMiddleware from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.types import ASGIApp, Receive, Scope, Send import invokeai.frontend.web as web_dir from invokeai.app.api.dependencies import ApiDependencies @@ -136,13 +137,56 @@ class RedirectRootWithQueryStringMiddleware(BaseHTTPMiddleware): """ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): - if request.url.path == "/" and request.url.query: - return RedirectResponse(url="/") + # Be root_path-aware: behind a sub-path proxy, `scope["path"]` retains the public prefix + # (see SubPathASGIMiddleware), so the app root is `/`, not just `/`. + root_path = request.scope.get("root_path", "") + if request.url.path in ("/", root_path + "/") and request.url.query: + return RedirectResponse(url=root_path + "/") response = await call_next(request) return response +class SubPathASGIMiddleware: + """Make the app work behind a reverse proxy that serves it under a sub-path (e.g. `/invoke`). + + Handles both common proxy styles: + - The proxy STRIPS the sub-path: the incoming path is already `/api/...`. We restore the prefix + so `scope["path"]` is the public path, and advertise it via `root_path`. + - The proxy PRESERVES the sub-path: the incoming path is already `/invoke/api/...`. We leave the + path as-is and advertise the prefix via `root_path`. + + Either way `scope["path"]` ends up holding the full public path (prefix included), as the ASGI + spec requires. Starlette's router strips `root_path` for route matching (via `get_route_path`) + and builds redirect/`url_for` URLs from the full path, so server-generated redirects keep the + prefix. + + Note: we deliberately do NOT use uvicorn's `root_path`, because uvicorn also PREPENDS it to the + request path, which would double the prefix for the preserve-style proxy. Doing the rewrite here + covers both styles. + """ + + def __init__(self, app: ASGIApp, base_path: str) -> None: + self.app = app + self.base_path = base_path + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] in ("http", "websocket"): + scope = dict(scope) + path: str = scope.get("path", "") + # Strip-style proxy: the prefix is missing, so restore it. Per the ASGI spec `scope["path"]` + # must include `root_path`; Starlette strips it again for matching and uses the full path + # for redirects/url_for, keeping the prefix on server-generated redirects. + if not (path == self.base_path or path.startswith(self.base_path + "/")): + scope["path"] = self.base_path + path + raw_path = scope.get("raw_path") + if raw_path is not None: + scope["raw_path"] = self.base_path.encode("latin-1") + raw_path + # Advertise the public prefix for both styles (openapi servers, /docs, redirects). + scope["root_path"] = self.base_path + await self.app(scope, receive, send) + + # Add the middleware app.add_middleware(RedirectRootWithQueryStringMiddleware) app.add_middleware(SlidingWindowTokenMiddleware) @@ -193,18 +237,22 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): @app.get("/docs", include_in_schema=False) -def overridden_swagger() -> HTMLResponse: +def overridden_swagger(request: Request) -> HTMLResponse: + # Prefix with the proxy root_path (if any) so the schema resolves under a sub-path deployment. + root_path = request.scope.get("root_path", "") return get_swagger_ui_html( - openapi_url=app.openapi_url, # type: ignore [arg-type] # this is always a string + openapi_url=f"{root_path}{app.openapi_url}", # type: ignore [arg-type] # this is always a string title=f"{app.title} - Swagger UI", swagger_favicon_url="static/docs/invoke-favicon-docs.svg", ) @app.get("/redoc", include_in_schema=False) -def overridden_redoc() -> HTMLResponse: +def overridden_redoc(request: Request) -> HTMLResponse: + # Prefix with the proxy root_path (if any) so the schema resolves under a sub-path deployment. + root_path = request.scope.get("root_path", "") return get_redoc_html( - openapi_url=app.openapi_url, # type: ignore [arg-type] # this is always a string + openapi_url=f"{root_path}{app.openapi_url}", # type: ignore [arg-type] # this is always a string title=f"{app.title} - Redoc", redoc_favicon_url="static/docs/invoke-favicon-docs.svg", ) diff --git a/invokeai/app/run_app.py b/invokeai/app/run_app.py index febd4f4d4b1..7401b3148c6 100644 --- a/invokeai/app/run_app.py +++ b/invokeai/app/run_app.py @@ -1,3 +1,6 @@ +from typing import Any + + def get_app(): """Import the app and event loop. We wrap this in a function to more explicitly control when it happens, because importing from api_app does a bunch of stuff - it's more like calling a function than importing a module. @@ -87,15 +90,29 @@ def run_app() -> None: # imported. enable_dev_reload(custom_nodes_path=app_config.custom_nodes_path) + # When running behind a reverse proxy that serves the app under a sub-path (e.g. `/invoke`), + # wrap the app so route matching and openapi/docs work for both proxy styles (strip & preserve). + # Also honor X-Forwarded-* headers. Only enabled when `base_url` is set, so default installations + # are unaffected. + from invokeai.app.api_app import SubPathASGIMiddleware + + asgi_app: Any = app + proxy_kwargs: dict[str, Any] = {} + if app_config.base_url: + asgi_app = SubPathASGIMiddleware(app, app_config.base_url) + # `proxy_headers=True` is already uvicorn's default; we only need to widen the trusted proxies. + proxy_kwargs = {"forwarded_allow_ips": app_config.forwarded_allow_ips} + # Start the server. config = uvicorn.Config( - app=app, + app=asgi_app, host=app_config.host, port=app_config.port, loop="asyncio", log_level=app_config.log_level_network, ssl_certfile=app_config.ssl_certfile, ssl_keyfile=app_config.ssl_keyfile, + **proxy_kwargs, ) server = uvicorn.Server(config) diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index e6cc7c2798c..12bb3b5a5bb 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -32,6 +32,9 @@ LOG_LEVEL = Literal["debug", "info", "warning", "error", "critical"] IMAGE_SUBFOLDER_STRATEGY = Literal["flat", "date", "type", "hash"] CONFIG_SCHEMA_VERSION = "4.0.3" +# Path prefixes owned by real routes/mounts. A `base_url` starting with one of these would collide +# with routing and silently brick the server, so it is rejected during validation. +RESERVED_BASE_URL_PREFIXES = {"api", "ws", "static", "docs", "redoc", "openapi.json", "locales", "assets"} EXTERNAL_PROVIDER_CONFIG_FIELDS = ( "external_alibabacloud_api_key", "external_alibabacloud_base_url", @@ -134,6 +137,8 @@ class InvokeAIAppConfig(BaseSettings): external_openai_base_url: Base URL override for OpenAI image generation. external_seedream_api_key: API key for Seedream image generation. external_seedream_base_url: Base URL override for Seedream image generation. + base_url: Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Set only when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`). Leave unset when the proxy strips the sub-path or when serving at the domain root. + forwarded_allow_ips: Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set. """ _root: Optional[Path] = PrivateAttr(default=None) @@ -155,6 +160,8 @@ class InvokeAIAppConfig(BaseSettings): allow_headers: list[str] = Field(default=["*"], description="Headers allowed for CORS.") ssl_certfile: Optional[Path] = Field(default=None, description="SSL certificate file for HTTPS. See https://www.uvicorn.dev/settings/#https.") ssl_keyfile: Optional[Path] = Field(default=None, description="SSL key file for HTTPS. See https://www.uvicorn.dev/settings/#https.") + base_url: Optional[str] = Field(default=None, description="Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Required when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`); optional when the proxy strips it (set it anyway so openapi/docs URLs are correct). Leave unset when serving at the domain root. Normalized to a single leading slash with no trailing slash.") + forwarded_allow_ips: str = Field(default="127.0.0.1", description="Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set.") # MISC FEATURES log_tokenization: bool = Field(default=False, description="Enable logging of parsed prompt tokens.") @@ -257,6 +264,28 @@ class InvokeAIAppConfig(BaseSettings): model_config = SettingsConfigDict(env_prefix="INVOKEAI_", env_ignore_empty=True) + @field_validator("base_url") + @classmethod + def validate_base_url(cls, v: Optional[str]) -> Optional[str]: + """Normalize the reverse-proxy base path: ensure a single leading slash, no trailing slash. + + Empty values and a bare `/` normalize to `None` (feature disabled). + + Reject base paths whose first segment collides with a real route prefix (`/api`, `/ws`, ...): + such a value silently bricks the server in both proxy styles (the sub-path rewrite and + Starlette's own `root_path` stripping fight over the same prefix), with no hint at the cause, + so we fail fast instead. + """ + if v is None: + return None + v = v.strip().strip("/") + if not v: + return None + first_segment = v.split("/")[0] + if first_segment in RESERVED_BASE_URL_PREFIXES: + raise ValueError(f"base_url must not start with reserved path segment '/{first_segment}'") + return f"/{v}" + def update_config(self, config: dict[str, Any] | InvokeAIAppConfig, clobber: bool = True) -> None: """Updates the config, overwriting existing values. diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 7033408b197..f6c650c8021 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -40890,6 +40890,24 @@ "title": "Ssl Keyfile", "description": "SSL key file for HTTPS. See https://www.uvicorn.dev/settings/#https." }, + "base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Base Url", + "description": "Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Required when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`); optional when the proxy strips it (set it anyway so openapi/docs URLs are correct). Leave unset when serving at the domain root. Normalized to a single leading slash with no trailing slash." + }, + "forwarded_allow_ips": { + "type": "string", + "title": "Forwarded Allow Ips", + "description": "Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set.", + "default": "127.0.0.1" + }, "log_tokenization": { "type": "boolean", "title": "Log Tokenization", @@ -41423,7 +41441,7 @@ "additionalProperties": false, "type": "object", "title": "InvokeAIAppConfig", - "description": "Invoke's global app configuration.\n\nTypically, you won't need to interact with this class directly. Instead, use the `get_config` function from `invokeai.app.services.config` to get a singleton config object.\n\nAttributes:\n host: IP address to bind to. Use `0.0.0.0` to serve to your local network.\n port: Port to bind to.\n allow_origins: Allowed CORS origins.\n allow_credentials: Allow CORS credentials.\n allow_methods: Methods allowed for CORS.\n allow_headers: Headers allowed for CORS.\n ssl_certfile: SSL certificate file for HTTPS. See https://www.uvicorn.dev/settings/#https.\n ssl_keyfile: SSL key file for HTTPS. See https://www.uvicorn.dev/settings/#https.\n log_tokenization: Enable logging of parsed prompt tokens.\n patchmatch: Enable patchmatch inpaint code.\n models_dir: Path to the models directory.\n convert_cache_dir: Path to the converted models cache directory (DEPRECATED, but do not delete because it is needed for migration from previous versions).\n download_cache_dir: Path to the directory that contains dynamically downloaded models.\n legacy_conf_dir: Path to directory of legacy checkpoint config files.\n db_dir: Path to InvokeAI databases directory.\n outputs_dir: Path to directory for outputs.\n image_subfolder_strategy: Strategy for organizing images into subfolders. 'flat' stores all images in a single folder. 'date' organizes by YYYY/MM/DD. 'type' organizes by image category. 'hash' uses first 2 characters of UUID for filesystem performance.
Valid values: `flat`, `date`, `type`, `hash`\n custom_nodes_dir: Path to directory for custom nodes.\n style_presets_dir: Path to directory for style presets.\n workflow_thumbnails_dir: Path to directory for workflow thumbnails.\n log_handlers: Log handler. Valid options are \"console\", \"file=\", \"syslog=path|address:host:port\", \"http=\".\n log_format: Log format. Use \"plain\" for text-only, \"color\" for colorized output, \"legacy\" for 2.3-style logging and \"syslog\" for syslog-style.
Valid values: `plain`, `color`, `syslog`, `legacy`\n log_level: Emit logging messages at this level or higher.
Valid values: `debug`, `info`, `warning`, `error`, `critical`\n log_sql: Log SQL queries. `log_level` must be `debug` for this to do anything. Extremely verbose.\n log_level_network: Log level for network-related messages. 'info' and 'debug' are very verbose.
Valid values: `debug`, `info`, `warning`, `error`, `critical`\n use_memory_db: Use in-memory database. Useful for development.\n dev_reload: Automatically reload when Python sources are changed. Does not reload node definitions.\n profile_graphs: Enable graph profiling using `cProfile`.\n profile_prefix: An optional prefix for profile output files.\n profiles_dir: Path to profiles output directory.\n max_cache_ram_gb: The maximum amount of CPU RAM to use for model caching in GB. If unset, the limit will be configured based on the available RAM. In most cases, it is recommended to leave this unset.\n max_cache_vram_gb: The amount of VRAM to use for model caching in GB. If unset, the limit will be configured based on the available VRAM and the device_working_mem_gb. In most cases, it is recommended to leave this unset.\n log_memory_usage: If True, a memory snapshot will be captured before and after every model cache operation, and the result will be logged (at debug level). There is a time cost to capturing the memory snapshots, so it is recommended to only enable this feature if you are actively inspecting the model cache's behaviour.\n model_cache_keep_alive_min: How long to keep models in cache after last use, in minutes. A value of 0 (the default) means models are kept in cache indefinitely. If no model generations occur within the timeout period, the model cache is cleared using the same logic as the 'Clear Model Cache' button.\n device_working_mem_gb: The amount of working memory to keep available on the compute device (in GB). Has no effect if running on CPU. If you are experiencing OOM errors, try increasing this value.\n enable_partial_loading: Enable partial loading of models. This enables models to run with reduced VRAM requirements (at the cost of slower speed) by streaming the model from RAM to VRAM as its used. In some edge cases, partial loading can cause models to run more slowly if they were previously being fully loaded into VRAM.\n keep_ram_copy_of_weights: Whether to keep a full RAM copy of a model's weights when the model is loaded in VRAM. Keeping a RAM copy increases average RAM usage, but speeds up model switching and LoRA patching (assuming there is sufficient RAM). Set this to False if RAM pressure is consistently high.\n ram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_ram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.\n vram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_vram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.\n lazy_offload: DEPRECATED: This setting is no longer used. Lazy-offloading is enabled by default. This config setting will be removed once the new model cache behavior is stable.\n pytorch_cuda_alloc_conf: Configure the Torch CUDA memory allocator. This will impact peak reserved VRAM usage and performance. Setting to \"backend:cudaMallocAsync\" works well on many systems. The optimal configuration is highly dependent on the system configuration (device type, VRAM, CUDA driver version, etc.), so must be tuned experimentally.\n device: Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities.
Valid values: `auto`, `cpu`, `cuda`, `mps`, `cuda:N` (where N is a device number)\n precision: Floating point precision. `float16` will consume half the memory of `float32` but produce slightly lower-quality images. The `auto` setting will guess the proper precision based on your video card and operating system.
Valid values: `auto`, `float16`, `bfloat16`, `float32`\n sequential_guidance: Whether to calculate guidance in serial instead of in parallel, lowering memory requirements.\n attention_type: Attention type.
Valid values: `auto`, `normal`, `xformers`, `sliced`, `torch-sdp`\n attention_slice_size: Slice size, valid when attention_type==\"sliced\".
Valid values: `auto`, `balanced`, `max`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`\n force_tiled_decode: Whether to enable tiled VAE decode (reduces memory consumption with some performance penalty).\n pil_compress_level: The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting.\n max_queue_size: Maximum number of items in the session queue.\n clear_queue_on_startup: Empties session queue on startup. If true, disables `max_queue_history`.\n max_queue_history: Keep the last N completed, failed, and canceled queue items. Older items are deleted on startup. Set to 0 to prune all terminal items. Ignored if `clear_queue_on_startup` is true.\n allow_nodes: List of nodes to allow. Omit to allow all.\n deny_nodes: List of nodes to deny. Omit to deny none.\n node_cache_size: How many cached nodes to keep in memory.\n hashing_algorithm: Model hashing algorthim for model installs. 'blake3_multi' is best for SSDs. 'blake3_single' is best for spinning disk HDDs. 'random' disables hashing, instead assigning a UUID to models. Useful when using a memory db to reduce model installation time, or if you don't care about storing stable hashes for models. Alternatively, any other hashlib algorithm is accepted, though these are not nearly as performant as blake3.
Valid values: `blake3_multi`, `blake3_single`, `random`, `md5`, `sha1`, `sha224`, `sha256`, `sha384`, `sha512`, `blake2b`, `blake2s`, `sha3_224`, `sha3_256`, `sha3_384`, `sha3_512`, `shake_128`, `shake_256`\n remote_api_tokens: List of regular expression and token pairs used when downloading models from URLs. The download URL is tested against the regex, and if it matches, the token is provided in as a Bearer token.\n scan_models_on_startup: Scan the models directory on startup, registering orphaned models. This is typically only used in conjunction with `use_memory_db` for testing purposes.\n unsafe_disable_picklescan: UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production.\n allow_unknown_models: Allow installation of models that we are unable to identify. If enabled, models will be marked as `unknown` in the database, and will not have any metadata associated with them. If disabled, unknown models will be rejected during installation.\n multiuser: Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization.\n strict_password_checking: Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user.\n external_alibabacloud_api_key: API key for Alibaba Cloud DashScope image generation.\n external_alibabacloud_base_url: Base URL override for Alibaba Cloud DashScope image generation.\n external_gemini_api_key: API key for Gemini image generation.\n external_openai_api_key: API key for OpenAI image generation.\n external_gemini_base_url: Base URL override for Gemini image generation.\n external_openai_base_url: Base URL override for OpenAI image generation.\n external_seedream_api_key: API key for Seedream image generation.\n external_seedream_base_url: Base URL override for Seedream image generation." + "description": "Invoke's global app configuration.\n\nTypically, you won't need to interact with this class directly. Instead, use the `get_config` function from `invokeai.app.services.config` to get a singleton config object.\n\nAttributes:\n host: IP address to bind to. Use `0.0.0.0` to serve to your local network.\n port: Port to bind to.\n allow_origins: Allowed CORS origins.\n allow_credentials: Allow CORS credentials.\n allow_methods: Methods allowed for CORS.\n allow_headers: Headers allowed for CORS.\n ssl_certfile: SSL certificate file for HTTPS. See https://www.uvicorn.dev/settings/#https.\n ssl_keyfile: SSL key file for HTTPS. See https://www.uvicorn.dev/settings/#https.\n log_tokenization: Enable logging of parsed prompt tokens.\n patchmatch: Enable patchmatch inpaint code.\n models_dir: Path to the models directory.\n convert_cache_dir: Path to the converted models cache directory (DEPRECATED, but do not delete because it is needed for migration from previous versions).\n download_cache_dir: Path to the directory that contains dynamically downloaded models.\n legacy_conf_dir: Path to directory of legacy checkpoint config files.\n db_dir: Path to InvokeAI databases directory.\n outputs_dir: Path to directory for outputs.\n image_subfolder_strategy: Strategy for organizing images into subfolders. 'flat' stores all images in a single folder. 'date' organizes by YYYY/MM/DD. 'type' organizes by image category. 'hash' uses first 2 characters of UUID for filesystem performance.
Valid values: `flat`, `date`, `type`, `hash`\n custom_nodes_dir: Path to directory for custom nodes.\n style_presets_dir: Path to directory for style presets.\n workflow_thumbnails_dir: Path to directory for workflow thumbnails.\n log_handlers: Log handler. Valid options are \"console\", \"file=\", \"syslog=path|address:host:port\", \"http=\".\n log_format: Log format. Use \"plain\" for text-only, \"color\" for colorized output, \"legacy\" for 2.3-style logging and \"syslog\" for syslog-style.
Valid values: `plain`, `color`, `syslog`, `legacy`\n log_level: Emit logging messages at this level or higher.
Valid values: `debug`, `info`, `warning`, `error`, `critical`\n log_sql: Log SQL queries. `log_level` must be `debug` for this to do anything. Extremely verbose.\n log_level_network: Log level for network-related messages. 'info' and 'debug' are very verbose.
Valid values: `debug`, `info`, `warning`, `error`, `critical`\n use_memory_db: Use in-memory database. Useful for development.\n dev_reload: Automatically reload when Python sources are changed. Does not reload node definitions.\n profile_graphs: Enable graph profiling using `cProfile`.\n profile_prefix: An optional prefix for profile output files.\n profiles_dir: Path to profiles output directory.\n max_cache_ram_gb: The maximum amount of CPU RAM to use for model caching in GB. If unset, the limit will be configured based on the available RAM. In most cases, it is recommended to leave this unset.\n max_cache_vram_gb: The amount of VRAM to use for model caching in GB. If unset, the limit will be configured based on the available VRAM and the device_working_mem_gb. In most cases, it is recommended to leave this unset.\n log_memory_usage: If True, a memory snapshot will be captured before and after every model cache operation, and the result will be logged (at debug level). There is a time cost to capturing the memory snapshots, so it is recommended to only enable this feature if you are actively inspecting the model cache's behaviour.\n model_cache_keep_alive_min: How long to keep models in cache after last use, in minutes. A value of 0 (the default) means models are kept in cache indefinitely. If no model generations occur within the timeout period, the model cache is cleared using the same logic as the 'Clear Model Cache' button.\n device_working_mem_gb: The amount of working memory to keep available on the compute device (in GB). Has no effect if running on CPU. If you are experiencing OOM errors, try increasing this value.\n enable_partial_loading: Enable partial loading of models. This enables models to run with reduced VRAM requirements (at the cost of slower speed) by streaming the model from RAM to VRAM as its used. In some edge cases, partial loading can cause models to run more slowly if they were previously being fully loaded into VRAM.\n keep_ram_copy_of_weights: Whether to keep a full RAM copy of a model's weights when the model is loaded in VRAM. Keeping a RAM copy increases average RAM usage, but speeds up model switching and LoRA patching (assuming there is sufficient RAM). Set this to False if RAM pressure is consistently high.\n ram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_ram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.\n vram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_vram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.\n lazy_offload: DEPRECATED: This setting is no longer used. Lazy-offloading is enabled by default. This config setting will be removed once the new model cache behavior is stable.\n pytorch_cuda_alloc_conf: Configure the Torch CUDA memory allocator. This will impact peak reserved VRAM usage and performance. Setting to \"backend:cudaMallocAsync\" works well on many systems. The optimal configuration is highly dependent on the system configuration (device type, VRAM, CUDA driver version, etc.), so must be tuned experimentally.\n device: Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities.
Valid values: `auto`, `cpu`, `cuda`, `mps`, `cuda:N` (where N is a device number)\n precision: Floating point precision. `float16` will consume half the memory of `float32` but produce slightly lower-quality images. The `auto` setting will guess the proper precision based on your video card and operating system.
Valid values: `auto`, `float16`, `bfloat16`, `float32`\n sequential_guidance: Whether to calculate guidance in serial instead of in parallel, lowering memory requirements.\n attention_type: Attention type.
Valid values: `auto`, `normal`, `xformers`, `sliced`, `torch-sdp`\n attention_slice_size: Slice size, valid when attention_type==\"sliced\".
Valid values: `auto`, `balanced`, `max`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`\n force_tiled_decode: Whether to enable tiled VAE decode (reduces memory consumption with some performance penalty).\n pil_compress_level: The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting.\n max_queue_size: Maximum number of items in the session queue.\n clear_queue_on_startup: Empties session queue on startup. If true, disables `max_queue_history`.\n max_queue_history: Keep the last N completed, failed, and canceled queue items. Older items are deleted on startup. Set to 0 to prune all terminal items. Ignored if `clear_queue_on_startup` is true.\n allow_nodes: List of nodes to allow. Omit to allow all.\n deny_nodes: List of nodes to deny. Omit to deny none.\n node_cache_size: How many cached nodes to keep in memory.\n hashing_algorithm: Model hashing algorthim for model installs. 'blake3_multi' is best for SSDs. 'blake3_single' is best for spinning disk HDDs. 'random' disables hashing, instead assigning a UUID to models. Useful when using a memory db to reduce model installation time, or if you don't care about storing stable hashes for models. Alternatively, any other hashlib algorithm is accepted, though these are not nearly as performant as blake3.
Valid values: `blake3_multi`, `blake3_single`, `random`, `md5`, `sha1`, `sha224`, `sha256`, `sha384`, `sha512`, `blake2b`, `blake2s`, `sha3_224`, `sha3_256`, `sha3_384`, `sha3_512`, `shake_128`, `shake_256`\n remote_api_tokens: List of regular expression and token pairs used when downloading models from URLs. The download URL is tested against the regex, and if it matches, the token is provided in as a Bearer token.\n scan_models_on_startup: Scan the models directory on startup, registering orphaned models. This is typically only used in conjunction with `use_memory_db` for testing purposes.\n unsafe_disable_picklescan: UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production.\n allow_unknown_models: Allow installation of models that we are unable to identify. If enabled, models will be marked as `unknown` in the database, and will not have any metadata associated with them. If disabled, unknown models will be rejected during installation.\n multiuser: Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization.\n strict_password_checking: Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user.\n external_alibabacloud_api_key: API key for Alibaba Cloud DashScope image generation.\n external_alibabacloud_base_url: Base URL override for Alibaba Cloud DashScope image generation.\n external_gemini_api_key: API key for Gemini image generation.\n external_openai_api_key: API key for OpenAI image generation.\n external_gemini_base_url: Base URL override for Gemini image generation.\n external_openai_base_url: Base URL override for OpenAI image generation.\n external_seedream_api_key: API key for Seedream image generation.\n external_seedream_base_url: Base URL override for Seedream image generation.\n base_url: Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Set only when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`). Leave unset when the proxy strips the sub-path or when serving at the domain root.\n forwarded_allow_ips: Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set." }, "InvokeAIAppConfigWithSetFields": { "properties": { diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx index 685e1ee3a91..cde63370e84 100644 --- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx +++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx @@ -5,6 +5,7 @@ import { addStorageListeners } from 'app/store/enhancers/reduxRemember/driver'; import { $store } from 'app/store/nanostores/store'; import { createStore } from 'app/store/store'; import Loading from 'common/components/Loading/Loading'; +import { getBasePath } from 'common/util/baseUrl'; import React, { lazy, useEffect, useState } from 'react'; import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; @@ -48,7 +49,7 @@ const InvokeAIUI = () => { return ( - + }> diff --git a/invokeai/frontend/web/src/common/util/baseUrl.test.ts b/invokeai/frontend/web/src/common/util/baseUrl.test.ts new file mode 100644 index 00000000000..42652ddc81f --- /dev/null +++ b/invokeai/frontend/web/src/common/util/baseUrl.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { deriveDeploymentBaseUrl } from './baseUrl'; + +describe('deriveDeploymentBaseUrl', () => { + const fallback = 'http://fallback.example'; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns origin + sub-path prefix when served from a sub-path', () => { + expect(deriveDeploymentBaseUrl('https://example.com/invoke/assets/index-abc.js', fallback)).toBe( + 'https://example.com/invoke' + ); + }); + + it('returns just the origin when served from the domain root', () => { + expect(deriveDeploymentBaseUrl('https://example.com/assets/index-abc.js', fallback)).toBe('https://example.com'); + }); + + it('handles nested sub-paths', () => { + expect(deriveDeploymentBaseUrl('https://example.com/a/b/c/assets/index-abc.js', fallback)).toBe( + 'https://example.com/a/b/c' + ); + }); + + it('preserves a non-default port', () => { + expect(deriveDeploymentBaseUrl('http://localhost:8080/invoke/assets/index-abc.js', fallback)).toBe( + 'http://localhost:8080/invoke' + ); + }); + + it('returns origin (no prefix) in dev mode where there is no /assets/ segment', () => { + expect(deriveDeploymentBaseUrl('http://localhost:5173/src/main.tsx', fallback)).toBe('http://localhost:5173'); + }); + + it('falls back to the provided origin for an unparseable module url', () => { + expect(deriveDeploymentBaseUrl('not-a-url', fallback)).toBe(fallback); + }); + + it('uses lastIndexOf so a prefix containing an /assets/ segment resolves to the deploy root', () => { + expect(deriveDeploymentBaseUrl('https://example.com/foo/assets/bar/assets/index-abc.js', fallback)).toBe( + 'https://example.com/foo/assets/bar' + ); + }); + + it('falls back to the app origin when the bundle is served from a foreign (CDN) origin', () => { + vi.stubGlobal('window', { location: { origin: 'https://app.example' } }); + // Bundle on a CDN origin -> sub-path detection there is meaningless; must target the app origin. + expect(deriveDeploymentBaseUrl('https://cdn.example/invoke/assets/index-abc.js', fallback)).toBe(fallback); + }); + + it('still detects the sub-path when the bundle origin matches the app origin', () => { + vi.stubGlobal('window', { location: { origin: 'https://example.com' } }); + expect(deriveDeploymentBaseUrl('https://example.com/invoke/assets/index-abc.js', fallback)).toBe( + 'https://example.com/invoke' + ); + }); +}); diff --git a/invokeai/frontend/web/src/common/util/baseUrl.ts b/invokeai/frontend/web/src/common/util/baseUrl.ts new file mode 100644 index 00000000000..25d0a7e98d8 --- /dev/null +++ b/invokeai/frontend/web/src/common/util/baseUrl.ts @@ -0,0 +1,57 @@ +/** + * Derives the deployment base URL (origin + optional sub-path prefix, WITHOUT a trailing slash) + * from the URL of the app's entry module and a fallback origin. + * + * Because Vite is configured with `base: './'`, the entry chunk is always emitted under + * `/assets/`, so the deployment root is everything before `/assets/`: + * + * https://example.com/invoke/assets/index-abc.js -> https://example.com/invoke + * https://example.com/assets/index-abc.js -> https://example.com + * + * In dev mode (Vite serves `/src/...`, no `/assets/` segment) and when served from the domain + * root, the prefix is empty, so the result is just the origin - identical to the previous + * behavior, leaving existing installations unaffected. + * + * Exported separately from {@link getDeploymentBaseUrl} so it can be unit-tested without relying + * on `import.meta.url`. + */ +export const deriveDeploymentBaseUrl = (moduleUrl: string, fallbackOrigin: string): string => { + try { + const url = new URL(moduleUrl); + // If the bundle is served from a different origin than the page (e.g. CDN-hosted assets in + // `package` mode), sub-path detection on that foreign origin is meaningless - API/websocket/locale + // requests must target the app origin, not the asset origin. Fall back to the app origin. + if (typeof window !== 'undefined' && url.origin !== window.location.origin) { + return fallbackOrigin; + } + // `lastIndexOf` rather than `indexOf` in case the deployment prefix itself contains an `/assets/` + // segment (e.g. `/foo/assets/bar/`); Vite emits a single flat `assets/` dir at the deploy root. + const assetsIdx = url.pathname.lastIndexOf('/assets/'); + const prefix = assetsIdx >= 0 ? url.pathname.slice(0, assetsIdx) : ''; + return `${url.origin}${prefix}`; + } catch { + return fallbackOrigin; + } +}; + +let _cachedBaseUrl: string | undefined; + +/** + * Returns the deployment base URL: origin + optional sub-path prefix, WITHOUT a trailing slash. + * See {@link deriveDeploymentBaseUrl} for how the prefix is determined. + */ +export const getDeploymentBaseUrl = (): string => { + if (_cachedBaseUrl === undefined) { + _cachedBaseUrl = deriveDeploymentBaseUrl(import.meta.url, window.location.origin); + } + return _cachedBaseUrl; +}; + +/** + * Returns just the normalized sub-path prefix: `''` at the domain root, or e.g. `'/invoke'` + * (leading slash, no trailing slash). Used for the React Router `basename` and the socket.io `path`. + */ +export const getBasePath = (): string => { + const { pathname } = new URL(getDeploymentBaseUrl()); + return pathname === '/' ? '' : pathname.replace(/\/$/, ''); +}; diff --git a/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx b/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx index b0ad9a5e047..0ed06419782 100644 --- a/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx +++ b/invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx @@ -15,6 +15,7 @@ import { Text, VStack, } from '@invoke-ai/ui-library'; +import { getBasePath } from 'common/util/baseUrl'; import { validatePasswordField } from 'features/auth/util/passwordUtils'; import type { ChangeEvent, FormEvent } from 'react'; import { memo, useCallback, useEffect, useState } from 'react'; @@ -59,8 +60,9 @@ export const AdministratorSetup = memo(() => { const result = await setup({ email, display_name: displayName, password }).unwrap(); if (result.success) { // Auto-login after setup - need to call login API - // For now, just redirect to login page - window.location.href = '/login'; + // For now, just redirect to login page. + // Prefix with the deployment base path so this keeps working behind a reverse-proxy sub-path. + window.location.href = `${getBasePath()}/login`; } } catch { // Error is handled by RTK Query and displayed via error state diff --git a/invokeai/frontend/web/src/features/auth/components/LoginPage.tsx b/invokeai/frontend/web/src/features/auth/components/LoginPage.tsx index b4f01d5878b..eece438cd81 100644 --- a/invokeai/frontend/web/src/features/auth/components/LoginPage.tsx +++ b/invokeai/frontend/web/src/features/auth/components/LoginPage.tsx @@ -14,6 +14,7 @@ import { VStack, } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { getBasePath } from 'common/util/baseUrl'; import { selectSessionExpired, setCredentials } from 'features/auth/store/authSlice'; import type { ChangeEvent, FormEvent } from 'react'; import { memo, useCallback, useEffect, useState } from 'react'; @@ -61,8 +62,9 @@ export const LoginPage = memo(() => { }; dispatch(setCredentials({ token: result.token, user })); // Force a page reload to ensure all user-specific state is loaded from server - // This is important for multiuser isolation to prevent state leakage - window.location.href = '/app'; + // This is important for multiuser isolation to prevent state leakage. + // Prefix with the deployment base path so this keeps working behind a reverse-proxy sub-path. + window.location.href = `${getBasePath()}/app`; } catch { // Error is handled by RTK Query and displayed via error state } diff --git a/invokeai/frontend/web/src/i18n.ts b/invokeai/frontend/web/src/i18n.ts index adf53c0fd94..a6a867282ad 100644 --- a/invokeai/frontend/web/src/i18n.ts +++ b/invokeai/frontend/web/src/i18n.ts @@ -1,3 +1,4 @@ +import { getDeploymentBaseUrl } from 'common/util/baseUrl'; import i18n from 'i18next'; import Backend from 'i18next-http-backend'; import { initReactI18next } from 'react-i18next'; @@ -32,7 +33,7 @@ if (import.meta.env.MODE === 'package') { fallbackLng: 'en', debug: false, backend: { - loadPath: `${window.location.origin}/locales/{{lng}}.json`, + loadPath: `${getDeploymentBaseUrl()}/locales/{{lng}}.json`, }, interpolation: { escapeValue: false, diff --git a/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts b/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts index 653f458dde8..ed53508e0fe 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/appInfo.ts @@ -8,7 +8,7 @@ import type { ExternalProviderStatus, } from 'services/api/types'; -import { api, buildV1Url } from '..'; +import { api, buildV1Url, getBaseUrl } from '..'; /** * Builds an endpoint URL for the app router @@ -138,7 +138,7 @@ export const appInfoApi = api.injectEndpoints({ invalidatesTags: ['InvocationCacheStatus'], }), getOpenAPISchema: build.query({ - query: () => `${window.location.origin}/openapi.json`, + query: () => `${getBaseUrl()}/openapi.json`, providesTags: ['Schema'], }), }), diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index a586273f3a7..e19c54e8469 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -7,6 +7,7 @@ import type { TagDescription, } from '@reduxjs/toolkit/query/react'; import { buildCreateApi, coreModule, fetchBaseQuery, reactHooksModule } from '@reduxjs/toolkit/query/react'; +import { getDeploymentBaseUrl } from 'common/util/baseUrl'; import { sessionExpiredLogout } from 'features/auth/store/authSlice'; import queryString from 'query-string'; import stableHash from 'stable-hash'; @@ -68,7 +69,7 @@ export const LIST_TAG = 'LIST'; export const LIST_ALL_TAG = 'LIST_ALL'; export const getBaseUrl = (): string => { - return window.location.origin; + return getDeploymentBaseUrl(); }; const dynamicBaseQuery: BaseQueryFn = async ( diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 7864579706a..6cee519e000 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -16231,6 +16231,8 @@ export type components = { * external_openai_base_url: Base URL override for OpenAI image generation. * external_seedream_api_key: API key for Seedream image generation. * external_seedream_base_url: Base URL override for Seedream image generation. + * base_url: Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Set only when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`). Leave unset when the proxy strips the sub-path or when serving at the domain root. + * forwarded_allow_ips: Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set. */ InvokeAIAppConfig: { /** @@ -16294,6 +16296,17 @@ export type components = { * @description SSL key file for HTTPS. See https://www.uvicorn.dev/settings/#https. */ ssl_keyfile?: string | null; + /** + * Base Url + * @description Public base path when running behind a reverse proxy under a sub-path, e.g. `/invoke`. Required when the proxy PRESERVES the sub-path (the backend receives `/invoke/api/...`); optional when the proxy strips it (set it anyway so openapi/docs URLs are correct). Leave unset when serving at the domain root. Normalized to a single leading slash with no trailing slash. + */ + base_url?: string | null; + /** + * Forwarded Allow Ips + * @description Comma-separated list of IPs (or `*`) allowed to set X-Forwarded-* headers. Set to the reverse proxy's IP. Only used when `base_url` is set. + * @default 127.0.0.1 + */ + forwarded_allow_ips?: string; /** * Log Tokenization * @description Enable logging of parsed prompt tokens. diff --git a/invokeai/frontend/web/src/services/events/useSocketIO.ts b/invokeai/frontend/web/src/services/events/useSocketIO.ts index dcbe2501f3c..c8945e8df49 100644 --- a/invokeai/frontend/web/src/services/events/useSocketIO.ts +++ b/invokeai/frontend/web/src/services/events/useSocketIO.ts @@ -1,5 +1,6 @@ import { useAppStore } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { getBasePath, getDeploymentBaseUrl } from 'common/util/baseUrl'; import type { MapStore } from 'nanostores'; import { useEffect, useMemo } from 'react'; import { selectQueueStatus } from 'services/api/endpoints/queue'; @@ -25,15 +26,17 @@ export const useSocketIO = () => { const store = useAppStore(); const socketUrl = useMemo(() => { - const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; - return `${wsProtocol}://${window.location.host}`; + const base = new URL(getDeploymentBaseUrl()); + const wsProtocol = base.protocol === 'https:' ? 'wss' : 'ws'; + // Origin only - the sub-path prefix (if any) is passed via the socket.io `path` option below. + return `${wsProtocol}://${base.host}`; }, []); const socketOptions = useMemo(() => { const token = localStorage.getItem('auth_token'); const options: Partial = { timeout: 60000, - path: '/ws/socket.io', + path: `${getBasePath()}/ws/socket.io`, autoConnect: false, // achtung! removing this breaks the dynamic middleware forceNew: true, auth: token ? { token } : undefined, diff --git a/tests/app/test_subpath_middleware.py b/tests/app/test_subpath_middleware.py new file mode 100644 index 00000000000..89506d19361 --- /dev/null +++ b/tests/app/test_subpath_middleware.py @@ -0,0 +1,71 @@ +"""Tests for reverse-proxy sub-path support (`SubPathASGIMiddleware` + root_path-aware redirect). + +Exercises both proxy styles against a minimal FastAPI app so the subtle routing/redirect matrix +(routing, trailing-slash 307s, and the `?__theme=dark` root redirect) is protected from regression. +""" + +import pytest +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient + +from invokeai.app.api_app import RedirectRootWithQueryStringMiddleware, SubPathASGIMiddleware + +BASE_PATH = "/invoke" + + +def _build_app() -> FastAPI: + app = FastAPI() + app.add_middleware(RedirectRootWithQueryStringMiddleware) + + @app.get("/") + async def root() -> JSONResponse: + return JSONResponse({"ok": True}) + + @app.get("/api/v1/images") + async def images() -> JSONResponse: + return JSONResponse({"images": []}) + + # Trailing slash so FastAPI emits a 307 redirect for the slash-less form. + @app.get("/api/v1/boards/") + async def boards() -> JSONResponse: + return JSONResponse({"boards": []}) + + return app + + +def _client(preserve: bool) -> TestClient: + """A TestClient whose base_url selects the proxy style. + + - preserve=True: proxy forwards the sub-path unchanged -> requests hit `/invoke/...`. + - preserve=False: proxy strips the sub-path -> requests hit `/...` (middleware restores the prefix). + """ + wrapped = SubPathASGIMiddleware(_build_app(), BASE_PATH) + root = f"http://testserver{BASE_PATH}" if preserve else "http://testserver" + # Don't auto-follow redirects; we assert on the Location header. + return TestClient(wrapped, base_url=root, follow_redirects=False) + + +@pytest.mark.parametrize("preserve", [True, False], ids=["preserve", "strip"]) +def test_routing_works_both_styles(preserve: bool): + client = _client(preserve) + resp = client.get("/api/v1/images") + assert resp.status_code == 200 + assert resp.json() == {"images": []} + + +@pytest.mark.parametrize("preserve", [True, False], ids=["preserve", "strip"]) +def test_trailing_slash_redirect_keeps_prefix(preserve: bool): + client = _client(preserve) + resp = client.get("/api/v1/boards") # no trailing slash -> 307 + assert resp.status_code == 307 + # The server-generated redirect must retain the public sub-path prefix. + assert resp.headers["location"].endswith(f"{BASE_PATH}/api/v1/boards/") + + +@pytest.mark.parametrize("preserve", [True, False], ids=["preserve", "strip"]) +def test_theme_query_redirect_keeps_prefix(preserve: bool): + client = _client(preserve) + resp = client.get("/?__theme=dark") + assert resp.status_code == 307 + assert resp.headers["location"] == f"{BASE_PATH}/" diff --git a/tests/test_config.py b/tests/test_config.py index d20ee44b915..85eaaef5328 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -352,3 +352,35 @@ def test_deny_nodes(patch_rootdir): # Reset the config so that it doesn't affect other tests get_config.cache_clear() InvocationRegistry.invalidate_invocation_typeadapter() + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("/invoke", "/invoke"), + ("invoke", "/invoke"), + ("invoke/", "/invoke"), + ("/invoke/", "/invoke"), + ("//invoke//", "/invoke"), + (" /invoke ", "/invoke"), + ("/invoke/sub", "/invoke/sub"), + ("", None), + ("/", None), + (" ", None), + (None, None), + ], +) +def test_base_url_validator_normalizes(raw: str | None, expected: str | None, patch_rootdir: None): + """`base_url` is normalized to a single leading slash with no trailing slash; empty/`/` disable it.""" + config = InvokeAIAppConfig(base_url=raw) + assert config.base_url == expected + + +@pytest.mark.parametrize( + "reserved", + ["/api", "api", "/ws", "/static", "/docs", "/redoc", "/openapi.json", "/locales", "/assets", "/api/foo"], +) +def test_base_url_validator_rejects_reserved_prefix(reserved: str, patch_rootdir: None): + """A `base_url` whose first segment collides with a real route prefix must fail fast, not brick the server.""" + with pytest.raises(ValidationError, match="reserved path segment"): + InvokeAIAppConfig(base_url=reserved)