diff --git a/.env.example b/.env.example index d5c4250d67..e8ceddf087 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,11 @@ DOMAIN_CLIENT=http://localhost:3080 DOMAIN_SERVER=http://localhost:3080 NO_INDEX=true +# Use the address that is at most n number of hops away from the Express application. +# req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left. +# A value of 0 means that the first untrusted address would be req.socket.remoteAddress, i.e. there is no reverse proxy. +# Defaulted to 1. +TRUST_PROXY=1 #===============# # JSON Logging # @@ -292,6 +297,10 @@ MEILI_NO_ANALYTICS=true MEILI_HOST=http://0.0.0.0:7700 MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt +# Optional: Disable indexing, useful in a multi-node setup +# where only one instance should perform an index sync. +# MEILI_NO_SYNC=true + #==================================================# # Speech to Text & Text to Speech # #==================================================# @@ -389,7 +398,7 @@ FACEBOOK_CALLBACK_URL=/oauth/facebook/callback GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= GITHUB_CALLBACK_URL=/oauth/github/callback -# GitHub Eenterprise +# GitHub Enterprise # GITHUB_ENTERPRISE_BASE_URL= # GITHUB_ENTERPRISE_USER_AGENT= @@ -499,6 +508,16 @@ HELP_AND_FAQ_URL=https://librechat.ai # Google tag manager id #ANALYTICS_GTM_ID=user provided google tag manager id +#===============# +# REDIS Options # +#===============# + +# REDIS_URI=10.10.10.10:6379 +# USE_REDIS=true + +# USE_REDIS_CLUSTER=true +# REDIS_CA=/path/to/ca.crt + #==================================================# # Others # #==================================================# @@ -506,9 +525,6 @@ HELP_AND_FAQ_URL=https://librechat.ai # NODE_ENV= -# REDIS_URI= -# USE_REDIS= - # E2E_USER_EMAIL= # E2E_USER_PASSWORD= @@ -531,4 +547,4 @@ HELP_AND_FAQ_URL=https://librechat.ai #=====================================================# # OpenWeather # #=====================================================# -OPENWEATHER_API_KEY= \ No newline at end of file +OPENWEATHER_API_KEY= diff --git a/.github/ISSUE_TEMPLATE/LOCIZE_TRANSLATION_ACCESS_REQUEST.yml b/.github/ISSUE_TEMPLATE/LOCIZE_TRANSLATION_ACCESS_REQUEST.yml new file mode 100644 index 0000000000..49b01a814d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/LOCIZE_TRANSLATION_ACCESS_REQUEST.yml @@ -0,0 +1,42 @@ +name: Locize Translation Access Request +description: Request access to an additional language in Locize for LibreChat translations. +title: "Locize Access Request: " +labels: ["🌍 i18n", "🔑 access request"] +body: + - type: markdown + attributes: + value: | + Thank you for your interest in contributing to LibreChat translations! + Please fill out the form below to request access to an additional language in **Locize**. + + **🔗 Available Languages:** [View the list here](https://www.librechat.ai/docs/translation) + + **📌 Note:** Ensure that the requested language is supported before submitting your request. + - type: input + id: account_name + attributes: + label: Locize Account Name + description: Please provide your Locize account name (e.g., John Doe). + placeholder: e.g., John Doe + validations: + required: true + - type: input + id: language_requested + attributes: + label: Language Code (ISO 639-1) + description: | + Enter the **ISO 639-1** language code for the language you want to translate into. + Example: `es` for Spanish, `zh-Hant` for Traditional Chinese. + + **🔗 Reference:** [Available Languages](https://www.librechat.ai/docs/translation) + placeholder: e.g., es + validations: + required: true + - type: checkboxes + id: agreement + attributes: + label: Agreement + description: By submitting this request, you confirm that you will contribute responsibly and adhere to the project guidelines. + options: + - label: I agree to use my access solely for contributing to LibreChat translations. + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/QUESTION.yml b/.github/ISSUE_TEMPLATE/QUESTION.yml deleted file mode 100644 index c66e6baa3b..0000000000 --- a/.github/ISSUE_TEMPLATE/QUESTION.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Question -description: Ask your question -title: "[Question]: " -labels: ["❓ question"] -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to fill this! - - type: textarea - id: what-is-your-question - attributes: - label: What is your question? - description: Please give as many details as possible - placeholder: Please give as many details as possible - validations: - required: true - - type: textarea - id: more-details - attributes: - label: More Details - description: Please provide more details if needed. - placeholder: Please provide more details if needed. - validations: - required: true - - type: dropdown - id: browsers - attributes: - label: What is the main subject of your question? - multiple: true - options: - - Documentation - - Installation - - UI - - Endpoints - - User System/OAuth - - Other - - type: textarea - id: screenshots - attributes: - label: Screenshots - description: If applicable, add screenshots to help explain your problem. You can drag and drop, paste images directly here or link to them. - - type: checkboxes - id: terms - attributes: - label: Code of Conduct - description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md) - options: - - label: I agree to follow this project's Code of Conduct - required: true diff --git a/.github/configuration-release.json b/.github/configuration-release.json new file mode 100644 index 0000000000..68fe80ed8f --- /dev/null +++ b/.github/configuration-release.json @@ -0,0 +1,60 @@ +{ + "categories": [ + { + "title": "### ✨ New Features", + "labels": ["feat"] + }, + { + "title": "### 🌍 Internationalization", + "labels": ["i18n"] + }, + { + "title": "### 👐 Accessibility", + "labels": ["a11y"] + }, + { + "title": "### 🔧 Fixes", + "labels": ["Fix", "fix"] + }, + { + "title": "### ⚙️ Other Changes", + "labels": ["ci", "style", "docs", "refactor", "chore"] + } + ], + "ignore_labels": [ + "🔁 duplicate", + "📊 analytics", + "🌱 good first issue", + "🔍 investigation", + "🙏 help wanted", + "❌ invalid", + "❓ question", + "🚫 wontfix", + "🚀 release", + "version" + ], + "base_branches": ["main"], + "sort": { + "order": "ASC", + "on_property": "mergedAt" + }, + "label_extractor": [ + { + "pattern": "^(?:[^A-Za-z0-9]*)(feat|fix|chore|docs|refactor|ci|style|a11y|i18n)\\s*:", + "target": "$1", + "flags": "i", + "on_property": "title", + "method": "match" + }, + { + "pattern": "^(?:[^A-Za-z0-9]*)(v\\d+\\.\\d+\\.\\d+(?:-rc\\d+)?).*", + "target": "version", + "flags": "i", + "on_property": "title", + "method": "match" + } + ], + "template": "## [#{{TO_TAG}}] - #{{TO_TAG_DATE}}\n\nChanges from #{{FROM_TAG}} to #{{TO_TAG}}.\n\n#{{CHANGELOG}}\n\n[See full release details][release-#{{TO_TAG}}]\n\n[release-#{{TO_TAG}}]: https://github.com/#{{OWNER}}/#{{REPO}}/releases/tag/#{{TO_TAG}}\n\n---", + "pr_template": "- #{{TITLE}} by **@#{{AUTHOR}}** in [##{{NUMBER}}](#{{URL}})", + "empty_template": "- no changes" +} \ No newline at end of file diff --git a/.github/configuration-unreleased.json b/.github/configuration-unreleased.json new file mode 100644 index 0000000000..29eaf5e13b --- /dev/null +++ b/.github/configuration-unreleased.json @@ -0,0 +1,68 @@ +{ + "categories": [ + { + "title": "### ✨ New Features", + "labels": ["feat"] + }, + { + "title": "### 🌍 Internationalization", + "labels": ["i18n"] + }, + { + "title": "### 👐 Accessibility", + "labels": ["a11y"] + }, + { + "title": "### 🔧 Fixes", + "labels": ["Fix", "fix"] + }, + { + "title": "### ⚙️ Other Changes", + "labels": ["ci", "style", "docs", "refactor", "chore"] + } + ], + "ignore_labels": [ + "🔁 duplicate", + "📊 analytics", + "🌱 good first issue", + "🔍 investigation", + "🙏 help wanted", + "❌ invalid", + "❓ question", + "🚫 wontfix", + "🚀 release", + "version", + "action" + ], + "base_branches": ["main"], + "sort": { + "order": "ASC", + "on_property": "mergedAt" + }, + "label_extractor": [ + { + "pattern": "^(?:[^A-Za-z0-9]*)(feat|fix|chore|docs|refactor|ci|style|a11y|i18n)\\s*:", + "target": "$1", + "flags": "i", + "on_property": "title", + "method": "match" + }, + { + "pattern": "^(?:[^A-Za-z0-9]*)(v\\d+\\.\\d+\\.\\d+(?:-rc\\d+)?).*", + "target": "version", + "flags": "i", + "on_property": "title", + "method": "match" + }, + { + "pattern": "^(?:[^A-Za-z0-9]*)(action)\\b.*", + "target": "action", + "flags": "i", + "on_property": "title", + "method": "match" + } + ], + "template": "## [Unreleased]\n\n#{{CHANGELOG}}\n\n---", + "pr_template": "- #{{TITLE}} by **@#{{AUTHOR}}** in [##{{NUMBER}}](#{{URL}})", + "empty_template": "- no changes" +} \ No newline at end of file diff --git a/.github/workflows/generate-release-changelog-pr.yml b/.github/workflows/generate-release-changelog-pr.yml new file mode 100644 index 0000000000..c3bceae9de --- /dev/null +++ b/.github/workflows/generate-release-changelog-pr.yml @@ -0,0 +1,94 @@ +name: Generate Release Changelog PR + +on: + push: + tags: + - 'v*.*.*' + +jobs: + generate-release-changelog-pr: + permissions: + contents: write # Needed for pushing commits and creating branches. + pull-requests: write + runs-on: ubuntu-latest + steps: + # 1. Checkout the repository (with full history). + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # 2. Generate the release changelog using our custom configuration. + - name: Generate Release Changelog + id: generate_release + uses: mikepenz/release-changelog-builder-action@v5.1.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + configuration: ".github/configuration-release.json" + owner: ${{ github.repository_owner }} + repo: ${{ github.event.repository.name }} + outputFile: CHANGELOG-release.md + + # 3. Update the main CHANGELOG.md: + # - If it doesn't exist, create it with a basic header. + # - Remove the "Unreleased" section (if present). + # - Prepend the new release changelog above previous releases. + # - Remove all temporary files before committing. + - name: Update CHANGELOG.md + run: | + # Determine the release tag, e.g. "v1.2.3" + TAG=${GITHUB_REF##*/} + echo "Using release tag: $TAG" + + # Ensure CHANGELOG.md exists; if not, create a basic header. + if [ ! -f CHANGELOG.md ]; then + echo "# Changelog" > CHANGELOG.md + echo "" >> CHANGELOG.md + echo "All notable changes to this project will be documented in this file." >> CHANGELOG.md + echo "" >> CHANGELOG.md + fi + + echo "Updating CHANGELOG.md…" + + # Remove the "Unreleased" section (from "## [Unreleased]" until the first occurrence of '---') if it exists. + if grep -q "^## \[Unreleased\]" CHANGELOG.md; then + awk '/^## \[Unreleased\]/{flag=1} flag && /^---/{flag=0; next} !flag' CHANGELOG.md > CHANGELOG.cleaned + else + cp CHANGELOG.md CHANGELOG.cleaned + fi + + # Split the cleaned file into: + # - header.md: content before the first release header ("## [v..."). + # - tail.md: content from the first release header onward. + awk '/^## \[v/{exit} {print}' CHANGELOG.cleaned > header.md + awk 'f{print} /^## \[v/{f=1; print}' CHANGELOG.cleaned > tail.md + + # Combine header, the new release changelog, and the tail. + echo "Combining updated changelog parts..." + cat header.md CHANGELOG-release.md > CHANGELOG.md.new + echo "" >> CHANGELOG.md.new + cat tail.md >> CHANGELOG.md.new + + mv CHANGELOG.md.new CHANGELOG.md + + # Remove temporary files. + rm -f CHANGELOG.cleaned header.md tail.md CHANGELOG-release.md + + echo "Final CHANGELOG.md content:" + cat CHANGELOG.md + + # 4. Create (or update) the Pull Request with the updated CHANGELOG.md. + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + sign-commits: true + commit-message: "chore: update CHANGELOG for release ${GITHUB_REF##*/}" + base: main + branch: "changelog/${GITHUB_REF##*/}" + reviewers: danny-avila + title: "chore: update CHANGELOG for release ${GITHUB_REF##*/}" + body: | + **Description**: + - This PR updates the CHANGELOG.md by removing the "Unreleased" section and adding new release notes for release ${GITHUB_REF##*/} above previous releases. \ No newline at end of file diff --git a/.github/workflows/generate-unreleased-changelog-pr.yml b/.github/workflows/generate-unreleased-changelog-pr.yml new file mode 100644 index 0000000000..b130e4fb33 --- /dev/null +++ b/.github/workflows/generate-unreleased-changelog-pr.yml @@ -0,0 +1,106 @@ +name: Generate Unreleased Changelog PR + +on: + schedule: + - cron: "0 0 * * 1" # Runs every Monday at 00:00 UTC + +jobs: + generate-unreleased-changelog-pr: + permissions: + contents: write # Needed for pushing commits and creating branches. + pull-requests: write + runs-on: ubuntu-latest + steps: + # 1. Checkout the repository on main. + - name: Checkout Repository on Main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + # 4. Get the latest version tag. + - name: Get Latest Tag + id: get_latest_tag + run: | + LATEST_TAG=$(git describe --tags $(git rev-list --tags --max-count=1) || echo "none") + echo "Latest tag: $LATEST_TAG" + echo "tag=$LATEST_TAG" >> $GITHUB_OUTPUT + + # 5. Generate the Unreleased changelog. + - name: Generate Unreleased Changelog + id: generate_unreleased + uses: mikepenz/release-changelog-builder-action@v5.1.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + configuration: ".github/configuration-unreleased.json" + owner: ${{ github.repository_owner }} + repo: ${{ github.event.repository.name }} + outputFile: CHANGELOG-unreleased.md + fromTag: ${{ steps.get_latest_tag.outputs.tag }} + toTag: main + + # 7. Update CHANGELOG.md with the new Unreleased section. + - name: Update CHANGELOG.md + id: update_changelog + run: | + # Create CHANGELOG.md if it doesn't exist. + if [ ! -f CHANGELOG.md ]; then + echo "# Changelog" > CHANGELOG.md + echo "" >> CHANGELOG.md + echo "All notable changes to this project will be documented in this file." >> CHANGELOG.md + echo "" >> CHANGELOG.md + fi + + echo "Updating CHANGELOG.md…" + + # Extract content before the "## [Unreleased]" (or first version header if missing). + if grep -q "^## \[Unreleased\]" CHANGELOG.md; then + awk '/^## \[Unreleased\]/{exit} {print}' CHANGELOG.md > CHANGELOG_TMP.md + else + awk '/^## \[v/{exit} {print}' CHANGELOG.md > CHANGELOG_TMP.md + fi + + # Append the generated Unreleased changelog. + echo "" >> CHANGELOG_TMP.md + cat CHANGELOG-unreleased.md >> CHANGELOG_TMP.md + echo "" >> CHANGELOG_TMP.md + + # Append the remainder of the original changelog (starting from the first version header). + awk 'f{print} /^## \[v/{f=1; print}' CHANGELOG.md >> CHANGELOG_TMP.md + + # Replace the old file with the updated file. + mv CHANGELOG_TMP.md CHANGELOG.md + + # Remove the temporary generated file. + rm -f CHANGELOG-unreleased.md + + echo "Final CHANGELOG.md:" + cat CHANGELOG.md + + # 8. Check if CHANGELOG.md has any updates. + - name: Check for CHANGELOG.md changes + id: changelog_changes + run: | + if git diff --quiet CHANGELOG.md; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + # 9. Create (or update) the Pull Request only if there are changes. + - name: Create Pull Request + if: steps.changelog_changes.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + base: main + branch: "changelog/unreleased-update" + sign-commits: true + commit-message: "action: update Unreleased changelog" + title: "action: update Unreleased changelog" + body: | + **Description**: + - This PR updates the Unreleased section in CHANGELOG.md. + - It compares the current main branch with the latest version tag (determined as ${{ steps.get_latest_tag.outputs.tag }}), + regenerates the Unreleased changelog, removes any old Unreleased block, and inserts the new content. \ No newline at end of file diff --git a/.github/workflows/i18n-unused-keys.yml b/.github/workflows/i18n-unused-keys.yml index 2e66dfa987..5e29a8a8bd 100644 --- a/.github/workflows/i18n-unused-keys.yml +++ b/.github/workflows/i18n-unused-keys.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - "client/src/**" + - "api/**" jobs: detect-unused-i18n-keys: @@ -21,7 +22,7 @@ jobs: # Define paths I18N_FILE="client/src/locales/en/translation.json" - SOURCE_DIR="client/src" + SOURCE_DIRS=("client/src" "api") # Check if translation file exists if [[ ! -f "$I18N_FILE" ]]; then @@ -37,7 +38,15 @@ jobs: # Check if each key is used in the source code for KEY in $KEYS; do - if ! grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$SOURCE_DIR"; then + FOUND=false + for DIR in "${SOURCE_DIRS[@]}"; do + if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then + FOUND=true + break + fi + done + + if [[ "$FOUND" == false ]]; then UNUSED_KEYS+=("$KEY") fi done diff --git a/LICENSE b/LICENSE index 49a224977b..535850a920 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 LibreChat +Copyright (c) 2025 LibreChat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/api/app/clients/GoogleClient.js b/api/app/clients/GoogleClient.js index 03461a6796..e816843ea7 100644 --- a/api/app/clients/GoogleClient.js +++ b/api/app/clients/GoogleClient.js @@ -51,7 +51,7 @@ class GoogleClient extends BaseClient { const serviceKey = creds[AuthKeys.GOOGLE_SERVICE_KEY] ?? {}; this.serviceKey = - serviceKey && typeof serviceKey === 'string' ? JSON.parse(serviceKey) : serviceKey ?? {}; + serviceKey && typeof serviceKey === 'string' ? JSON.parse(serviceKey) : (serviceKey ?? {}); /** @type {string | null | undefined} */ this.project_id = this.serviceKey.project_id; this.client_email = this.serviceKey.client_email; @@ -73,6 +73,8 @@ class GoogleClient extends BaseClient { * @type {string} */ this.outputTokensKey = 'output_tokens'; this.visionMode = VisionModes.generative; + /** @type {string} */ + this.systemMessage; if (options.skipSetOptions) { return; } @@ -184,7 +186,7 @@ class GoogleClient extends BaseClient { if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) { promptPrefix = `${promptPrefix ?? ''}\n${this.options.artifactsPrompt}`.trim(); } - this.options.promptPrefix = promptPrefix; + this.systemMessage = promptPrefix; this.initializeClient(); return this; } @@ -314,7 +316,7 @@ class GoogleClient extends BaseClient { } this.augmentedPrompt = await this.contextHandlers.createContext(); - this.options.promptPrefix = this.augmentedPrompt + this.options.promptPrefix; + this.systemMessage = this.augmentedPrompt + this.systemMessage; } } @@ -361,8 +363,8 @@ class GoogleClient extends BaseClient { throw new Error('[GoogleClient] PaLM 2 and Codey models are no longer supported.'); } - if (this.options.promptPrefix) { - const instructionsTokenCount = this.getTokenCount(this.options.promptPrefix); + if (this.systemMessage) { + const instructionsTokenCount = this.getTokenCount(this.systemMessage); this.maxContextTokens = this.maxContextTokens - instructionsTokenCount; if (this.maxContextTokens < 0) { @@ -417,8 +419,8 @@ class GoogleClient extends BaseClient { ], }; - if (this.options.promptPrefix) { - payload.instances[0].context = this.options.promptPrefix; + if (this.systemMessage) { + payload.instances[0].context = this.systemMessage; } logger.debug('[GoogleClient] buildMessages', payload); @@ -464,7 +466,7 @@ class GoogleClient extends BaseClient { identityPrefix = `${identityPrefix}\nYou are ${this.options.modelLabel}`; } - let promptPrefix = (this.options.promptPrefix ?? '').trim(); + let promptPrefix = (this.systemMessage ?? '').trim(); if (identityPrefix) { promptPrefix = `${identityPrefix}${promptPrefix}`; @@ -639,7 +641,7 @@ class GoogleClient extends BaseClient { let error; try { if (!EXCLUDED_GENAI_MODELS.test(modelName) && !this.project_id) { - /** @type {GenAI} */ + /** @type {GenerativeModel} */ const client = this.client; /** @type {GenerateContentRequest} */ const requestOptions = { @@ -648,7 +650,7 @@ class GoogleClient extends BaseClient { generationConfig: googleGenConfigSchema.parse(this.modelOptions), }; - const promptPrefix = (this.options.promptPrefix ?? '').trim(); + const promptPrefix = (this.systemMessage ?? '').trim(); if (promptPrefix.length) { requestOptions.systemInstruction = { parts: [ @@ -663,7 +665,17 @@ class GoogleClient extends BaseClient { /** @type {GenAIUsageMetadata} */ let usageMetadata; - const result = await client.generateContentStream(requestOptions); + abortController.signal.addEventListener( + 'abort', + () => { + logger.warn('[GoogleClient] Request was aborted', abortController.signal.reason); + }, + { once: true }, + ); + + const result = await client.generateContentStream(requestOptions, { + signal: abortController.signal, + }); for await (const chunk of result.stream) { usageMetadata = !usageMetadata ? chunk?.usageMetadata diff --git a/api/app/clients/OllamaClient.js b/api/app/clients/OllamaClient.js index d86e120f43..77d007580c 100644 --- a/api/app/clients/OllamaClient.js +++ b/api/app/clients/OllamaClient.js @@ -2,7 +2,7 @@ const { z } = require('zod'); const axios = require('axios'); const { Ollama } = require('ollama'); const { Constants } = require('librechat-data-provider'); -const { deriveBaseURL } = require('~/utils'); +const { deriveBaseURL, logAxiosError } = require('~/utils'); const { sleep } = require('~/server/utils'); const { logger } = require('~/config'); @@ -68,7 +68,7 @@ class OllamaClient { } catch (error) { const logMessage = 'Failed to fetch models from Ollama API. If you are not using Ollama directly, and instead, through some aggregator or reverse proxy that handles fetching via OpenAI spec, ensure the name of the endpoint doesn\'t start with `ollama` (case-insensitive).'; - logger.error(logMessage, error); + logAxiosError({ message: logMessage, error }); return []; } } diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 368e7d6e84..7bd7879dcf 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -7,6 +7,7 @@ const { ImageDetail, EModelEndpoint, resolveHeaders, + KnownEndpoints, openAISettings, ImageDetailCost, CohereConstants, @@ -116,11 +117,7 @@ class OpenAIClient extends BaseClient { const { reverseProxyUrl: reverseProxy } = this.options; - if ( - !this.useOpenRouter && - reverseProxy && - reverseProxy.includes('https://openrouter.ai/api/v1') - ) { + if (!this.useOpenRouter && reverseProxy && reverseProxy.includes(KnownEndpoints.openrouter)) { this.useOpenRouter = true; } diff --git a/api/app/clients/prompts/formatAgentMessages.spec.js b/api/app/clients/prompts/formatAgentMessages.spec.js index 20731f6984..957409d6ab 100644 --- a/api/app/clients/prompts/formatAgentMessages.spec.js +++ b/api/app/clients/prompts/formatAgentMessages.spec.js @@ -282,4 +282,47 @@ describe('formatAgentMessages', () => { // Additional check to ensure the consecutive assistant messages were combined expect(result[1].content).toHaveLength(2); }); + + it('should skip THINK type content parts', () => { + const payload = [ + { + role: 'assistant', + content: [ + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Initial response' }, + { type: ContentTypes.THINK, [ContentTypes.THINK]: 'Reasoning about the problem...' }, + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Final answer' }, + ], + }, + ]; + + const result = formatAgentMessages(payload); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(AIMessage); + expect(result[0].content).toEqual('Initial response\nFinal answer'); + }); + + it('should join TEXT content as string when THINK content type is present', () => { + const payload = [ + { + role: 'assistant', + content: [ + { type: ContentTypes.THINK, [ContentTypes.THINK]: 'Analyzing the problem...' }, + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'First part of response' }, + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Second part of response' }, + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Final part of response' }, + ], + }, + ]; + + const result = formatAgentMessages(payload); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(AIMessage); + expect(typeof result[0].content).toBe('string'); + expect(result[0].content).toBe( + 'First part of response\nSecond part of response\nFinal part of response', + ); + expect(result[0].content).not.toContain('Analyzing the problem...'); + }); }); diff --git a/api/app/clients/prompts/formatMessages.js b/api/app/clients/prompts/formatMessages.js index d84e62cca8..235e51e51f 100644 --- a/api/app/clients/prompts/formatMessages.js +++ b/api/app/clients/prompts/formatMessages.js @@ -153,6 +153,7 @@ const formatAgentMessages = (payload) => { let currentContent = []; let lastAIMessage = null; + let hasReasoning = false; for (const part of message.content) { if (part.type === ContentTypes.TEXT && part.tool_call_ids) { /* @@ -207,11 +208,25 @@ const formatAgentMessages = (payload) => { content: output || '', }), ); + } else if (part.type === ContentTypes.THINK) { + hasReasoning = true; + continue; } else { currentContent.push(part); } } + if (hasReasoning) { + currentContent = currentContent + .reduce((acc, curr) => { + if (curr.type === ContentTypes.TEXT) { + return `${acc}${curr[ContentTypes.TEXT]}\n`; + } + return acc; + }, '') + .trim(); + } + if (currentContent.length > 0) { messages.push(new AIMessage({ content: currentContent })); } diff --git a/api/app/clients/tools/util/fileSearch.js b/api/app/clients/tools/util/fileSearch.js index 23ba58bb5a..54da483362 100644 --- a/api/app/clients/tools/util/fileSearch.js +++ b/api/app/clients/tools/util/fileSearch.js @@ -106,18 +106,21 @@ const createFileSearchTool = async ({ req, files, entity_id }) => { const formattedResults = validResults .flatMap((result) => - result.data.map(([docInfo, relevanceScore]) => ({ + result.data.map(([docInfo, distance]) => ({ filename: docInfo.metadata.source.split('/').pop(), content: docInfo.page_content, - relevanceScore, + distance, })), ) - .sort((a, b) => b.relevanceScore - a.relevanceScore); + // TODO: results should be sorted by relevance, not distance + .sort((a, b) => a.distance - b.distance) + // TODO: make this configurable + .slice(0, 10); const formattedString = formattedResults .map( (result) => - `File: ${result.filename}\nRelevance: ${result.relevanceScore.toFixed(4)}\nContent: ${ + `File: ${result.filename}\nRelevance: ${1.0 - result.distance.toFixed(4)}\nContent: ${ result.content }\n`, ) diff --git a/api/cache/keyvRedis.js b/api/cache/keyvRedis.js index d544b50a11..816dcd29b2 100644 --- a/api/cache/keyvRedis.js +++ b/api/cache/keyvRedis.js @@ -1,15 +1,81 @@ +const fs = require('fs'); +const ioredis = require('ioredis'); const KeyvRedis = require('@keyv/redis'); const { isEnabled } = require('~/server/utils'); const logger = require('~/config/winston'); -const { REDIS_URI, USE_REDIS } = process.env; +const { REDIS_URI, USE_REDIS, USE_REDIS_CLUSTER, REDIS_CA, REDIS_KEY_PREFIX, REDIS_MAX_LISTENERS } = + process.env; let keyvRedis; +const redis_prefix = REDIS_KEY_PREFIX || ''; +const redis_max_listeners = REDIS_MAX_LISTENERS || 10; + +function mapURI(uri) { + const regex = + /^(?:(?\w+):\/\/)?(?:(?[^:@]+)(?::(?[^@]+))?@)?(?[\w.-]+)(?::(?\d{1,5}))?$/; + const match = uri.match(regex); + + if (match) { + const { scheme, user, password, host, port } = match.groups; + + return { + scheme: scheme || 'none', + user: user || null, + password: password || null, + host: host || null, + port: port || null, + }; + } else { + const parts = uri.split(':'); + if (parts.length === 2) { + return { + scheme: 'none', + user: null, + password: null, + host: parts[0], + port: parts[1], + }; + } + + return { + scheme: 'none', + user: null, + password: null, + host: uri, + port: null, + }; + } +} if (REDIS_URI && isEnabled(USE_REDIS)) { - keyvRedis = new KeyvRedis(REDIS_URI, { useRedisSets: false }); + let redisOptions = null; + let keyvOpts = { + useRedisSets: false, + keyPrefix: redis_prefix, + }; + + if (REDIS_CA) { + const ca = fs.readFileSync(REDIS_CA); + redisOptions = { tls: { ca } }; + } + + if (isEnabled(USE_REDIS_CLUSTER)) { + const hosts = REDIS_URI.split(',').map((item) => { + var value = mapURI(item); + + return { + host: value.host, + port: value.port, + }; + }); + const cluster = new ioredis.Cluster(hosts, { redisOptions }); + keyvRedis = new KeyvRedis(cluster, keyvOpts); + } else { + keyvRedis = new KeyvRedis(REDIS_URI, keyvOpts); + } keyvRedis.on('error', (err) => logger.error('KeyvRedis connection error:', err)); - keyvRedis.setMaxListeners(20); + keyvRedis.setMaxListeners(redis_max_listeners); logger.info( '[Optional] Redis initialized. Note: Redis support is experimental. If you have issues, disable it. Cache needs to be flushed for values to refresh.', ); diff --git a/api/lib/db/indexSync.js b/api/lib/db/indexSync.js index 86c909419d..9c40e684d3 100644 --- a/api/lib/db/indexSync.js +++ b/api/lib/db/indexSync.js @@ -1,9 +1,11 @@ const { MeiliSearch } = require('meilisearch'); const Conversation = require('~/models/schema/convoSchema'); const Message = require('~/models/schema/messageSchema'); +const { isEnabled } = require('~/server/utils'); const { logger } = require('~/config'); -const searchEnabled = process.env?.SEARCH?.toLowerCase() === 'true'; +const searchEnabled = isEnabled(process.env.SEARCH); +const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC); let currentTimeout = null; class MeiliSearchClient { @@ -23,8 +25,7 @@ class MeiliSearchClient { } } -// eslint-disable-next-line no-unused-vars -async function indexSync(req, res, next) { +async function indexSync() { if (!searchEnabled) { return; } @@ -33,10 +34,15 @@ async function indexSync(req, res, next) { const client = MeiliSearchClient.getInstance(); const { status } = await client.health(); - if (status !== 'available' || !process.env.SEARCH) { + if (status !== 'available') { throw new Error('Meilisearch not available'); } + if (indexingDisabled === true) { + logger.info('[indexSync] Indexing is disabled, skipping...'); + return; + } + const messageCount = await Message.countDocuments(); const convoCount = await Conversation.countDocuments(); const messages = await client.index('messages').getStats(); @@ -71,7 +77,6 @@ async function indexSync(req, res, next) { logger.info('[indexSync] Meilisearch not configured, search will be disabled.'); } else { logger.error('[indexSync] error', err); - // res.status(500).json({ error: 'Server error' }); } } } diff --git a/api/models/Agent.js b/api/models/Agent.js index 6fa00f56bc..6ea203113c 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -97,11 +97,22 @@ const updateAgent = async (searchParameter, updateData) => { const addAgentResourceFile = async ({ agent_id, tool_resource, file_id }) => { const searchParameter = { id: agent_id }; - // build the update to push or create the file ids set const fileIdsPath = `tool_resources.${tool_resource}.file_ids`; + + await Agent.updateOne( + { + id: agent_id, + [`${fileIdsPath}`]: { $exists: false }, + }, + { + $set: { + [`${fileIdsPath}`]: [], + }, + }, + ); + const updateData = { $addToSet: { [fileIdsPath]: file_id } }; - // return the updated agent or throw if no agent matches const updatedAgent = await updateAgent(searchParameter, updateData); if (updatedAgent) { return updatedAgent; @@ -290,6 +301,7 @@ const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds }; module.exports = { + Agent, getAgent, loadAgent, createAgent, diff --git a/api/models/Agent.spec.js b/api/models/Agent.spec.js new file mode 100644 index 0000000000..769eda2bb7 --- /dev/null +++ b/api/models/Agent.spec.js @@ -0,0 +1,160 @@ +const mongoose = require('mongoose'); +const { v4: uuidv4 } = require('uuid'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { Agent, addAgentResourceFile, removeAgentResourceFiles } = require('./Agent'); + +describe('Agent Resource File Operations', () => { + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + await mongoose.connect(mongoUri); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await Agent.deleteMany({}); + }); + + const createBasicAgent = async () => { + const agentId = `agent_${uuidv4()}`; + const agent = await Agent.create({ + id: agentId, + name: 'Test Agent', + provider: 'test', + model: 'test-model', + author: new mongoose.Types.ObjectId(), + }); + return agent; + }; + + test('should handle concurrent file additions', async () => { + const agent = await createBasicAgent(); + const fileIds = Array.from({ length: 10 }, () => uuidv4()); + + // Concurrent additions + const additionPromises = fileIds.map((fileId) => + addAgentResourceFile({ + agent_id: agent.id, + tool_resource: 'test_tool', + file_id: fileId, + }), + ); + + await Promise.all(additionPromises); + + const updatedAgent = await Agent.findOne({ id: agent.id }); + expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined(); + expect(updatedAgent.tool_resources.test_tool.file_ids).toHaveLength(10); + expect(new Set(updatedAgent.tool_resources.test_tool.file_ids).size).toBe(10); + }); + + test('should handle concurrent additions and removals', async () => { + const agent = await createBasicAgent(); + const initialFileIds = Array.from({ length: 5 }, () => uuidv4()); + + await Promise.all( + initialFileIds.map((fileId) => + addAgentResourceFile({ + agent_id: agent.id, + tool_resource: 'test_tool', + file_id: fileId, + }), + ), + ); + + const newFileIds = Array.from({ length: 5 }, () => uuidv4()); + const operations = [ + ...newFileIds.map((fileId) => + addAgentResourceFile({ + agent_id: agent.id, + tool_resource: 'test_tool', + file_id: fileId, + }), + ), + ...initialFileIds.map((fileId) => + removeAgentResourceFiles({ + agent_id: agent.id, + files: [{ tool_resource: 'test_tool', file_id: fileId }], + }), + ), + ]; + + await Promise.all(operations); + + const updatedAgent = await Agent.findOne({ id: agent.id }); + expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined(); + expect(updatedAgent.tool_resources.test_tool.file_ids).toHaveLength(5); + }); + + test('should initialize array when adding to non-existent tool resource', async () => { + const agent = await createBasicAgent(); + const fileId = uuidv4(); + + const updatedAgent = await addAgentResourceFile({ + agent_id: agent.id, + tool_resource: 'new_tool', + file_id: fileId, + }); + + expect(updatedAgent.tool_resources.new_tool.file_ids).toBeDefined(); + expect(updatedAgent.tool_resources.new_tool.file_ids).toHaveLength(1); + expect(updatedAgent.tool_resources.new_tool.file_ids[0]).toBe(fileId); + }); + + test('should handle rapid sequential modifications to same tool resource', async () => { + const agent = await createBasicAgent(); + const fileId = uuidv4(); + + for (let i = 0; i < 10; i++) { + await addAgentResourceFile({ + agent_id: agent.id, + tool_resource: 'test_tool', + file_id: `${fileId}_${i}`, + }); + + if (i % 2 === 0) { + await removeAgentResourceFiles({ + agent_id: agent.id, + files: [{ tool_resource: 'test_tool', file_id: `${fileId}_${i}` }], + }); + } + } + + const updatedAgent = await Agent.findOne({ id: agent.id }); + expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined(); + expect(Array.isArray(updatedAgent.tool_resources.test_tool.file_ids)).toBe(true); + }); + + test('should handle multiple tool resources concurrently', async () => { + const agent = await createBasicAgent(); + const toolResources = ['tool1', 'tool2', 'tool3']; + const operations = []; + + toolResources.forEach((tool) => { + const fileIds = Array.from({ length: 5 }, () => uuidv4()); + fileIds.forEach((fileId) => { + operations.push( + addAgentResourceFile({ + agent_id: agent.id, + tool_resource: tool, + file_id: fileId, + }), + ); + }); + }); + + await Promise.all(operations); + + const updatedAgent = await Agent.findOne({ id: agent.id }); + toolResources.forEach((tool) => { + expect(updatedAgent.tool_resources[tool].file_ids).toBeDefined(); + expect(updatedAgent.tool_resources[tool].file_ids).toHaveLength(5); + }); + }); +}); diff --git a/api/models/Categories.js b/api/models/Categories.js index 0f7f29703f..6fb88fb995 100644 --- a/api/models/Categories.js +++ b/api/models/Categories.js @@ -1,40 +1,41 @@ const { logger } = require('~/config'); // const { Categories } = require('./schema/categories'); + const options = [ { - label: 'idea', + label: 'com_ui_idea', value: 'idea', }, { - label: 'travel', + label: 'com_ui_travel', value: 'travel', }, { - label: 'teach_or_explain', + label: 'com_ui_teach_or_explain', value: 'teach_or_explain', }, { - label: 'write', + label: 'com_ui_write', value: 'write', }, { - label: 'shop', + label: 'com_ui_shop', value: 'shop', }, { - label: 'code', + label: 'com_ui_code', value: 'code', }, { - label: 'misc', + label: 'com_ui_misc', value: 'misc', }, { - label: 'roleplay', + label: 'com_ui_roleplay', value: 'roleplay', }, { - label: 'finance', + label: 'com_ui_finance', value: 'finance', }, ]; diff --git a/api/models/schema/userSchema.js b/api/models/schema/userSchema.js index 1a3e23a003..26712b6456 100644 --- a/api/models/schema/userSchema.js +++ b/api/models/schema/userSchema.js @@ -39,12 +39,18 @@ const Session = mongoose.Schema({ }, }); +const backupCodeSchema = mongoose.Schema({ + codeHash: { type: String, required: true }, + used: { type: Boolean, default: false }, + usedAt: { type: Date, default: null }, +}); + const passkeySchema = mongoose.Schema({ id: { type: String, required: true }, publicKey: { type: Buffer, required: true }, counter: { type: Number, default: 0 }, transports: { type: [String], default: [] }, -}); + }); /** @type {MongooseSchema} */ const userSchema = mongoose.Schema( @@ -130,7 +136,12 @@ const userSchema = mongoose.Schema( }, plugins: { type: Array, - default: [], + }, + totpSecret: { + type: String, + }, + backupCodes: { + type: [backupCodeSchema], }, refreshToken: { type: [Session], diff --git a/api/package.json b/api/package.json index 963d28adbd..9d9fa8d922 100644 --- a/api/package.json +++ b/api/package.json @@ -45,7 +45,7 @@ "@langchain/google-genai": "^0.1.7", "@langchain/google-vertexai": "^0.1.8", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.0.5", + "@librechat/agents": "^2.1.2", "@waylaidwanderer/fetch-event-source": "^3.0.1", "axios": "1.7.8", "bcryptjs": "^2.4.3", @@ -65,6 +65,7 @@ "firebase": "^11.0.2", "googleapis": "^126.0.1", "handlebars": "^4.7.7", + "https-proxy-agent": "^7.0.6", "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 71551ea867..7cdfaa9aaf 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -61,7 +61,7 @@ const refreshController = async (req, res) => { try { const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET); - const user = await getUserById(payload.id, '-password -__v'); + const user = await getUserById(payload.id, '-password -__v -totpSecret'); if (!user) { return res.status(401).redirect('/login'); } diff --git a/api/server/controllers/TwoFactorController.js b/api/server/controllers/TwoFactorController.js new file mode 100644 index 0000000000..f145d69d92 --- /dev/null +++ b/api/server/controllers/TwoFactorController.js @@ -0,0 +1,119 @@ +const { + verifyTOTP, + verifyBackupCode, + generateTOTPSecret, + generateBackupCodes, + getTOTPSecret, +} = require('~/server/services/twoFactorService'); +const { updateUser, getUserById } = require('~/models'); +const { logger } = require('~/config'); +const { encryptV2 } = require('~/server/utils/crypto'); + +const enable2FAController = async (req, res) => { + const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, ''); + + try { + const userId = req.user.id; + const secret = generateTOTPSecret(); + const { plainCodes, codeObjects } = await generateBackupCodes(); + + const encryptedSecret = await encryptV2(secret); + const user = await updateUser(userId, { totpSecret: encryptedSecret, backupCodes: codeObjects }); + + const otpauthUrl = `otpauth://totp/${safeAppTitle}:${user.email}?secret=${secret}&issuer=${safeAppTitle}`; + + res.status(200).json({ + otpauthUrl, + backupCodes: plainCodes, + }); + } catch (err) { + logger.error('[enable2FAController]', err); + res.status(500).json({ message: err.message }); + } +}; + +const verify2FAController = async (req, res) => { + try { + const userId = req.user.id; + const { token, backupCode } = req.body; + const user = await getUserById(userId); + if (!user || !user.totpSecret) { + return res.status(400).json({ message: '2FA not initiated' }); + } + + // Retrieve the plain TOTP secret using getTOTPSecret. + const secret = await getTOTPSecret(user.totpSecret); + + if (token && (await verifyTOTP(secret, token))) { + return res.status(200).json(); + } else if (backupCode) { + const verified = await verifyBackupCode({ user, backupCode }); + if (verified) { + return res.status(200).json(); + } + } + + return res.status(400).json({ message: 'Invalid token.' }); + } catch (err) { + logger.error('[verify2FAController]', err); + res.status(500).json({ message: err.message }); + } +}; + +const confirm2FAController = async (req, res) => { + try { + const userId = req.user.id; + const { token } = req.body; + const user = await getUserById(userId); + + if (!user || !user.totpSecret) { + return res.status(400).json({ message: '2FA not initiated' }); + } + + // Retrieve the plain TOTP secret using getTOTPSecret. + const secret = await getTOTPSecret(user.totpSecret); + + if (await verifyTOTP(secret, token)) { + return res.status(200).json(); + } + + return res.status(400).json({ message: 'Invalid token.' }); + } catch (err) { + logger.error('[confirm2FAController]', err); + res.status(500).json({ message: err.message }); + } +}; + +const disable2FAController = async (req, res) => { + try { + const userId = req.user.id; + await updateUser(userId, { totpSecret: null, backupCodes: [] }); + res.status(200).json(); + } catch (err) { + logger.error('[disable2FAController]', err); + res.status(500).json({ message: err.message }); + } +}; + +const regenerateBackupCodesController = async (req, res) => { + try { + const userId = req.user.id; + const { plainCodes, codeObjects } = await generateBackupCodes(); + await updateUser(userId, { backupCodes: codeObjects }); + res.status(200).json({ + backupCodes: plainCodes, + backupCodesHash: codeObjects, + }); + } catch (err) { + logger.error('[regenerateBackupCodesController]', err); + res.status(500).json({ message: err.message }); + } +}; + +module.exports = { + enable2FAController, + verify2FAController, + confirm2FAController, + disable2FAController, + regenerateBackupCodesController, +}; diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index 17089e8fdc..a331b8daae 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -19,7 +19,9 @@ const { Transaction } = require('~/models/Transaction'); const { logger } = require('~/config'); const getUserController = async (req, res) => { - res.status(200).send(req.user); + const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user }; + delete userData.totpSecret; + res.status(200).send(userData); }; const getTermsStatusController = async (req, res) => { diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 33fe585f42..f43c9db5ba 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -199,6 +199,22 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU aggregateContent({ event, data }); }, }, + [GraphEvents.ON_REASONING_DELTA]: { + /** + * Handle ON_REASONING_DELTA event. + * @param {string} event - The event name. + * @param {StreamEventData} data - The event data. + * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata. + */ + handle: (event, data, metadata) => { + if (metadata?.last_agent_index === metadata?.agent_index) { + sendEvent(res, { event, data }); + } else if (!metadata?.hide_sequential_outputs) { + sendEvent(res, { event, data }); + } + aggregateContent({ event, data }); + }, + }, }; return handlers; diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index a8e9ad82f7..156424e035 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -20,11 +20,6 @@ const { bedrockOutputParser, removeNullishValues, } = require('librechat-data-provider'); -const { - extractBaseURL, - // constructAzureURL, - // genAzureChatCompletion, -} = require('~/utils'); const { formatMessage, formatAgentMessages, @@ -477,19 +472,6 @@ class AgentClient extends BaseClient { abortController = new AbortController(); } - const baseURL = extractBaseURL(this.completionsUrl); - logger.debug('[api/server/controllers/agents/client.js] chatCompletion', { - baseURL, - payload, - }); - - // if (this.useOpenRouter) { - // opts.defaultHeaders = { - // 'HTTP-Referer': 'https://librechat.ai', - // 'X-Title': 'LibreChat', - // }; - // } - // if (this.options.headers) { // opts.defaultHeaders = { ...opts.defaultHeaders, ...this.options.headers }; // } @@ -626,7 +608,7 @@ class AgentClient extends BaseClient { let systemContent = [ systemMessage, agent.instructions ?? '', - i !== 0 ? agent.additional_instructions ?? '' : '', + i !== 0 ? (agent.additional_instructions ?? '') : '', ] .join('\n') .trim(); diff --git a/api/server/controllers/agents/run.js b/api/server/controllers/agents/run.js index 0fcc58a379..6c98a641db 100644 --- a/api/server/controllers/agents/run.js +++ b/api/server/controllers/agents/run.js @@ -1,5 +1,5 @@ const { Run, Providers } = require('@librechat/agents'); -const { providerEndpointMap } = require('librechat-data-provider'); +const { providerEndpointMap, KnownEndpoints } = require('librechat-data-provider'); /** * @typedef {import('@librechat/agents').t} t @@ -7,6 +7,7 @@ const { providerEndpointMap } = require('librechat-data-provider'); * @typedef {import('@librechat/agents').StreamEventData} StreamEventData * @typedef {import('@librechat/agents').EventHandler} EventHandler * @typedef {import('@librechat/agents').GraphEvents} GraphEvents + * @typedef {import('@librechat/agents').LLMConfig} LLMConfig * @typedef {import('@librechat/agents').IState} IState */ @@ -32,6 +33,7 @@ async function createRun({ streamUsage = true, }) { const provider = providerEndpointMap[agent.provider] ?? agent.provider; + /** @type {LLMConfig} */ const llmConfig = Object.assign( { provider, @@ -41,6 +43,11 @@ async function createRun({ agent.model_parameters, ); + /** @type {'reasoning_content' | 'reasoning'} */ + let reasoningKey; + if (llmConfig.configuration?.baseURL?.includes(KnownEndpoints.openrouter)) { + reasoningKey = 'reasoning'; + } if (/o1(?!-(?:mini|preview)).*$/.test(llmConfig.model)) { llmConfig.streaming = false; llmConfig.disableStreaming = true; @@ -50,6 +57,7 @@ async function createRun({ const graphConfig = { signal, llmConfig, + reasoningKey, tools: agent.tools, instructions: agent.instructions, additional_instructions: agent.additional_instructions, diff --git a/api/server/controllers/auth/LoginController.js b/api/server/controllers/auth/LoginController.js index 1b543e9baf..8ab9a99ddb 100644 --- a/api/server/controllers/auth/LoginController.js +++ b/api/server/controllers/auth/LoginController.js @@ -1,3 +1,4 @@ +const { generate2FATempToken } = require('~/server/services/twoFactorService'); const { setAuthTokens } = require('~/server/services/AuthService'); const { logger } = require('~/config'); @@ -7,7 +8,12 @@ const loginController = async (req, res) => { return res.status(400).json({ message: 'Invalid credentials' }); } - const { password: _, __v, ...user } = req.user; + if (req.user.backupCodes != null && req.user.backupCodes.length > 0) { + const tempToken = generate2FATempToken(req.user._id); + return res.status(200).json({ twoFAPending: true, tempToken }); + } + + const { password: _p, totpSecret: _t, __v, ...user } = req.user; user.id = user._id.toString(); const token = await setAuthTokens(req.user._id, res); diff --git a/api/server/controllers/auth/TwoFactorAuthController.js b/api/server/controllers/auth/TwoFactorAuthController.js new file mode 100644 index 0000000000..78c5c0314e --- /dev/null +++ b/api/server/controllers/auth/TwoFactorAuthController.js @@ -0,0 +1,58 @@ +const jwt = require('jsonwebtoken'); +const { verifyTOTP, verifyBackupCode, getTOTPSecret } = require('~/server/services/twoFactorService'); +const { setAuthTokens } = require('~/server/services/AuthService'); +const { getUserById } = require('~/models/userMethods'); +const { logger } = require('~/config'); + +const verify2FA = async (req, res) => { + try { + const { tempToken, token, backupCode } = req.body; + if (!tempToken) { + return res.status(400).json({ message: 'Missing temporary token' }); + } + + let payload; + try { + payload = jwt.verify(tempToken, process.env.JWT_SECRET); + } catch (err) { + return res.status(401).json({ message: 'Invalid or expired temporary token' }); + } + + const user = await getUserById(payload.userId); + // Ensure that the user exists and has backup codes (i.e. 2FA enabled) + if (!user || !(user.backupCodes && user.backupCodes.length > 0)) { + return res.status(400).json({ message: '2FA is not enabled for this user' }); + } + + // Use the new getTOTPSecret function to retrieve (and decrypt if necessary) the TOTP secret. + const secret = await getTOTPSecret(user.totpSecret); + + let verified = false; + if (token && (await verifyTOTP(secret, token))) { + verified = true; + } else if (backupCode) { + verified = await verifyBackupCode({ user, backupCode }); + } + + if (!verified) { + return res.status(401).json({ message: 'Invalid 2FA code or backup code' }); + } + + // Prepare user data for response. + // If the user is a plain object (from lean queries), we create a shallow copy. + const userData = user.toObject ? user.toObject() : { ...user }; + // Remove sensitive fields. + delete userData.password; + delete userData.__v; + delete userData.totpSecret; + userData.id = user._id.toString(); + + const authToken = await setAuthTokens(user._id, res); + return res.status(200).json({ token: authToken, user: userData }); + } catch (err) { + logger.error('[verify2FA]', err); + return res.status(500).json({ message: 'Something went wrong' }); + } +}; + +module.exports = { verify2FA }; diff --git a/api/server/index.js b/api/server/index.js index a27e6b4008..399748d50b 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -24,10 +24,11 @@ const routes = require('./routes'); const { mongoUserStore, mongoChallengeStore } = require('~/cache'); const { WebAuthnStrategy } = require('passport-simple-webauthn2'); -const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION } = process.env ?? {}; +const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {}; const port = Number(PORT) || 3080; const host = HOST || 'localhost'; +const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */ const startServer = async () => { if (typeof Bun !== 'undefined') { @@ -55,7 +56,7 @@ const startServer = async () => { app.use(staticCache(app.locals.paths.dist)); app.use(staticCache(app.locals.paths.fonts)); app.use(staticCache(app.locals.paths.assets)); - app.set('trust proxy', 1); /* trust first proxy */ + app.set('trust proxy', trusted_proxy); app.use(cors()); app.use(cookieParser()); @@ -165,6 +166,18 @@ process.on('uncaughtException', (err) => { logger.error('There was an uncaught error:', err); } + if (err.message.includes('abort')) { + logger.warn('There was an uncatchable AbortController error.'); + return; + } + + if (err.message.includes('GoogleGenerativeAI')) { + logger.warn( + '\n\n`GoogleGenerativeAI` errors cannot be caught due to an upstream issue, see: https://github.com/google-gemini/generative-ai-js/issues/303', + ); + return; + } + if (err.message.includes('fetch failed')) { if (messageCount === 0) { logger.warn('Meilisearch error, search will be disabled'); diff --git a/api/server/routes/auth.js b/api/server/routes/auth.js index 3e86ffd868..03046d903f 100644 --- a/api/server/routes/auth.js +++ b/api/server/routes/auth.js @@ -7,6 +7,13 @@ const { } = require('~/server/controllers/AuthController'); const { loginController } = require('~/server/controllers/auth/LoginController'); const { logoutController } = require('~/server/controllers/auth/LogoutController'); +const { verify2FA } = require('~/server/controllers/auth/TwoFactorAuthController'); +const { + enable2FAController, + verify2FAController, + disable2FAController, + regenerateBackupCodesController, confirm2FAController, +} = require('~/server/controllers/TwoFactorController'); const { checkBan, loginLimiter, @@ -50,4 +57,11 @@ router.post( ); router.post('/resetPassword', checkBan, validatePasswordReset, resetPasswordController); +router.get('/2fa/enable', requireJwtAuth, enable2FAController); +router.post('/2fa/verify', requireJwtAuth, verify2FAController); +router.post('/2fa/verify-temp', checkBan, verify2FA); +router.post('/2fa/confirm', requireJwtAuth, confirm2FAController); +router.post('/2fa/disable', requireJwtAuth, disable2FAController); +router.post('/2fa/backup/regenerate', requireJwtAuth, regenerateBackupCodesController); + module.exports = router; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 3e03a45125..1726ef3460 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -22,12 +22,14 @@ const { getAgent } = require('~/models/Agent'); const { logger } = require('~/config'); const providerConfigMap = { + [Providers.OLLAMA]: initCustom, + [Providers.DEEPSEEK]: initCustom, + [Providers.OPENROUTER]: initCustom, [EModelEndpoint.openAI]: initOpenAI, + [EModelEndpoint.google]: initGoogle, [EModelEndpoint.azureOpenAI]: initOpenAI, [EModelEndpoint.anthropic]: initAnthropic, [EModelEndpoint.bedrock]: getBedrockOptions, - [EModelEndpoint.google]: initGoogle, - [Providers.OLLAMA]: initCustom, }; /** @@ -100,8 +102,10 @@ const initializeAgentOptions = async ({ const provider = agent.provider; let getOptions = providerConfigMap[provider]; - - if (!getOptions) { + if (!getOptions && providerConfigMap[provider.toLowerCase()] != null) { + agent.provider = provider.toLowerCase(); + getOptions = providerConfigMap[agent.provider]; + } else if (!getOptions) { const customEndpointConfig = await getCustomEndpointConfig(provider); if (!customEndpointConfig) { throw new Error(`Provider ${provider} not supported`); diff --git a/api/server/services/Endpoints/openAI/llm.js b/api/server/services/Endpoints/openAI/llm.js index 2587b242c9..c12f835f2f 100644 --- a/api/server/services/Endpoints/openAI/llm.js +++ b/api/server/services/Endpoints/openAI/llm.js @@ -1,4 +1,5 @@ const { HttpsProxyAgent } = require('https-proxy-agent'); +const { KnownEndpoints } = require('librechat-data-provider'); const { sanitizeModelName, constructAzureURL } = require('~/utils'); const { isEnabled } = require('~/server/utils'); @@ -57,10 +58,9 @@ function getLLMConfig(apiKey, options = {}) { /** @type {OpenAIClientOptions['configuration']} */ const configOptions = {}; - - // Handle OpenRouter or custom reverse proxy - if (useOpenRouter || reverseProxyUrl === 'https://openrouter.ai/api/v1') { - configOptions.baseURL = 'https://openrouter.ai/api/v1'; + if (useOpenRouter || (reverseProxyUrl && reverseProxyUrl.includes(KnownEndpoints.openrouter))) { + llmConfig.include_reasoning = true; + configOptions.baseURL = reverseProxyUrl; configOptions.defaultHeaders = Object.assign( { 'HTTP-Referer': 'https://librechat.ai', diff --git a/api/server/services/Files/Code/crud.js b/api/server/services/Files/Code/crud.js index 076a4d9f13..7b26093d62 100644 --- a/api/server/services/Files/Code/crud.js +++ b/api/server/services/Files/Code/crud.js @@ -2,6 +2,7 @@ const axios = require('axios'); const FormData = require('form-data'); const { getCodeBaseURL } = require('@librechat/agents'); +const { logAxiosError } = require('~/utils'); const MAX_FILE_SIZE = 150 * 1024 * 1024; @@ -78,7 +79,11 @@ async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = '' return `${fileIdentifier}?entity_id=${entity_id}`; } catch (error) { - throw new Error(`Error uploading file: ${error.message}`); + logAxiosError({ + message: `Error uploading code environment file: ${error.message}`, + error, + }); + throw new Error(`Error uploading code environment file: ${error.message}`); } } diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 2a941a4647..c92e628589 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -12,6 +12,7 @@ const { const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { convertImage } = require('~/server/services/Files/images/convert'); const { createFile, getFiles, updateFile } = require('~/models/File'); +const { logAxiosError } = require('~/utils'); const { logger } = require('~/config'); /** @@ -85,7 +86,10 @@ const processCodeOutput = async ({ /** Note: `messageId` & `toolCallId` are not part of file DB schema; message object records associated file ID */ return Object.assign(file, { messageId, toolCallId }); } catch (error) { - logger.error('Error downloading file:', error); + logAxiosError({ + message: 'Error downloading code environment file', + error, + }); } }; @@ -135,7 +139,10 @@ async function getSessionInfo(fileIdentifier, apiKey) { return response.data.find((file) => file.name.startsWith(path))?.lastModified; } catch (error) { - logger.error(`Error fetching session info: ${error.message}`, error); + logAxiosError({ + message: `Error fetching session info: ${error.message}`, + error, + }); return null; } } @@ -202,7 +209,7 @@ const primeFiles = async (options, apiKey) => { const { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions( FileSources.execute_code, ); - const stream = await getDownloadStream(file.filepath); + const stream = await getDownloadStream(options.req, file.filepath); const fileIdentifier = await uploadCodeEnvFile({ req: options.req, stream, diff --git a/api/server/services/Files/Firebase/crud.js b/api/server/services/Files/Firebase/crud.js index 76a6c1d8d4..8319f908ef 100644 --- a/api/server/services/Files/Firebase/crud.js +++ b/api/server/services/Files/Firebase/crud.js @@ -224,10 +224,11 @@ async function uploadFileToFirebase({ req, file, file_id }) { /** * Retrieves a readable stream for a file from Firebase storage. * + * @param {ServerRequest} _req * @param {string} filepath - The filepath. * @returns {Promise} A readable stream of the file. */ -async function getFirebaseFileStream(filepath) { +async function getFirebaseFileStream(_req, filepath) { try { const storage = getFirebaseStorage(); if (!storage) { diff --git a/api/server/services/Files/Local/crud.js b/api/server/services/Files/Local/crud.js index e004eab79e..c2bb75c125 100644 --- a/api/server/services/Files/Local/crud.js +++ b/api/server/services/Files/Local/crud.js @@ -175,6 +175,17 @@ const isValidPath = (req, base, subfolder, filepath) => { return normalizedFilepath.startsWith(normalizedBase); }; +/** + * @param {string} filepath + */ +const unlinkFile = async (filepath) => { + try { + await fs.promises.unlink(filepath); + } catch (error) { + logger.error('Error deleting file:', error); + } +}; + /** * Deletes a file from the filesystem. This function takes a file object, constructs the full path, and * verifies the path's validity before deleting the file. If the path is invalid, an error is thrown. @@ -217,7 +228,7 @@ const deleteLocalFile = async (req, file) => { throw new Error(`Invalid file path: ${file.filepath}`); } - await fs.promises.unlink(filepath); + await unlinkFile(filepath); return; } @@ -233,7 +244,7 @@ const deleteLocalFile = async (req, file) => { throw new Error('Invalid file path'); } - await fs.promises.unlink(filepath); + await unlinkFile(filepath); }; /** @@ -275,11 +286,31 @@ async function uploadLocalFile({ req, file, file_id }) { /** * Retrieves a readable stream for a file from local storage. * + * @param {ServerRequest} req - The request object from Express * @param {string} filepath - The filepath. * @returns {ReadableStream} A readable stream of the file. */ -function getLocalFileStream(filepath) { +function getLocalFileStream(req, filepath) { try { + if (filepath.includes('/uploads/')) { + const basePath = filepath.split('/uploads/')[1]; + + if (!basePath) { + logger.warn(`Invalid base path: ${filepath}`); + throw new Error(`Invalid file path: ${filepath}`); + } + + const fullPath = path.join(req.app.locals.paths.uploads, basePath); + const uploadsDir = req.app.locals.paths.uploads; + + const rel = path.relative(uploadsDir, fullPath); + if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(`..${path.sep}`)) { + logger.warn(`Invalid relative file path: ${filepath}`); + throw new Error(`Invalid file path: ${filepath}`); + } + + return fs.createReadStream(fullPath); + } return fs.createReadStream(filepath); } catch (error) { logger.error('Error getting local file stream:', error); diff --git a/api/server/services/Files/VectorDB/crud.js b/api/server/services/Files/VectorDB/crud.js index d290eea4b1..37a1e81487 100644 --- a/api/server/services/Files/VectorDB/crud.js +++ b/api/server/services/Files/VectorDB/crud.js @@ -37,7 +37,14 @@ const deleteVectors = async (req, file) => { error, message: 'Error deleting vectors', }); - throw new Error(error.message || 'An error occurred during file deletion.'); + if ( + error.response && + error.response.status !== 404 && + (error.response.status < 200 || error.response.status >= 300) + ) { + logger.warn('Error deleting vectors, file will not be deleted'); + throw new Error(error.message || 'An error occurred during file deletion.'); + } } }; diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index a5d9c8c1e0..8744eb409b 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -347,8 +347,8 @@ const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true }) req.app.locals.imageOutputType }`; } - - const filepath = await saveBuffer({ userId: req.user.id, fileName: filename, buffer }); + const fileName = `${file_id}-${filename}`; + const filepath = await saveBuffer({ userId: req.user.id, fileName, buffer }); return await createFile( { user: req.user.id, @@ -801,8 +801,7 @@ async function saveBase64Image( { req, file_id: _file_id, filename: _filename, endpoint, context, resolution = 'high' }, ) { const file_id = _file_id ?? v4(); - - let filename = _filename; + let filename = `${file_id}-${_filename}`; const { buffer: inputBuffer, type } = base64ToBuffer(url); if (!path.extname(_filename)) { const extension = mime.getExtension(type); diff --git a/api/server/services/ModelService.js b/api/server/services/ModelService.js index 1394a5d697..9630f0bd87 100644 --- a/api/server/services/ModelService.js +++ b/api/server/services/ModelService.js @@ -1,4 +1,5 @@ const axios = require('axios'); +const { Providers } = require('@librechat/agents'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { EModelEndpoint, defaultModels, CacheKeys } = require('librechat-data-provider'); const { inputSchema, logAxiosError, extractBaseURL, processModelData } = require('~/utils'); @@ -57,7 +58,7 @@ const fetchModels = async ({ return models; } - if (name && name.toLowerCase().startsWith('ollama')) { + if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) { return await OllamaClient.fetchModels(baseURL); } diff --git a/api/server/services/twoFactorService.js b/api/server/services/twoFactorService.js new file mode 100644 index 0000000000..e48b2ac938 --- /dev/null +++ b/api/server/services/twoFactorService.js @@ -0,0 +1,238 @@ +const { sign } = require('jsonwebtoken'); +const { webcrypto } = require('node:crypto'); +const { hashBackupCode, decryptV2 } = require('~/server/utils/crypto'); +const { updateUser } = require('~/models/userMethods'); + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +/** + * Encodes a Buffer into a Base32 string using the RFC 4648 alphabet. + * + * @param {Buffer} buffer - The buffer to encode. + * @returns {string} The Base32 encoded string. + */ +const encodeBase32 = (buffer) => { + let bits = 0; + let value = 0; + let output = ''; + for (const byte of buffer) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) { + output += BASE32_ALPHABET[(value << (5 - bits)) & 31]; + } + return output; +}; + +/** + * Decodes a Base32-encoded string back into a Buffer. + * + * @param {string} base32Str - The Base32-encoded string. + * @returns {Buffer} The decoded buffer. + */ +const decodeBase32 = (base32Str) => { + const cleaned = base32Str.replace(/=+$/, '').toUpperCase(); + let bits = 0; + let value = 0; + const output = []; + for (const char of cleaned) { + const idx = BASE32_ALPHABET.indexOf(char); + if (idx === -1) { + continue; + } + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + output.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Buffer.from(output); +}; + +/** + * Generates a temporary token for 2FA verification. + * The token is signed with the JWT_SECRET and expires in 5 minutes. + * + * @param {string} userId - The unique identifier of the user. + * @returns {string} The signed JWT token. + */ +const generate2FATempToken = (userId) => + sign({ userId, twoFAPending: true }, process.env.JWT_SECRET, { expiresIn: '5m' }); + +/** + * Generates a TOTP secret. + * Creates 10 random bytes using WebCrypto and encodes them into a Base32 string. + * + * @returns {string} A Base32-encoded secret for TOTP. + */ +const generateTOTPSecret = () => { + const randomArray = new Uint8Array(10); + webcrypto.getRandomValues(randomArray); + return encodeBase32(Buffer.from(randomArray)); +}; + +/** + * Generates a Time-based One-Time Password (TOTP) based on the provided secret and time. + * This implementation uses a 30-second time step and produces a 6-digit code. + * + * @param {string} secret - The Base32-encoded TOTP secret. + * @param {number} [forTime=Date.now()] - The time (in milliseconds) for which to generate the TOTP. + * @returns {Promise} A promise that resolves to the 6-digit TOTP code. + */ +const generateTOTP = async (secret, forTime = Date.now()) => { + const timeStep = 30; // seconds + const counter = Math.floor(forTime / 1000 / timeStep); + const counterBuffer = new ArrayBuffer(8); + const counterView = new DataView(counterBuffer); + // Write counter into the last 4 bytes (big-endian) + counterView.setUint32(4, counter, false); + + // Decode the secret into an ArrayBuffer + const keyBuffer = decodeBase32(secret); + const keyArrayBuffer = keyBuffer.buffer.slice( + keyBuffer.byteOffset, + keyBuffer.byteOffset + keyBuffer.byteLength, + ); + + // Import the key for HMAC-SHA1 signing + const cryptoKey = await webcrypto.subtle.importKey( + 'raw', + keyArrayBuffer, + { name: 'HMAC', hash: 'SHA-1' }, + false, + ['sign'], + ); + + // Generate HMAC signature + const signatureBuffer = await webcrypto.subtle.sign('HMAC', cryptoKey, counterBuffer); + const hmac = new Uint8Array(signatureBuffer); + + // Dynamic truncation as per RFC 4226 + const offset = hmac[hmac.length - 1] & 0xf; + const slice = hmac.slice(offset, offset + 4); + const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength); + const binaryCode = view.getUint32(0, false) & 0x7fffffff; + const code = (binaryCode % 1000000).toString().padStart(6, '0'); + return code; +}; + +/** + * Verifies a provided TOTP token against the secret. + * It allows for a ±1 time-step window to account for slight clock discrepancies. + * + * @param {string} secret - The Base32-encoded TOTP secret. + * @param {string} token - The TOTP token provided by the user. + * @returns {Promise} A promise that resolves to true if the token is valid; otherwise, false. + */ +const verifyTOTP = async (secret, token) => { + const timeStepMS = 30 * 1000; + const currentTime = Date.now(); + for (let offset = -1; offset <= 1; offset++) { + const expected = await generateTOTP(secret, currentTime + offset * timeStepMS); + if (expected === token) { + return true; + } + } + return false; +}; + +/** + * Generates backup codes for two-factor authentication. + * Each backup code is an 8-character hexadecimal string along with its SHA-256 hash. + * The plain codes are returned for one-time download, while the hashed objects are meant for secure storage. + * + * @param {number} [count=10] - The number of backup codes to generate. + * @returns {Promise<{ plainCodes: string[], codeObjects: Array<{ codeHash: string, used: boolean, usedAt: Date | null }> }>} + * A promise that resolves to an object containing both plain backup codes and their corresponding code objects. + */ +const generateBackupCodes = async (count = 10) => { + const plainCodes = []; + const codeObjects = []; + const encoder = new TextEncoder(); + for (let i = 0; i < count; i++) { + const randomArray = new Uint8Array(4); + webcrypto.getRandomValues(randomArray); + const code = Array.from(randomArray) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); // 8-character hex code + plainCodes.push(code); + + // Compute SHA-256 hash of the code using WebCrypto + const codeBuffer = encoder.encode(code); + const hashBuffer = await webcrypto.subtle.digest('SHA-256', codeBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const codeHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + codeObjects.push({ codeHash, used: false, usedAt: null }); + } + return { plainCodes, codeObjects }; +}; + +/** + * Verifies a backup code for a user and updates its status as used if valid. + * + * @param {Object} params - The parameters object. + * @param {TUser | undefined} [params.user] - The user object containing backup codes. + * @param {string | undefined} [params.backupCode] - The backup code to verify. + * @returns {Promise} A promise that resolves to true if the backup code is valid and updated; otherwise, false. + */ +const verifyBackupCode = async ({ user, backupCode }) => { + if (!backupCode || !user || !Array.isArray(user.backupCodes)) { + return false; + } + + const hashedInput = await hashBackupCode(backupCode.trim()); + const matchingCode = user.backupCodes.find( + (codeObj) => codeObj.codeHash === hashedInput && !codeObj.used, + ); + + if (matchingCode) { + const updatedBackupCodes = user.backupCodes.map((codeObj) => + codeObj.codeHash === hashedInput && !codeObj.used + ? { ...codeObj, used: true, usedAt: new Date() } + : codeObj, + ); + + await updateUser(user._id, { backupCodes: updatedBackupCodes }); + return true; + } + + return false; +}; + +/** + * Retrieves and, if necessary, decrypts a stored TOTP secret. + * If the secret contains a colon, it is assumed to be in the format "iv:encryptedData" and will be decrypted. + * If the secret is exactly 16 characters long, it is assumed to be a legacy plain secret. + * + * @param {string|null} storedSecret - The stored TOTP secret (which may be encrypted). + * @returns {Promise} A promise that resolves to the plain TOTP secret, or null if none is provided. + */ +const getTOTPSecret = async (storedSecret) => { + if (!storedSecret) { return null; } + // Check for a colon marker (encrypted secrets are stored as "iv:encryptedData") + if (storedSecret.includes(':')) { + return await decryptV2(storedSecret); + } + // If it's exactly 16 characters, assume it's already plain (legacy secret) + if (storedSecret.length === 16) { + return storedSecret; + } + // Fallback in case it doesn't meet our criteria. + return storedSecret; +}; + +module.exports = { + verifyTOTP, + generateTOTP, + getTOTPSecret, + verifyBackupCode, + generateTOTPSecret, + generateBackupCodes, + generate2FATempToken, +}; diff --git a/api/server/utils/crypto.js b/api/server/utils/crypto.js index ea71df51ad..407fad62ac 100644 --- a/api/server/utils/crypto.js +++ b/api/server/utils/crypto.js @@ -112,4 +112,25 @@ async function getRandomValues(length) { return Buffer.from(randomValues).toString('hex'); } -module.exports = { encrypt, decrypt, encryptV2, decryptV2, hashToken, getRandomValues }; +/** + * Computes SHA-256 hash for the given input using WebCrypto + * @param {string} input + * @returns {Promise} - Hex hash string + */ +const hashBackupCode = async (input) => { + const encoder = new TextEncoder(); + const data = encoder.encode(input); + const hashBuffer = await webcrypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); +}; + +module.exports = { + encrypt, + decrypt, + encryptV2, + decryptV2, + hashToken, + hashBackupCode, + getRandomValues, +}; diff --git a/api/strategies/googleStrategy.js b/api/strategies/googleStrategy.js index ab8a268953..fd65823327 100644 --- a/api/strategies/googleStrategy.js +++ b/api/strategies/googleStrategy.js @@ -6,7 +6,7 @@ const getProfileDetails = ({ profile }) => ({ id: profile.id, avatarUrl: profile.photos[0].value, username: profile.name.givenName, - name: `${profile.name.givenName} ${profile.name.familyName}`, + name: `${profile.name.givenName}${profile.name.familyName ? ` ${profile.name.familyName}` : ''}`, emailVerified: profile.emails[0].verified, }); diff --git a/api/strategies/jwtStrategy.js b/api/strategies/jwtStrategy.js index e65b284950..ac19e92ac3 100644 --- a/api/strategies/jwtStrategy.js +++ b/api/strategies/jwtStrategy.js @@ -12,7 +12,7 @@ const jwtLogin = async () => }, async (payload, done) => { try { - const user = await getUserById(payload?.id, '-password -__v'); + const user = await getUserById(payload?.id, '-password -__v -totpSecret'); if (user) { user.id = user._id.toString(); if (!user.role) { diff --git a/api/utils/axios.js b/api/utils/axios.js index 8b12a5ca99..acd23a184f 100644 --- a/api/utils/axios.js +++ b/api/utils/axios.js @@ -5,40 +5,32 @@ const { logger } = require('~/config'); * * @param {Object} options - The options object. * @param {string} options.message - The custom message to be logged. - * @param {Error} options.error - The Axios error object. + * @param {import('axios').AxiosError} options.error - The Axios error object. */ const logAxiosError = ({ message, error }) => { - const timedOutMessage = 'Cannot read properties of undefined (reading \'status\')'; - if (error.response) { - logger.error( - `${message} The request was made and the server responded with a status code that falls out of the range of 2xx: ${ - error.message ? error.message : '' - }. Error response data:\n`, - { - headers: error.response?.headers, - status: error.response?.status, - data: error.response?.data, - }, - ); - } else if (error.request) { - logger.error( - `${message} The request was made but no response was received: ${ - error.message ? error.message : '' - }. Error Request:\n`, - { - request: error.request, - }, - ); - } else if (error?.message?.includes(timedOutMessage)) { - logger.error( - `${message}\nThe request either timed out or was unsuccessful. Error message:\n`, - error, - ); - } else { - logger.error( - `${message}\nSomething happened in setting up the request. Error message:\n`, - error, - ); + try { + if (error.response?.status) { + const { status, headers, data } = error.response; + logger.error(`${message} The server responded with status ${status}: ${error.message}`, { + status, + headers, + data, + }); + } else if (error.request) { + const { method, url } = error.config || {}; + logger.error( + `${message} No response received for ${method ? method.toUpperCase() : ''} ${url || ''}: ${error.message}`, + { requestInfo: { method, url } }, + ); + } else if (error?.message?.includes('Cannot read properties of undefined (reading \'status\')')) { + logger.error( + `${message} It appears the request timed out or was unsuccessful: ${error.message}`, + ); + } else { + logger.error(`${message} An error occurred while setting up the request: ${error.message}`); + } + } catch (err) { + logger.error(`Error in logAxiosError: ${err.message}`); } }; diff --git a/client/package.json b/client/package.json index 993cf30071..df85c2521c 100644 --- a/client/package.json +++ b/client/package.json @@ -28,7 +28,8 @@ }, "homepage": "https://librechat.ai", "dependencies": { - "@ariakit/react": "^0.4.11", + "@ariakit/react": "^0.4.15", + "@ariakit/react-core": "^0.4.15", "@codesandbox/sandpack-react": "^2.19.10", "@dicebear/collection": "^7.0.4", "@dicebear/core": "^7.0.4", @@ -43,6 +44,7 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.0", "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-progress": "^1.1.2", "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", @@ -63,6 +65,8 @@ "framer-motion": "^11.5.4", "html-to-image": "^1.11.11", "i18next": "^24.2.2", + "i18next-browser-languagedetector": "^8.0.3", + "input-otp": "^1.4.2", "js-cookie": "^3.0.5", "librechat-data-provider": "*", "lodash": "^4.17.21", @@ -82,7 +86,7 @@ "react-i18next": "^15.4.0", "react-lazy-load-image-component": "^1.6.0", "react-markdown": "^9.0.1", - "react-resizable-panels": "^2.1.1", + "react-resizable-panels": "^2.1.7", "react-router-dom": "^6.11.2", "react-speech-recognition": "^3.10.0", "react-textarea-autosize": "^8.4.0", diff --git a/client/src/components/Auth/AuthLayout.tsx b/client/src/components/Auth/AuthLayout.tsx index b5a71e08aa..cb0f651ad9 100644 --- a/client/src/components/Auth/AuthLayout.tsx +++ b/client/src/components/Auth/AuthLayout.tsx @@ -97,7 +97,7 @@ function AuthLayout({ ) : ( <> {children} - {(pathname.includes('login') || pathname.includes('register')) && ( + {!pathname.includes('2fa') && (pathname.includes('login') || pathname.includes('register')) && ( = ({ onSubmit, startupConfig, error, type="submit" className=" w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white - transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 - focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 - disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700 + transition-colors hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 " > {localize('com_auth_continue')} diff --git a/client/src/components/Auth/TwoFactorScreen.tsx b/client/src/components/Auth/TwoFactorScreen.tsx new file mode 100644 index 0000000000..04f89d7cea --- /dev/null +++ b/client/src/components/Auth/TwoFactorScreen.tsx @@ -0,0 +1,176 @@ +import React, { useState, useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useForm, Controller } from 'react-hook-form'; +import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp'; +import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label } from '~/components'; +import { useVerifyTwoFactorTempMutation } from '~/data-provider'; +import { useToastContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; + +interface VerifyPayload { + tempToken: string; + token?: string; + backupCode?: string; +} + +type TwoFactorFormInputs = { + token?: string; + backupCode?: string; +}; + +const TwoFactorScreen: React.FC = React.memo(() => { + const [searchParams] = useSearchParams(); + const tempTokenRaw = searchParams.get('tempToken'); + const tempToken = tempTokenRaw !== null && tempTokenRaw !== '' ? tempTokenRaw : ''; + + const { + control, + handleSubmit, + formState: { errors }, + } = useForm(); + const localize = useLocalize(); + const { showToast } = useToastContext(); + const [useBackup, setUseBackup] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const { mutate: verifyTempMutate } = useVerifyTwoFactorTempMutation({ + onSuccess: (result) => { + if (result.token != null && result.token !== '') { + window.location.href = '/'; + } + }, + onMutate: () => { + setIsLoading(true); + }, + onError: (error: unknown) => { + setIsLoading(false); + const err = error as { response?: { data?: { message?: unknown } } }; + const errorMsg = + typeof err.response?.data?.message === 'string' + ? err.response.data.message + : 'Error verifying 2FA'; + showToast({ message: errorMsg, status: 'error' }); + }, + }); + + const onSubmit = useCallback( + (data: TwoFactorFormInputs) => { + const payload: VerifyPayload = { tempToken }; + if (useBackup && data.backupCode != null && data.backupCode !== '') { + payload.backupCode = data.backupCode; + } else if (data.token != null && data.token !== '') { + payload.token = data.token; + } + verifyTempMutate(payload); + }, + [tempToken, useBackup, verifyTempMutate], + ); + + const toggleBackupOn = useCallback(() => { + setUseBackup(true); + }, []); + + const toggleBackupOff = useCallback(() => { + setUseBackup(false); + }, []); + + return ( +
+
+ + {!useBackup && ( +
+ ( + + + + + + + + + + + + + + )} + /> + {errors.token && {errors.token.message}} +
+ )} + {useBackup && ( +
+ ( + + + + + + + + + + + + + )} + /> + {errors.backupCode && ( + {errors.backupCode.message} + )} +
+ )} +
+ +
+
+ {!useBackup ? ( + + ) : ( + + )} +
+
+
+ ); +}); + +export default TwoFactorScreen; diff --git a/client/src/components/Auth/index.ts b/client/src/components/Auth/index.ts index cd1ac1adce..afde148015 100644 --- a/client/src/components/Auth/index.ts +++ b/client/src/components/Auth/index.ts @@ -4,3 +4,4 @@ export { default as ResetPassword } from './ResetPassword'; export { default as VerifyEmail } from './VerifyEmail'; export { default as ApiErrorWatcher } from './ApiErrorWatcher'; export { default as RequestPasswordReset } from './RequestPasswordReset'; +export { default as TwoFactorScreen } from './TwoFactorScreen'; diff --git a/client/src/components/Chat/Input/Files/FileUpload.tsx b/client/src/components/Chat/Input/Files/FileUpload.tsx index 506f50c01d..723fa32e86 100644 --- a/client/src/components/Chat/Input/Files/FileUpload.tsx +++ b/client/src/components/Chat/Input/Files/FileUpload.tsx @@ -55,7 +55,7 @@ const FileUpload: React.FC = ({ let statusText: string; if (!status) { - statusText = text ?? localize('com_endpoint_import'); + statusText = text ?? localize('com_ui_import'); } else if (status === 'success') { statusText = successText ?? localize('com_ui_upload_success'); } else { @@ -72,12 +72,12 @@ const FileUpload: React.FC = ({ )} > - {statusText} + {statusText} diff --git a/client/src/components/Chat/Input/HeaderOptions.tsx b/client/src/components/Chat/Input/HeaderOptions.tsx index 0bd3326b53..5313f43b8d 100644 --- a/client/src/components/Chat/Input/HeaderOptions.tsx +++ b/client/src/components/Chat/Input/HeaderOptions.tsx @@ -1,8 +1,13 @@ import { useRecoilState } from 'recoil'; import { Settings2 } from 'lucide-react'; -import { Root, Anchor } from '@radix-ui/react-popover'; import { useState, useEffect, useMemo } from 'react'; -import { tConvoUpdateSchema, EModelEndpoint, isParamEndpoint } from 'librechat-data-provider'; +import { Root, Anchor } from '@radix-ui/react-popover'; +import { + EModelEndpoint, + isParamEndpoint, + isAgentsEndpoint, + tConvoUpdateSchema, +} from 'librechat-data-provider'; import type { TPreset, TInterfaceConfig } from 'librechat-data-provider'; import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints'; import { PluginStoreDialog, TooltipAnchor } from '~/components'; @@ -42,7 +47,6 @@ export default function HeaderOptions({ if (endpoint && noSettings[endpoint]) { setShowPopover(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [endpoint, noSettings]); const saveAsPreset = () => { @@ -67,7 +71,7 @@ export default function HeaderOptions({
- {interfaceConfig?.modelSelect === true && ( + {interfaceConfig?.modelSelect === true && !isAgentsEndpoint(endpoint) && (
- {localize('com_nav_balance')}: ${parseFloat(balanceQuery.data).toFixed(2)} + {localize('com_nav_balance')}: {parseFloat(balanceQuery.data).toFixed(2)}
diff --git a/client/src/components/Nav/SettingsTabs/Account/Account.tsx b/client/src/components/Nav/SettingsTabs/Account/Account.tsx index d6e9c55b79..4d883bb94d 100644 --- a/client/src/components/Nav/SettingsTabs/Account/Account.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/Account.tsx @@ -3,19 +3,36 @@ import DisplayUsernameMessages from './DisplayUsernameMessages'; import DeleteAccount from './DeleteAccount'; import Avatar from './Avatar'; import PassKeys from './PassKeys'; +import EnableTwoFactorItem from './TwoFactorAuthentication'; +import BackupCodesItem from './BackupCodesItem'; +import { useAuthContext } from '~/hooks'; function Account() { + const user = useAuthContext(); + return (
+
+ +
+ {user?.user?.provider === 'local' && ( + <> +
+ +
+ {Array.isArray(user.user?.backupCodes) && user.user?.backupCodes.length > 0 && ( +
+ +
+ )} + + )}
-
- -
diff --git a/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx b/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx index 48f585bdbb..5ecdb5a990 100644 --- a/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx @@ -47,7 +47,7 @@ function Avatar() { const { mutate: uploadAvatar, isLoading: isUploading } = useUploadAvatarMutation({ onSuccess: (data) => { showToast({ message: localize('com_ui_upload_success') }); - setUser((prev) => ({ ...prev, avatar: data.url } as TUser)); + setUser((prev) => ({ ...prev, avatar: data.url }) as TUser); openButtonRef.current?.click(); }, onError: (error) => { @@ -133,9 +133,11 @@ function Avatar() { >
{localize('com_nav_profile_picture')} - - - {localize('com_nav_change_picture')} + +
diff --git a/client/src/components/Nav/SettingsTabs/Account/BackupCodesItem.tsx b/client/src/components/Nav/SettingsTabs/Account/BackupCodesItem.tsx new file mode 100644 index 0000000000..a034e2773a --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Account/BackupCodesItem.tsx @@ -0,0 +1,194 @@ +import React, { useState } from 'react'; +import { RefreshCcw, ShieldX } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { TBackupCode, TRegenerateBackupCodesResponse, type TUser } from 'librechat-data-provider'; +import { + OGDialog, + OGDialogContent, + OGDialogTitle, + OGDialogTrigger, + Button, + Label, + Spinner, + TooltipAnchor, +} from '~/components'; +import { useRegenerateBackupCodesMutation } from '~/data-provider'; +import { useAuthContext, useLocalize } from '~/hooks'; +import { useToastContext } from '~/Providers'; +import { useSetRecoilState } from 'recoil'; +import store from '~/store'; + +const BackupCodesItem: React.FC = () => { + const localize = useLocalize(); + const { user } = useAuthContext(); + const { showToast } = useToastContext(); + const setUser = useSetRecoilState(store.user); + const [isDialogOpen, setDialogOpen] = useState(false); + + const { mutate: regenerateBackupCodes, isLoading } = useRegenerateBackupCodesMutation(); + + const fetchBackupCodes = (auto: boolean = false) => { + regenerateBackupCodes(undefined, { + onSuccess: (data: TRegenerateBackupCodesResponse) => { + const newBackupCodes: TBackupCode[] = data.backupCodesHash.map((codeHash) => ({ + codeHash, + used: false, + usedAt: null, + })); + + setUser((prev) => ({ ...prev, backupCodes: newBackupCodes }) as TUser); + showToast({ + message: localize('com_ui_backup_codes_regenerated'), + status: 'success', + }); + + // Trigger file download only when user explicitly clicks the button. + if (!auto && newBackupCodes.length) { + const codesString = data.backupCodes.join('\n'); + const blob = new Blob([codesString], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'backup-codes.txt'; + a.click(); + URL.revokeObjectURL(url); + } + }, + onError: () => + showToast({ + message: localize('com_ui_backup_codes_regenerate_error'), + status: 'error', + }), + }); + }; + + const handleRegenerate = () => { + fetchBackupCodes(false); + }; + + return ( + +
+
+ +
+ + + +
+ + + + {localize('com_ui_backup_codes')} + + + + + {Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? ( + <> +
+ {user?.backupCodes.map((code, index) => { + const isUsed = code.used; + const description = `Backup code number ${index + 1}, ${ + isUsed + ? `used on ${code.usedAt ? new Date(code.usedAt).toLocaleDateString() : 'an unknown date'}` + : 'not used yet' + }`; + + return ( + { + const announcement = new CustomEvent('announce', { + detail: { message: description }, + }); + document.dispatchEvent(announcement); + }} + className={`flex flex-col rounded-xl border p-4 backdrop-blur-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary ${ + isUsed + ? 'border-red-200 bg-red-50/80 dark:border-red-800 dark:bg-red-900/20' + : 'border-green-200 bg-green-50/80 dark:border-green-800 dark:bg-green-900/20' + } `} + > + + + ); + })} +
+
+ +
+ + ) : ( +
+ +

{localize('com_ui_no_backup_codes')}

+ +
+ )} +
+
+
+
+ ); +}; + +export default React.memo(BackupCodesItem); diff --git a/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx b/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx index 1c1e207d58..b00e7498bc 100644 --- a/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx @@ -57,7 +57,7 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
- + {localize('com_nav_delete_account_confirm')} diff --git a/client/src/components/Nav/SettingsTabs/Account/DisableTwoFactorToggle.tsx b/client/src/components/Nav/SettingsTabs/Account/DisableTwoFactorToggle.tsx new file mode 100644 index 0000000000..5dfad770d3 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Account/DisableTwoFactorToggle.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { LockIcon, UnlockIcon } from 'lucide-react'; +import { Label, Button } from '~/components'; +import { useLocalize } from '~/hooks'; + +interface DisableTwoFactorToggleProps { + enabled: boolean; + onChange: () => void; + disabled?: boolean; +} + +export const DisableTwoFactorToggle: React.FC = ({ + enabled, + onChange, + disabled, +}) => { + const localize = useLocalize(); + + return ( +
+
+ +
+
+ +
+
+ ); +}; diff --git a/client/src/components/Nav/SettingsTabs/Account/TwoFactorAuthentication.tsx b/client/src/components/Nav/SettingsTabs/Account/TwoFactorAuthentication.tsx new file mode 100644 index 0000000000..bd46e80249 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Account/TwoFactorAuthentication.tsx @@ -0,0 +1,298 @@ +import React, { useCallback, useState } from 'react'; +import { useSetRecoilState } from 'recoil'; +import { SmartphoneIcon } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import type { TUser, TVerify2FARequest } from 'librechat-data-provider'; +import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle, Progress } from '~/components'; +import { SetupPhase, QRPhase, VerifyPhase, BackupPhase, DisablePhase } from './TwoFactorPhases'; +import { DisableTwoFactorToggle } from './DisableTwoFactorToggle'; +import { useAuthContext, useLocalize } from '~/hooks'; +import { useToastContext } from '~/Providers'; +import store from '~/store'; +import { + useConfirmTwoFactorMutation, + useDisableTwoFactorMutation, + useEnableTwoFactorMutation, + useVerifyTwoFactorMutation, +} from '~/data-provider'; + +export type Phase = 'setup' | 'qr' | 'verify' | 'backup' | 'disable'; + +const phaseVariants = { + initial: { opacity: 0, scale: 0.95 }, + animate: { opacity: 1, scale: 1, transition: { duration: 0.3, ease: 'easeOut' } }, + exit: { opacity: 0, scale: 0.95, transition: { duration: 0.3, ease: 'easeIn' } }, +}; + +const TwoFactorAuthentication: React.FC = () => { + const localize = useLocalize(); + const { user } = useAuthContext(); + const setUser = useSetRecoilState(store.user); + const { showToast } = useToastContext(); + + const [secret, setSecret] = useState(''); + const [otpauthUrl, setOtpauthUrl] = useState(''); + const [downloaded, setDownloaded] = useState(false); + const [disableToken, setDisableToken] = useState(''); + const [backupCodes, setBackupCodes] = useState([]); + const [isDialogOpen, setDialogOpen] = useState(false); + const [verificationToken, setVerificationToken] = useState(''); + const [phase, setPhase] = useState(Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? 'disable' : 'setup'); + + const { mutate: confirm2FAMutate } = useConfirmTwoFactorMutation(); + const { mutate: enable2FAMutate, isLoading: isGenerating } = useEnableTwoFactorMutation(); + const { mutate: verify2FAMutate, isLoading: isVerifying } = useVerifyTwoFactorMutation(); + const { mutate: disable2FAMutate, isLoading: isDisabling } = useDisableTwoFactorMutation(); + + const steps = ['Setup', 'Scan QR', 'Verify', 'Backup']; + const phasesLabel: Record = { + setup: 'Setup', + qr: 'Scan QR', + verify: 'Verify', + backup: 'Backup', + disable: '', + }; + + const currentStep = steps.indexOf(phasesLabel[phase]); + + const resetState = useCallback(() => { + if (Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 && otpauthUrl) { + disable2FAMutate(undefined, { + onError: () => + showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }), + }); + } + + setOtpauthUrl(''); + setSecret(''); + setBackupCodes([]); + setVerificationToken(''); + setDisableToken(''); + setPhase(Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? 'disable' : 'setup'); + setDownloaded(false); + }, [user, otpauthUrl, disable2FAMutate, localize, showToast]); + + const handleGenerateQRCode = useCallback(() => { + enable2FAMutate(undefined, { + onSuccess: ({ otpauthUrl, backupCodes }) => { + setOtpauthUrl(otpauthUrl); + setSecret(otpauthUrl.split('secret=')[1].split('&')[0]); + setBackupCodes(backupCodes); + setPhase('qr'); + }, + onError: () => showToast({ message: localize('com_ui_2fa_generate_error'), status: 'error' }), + }); + }, [enable2FAMutate, localize, showToast]); + + const handleVerify = useCallback(() => { + if (!verificationToken) { + return; + } + + verify2FAMutate( + { token: verificationToken }, + { + onSuccess: () => { + showToast({ message: localize('com_ui_2fa_verified') }); + confirm2FAMutate( + { token: verificationToken }, + { + onSuccess: () => setPhase('backup'), + onError: () => + showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }), + }, + ); + }, + onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }), + }, + ); + }, [verificationToken, verify2FAMutate, confirm2FAMutate, localize, showToast]); + + const handleDownload = useCallback(() => { + if (!backupCodes.length) { + return; + } + const blob = new Blob([backupCodes.join('\n')], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'backup-codes.txt'; + a.click(); + URL.revokeObjectURL(url); + setDownloaded(true); + }, [backupCodes]); + + const handleConfirm = useCallback(() => { + setDialogOpen(false); + setPhase('disable'); + showToast({ message: localize('com_ui_2fa_enabled') }); + setUser( + (prev) => + ({ + ...prev, + backupCodes: backupCodes.map((code) => ({ + code, + codeHash: code, + used: false, + usedAt: null, + })), + }) as TUser, + ); + }, [setUser, localize, showToast, backupCodes]); + + const handleDisableVerify = useCallback( + (token: string, useBackup: boolean) => { + // Validate: if not using backup, ensure token has at least 6 digits; + // if using backup, ensure backup code has at least 8 characters. + if (!useBackup && token.trim().length < 6) { + return; + } + + if (useBackup && token.trim().length < 8) { + return; + } + + const payload: TVerify2FARequest = {}; + if (useBackup) { + payload.backupCode = token.trim(); + } else { + payload.token = token.trim(); + } + + verify2FAMutate(payload, { + onSuccess: () => { + disable2FAMutate(undefined, { + onSuccess: () => { + showToast({ message: localize('com_ui_2fa_disabled') }); + setDialogOpen(false); + setUser( + (prev) => + ({ + ...prev, + totpSecret: '', + backupCodes: [], + }) as TUser, + ); + setPhase('setup'); + setOtpauthUrl(''); + }, + onError: () => + showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }), + }); + }, + onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }), + }); + }, + [disableToken, verify2FAMutate, disable2FAMutate, showToast, localize, setUser], + ); + + return ( + { + setDialogOpen(open); + if (!open) { + resetState(); + } + }} + > + 0} + onChange={() => setDialogOpen(true)} + disabled={isVerifying || isDisabling || isGenerating} + /> + + + + + + + + {Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? localize('com_ui_2fa_disable') : localize('com_ui_2fa_setup')} + + {Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 && phase !== 'disable' && ( +
+ +
+ {steps.map((step, index) => ( + = index ? 'var(--text-primary)' : 'var(--text-tertiary)', + }} + className="font-medium" + > + {step} + + ))} +
+
+ )} +
+ + + {phase === 'setup' && ( + setPhase('qr')} + onError={(error) => showToast({ message: error.message, status: 'error' })} + /> + )} + + {phase === 'qr' && ( + setPhase('verify')} + onError={(error) => showToast({ message: error.message, status: 'error' })} + /> + )} + + {phase === 'verify' && ( + showToast({ message: error.message, status: 'error' })} + /> + )} + + {phase === 'backup' && ( + showToast({ message: error.message, status: 'error' })} + /> + )} + + {phase === 'disable' && ( + showToast({ message: error.message, status: 'error' })} + /> + )} + +
+
+
+
+ ); +}; + +export default React.memo(TwoFactorAuthentication); diff --git a/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/BackupPhase.tsx b/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/BackupPhase.tsx new file mode 100644 index 0000000000..67e05a1423 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/BackupPhase.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { Download } from 'lucide-react'; +import { Button, Label } from '~/components'; +import { useLocalize } from '~/hooks'; + +const fadeAnimation = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 }, + transition: { duration: 0.2 }, +}; + +interface BackupPhaseProps { + onNext: () => void; + onError: (error: Error) => void; + backupCodes: string[]; + onDownload: () => void; + downloaded: boolean; +} + +export const BackupPhase: React.FC = ({ + backupCodes, + onDownload, + downloaded, + onNext, +}) => { + const localize = useLocalize(); + + return ( + + +
+ {backupCodes.map((code, index) => ( + +
+ #{index + 1} + {code} +
+
+ ))} +
+
+ + +
+
+ ); +}; diff --git a/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/DisablePhase.tsx b/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/DisablePhase.tsx new file mode 100644 index 0000000000..27422d26c3 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/DisablePhase.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp'; +import { + Button, + InputOTP, + InputOTPGroup, + InputOTPSlot, + InputOTPSeparator, + Spinner, +} from '~/components'; +import { useLocalize } from '~/hooks'; + +const fadeAnimation = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 }, + transition: { duration: 0.2 }, +}; + +interface DisablePhaseProps { + onSuccess?: () => void; + onError?: (error: Error) => void; + onDisable: (token: string, useBackup: boolean) => void; + isDisabling: boolean; +} + +export const DisablePhase: React.FC = ({ onDisable, isDisabling }) => { + const localize = useLocalize(); + const [token, setToken] = useState(''); + const [useBackup, setUseBackup] = useState(false); + + return ( + +
+ + {useBackup ? ( + + + + + + + + + + + ) : ( + <> + + + + + + + + + + + + + )} + +
+ + +
+ ); +}; diff --git a/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/QRPhase.tsx b/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/QRPhase.tsx new file mode 100644 index 0000000000..7a0eccae3f --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/QRPhase.tsx @@ -0,0 +1,66 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { QRCodeSVG } from 'qrcode.react'; +import { Copy, Check } from 'lucide-react'; +import { Input, Button, Label } from '~/components'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +const fadeAnimation = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 }, + transition: { duration: 0.2 }, +}; + +interface QRPhaseProps { + secret: string; + otpauthUrl: string; + onNext: () => void; + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +export const QRPhase: React.FC = ({ secret, otpauthUrl, onNext }) => { + const localize = useLocalize(); + const [isCopying, setIsCopying] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(secret); + setIsCopying(true); + setTimeout(() => setIsCopying(false), 2000); + }; + + return ( + +
+ + + +
+ +
+ + +
+
+
+ +
+ ); +}; diff --git a/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/SetupPhase.tsx b/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/SetupPhase.tsx new file mode 100644 index 0000000000..4fd2d1181d --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/SetupPhase.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { QrCode } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { Button, Spinner } from '~/components'; +import { useLocalize } from '~/hooks'; + +const fadeAnimation = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 }, + transition: { duration: 0.2 }, +}; + +interface SetupPhaseProps { + onNext: () => void; + onError: (error: Error) => void; + isGenerating: boolean; + onGenerate: () => void; +} + +export const SetupPhase: React.FC = ({ isGenerating, onGenerate, onNext }) => { + const localize = useLocalize(); + + return ( + +
+

+ {localize('com_ui_2fa_account_security')} +

+ +
+
+ ); +}; diff --git a/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/VerifyPhase.tsx b/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/VerifyPhase.tsx new file mode 100644 index 0000000000..e872dfa0d2 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/VerifyPhase.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { Button, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '~/components'; +import { REGEXP_ONLY_DIGITS } from 'input-otp'; +import { useLocalize } from '~/hooks'; + +const fadeAnimation = { + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -20 }, + transition: { duration: 0.2 }, +}; + +interface VerifyPhaseProps { + token: string; + onTokenChange: (value: string) => void; + isVerifying: boolean; + onNext: () => void; + onError: (error: Error) => void; +} + +export const VerifyPhase: React.FC = ({ + token, + onTokenChange, + isVerifying, + onNext, +}) => { + const localize = useLocalize(); + + return ( + +
+ + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + +
+ +
+ ); +}; diff --git a/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/index.ts b/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/index.ts new file mode 100644 index 0000000000..1cc474efef --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Account/TwoFactorPhases/index.ts @@ -0,0 +1,5 @@ +export * from './BackupPhase'; +export * from './QRPhase'; +export * from './VerifyPhase'; +export * from './SetupPhase'; +export * from './DisablePhase'; diff --git a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx index c39e8351e8..e3bafd9152 100644 --- a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx @@ -82,7 +82,7 @@ function ImportConversations() { onClick={handleImportClick} onKeyDown={handleKeyDown} disabled={!allowImport} - aria-label={localize('com_ui_import_conversation')} + aria-label={localize('com_ui_import')} className="btn btn-neutral relative" > {allowImport ? ( @@ -90,7 +90,7 @@ function ImportConversations() { ) : ( )} - {localize('com_ui_import_conversation')} + {localize('com_ui_import')} setIsOpen(true)}> - + +