diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 108f180a09..d11e4fab88 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -96,6 +96,7 @@ jobs: outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }} cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max + sbom: true build-args: | BUILD_HASH=${{ github.sha }} USE_SLIM=true diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a6674982..d38676eef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,82 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.7] - 2026-03-01 + +### Fixed + +- 🔒 **Connection access control privacy.** Tool server and terminal connections without explicit access grants are now private (admin-only) by default, fixing a bug where connections configured with no access grants were visible to all users instead of being restricted. [Commit](https://github.com/open-webui/open-webui/commit/2751a0f0b) +- 🧠 **ChatControls memory leak.** The ChatControls panel no longer leaks event listeners, ResizeObserver instances, and media query handlers when navigating between chats, fixing memory accumulation that could degrade performance during extended use. [#22112](https://github.com/open-webui/open-webui/pull/22112) +- 💾 **Temporary chat params preservation.** Model parameters are now correctly saved when creating a temporary chat, ensuring custom settings like temperature and top_p persist across the session. [Commit](https://github.com/open-webui/open-webui/commit/fe837d80e) +- ⚡ **Faster artifact content updates.** Artifact content extraction during streaming is now debounced via requestAnimationFrame, reducing redundant DOM reads and improving CPU efficiency when tokens arrive faster than the browser can paint. [Commit](https://github.com/open-webui/open-webui/commit/6863ca482) + +## [0.8.6] - 2026-03-01 + +### Added + +- 🖥️ **Open Terminal integration.** Users can now connect to [Open Terminal](https://github.com/open-webui/open-terminal) instances to browse, read, and upload files directly in chat, with the terminal acting as an always-on tool. File navigation includes folder browsing, image and PDF previews, drag-and-drop uploads, directory creation, and file deletion. The current working directory is automatically injected into tool descriptions for context-aware commands. [Commit](https://github.com/open-webui/open-webui/commit/636ab99ad8e5b71b32dd37ba7c62c32368585b2a), [Commit](https://github.com/open-webui/open-webui/commit/64ff15a5365e2c4122fccab582782669f06ec58d), [Commit](https://github.com/open-webui/open-webui/commit/4737e1f11847d057859ec78892fa89e24cbcd83b) +- 📄 **Terminal file creation.** Users can now create new empty files directly in the Open Terminal file browser, in addition to the existing folder creation functionality. [Commit](https://github.com/open-webui/open-webui/commit/234306ff57c9e24314ff805a60de919632465319) +- ✏️ **Terminal file editing.** Users can now edit text files directly in the Open Terminal file browser, with the ability to save changes back to the terminal. [Commit](https://github.com/open-webui/open-webui/commit/3d535db304bfc6fa09e655f737de8a36c0482868) +- 🛠️ **Terminal file preview toolbar.** The Open Terminal file browser now displays contextual toolbar buttons based on file type, including preview/source toggle for Markdown and CSV files, reset view for images, and improved editing controls for text files. [Commit](https://github.com/open-webui/open-webui/commit/d2b38127d0572006577b85c770607b04782de4f9) +- 🔄 **Terminal file write refresh.** The file browser now automatically refreshes when files are written or modified via the write_file or replace_file_content tools, eliminating the need to manually refresh. [Commit](https://github.com/open-webui/open-webui/commit/18865a9fef1bb154603b7b8af0116a10560e03ac) +- 🛡️ **Docker image SBOM attestation.** Docker images now include a Software Bill of Materials (SBOM) for vulnerability scanning and supply chain security compliance. [#21779](https://github.com/open-webui/open-webui/issues/21779), [Commit](https://github.com/open-webui/open-webui/commit/febc66ef2bb05606b59719e737ac5ad839002977) +- 📡 **Reporting-Endpoints security header.** Administrators can now configure a Reporting-Endpoints header via the REPORTING_ENDPOINTS environment variable to receive CSP violation reports directly, aiding in security policy debugging and hardening. [#21830](https://github.com/open-webui/open-webui/issues/21830) +- 🎯 **Action button priority sorting.** Action buttons under assistant messages now appear in a consistent order based on the priority field from function Valves, allowing developers to control button placement. [#21790](https://github.com/open-webui/open-webui/pull/21790) +- 🏷️ **Public/Private model filtering.** The Admin Settings Model listing now displays Public/Private badges and includes filter options to easily view public or private models. [#21732](https://github.com/open-webui/open-webui/issues/21732), [#21797](https://github.com/open-webui/open-webui/pull/21797) +- 👁️ **Show/Hide all models bulk action.** Administrators can now show or hide all models at once from the Admin Settings Models page Actions menu, making it faster to manage model visibility. Bulk actions now display a single toast notification on success for better user feedback. [#21838](https://github.com/open-webui/open-webui/pull/21838), [#21958](https://github.com/open-webui/open-webui/pull/21958) +- 🔐 **Individual user sharing control.** Administrators can now disable individual user sharing via the USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS environment variable, allowing only group-based sharing when set to false. [#21793](https://github.com/open-webui/open-webui/issues/21793), [Commit](https://github.com/open-webui/open-webui/commit/3d99de67716774af2f95f2e3c8e7cc4879464c71), [Commit](https://github.com/open-webui/open-webui/commit/176f9a781619d836be003d28d53904639cad4128) +- 🔄 **OAuth profile sync on login.** Administrators can now enable automatic synchronization of user profile name and email from OAuth providers on login via the OAUTH_UPDATE_NAME_ON_LOGIN and OAUTH_UPDATE_EMAIL_ON_LOGIN environment variables. [#21787](https://github.com/open-webui/open-webui/pull/21787), [Commit](https://github.com/open-webui/open-webui/commit/9478c5e7ac8254b5f522c006da0c1c49bb282727) +- 👥 **Default group share permission.** Administrators can now configure the default sharing permission for new groups via the DEFAULT_GROUP_SHARE_PERMISSION environment variable, controlling whether anyone, no one, or only members can share to new groups. [Commit](https://github.com/open-webui/open-webui/commit/538501c88da034434bcd1969f15341dbbaf154e4) +- 💨 **Streaming performance.** Chat responses now render more efficiently during streaming, reducing CPU usage and improving responsiveness. [Commit](https://github.com/open-webui/open-webui/commit/484ba91b0777042eb848134f206ef3921f968dea) +- 🧮 **Streaming message comparison.** Chat message updates during streaming are now faster thanks to an optimization that skips expensive comparisons when content changes. [#21884](https://github.com/open-webui/open-webui/pull/21884) +- 🚀 **Streaming scroll optimization.** Chat auto-scroll during streaming is now more efficient by batching scroll operations via requestAnimationFrame, reducing unnecessary layout reflows when tokens arrive faster than the browser can paint. [#21946](https://github.com/open-webui/open-webui/pull/21946) +- 📋 **Message cloning performance.** Chat message cloning during streaming is now more efficient thanks to the use of structuredClone() instead of JSON.parse(JSON.stringify(...)). [#21948](https://github.com/open-webui/open-webui/pull/21948) +- 🎯 **Faster code block rendering.** Chat message updates during streaming are now faster. [#22101](https://github.com/open-webui/open-webui/pull/22101) +- 📊 **Faster status history display.** Chat message updates during streaming are now faster. [#22103](https://github.com/open-webui/open-webui/pull/22103) +- 🛠️ **Faster tool result handling.** Tool execution results are now handled more efficiently, improving streaming performance. [#22104](https://github.com/open-webui/open-webui/pull/22104) +- 💾 **Faster model and file operations.** Model selection, file preparation, and history saving are now faster. [#22102](https://github.com/open-webui/open-webui/pull/22102) +- 🛠️ **Tool server advanced options toggle.** Advanced OpenAPI configuration options in the tool server modal are now hidden by default behind a toggle, simplifying the interface for basic setups. The admin settings tab was also renamed from "Tools" to "Integrations" for clearer organization. [Commit](https://github.com/open-webui/open-webui/commit/f0c71e5a6d971af7322d4245313e5e04620253f0), [Commit](https://github.com/open-webui/open-webui/commit/4731ccb73c4b4bab78fd86fec7b2c231af8cca8b) +- 🔧 **Faster tool loading.** Tool access control now skips an unnecessary database query when no tools are attached to the request, slightly improving performance. [#21873](https://github.com/open-webui/open-webui/pull/21873) +- ➗ **Faster math rendering.** Mathematical notation now renders more efficiently, improving responsiveness when displaying equations in chat. [#21880](https://github.com/open-webui/open-webui/pull/21880) +- 🏎️ **Faster message list updates.** The chat message list now rebuilds at most once per animation frame during streaming, reducing CPU overhead. [#21885](https://github.com/open-webui/open-webui/pull/21885) +- 📋 **Faster message rendering.** Chat message rendering is now more efficient during streaming. [#22086](https://github.com/open-webui/open-webui/pull/22086) +- 🗄️ **Faster real-time chat updates.** Chat responses now process faster with improved handling for concurrent users. [#22087](https://github.com/open-webui/open-webui/pull/22087) +- 📝 **Faster status persistence.** Only final status updates are now saved to the database during streaming, reducing unnecessary writes. [#22085](https://github.com/open-webui/open-webui/pull/22085) +- 🔄 **Faster event matching.** Event handling in the socket handler is now more efficient. [Commit](https://github.com/open-webui/open-webui/commit/ff86283be0479ccb86b639926b2b67ccbbe78746) +- 🔀 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. +- 🌐 **Translation updates.** Translations for German, Portuguese (Brazil), Simplified Chinese, Traditional Chinese, Catalan, and Spanish were enhanced and expanded. + +### Fixed + +- 🗄️ **Database migration execution.** Database migrations now run correctly on startup, fixing a circular import issue that caused schema updates to fail silently. [#21848](https://github.com/open-webui/open-webui/pull/21848), [Commit](https://github.com/open-webui/open-webui/commit/87d33f6e18196876603eee7d1bf8e4977c7fa9c1) +- 🔔 **Notification HTML escaping.** Notification messages now properly escape HTML content, matching the behavior in chat messages and ensuring consistent rendering across the interface. [#21860](https://github.com/open-webui/open-webui/issues/21860), [Commit](https://github.com/open-webui/open-webui/commit/e83f668107723fa90ba0efa76c340c8338f45431) +- 🛠️ **Tool call JSON error handling.** Chat no longer crashes when models generate malformed JSON in tool call arguments; instead, a descriptive error message is returned to the model for retry. [#21984](https://github.com/open-webui/open-webui/pull/21984), [Commit](https://github.com/open-webui/open-webui/commit/668bd44485bdf88e9083c6f09c3c47ab97a128a4) +- 🧠 **Reasoning model KV cache preservation.** Reasoning model thinking tags are no longer stored as HTML in the database, preserving KV cache efficiency for backends like llama.cpp and ensuring faster subsequent conversation turns. [#21815](https://github.com/open-webui/open-webui/issues/21815), [Commit](https://github.com/open-webui/open-webui/commit/81781e6495dcc788c863bbf6b4aa4cf0ddd9fdcc) +- ⚡ **Duplicate model execution prevention.** Models are no longer called twice when no tools are configured, eliminating unnecessary API requests and reducing latency. [#21802](https://github.com/open-webui/open-webui/issues/21802), [Commit](https://github.com/open-webui/open-webui/commit/3c8d658160809f6d651837cf93d89dddc1d17caf) +- 🔐 **OAuth session database error.** OAuth login no longer fails with a database error when creating sessions, fixing the "'NoneType' object has no attribute 'id'" and "can't adapt type 'dict'" errors that occurred during OAuth group creation. [#21788](https://github.com/open-webui/open-webui/issues/21788) +- 👤 **User sharing permission enforcement.** The user sharing option now correctly respects the USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS setting, fixing an issue where sharing to individual users was incorrectly allowed even when disabled. [#21856](https://github.com/open-webui/open-webui/pull/21856), [Commit](https://github.com/open-webui/open-webui/commit/acb21470241ed6fd3eb3f659f196f697c418d9e8), [Commit](https://github.com/open-webui/open-webui/commit/ace69bba7512dc0a653695f9e6311712dfabb640) +- 🔑 **Password manager autofill.** Password manager autofill (like iCloud Passwords, 1Password, Bitwarden) now correctly captures filled-in passwords, fixing login failures where the password appeared filled but was sent as empty. [#21869](https://github.com/open-webui/open-webui/pull/21869), [Commit](https://github.com/open-webui/open-webui/commit/9dff497abf821dfba6eb8ea65e48a657ee91fd71) +- 📝 **RAG template duplication.** RAG templates are no longer duplicated in chat messages when models make multiple tool calls, preventing hallucinations and incorrect tool usage. [#21780](https://github.com/open-webui/open-webui/issues/21780), [Commit](https://github.com/open-webui/open-webui/commit/8f49725aa5f2d9b87e559e7d3f02f037335b7914) +- 📋 **Audit log stdout.** Audit logs now correctly appear on stdout when the ENABLE_AUDIT_STDOUT environment variable is set to true, aligning runtime behavior with the intended configuration. [#21777](https://github.com/open-webui/open-webui/pull/21777) +- 🎯 **Function valve priority resolution.** Function priorities defined in code are now correctly applied when no custom value has been saved in the database, ensuring consistent action button and filter ordering. [#21841](https://github.com/open-webui/open-webui/pull/21841) +- 📄 **Web content knowledge base append.** Processing web URLs with overwrite=false now correctly appends content to existing knowledge bases instead of silently doing nothing, fixing a regression where no content was being added. [#21786](https://github.com/open-webui/open-webui/pull/21786), [Commit](https://github.com/open-webui/open-webui/commit/5ee509325970f01524348b0f91081110340f2e7e) +- 🔍 **Web search domain filter config.** The WEB_SEARCH_DOMAIN_FILTER_LIST environment variable is now correctly read and applied, fixing an issue where domain filtering for web searches always used an empty default value. [#21964](https://github.com/open-webui/open-webui/pull/21964), [#20186](https://github.com/open-webui/open-webui/issues/20186) +- 🧹 **Tooltip memory leak.** Tooltip instances are now properly destroyed when elements change, fixing a memory leak that could cause performance issues over time. [#21969](https://github.com/open-webui/open-webui/pull/21969) +- ⌨️ **MessageInput memory leak.** Event listeners in the message input component are now properly cleaned up, preventing a memory leak that could cause page crashes during extended use. [#21968](https://github.com/open-webui/open-webui/pull/21968) +- 📝 **Notes memory leak.** Event listeners in the Notes component are now properly cleaned up, fixing a memory leak that could cause page crashes during extended use. [#21963](https://github.com/open-webui/open-webui/pull/21963) +- 🏗️ **Model create memory leak.** Event listeners in the model creation page are now properly cleaned up, fixing a memory leak that could cause page crashes during extended use. [#21966](https://github.com/open-webui/open-webui/pull/21966) +- 💬 **MentionList memory leak.** Event listeners in the MentionList component are now properly cleaned up, fixing a memory leak that could cause page crashes during extended use. [#21965](https://github.com/open-webui/open-webui/pull/21965) +- 📐 **Sidebar memory leak.** Event listeners in the Sidebar component are now properly cleaned up, fixing a memory leak that could cause page crashes during extended use. [#22082](https://github.com/open-webui/open-webui/pull/22082) +- 🎨 **Sidebar user menu positioning.** The sidebar user menu no longer drifts rightward when the sidebar is resized, keeping the menu properly aligned with its trigger. [#21853](https://github.com/open-webui/open-webui/pull/21853) +- 💻 **Code block UI.** Code block headers are now sticky and properly positioned, with language labels now showing tooltips for truncated text. [Commit](https://github.com/open-webui/open-webui/commit/6b462ff121d28cd2d335db7763052622d374e3a5) +- 📊 **Multi-model responses horizontal scroll.** The model list in multi-model responses tabs now has horizontal scroll support, making all models accessible on desktop screens. [#21800](https://github.com/open-webui/open-webui/issues/21800), [Commit](https://github.com/open-webui/open-webui/commit/a3de0bcc586ddd14dde6ae915067f082d628eaeb) +- 🎭 **TailwindCSS gray color theme.** Custom gray color palette is now correctly applied to the CSS root theme layer, fixing an issue where --color-gray-x variables were missing. [#21900](https://github.com/open-webui/open-webui/pull/21900), [#21899](https://github.com/open-webui/open-webui/issues/21899) +- 📎 **Broken documentation links.** Fixed broken links in the backend config and admin settings that pointed to outdated documentation locations. [#21904](https://github.com/open-webui/open-webui/pull/21904) +- 🔓 **OAuth session token decryption.** OAuth sessions are now properly detached from the database context before token decryption, preventing potential database session conflicts when reading encrypted tokens. [#21794](https://github.com/open-webui/open-webui/pull/21794) +- 🕐 **Chat timestamp i18n fix.** Chat timestamps in the sidebar now display correctly, fixing an issue where the time ago format (e.g., "5m", "2h", "3d") was not being localized properly due to incorrect variable casing in the translation function. [Commit](https://github.com/open-webui/open-webui/commit/ae28e7d24530eb9f7909b293bcd0f33048a022a9) +- 🍞 **Model toast notification fix.** Hiding or showing a single model now displays only one toast notification instead of two, removing the redundant generic "model updated" message when a specific action toast is shown. [#22079](https://github.com/open-webui/open-webui/pull/22079) +- 📡 **Offline mode embedding model fix.** Open WebUI no longer attempts to download embedding models when in offline mode, fixing error logs that occurred when trying to fetch models that weren't cached locally. [#22106](https://github.com/open-webui/open-webui/pull/22106), [#21405](https://github.com/open-webui/open-webui/issues/21405) + ## [0.8.5] - 2026-02-23 ### Added diff --git a/CHANGELOG_EXTRA.md b/CHANGELOG_EXTRA.md index 17585bfbbe..79651a800e 100644 --- a/CHANGELOG_EXTRA.md +++ b/CHANGELOG_EXTRA.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.7.1] - 2026.03.02 + +### Changed + +- 合并官方 0.8.7 改动 + ## [0.8.5.1] - 2026.02.24 ### Changed diff --git a/CONTRIBUTOR_LICENSE_AGREEMENT b/CONTRIBUTOR_LICENSE_AGREEMENT index ca9f48b02e..594e1506ac 100644 --- a/CONTRIBUTOR_LICENSE_AGREEMENT +++ b/CONTRIBUTOR_LICENSE_AGREEMENT @@ -1,7 +1,7 @@ -# Open WebUI Contributor License Agreement +# Contributor License Agreement -By submitting my contributions to Open WebUI, I grant Open WebUI full freedom to use my work in any way they choose, under any terms they like, both now and in the future. This approach helps ensure the project remains unified, flexible, and easy to maintain, while empowering Open WebUI to respond quickly to the needs of its users and the wider community. +By submitting my contributions to this repository in any form, I grant Open WebUI Inc. a perpetual, worldwide, irrevocable, royalty-free license, under copyright and patent, to use, modify, distribute, sublicense, and commercialize my work under any terms they choose, both now and in the future. -Taking part in this process means my work can be seamlessly integrated and combined with others, ensuring longevity and adaptability for everyone who benefits from the Open WebUI project. This collaborative approach strengthens the project’s future and helps guarantee that improvements can always be shared and distributed in the most effective way possible. +I represent that my contributions are my original work (or that I have sufficient rights to grant this license) and that I have the authority to enter into this agreement. **_To the fullest extent permitted by law, my contributions are provided on an “as is” basis, with no warranties or guarantees of any kind, and I disclaim any liability for any issues or damages arising from their use or incorporation into the project, regardless of the type of legal claim._** \ No newline at end of file diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index b942b80a24..4e6bf70eab 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -302,7 +302,7 @@ def __getattr__(self, key): if JWT_EXPIRES_IN.value == "-1": log.warning( "⚠️ SECURITY WARNING: JWT_EXPIRES_IN is set to '-1'\n" - " See: https://docs.openwebui.com/getting-started/env-configuration\n" + " See: https://docs.openwebui.com/reference/env-configuration\n" ) #################################### @@ -618,6 +618,18 @@ def __getattr__(self, key): os.environ.get("OAUTH_UPDATE_PICTURE_ON_LOGIN", "False").lower() == "true", ) +OAUTH_UPDATE_NAME_ON_LOGIN = PersistentConfig( + "OAUTH_UPDATE_NAME_ON_LOGIN", + "oauth.update_name_on_login", + os.environ.get("OAUTH_UPDATE_NAME_ON_LOGIN", "False").lower() == "true", +) + +OAUTH_UPDATE_EMAIL_ON_LOGIN = PersistentConfig( + "OAUTH_UPDATE_EMAIL_ON_LOGIN", + "oauth.update_email_on_login", + os.environ.get("OAUTH_UPDATE_EMAIL_ON_LOGIN", "False").lower() == "true", +) + OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID = ( os.environ.get("OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID", "False").lower() == "true" @@ -1103,6 +1115,20 @@ def reachable(host: str, port: int) -> bool: tool_server_connections, ) +#################################### +# TERMINAL_SERVER +#################################### + +terminal_server_connections = json.loads( + os.environ.get("TERMINAL_SERVER_CONNECTIONS", "[]") +) + +TERMINAL_SERVER_CONNECTIONS = PersistentConfig( + "TERMINAL_SERVER_CONNECTIONS", + "terminal_server.connections", + terminal_server_connections, +) + #################################### # WEBUI #################################### @@ -1401,6 +1427,11 @@ def reachable(host: str, port: int) -> bool: == "true" ) +USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = ( + os.environ.get("USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS", "True").lower() + == "true" +) + USER_PERMISSIONS_CHAT_CONTROLS = ( os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true" ) @@ -1554,6 +1585,9 @@ def reachable(host: str, port: int) -> bool: "notes": USER_PERMISSIONS_NOTES_ALLOW_SHARING, "public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING, }, + "access_grants": { + "allow_users": USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS, + }, "chat": { "controls": USER_PERMISSIONS_CHAT_CONTROLS, "valves": USER_PERMISSIONS_CHAT_VALVES, @@ -3113,17 +3147,25 @@ class BannerModel(BaseModel): int(os.getenv("WEB_SEARCH_RESULT_COUNT", "3")), ) + +try: + web_search_domain_filter_list = json.loads( + os.getenv("WEB_SEARCH_DOMAIN_FILTER_LIST", "[]") + ) +except Exception as e: + web_search_domain_filter_list = [ + # "wikipedia.com", + # "wikimedia.org", + # "wikidata.org", + # "!stackoverflow.com", + ] + # You can provide a list of your own websites to filter after performing a web search. # This ensures the highest level of safety and reliability of the information sources. WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig( "WEB_SEARCH_DOMAIN_FILTER_LIST", "rag.web.search.domain.filter_list", - [ - # "wikipedia.com", - # "wikimedia.org", - # "wikidata.org", - # "!stackoverflow.com", - ], + web_search_domain_filter_list, ) WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig( diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 1bf7a923df..a1124d982e 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -1047,3 +1047,16 @@ def parse_section(section): #################################### EXTERNAL_PWA_MANIFEST_URL = os.environ.get("EXTERNAL_PWA_MANIFEST_URL") + +#################################### +# GROUP DEFAULTS +#################################### + +# Controls the default "Who can share to this group" setting for new groups. +# Env var values: "true" (anyone), "false" (no one), "members" (only group members). +_default_group_share = ( + os.environ.get("DEFAULT_GROUP_SHARE_PERMISSION", "members").strip().lower() +) +DEFAULT_GROUP_SHARE_PERMISSION = ( + "members" if _default_group_share == "members" else _default_group_share == "true" +) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 2f0dedd90c..1513a4b09d 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -90,6 +90,7 @@ utils, scim, credit, + terminals, ) from open_webui.routers.retrieval import ( @@ -126,6 +127,8 @@ THREAD_POOL_SIZE, # Tool Server Configs TOOL_SERVER_CONNECTIONS, + # Terminal Server + TERMINAL_SERVER_CONNECTIONS, # Code Execution ENABLE_CODE_EXECUTION, CODE_EXECUTION_ENGINE, @@ -547,7 +550,7 @@ process_chat_payload, process_chat_response, ) -from open_webui.utils.tools import set_tool_servers +from open_webui.utils.tools import set_tool_servers, set_terminal_servers from open_webui.utils.auth import ( get_license_data, @@ -713,8 +716,13 @@ async def lifespan(app: FastAPI): ) await set_tool_servers(mock_request) log.info(f"Initialized {len(app.state.TOOL_SERVERS)} tool server(s)") + + await set_terminal_servers(mock_request) + log.info( + f"Initialized {len(app.state.TERMINAL_SERVERS)} terminal server(s)" + ) except Exception as e: - log.warning(f"Failed to initialize tool servers at startup: {e}") + log.warning(f"Failed to initialize tool/terminal servers at startup: {e}") yield @@ -796,6 +804,15 @@ async def lifespan(app: FastAPI): app.state.config.TOOL_SERVER_CONNECTIONS = TOOL_SERVER_CONNECTIONS app.state.TOOL_SERVERS = [] +######################################## +# +# TERMINAL SERVER +# +######################################## + +app.state.config.TERMINAL_SERVER_CONNECTIONS = TERMINAL_SERVER_CONNECTIONS +app.state.TERMINAL_SERVERS = [] + ######################################## # # DIRECT CONNECTIONS @@ -1597,6 +1614,7 @@ async def inspect_websocket(request: Request, call_next): if ENABLE_ADMIN_ANALYTICS: app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"]) app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"]) +app.include_router(terminals.router, prefix="/api/v1/terminals", tags=["terminals"]) # SCIM 2.0 API for identity management if ENABLE_SCIM: diff --git a/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py b/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py index c8a3647aec..567f7d673a 100644 --- a/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py +++ b/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py @@ -127,6 +127,11 @@ def upgrade() -> None: timestamp = message.get("timestamp", now) + try: + timestamp = int(float(timestamp)) + except Exception as e: + timestamp = now + # Normalize timestamp: convert ms to seconds, validate range if timestamp > 10_000_000_000: timestamp = timestamp // 1000 diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index 227621becd..4519abc964 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -204,6 +204,43 @@ def has_public_read_access_grant(access_grants: Optional[list]) -> bool: return False +def has_user_access_grant(access_grants: Optional[list]) -> bool: + """ + Returns True when a direct grant list includes any non-wildcard user grant. + """ + for grant in normalize_access_grants(access_grants): + if grant["principal_type"] == "user" and grant["principal_id"] != "*": + return True + return False + + +def strip_user_access_grants(access_grants: Optional[list]) -> list: + """ + Remove all non-wildcard user grants from the list. + Keeps group grants and the public wildcard (user:*) intact. + """ + if not access_grants: + return [] + return [ + grant + for grant in access_grants + if not ( + ( + grant.get("principal_type") + if isinstance(grant, dict) + else getattr(grant, "principal_type", None) + ) + == "user" + and ( + grant.get("principal_id") + if isinstance(grant, dict) + else getattr(grant, "principal_id", None) + ) + != "*" + ) + ] + + def grants_to_access_control(grants: list) -> Optional[dict]: """ Convert a list of grant objects (AccessGrantModel or AccessGrantResponse) diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 0b4639fc62..25fb873b95 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -146,7 +146,7 @@ def authenticate_user( def authenticate_user_by_api_key( self, api_key: str, db: Optional[Session] = None ) -> Optional[UserModel]: - log.info(f"authenticate_user_by_api_key: {api_key}") + log.info(f"authenticate_user_by_api_key") # if no api_key, return None if not api_key: return None @@ -197,7 +197,10 @@ def update_email_by_id( with get_db_context(db) as db: result = db.query(Auth).filter_by(id=id).update({"email": email}) db.commit() - return True if result == 1 else False + if result == 1: + Users.update_user_by_id(id, {"email": email}, db=db) + return True + return False except Exception: return False diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py index c9a38f1ede..4c7f456e59 100644 --- a/backend/open_webui/models/groups.py +++ b/backend/open_webui/models/groups.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from open_webui.env import DEFAULT_GROUP_SHARE_PERMISSION from open_webui.models.files import FileMetadataResponse @@ -130,13 +131,26 @@ class GroupListResponse(BaseModel): class GroupTable: + def _ensure_default_share_config(self, group_data: dict) -> dict: + """Ensure the group data dict has a default share config if not already set.""" + if "data" not in group_data or group_data["data"] is None: + group_data["data"] = {} + if "config" not in group_data["data"]: + group_data["data"]["config"] = {} + if "share" not in group_data["data"]["config"]: + group_data["data"]["config"]["share"] = DEFAULT_GROUP_SHARE_PERMISSION + return group_data + def insert_new_group( self, user_id: str, form_data: GroupForm, db: Optional[Session] = None ) -> Optional[GroupModel]: with get_db_context(db) as db: + group_data = self._ensure_default_share_config( + form_data.model_dump(exclude_none=True) + ) group = GroupModel( **{ - **form_data.model_dump(exclude_none=True), + **group_data, "id": str(uuid.uuid4()), "user_id": user_id, "created_at": int(time.time()), @@ -504,6 +518,11 @@ def create_groups_by_group_names( user_id=user_id, name=group_name, description="", + data={ + "config": { + "share": DEFAULT_GROUP_SHARE_PERMISSION, + } + }, created_at=int(time.time()), updated_at=int(time.time()), ) diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 4e5e208d8b..4212cf0fd5 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -613,6 +613,21 @@ def add_file_to_knowledge_by_id( except Exception: return None + def has_file( + self, knowledge_id: str, file_id: str, db: Optional[Session] = None + ) -> bool: + """Check whether a file belongs to a knowledge base.""" + try: + with get_db_context(db) as db: + return ( + db.query(KnowledgeFile) + .filter_by(knowledge_id=knowledge_id, file_id=file_id) + .first() + is not None + ) + except Exception: + return False + def remove_file_from_knowledge_by_id( self, knowledge_id: str, file_id: str, db: Optional[Session] = None ) -> bool: diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index fbcd763f34..c9110d3267 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -135,6 +135,7 @@ def create_session( db.refresh(result) if result: + db.expunge(result) # Detach so dict swap is never flushed result.token = token # Return decrypted token return OAuthSessionModel.model_validate(result) else: @@ -151,6 +152,7 @@ def get_session_by_id( with get_db_context(db) as db: session = db.query(OAuthSession).filter_by(id=session_id).first() if session: + db.expunge(session) session.token = self._decrypt_token(session.token) return OAuthSessionModel.model_validate(session) @@ -171,6 +173,7 @@ def get_session_by_id_and_user_id( .first() ) if session: + db.expunge(session) session.token = self._decrypt_token(session.token) return OAuthSessionModel.model_validate(session) @@ -192,6 +195,7 @@ def get_session_by_provider_and_user_id( .first() ) if session: + db.expunge(session) session.token = self._decrypt_token(session.token) return OAuthSessionModel.model_validate(session) @@ -211,6 +215,7 @@ def get_sessions_by_user_id( results = [] for session in sessions: try: + db.expunge(session) session.token = self._decrypt_token(session.token) results.append(OAuthSessionModel.model_validate(session)) except Exception as e: @@ -245,6 +250,7 @@ def update_session_by_id( session = db.query(OAuthSession).filter_by(id=session_id).first() if session: + db.expunge(session) session.token = self._decrypt_token(session.token) return OAuthSessionModel.model_validate(session) diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 4528019fe9..2469317f53 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -1239,6 +1239,8 @@ def get_model_path(model: str, update_model: bool = False): return model_repo_path except Exception as e: log.exception(f"Cannot determine model snapshot path: {e}") + if OFFLINE_MODE: + raise return model diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index 01a857a2b6..3156078326 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -1194,7 +1194,9 @@ def transcription( ) try: - ext = file.filename.split(".")[-1] + safe_name = os.path.basename(file.filename) if file.filename else "" + ext = safe_name.rsplit(".", 1)[-1] if "." in safe_name else "" + id = uuid.uuid4() filename = f"{id}.{ext}" @@ -1204,6 +1206,10 @@ def transcription( os.makedirs(file_dir, exist_ok=True) file_path = f"{file_dir}/{filename}" + # Defense-in-depth: ensure resolved path stays within intended directory + if not os.path.realpath(file_path).startswith(os.path.realpath(file_dir)): + raise ValueError("Invalid file path detected") + with open(file_path, "wb") as f: f.write(contents) @@ -1225,7 +1231,7 @@ def transcription( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(e), + detail="Transcription failed.", ) except Exception as e: @@ -1233,7 +1239,7 @@ def transcription( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(e), + detail="Transcription failed.", ) diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index e3cdc46a16..55a6e6ebba 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -1578,7 +1578,9 @@ async def update_message_by_id( if ( user.role != "admin" and message.user_id != user.id - and not channel_has_access(user.id, channel, permission="read", db=db) + and not channel_has_access( + user.id, channel, permission="write", strict=False, db=db + ) ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index a441c04066..a51b6ff8d8 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -15,6 +15,7 @@ get_tool_server_data, get_tool_server_url, set_tool_servers, + set_terminal_servers, ) from open_webui.utils.mcp.client import MCPClient from open_webui.models.oauth_sessions import OAuthSessions @@ -214,6 +215,51 @@ async def set_tool_servers_config( } +class TerminalServerConnection(BaseModel): + id: Optional[str] = "" + name: Optional[str] = "" + + enabled: Optional[bool] = True + + url: str + path: Optional[str] = "/openapi.json" + + key: Optional[str] = "" + auth_type: Optional[str] = "bearer" + + config: Optional[dict] = None + + model_config = ConfigDict(extra="allow") + + +class TerminalServersConfigForm(BaseModel): + TERMINAL_SERVER_CONNECTIONS: list[TerminalServerConnection] + + +@router.get("/terminal_servers") +async def get_terminal_servers_config(request: Request, user=Depends(get_admin_user)): + return { + "TERMINAL_SERVER_CONNECTIONS": request.app.state.config.TERMINAL_SERVER_CONNECTIONS, + } + + +@router.post("/terminal_servers") +async def set_terminal_servers_config( + request: Request, + form_data: TerminalServersConfigForm, + user=Depends(get_admin_user), +): + request.app.state.config.TERMINAL_SERVER_CONNECTIONS = [ + connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS + ] + + await set_terminal_servers(request) + + return { + "TERMINAL_SERVER_CONNECTIONS": request.app.state.config.TERMINAL_SERVER_CONNECTIONS, + } + + @router.post("/tool_servers/verify") async def verify_tool_servers_config( request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user) diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 68ef37afe4..6e3271d7ec 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -57,64 +57,7 @@ router = APIRouter() -############################ -# Check if the current user has access to a file through any knowledge bases the user may be in. -############################ - - -# TODO: Optimize this function to use the knowledge_file table for faster lookups. -def has_access_to_file( - file_id: Optional[str], - access_type: str, - user=Depends(get_verified_user), - db: Optional[Session] = None, -) -> bool: - file = Files.get_file_by_id(file_id, db=db) - log.debug(f"Checking if user has {access_type} access to file") - if not file: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.NOT_FOUND, - ) - - # Check if the file is associated with any knowledge bases the user has access to - knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id, db=db) - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user.id, db=db) - } - for knowledge_base in knowledge_bases: - if knowledge_base.user_id == user.id or AccessGrants.has_access( - user_id=user.id, - resource_type="knowledge", - resource_id=knowledge_base.id, - permission=access_type, - user_group_ids=user_group_ids, - db=db, - ): - return True - - knowledge_base_id = file.meta.get("collection_name") if file.meta else None - if knowledge_base_id: - knowledge_bases = Knowledges.get_knowledge_bases_by_user_id( - user.id, access_type, db=db - ) - for knowledge_base in knowledge_bases: - if knowledge_base.id == knowledge_base_id: - return True - - # Check if the file is associated with any channels the user has access to - channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db) - if access_type == "read" and channels: - return True - - # Check if the file is associated with any chats the user has access to - # TODO: Granular access control for chats - chats = Chats.get_shared_chats_by_file_id(file_id, db=db) - if chats: - return True - - return False - +from open_webui.utils.access_control.files import has_access_to_file ############################ # Upload File diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 1fedab4466..8bc58b4371 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -29,8 +29,8 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.utils.auth import get_verified_user, get_admin_user -from open_webui.utils.access_control import has_permission -from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant +from open_webui.utils.access_control import has_permission, filter_allowed_access_grants +from open_webui.models.access_grants import AccessGrants from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL @@ -251,7 +251,7 @@ async def create_new_knowledge( user=Depends(get_verified_user), ): # NOTE: We intentionally do NOT use Depends(get_session) here. - # Database operations (has_permission, insert_new_knowledge) manage their own sessions. + # Database operations (has_permission, filter_allowed_access_grants, insert_new_knowledge) manage their own sessions. # This prevents holding a connection during embed_knowledge_base_metadata() # which makes external embedding API calls (1-5+ seconds). if user.role != "admin" and not has_permission( @@ -262,17 +262,13 @@ async def create_new_knowledge( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - # Check if user can share publicly - if ( - user.role != "admin" - and has_public_read_access_grant(form_data.access_grants) - and not has_permission( - user.id, - "sharing.public_knowledge", - request.app.state.config.USER_PERMISSIONS, - ) - ): - form_data.access_grants = [] + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + "sharing.public_knowledge", + ) knowledge = Knowledges.insert_new_knowledge(user.id, form_data) @@ -482,17 +478,13 @@ async def update_knowledge_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - # Check if user can share publicly - if ( - user.role != "admin" - and has_public_read_access_grant(form_data.access_grants) - and not has_permission( - user.id, - "sharing.public_knowledge", - request.app.state.config.USER_PERMISSIONS, - ) - ): - form_data.access_grants = [] + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + "sharing.public_knowledge", + ) knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data) if knowledge: @@ -554,24 +546,13 @@ async def update_knowledge_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - # Strip public sharing if user lacks permission - if ( - user.role != "admin" - and has_public_read_access_grant(form_data.access_grants) - and not has_permission( - user.id, - "sharing.public_knowledge", - request.app.state.config.USER_PERMISSIONS, - ) - ): - form_data.access_grants = [ - grant - for grant in form_data.access_grants - if not ( - grant.get("principal_type") == "user" - and grant.get("principal_id") == "*" - ) - ] + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + "sharing.public_knowledge", + ) AccessGrants.set_access_grants("knowledge", id, form_data.access_grants, db=db) @@ -764,6 +745,13 @@ def update_file_from_knowledge_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) + # Validate the file actually belongs to this knowledge base + if not Knowledges.has_file(knowledge_id=id, file_id=form_data.file_id, db=db): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + # Remove content from the vector database VECTOR_DB_CLIENT.delete( collection_name=knowledge.id, filter={"file_id": form_data.file_id} @@ -838,6 +826,13 @@ def remove_file_from_knowledge_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) + # Validate the file actually belongs to this knowledge base + if not Knowledges.has_file(knowledge_id=id, file_id=form_data.file_id, db=db): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + Knowledges.remove_file_from_knowledge_by_id( knowledge_id=id, file_id=form_data.file_id, db=db ) diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index c417453486..95279bc378 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -17,7 +17,7 @@ ModelAccessResponse, Models, ) -from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant +from open_webui.models.access_grants import AccessGrants from pydantic import BaseModel from open_webui.constants import ERROR_MESSAGES @@ -33,7 +33,7 @@ from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control import has_permission, filter_allowed_access_grants from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR from open_webui.internal.db import get_session from sqlalchemy.orm import Session @@ -565,24 +565,13 @@ async def update_model_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - # Strip public sharing if user lacks permission - if ( - user.role != "admin" - and has_public_read_access_grant(form_data.access_grants) - and not has_permission( - user.id, - "sharing.public_models", - request.app.state.config.USER_PERMISSIONS, - ) - ): - form_data.access_grants = [ - grant - for grant in form_data.access_grants - if not ( - grant.get("principal_type") == "user" - and grant.get("principal_id") == "*" - ) - ] + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + "sharing.public_models", + ) AccessGrants.set_access_grants( "model", form_data.id, form_data.access_grants, db=db diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 41bb65f55a..de8c18e934 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -27,8 +27,8 @@ from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.access_control import has_permission -from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant +from open_webui.utils.access_control import has_permission, filter_allowed_access_grants +from open_webui.models.access_grants import AccessGrants from open_webui.internal.db import get_session from sqlalchemy.orm import Session @@ -283,18 +283,14 @@ async def update_note_by_id( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) - # Check if user can share publicly - if ( - user.role != "admin" - and has_public_read_access_grant(form_data.access_grants) - and not has_permission( - user.id, - "sharing.public_notes", - request.app.state.config.USER_PERMISSIONS, - db=db, - ) - ): - form_data.access_grants = [] + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + "sharing.public_notes", + db=db, + ) try: note = Notes.update_note_by_id(id, form_data, db=db) @@ -357,24 +353,13 @@ async def update_note_access_by_id( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) - # Strip public sharing if user lacks permission - if ( - user.role != "admin" - and has_public_read_access_grant(form_data.access_grants) - and not has_permission( - user.id, - "sharing.public_notes", - request.app.state.config.USER_PERMISSIONS, - ) - ): - form_data.access_grants = [ - grant - for grant in form_data.access_grants - if not ( - grant.get("principal_type") == "user" - and grant.get("principal_id") == "*" - ) - ] + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + "sharing.public_notes", + ) AccessGrants.set_access_grants("note", id, form_data.access_grants, db=db) diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 9653571fbb..1f3342dad7 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -9,7 +9,7 @@ PromptModel, Prompts, ) -from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant +from open_webui.models.access_grants import AccessGrants from open_webui.models.groups import Groups from open_webui.models.prompt_history import ( PromptHistories, @@ -18,7 +18,7 @@ ) from open_webui.constants import ERROR_MESSAGES from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.access_control import has_permission +from open_webui.utils.access_control import has_permission, filter_allowed_access_grants from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.internal.db import get_session from sqlalchemy.orm import Session @@ -473,24 +473,13 @@ async def update_prompt_access_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - # Strip public sharing if user lacks permission - if ( - user.role != "admin" - and has_public_read_access_grant(form_data.access_grants) - and not has_permission( - user.id, - "sharing.public_prompts", - request.app.state.config.USER_PERMISSIONS, - ) - ): - form_data.access_grants = [ - grant - for grant in form_data.access_grants - if not ( - grant.get("principal_type") == "user" - and grant.get("principal_id") == "*" - ) - ] + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + "sharing.public_prompts", + ) AccessGrants.set_access_grants("prompt", prompt_id, form_data.access_grants, db=db) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 1ff4cef3e6..a0cb4fee45 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -37,6 +37,7 @@ from langchain_core.documents import Document from open_webui.models.files import FileModel, FileUpdateForm, Files +from open_webui.utils.access_control.files import has_access_to_file from open_webui.models.knowledge import Knowledges from open_webui.storage.provider import Storage from open_webui.internal.db import get_session, get_db @@ -1971,6 +1972,7 @@ async def process_web( docs, collection_name, overwrite=overwrite, + add=(not overwrite), user=user, ) else: @@ -2489,6 +2491,34 @@ async def search_query_with_semaphore(query): ) +def _validate_collection_access(collection_names: list[str], user) -> None: + """ + Prevent users from querying collections they don't own. + Enforces ownership on user-memory-* and file-* collections. + Admins bypass this check. + """ + if user.role == "admin": + return + + for name in collection_names: + if name.startswith("user-memory-") and name != f"user-memory-{user.id}": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + elif name.startswith("file-"): + file_id = name[len("file-") :] + if not has_access_to_file( + file_id=file_id, + access_type="read", + user=user, + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + class QueryDocForm(BaseModel): collection_name: str query: str @@ -2504,6 +2534,8 @@ async def query_doc_handler( form_data: QueryDocForm, user=Depends(get_verified_user), ): + _validate_collection_access([form_data.collection_name], user) + try: if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and ( form_data.hybrid is None or form_data.hybrid @@ -2578,6 +2610,8 @@ async def query_collection_handler( form_data: QueryCollectionsForm, user=Depends(get_verified_user), ): + _validate_collection_access(form_data.collection_names, user) + try: if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and ( form_data.hybrid is None or form_data.hybrid @@ -2756,6 +2790,27 @@ async def process_files_batch( for file in form_data.files: try: + # Ownership check: verify the requesting user owns the file or is an admin + db_file = Files.get_file_by_id(file.id) + if not db_file: + file_errors.append( + BatchProcessFilesResult( + file_id=file.id, + status="failed", + error="File not found", + ) + ) + continue + if db_file.user_id != user.id and user.role != "admin": + file_errors.append( + BatchProcessFilesResult( + file_id=file.id, + status="failed", + error="Permission denied: not file owner", + ) + ) + continue + text_content = file.data.get("content", "") docs: List[Document] = [ Document( diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index fb7b01b87f..f2594ae5d0 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -17,7 +17,7 @@ SkillAccessListResponse, Skills, ) -from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant +from open_webui.models.access_grants import AccessGrants from open_webui.utils.auth import get_admin_user, get_verified_user from open_webui.utils.access_control import has_access, has_permission @@ -341,24 +341,13 @@ async def update_skill_access_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - # Strip public sharing if user lacks permission - if ( - user.role != "admin" - and has_public_read_access_grant(form_data.access_grants) - and not has_permission( - user.id, - "sharing.public_skills", - request.app.state.config.USER_PERMISSIONS, - ) - ): - form_data.access_grants = [ - grant - for grant in form_data.access_grants - if not ( - grant.get("principal_type") == "user" - and grant.get("principal_id") == "*" - ) - ] + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + "sharing.public_skills", + ) AccessGrants.set_access_grants("skill", id, form_data.access_grants, db=db) diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py new file mode 100644 index 0000000000..254545a273 --- /dev/null +++ b/backend/open_webui/routers/terminals.py @@ -0,0 +1,151 @@ +"""Reverse proxy for admin-configured terminal servers. + +Routes: + GET / — list terminals the user has access to + * /{server_id}/{path:path} — proxy request to terminal server +""" + +import logging + +import aiohttp +from fastapi import APIRouter, Depends, Request, Response +from fastapi.responses import JSONResponse, StreamingResponse +from starlette.background import BackgroundTask + +from open_webui.utils.auth import get_verified_user +from open_webui.utils.access_control import has_connection_access +from open_webui.models.groups import Groups + +log = logging.getLogger(__name__) + +router = APIRouter() + +STREAMING_CONTENT_TYPES = ("application/octet-stream", "image/", "application/pdf") +STRIPPED_RESPONSE_HEADERS = frozenset( + ("transfer-encoding", "connection", "content-encoding", "content-length") +) + + +@router.get("/") +async def list_terminal_servers(request: Request, user=Depends(get_verified_user)): + """Return terminal servers the authenticated user has access to.""" + connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + + return [ + { + "id": connection.get("id", ""), + "url": connection.get("url", ""), + "name": connection.get("name", ""), + } + for connection in connections + if connection.get("enabled", True) + and has_connection_access(user, connection, user_group_ids) + ] + + +PROXY_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] + + +@router.api_route("/{server_id}/{path:path}", methods=PROXY_METHODS) +async def proxy_terminal( + server_id: str, + path: str, + request: Request, + user=Depends(get_verified_user), +): + """Proxy a request to the admin terminal server identified by *server_id*.""" + connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + connection = next((c for c in connections if c.get("id") == server_id), None) + + if connection is None: + return JSONResponse( + {"error": f"Terminal server '{server_id}' not found"}, status_code=404 + ) + + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + if not has_connection_access(user, connection, user_group_ids): + return JSONResponse({"error": "Access denied"}, status_code=403) + + base_url = (connection.get("url") or "").rstrip("/") + if not base_url: + return JSONResponse( + {"error": "Terminal server URL not configured"}, status_code=503 + ) + + target_url = f"{base_url}/{path}" + if request.query_params: + target_url += f"?{request.query_params}" + + headers = {"X-User-Id": user.id} + cookies = {} + auth_type = connection.get("auth_type", "bearer") + + if auth_type == "bearer": + headers["Authorization"] = f"Bearer {connection.get('key', '')}" + elif auth_type == "session": + cookies = request.cookies + headers["Authorization"] = f"Bearer {request.state.token.credentials}" + elif auth_type == "system_oauth": + cookies = request.cookies + oauth_token = request.headers.get("x-oauth-access-token", "") + if oauth_token: + headers["Authorization"] = f"Bearer {oauth_token}" + # auth_type == "none": no Authorization header + + content_type = request.headers.get("content-type") + if content_type: + headers["Content-Type"] = content_type + + body = await request.body() + session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=300, connect=10), + trust_env=True, + ) + + try: + upstream_response = await session.request( + method=request.method, + url=target_url, + headers=headers, + cookies=cookies, + data=body or None, + ) + + upstream_content_type = upstream_response.headers.get("content-type", "") + filtered_headers = { + key: value + for key, value in upstream_response.headers.items() + if key.lower() not in STRIPPED_RESPONSE_HEADERS + } + + # Stream binary responses directly + if any(t in upstream_content_type for t in STREAMING_CONTENT_TYPES): + + async def cleanup(): + await upstream_response.release() + await session.close() + + return StreamingResponse( + content=upstream_response.content.iter_any(), + status_code=upstream_response.status, + headers=filtered_headers, + background=BackgroundTask(cleanup), + ) + + # Buffer text/JSON responses + response_body = await upstream_response.read() + status_code = upstream_response.status + await upstream_response.release() + await session.close() + + return Response( + content=response_body, status_code=status_code, headers=filtered_headers + ) + + except Exception as error: + await session.close() + log.exception("Terminal proxy error: %s", error) + return JSONResponse( + {"error": f"Terminal proxy error: {error}"}, status_code=502 + ) diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 531cfe16a7..e5b2daad51 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -21,7 +21,7 @@ ToolAccessResponse, Tools, ) -from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant +from open_webui.models.access_grants import AccessGrants from open_webui.utils.plugin import ( load_tool_module_by_id, replace_imports, @@ -576,24 +576,13 @@ async def update_tool_access_by_id( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - # Strip public sharing if user lacks permission - if ( - user.role != "admin" - and has_public_read_access_grant(form_data.access_grants) - and not has_permission( - user.id, - "sharing.public_tools", - request.app.state.config.USER_PERMISSIONS, - ) - ): - form_data.access_grants = [ - grant - for grant in form_data.access_grants - if not ( - grant.get("principal_type") == "user" - and grant.get("principal_id") == "*" - ) - ] + form_data.access_grants = filter_allowed_access_grants( + request.app.state.config.USER_PERMISSIONS, + user.id, + user.role, + form_data.access_grants, + "sharing.public_tools", + ) AccessGrants.set_access_grants("tool", id, form_data.access_grants, db=db) diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index aa33d78e99..961732c28e 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -223,6 +223,10 @@ class SharingPermissions(BaseModel): public_notes: bool = True +class AccessGrantsPermissions(BaseModel): + allow_users: bool = True + + class ChatPermissions(BaseModel): controls: bool = True valves: bool = True @@ -266,6 +270,7 @@ class SettingsPermissions(BaseModel): class UserPermissions(BaseModel): workspace: WorkspacePermissions sharing: SharingPermissions + access_grants: AccessGrantsPermissions chat: ChatPermissions features: FeaturesPermissions settings: SettingsPermissions @@ -280,6 +285,9 @@ async def get_default_user_permissions(request: Request, user=Depends(get_admin_ "sharing": SharingPermissions( **request.app.state.config.USER_PERMISSIONS.get("sharing", {}) ), + "access_grants": AccessGrantsPermissions( + **request.app.state.config.USER_PERMISSIONS.get("access_grants", {}) + ), "chat": ChatPermissions( **request.app.state.config.USER_PERMISSIONS.get("chat", {}) ), diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index c1d7e5c29e..758b530c92 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -790,21 +790,26 @@ async def __event_emitter__(event_data): }, room=f"user:{user_id}", ) + if ( update_db and message_id and not request_info.get("chat_id", "").startswith("local:") ): - if "type" in event_data and event_data["type"] == "status": - Chats.add_message_status_to_chat_by_id_and_message_id( + event_type = event_data.get("type") + + if event_type == "status": + await asyncio.to_thread( + Chats.add_message_status_to_chat_by_id_and_message_id, request_info["chat_id"], request_info["message_id"], event_data.get("data", {}), ) - if "type" in event_data and event_data["type"] == "message": - message = Chats.get_message_by_id_and_message_id( + elif event_type == "message": + message = await asyncio.to_thread( + Chats.get_message_by_id_and_message_id, request_info["chat_id"], request_info["message_id"], ) @@ -813,7 +818,8 @@ async def __event_emitter__(event_data): content = message.get("content", "") content += event_data.get("data", {}).get("content", "") - Chats.upsert_message_to_chat_by_id_and_message_id( + await asyncio.to_thread( + Chats.upsert_message_to_chat_by_id_and_message_id, request_info["chat_id"], request_info["message_id"], { @@ -821,10 +827,11 @@ async def __event_emitter__(event_data): }, ) - if "type" in event_data and event_data["type"] == "replace": + elif event_type == "replace": content = event_data.get("data", {}).get("content", "") - Chats.upsert_message_to_chat_by_id_and_message_id( + await asyncio.to_thread( + Chats.upsert_message_to_chat_by_id_and_message_id, request_info["chat_id"], request_info["message_id"], { @@ -832,8 +839,9 @@ async def __event_emitter__(event_data): }, ) - if "type" in event_data and event_data["type"] == "embeds": - message = Chats.get_message_by_id_and_message_id( + elif event_type == "embeds": + message = await asyncio.to_thread( + Chats.get_message_by_id_and_message_id, request_info["chat_id"], request_info["message_id"], ) @@ -841,7 +849,8 @@ async def __event_emitter__(event_data): embeds = event_data.get("data", {}).get("embeds", []) embeds.extend(message.get("embeds", [])) - Chats.upsert_message_to_chat_by_id_and_message_id( + await asyncio.to_thread( + Chats.upsert_message_to_chat_by_id_and_message_id, request_info["chat_id"], request_info["message_id"], { @@ -849,8 +858,9 @@ async def __event_emitter__(event_data): }, ) - if "type" in event_data and event_data["type"] == "files": - message = Chats.get_message_by_id_and_message_id( + elif event_type == "files": + message = await asyncio.to_thread( + Chats.get_message_by_id_and_message_id, request_info["chat_id"], request_info["message_id"], ) @@ -858,7 +868,8 @@ async def __event_emitter__(event_data): files = event_data.get("data", {}).get("files", []) files.extend(message.get("files", [])) - Chats.upsert_message_to_chat_by_id_and_message_id( + await asyncio.to_thread( + Chats.upsert_message_to_chat_by_id_and_message_id, request_info["chat_id"], request_info["message_id"], { @@ -866,10 +877,11 @@ async def __event_emitter__(event_data): }, ) - if event_data.get("type") in ["source", "citation"]: + elif event_type in ("source", "citation"): data = event_data.get("data", {}) - if data.get("type") == None: - message = Chats.get_message_by_id_and_message_id( + if data.get("type") is None: + message = await asyncio.to_thread( + Chats.get_message_by_id_and_message_id, request_info["chat_id"], request_info["message_id"], ) @@ -877,7 +889,8 @@ async def __event_emitter__(event_data): sources = message.get("sources", []) sources.append(data) - Chats.upsert_message_to_chat_by_id_and_message_id( + await asyncio.to_thread( + Chats.upsert_message_to_chat_by_id_and_message_id, request_info["chat_id"], request_info["message_id"], { diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 330baf1318..a59df3813f 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -1627,7 +1627,7 @@ async def view_file( try: from open_webui.models.files import Files - from open_webui.routers.files import has_access_to_file + from open_webui.utils.access_control.files import has_access_to_file user_id = __user__.get("id") user_role = __user__.get("role", "user") diff --git a/backend/open_webui/utils/access_control.py b/backend/open_webui/utils/access_control/__init__.py similarity index 72% rename from backend/open_webui/utils/access_control.py rename to backend/open_webui/utils/access_control/__init__.py index b7ea9830db..a228bbd6a8 100644 --- a/backend/open_webui/utils/access_control.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -153,6 +153,31 @@ def has_access( return False +def has_connection_access( + user: UserModel, + connection: dict, + user_group_ids: Optional[Set[str]] = None, +) -> bool: + """ + Check if a user can access a server connection (tool server, terminal, etc.) + based on ``config.access_grants`` within the connection dict. + + - Admin with BYPASS_ADMIN_ACCESS_CONTROL → always allowed + - Missing, None, or empty access_grants → private, admin-only + - access_grants has entries → delegates to ``has_access`` + """ + from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL + + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + return True + + if user_group_ids is None: + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + + access_grants = (connection.get("config") or {}).get("access_grants", []) + return has_access(user.id, "read", access_grants, user_group_ids) + + def migrate_access_control( data: dict, ac_key: str = "access_control", grants_key: str = "access_grants" ) -> None: @@ -194,3 +219,63 @@ def migrate_access_control( data[grants_key] = grants data.pop(ac_key, None) + + +from open_webui.models.access_grants import ( + has_public_read_access_grant, + has_user_access_grant, + strip_user_access_grants, +) + + +def filter_allowed_access_grants( + default_permissions: Dict[str, Any], + user_id: str, + user_role: str, + access_grants: list, + public_permission_key: str, + db: Optional[Any] = None, +) -> list: + """ + Checks if the user has the required permissions to grant access to a resource. + Returns the filtered list of access grants if permissions are missing. + """ + if user_role == "admin" or not access_grants: + return access_grants + + # Check if user can share publicly + if has_public_read_access_grant(access_grants) and not has_permission( + user_id, + public_permission_key, + default_permissions, + db=db, + ): + access_grants = [ + grant + for grant in access_grants + if not ( + ( + grant.get("principal_type") + if isinstance(grant, dict) + else getattr(grant, "principal_type", None) + ) + == "user" + and ( + grant.get("principal_id") + if isinstance(grant, dict) + else getattr(grant, "principal_id", None) + ) + == "*" + ) + ] + + # Strip individual user sharing if user lacks permission + if has_user_access_grant(access_grants) and not has_permission( + user_id, + "access_grants.allow_users", + default_permissions, + db=db, + ): + access_grants = strip_user_access_grants(access_grants) + + return access_grants diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py new file mode 100644 index 0000000000..11c06f14ad --- /dev/null +++ b/backend/open_webui/utils/access_control/files.py @@ -0,0 +1,75 @@ +import logging +from typing import Optional, Any + +from open_webui.models.users import UserModel +from open_webui.models.files import Files +from open_webui.models.knowledge import Knowledges +from open_webui.models.channels import Channels +from open_webui.models.chats import Chats +from open_webui.models.groups import Groups +from open_webui.models.access_grants import AccessGrants + +log = logging.getLogger(__name__) + + +def has_access_to_file( + file_id: Optional[str], + access_type: str, + user: UserModel, + db: Optional[Any] = None, +) -> bool: + """ + Check if a user has the specified access to a file through any of: + - Knowledge bases (ownership or access grants) + - Channels the user is a member of + - Shared chats + + NOTE: This does NOT check direct file ownership — callers should check + file.user_id == user.id separately before calling this. + """ + file = Files.get_file_by_id(file_id, db=db) + log.debug(f"Checking if user has {access_type} access to file") + if not file: + return False + + # Direct ownership + if file.user_id == user.id: + return True + + # Check if the file is associated with any knowledge bases the user has access to + knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id, db=db) + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id, db=db) + } + for knowledge_base in knowledge_bases: + if knowledge_base.user_id == user.id or AccessGrants.has_access( + user_id=user.id, + resource_type="knowledge", + resource_id=knowledge_base.id, + permission=access_type, + user_group_ids=user_group_ids, + db=db, + ): + return True + + knowledge_base_id = file.meta.get("collection_name") if file.meta else None + if knowledge_base_id: + knowledge_bases = Knowledges.get_knowledge_bases_by_user_id( + user.id, access_type, db=db + ) + for knowledge_base in knowledge_bases: + if knowledge_base.id == knowledge_base_id: + return True + + # Check if the file is associated with any channels the user has access to + channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db) + if access_type == "read" and channels: + return True + + # Check if the file is associated with any chats the user has access to + # TODO: Granular access control for chats + chats = Chats.get_shared_chats_by_file_id(file_id, db=db) + if chats: + return True + + return False diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py index 37349d2902..9c71f0d651 100644 --- a/backend/open_webui/utils/filter.py +++ b/backend/open_webui/utils/filter.py @@ -22,10 +22,14 @@ def get_function_module(request, function_id, load_from_db=True): def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None): def get_priority(function_id): - function = Functions.get_function_by_id(function_id) - if function is not None: - valves = Functions.get_function_valves_by_id(function_id) - return valves.get("priority", 0) if valves else 0 + try: + function_module = get_function_module(request, function_id) + if function_module and hasattr(function_module, "Valves"): + valves_db = Functions.get_function_valves_by_id(function_id) + valves = function_module.Valves(**(valves_db if valves_db else {})) + return getattr(valves, "priority", 0) + except Exception: + pass return 0 filter_ids = [function.id for function in Functions.get_global_filter_functions()] @@ -50,7 +54,7 @@ def get_active_status(filter_id): ] filter_ids = [fid for fid in filter_ids if fid in active_filter_ids] - filter_ids.sort(key=get_priority) + filter_ids.sort(key=lambda fid: (get_priority(fid), fid)) return filter_ids diff --git a/backend/open_webui/utils/logger.py b/backend/open_webui/utils/logger.py index 283f37ea66..26a525fc0b 100644 --- a/backend/open_webui/utils/logger.py +++ b/backend/open_webui/utils/logger.py @@ -153,7 +153,7 @@ def start_logger(): logger.remove() audit_filter = lambda record: ( - "auditable" not in record["extra"] if ENABLE_AUDIT_STDOUT else True + True if ENABLE_AUDIT_STDOUT else "auditable" not in record["extra"] ) if LOG_FORMAT == "json": logger.add( diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 763d74bd13..55f55ab033 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -86,6 +86,7 @@ get_message_list, add_or_update_system_message, add_or_update_user_message, + set_last_user_message_content, get_last_user_message, get_last_user_message_item, get_last_assistant_message, @@ -98,8 +99,9 @@ from open_webui.utils.tools import ( get_tools, get_updated_tool_function, - has_tool_server_access, + get_terminal_tools, ) +from open_webui.utils.access_control import has_connection_access from open_webui.utils.plugin import load_function_module_by_id from open_webui.utils.filter import ( get_sorted_filter_ids, @@ -373,9 +375,14 @@ def serialize_output(output: list) -> str: result_item = tool_outputs.get(call_id) if result_item: result_text = "" - for out in result_item.get("output", []): - if "text" in out: - result_text += out.get("text", "") + for output in result_item.get("output", []): + if "text" in output: + output_text = output.get("text", "") + result_text += ( + str(output_text) + if not isinstance(output_text, str) + else output_text + ) files = result_item.get("files") embeds = result_item.get("embeds", "") @@ -888,6 +895,7 @@ def process_tool_result( user=None, ): tool_result_embeds = [] + EXTERNAL_TOOL_TYPES = ("external", "action", "terminal") if isinstance(tool_result, HTMLResponse): content_disposition = tool_result.headers.get("Content-Disposition", "") @@ -922,7 +930,7 @@ def process_tool_result( else: tool_result = tool_result.body.decode("utf-8", "replace") - elif (tool_type in ("external", "action") and isinstance(tool_result, tuple)) or ( + elif (tool_type in EXTERNAL_TOOL_TYPES and isinstance(tool_result, tuple)) or ( direct_tool and isinstance(tool_result, list) and len(tool_result) == 2 ): tool_result, tool_response_headers = tool_result @@ -1018,9 +1026,67 @@ def process_tool_result( if isinstance(tool_result, dict) or isinstance(tool_result, list): tool_result = json.dumps(tool_result, indent=2, ensure_ascii=False) + # Safety: ensure tool_result is always a string (or None) to prevent + # downstream TypeError when concatenating (e.g. if an upstream callable + # returned a tuple that was not unpacked by the branches above). + if tool_result is not None and not isinstance(tool_result, str): + if isinstance(tool_result, tuple): + # execute_tool_server returns (data, headers); unpack the data part + tool_result = ( + json.dumps(tool_result[0], indent=2, ensure_ascii=False) + if len(tool_result) > 0 + else "" + ) + else: + tool_result = str(tool_result) + return tool_result, tool_result_files, tool_result_embeds +async def terminal_event_handler( + tool_function_name: str, + tool_function_params: dict, + tool_result, + event_emitter, +): + """Emit terminal:* events for Open Terminal tools. + + - display_file → emits 'terminal:display_file' to open the file preview. + - write_file → emits 'terminal:write_file' to silently refresh the file browser. + """ + if not event_emitter: + return + + path = tool_function_params.get("path", "") + if not path: + return + + if tool_function_name == "display_file": + # Only emit if the file actually exists + parsed = tool_result + if isinstance(parsed, str): + try: + parsed = json.loads(parsed) + except (json.JSONDecodeError, TypeError): + pass + if isinstance(parsed, dict) and parsed.get("exists") is False: + return + + await event_emitter( + { + "type": f"terminal:{tool_function_name}", + "data": {"path": path}, + } + ) + elif tool_function_name == "write_file": + await event_emitter( + { + "type": f"terminal:{tool_function_name}", + "data": {"path": path}, + } + ) + + async def chat_completion_tools_handler( request: Request, body: dict, extra_params: dict, user: UserModel, models, tools ) -> tuple[dict, dict]: @@ -1175,6 +1241,13 @@ async def tool_call_handler(tool_call): ) if event_emitter: + await terminal_event_handler( + tool_function_name, + tool_function_params, + tool_result, + event_emitter, + ) + if tool_result_files: await event_emitter( { @@ -1977,7 +2050,7 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]: for message in messages: if message.get("role") == "assistant" and message.get("output"): # Use output items for clean OpenAI-format messages - output_messages = convert_output_to_messages(message["output"]) + output_messages = convert_output_to_messages(message["output"], raw=True) if output_messages: processed.extend(output_messages) continue @@ -2224,6 +2297,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) tool_ids = form_data.pop("tool_ids", None) + terminal_id = form_data.pop("terminal_id", None) files = form_data.pop("files", None) # Caller-provided OpenAI-style tools take precedence over server-side @@ -2297,6 +2371,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): metadata = { **metadata, "tool_ids": tool_ids, + "terminal_id": terminal_id, "files": files, } form_data["metadata"] = metadata @@ -2341,7 +2416,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): continue # Check access control for MCP server - if not has_tool_server_access(user, mcp_server_connection): + if not has_connection_access(user, mcp_server_connection): log.warning( f"Access denied to MCP server {server_id} for user {user.id}" ) @@ -2478,6 +2553,21 @@ async def tool_function(**kwargs): if mcp_tools_dict: tools_dict = {**tools_dict, **mcp_tools_dict} + # Resolve terminal tools if terminal_id is set (outside tool_ids check + # so system terminals work even when no other tools are selected) + if terminal_id: + try: + terminal_tools = await get_terminal_tools( + request, + terminal_id, + user, + extra_params, + ) + if terminal_tools: + tools_dict = {**tools_dict, **terminal_tools} + except Exception as e: + log.exception(e) + if direct_tool_servers: for tool_server in direct_tool_servers: tool_specs = tool_server.pop("specs", []) @@ -2530,16 +2620,15 @@ async def tool_function(**kwargs): {"type": "function", "function": tool.get("spec", {})} for tool in tools_dict.values() ] - - else: - # If the function calling is not native, then call the tools function calling handler - try: - form_data, flags = await chat_completion_tools_handler( - request, form_data, extra_params, user, models, tools_dict - ) - sources.extend(flags.get("sources", [])) - except Exception as e: - log.exception(e) + else: + # If the function calling is not native, then call the tools function calling handler + try: + form_data, flags = await chat_completion_tools_handler( + request, form_data, extra_params, user, models, tools_dict + ) + sources.extend(flags.get("sources", [])) + except Exception as e: + log.exception(e) # Check if file context extraction is enabled for this model (default True) file_context_enabled = ( @@ -4059,6 +4148,13 @@ async def flush_pending_delta_data(threshold: int = 0): log.error( f"Error parsing tool call arguments: {tool_args}" ) + results.append( + { + "tool_call_id": tool_call_id, + "content": f"Error: Tool call arguments could not be parsed. The model generated malformed or incomplete JSON for `{tool_function_name}`. Please try again.", + } + ) + continue # Ensure arguments are valid JSON for downstream LLM integrations log.debug( @@ -4139,6 +4235,13 @@ async def flush_pending_delta_data(threshold: int = 0): ) ) + await terminal_event_handler( + tool_function_name, + tool_function_params, + tool_result, + event_emitter, + ) + # Extract citation sources from tool results if ( tool_function_name @@ -4164,7 +4267,7 @@ async def flush_pending_delta_data(threshold: int = 0): results.append( { "tool_call_id": tool_call_id, - "content": tool_result or "", + "content": str(tool_result) if tool_result else "", **( {"files": tool_result_files} if tool_result_files @@ -4241,8 +4344,8 @@ async def flush_pending_delta_data(threshold: int = 0): all_tool_call_sources.extend(tool_call_sources) if all_tool_call_sources and user_message: # Restore original user message before re-applying to avoid recursive nesting - form_data["messages"] = add_or_update_user_message( - user_message, form_data["messages"], append=False + set_last_user_message_content( + user_message, form_data["messages"] ) form_data["messages"] = apply_source_context_to_messages( request, diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index c4641d9bdb..514616ed0b 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -209,7 +209,12 @@ def flush_pending(): content = "" for part in output_parts: if part.get("type") == "input_text": - content += part.get("text", "") + output_text = part.get("text", "") + content += ( + str(output_text) + if not isinstance(output_text, str) + else output_text + ) messages.append( { @@ -276,6 +281,24 @@ def get_last_user_message(messages: list[dict]) -> Optional[str]: return get_content_from_message(message) +def set_last_user_message_content(content: str, messages: list[dict]) -> list[dict]: + """ + Replace the text content of the last user message in-place. + Handles both plain-string and list-of-parts content formats. + """ + for message in reversed(messages): + if message.get("role") == "user": + if isinstance(message.get("content"), list): + for item in message["content"]: + if item.get("type") == "text": + item["text"] = content + break + else: + message["content"] = content + break + return messages + + def get_last_assistant_message_item(messages: list[dict]) -> Optional[dict]: for message in reversed(messages): if message["role"] == "assistant": diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 6040c0645d..6c82b4aa98 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -331,12 +331,25 @@ def get_filter_items_from_module(function, module): elif meta.get(key) is None: meta[key] = copy.deepcopy(value) + def get_action_priority(action_id): + try: + function_module = request.app.state.FUNCTIONS.get(action_id) + if function_module and hasattr(function_module, "Valves"): + valves_db = Functions.get_function_valves_by_id(action_id) + valves = function_module.Valves(**(valves_db if valves_db else {})) + return getattr(valves, "priority", 0) + except Exception: + pass + return 0 + for model in models: action_ids = [ action_id for action_id in list(set(model.pop("action_ids", []) + global_action_ids)) if action_id in enabled_action_ids ] + action_ids.sort(key=lambda aid: (get_action_priority(aid), aid)) + filter_ids = [ filter_id for filter_id in list(set(model.pop("filter_ids", []) + global_filter_ids)) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index b23c5c90a3..284917d22c 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -55,6 +55,8 @@ OAUTH_ADMIN_ROLES, OAUTH_ALLOWED_DOMAINS, OAUTH_UPDATE_PICTURE_ON_LOGIN, + OAUTH_UPDATE_NAME_ON_LOGIN, + OAUTH_UPDATE_EMAIL_ON_LOGIN, OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID, OAUTH_AUDIENCE, WEBHOOK_URL, @@ -129,6 +131,8 @@ class OAuthClientInformationFull(OAuthClientMetadata): auth_manager_config.WEBHOOK_URL = WEBHOOK_URL auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN +auth_manager_config.OAUTH_UPDATE_NAME_ON_LOGIN = OAUTH_UPDATE_NAME_ON_LOGIN +auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN = OAUTH_UPDATE_EMAIL_ON_LOGIN auth_manager_config.OAUTH_AUDIENCE = OAUTH_AUDIENCE @@ -1548,6 +1552,33 @@ async def handle_callback(self, request, provider, response, db=None): # Update the user object in memory as well, # to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below user.role = determined_role + + if auth_manager_config.OAUTH_UPDATE_NAME_ON_LOGIN: + username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM + if username_claim: + new_name = user_data.get(username_claim) + if new_name and new_name != user.name: + Users.update_user_by_id(user.id, {"name": new_name}, db=db) + user.name = new_name + log.debug(f"Updated name for user {user.email}") + + if auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN: + email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM + if email_claim: + new_email = user_data.get(email_claim) + if new_email and new_email.lower() != user.email.lower(): + existing_user = Users.get_user_by_email(new_email, db=db) + if existing_user: + log.error( + f"Cannot update email to {new_email} for user {user.id} because it is already taken." + ) + else: + Auths.update_email_by_id( + user.id, new_email.lower(), db=db + ) + user.email = new_email.lower() + log.debug(f"Updated email for user {user.id}") + # Update profile picture if enabled and different from current if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN: picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM @@ -1706,17 +1737,22 @@ async def handle_callback(self, request, provider, response, db=None): db=db, ) - response.set_cookie( - key="oauth_session_id", - value=session.id, - httponly=True, - samesite=WEBUI_AUTH_COOKIE_SAME_SITE, - secure=WEBUI_AUTH_COOKIE_SECURE, - ) + if session: + response.set_cookie( + key="oauth_session_id", + value=session.id, + httponly=True, + samesite=WEBUI_AUTH_COOKIE_SAME_SITE, + secure=WEBUI_AUTH_COOKIE_SECURE, + ) - log.info( - f"Stored OAuth session server-side for user {user.id}, provider {provider}" - ) + log.info( + f"Stored OAuth session server-side for user {user.id}, provider {provider}" + ) + else: + log.warning( + f"Failed to create OAuth session for user {user.id}, provider {provider}" + ) except Exception as e: log.error(f"Failed to store OAuth session server-side: {e}") diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index de59c0b0ad..583c7c0043 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -145,9 +145,8 @@ def convert_response_ollama_to_openai(ollama_response: dict) -> dict: return response -async def convert_streaming_response_ollama_to_openai( - user, model_id, form_data, ollama_streaming_response -): +async def convert_streaming_response_ollama_to_openai(ollama_streaming_response): + has_tool_calls = False with CreditDeduct( user=user, model_id=model_id, @@ -165,6 +164,7 @@ async def convert_streaming_response_ollama_to_openai( if tool_calls: openai_tool_calls = convert_ollama_tool_call_to_openai(tool_calls) + has_tool_calls = True done = data.get("done", False) @@ -176,7 +176,7 @@ async def convert_streaming_response_ollama_to_openai( model, message_content, reasoning_content, openai_tool_calls, usage ) - if done and openai_tool_calls: + if done and has_tool_calls: data["choices"][0]["finish_reason"] = "tool_calls" line = f"data: {json.dumps(data)}\n\n" diff --git a/backend/open_webui/utils/security_headers.py b/backend/open_webui/utils/security_headers.py index fbcf7d6977..3b31c2c05c 100644 --- a/backend/open_webui/utils/security_headers.py +++ b/backend/open_webui/utils/security_headers.py @@ -28,6 +28,7 @@ def set_security_headers() -> Dict[str, str]: - x-frame-options - x-permitted-cross-domain-policies - content-security-policy + - reporting-endpoints Each environment variable is associated with a specific setter function that constructs the header. If the environment variable is set, the @@ -47,6 +48,7 @@ def set_security_headers() -> Dict[str, str]: "XFRAME_OPTIONS": set_xframe, "XPERMITTED_CROSS_DOMAIN_POLICIES": set_xpermitted_cross_domain_policies, "CONTENT_SECURITY_POLICY": set_content_security_policy, + "REPORTING_ENDPOINTS": set_reporting_endpoints, } for env_var, setter in header_setters.items(): @@ -131,3 +133,8 @@ def set_xpermitted_cross_domain_policies(value: str): # Set Content-Security-Policy response header def set_content_security_policy(value: str): return {"Content-Security-Policy": value} + + +# Set Reporting-Endpoints response header +def set_reporting_endpoints(value: str): + return {"Reporting-Endpoints": value} diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 310fa999c7..ab9c1b661b 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -40,7 +40,7 @@ from open_webui.models.groups import Groups from open_webui.models.access_grants import AccessGrants from open_webui.utils.plugin import load_tool_module_by_id -from open_webui.utils.access_control import has_access +from open_webui.utils.access_control import has_access, has_connection_access from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.env import ( AIOHTTP_CLIENT_TIMEOUT, @@ -144,25 +144,13 @@ def get_updated_tool_function(function: Callable, extra_params: dict): return function -def has_tool_server_access( - user: UserModel, server_connection: dict, user_group_ids: set = None -) -> bool: - """Check if user has access to a tool server (MCP or OpenAPI).""" - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: - return True - - if user_group_ids is None: - user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} - - server_config = server_connection.get("config", {}) - access_grants = server_config.get("access_grants", []) - return has_access(user.id, "read", access_grants, user_group_ids) - - async def get_tools( request: Request, tool_ids: list[str], user: UserModel, extra_params: dict ) -> dict[str, dict]: """Load tools for the given tool_ids, checking access control.""" + if not tool_ids: + return {} + tools_dict = {} # Get user's group memberships for access control checks @@ -294,7 +282,7 @@ async def get_tools( ) # Check access control for tool server - if not has_tool_server_access( + if not has_connection_access( user, tool_server_connection, user_group_ids ): log.warning( @@ -391,7 +379,7 @@ async def tool_function(**kwargs): tool_dict = { "tool_id": tool_id, "callable": callable, - "spec": spec, + "spec": clean_openai_tool_schema(spec), # Misc info "type": "external", } @@ -560,6 +548,7 @@ def is_builtin_tool_enabled(category: str) -> bool: # Generate spec from function pydantic_model = convert_function_to_pydantic_model(func) spec = convert_pydantic_model_to_openai_function_spec(pydantic_model) + spec = clean_openai_tool_schema(spec) tools_dict[func.__name__] = { "tool_id": f"builtin:{func.__name__}", @@ -668,6 +657,44 @@ def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]: return model +def clean_properties(schema: dict): + if not isinstance(schema, dict): + return + + if "anyOf" in schema: + non_null_types = [t for t in schema["anyOf"] if t.get("type") != "null"] + if len(non_null_types) == 1: + schema.update(non_null_types[0]) + del schema["anyOf"] + else: + schema["anyOf"] = non_null_types + + if "default" in schema and schema["default"] is None: + del schema["default"] + + # fix missing type + if "type" not in schema and "anyOf" not in schema and "properties" not in schema: + schema["type"] = "string" + + if "properties" in schema: + for prop_name, prop_schema in schema["properties"].items(): + clean_properties(prop_schema) + + if "items" in schema: + clean_properties(schema["items"]) + + +def clean_openai_tool_schema(spec: dict) -> dict: + import copy + + cleaned_spec = copy.deepcopy(spec) + + if "parameters" in cleaned_spec: + clean_properties(cleaned_spec["parameters"]) + + return cleaned_spec + + def get_functions_from_tool(tool: object) -> list[Callable]: return [ getattr(tool, func) @@ -690,7 +717,9 @@ def get_tool_specs(tool_module: object) -> list[dict]: ) specs = [ - convert_pydantic_model_to_openai_function_spec(function_model) + clean_openai_tool_schema( + convert_pydantic_model_to_openai_function_spec(function_model) + ) for function_model in function_models ] @@ -766,7 +795,7 @@ def convert_openapi_to_tool_payload(openapi_spec): f". Possible values: {', '.join(param_schema.get('enum'))}" ) param_property = { - "type": param_schema.get("type"), + "type": param_schema.get("type") or "string", "description": description, } @@ -774,6 +803,11 @@ def convert_openapi_to_tool_payload(openapi_spec): if param_schema.get("type") == "array" and "items" in param_schema: param_property["items"] = param_schema["items"] + # Filter out None values to prevent schema validation errors + param_property = { + k: v for k, v in param_property.items() if v is not None + } + tool["parameters"]["properties"][param_name] = param_property if param.get("required"): tool["parameters"]["required"].append(param_name) @@ -837,6 +871,180 @@ async def get_tool_servers(request: Request): return tool_servers +async def get_terminal_cwd( + base_url: str, + headers: dict, + cookies: Optional[dict] = None, +) -> Optional[str]: + """Fetch the current working directory from a terminal server.""" + try: + cwd_url = f"{base_url.rstrip('/')}/files/cwd" + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=5), + trust_env=True, + ) as session: + async with session.get( + cwd_url, headers=headers, cookies=cookies or {} + ) as resp: + if resp.status == 200: + data = await resp.json() + return data.get("cwd") + except Exception as e: + log.debug(f"Failed to fetch terminal CWD: {e}") + return None + + +async def set_terminal_servers(request: Request): + """Load and cache OpenAPI specs from all TERMINAL_SERVER_CONNECTIONS.""" + connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + + # Build server configs compatible with get_tool_servers_data + # Terminal connections store id/name at top level; translate to info dict + server_configs = [] + for connection in connections: + if not connection.get("url"): + continue + + enabled = connection.get("enabled", True) + + server_configs.append( + { + "url": connection.get("url", ""), + "key": connection.get("key", ""), + "auth_type": connection.get("auth_type", "bearer"), + "path": connection.get("path", "/openapi.json"), + "spec_type": "url", + # get_tool_servers_data reads config.enable to filter active servers + "config": {"enable": enabled}, + "info": { + "id": connection.get("id", ""), + "name": connection.get("name", ""), + }, + } + ) + + request.app.state.TERMINAL_SERVERS = await get_tool_servers_data(server_configs) + + if request.app.state.redis is not None: + await request.app.state.redis.set( + "terminal_servers", json.dumps(request.app.state.TERMINAL_SERVERS) + ) + + return request.app.state.TERMINAL_SERVERS + + +async def get_terminal_servers(request: Request): + """Return cached terminal server specs, loading if needed.""" + terminal_servers = [] + if request.app.state.redis is not None: + try: + terminal_servers = json.loads( + await request.app.state.redis.get("terminal_servers") + ) + request.app.state.TERMINAL_SERVERS = terminal_servers + except Exception as e: + log.error(f"Error fetching terminal_servers from Redis: {e}") + + if not terminal_servers: + terminal_servers = await set_terminal_servers(request) + + return terminal_servers + + +async def get_terminal_tools( + request: Request, + terminal_id: str, + user: UserModel, + extra_params: dict, +) -> dict[str, dict]: + """Resolve tools for a terminal server identified by terminal_id. + + - Finds the connection in TERMINAL_SERVER_CONNECTIONS + - Checks access_grants + - Loads specs from cache + - Builds callables that route through the terminal proxy + """ + connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] + connection = next((c for c in connections if c.get("id") == terminal_id), None) + if connection is None: + log.warning(f"Terminal server not found: {terminal_id}") + return {} + + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + if not has_connection_access(user, connection, user_group_ids): + log.warning(f"Access denied to terminal {terminal_id} for user {user.id}") + return {} + + # Find the cached spec data for this terminal + terminal_servers = await get_terminal_servers(request) + server_data = next( + (s for s in terminal_servers if s.get("id") == terminal_id), None + ) + if server_data is None: + log.warning(f"Terminal server spec not found for {terminal_id}") + return {} + + specs = server_data.get("specs", []) + if not specs: + return {} + + # Build auth headers + auth_type = connection.get("auth_type", "bearer") + cookies = {} + headers = {"Content-Type": "application/json", "X-User-Id": user.id} + + if auth_type == "bearer": + headers["Authorization"] = f"Bearer {connection.get('key', '')}" + elif auth_type == "session": + cookies = request.cookies + headers["Authorization"] = f"Bearer {request.state.token.credentials}" + elif auth_type == "system_oauth": + cookies = request.cookies + oauth_token = extra_params.get("__oauth_token__", None) + if oauth_token: + headers["Authorization"] = f"Bearer {oauth_token.get('access_token', '')}" + # auth_type == "none": no Authorization header + + terminal_cwd = await get_terminal_cwd(connection.get("url", ""), headers, cookies) + + tools_dict = {} + for spec in specs: + function_name = spec["name"] + + # Inject CWD into run_command description + tool_spec = clean_openai_tool_schema(spec) + if function_name == "run_command" and terminal_cwd: + tool_spec["description"] = ( + tool_spec.get("description", "") + + f"\n\nThe current working directory is: {terminal_cwd}" + ) + + def make_tool_function(fn_name, srv_data, hdrs, cks): + async def tool_function(**kwargs): + return await execute_tool_server( + url=srv_data["url"], + headers=hdrs, + cookies=cks, + name=fn_name, + params=kwargs, + server_data=srv_data, + ) + + return tool_function + + tool_function = make_tool_function(function_name, server_data, headers, cookies) + callable = get_async_tool_function_and_apply_extra_params(tool_function, {}) + + tools_dict[function_name] = { + "tool_id": f"terminal:{terminal_id}", + "callable": callable, + "spec": tool_spec, + "type": "terminal", + } + + return tools_dict + + async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, Any]: _headers = { "Accept": "application/json", @@ -953,6 +1161,11 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, log.error(f"Failed to connect to {url} OpenAPI tool server") continue + # Guard against invalid or non-OpenAPI specs (e.g., MCP-style configs) + if not isinstance(response, dict) or "paths" not in response: + log.warning(f"Invalid OpenAPI spec from {url}: missing 'paths'") + continue + response = { "openapi": response, "info": response.get("info", {}), diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 90951e5265..07a70ee63b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -26,33 +26,40 @@ We appreciate the community's interest in identifying potential vulnerabilities. 2. **No Vague Reports**: Submissions such as "I found a vulnerability" without any details will be treated as spam and will not be accepted. -3. **In-Depth Understanding Required**: Reports must reflect a clear understanding of the codebase and provide specific details about the vulnerability, including the affected components and potential impacts. +3. **In-Depth Understanding**: Reports must reflect a clear understanding of the codebase, how Open WebUI is used and provide specific details about the vulnerability, including the affected components and potential impacts. 4. **Proof of Concept (PoC) is Mandatory**: Each submission must include a well-documented proof of concept (PoC) that demonstrates the vulnerability. If confidentiality is a concern, reporters are encouraged to create a private fork of the repository and share access with the maintainers. Reports lacking valid evidence may be disregarded. > [!NOTE] > A PoC (Proof of Concept) is a **demonstration of exploitation of a vulnerability**. Your PoC must show: > -> 1. What security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation) -> 2. How this vulnerability was abused +> 1. Exactly what security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation) +> 2. How this vulnerability is triggered/abused (inputs, endpoints, UI actions, etc.) > 3. What actions the attacker can now perform -> -> **Examples of valid PoCs:** -> -> - Step-by-step reproduction instructions with exact commands -> - Complete exploit code with detailed execution instructions -> - Screenshots/videos demonstrating the exploit (supplementary to written steps) +> 4. What data/action becomes possible that should not be possible +> 5. Exact steps and commands to reproduce (copy/paste runnable where possible), expected result vs. actual result > > **Failure to provide a reproducible PoC may lead to closure of the report** > > We will notify you, if we struggle to reproduce the exploit using your PoC to allow you to improve your PoC. +> If we cannot reproduce the issue from your PoC, we may ask for clarification or improvements > However, if we repeatedly cannot reproduce the exploit using the PoC, the report may be closed. -5. **Required Patch or Actionable Remediation Plan Submission**: Along with the PoC, reporters must provide a patch or some actionable steps to remediate the identified vulnerability. This helps us evaluate and implement fixes rapidly. +5. **Remediation is required**: + +Along with the PoC, you must provide **either**: + +1. **A patch/PR**, **or** +2. **a remediation plan** ("actionable steps") that a maintainer can apply without guesswork. + +Your remediation guidance can include, for example: -6. **Streamlined Merging Process**: When vulnerability reports meet the above criteria, we can consider provided patches for immediate merging, similar to regular pull requests. Well-structured and thorough submissions will expedite the process of enhancing our security. +- The **likely root cause** (what's wrong and where) +- The **location(s)** to change (file/module/function names if known) +- The **recommended fix approach** (validation/sanitization rules, auth checks, safe defaults, etc.) +- Any **security tradeoffs** or potential regressions to watch for -7. **Default Configuration Testing**: All vulnerability reports MUST be tested and reproducible using Open WebUI's out-of-the-box default configuration. Claims of vulnerabilities that only manifest with explicitly weakened security settings may be discarded, unless they are covered by the following exception: +6. **Default Configuration Testing**: All vulnerability reports must be tested and reproducible using Open WebUI's out-of-the-box default configuration. Claims of vulnerabilities that only manifest with explicitly weakened security settings may be discarded, unless they are covered by the following exception: > [!NOTE] > **Note**: If you believe you have found a security issue that @@ -61,26 +68,26 @@ We appreciate the community's interest in identifying potential vulnerabilities. > 2. represents a genuine bypass of intended security controls, **or** > 3. works only with non-default configurations, **but the configuration in question is likely to be used by production deployments**, **then we absolutely want to hear about it.** This policy is intended to filter configuration issues and deployment problems, not to discourage legitimate security research. -8. **Threat Model Understanding Required**: Reports must demonstrate understanding of Open WebUI's self-hosted, authenticated, role-based access control architecture. Comparing Open WebUI to services with fundamentally different security models without acknowledging the architectural differences may result in report rejection. +7. **Threat Model Understanding Required**: Reports must demonstrate understanding of Open WebUI's self-hosted, authenticated, extensible, role-based access control architecture. Comparing Open WebUI to services with fundamentally different security models without acknowledging the architectural differences may result in report rejection. -9. **CVSS Scoring Accuracy:** If you include a CVSS score with your report, it must accurately reflect the vulnerability according to CVSS methodology. Common errors include 1) rating PR:N (None) when authentication is required, 2) scoring hypothetical attack chains instead of the actual vulnerability, or 3) inflating severity without evidence. **We will adjust inaccurate CVSS scores.** Intentionally inflated scores may result in report rejection. +8. **CVSS Scoring Accuracy:** If you include a CVSS score with your report, it must accurately reflect the vulnerability according to CVSS methodology. Common errors include 1) rating PR:N (None) when authentication is required, 2) scoring hypothetical attack chains instead of the actual vulnerability, or 3) inflating severity without evidence. **We will adjust inaccurate CVSS scores.** Intentionally inflated scores may result in report rejection. > [!WARNING] > > **Using CVE Precedents:** If you cite other CVEs to support your report, ensure they are **genuinely comparable** in vulnerability type, threat model, and attack vector. Citing CVEs from different product categories, different vulnerability classes or different deployment models will lead us to suspect the use of AI in your report. -10. **Admin Actions Are Out of Scope:** Vulnerabilities that require an administrator to actively perform unsafe actions are **not considered valid vulnerabilities**. Admins have full system control and are expected to understand the security implications of their actions and configurations. This includes but is not limited to: adding malicious external servers (models, tools, webhooks), pasting untrusted code into Functions/Tools, or intentionally weakening security settings. **Reports requiring admin negligence or social engineering of admins may be rejected.** +9. **Admin Actions Are Out of Scope:** Vulnerabilities that require an administrator to actively perform unsafe actions are **not considered valid vulnerabilities**. **Admins have full system control and are expected to understand the security implications of their actions and configurations**. This includes but is not limited to: adding malicious external servers (models, tools, webhooks), pasting untrusted code into Functions/Tools, or intentionally weakening security settings. **Reports requiring admin negligence or social engineering of admins may be rejected.** > [!NOTE] > Similar to rule "Default Configuration Testing": If you believe you have found a vulnerability that affects admins and is NOT caused by admin negligence or intentionally malicious actions, > **then we absolutely want to hear about it.** This policy is intended to filter social engineering attacks on admins, malicious plugins being deployed by admins and similar malicious actions, not to discourage legitimate security research. -11. **AI report transparency:** Due to an extreme spike in AI-aided vulnerability reports **YOU MUST DISCLOSE if AI was used in any capacity** - whether for writing the report, generating the PoC, or identifying the vulnerability. If AI helped you in any way shape or form in the creation of the report, PoC or finding the vulnerability, you MUST disclose it. +10. **AI report transparency:** Due to an extreme spike in AI-aided vulnerability reports **you MUST DISCLOSE if AI was used in any capacity** - whether for writing the report, generating the PoC, or identifying the vulnerability. If AI helped you in any way shape or form in the creation of the report, PoC or finding the vulnerability, you MUST disclose it. > [!NOTE] > AI-aided vulnerability reports **will not be rejected by us by default**. But: > -> - If we suspect you used AI (but you did not disclose it to us), we will be asking tough follow-up questions to validate your understanding of the reported vulnerability and Open WebUI itself. +> - If we suspect you used AI (but you did not disclose it to us), we will be asking thorough follow-up questions to validate your understanding of the reported vulnerability and Open WebUI itself. > - If we suspect you used AI (but you did not disclose it to us) **and** your report ends up being invalid/not a vulnerability/not reproducible, then you **may be banned** from reporting future vulnerabilities. > > This measure was necessary due to the extreme rise in clearly AI written vulnerability reports, where the vast majority of them @@ -91,9 +98,9 @@ We appreciate the community's interest in identifying potential vulnerabilities. > - violated any of the rules outlined here > - had a clear lack of understanding of Open WebUI > - wrote comments with conflicting information -> - used illogical arguments +> - used illogical and conflicting arguments -**Non-compliant submissions will be closed, and repeat extreme violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users. +**Non-compliant submissions will be closed, and repeat or extreme violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users. ## Where to report the vulnerability @@ -119,12 +126,12 @@ If your concern does not meet the vulnerability requirements outlined above, is - Feature requests for optional security enhancements (2FA, audit logging, etc.) - General security questions about production deployment -Please use the adequate channel for your specific issue - e.g. best-practice guidance or additional documentation needs into the Documentation Repository, and feature requests into the Main Repository as an issue or discussion. +Please use the adequate channel for your specific issue - e.g. best-practice guidance or **dditional documentation needs into the [Documentation Repository](https://github.com/open-webui/docs)**, and **feature requests into the Main Repository as an issue or discussion**. We regularly audit our internal processes and system architecture for vulnerabilities using a combination of automated and manual testing techniques. We are also planning to implement SAST and SCA scans in our project soon. -For any other immediate concerns, please create an issue in our [issue tracker](https://github.com/open-webui/open-webui/issues) or contact our team on [Discord](https://discord.gg/5rJgQTnV4s). +For any other immediate concerns and questions, please create an issue in our [issue tracker](https://github.com/open-webui/open-webui/issues) or contact our team on [Discord](https://discord.gg/5rJgQTnV4s). --- -_Last updated on **2025-11-06**._ +_Last updated on **2026-02-25**._ diff --git a/package-lock.json b/package-lock.json index abb5f71c2e..23d9defff1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.8.5.1", + "version": "0.8.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.8.5.1", + "version": "0.8.7.1", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -38,6 +38,7 @@ "@tiptap/pm": "^3.0.7", "@tiptap/starter-kit": "^3.0.7", "@tiptap/suggestion": "^3.4.2", + "@xterm/xterm": "^6.0.0", "@xyflow/svelte": "^0.1.19", "alpinejs": "^3.15.0", "async": "^3.2.5", @@ -4480,6 +4481,15 @@ "node": ">=10.0.0" } }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/@xyflow/svelte": { "version": "0.1.19", "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-0.1.19.tgz", diff --git a/package.json b/package.json index 96c1d96a11..75773b2545 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.8.5.1", + "version": "0.8.7.1", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -82,6 +82,7 @@ "@tiptap/pm": "^3.0.7", "@tiptap/starter-kit": "^3.0.7", "@tiptap/suggestion": "^3.4.2", + "@xterm/xterm": "^6.0.0", "@xyflow/svelte": "^0.1.19", "alpinejs": "^3.15.0", "async": "^3.2.5", diff --git a/src/app.css b/src/app.css index 8d2d6972f4..5eea66bebc 100644 --- a/src/app.css +++ b/src/app.css @@ -332,12 +332,9 @@ input[type='number'] { } .codespan { - color: #eb5757; - border-width: 0px; - padding: 3px 8px; - font-size: 0.8em; - font-weight: 600; - @apply rounded-md dark:bg-gray-800 bg-gray-100 mx-0.5; + padding: 0.15rem 0.3rem; + font-size: 0.85em; + @apply font-mono rounded-md text-gray-800 bg-gray-100 dark:text-gray-200 dark:bg-gray-800 mx-0.5; } .svelte-flow { @@ -566,12 +563,9 @@ input[type='number'] { } .tiptap p code { - color: #eb5757; - border-width: 0px; - padding: 3px 8px; - font-size: 0.8em; - font-weight: 600; - @apply rounded-md dark:bg-gray-800 bg-gray-50 mx-0.5; + padding: 0.15rem 0.3rem; + font-size: 0.85em; + @apply font-mono rounded-md text-gray-800 bg-gray-50 dark:text-gray-200 dark:bg-gray-800 mx-0.5; } /* Code styling */ diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index 695d9e0d2e..0998f1aafc 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -172,6 +172,63 @@ export const setToolServerConnections = async (token: string, connections: objec return res; }; +export const getTerminalServerConnections = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const setTerminalServerConnections = async (token: string, connections: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...connections + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const verifyToolServerConnection = async (token: string, connection: object) => { let error = null; diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts new file mode 100644 index 0000000000..5815bcd3c8 --- /dev/null +++ b/src/lib/apis/terminal/index.ts @@ -0,0 +1,195 @@ +export type FileEntry = { + name: string; + type: 'file' | 'directory'; + size?: number; + modified?: number; +}; + +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export type TerminalServer = { + id: string; + url: string; + name: string; +}; + +export const getTerminalServers = async (token: string): Promise => { + const res = await fetch(`${WEBUI_API_BASE_URL}/terminals/`, { + headers: { + Authorization: `Bearer ${token}` + } + }).catch(() => null); + if (!res || !res.ok) return []; + return res.json().catch(() => []); +}; + +export const getCwd = async (baseUrl: string, apiKey: string): Promise => { + const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` } + }).catch(() => null); + if (!res || !res.ok) return null; + const json = await res.json().catch(() => null); + return json?.cwd ?? null; +}; + +export const listFiles = async ( + baseUrl: string, + apiKey: string, + path: string = '/' +): Promise => { + // The endpoint uses `directory` as the query param name + const url = `${baseUrl.replace(/\/$/, '')}/files/list?directory=${encodeURIComponent(path)}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error('open-terminal listFiles error:', err); + return null; + }); + return res?.entries ?? null; +}; + +export const readFile = async ( + baseUrl: string, + apiKey: string, + path: string +): Promise => { + const url = `${baseUrl.replace(/\/$/, '')}/files/read?path=${encodeURIComponent(path)}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` } + }).catch((err) => { + console.error('open-terminal readFile error:', err); + return null; + }); + + if (!res || !res.ok) return null; + + const contentType = res.headers.get('content-type') ?? ''; + if (contentType.startsWith('image/') || contentType.startsWith('application/octet')) { + // Binary — return a placeholder + return `[Binary file: ${contentType}]`; + } + + // Text files: endpoint returns JSON { path, total_lines, content } + // Binary image files: endpoint returns raw bytes (handled above) + const json = await res.json().catch(() => null); + return json?.content ?? null; +}; + +export const downloadFileBlob = async ( + baseUrl: string, + apiKey: string, + path: string +): Promise<{ blob: Blob; filename: string } | null> => { + const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` } + }).catch(() => null); + + if (!res || !res.ok) return null; + + const filename = path.split('/').pop() ?? 'file'; + const blob = await res.blob(); + return { blob, filename }; +}; + +export const uploadToTerminal = async ( + baseUrl: string, + apiKey: string, + directory: string, + file: File +): Promise<{ path: string; size: number } | null> => { + const url = `${baseUrl.replace(/\/$/, '')}/files/upload?directory=${encodeURIComponent(directory)}`; + const body = new FormData(); + body.append('file', file); + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}` }, + body + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error('open-terminal uploadToTerminal error:', err); + return null; + }); + return res; +}; + +export const createDirectory = async ( + baseUrl: string, + apiKey: string, + path: string +): Promise<{ path: string } | null> => { + const url = `${baseUrl.replace(/\/$/, '')}/files/mkdir`; + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ path }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error('open-terminal createDirectory error:', err); + return null; + }); + return res; +}; + +export const deleteEntry = async ( + baseUrl: string, + apiKey: string, + path: string +): Promise<{ path: string; type: string } | null> => { + const url = `${baseUrl.replace(/\/$/, '')}/files/delete?path=${encodeURIComponent(path)}`; + const res = await fetch(url, { + method: 'DELETE', + headers: { Authorization: `Bearer ${apiKey}` } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error('open-terminal deleteEntry error:', err); + return null; + }); + return res; +}; + +export const setCwd = async ( + baseUrl: string, + apiKey: string, + path: string +): Promise<{ cwd: string } | null> => { + const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`; + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ path }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.error('open-terminal setCwd error:', err); + return null; + }); + return res; +}; diff --git a/src/lib/components/AddTerminalServerModal.svelte b/src/lib/components/AddTerminalServerModal.svelte new file mode 100644 index 0000000000..233eada3dc --- /dev/null +++ b/src/lib/components/AddTerminalServerModal.svelte @@ -0,0 +1,353 @@ + + + +
+
+

