Chat search built each result row with ChatTitleIdResponse(**chat.model_dump(), ...), which recursively copies the entire chat blob per row only for the constructor to ignore everything except id, title and timestamps: a 60-row search page deep-copied up to 60 full conversations. The folder listing, archived and export endpoints and every single-chat response did the same dump-and-revalidate dance via ChatResponse(**chat.model_dump()).
Search rows are now built from the five fields the response actually has (the snippet helper receives the blob by reference as before), and all 18 ChatResponse constructions use ChatResponse.model_validate(chat, from_attributes=True), which reads the fields off the already-validated ChatModel without copying the blob.
Benchmark (~500 KB chat blob):
| metric | before | after |
| --- | --- | --- |
| search result row | 0.05 ms | 0.003 ms |
| ChatResponse construction | 0.05 ms | 0.003 ms |
| per search page (60 rows) | 3 ms | 0.2 ms |
Beyond CPU, each converted row also stops materializing a second full copy of the conversation in memory while the page is being built.
Functionally verified: both construction styles produce identical model_dump() output for ChatResponse (including defaulted fields absent on ChatModel) and for search rows including the snippet.
DELETE /api/v1/chats/{id} called stop_item_tasks(id) before checking the
caller's chat.delete permission or ownership of the target chat. An
authenticated user who knew another user's chat id could therefore cancel that
chat's in-flight generation (streaming response, title or tag generation) even
though the deletion was then rejected. The chat id is discoverable through
legitimate read-only access to a shared chat or folder.
Reorder the handler to authorize first (admin, or owner holding chat.delete) and
only then cancel tasks and delete, matching the dedicated task-stop endpoint.
Legitimate deletions are unchanged; an unauthorized caller now returns 404 or 401
before any cancellation. The duplicated tag-cleanup and event-publish blocks are
merged.
Co-authored-by: GabrielGomesAL <193945687+GabrielGomesAL@users.noreply.github.com>
When an audio file is split into multiple chunks for transcription, transcribe() collected the per-chunk results with asyncio.as_completed(), which yields results in completion order rather than submission order. Whenever a later chunk finished transcribing before an earlier one, the assembled transcript was scrambled, for example the second half of a recording appearing before the first, and the stored file content plus everything downstream (file preview, full-context retrieval) read out of chronological order. This change awaits the chunk tasks with asyncio.gather() instead, which runs them just as concurrently but returns the results in the order the tasks were created, i.e. chunk_paths order. The existing error handling and chunk cleanup are unchanged: an HTTPException from a chunk is re-raised as is and any other error is wrapped in a 500. Fixes#27143
Add self-hosted OpenSERP as a web search engine option. OpenSERP
provides browser-rendered search across Google, Bing, Yandex, Baidu,
DuckDuckGo, and Ecosia with no API keys required.
- New module: retrieval/web/openserp.py (async, uses aiohttp session pool)
- Config: OPENSERP_BASE_URL env var (defaults to http://localhost:7070)
- Routing: search_web() dispatch for 'openserp' engine
- Follows existing patterns (searxng, brave)
Co-authored-by: crustopher-lgtm <crustopher-lgtm@users.noreply.github.com>
The OAuth / OIDC section in Admin Settings > Authentication had no
enable/disable switch, unlike the LDAP section above it. Add one that
persists via the existing Save flow and actually gates OAuth sign-in,
mirroring how the LDAP toggle works.
- config: new ENABLE_OAUTH persistent config ('oauth.enable'), defaulting
to True so existing deployments with a provider configured keep working.
- oauth: expose ENABLE_OAUTH via the OAuth runtime config and reject the
login and callback handlers with 404 when it is disabled.
- /api/config: report no OAuth providers when disabled so the login page
hides the OAuth buttons (and cannot auto-redirect), without clearing the
admin's provider configuration.
- auths: expose ENABLE_OAUTH through the admin OAuth config get/update
endpoints (OAuthConfigForm + OAUTH_CONFIG_KEYS).
- Authentication.svelte: bind the OAuth / OIDC header Switch to the
persisted oauthConfig.ENABLE_OAUTH and collapse the section when off,
matching the LDAP header (size, weight, alignment).
The Socket.IO handshake and the terminal WebSocket route each reimplement JWT authentication instead of going through the HTTP dependency chain. Both verified that the token decoded, that it had not been revoked, and that the user row existed, but neither applied the role check that `get_verified_user` enforces on every HTTP route, so any role outside `user` and `admin` was accepted.
That splits authorization across two planes. Deactivating an account by setting its role to `pending` takes effect immediately over HTTP, which returns 401, while the same JWT still opens a WebSocket. Changing a role disconnects the account's live sockets but does not revoke its token, so the client simply reconnects and gets a fresh session. Until the token expires, four weeks by default, a deactivated account keeps its channel rooms and can still read and write any note it holds an access grant on through the collaborative document handlers.
Resolve the user once, in `get_verified_user_by_token`, and route both WebSocket entry points through it. The role set moves into `VERIFIED_USER_ROLES` so the HTTP and WebSocket gates cannot drift apart, which is the underlying cause rather than either call site on its own. This also replaces five copies of the decode, revocation check and user lookup sequence.
`user-join` now resolves the user instead of reusing the identity cached in `SESSION_POOL`, which costs one extra query per handshake. Gating on the cached role would make the authorization decision depend on every future role-mutation path remembering to tear down the session pool, and that is precisely the invariant that failed here.
Since the config refactor, get_web_loader dispatched on the WEB_LOADER_ENGINE module constant, which is read from the environment once at import time. The engine selected in the Admin UI is stored under web.loader.engine in the config table but was never consulted, so UI-configured loader engines (external, playwright, firecrawl, tavily, microsoft_web_iq) were silently ignored and the built-in SafeWebBaseLoader always fetched pages directly. The same applied to the per-engine settings such as the external web loader URL and API key. This breaks egress-restricted deployments that rely on an external web loader: pages are fetched directly from the container and fail with errors like "Network is unreachable" even though an external loader is configured.
Pass the DB-backed loader settings into get_web_loader from both call sites, web search in process_web_search and web fetch via get_loader, and resolve every engine setting from them, keeping the module-level env constants as the fallback for keys that were never saved. Also initialise WebLoaderClass so an unknown engine raises the intended ValueError instead of an UnboundLocalError.
Fixes#26747
get_chat_by_id sent admins down a branch that returned the chat only when
ENABLE_ADMIN_CHAT_ACCESS was on, or the chat was internal, and never fell
through to the access-grant and shared-folder checks. With the setting off,
an admin was therefore denied a chat that had been deliberately shared with
them, either directly or through a shared folder, while any non-admin holding
the same grant could open it. The admin role removed access the user had been
given rather than only closing the admin-only path.
Try the admin path first, then let everyone fall through to the grant and
folder checks. ENABLE_ADMIN_CHAT_ACCESS=false still closes the admin-only
route to other users' chats, and internal chats stay reachable.
AccessGrantsPermissions only declared allow_users, so allow_groups was
missing from the model backing the default user permissions endpoints.
Pydantic ignores undeclared fields, so POST /users/default/permissions
dropped allow_groups before model_dump(), it never reached the persisted
user.permissions config, and fill_missing_permissions restored it to the
default on the next read. Turning "Allow Sharing With Groups" off in
Admin Settings silently reverted to on, while the same toggle worked when
set per group, since group permissions are stored as a plain dict.
GET /users/default/permissions and /users/default/permissions/defaults
dropped it from their responses for the same reason.
Declare allow_groups on the model so it round-trips, matching the
access_grants block in DEFAULT_USER_PERMISSIONS. It defaults to True, so
existing payloads that omit it are unaffected.
The channel message update and delete handlers enforced authorship only on group and dm channels. On standard channels the else branch accepted any caller holding write access on the channel, so a member who could post could also edit or delete messages authored by other members. Because the update form binds content, data and meta, and the model layer never touches message.user_id, an edited message kept the original author's attribution, so another member's message could be rewritten under their name.
Write access on a channel is the capability to post, not a moderation capability, and the frontend gates the edit and delete controls on authorship (message.user_id === user.id, or admin) for every channel type. The group and dm branch already encodes this with an explicit authorship check. Apply the same rule to the standard branch: the caller must hold write access on the channel and be the message author, unless they are an admin. Pinning is unchanged, since it is exposed to every member by design.
* feat: expose LDAP group sync settings in admin config
LDAP group synchronization was already wired into the login flow but its
settings (group management, auto-creation, and the group attribute) could
only be set via environment variables. OAuth, by contrast, exposes its
group-mapping settings through the admin config API and UI.
Bring LDAP to parity:
- Add enable_group_management, enable_group_creation and
attribute_for_groups to LdapServerConfig and LDAP_SERVER_CONFIG_KEYS so
the /admin/config/ldap/server endpoint reads and persists them.
- Add a "Group Mapping / Auto-Create Groups / Group Attribute" section to
the LDAP admin settings UI, mirroring the OAuth group-mapping controls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe
* fix: harden LDAP group sync config and login flow
Address review findings on the LDAP group-sync settings:
- ldap_auth: move the auto-create-groups call inside the try/except that
wraps group sync, so a group-creation error is logged instead of
bubbling to the broad handler and failing the whole login.
- update_ldap_server: reject saving with group management enabled but an
empty group attribute, which would otherwise make sync silently no-op
(mirrors the existing required-field validation).
- Authentication.svelte: merge the LDAP server config response into the
client defaults instead of replacing the object, so any key an older
backend omits keeps its default value.
Note: the empty-directory-groups behavior was reviewed and already
matches OAuth (both skip removal when no groups are returned), so it was
left unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe
* fix: default blank LDAP group attribute to memberOf before save
The Group Attribute field advertises "Default to memberOf", but the
backend now rejects an empty group attribute when group management is
enabled. Fall back to the memberOf default client-side when the field is
left blank, so the advertised default holds and the save isn't rejected.
The backend validation remains as defense-in-depth for direct API calls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe
* fix: initialize LDAP port default as null instead of empty string
The backend LdapServerConfig types port as `int | None`, but the frontend
initialized it to an empty string. If a save carried that default (e.g.
when the backend response omits port under version skew), Pydantic would
reject the empty string. `null` matches the model and is also what the
type="number" input yields when the field is empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe
* fix: parse LDAP group DNs correctly instead of splitting on commas
Group CN extraction split the DN on raw commas and sliced off "CN=",
which mangles any group whose name contains an escaped separator (e.g.
"CN=Sales\, EMEA,OU=...") into a truncated, wrong name that then fails to
match the intended Open WebUI group. Use ldap3's parse_dn to split the DN
respecting RFC 4514 escaping, and unescape the resulting value so the CN
matches what an administrator sees.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe
* chore: address review feedback on _unescape_ldap_dn_value
Trim the docstring and rename the loop index to a more descriptive name
(i -> pos) per review feedback on the group DN unescaping helper. No
behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtCvvQ7dcadoufbRpCKcpe
---------
Co-authored-by: Claude <noreply@anthropic.com>
The per-request Ollama handlers (chat, generate, embed, embeddings,
and the OpenAI-compat completions/chat-completions/messages/responses
endpoints) fetched 'ollama.api_configs' up to three times and
'ollama.base_urls' separately within a single request — the .get()
default-argument pattern made the second api_configs fetch
unconditional, and get_api_key() triggered a third. Up to four
sequential SELECTs per request collapse to one.
A new get_ollama_connection_config() helper fetches base_urls and
api_configs together in one batched Config.get_many where both are
needed; handlers that only need api_configs fetch it once into a
local. Admin operations (pull/push/copy/delete) and the TTL-cached
model-list path are deliberately left untouched.
Resolution semantics (str(idx) key first, url-key legacy fallback,
same defaults) are unchanged.
Claude-Session: https://claude.ai/code/session_01MHg5zs1VBjvRWQ54qHpfYD
Co-authored-by: Claude <noreply@anthropic.com>
The Automatic1111 branch of the image generation route called set_image_model whenever the request carried a model field. On this backend set_image_model is not request-scoped: it persists image_generation.model to the global configuration and posts the new sd_model_checkpoint to the shared server, because Automatic1111 holds a single checkpoint instance-wide. A non-admin holding features.image_generation could therefore change the instance-wide image model and the shared backend checkpoint for every user by sending a model on an ordinary generation request, even though the setting is otherwise managed only through the admin-only image configuration route and the frontend never sends this field.
Gate the switch on an admin caller. A non-admin now generates on the currently configured checkpoint and the model field no longer mutates global state; admins retain per-request model switching here and through the image configuration route. Image editing is unaffected, as it selects its model per request without writing global configuration.
`sharing.folders` is present in `DEFAULT_USER_PERMISSIONS` but absent from the `SharingPermissions` response/update schema, so the admin default and group permission API silently drops it on every round-trip and the setting is never saved. Add `folders: bool = False`, matching the config default (`USER_PERMISSIONS_FOLDERS_ALLOW_SHARING`), restoring parity with `DEFAULT_USER_PERMISSIONS`.
Fixes#27120.