+ {#if edit} + {$i18n.t('Edit Terminal Connection')} + {:else} + {$i18n.t('Add Terminal Connection')} + {/if} +

+ + +
+ +
+
+
+
+
+
+
+ +
+ +
+ +
+
+ {#if admin} +
+
+ +
+
+ +
+
+ {/if} +
+ +
+
+
+ +
+ +
+ +
+
+
+ +
+ + + {#if admin} + + {/if} +
+ + {#if showAdvanced} +
+
+
+
+
+ {$i18n.t('OpenAPI Spec')} +
+
+
+ +
+
+
+ + +
+
+
+ +
+ {$i18n.t(`WebUI will make requests to "{{url}}"`, { + url: path.includes('://') + ? path + : `${url}${path.startsWith('/') ? '' : '/'}${path}` + })} +
+
+
+ {/if} + +
+
+
+
+
+ {$i18n.t('Auth')} +
+
+
+ +
+
+ +
+ +
+ {#if auth_type === 'bearer'} + + {:else if auth_type === 'none'} +
+ {$i18n.t('No authentication')} +
+ {:else if auth_type === 'session'} +
+ {$i18n.t('Forwards system user session credentials to authenticate')} +
+ {:else if auth_type === 'system_oauth'} +
+ {$i18n.t('Forwards system user OAuth access token to authenticate')} +
+ {/if} +
+
+
+
+ +
+
+
+ {#if edit} + + {/if} + + +
+
+
+
+
+
+
+
+ + diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index a46f858501..db61a58e77 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -19,7 +19,8 @@ import Tags from './common/Tags.svelte'; import { getToolServerData } from '$lib/apis'; import { verifyToolServerConnection, registerOAuthClient } from '$lib/apis/configs'; - import AccessControl from './workspace/common/AccessControl.svelte'; + import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte'; + import LockClosed from '$lib/components/icons/LockClosed.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; import Textarea from './common/Textarea.svelte'; @@ -58,6 +59,8 @@ let enable = true; let loading = false; + let showAdvanced = false; + let showAccessControlModal = false; const registerOAuthClientHandler = async () => { if (url === '') { @@ -439,30 +442,94 @@ }} >
- {#if !direct} -
-
-
{$i18n.t('Type')}
+
+
+
{$i18n.t('Type')}
+ +
+ +
+
+
-
- +
+
+
+ {/if} +
+ +
+ + +
+
- {/if} +
@@ -520,81 +587,6 @@
- {#if ['', 'openapi'].includes(type)} -
-
-
-
-
- {$i18n.t('OpenAPI Spec')} -
-
-
- -
-
- -
- -
- {#if spec_type === 'url'} -
- - -
- {:else if spec_type === 'json'} -
- -