From 5f8242528db4375e208add429f81473e3c80832f Mon Sep 17 00:00:00 2001 From: "Olkhovski, Eugene" Date: Thu, 27 Nov 2025 09:23:37 -0500 Subject: [PATCH] Add Eugene's OpenWebUI integrations with placeholder credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrations added: - Google Drive integration with OAuth - Zoho People API integration - Google OAuth authentication - IntraSearch component - Corporate authentication support Features: - New routers: google_drive, zoho_people, intrasearch, services - OAuth token management service - Configuration loaders and utilities - Setup scripts and documentation - Vite proxy configuration for development All credentials replaced with placeholders for security. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .env.example | 237 ++++--- openwebui/.env.example | 96 ++- openwebui/CORPORATE_SETUP.md | 159 +++++ openwebui/GOOGLE_OAUTH_SETUP.md | 122 ++++ openwebui/INTRASEARCH_INDEPENDENCE.md | 137 +++++ openwebui/OAUTH_CONFIG_README.md | 133 ++++ openwebui/PYODIDE_OPTIMIZATION.md | 50 ++ openwebui/SETUP_COMPLETE.md | 84 +++ .../open_webui/integrations/__init__.py | 1 + .../open_webui/integrations/google_drive.py | 351 +++++++++++ openwebui/backend/open_webui/main.py | 16 + .../open_webui/routers/google_drive.py | 182 ++++++ .../backend/open_webui/routers/intrasearch.py | 403 ++++++++++++ .../backend/open_webui/routers/services.py | 578 ++++++++++++++++++ .../backend/open_webui/routers/zoho_people.py | 232 +++++++ .../backend/open_webui/services/__init__.py | 1 + .../services/oauth_token_manager.py | 315 ++++++++++ .../open_webui/services/zoho_people.py | 281 +++++++++ .../open_webui/utils/oauth_config_loader.py | 114 ++++ .../open_webui/utils/oauth_services.py | 200 ++++++ openwebui/backend/start-with-oauth.sh | 86 +++ openwebui/corporate_config.json | 25 + openwebui/corporate_config_template.json | 25 + openwebui/oauth_config.json | 74 +++ openwebui/setup_openwebui.sh | 116 ++++ .../src/routes/(app)/intrasearch/+page.svelte | 23 + openwebui/start-openai-only.sh | 76 +++ openwebui/start-with-oauth.sh | 24 + openwebui/vite.config.ts | 21 + 29 files changed, 4034 insertions(+), 128 deletions(-) create mode 100755 openwebui/CORPORATE_SETUP.md create mode 100755 openwebui/GOOGLE_OAUTH_SETUP.md create mode 100644 openwebui/INTRASEARCH_INDEPENDENCE.md create mode 100644 openwebui/OAUTH_CONFIG_README.md create mode 100755 openwebui/PYODIDE_OPTIMIZATION.md create mode 100755 openwebui/SETUP_COMPLETE.md create mode 100755 openwebui/backend/open_webui/integrations/__init__.py create mode 100755 openwebui/backend/open_webui/integrations/google_drive.py create mode 100755 openwebui/backend/open_webui/routers/google_drive.py create mode 100644 openwebui/backend/open_webui/routers/intrasearch.py create mode 100755 openwebui/backend/open_webui/routers/services.py create mode 100644 openwebui/backend/open_webui/routers/zoho_people.py create mode 100755 openwebui/backend/open_webui/services/__init__.py create mode 100755 openwebui/backend/open_webui/services/oauth_token_manager.py create mode 100644 openwebui/backend/open_webui/services/zoho_people.py create mode 100644 openwebui/backend/open_webui/utils/oauth_config_loader.py create mode 100755 openwebui/backend/open_webui/utils/oauth_services.py create mode 100755 openwebui/backend/start-with-oauth.sh create mode 100755 openwebui/corporate_config.json create mode 100755 openwebui/corporate_config_template.json create mode 100644 openwebui/oauth_config.json create mode 100755 openwebui/setup_openwebui.sh create mode 100755 openwebui/src/routes/(app)/intrasearch/+page.svelte create mode 100755 openwebui/start-openai-only.sh create mode 100755 openwebui/start-with-oauth.sh diff --git a/.env.example b/.env.example index b6372747d9..b39e416b6b 100644 --- a/.env.example +++ b/.env.example @@ -1,123 +1,114 @@ -# Description: Example of .env file -# Usage: Copy this file to .env and change the values -# according to your needs -# Do not commit .env file to git -# Do not change .env.example file - -# Config directory -# Directory where files, logs and database will be stored -# Example: LANGBUILDER_CONFIG_DIR=~/.langbuilder -LANGBUILDER_CONFIG_DIR= - -# Save database in the config directory -# Values: true, false -# If false, the database will be saved in LangBuilder's root directory -# This means that the database will be deleted when LangBuilder is uninstalled -# and that the database will not be shared between different virtual environments -# Example: LANGBUILDER_SAVE_DB_IN_CONFIG_DIR=true -LANGBUILDER_SAVE_DB_IN_CONFIG_DIR= - -# Database URL -# Postgres example: LANGBUILDER_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/langbuilder -# SQLite example: -LANGBUILDER_DATABASE_URL=sqlite:///./langbuilder.db - -# Database connection retry -# Values: true, false -# If true, the database will retry to connect to the database if it fails -# Example: LANGBUILDER_DATABASE_CONNECTION_RETRY=true -LANGBUILDER_DATABASE_CONNECTION_RETRY=false - -# Cache type -LANGBUILDER_LANGCHAIN_CACHE=SQLiteCache - -# Server host -# Example: LANGBUILDER_HOST=localhost -LANGBUILDER_HOST= - -# Worker processes -# Example: LANGBUILDER_WORKERS=1 -LANGBUILDER_WORKERS= - -# Server port -# Example: LANGBUILDER_PORT=7860 -LANGBUILDER_PORT= - -# Logging level -# Example: LANGBUILDER_LOG_LEVEL=critical -LANGBUILDER_LOG_LEVEL= - -# Path to the log file -# Example: LANGBUILDER_LOG_FILE=logs/langbuilder.log -LANGBUILDER_LOG_FILE= - -# Time/Size for log to rotate -# Example: LANGBUILDER_LOG_ROTATION=‘10 MB’/‘1 day’ -LANGBUILDER_LOG_ROTATION= - -# Path to the frontend directory containing build files -# Example: LANGBUILDER_FRONTEND_PATH=/path/to/frontend/build/files -LANGBUILDER_FRONTEND_PATH= - -# Whether to open the browser after starting the server -# Values: true, false -# Example: LANGBUILDER_OPEN_BROWSER=true -LANGBUILDER_OPEN_BROWSER= - -# Whether to remove API keys from the projects saved in the database -# Values: true, false -# Example: LANGBUILDER_REMOVE_API_KEYS=false -LANGBUILDER_REMOVE_API_KEYS= - -# Whether to use RedisCache or ThreadingInMemoryCache or AsyncInMemoryCache -# Values: async, memory, redis -# Example: LANGBUILDER_CACHE_TYPE=memory -# If you want to use redis then the following environment variables must be set: -# LANGBUILDER_REDIS_HOST (default: localhost) -# LANGBUILDER_REDIS_PORT (default: 6379) -# LANGBUILDER_REDIS_DB (default: 0) -# LANGBUILDER_REDIS_CACHE_EXPIRE (default: 3600) -LANGBUILDER_CACHE_TYPE= - -# Set LANGBUILDER_AUTO_LOGIN to false if you want to disable auto login -# and use the login form to login. LANGBUILDER_SUPERUSER and LANGBUILDER_SUPERUSER_PASSWORD -# must be set if AUTO_LOGIN is set to false -# Values: true, false -LANGBUILDER_AUTO_LOGIN= - -# SET LANGBUILDER_ENABLE_SUPERUSER_CLI to false to disable -# superuser creation via the CLI -LANGBUILDER_ENABLE_SUPERUSER_CLI= - -# Superuser username -# Example: LANGBUILDER_SUPERUSER=admin -LANGBUILDER_SUPERUSER= - -# Superuser password -# Example: LANGBUILDER_SUPERUSER_PASSWORD=123456 -LANGBUILDER_SUPERUSER_PASSWORD= - -# Should store environment variables in the database -# Values: true, false -LANGBUILDER_STORE_ENVIRONMENT_VARIABLES= - -# Should enable the MCP composer feature in MCP projects -# Values: true, false -# Default: false -LANGBUILDER_MCP_COMPOSER_ENABLED= - -# STORE_URL -# Example: LANGBUILDER_STORE_URL=https://api.langbuilder.store -# LANGBUILDER_STORE_URL= - -# DOWNLOAD_WEBHOOK_URL -# -# LANGBUILDER_DOWNLOAD_WEBHOOK_URL= - -# LIKE_WEBHOOK_URL -# -# LANGBUILDER_LIKE_WEBHOOK_URL= - -# Value must finish with slash / -#BACKEND_URL=http://localhost:7860/ -BACKEND_URL= +# Ollama URL for the backend to connect +# The path '/ollama' will be redirected to the specified backend URL +OLLAMA_BASE_URL='http://localhost:11434' + +OPENAI_API_BASE_URL='' +OPENAI_API_KEY= + +# Port Configuration - ONLY PLACE TO DEFINE PORTS +FRONTEND_PORT=5175 +BACKEND_PORT=8002 + +# For production, you should only need one host as +# fastapi serves the svelte-kit built frontend and backend from the same host and port. +# To test with CORS locally, you can set something like +# NOTE: Using '*' is not recommended for production deployments +CORS_ALLOW_ORIGIN="http://localhost:${FRONTEND_PORT};http://localhost:${BACKEND_PORT}" + +# For production you should set this to match the proxy configuration (127.0.0.1) +FORWARDED_ALLOW_IPS='*' + +# DO NOT TRACK +SCARF_NO_ANALYTICS=true +DO_NOT_TRACK=true +ANONYMIZED_TELEMETRY=false + +# ================================= +# Google OAuth Configuration +# ================================= + +# Google OAuth Credentials (from Google Cloud Console) +GOOGLE_CLIENT_ID='your-google-client-id.apps.googleusercontent.com' +GOOGLE_CLIENT_SECRET='your-google-client-secret' +GOOGLE_REDIRECT_URI="http://localhost:${BACKEND_PORT}/oauth/google/callback" + +# In your backend/.env file +GOOGLE_DRIVE_CLIENT_ID='your-google-drive-client-id.apps.googleusercontent.com' +GOOGLE_DRIVE_CLIENT_SECRET='your-google-drive-client-secret' +WEBUI_URL="http://localhost:${FRONTEND_PORT}" + +# OAuth Settings +ENABLE_OAUTH_SIGNUP=true +OAUTH_ALLOWED_DOMAINS='*' +OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true +OAUTH_UPDATE_PICTURE_ON_LOGIN=true + +# OpenID Provider URL for proper logout functionality +OPENID_PROVIDER_URL='https://accounts.google.com/.well-known/openid_configuration' + +# Additional OAuth Settings +ENABLE_SIGNUP=true +ENABLE_LOGIN_FORM=true +ENABLE_API_KEY=true + +# Database Configuration +DATA_DIR='./data' +DATABASE_URL='sqlite:///./data/webui.db' + +# Application Settings +WEBUI_NAME='ActionBridge' +WEBUI_SECRET_KEY='your-secret-key-here' +JWT_EXPIRES_IN='24h' +OPEN_WEBUI_PORT=${BACKEND_PORT} +PORT=${BACKEND_PORT} +HOST=0.0.0.0 + +# ================================= +# Session Configuration (OAuth Fix) +# ================================= + +# Session cookie settings for OAuth state management +WEBUI_SESSION_COOKIE_SAME_SITE='lax' +WEBUI_SESSION_COOKIE_SECURE='false' + +# Enable debug logging for OAuth troubleshooting +GLOBAL_LOG_LEVEL='DEBUG' + +# ================================= +# Corporate Authentication (Optional) +# ================================= + +# Corporate Authentication Config File +CORPORATE_AUTH_CONFIG='/app/corporate_config.json' + +# Google Workspace Setup (Required for corporate authentication) +GOOGLE_WORKSPACE_DOMAIN='actionbridge.com' +GOOGLE_WORKSPACE_ADMIN_EMAIL='admin@actionbridge.com' +GOOGLE_SERVICE_ACCOUNT_KEY_FILE='/app/secrets/google-service-account.json' + +# Corporate Group Mappings (JSON format) +CORPORATE_GROUP_MAPPINGS="{\"actionbridge-admin@actionbridge.com\": \"admin\", \"actionbridge-users@actionbridge.com\": \"user\", \"engineering@actionbridge.com\": \"user\", \"management@actionbridge.com\": \"admin\"}" + +# ================================= +# Zoho OAuth Configuration +# ================================= + +# Zoho OAuth Credentials (from Zoho Developer Console) +# EUGENE +ZOHO_CLIENT_ID='your-zoho-client-id' +ZOHO_CLIENT_SECRET='your-zoho-client-secret' +ZOHO_REDIRECT_URI="http://localhost:${BACKEND_PORT}/api/v1/services/zoho/callback" +# TEST +#ZOHO_CLIENT_ID='your-alt-zoho-client-id' +#ZOHO_CLIENT_SECRET='your-alt-zoho-client-secret' +#ZOHO_REDIRECT_URI="http://localhost:\${BACKEND_PORT}/api/v1/services/zoho/callback" + +# ================================= +# GoogleDriveAgent Configuration +# ================================= +GOOGLE_DRIVE_TOKEN='your-google-drive-access-token' +GOOGLE_DRIVE_USER_ID='your-google-drive-user-id' +GOOGLE_DRIVE_AGENT_URL='http://localhost:8000/process' +GOOGLE_DRIVE_AGENT_PATH='/home/eugene/proj/GoogleDriveAgent' +KMS_MASTER_KEY='dGVzdC1rZXktMzItYnl0ZXMtYmFzZTY0LWVuY29kZWQ=' + diff --git a/openwebui/.env.example b/openwebui/.env.example index 35ea12a885..8ea4c0f4b0 100644 --- a/openwebui/.env.example +++ b/openwebui/.env.example @@ -3,15 +3,17 @@ OLLAMA_BASE_URL='http://localhost:11434' OPENAI_API_BASE_URL='' -OPENAI_API_KEY='' +OPENAI_API_KEY='your-openai-api-key-here' -# AUTOMATIC1111_BASE_URL="http://localhost:7860" +# Port Configuration - ONLY PLACE TO DEFINE PORTS +FRONTEND_PORT=5175 +BACKEND_PORT=8002 # For production, you should only need one host as # fastapi serves the svelte-kit built frontend and backend from the same host and port. # To test with CORS locally, you can set something like -# CORS_ALLOW_ORIGIN='http://localhost:5173;http://localhost:8080' -CORS_ALLOW_ORIGIN='*' +# NOTE: Using '*' is not recommended for production deployments +CORS_ALLOW_ORIGIN="http://localhost:${FRONTEND_PORT};http://localhost:${BACKEND_PORT}" # For production you should set this to match the proxy configuration (127.0.0.1) FORWARDED_ALLOW_IPS='*' @@ -19,4 +21,88 @@ FORWARDED_ALLOW_IPS='*' # DO NOT TRACK SCARF_NO_ANALYTICS=true DO_NOT_TRACK=true -ANONYMIZED_TELEMETRY=false \ No newline at end of file +ANONYMIZED_TELEMETRY=false + +# ================================= +# Google OAuth Configuration +# ================================= + +# Google OAuth Credentials (from Google Cloud Console) +GOOGLE_CLIENT_ID='your-google-client-id.apps.googleusercontent.com' +GOOGLE_CLIENT_SECRET='your-google-client-secret' +GOOGLE_REDIRECT_URI="http://localhost:${BACKEND_PORT}/oauth/google/callback" + +# Google Drive OAuth Credentials (separate OAuth client for Drive integration) +GOOGLE_DRIVE_CLIENT_ID='your-google-drive-client-id.apps.googleusercontent.com' +GOOGLE_DRIVE_CLIENT_SECRET='your-google-drive-client-secret' +WEBUI_URL="http://localhost:${FRONTEND_PORT}" + +# OAuth Settings +ENABLE_OAUTH_SIGNUP=true +OAUTH_ALLOWED_DOMAINS='*' +OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true +OAUTH_UPDATE_PICTURE_ON_LOGIN=true + +# OpenID Provider URL for proper logout functionality +OPENID_PROVIDER_URL='https://accounts.google.com/.well-known/openid_configuration' + +# Additional OAuth Settings +ENABLE_SIGNUP=true +ENABLE_LOGIN_FORM=true +ENABLE_API_KEY=true + +# Database Configuration +DATA_DIR='./data' +DATABASE_URL='sqlite:///./data/webui.db' + +# Application Settings +WEBUI_NAME='ActionBridge' +WEBUI_SECRET_KEY='your-secret-key-here-change-in-production' +JWT_EXPIRES_IN='24h' +OPEN_WEBUI_PORT=${BACKEND_PORT} +PORT=${BACKEND_PORT} +HOST=0.0.0.0 + +# ================================= +# Session Configuration (OAuth Fix) +# ================================= + +# Session cookie settings for OAuth state management +WEBUI_SESSION_COOKIE_SAME_SITE='lax' +WEBUI_SESSION_COOKIE_SECURE='false' + +# Enable debug logging for OAuth troubleshooting (set to INFO in production) +GLOBAL_LOG_LEVEL='DEBUG' + +# ================================= +# Corporate Authentication (Optional) +# ================================= + +# Corporate Authentication Config File +CORPORATE_AUTH_CONFIG='/app/corporate_config.json' + +# Google Workspace Setup (Required for corporate authentication) +GOOGLE_WORKSPACE_DOMAIN='your-domain.com' +GOOGLE_WORKSPACE_ADMIN_EMAIL='admin@your-domain.com' +GOOGLE_SERVICE_ACCOUNT_KEY_FILE='/app/secrets/google-service-account.json' + +# Corporate Group Mappings (JSON format) +CORPORATE_GROUP_MAPPINGS="{\"admin-group@your-domain.com\": \"admin\", \"users-group@your-domain.com\": \"user\"}" + +# ================================= +# Zoho OAuth Configuration +# ================================= + +# Zoho OAuth Credentials (from Zoho Developer Console) +ZOHO_CLIENT_ID='your-zoho-client-id' +ZOHO_CLIENT_SECRET='your-zoho-client-secret' +ZOHO_REDIRECT_URI="http://localhost:${BACKEND_PORT}/api/v1/services/zoho/callback" + +# ================================= +# GoogleDriveAgent Configuration (Optional) +# ================================= +GOOGLE_DRIVE_TOKEN='' +GOOGLE_DRIVE_USER_ID='' +GOOGLE_DRIVE_AGENT_URL='http://localhost:8000/process' +GOOGLE_DRIVE_AGENT_PATH='/path/to/GoogleDriveAgent' +KMS_MASTER_KEY='your-kms-master-key-base64-encoded' diff --git a/openwebui/CORPORATE_SETUP.md b/openwebui/CORPORATE_SETUP.md new file mode 100755 index 0000000000..2d8ac5f41d --- /dev/null +++ b/openwebui/CORPORATE_SETUP.md @@ -0,0 +1,159 @@ +# Corporate Authentication Setup Guide + +Этот гайд поможет настроить корпоративную аутентификацию с Google Workspace для ActionBridge. + +## Возможности системы + +✅ **Google Workspace Integration** - проверка пользователей через Directory API +✅ **Multi-session support** - сохранение полной мульти-сессии +✅ **RBAC via Google Groups** - автоматическое назначение ролей по группам +✅ **Domain verification** - только сотрудники компании могут войти +✅ **Multi-company support** - легко адаптируется для разных компаний + +## Шаг 1: Настройка Google Cloud Project + +### 1.1 Создайте Google Cloud Project +1. Откройте [Google Cloud Console](https://console.cloud.google.com) +2. Создайте новый проект для ActionBridge +3. Активируйте следующий API: + - **Admin SDK API** (для проверки пользователей через Google Workspace Directory) + + **Примечание**: для OAuth аутентификации отдельный API не требуется, достаточно создать OAuth 2.0 Client ID в следующем шаге. + +### 1.2 Создайте Service Account +1. Идите в IAM & Admin > Service Accounts +2. Создайте новый Service Account: `actionbridge-workspace-reader` +3. Скачайте JSON ключ +4. Сохраните как `/app/secrets/google-service-account.json` + +### 1.3 Создайте OAuth 2.0 Client +1. Идите в APIs & Services > Credentials +2. Создайте OAuth 2.0 Client ID: + - Application type: Web application + - Authorized redirect URIs: `https://your-domain.com/oauth/google/callback` +3. Сохраните Client ID и Client Secret + +## Шаг 2: Настройка Google Workspace + +### 2.1 Domain-wide Delegation +1. В Google Admin Console перейдите в Security > API Controls +2. Добавьте ваш Service Account Client ID +3. Предоставьте следующие OAuth Scopes: + ``` + https://www.googleapis.com/auth/admin.directory.user.readonly + https://www.googleapis.com/auth/admin.directory.group.readonly + ``` + +### 2.2 Создайте группы для RBAC +Создайте следующие группы в Google Admin: +- `actionbridge-admin@actionbridge.com` - администраторы +- `actionbridge-users@actionbridge.com` - обычные пользователи +- `engineering@actionbridge.com` - инженеры (user роль) +- `management@actionbridge.com` - менеджмент (admin роль) + +## Шаг 3: Конфигурация ActionBridge + +### 3.1 Создайте corporate_config.json +```json +{ + "company_name": "ActionBridge", + "google_workspace": { + "domain": "actionbridge.com", + "admin_email": "admin@actionbridge.com", + "service_account_key_file": "/app/secrets/google-service-account.json" + }, + "require_workspace_verification": true, + "auto_approve_verified_users": true, + "default_role_for_verified_users": "user", + "oauth_client_id": "YOUR_CLIENT_ID", + "oauth_client_secret": "YOUR_CLIENT_SECRET", + "group_to_role_mapping": { + "actionbridge-admin@actionbridge.com": "admin", + "actionbridge-users@actionbridge.com": "user", + "engineering@actionbridge.com": "user", + "management@actionbridge.com": "admin" + } +} +``` + +### 3.2 Environment Variables +```bash +# Corporate Configuration +CORPORATE_AUTH_CONFIG=/app/corporate_config.json + +# Google OAuth (от Google Cloud Console) +GOOGLE_CLIENT_ID=your_client_id +GOOGLE_CLIENT_SECRET=your_client_secret +GOOGLE_REDIRECT_URI=https://your-domain.com/oauth/google/callback + +# Enable OAuth +ENABLE_OAUTH_SIGNUP=true +OAUTH_ALLOWED_DOMAINS=* # Или ограничьте доменом: actionbridge.com +``` + +## Шаг 4: Тестирование + +### 4.1 Проверка подключения к Workspace +```python +import asyncio +from backend.open_webui.utils.corporate_auth import test_workspace_connection + +# Тест подключения +result = asyncio.run(test_workspace_connection("/app/corporate_config.json")) +print(result) +``` + +### 4.2 Тест авторизации +1. Перейдите на `https://your-domain.com` +2. Нажмите "Sign in with Google" +3. Выберите аккаунт из корпоративного домена +4. Убедитесь что пользователь создался с правильной ролью + +## Для других компаний + +Для адаптации под другую компанию: + +1. Скопируйте `corporate_config_template.json` +2. Замените: + - `COMPANY_NAME` → название компании + - `company.com` → домен компании + - Группы в `group_to_role_mapping` +3. Создайте свой Service Account в Google Cloud +4. Настройте Domain-wide Delegation + +## Возможные ошибки + +### "Service not available" +- Проверьте что Service Account JSON файл существует +- Проверьте права доступа к файлу + +### "User not found in workspace" +- Убедитесь что пользователь существует в Google Workspace +- Проверьте что домен в конфигурации правильный + +### "Access denied" +- Проверьте Domain-wide Delegation в Google Admin +- Убедитесь что у admin_email есть права на Directory API + +### "OAuth error" +- Проверьте Client ID и Client Secret +- Убедитесь что redirect URI правильный + +## Безопасность + +🔒 **Service Account** имеет только права на чтение Directory API +🔒 **Hosted Domain** ограничивает OAuth только корпоративными аккаунтами +🔒 **Workspace Verification** проверяет каждого пользователя через API +🔒 **Multi-session** поддерживается без компромиссов безопасности + +## Логи + +Для диагностики включите debug логи: +```bash +SRC_LOG_LEVELS={"MAIN": "DEBUG"} +``` + +Система будет логировать: +- Результаты проверки пользователей в Workspace +- Маппинг групп на роли +- Ошибки подключения к API \ No newline at end of file diff --git a/openwebui/GOOGLE_OAUTH_SETUP.md b/openwebui/GOOGLE_OAUTH_SETUP.md new file mode 100755 index 0000000000..a530fdafbe --- /dev/null +++ b/openwebui/GOOGLE_OAUTH_SETUP.md @@ -0,0 +1,122 @@ +# Google OAuth Setup Guide + +This guide will help you set up Google sign-in for your OpenWebUI instance. + +## Quick Setup (Basic Google OAuth) + +### 1. Get Google OAuth Credentials + +1. Go to [Google Cloud Console](https://console.cloud.google.com) +2. Create a new project or select an existing one +3. Enable the Google+ API (if using older scopes) or just OAuth will work for basic sign-in +4. Go to **APIs & Services** > **Credentials** +5. Click **Create Credentials** > **OAuth 2.0 Client IDs** +6. Choose **Web application** +7. Add authorized redirect URIs: + - For local development: `http://localhost:3000/oauth/google/callback` + - For production: `https://yourdomain.com/oauth/google/callback` +8. Copy the **Client ID** and **Client Secret** + +### 2. Configure OpenWebUI + +Edit your `.env` file and add: + +```bash +# Google OAuth Credentials +GOOGLE_CLIENT_ID='your-google-client-id-here' +GOOGLE_CLIENT_SECRET='your-google-client-secret-here' +GOOGLE_REDIRECT_URI='http://localhost:3000/oauth/google/callback' + +# OAuth Settings +ENABLE_OAUTH_SIGNUP=true +OAUTH_ALLOWED_DOMAINS='*' # Allow all domains, or specify: 'company.com' +OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true +``` + +### 3. Restart OpenWebUI + +After updating the `.env` file, restart your OpenWebUI instance: + +```bash +# If using Docker +docker-compose down && docker-compose up -d + +# If running locally +# Stop the process and start again +``` + +### 4. Test Google Sign-In + +1. Go to your OpenWebUI login page +2. You should now see a "Continue with Google" button +3. Click it to test the Google OAuth flow + +## Advanced Setup (Corporate Authentication) + +If you want to restrict access to only your company's Google Workspace users and assign roles based on Google Groups, see the existing `CORPORATE_SETUP.md` file for detailed instructions. + +## Configuration Options + +### OAuth Settings + +- `OAUTH_ALLOWED_DOMAINS`: Restrict to specific email domains (e.g., 'company.com') +- `OAUTH_MERGE_ACCOUNTS_BY_EMAIL`: Merge OAuth accounts with existing email accounts +- `ENABLE_OAUTH_SIGNUP`: Allow new user registration via OAuth + +### Google-Specific Settings + +- `GOOGLE_OAUTH_SCOPE`: OAuth scopes (default: 'openid email profile') +- `GOOGLE_REDIRECT_URI`: Must match the redirect URI in Google Cloud Console + +## Troubleshooting + +### Common Issues + +1. **"Redirect URI mismatch"** + - Make sure `GOOGLE_REDIRECT_URI` matches exactly what you configured in Google Cloud Console + - Include the protocol (http/https) and port if applicable + +2. **"OAuth provider not configured"** + - Ensure both `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are set + - Restart the application after setting environment variables + +3. **"Access denied"** + - Check `OAUTH_ALLOWED_DOMAINS` setting + - Verify the user's email domain is allowed + +### Logs + +Check the application logs for detailed error messages: + +```bash +# Docker logs +docker-compose logs open-webui + +# Or check the backend logs specifically +docker-compose logs backend +``` + +## Security Considerations + +1. **Never commit OAuth credentials** to version control +2. **Use HTTPS in production** for the redirect URI +3. **Restrict allowed domains** if you want to limit access +4. **Keep OAuth secrets secure** and rotate them periodically + +## Production Deployment + +For production deployments: + +1. Update `GOOGLE_REDIRECT_URI` to use your production domain with HTTPS +2. Consider using `OAUTH_ALLOWED_DOMAINS` to restrict to your organization +3. Set up proper secret management instead of plain text in `.env` +4. Configure SSL/TLS termination properly + +Example production configuration: + +```bash +GOOGLE_CLIENT_ID='your-production-client-id' +GOOGLE_CLIENT_SECRET='your-production-client-secret' +GOOGLE_REDIRECT_URI='https://yourdomain.com/oauth/google/callback' +OAUTH_ALLOWED_DOMAINS='yourcompany.com' +``` \ No newline at end of file diff --git a/openwebui/INTRASEARCH_INDEPENDENCE.md b/openwebui/INTRASEARCH_INDEPENDENCE.md new file mode 100644 index 0000000000..ea4f531f3e --- /dev/null +++ b/openwebui/INTRASEARCH_INDEPENDENCE.md @@ -0,0 +1,137 @@ +# IntraSearch Module Independence + +## Goal +Make the "IntraSearch" section completely independent from LLM providers and GPT models, while keeping the "New Chat" functionality unchanged. + +## What Was Done + +### 1. ✅ Created Independent Stores for IntraSearch +**File:** `src/lib/stores/intrasearch.ts` + +**Differences from main stores:** +- `intraSearchMessages` instead of `chats` +- `intraSearchSessions` instead of `chats` +- `intraSearchSettings` instead of `settings` +- `intraSearchLoading` instead of global `loading` +- Own UI controls without LLM dependencies + +**Result:** IntraSearch no longer uses global chat stores + +### 2. ✅ Created Independent API Layer +**File:** `src/lib/apis/intrasearch/index.ts` + +**Functions without LLM dependencies:** +- `performIntraSearch()` - search internal systems +- `createIntraSearchSession()` - create search sessions +- `getSearchSuggestions()` - query suggestions +- `saveIntraSearchMessage()` - save messages + +**Differences from chat API:** +- Endpoints `/api/v1/intrasearch/*` instead of `/api/v1/chats/*` +- No calls to LLM providers +- No GPT model usage + +### 3. ✅ Created New Independent Component +**File:** `src/lib/components/intrasearch/IntraSearchChatNew.svelte` + +**What was removed:** +- LLM provider imports (`generateChatCompletion`, `openai`, etc.) +- Model usage (`models` store) +- AI response streaming +- Tokenization and chat completion + +**What was added:** +- Direct search through internal systems +- Search result formatting without LLM +- Display sources and metadata +- Independent search settings + +### 4. ✅ Created Backend API +**File:** `backend/open_webui/routers/intrasearch.py` + +**Development stub:** +- Mock data for demonstration +- Endpoints without LLM dependencies +- Own data models +- Independent session storage + +### 5. ✅ Updated Routing +**File:** `src/routes/(app)/intrasearch/+page.svelte` + +**Changes:** +- Import `IntraSearchChatNew` instead of `IntraSearchChat` +- Independent sessionId +- Own page title + +## Result + +### ✅ IntraSearch NO LONGER uses: +- LLM providers (OpenAI, Claude, etc.) +- GPT models +- Chat completion API +- Tokenization +- AI streaming +- Shared chat stores +- Chat API endpoints + +### ✅ IntraSearch now uses: +- Own stores for search +- Independent API for internal search +- Direct search result display +- Own settings +- Separate search sessions + +### ✅ New Chat remains UNTOUCHED: +- All LLM functions work as before +- Stores and API unchanged +- `Chat.svelte` component not affected +- Chat routes unchanged + +## Testing Independence + +### Test 1: Remove LLM Providers +```bash +# Can remove all LLM providers from IntraSearch +# New Chat continues to work +``` + +### Test 2: Change IntraSearch Logic +```bash +# Can completely rewrite IntraSearch logic +# New Chat won't be affected +``` + +### Test 3: Independent Deployment +```bash +# IntraSearch can work without: +# - OpenAI API keys +# - LLM servers +# - Chat completion endpoints +``` + +## What's Next + +Now you can safely: + +1. **Replace IntraSearch backend** with enterprise search +2. **Change UI** for corporate requirements +3. **Add integrations** with internal systems +4. **Remove AI logic** completely from IntraSearch + +Meanwhile, "New Chat" will continue working with all LLM providers and models. + +## Files to Change for IntraSearch + +**Only these files need changes for IntraSearch:** +- `src/lib/stores/intrasearch.ts` +- `src/lib/apis/intrasearch/index.ts` +- `src/lib/components/intrasearch/IntraSearchChatNew.svelte` +- `backend/open_webui/routers/intrasearch.py` +- `src/routes/(app)/intrasearch/+page.svelte` + +**These files should NOT be touched (New Chat):** +- `src/lib/stores/index.ts` +- `src/lib/apis/index.ts`, `src/lib/apis/chats/` +- `src/lib/components/chat/Chat.svelte` +- `backend/open_webui/routers/chats.py` +- `src/routes/(app)/+page.svelte` \ No newline at end of file diff --git a/openwebui/OAUTH_CONFIG_README.md b/openwebui/OAUTH_CONFIG_README.md new file mode 100644 index 0000000000..155523f1e6 --- /dev/null +++ b/openwebui/OAUTH_CONFIG_README.md @@ -0,0 +1,133 @@ +# OAuth Configuration Management + +This system allows you to manage OAuth settings (scopes, regions) through an external JSON configuration file. + +## Configuration File + +The main configuration file is located at: `oauth_config.json` + +### Structure + +```json +{ + "zoho": { + "region": "eu", + "scopes": [ + "ZohoPeople.employee.ALL", + "ZohoPeople.forms.READ", + "ZohoPeople.attendance.ALL", + "ZohoPeople.leave.READ", + "ZohoPeople.timetracker.ALL", + "AaaServer.profile.READ", + "email" + ], + "regions": { + "us": { "auth_url": "...", "token_url": "...", ... }, + "eu": { "auth_url": "...", "token_url": "...", ... }, + "in": { "auth_url": "...", "token_url": "...", ... }, + "au": { "auth_url": "...", "token_url": "...", ... }, + "jp": { "auth_url": "...", "token_url": "...", ... } + } + }, + "google_drive": { + "scopes": [ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile" + ] + } +} +``` + +## Usage + +### Changing Zoho Region + +1. Edit `oauth_config.json` +2. Change the `zoho.region` value to one of: `us`, `eu`, `in`, `au`, `jp` +3. Reload configuration (see below) +4. Disconnect and reconnect Zoho service + +### Changing Scopes + +1. Edit `oauth_config.json` +2. Modify the `scopes` array for the desired service +3. Reload configuration (see below) +4. Disconnect and reconnect the service to get new permissions + +### Reloading Configuration + +**Method 1: API Call** +```bash +curl -X POST http://localhost:8000/api/v1/services/reload-config \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +**Method 2: Restart Backend** +```bash +./start-backend.sh +``` + +## Available Zoho Regions + +- **US** (`us`): `accounts.zoho.com` +- **EU** (`eu`): `accounts.zoho.eu` +- **India** (`in`): `accounts.zoho.in` +- **Australia** (`au`): `accounts.zoho.com.au` +- **Japan** (`jp`): `accounts.zoho.jp` + +## Environment Variables + +You can override the config file path: + +```bash +export OAUTH_CONFIG_PATH="/path/to/your/oauth_config.json" +``` + +## Example: Switching to US Region + +1. Edit `oauth_config.json`: +```json +{ + "zoho": { + "region": "us", + "scopes": ["ZohoPeople.employee.ALL", "email"] + } +} +``` + +2. Reload configuration: +```bash +curl -X POST http://localhost:8000/api/v1/services/reload-config \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +3. In the UI: + - Go to Settings > External Tools + - Click "Disconnect" for Zoho People + - Click "Connect" to reconnect with new region/scopes + +## Troubleshooting + +### Configuration Not Loading +- Check file path: `oauth_config.json` should be in the project root +- Check JSON syntax: use a JSON validator +- Check logs for error messages during startup + +### Region Change Not Working +- Make sure you disconnected and reconnected the service +- Check that the region exists in the `regions` object +- Verify the new region URLs are correct + +### Scopes Not Applied +- Disconnect and reconnect the service after changing scopes +- Some scopes may require admin approval in Zoho +- Check the token response in logs to see actual granted scopes + +## Security Notes + +- Keep OAuth credentials secure in environment variables +- Don't commit the config file with sensitive data to version control +- Consider restricting the reload endpoint to admin users only \ No newline at end of file diff --git a/openwebui/PYODIDE_OPTIMIZATION.md b/openwebui/PYODIDE_OPTIMIZATION.md new file mode 100755 index 0000000000..021f8b54a0 --- /dev/null +++ b/openwebui/PYODIDE_OPTIMIZATION.md @@ -0,0 +1,50 @@ +# Pyodide Optimization - Frontend Performance Fix + +## Problem +The frontend was downloading/installing Python packages (Pyodide) on every restart, taking 60+ seconds each time. + +## Solution +Created an optimized caching system that checks if packages are already downloaded before running the expensive download process. + +## What was changed: + +### 1. New Script: `scripts/prepare-pyodide-fast.js` +- **Smart caching**: Only downloads packages if cache is missing or older than 7 days +- **Fast validation**: Simple file existence checks instead of complex package validation +- **Error handling**: Graceful fallbacks if packages can't be installed + +### 2. Updated `package.json` +- Changed `npm run dev` to use `pyodide:check` instead of `pyodide:fetch` +- `pyodide:check` uses the new fast script +- Original `pyodide:fetch` still available for force refresh + +## Performance Improvement: +- **Before**: 60+ seconds every restart (downloading packages) +- **After**: ~0.2 seconds when cache is valid ✅ + +## Usage: + +### Normal development (uses cache): +```bash +npm run dev +# Uses pyodide:check - very fast if cache exists +``` + +### Force refresh packages: +```bash +npm run pyodide:fetch +# Downloads fresh packages (use if you need to update packages) +``` + +## Cache Location: +- `static/pyodide/` - Contains cached Python packages +- Cache is valid for 7 days, then auto-refreshes +- Safe to delete if you want to force a refresh + +## Benefits: +1. **Much faster startup** - No more waiting for Python package downloads +2. **Offline development** - Works without internet once packages are cached +3. **Bandwidth savings** - Packages only downloaded when needed +4. **Backwards compatible** - Original behavior available via `npm run pyodide:fetch` + +Your frontend should now start much faster! 🚀 \ No newline at end of file diff --git a/openwebui/SETUP_COMPLETE.md b/openwebui/SETUP_COMPLETE.md new file mode 100755 index 0000000000..95d4cf2dc9 --- /dev/null +++ b/openwebui/SETUP_COMPLETE.md @@ -0,0 +1,84 @@ +# Google OAuth Setup Complete + +✅ **Google OAuth has been successfully configured!** + +## What was copied from V2: + +### 1. OAuth Credentials +- **Client ID**: `your-google-client-id.apps.googleusercontent.com` +- **Client Secret**: `your-google-client-secret` +- **Redirect URI**: `http://localhost:5173/oauth/google/callback` + +### 2. Service Account File +- Copied: `/home/eugene/proj/CG_OpenChatUI/open-webui_V2/secrets/google-service-account.json` +- To: `/home/eugene/proj/CG_OpenChatUI/open-webui/secrets/google-service-account.json` + +### 3. Configuration Files +- ✅ Updated `.env` with all OAuth settings +- ✅ Corporate config (`corporate_config.json`) already exists +- ✅ Corporate authentication module already present + +## Current Configuration + +Your `.env` file now includes: + +```bash +# Google OAuth +GOOGLE_CLIENT_ID='your-google-client-id.apps.googleusercontent.com' +GOOGLE_CLIENT_SECRET='your-google-client-secret' +GOOGLE_REDIRECT_URI='http://localhost:5173/oauth/google/callback' + +# OAuth Settings +ENABLE_OAUTH_SIGNUP=true +OAUTH_ALLOWED_DOMAINS='*' +OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true + +# Application +WEBUI_NAME='ActionBridge' +OPEN_WEBUI_PORT=8000 +``` + +## Next Steps + +1. **Start/Restart OpenWebUI**: + ```bash + # If using Docker + docker-compose down && docker-compose up -d + + # If running locally + # Restart your application + ``` + +2. **Test Google Sign-In**: + - Go to `http://localhost:5173` (frontend dev server) + - You should see "Continue with Google" button + - Click to test the OAuth flow + +3. **Important**: Make sure the redirect URI in Google Cloud Console matches: + - `http://localhost:5173/oauth/google/callback` + +## Google Cloud Console Setup + +If you need to update the redirect URI in Google Cloud Console: + +1. Go to [Google Cloud Console](https://console.cloud.google.com) +2. Navigate to **APIs & Services** > **Credentials** +3. Find your OAuth 2.0 Client ID (the one you configured) +4. Add `http://localhost:5173/oauth/google/callback` to **Authorized redirect URIs** + +## Corporate Authentication + +The setup includes ActionBridge corporate authentication: +- Domain: `actionbridge.com` +- Admin groups: `actionbridge-admin@actionbridge.com`, `management@actionbridge.com` +- User groups: `actionbridge-users@actionbridge.com`, `engineering@actionbridge.com` + +## Troubleshooting + +If you encounter issues: +1. Check logs for OAuth errors +2. Verify redirect URI matches in Google Cloud Console +3. Ensure the port (`8000`) is correct for your setup +4. Check that all environment variables are loaded + +Your Google OAuth integration is now ready to use! 🎉 \ No newline at end of file diff --git a/openwebui/backend/open_webui/integrations/__init__.py b/openwebui/backend/open_webui/integrations/__init__.py new file mode 100755 index 0000000000..737ad95c8f --- /dev/null +++ b/openwebui/backend/open_webui/integrations/__init__.py @@ -0,0 +1 @@ +# Integrations module \ No newline at end of file diff --git a/openwebui/backend/open_webui/integrations/google_drive.py b/openwebui/backend/open_webui/integrations/google_drive.py new file mode 100755 index 0000000000..9824b1303f --- /dev/null +++ b/openwebui/backend/open_webui/integrations/google_drive.py @@ -0,0 +1,351 @@ +""" +Google Drive API Integration Module +Handles search, file access, and content extraction from Google Drive +""" + +from typing import List, Dict, Optional, Any, AsyncIterator +import httpx +from datetime import datetime +import json +import mimetypes +import io +from urllib.parse import quote + +from open_webui.services.oauth_token_manager import token_manager +from open_webui.utils.oauth_services import ServiceType, get_service_config + + +class GoogleDriveFile: + """Represents a Google Drive file with metadata""" + + def __init__(self, data: Dict[str, Any]): + self.id = data.get("id", "") + self.name = data.get("name", "") + self.mime_type = data.get("mimeType", "") + self.size = data.get("size") + self.created_time = data.get("createdTime") + self.modified_time = data.get("modifiedTime") + self.web_view_link = data.get("webViewLink", "") + self.parents = data.get("parents", []) + self.owners = data.get("owners", []) + self.permissions = data.get("permissions", []) + self.description = data.get("description", "") + self.full_text = data.get("fullText", "") # For indexed content + + def is_readable(self) -> bool: + """Check if file can be read (text/document formats)""" + readable_types = [ + "application/vnd.google-apps.document", # Google Docs + "application/vnd.google-apps.spreadsheet", # Google Sheets + "application/vnd.google-apps.presentation", # Google Slides + "text/plain", + "text/markdown", + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", # DOCX + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # XLSX + "application/vnd.openxmlformats-officedocument.presentationml.presentation", # PPTX + "text/csv", + "application/json", + "text/html" + ] + return self.mime_type in readable_types + + def get_export_mime_type(self) -> Optional[str]: + """Get appropriate export MIME type for Google Workspace files""" + google_mime_exports = { + "application/vnd.google-apps.document": "text/plain", + "application/vnd.google-apps.spreadsheet": "text/csv", + "application/vnd.google-apps.presentation": "text/plain", + "application/vnd.google-apps.drawing": "image/png" + } + return google_mime_exports.get(self.mime_type) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization""" + return { + "id": self.id, + "name": self.name, + "mime_type": self.mime_type, + "size": self.size, + "created_time": self.created_time, + "modified_time": self.modified_time, + "web_view_link": self.web_view_link, + "parents": self.parents, + "owners": self.owners, + "description": self.description, + "is_readable": self.is_readable(), + "full_text": self.full_text + } + + +class GoogleDriveIntegration: + """Google Drive API integration class""" + + def __init__(self): + self.base_url = "https://www.googleapis.com/drive/v3" + self.config = get_service_config(ServiceType.GOOGLE_DRIVE) + + async def _get_headers(self, user_id: str) -> Dict[str, str]: + """Get authorization headers with valid token""" + access_token = await token_manager.get_valid_token(user_id, ServiceType.GOOGLE_DRIVE) + if not access_token: + raise ValueError("User not authorized for Google Drive") + + return { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + } + + async def _make_request(self, user_id: str, method: str, url: str, **kwargs) -> Dict[str, Any]: + """Make authenticated request to Google Drive API""" + headers = await self._get_headers(user_id) + headers.update(kwargs.pop("headers", {})) + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.request(method, url, headers=headers, **kwargs) + + if response.status_code == 401: + # Try to refresh token and retry once + await token_manager.refresh_token_if_needed(user_id, ServiceType.GOOGLE_DRIVE) + headers = await self._get_headers(user_id) + headers.update(kwargs.get("headers", {})) + response = await client.request(method, url, headers=headers, **kwargs) + + if response.status_code != 200: + raise httpx.HTTPError( + f"Google Drive API request failed: {response.status_code} - {response.text}" + ) + + return response.json() + + async def search_files( + self, + user_id: str, + query: str, + file_types: Optional[List[str]] = None, + limit: int = 50, + include_content: bool = False + ) -> List[GoogleDriveFile]: + """Search for files in Google Drive""" + + # Build search query + search_parts = [f"fullText contains '{query}'"] + + if file_types: + mime_type_conditions = [] + for file_type in file_types: + if file_type.lower() == "documents": + mime_type_conditions.extend([ + "mimeType='application/vnd.google-apps.document'", + "mimeType='application/pdf'", + "mimeType='application/vnd.openxmlformats-officedocument.wordprocessingml.document'" + ]) + elif file_type.lower() == "spreadsheets": + mime_type_conditions.extend([ + "mimeType='application/vnd.google-apps.spreadsheet'", + "mimeType='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'", + "mimeType='text/csv'" + ]) + elif file_type.lower() == "presentations": + mime_type_conditions.extend([ + "mimeType='application/vnd.google-apps.presentation'", + "mimeType='application/vnd.openxmlformats-officedocument.presentationml.presentation'" + ]) + + if mime_type_conditions: + search_parts.append(f"({' or '.join(mime_type_conditions)})") + + # Add trashed = false to exclude deleted files + search_parts.append("trashed = false") + + search_query = " and ".join(search_parts) + + # Set up request parameters + params = { + "q": search_query, + "pageSize": min(limit, 100), # Google Drive max is 1000 + "fields": "files(id,name,mimeType,size,createdTime,modifiedTime,webViewLink,parents,owners,permissions,description)", + "orderBy": "modifiedTime desc" + } + + url = f"{self.base_url}/files" + response_data = await self._make_request(user_id, "GET", url, params=params) + + files = [] + for file_data in response_data.get("files", []): + drive_file = GoogleDriveFile(file_data) + + # Optionally include file content for text files + if include_content and drive_file.is_readable(): + try: + content = await self.get_file_content(user_id, drive_file.id) + file_data["fullText"] = content + drive_file = GoogleDriveFile(file_data) + except Exception as e: + print(f"Failed to get content for file {drive_file.id}: {e}") + + files.append(drive_file) + + return files + + async def get_file_metadata(self, user_id: str, file_id: str) -> GoogleDriveFile: + """Get detailed metadata for a specific file""" + url = f"{self.base_url}/files/{file_id}" + params = { + "fields": "id,name,mimeType,size,createdTime,modifiedTime,webViewLink,parents,owners,permissions,description" + } + + response_data = await self._make_request(user_id, "GET", url, params=params) + return GoogleDriveFile(response_data) + + async def get_file_content(self, user_id: str, file_id: str, max_size: int = 10 * 1024 * 1024) -> str: + """Get text content of a file""" + file_metadata = await self.get_file_metadata(user_id, file_id) + + if not file_metadata.is_readable(): + raise ValueError(f"File type {file_metadata.mime_type} is not readable") + + # Check file size + if file_metadata.size and int(file_metadata.size) > max_size: + raise ValueError(f"File too large: {file_metadata.size} bytes (max {max_size})") + + headers = await self._get_headers(user_id) + + async with httpx.AsyncClient(timeout=60.0) as client: + # For Google Workspace files, use export endpoint + export_mime_type = file_metadata.get_export_mime_type() + if export_mime_type: + url = f"{self.base_url}/files/{file_id}/export" + params = {"mimeType": export_mime_type} + else: + # For regular files, use download endpoint + url = f"{self.base_url}/files/{file_id}" + params = {"alt": "media"} + + response = await client.get(url, headers=headers, params=params) + + if response.status_code != 200: + raise httpx.HTTPError( + f"Failed to download file content: {response.status_code} - {response.text}" + ) + + # Return text content + content = response.text if response.text else response.content.decode('utf-8', errors='ignore') + return content + + async def list_folders(self, user_id: str, parent_id: Optional[str] = None) -> List[GoogleDriveFile]: + """List folders in Drive""" + query_parts = ["mimeType='application/vnd.google-apps.folder'", "trashed = false"] + + if parent_id: + query_parts.append(f"'{parent_id}' in parents") + + params = { + "q": " and ".join(query_parts), + "fields": "files(id,name,createdTime,modifiedTime,parents)", + "orderBy": "name" + } + + url = f"{self.base_url}/files" + response_data = await self._make_request(user_id, "GET", url, params=params) + + return [GoogleDriveFile(file_data) for file_data in response_data.get("files", [])] + + async def get_recent_files(self, user_id: str, limit: int = 20) -> List[GoogleDriveFile]: + """Get recently modified files""" + params = { + "q": "trashed = false", + "pageSize": min(limit, 100), + "fields": "files(id,name,mimeType,size,createdTime,modifiedTime,webViewLink,owners)", + "orderBy": "modifiedTime desc" + } + + url = f"{self.base_url}/files" + response_data = await self._make_request(user_id, "GET", url, params=params) + + return [GoogleDriveFile(file_data) for file_data in response_data.get("files", [])] + + async def get_shared_files(self, user_id: str, limit: int = 50) -> List[GoogleDriveFile]: + """Get files shared with the user""" + params = { + "q": "sharedWithMe = true and trashed = false", + "pageSize": min(limit, 100), + "fields": "files(id,name,mimeType,size,createdTime,modifiedTime,webViewLink,owners,sharingUser)", + "orderBy": "modifiedTime desc" + } + + url = f"{self.base_url}/files" + response_data = await self._make_request(user_id, "GET", url, params=params) + + return [GoogleDriveFile(file_data) for file_data in response_data.get("files", [])] + + async def search_by_content_type( + self, + user_id: str, + content_type: str, + limit: int = 50 + ) -> List[GoogleDriveFile]: + """Search files by content type (documents, spreadsheets, presentations, etc.)""" + + mime_type_queries = { + "documents": [ + "mimeType='application/vnd.google-apps.document'", + "mimeType='application/pdf'", + "mimeType='application/vnd.openxmlformats-officedocument.wordprocessingml.document'", + "mimeType='text/plain'" + ], + "spreadsheets": [ + "mimeType='application/vnd.google-apps.spreadsheet'", + "mimeType='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'", + "mimeType='text/csv'" + ], + "presentations": [ + "mimeType='application/vnd.google-apps.presentation'", + "mimeType='application/vnd.openxmlformats-officedocument.presentationml.presentation'" + ], + "images": [ + "mimeType contains 'image/'" + ], + "pdfs": [ + "mimeType='application/pdf'" + ] + } + + mime_conditions = mime_type_queries.get(content_type.lower(), []) + if not mime_conditions: + raise ValueError(f"Unsupported content type: {content_type}") + + query = f"({' or '.join(mime_conditions)}) and trashed = false" + + params = { + "q": query, + "pageSize": min(limit, 100), + "fields": "files(id,name,mimeType,size,createdTime,modifiedTime,webViewLink,owners)", + "orderBy": "modifiedTime desc" + } + + url = f"{self.base_url}/files" + response_data = await self._make_request(user_id, "GET", url, params=params) + + return [GoogleDriveFile(file_data) for file_data in response_data.get("files", [])] + + async def get_user_info(self, user_id: str) -> Dict[str, Any]: + """Get information about the user's Google Drive""" + headers = await self._get_headers(user_id) + + async with httpx.AsyncClient() as client: + # Get drive info + about_response = await client.get( + f"{self.base_url}/about", + headers=headers, + params={"fields": "user,storageQuota"} + ) + + if about_response.status_code != 200: + raise httpx.HTTPError(f"Failed to get user info: {about_response.text}") + + return about_response.json() + + +# Global instance +google_drive = GoogleDriveIntegration() \ No newline at end of file diff --git a/openwebui/backend/open_webui/main.py b/openwebui/backend/open_webui/main.py index 9998af0e73..db1d833180 100644 --- a/openwebui/backend/open_webui/main.py +++ b/openwebui/backend/open_webui/main.py @@ -93,6 +93,10 @@ users, utils, scim, + google_drive, + zoho_people, + intrasearch, + services, ) from open_webui.routers.retrieval import ( @@ -1317,6 +1321,18 @@ async def inspect_websocket(request: Request, call_next): ) app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"]) +# Google Drive integration +app.include_router(google_drive.router, prefix="/api/v1/google-drive", tags=["google-drive"]) + +# Zoho People integration +app.include_router(zoho_people.router, prefix="/api/v1/zoho-people", tags=["zoho-people"]) + +# IntraSearch integration +app.include_router(intrasearch.router, prefix="/api/v1/intrasearch", tags=["intrasearch"]) + +# Services integration (Google Drive, Zoho unified endpoints) +app.include_router(services.router, prefix="/api/v1/services", tags=["services"]) + # SCIM 2.0 API for identity management if SCIM_ENABLED: app.include_router(scim.router, prefix="/api/v1/scim/v2", tags=["scim"]) diff --git a/openwebui/backend/open_webui/routers/google_drive.py b/openwebui/backend/open_webui/routers/google_drive.py new file mode 100755 index 0000000000..4b735d3356 --- /dev/null +++ b/openwebui/backend/open_webui/routers/google_drive.py @@ -0,0 +1,182 @@ +""" +Google Drive API Router +Exposes Google Drive functionality through REST API +""" + +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import List, Optional +from pydantic import BaseModel + +from open_webui.utils.auth import get_verified_user +from open_webui.integrations.google_drive import google_drive, GoogleDriveFile + + +router = APIRouter() + + +class SearchRequest(BaseModel): + query: str + file_types: Optional[List[str]] = None + limit: int = 50 + include_content: bool = False + + +class FileContent(BaseModel): + file_id: str + name: str + content: str + mime_type: str + + +@router.post("/search") +async def search_drive_files( + request: SearchRequest, + user=Depends(get_verified_user) +): + """Search for files in user's Google Drive""" + try: + files = await google_drive.search_files( + user_id=user.id, + query=request.query, + file_types=request.file_types, + limit=request.limit, + include_content=request.include_content + ) + + return { + "files": [file.to_dict() for file in files], + "count": len(files) + } + except ValueError as e: + raise HTTPException(status_code=401, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}") + + +@router.get("/files/{file_id}") +async def get_file_metadata( + file_id: str, + user=Depends(get_verified_user) +): + """Get metadata for a specific file""" + try: + file_metadata = await google_drive.get_file_metadata(user.id, file_id) + return file_metadata.to_dict() + except ValueError as e: + raise HTTPException(status_code=401, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get file metadata: {str(e)}") + + +@router.get("/files/{file_id}/content") +async def get_file_content( + file_id: str, + user=Depends(get_verified_user), + max_size: int = Query(default=10*1024*1024, description="Maximum file size in bytes") +): + """Get text content of a file""" + try: + # Get file metadata first + file_metadata = await google_drive.get_file_metadata(user.id, file_id) + + # Get content + content = await google_drive.get_file_content(user.id, file_id, max_size) + + return FileContent( + file_id=file_id, + name=file_metadata.name, + content=content, + mime_type=file_metadata.mime_type + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get file content: {str(e)}") + + +@router.get("/recent") +async def get_recent_files( + user=Depends(get_verified_user), + limit: int = Query(default=20, le=100) +): + """Get recently modified files""" + try: + files = await google_drive.get_recent_files(user.id, limit) + return { + "files": [file.to_dict() for file in files], + "count": len(files) + } + except ValueError as e: + raise HTTPException(status_code=401, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get recent files: {str(e)}") + + +@router.get("/shared") +async def get_shared_files( + user=Depends(get_verified_user), + limit: int = Query(default=50, le=100) +): + """Get files shared with the user""" + try: + files = await google_drive.get_shared_files(user.id, limit) + return { + "files": [file.to_dict() for file in files], + "count": len(files) + } + except ValueError as e: + raise HTTPException(status_code=401, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get shared files: {str(e)}") + + +@router.get("/folders") +async def list_folders( + user=Depends(get_verified_user), + parent_id: Optional[str] = Query(default=None, description="Parent folder ID") +): + """List folders in Drive""" + try: + folders = await google_drive.list_folders(user.id, parent_id) + return { + "folders": [folder.to_dict() for folder in folders], + "count": len(folders) + } + except ValueError as e: + raise HTTPException(status_code=401, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to list folders: {str(e)}") + + +@router.get("/content-type/{content_type}") +async def search_by_content_type( + content_type: str, + user=Depends(get_verified_user), + limit: int = Query(default=50, le=100) +): + """Search files by content type (documents, spreadsheets, presentations, etc.)""" + try: + files = await google_drive.search_by_content_type(user.id, content_type, limit) + return { + "files": [file.to_dict() for file in files], + "count": len(files), + "content_type": content_type + } + except ValueError as e: + if "Unsupported content type" in str(e): + raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=401, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}") + + +@router.get("/user-info") +async def get_drive_user_info(user=Depends(get_verified_user)): + """Get information about the user's Google Drive""" + try: + user_info = await google_drive.get_user_info(user.id) + return user_info + except ValueError as e: + raise HTTPException(status_code=401, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get user info: {str(e)}") \ No newline at end of file diff --git a/openwebui/backend/open_webui/routers/intrasearch.py b/openwebui/backend/open_webui/routers/intrasearch.py new file mode 100644 index 0000000000..e96fd93f7e --- /dev/null +++ b/openwebui/backend/open_webui/routers/intrasearch.py @@ -0,0 +1,403 @@ +""" +IntraSearch API Router - Independent from LLM providers +Handles enterprise internal search without GPT/LLM dependencies +""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from pydantic import BaseModel +from typing import Dict, List, Optional, Any +from datetime import datetime +import uuid +import httpx +import os +from dotenv import load_dotenv + +from open_webui.utils.auth import get_verified_user + +load_dotenv() + +router = APIRouter() + +# Pydantic models for IntraSearch (independent from chat models) +class SearchRequest(BaseModel): + query: str + sources: Optional[List[str]] = None + max_results: Optional[int] = 10 + search_depth: Optional[str] = "detailed" # basic, detailed, comprehensive + include_attachments: Optional[bool] = True + language: Optional[str] = "auto" + +class SearchResult(BaseModel): + id: str + title: str + content: str + source: str + score: float + metadata: Dict[str, Any] = {} + +class SearchResponse(BaseModel): + query: str + results: List[SearchResult] + total_results: int + search_time_ms: int + suggestions: Optional[List[str]] = None + +class IntraSearchMessage(BaseModel): + id: Optional[str] = None + role: str # user, assistant, system + content: str + timestamp: Optional[datetime] = None + metadata: Optional[Dict[str, Any]] = None + +class IntraSearchSession(BaseModel): + id: str + title: str + messages: List[IntraSearchMessage] = [] + created_at: datetime + updated_at: datetime + +class IntraSearchSettings(BaseModel): + search_depth: str = "detailed" + search_sources: List[str] = ["documents", "wiki", "knowledge_base"] + max_results: int = 10 + include_attachments: bool = True + language: str = "auto" + +# Mock data for development (replace with real implementation) +MOCK_SEARCH_RESULTS = [ + SearchResult( + id="doc_001", + title="Corporate Security Policy", + content="This document describes the core principles of the company's information security, including rules for handling confidential information, password requirements, and system access procedures.", + source="Corporate Wiki", + score=0.95, + metadata={ + "document_type": "policy", + "author": "Security Team", + "created_at": "2024-01-15", + "tags": ["security", "policy", "compliance"] + } + ), + SearchResult( + id="kb_002", + title="CRM System User Guide", + content="Comprehensive guide for using the corporate CRM system, including customer creation, deal management, report configuration, and integration with other systems.", + source="Knowledge Base", + score=0.88, + metadata={ + "document_type": "manual", + "author": "IT Department", + "created_at": "2024-02-10", + "tags": ["crm", "manual", "sales"] + } + ), + SearchResult( + id="db_003", + title="Department Contact Directory", + content="Database containing contact information for all company departments, including phone numbers, email addresses, office locations, and responsible personnel.", + source="Employee Database", + score=0.82, + metadata={ + "document_type": "directory", + "author": "HR Department", + "created_at": "2024-03-01", + "tags": ["contacts", "directory", "hr"] + } + ) +] + +# In-memory storage for demo (replace with database) +sessions_storage: Dict[str, IntraSearchSession] = {} +user_settings: Dict[str, IntraSearchSettings] = {} + +@router.post("/search", response_model=SearchResponse) +async def perform_search( + request: SearchRequest, + user=Depends(get_verified_user) +): + """Perform enterprise internal search using GoogleDriveAgent""" + + import time + start_time = time.time() + + # Get GoogleDrive token and user_id from environment + google_token = os.getenv("GOOGLE_DRIVE_TOKEN") + google_user_id = os.getenv("GOOGLE_DRIVE_USER_ID") + + if not google_token or not google_user_id: + # Fallback to mock results if credentials not configured + return await perform_mock_search(request, start_time) + + try: + # Import crypto utils from GoogleDriveAgent project + import sys + google_agent_path = os.getenv("GOOGLE_DRIVE_AGENT_PATH", "/home/eugene/proj/GoogleDriveAgent") + if google_agent_path not in sys.path: + sys.path.insert(0, google_agent_path) + + from crypto_utils import TokenVault + + # Encrypt tokens + vault = TokenVault() + tokens = { + 'google_drive': google_token, + 'user_id': google_user_id + } + encrypted_tokens = vault.encrypt_tokens(tokens) + + # Call GoogleDriveAgent API + google_agent_url = os.getenv("GOOGLE_DRIVE_AGENT_URL", "http://localhost:8000/process") + + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + google_agent_url, + json={ + "text": request.query, + "encrypted_tokens": encrypted_tokens + } + ) + + if response.status_code != 200: + error_text = response.text + print(f"GoogleDriveAgent error: {error_text}") + raise Exception(f"GoogleDriveAgent returned status {response.status_code}: {error_text}") + + # Try to parse JSON response + try: + agent_response = response.json() + except Exception as json_error: + print(f"JSON parse error: {json_error}") + print(f"Response text: {response.text[:500]}") + raise Exception(f"Failed to parse GoogleDriveAgent response: {json_error}") + + # Convert GoogleDriveAgent response to IntraSearch format + results = [] + + if "answer" in agent_response: + # Question-answering mode response + answer = agent_response["answer"] + sources = agent_response.get("sources", []) + + # Create result from answer + results.append(SearchResult( + id=str(uuid.uuid4()), + title="Answer from Google Drive Documents", + content=answer, + source="Google Drive", + score=1.0, + metadata={ + "document_type": "answer", + "query_type": "question_answering" + } + )) + + # Add source documents as additional results + for idx, source in enumerate(sources[:5]): + results.append(SearchResult( + id=str(uuid.uuid4()), + title=source.get("title", f"Document {idx+1}"), + content=source.get("relevant_excerpt", ""), + source=source.get("type", "Google Drive"), + score=1.0 - (idx * 0.1), + metadata={ + "document_type": source.get("type", "document"), + "url": source.get("link", ""), + "excerpt": source.get("relevant_excerpt", "") + } + )) + + search_time = int((time.time() - start_time) * 1000) + + return SearchResponse( + query=request.query, + results=results, + total_results=len(results), + search_time_ms=search_time, + suggestions=[] + ) + + except Exception as e: + print(f"Error calling GoogleDriveAgent: {e}") + import traceback + traceback.print_exc() + # Fallback to mock results on error + return await perform_mock_search(request, start_time) + +async def perform_mock_search(request: SearchRequest, start_time: float): + """Fallback mock search implementation""" + import time + + query_lower = request.query.lower() + filtered_results = [] + + for result in MOCK_SEARCH_RESULTS: + if (query_lower in result.title.lower() or + query_lower in result.content.lower() or + any(query_lower in tag for tag in result.metadata.get("tags", []))): + filtered_results.append(result) + + limited_results = filtered_results[:request.max_results] + + suggestions = [] + if query_lower: + base_suggestions = [ + "security policy", + "crm system guide", + "department contacts", + "document workflow", + "corporate standards", + "employee handbook", + "it support portal" + ] + suggestions = [s for s in base_suggestions if query_lower not in s.lower()][:3] + + search_time = int((time.time() - start_time) * 1000) + + return SearchResponse( + query=request.query, + results=limited_results, + total_results=len(filtered_results), + search_time_ms=search_time, + suggestions=suggestions + ) + +@router.get("/suggestions") +async def get_search_suggestions( + q: str = Query(..., description="Search query"), + user=Depends(get_verified_user) +): + """Get search suggestions based on query""" + + suggestions = [ + f"{q} политика", + f"{q} инструкция", + f"{q} контакты", + f"как {q}", + f"{q} документы" + ] + + return {"suggestions": suggestions[:5]} + +@router.post("/sessions", response_model=IntraSearchSession) +async def create_session( + title: Optional[str] = "IntraSearch Session", + user=Depends(get_verified_user) +): + """Create new IntraSearch session""" + + session_id = str(uuid.uuid4()) + now = datetime.now() + + session = IntraSearchSession( + id=session_id, + title=title or f"IntraSearch Session {now.strftime('%Y-%m-%d %H:%M')}", + messages=[], + created_at=now, + updated_at=now + ) + + sessions_storage[f"{user.id}_{session_id}"] = session + return session + +@router.get("/sessions", response_model=List[IntraSearchSession]) +async def get_sessions(user=Depends(get_verified_user)): + """Get all IntraSearch sessions for user""" + + user_sessions = [ + session for key, session in sessions_storage.items() + if key.startswith(f"{user.id}_") + ] + + return sorted(user_sessions, key=lambda x: x.updated_at, reverse=True) + +@router.get("/sessions/{session_id}", response_model=IntraSearchSession) +async def get_session( + session_id: str, + user=Depends(get_verified_user) +): + """Get specific IntraSearch session""" + + session_key = f"{user.id}_{session_id}" + if session_key not in sessions_storage: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found" + ) + + return sessions_storage[session_key] + +@router.post("/sessions/{session_id}/messages", response_model=IntraSearchMessage) +async def save_message( + session_id: str, + message: IntraSearchMessage, + user=Depends(get_verified_user) +): + """Save message to IntraSearch session""" + + session_key = f"{user.id}_{session_id}" + if session_key not in sessions_storage: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found" + ) + + # Set message ID and timestamp if not provided + if not message.id: + message.id = str(uuid.uuid4()) + if not message.timestamp: + message.timestamp = datetime.now() + + sessions_storage[session_key].messages.append(message) + sessions_storage[session_key].updated_at = datetime.now() + + return message + +@router.delete("/sessions/{session_id}") +async def delete_session( + session_id: str, + user=Depends(get_verified_user) +): + """Delete IntraSearch session""" + + session_key = f"{user.id}_{session_id}" + if session_key not in sessions_storage: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found" + ) + + del sessions_storage[session_key] + return {"message": "Session deleted successfully"} + +@router.get("/settings", response_model=IntraSearchSettings) +async def get_settings(user=Depends(get_verified_user)): + """Get IntraSearch settings for user""" + + return user_settings.get(user.id, IntraSearchSettings()) + +@router.put("/settings") +async def update_settings( + settings: IntraSearchSettings, + user=Depends(get_verified_user) +): + """Update IntraSearch settings for user""" + + user_settings[user.id] = settings + return {"message": "Settings updated successfully"} + +@router.get("/sources") +async def get_search_sources(user=Depends(get_verified_user)): + """Get available search sources""" + + sources = [ + "documents", + "wiki", + "knowledge_base", + "databases", + "repositories", + "email_archives", + "project_files", + "policies" + ] + + return {"sources": sources} \ No newline at end of file diff --git a/openwebui/backend/open_webui/routers/services.py b/openwebui/backend/open_webui/routers/services.py new file mode 100755 index 0000000000..88b0a75af8 --- /dev/null +++ b/openwebui/backend/open_webui/routers/services.py @@ -0,0 +1,578 @@ +""" +Updated Services Router with proper OAuth 2.0 implementation +""" + +from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from pydantic import BaseModel +from typing import Dict, Optional +from datetime import datetime, timedelta +import secrets +import urllib.parse +import httpx + +from open_webui.utils.auth import get_verified_user +from open_webui.utils.oauth_services import get_service_config, get_configured_services, ServiceType, reload_oauth_configs +from open_webui.services.oauth_token_manager import token_manager, TokenData + +router = APIRouter() + +# In-memory state storage for OAuth flows (in production, use Redis or similar) +oauth_states = {} + + +class ServiceStatus(BaseModel): + authorized: bool + email: Optional[str] = None + expires_at: Optional[datetime] = None + scopes: Optional[str] = None + + +class AuthResponse(BaseModel): + auth_url: str + state: str + + +@router.get("/status") +async def get_services_status(user=Depends(get_verified_user)): + """Get the authorization status of all services for the current user""" + user_services = token_manager.get_user_services(user.id) + + statuses = {} + for service_id in [ServiceType.GOOGLE_DRIVE, ServiceType.ZOHO, ServiceType.JIRA, ServiceType.HUBSPOT]: + if service_id in user_services: + token_data = user_services[service_id] + statuses[service_id] = ServiceStatus( + authorized=True, + email=token_data.user_email or f"{service_id}_user@example.com", + expires_at=token_data.expires_at, + scopes=token_data.scope + ) + else: + statuses[service_id] = ServiceStatus(authorized=False) + + return statuses + + +@router.get("/{service_id}/auth") +async def initiate_service_auth(service_id: str, request: Request, user=Depends(get_verified_user)): + """Initiate OAuth 2.0 authentication for a service""" + config = get_service_config(service_id) + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Service {service_id} not found" + ) + + if not config.is_configured(): + # Get the appropriate environment variable names for the service + env_var_names = { + "google_drive": "GOOGLE_DRIVE_CLIENT_ID and GOOGLE_DRIVE_CLIENT_SECRET", + "zoho": "ZOHO_CLIENT_ID and ZOHO_CLIENT_SECRET", + "onedrive": "ONEDRIVE_CLIENT_ID and ONEDRIVE_CLIENT_SECRET" + } + env_vars = env_var_names.get(service_id, f"{service_id.upper()}_CLIENT_ID and {service_id.upper()}_CLIENT_SECRET") + + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Service {service_id} is not configured. Please set environment variables {env_vars}." + ) + + # Generate secure state parameter + state = secrets.token_urlsafe(32) + + # Store state with user info for validation + oauth_states[state] = { + "user_id": user.id, + "service_id": service_id, + "timestamp": datetime.utcnow().timestamp() + } + + # Build redirect URI + base_url = str(request.base_url).rstrip('/') + redirect_uri = f"{base_url}/api/v1/services/{service_id}/callback" + + # Build authorization URL + auth_params = { + "client_id": config.client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "state": state, + "access_type": "offline", # For refresh tokens + "prompt": "consent" # Force consent to get refresh token + } + + # Service-specific parameters + if service_id == ServiceType.GOOGLE_DRIVE: + auth_params["include_granted_scopes"] = "true" + auth_params["scope"] = config.get_scope_string() # Space-separated for Google + elif service_id == ServiceType.ZOHO: + # Zoho requires comma-separated scopes and access_type=offline for refresh tokens + auth_params["scope"] = config.get_scope_string_comma_separated() + auth_params["access_type"] = "offline" + else: + # Default to space-separated scopes for other services + auth_params["scope"] = config.get_scope_string() + + auth_url = f"{config.auth_url}?{urllib.parse.urlencode(auth_params)}" + + return AuthResponse(auth_url=auth_url, state=state) + + +@router.get("/{service_id}/callback") +async def service_auth_callback( + service_id: str, + code: str, + state: str, + request: Request, + error: Optional[str] = None +): + """Handle OAuth callback and save tokens""" + # Check for OAuth errors + if error: + return HTMLResponse(f""" + + +

Authorization Error

+

Error: {error}

+

You can close this window and try again.

+ + + + """) + + # Validate state parameter + if state not in oauth_states: + return HTMLResponse(""" + + +

Invalid State

+

OAuth state validation failed. Please try again.

+ + + + """) + + state_data = oauth_states[state] + user_id = state_data["user_id"] + stored_service_id = state_data["service_id"] + + # Validate service ID matches + if service_id != stored_service_id: + return HTMLResponse(""" + + +

Service Mismatch

+

Service ID validation failed. Please try again.

+ + + + """) + + # Clean up state + del oauth_states[state] + + config = get_service_config(service_id) + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Service {service_id} not found" + ) + + # Build redirect URI + base_url = str(request.base_url).rstrip('/') + redirect_uri = f"{base_url}/api/v1/services/{service_id}/callback" + + try: + # Exchange authorization code for tokens + token_data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": config.client_id, + "client_secret": config.client_secret + } + + # For Zoho, add scope to token exchange request + if service_id == "zoho" and config.scopes: + token_data["scope"] = " ".join(config.scopes) + + print(f"Token exchange request for {service_id}:") + print(f" URL: {config.token_url}") + print(f" redirect_uri: {redirect_uri}") + print(f" client_id: {config.client_id}") + print(f" code: {code[:10]}...{code[-10:] if len(code) > 20 else code}") + + async with httpx.AsyncClient() as client: + token_response = await client.post( + config.token_url, + data=token_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30.0 + ) + + if token_response.status_code != 200: + print(f"Token exchange failed: {token_response.status_code} - {token_response.text}") + return HTMLResponse(f""" + + +

Token Exchange Failed

+

Failed to exchange authorization code for tokens.

+

Status: {token_response.status_code}

+ + + + """) + + token_data_raw = token_response.json() + print(f"Token response data: {token_data_raw}") + + # Check if access_token is present + if "access_token" not in token_data_raw: + error_description = token_data_raw.get("error_description", "Unknown error") + error_code = token_data_raw.get("error", "token_error") + print(f"Token response missing access_token: {token_data_raw}") + return HTMLResponse(f""" + + +

Authorization Error

+

An error occurred during authorization: '{error_code}'

+

Description: {error_description}

+

You can close this window and try again.

+ + + + """) + + # Get user info if available (skip for Zoho as it has different auth format) + user_email = None + user_name = None + + if config.userinfo_url and token_data_raw.get("access_token") and service_id != ServiceType.ZOHO: + try: + async with httpx.AsyncClient() as client: + userinfo_response = await client.get( + config.userinfo_url, + headers={ + "Authorization": f"Bearer {token_data_raw['access_token']}" + }, + timeout=30.0 + ) + if userinfo_response.status_code == 200: + user_info = userinfo_response.json() + user_email = user_info.get("email") + user_name = user_info.get("name") or user_info.get("given_name", "") + except Exception as e: + print(f"Failed to get user info: {e}") + elif service_id == ServiceType.ZOHO: + # Try to get user info from Zoho + print("\n" + "="*60) + print("ZOHO USER INFO RETRIEVAL DEBUG") + print("="*60) + + try: + print(f"1. Access token received: {token_data_raw.get('access_token', 'NO TOKEN')[:20]}...") + print(f"2. Token scope: {token_data_raw.get('scope', 'NO SCOPE')}") + + userinfo_headers = {"Authorization": f"Bearer {token_data_raw['access_token']}"} + + # Get the correct Zoho datacenter URL from config + zoho_config = get_service_config(ServiceType.ZOHO) + userinfo_url = zoho_config.userinfo_url or "https://accounts.zoho.eu/oauth/user/info" + + print(f"3. Using userinfo URL: {userinfo_url}") + print(f"4. Request headers: {userinfo_headers}") + + # Use httpx for the request (already imported) + with httpx.Client() as client: + userinfo_response = client.get( + userinfo_url, + headers=userinfo_headers, + timeout=10 + ) + + print(f"5. Response status: {userinfo_response.status_code}") + print(f"6. Response headers: {dict(userinfo_response.headers)}") + print(f"7. Response body: {userinfo_response.text}") + + if userinfo_response.status_code == 200: + user_info = userinfo_response.json() + print(f"8. Parsed JSON response: {user_info}") + print(f"9. Available keys in response: {list(user_info.keys())}") + + # Try all possible field names for email + email_fields = ["Email", "EMAIL", "email", "USEREMAIL", "user_email", + "UserEmail", "emailAddress", "EmailAddress", "mail", "Mail"] + user_email = None + + for field in email_fields: + if field in user_info: + user_email = user_info[field] + print(f"10. Found email in field '{field}': {user_email}") + break + + if not user_email: + print(f"11. No email found in any of the fields: {email_fields}") + user_email = "zoho_user@example.com" + + # Try all possible field names for name + name_fields = ["Display_Name", "DISPLAY_NAME", "display_name", "DisplayName", + "First_Name", "FIRST_NAME", "first_name", "FirstName", + "name", "Name", "NAME", "full_name", "FullName", "fullName"] + user_name = None + + for field in name_fields: + if field in user_info: + user_name = user_info[field] + print(f"12. Found name in field '{field}': {user_name}") + break + + if not user_name: + print(f"13. No name found in any of the fields: {name_fields}") + user_name = "Zoho User" + + print(f"14. Final extracted - Email: {user_email}, Name: {user_name}") + else: + # Fallback to placeholder + print(f"ERROR: Failed to get Zoho user info - Status: {userinfo_response.status_code}") + print(f"ERROR: Response body: {userinfo_response.text}") + user_email = "zoho_user@example.com" + user_name = "Zoho User" + + except Exception as e: + print(f"EXCEPTION: Failed to get Zoho user info: {e}") + import traceback + traceback.print_exc() + user_email = "zoho_user@example.com" + user_name = "Zoho User" + + print("="*60) + print("END ZOHO DEBUG") + print("="*60 + "\n") + + # Create token data object + expires_at = None + if token_data_raw.get("expires_in"): + expires_at = datetime.utcnow() + timedelta(seconds=int(token_data_raw["expires_in"])) + + token_data = TokenData( + access_token=token_data_raw["access_token"], + refresh_token=token_data_raw.get("refresh_token"), + expires_at=expires_at, + user_email=user_email, + user_name=user_name, + service_id=service_id, + scope=token_data_raw.get("scope") + ) + + # Store encrypted token data + token_manager.store_token(user_id, service_id, token_data) + + return HTMLResponse(f""" + + + Authorization Successful + + + +
+
+

Authorization Successful!

+
+

Service: {config.name}

+ {f'

Account: {user_email}

' if user_email else ''} +
+

You can close this window now. The service has been connected successfully!

+
+ + + + """) + + except Exception as e: + print(f"Error during OAuth callback: {e}") + return HTMLResponse(f""" + + +

Authorization Error

+

An error occurred during authorization: {str(e)}

+

You can close this window and try again.

+ + + + """) + + +@router.post("/{service_id}/disconnect") +async def disconnect_service(service_id: str, user=Depends(get_verified_user)): + """Disconnect a service by removing its tokens""" + config = get_service_config(service_id) + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Service {service_id} not found" + ) + + # Get token before deletion for revocation + token_data = token_manager.get_token(user.id, service_id) + + # Delete from our storage + deleted = token_manager.delete_token(user.id, service_id) + + # Try to revoke token at the provider + if token_data and config.revoke_url: + try: + async with httpx.AsyncClient() as client: + await client.post( + config.revoke_url, + data={"token": token_data.access_token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=10.0 + ) + except Exception as e: + print(f"Failed to revoke token at provider: {e}") + # Continue anyway - local deletion is more important + + if deleted: + return {"message": f"Successfully disconnected from {config.name}"} + else: + return {"message": f"Service {config.name} was not connected"} + + +@router.get("/{service_id}/token") +async def get_service_token(service_id: str, user=Depends(get_verified_user)): + """Get a valid access token for a specific service (for internal use)""" + config = get_service_config(service_id) + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Service {service_id} not found" + ) + + # Get token and refresh if needed + access_token = await token_manager.get_valid_token(user.id, service_id) + + if not access_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Service {config.name} is not authorized or token is invalid" + ) + + return {"access_token": access_token, "service": config.name} + + +@router.get("/configured") +async def get_configured_services_endpoint(): + """Get list of services that have OAuth credentials configured""" + configured = get_configured_services() + + services_info = {} + for service_id in configured: + config = get_service_config(service_id) + if config: + services_info[service_id] = { + "name": config.name, + "scopes": config.scopes + } + + return services_info + + +# Cleanup task - run periodically to remove expired tokens +@router.post("/cleanup") +async def cleanup_expired_tokens(): + """Admin endpoint to cleanup expired tokens""" + token_manager.cleanup_expired_tokens() + return {"message": "Cleanup completed"} + + +@router.post("/reload-config") +async def reload_oauth_config(user=Depends(get_verified_user)): + """Reload OAuth configuration from external config file""" + # Note: In production, you might want to restrict this to admin users only + try: + reload_oauth_configs() + return { + "success": True, + "message": "OAuth configuration reloaded successfully" + } + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to reload configuration: {str(e)}" + ) \ No newline at end of file diff --git a/openwebui/backend/open_webui/routers/zoho_people.py b/openwebui/backend/open_webui/routers/zoho_people.py new file mode 100644 index 0000000000..59c2ca9995 --- /dev/null +++ b/openwebui/backend/open_webui/routers/zoho_people.py @@ -0,0 +1,232 @@ +""" +Zoho People API Router +Provides endpoints for accessing Zoho People data for personalized AI responses +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from typing import Dict, List, Optional, Any +from datetime import datetime + +from open_webui.utils.auth import get_verified_user +from open_webui.services.zoho_people import zoho_people_api +from open_webui.services.oauth_token_manager import token_manager +from open_webui.utils.oauth_services import ServiceType + +router = APIRouter() + + +class EmployeeProfile(BaseModel): + employee_id: Optional[str] = None + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + full_name: Optional[str] = None + department: Optional[str] = None + designation: Optional[str] = None + employee_status: Optional[str] = None + joining_date: Optional[str] = None + mobile: Optional[str] = None + reporting_to: Optional[str] = None + location: Optional[str] = None + employee_type: Optional[str] = None + + +class TeamMember(BaseModel): + employee_id: Optional[str] = None + email: Optional[str] = None + full_name: Optional[str] = None + department: Optional[str] = None + designation: Optional[str] = None + employee_status: Optional[str] = None + mobile: Optional[str] = None + location: Optional[str] = None + + +class AttendanceSummary(BaseModel): + total_days: int + present_days: int + absent_days: int + late_days: int + early_departure_days: int + total_hours: float + from_date: str + to_date: str + + +class LeaveBalance(BaseModel): + allocated: int + used: int + available: int + pending: int + + +class LeaveRequest(BaseModel): + leave_id: Optional[str] = None + leave_type: Optional[str] = None + from_date: Optional[str] = None + to_date: Optional[str] = None + days: Optional[int] = None + status: Optional[str] = None + reason: Optional[str] = None + applied_date: Optional[str] = None + approved_by: Optional[str] = None + + +class EmployeeContext(BaseModel): + profile: Optional[EmployeeProfile] = None + team: Dict[str, Any] = {} + attendance: Optional[AttendanceSummary] = None + leave: Dict[str, Any] = {} + timestamp: str + + +def check_zoho_authorization(user_id: str): + """Check if user has authorized Zoho access""" + token_data = token_manager.get_token(user_id, ServiceType.ZOHO) + if not token_data: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Zoho People access not authorized. Please connect to Zoho in Settings -> External Tools." + ) + return token_data + + +@router.get("/profile", response_model=EmployeeProfile) +async def get_employee_profile(user=Depends(get_verified_user)): + """Get current employee's profile information from Zoho People""" + check_zoho_authorization(user.id) + + profile_data = await zoho_people_api.get_employee_profile(user.id) + + if not profile_data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Employee profile not found or access denied" + ) + + return EmployeeProfile(**profile_data) + + +@router.get("/team", response_model=List[TeamMember]) +async def get_team_members(user=Depends(get_verified_user)): + """Get team members (employees reporting to current user)""" + check_zoho_authorization(user.id) + + team_data = await zoho_people_api.get_team_members(user.id) + return [TeamMember(**member) for member in team_data] + + +@router.get("/attendance", response_model=AttendanceSummary) +async def get_attendance_summary( + from_date: Optional[str] = None, + to_date: Optional[str] = None, + user=Depends(get_verified_user) +): + """Get attendance summary for current employee""" + check_zoho_authorization(user.id) + + attendance_data = await zoho_people_api.get_attendance_summary(user.id, from_date, to_date) + + if not attendance_data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Attendance data not found" + ) + + return AttendanceSummary(**attendance_data) + + +@router.get("/leave/balance") +async def get_leave_balance(user=Depends(get_verified_user)): + """Get leave balance for current employee""" + check_zoho_authorization(user.id) + + balance_data = await zoho_people_api.get_leave_balance(user.id) + + if not balance_data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Leave balance data not found" + ) + + return balance_data + + +@router.get("/leave/requests", response_model=List[LeaveRequest]) +async def get_recent_leave_requests( + limit: int = 10, + user=Depends(get_verified_user) +): + """Get recent leave requests for current employee""" + check_zoho_authorization(user.id) + + requests_data = await zoho_people_api.get_recent_leave_requests(user.id, limit) + return [LeaveRequest(**request) for request in requests_data] + + +@router.get("/context", response_model=EmployeeContext) +async def get_employee_context(user=Depends(get_verified_user)): + """Get comprehensive employee context for personalized AI responses""" + check_zoho_authorization(user.id) + + context_data = await zoho_people_api.get_employee_context(user.id) + + if not context_data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Employee context data not found" + ) + + return EmployeeContext( + profile=EmployeeProfile(**context_data.get("profile", {})) if context_data.get("profile") else None, + team=context_data.get("team", {}), + attendance=AttendanceSummary(**context_data.get("attendance", {})) if context_data.get("attendance") else None, + leave=context_data.get("leave", {}), + timestamp=context_data.get("timestamp", datetime.now().isoformat()) + ) + + +@router.get("/connection/status") +async def get_zoho_connection_status(user=Depends(get_verified_user)): + """Get Zoho People connection status for current user""" + try: + print("\n" + "="*60) + print("ZOHO CONNECTION STATUS CHECK") + print("="*60) + print(f"User ID: {user.id}") + + token_data = token_manager.get_token(user.id, ServiceType.ZOHO) + + if not token_data: + print("No token data found for user") + return { + "connected": False, + "message": "Not connected to Zoho People" + } + + print(f"Token data found:") + print(f" - User email from token: {token_data.user_email}") + print(f" - User name from token: {token_data.user_name}") + print(f" - Token expires at: {token_data.expires_at}") + print(f" - Token scope: {token_data.scope}") + + # If we have a valid OAuth token, consider it connected + # Even if the user doesn't have access to People API + result = { + "connected": True, + "employee_email": token_data.user_email or "zoho_user@example.com", + "employee_name": token_data.user_name or "Zoho User", + "last_updated": token_data.expires_at.isoformat() if token_data.expires_at else None, + "message": "OAuth connected successfully" + } + + print(f"Returning result: {result}") + print("="*60 + "\n") + return result + + except Exception as e: + return { + "connected": False, + "message": f"Connection error: {str(e)}" + } \ No newline at end of file diff --git a/openwebui/backend/open_webui/services/__init__.py b/openwebui/backend/open_webui/services/__init__.py new file mode 100755 index 0000000000..8c14598a0d --- /dev/null +++ b/openwebui/backend/open_webui/services/__init__.py @@ -0,0 +1 @@ +# Services module \ No newline at end of file diff --git a/openwebui/backend/open_webui/services/oauth_token_manager.py b/openwebui/backend/open_webui/services/oauth_token_manager.py new file mode 100755 index 0000000000..210d0c3e32 --- /dev/null +++ b/openwebui/backend/open_webui/services/oauth_token_manager.py @@ -0,0 +1,315 @@ +""" +OAuth Token Management Module +Handles secure storage, retrieval, and refresh of OAuth tokens +""" + +import json +import os +import sqlite3 +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from cryptography.fernet import Fernet +import base64 +import hashlib + +from open_webui.config import DATA_DIR +from open_webui.utils.oauth_services import get_service_config, OAuthConfig +import httpx + + +class TokenData: + """Token data structure""" + + def __init__( + self, + access_token: str, + refresh_token: Optional[str] = None, + expires_at: Optional[datetime] = None, + user_email: Optional[str] = None, + user_name: Optional[str] = None, + service_id: str = "", + scope: Optional[str] = None + ): + self.access_token = access_token + self.refresh_token = refresh_token + self.expires_at = expires_at + self.user_email = user_email + self.user_name = user_name + self.service_id = service_id + self.scope = scope + + def is_expired(self) -> bool: + """Check if the access token is expired""" + if not self.expires_at: + return False + return datetime.utcnow() >= self.expires_at + + def expires_soon(self, minutes: int = 5) -> bool: + """Check if token expires within specified minutes""" + if not self.expires_at: + return False + return datetime.utcnow() >= (self.expires_at - timedelta(minutes=minutes)) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization""" + return { + "access_token": self.access_token, + "refresh_token": self.refresh_token, + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + "user_email": self.user_email, + "user_name": self.user_name, + "service_id": self.service_id, + "scope": self.scope + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "TokenData": + """Create TokenData from dictionary""" + expires_at = None + if data.get("expires_at"): + expires_at = datetime.fromisoformat(data["expires_at"]) + + return cls( + access_token=data["access_token"], + refresh_token=data.get("refresh_token"), + expires_at=expires_at, + user_email=data.get("user_email"), + user_name=data.get("user_name"), + service_id=data.get("service_id", ""), + scope=data.get("scope") + ) + + +class OAuthTokenManager: + """Manages OAuth tokens with encryption and database storage""" + + def __init__(self): + self.db_path = os.path.join(DATA_DIR, "oauth_tokens.db") + self._ensure_database() + self._encryption_key = self._get_or_create_encryption_key() + + def _get_or_create_encryption_key(self) -> bytes: + """Get or create encryption key for token storage""" + key_file = os.path.join(DATA_DIR, ".oauth_key") + + if os.path.exists(key_file): + with open(key_file, 'rb') as f: + return f.read() + + # Generate new key + key = Fernet.generate_key() + os.makedirs(DATA_DIR, exist_ok=True) + + with open(key_file, 'wb') as f: + f.write(key) + + # Set secure file permissions + os.chmod(key_file, 0o600) + return key + + def _ensure_database(self): + """Create database table if it doesn't exist""" + os.makedirs(DATA_DIR, exist_ok=True) + + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS oauth_tokens ( + user_id TEXT, + service_id TEXT, + encrypted_token_data BLOB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, service_id) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_user_service + ON oauth_tokens(user_id, service_id) + """) + + def _encrypt_token_data(self, token_data: TokenData) -> bytes: + """Encrypt token data for secure storage""" + fernet = Fernet(self._encryption_key) + data_json = json.dumps(token_data.to_dict()) + return fernet.encrypt(data_json.encode()) + + def _decrypt_token_data(self, encrypted_data: bytes) -> TokenData: + """Decrypt token data from storage""" + fernet = Fernet(self._encryption_key) + decrypted_json = fernet.decrypt(encrypted_data).decode() + data_dict = json.loads(decrypted_json) + return TokenData.from_dict(data_dict) + + def store_token(self, user_id: str, service_id: str, token_data: TokenData): + """Store encrypted token data in database""" + print(f"\n[TOKEN MANAGER] Storing token for user {user_id}, service {service_id}") + print(f"[TOKEN MANAGER] Token data to store:") + print(f" - Email: {token_data.user_email}") + print(f" - Name: {token_data.user_name}") + print(f" - Scope: {token_data.scope}") + print(f" - Expires at: {token_data.expires_at}") + + encrypted_data = self._encrypt_token_data(token_data) + + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + INSERT OR REPLACE INTO oauth_tokens + (user_id, service_id, encrypted_token_data, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + """, (user_id, service_id, encrypted_data)) + + print(f"[TOKEN MANAGER] Token stored successfully\n") + + def get_token(self, user_id: str, service_id: str) -> Optional[TokenData]: + """Retrieve and decrypt token data from database""" + print(f"Getting token for user {user_id}, service {service_id}") + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute(""" + SELECT encrypted_token_data FROM oauth_tokens + WHERE user_id = ? AND service_id = ? + """, (user_id, service_id)) + + row = cursor.fetchone() + if not row: + print(f"No token found for user {user_id}, service {service_id}") + return None + + print(f"Found token for user {user_id}, service {service_id}") + + try: + return self._decrypt_token_data(row[0]) + except Exception as e: + print(f"Error decrypting token data: {e}") + return None + + def delete_token(self, user_id: str, service_id: str) -> bool: + """Delete token data from database""" + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute(""" + DELETE FROM oauth_tokens + WHERE user_id = ? AND service_id = ? + """, (user_id, service_id)) + return cursor.rowcount > 0 + + def get_user_services(self, user_id: str) -> Dict[str, TokenData]: + """Get all services with tokens for a user""" + services = {} + + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute(""" + SELECT service_id, encrypted_token_data FROM oauth_tokens + WHERE user_id = ? + """, (user_id,)) + + for service_id, encrypted_data in cursor.fetchall(): + try: + token_data = self._decrypt_token_data(encrypted_data) + services[service_id] = token_data + except Exception as e: + print(f"Error decrypting token for service {service_id}: {e}") + + return services + + async def refresh_token_if_needed(self, user_id: str, service_id: str) -> Optional[TokenData]: + """Refresh token if it's expired or expires soon""" + token_data = self.get_token(user_id, service_id) + if not token_data: + return None + + if not token_data.expires_soon(): + return token_data + + if not token_data.refresh_token: + print(f"No refresh token available for {service_id}") + return token_data + + # Get service config for refresh + config = get_service_config(service_id) + if not config: + print(f"No config found for service {service_id}") + return token_data + + try: + # Refresh the token + new_token_data = await self._refresh_access_token(config, token_data) + if new_token_data: + # Store updated token + self.store_token(user_id, service_id, new_token_data) + return new_token_data + except Exception as e: + print(f"Error refreshing token for {service_id}: {e}") + + return token_data + + async def _refresh_access_token(self, config: OAuthConfig, token_data: TokenData) -> Optional[TokenData]: + """Refresh access token using refresh token""" + if not token_data.refresh_token: + return None + + async with httpx.AsyncClient() as client: + response = await client.post( + config.token_url, + data={ + "grant_type": "refresh_token", + "refresh_token": token_data.refresh_token, + "client_id": config.client_id, + "client_secret": config.client_secret + }, + headers={"Content-Type": "application/x-www-form-urlencoded"} + ) + + if response.status_code != 200: + print(f"Failed to refresh token: {response.status_code} - {response.text}") + return None + + refresh_response = response.json() + + # Create new token data + new_token_data = TokenData( + access_token=refresh_response["access_token"], + refresh_token=refresh_response.get("refresh_token", token_data.refresh_token), + expires_at=datetime.utcnow() + timedelta(seconds=refresh_response.get("expires_in", 3600)), + user_email=token_data.user_email, + user_name=token_data.user_name, + service_id=token_data.service_id, + scope=refresh_response.get("scope", token_data.scope) + ) + + return new_token_data + + async def get_valid_token(self, user_id: str, service_id: str) -> Optional[str]: + """Get a valid access token, refreshing if necessary""" + token_data = await self.refresh_token_if_needed(user_id, service_id) + return token_data.access_token if token_data else None + + def cleanup_expired_tokens(self): + """Remove tokens that are expired and have no refresh token""" + services = {} + to_delete = [] + + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute(""" + SELECT user_id, service_id, encrypted_token_data FROM oauth_tokens + """) + + for user_id, service_id, encrypted_data in cursor.fetchall(): + try: + token_data = self._decrypt_token_data(encrypted_data) + if token_data.is_expired() and not token_data.refresh_token: + to_delete.append((user_id, service_id)) + except Exception as e: + print(f"Error checking token {user_id}:{service_id}: {e}") + to_delete.append((user_id, service_id)) + + # Delete expired tokens + for user_id, service_id in to_delete: + conn.execute(""" + DELETE FROM oauth_tokens + WHERE user_id = ? AND service_id = ? + """, (user_id, service_id)) + + print(f"Cleaned up {len(to_delete)} expired tokens") + + +# Global instance +token_manager = OAuthTokenManager() \ No newline at end of file diff --git a/openwebui/backend/open_webui/services/zoho_people.py b/openwebui/backend/open_webui/services/zoho_people.py new file mode 100644 index 0000000000..fe811236bc --- /dev/null +++ b/openwebui/backend/open_webui/services/zoho_people.py @@ -0,0 +1,281 @@ +""" +Zoho People API Integration Module +Provides methods to interact with Zoho People API for employee data and personalized responses +""" + +import httpx +import logging +from typing import Dict, List, Optional, Any +from datetime import datetime, timezone +from open_webui.services.oauth_token_manager import token_manager +from open_webui.utils.oauth_services import ServiceType + +log = logging.getLogger(__name__) + + +class ZohoPeopleAPI: + """Zoho People API client for employee data retrieval""" + + def __init__(self): + self.base_url = "https://people.zoho.eu/people/api" + self.service_id = ServiceType.ZOHO + + async def _get_headers(self, user_id: str) -> Optional[Dict[str, str]]: + """Get authorization headers with valid access token""" + access_token = await token_manager.get_valid_token(user_id, self.service_id) + if not access_token: + return None + + return { + "Authorization": f"Zoho-oauthtoken {access_token}", + "Content-Type": "application/json" + } + + async def _make_request(self, user_id: str, endpoint: str, params: Optional[Dict] = None) -> Optional[Dict]: + """Make authenticated request to Zoho People API""" + headers = await self._get_headers(user_id) + if not headers: + log.error(f"No valid token for user {user_id}") + return None + + url = f"{self.base_url}/{endpoint}" + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=headers, params=params or {}) + + if response.status_code == 200: + data = response.json() + log.info(f"Zoho People API response for {endpoint}: {data}") + return data + elif response.status_code == 401: + log.error(f"Unauthorized access to Zoho People API for user {user_id}") + return None + else: + log.error(f"Zoho People API error: {response.status_code} - {response.text}") + return None + + except Exception as e: + log.error(f"Error making request to Zoho People API: {e}") + return None + + async def get_employee_profile(self, user_id: str) -> Optional[Dict]: + """Get current employee's profile information""" + try: + # Get current user's employee info + response = await self._make_request(user_id, "forms/employee/getRecords") + + if response and response.get("success"): + employee_data = response.get("data", {}) + + # Extract relevant profile information + profile = { + "employee_id": employee_data.get("Employee_Id"), + "email": employee_data.get("Email_Id"), + "first_name": employee_data.get("First_Name"), + "last_name": employee_data.get("Last_Name"), + "full_name": f"{employee_data.get('First_Name', '')} {employee_data.get('Last_Name', '')}".strip(), + "department": employee_data.get("Department"), + "designation": employee_data.get("Designation"), + "employee_status": employee_data.get("Employee_Status"), + "joining_date": employee_data.get("Date_of_joining"), + "mobile": employee_data.get("Mobile"), + "reporting_to": employee_data.get("Reporting_To"), + "location": employee_data.get("Location"), + "employee_type": employee_data.get("Employee_Type") + } + + return profile + + return None + + except Exception as e: + log.error(f"Error getting employee profile: {e}") + return None + + async def get_team_members(self, user_id: str) -> List[Dict]: + """Get team members (employees reporting to current user)""" + try: + # First get current user's profile to find their employee ID + profile = await self.get_employee_profile(user_id) + if not profile: + return [] + + # Get all employees and filter those reporting to current user + response = await self._make_request(user_id, "forms/employee/getRecords") + + if response and response.get("success"): + all_employees = response.get("data", []) + team_members = [] + + current_employee_id = profile.get("employee_id") + + for employee in all_employees: + if employee.get("Reporting_To") == current_employee_id: + team_member = { + "employee_id": employee.get("Employee_Id"), + "email": employee.get("Email_Id"), + "full_name": f"{employee.get('First_Name', '')} {employee.get('Last_Name', '')}".strip(), + "department": employee.get("Department"), + "designation": employee.get("Designation"), + "employee_status": employee.get("Employee_Status"), + "mobile": employee.get("Mobile"), + "location": employee.get("Location") + } + team_members.append(team_member) + + return team_members + + return [] + + except Exception as e: + log.error(f"Error getting team members: {e}") + return [] + + async def get_attendance_summary(self, user_id: str, from_date: str = None, to_date: str = None) -> Optional[Dict]: + """Get attendance summary for current employee""" + try: + # Default to current month if no dates provided + if not from_date or not to_date: + now = datetime.now(timezone.utc) + from_date = now.replace(day=1).strftime("%Y-%m-%d") + to_date = now.strftime("%Y-%m-%d") + + params = { + "fromDate": from_date, + "toDate": to_date + } + + response = await self._make_request(user_id, "attendance/getattendanceentries", params) + + if response and response.get("success"): + attendance_data = response.get("data", []) + + # Process attendance data + summary = { + "total_days": len(attendance_data), + "present_days": 0, + "absent_days": 0, + "late_days": 0, + "early_departure_days": 0, + "total_hours": 0.0, + "from_date": from_date, + "to_date": to_date + } + + for entry in attendance_data: + if entry.get("Status") == "Present": + summary["present_days"] += 1 + elif entry.get("Status") == "Absent": + summary["absent_days"] += 1 + + if entry.get("Late_Hours"): + summary["late_days"] += 1 + + if entry.get("Early_Hours"): + summary["early_departure_days"] += 1 + + # Add worked hours if available + worked_hours = entry.get("Total_Hours", 0) + if worked_hours: + summary["total_hours"] += float(worked_hours) + + return summary + + return None + + except Exception as e: + log.error(f"Error getting attendance summary: {e}") + return None + + async def get_leave_balance(self, user_id: str) -> Optional[Dict]: + """Get leave balance for current employee""" + try: + response = await self._make_request(user_id, "leave/getleavebalance") + + if response and response.get("success"): + leave_data = response.get("data", []) + + balance = {} + for leave_type in leave_data: + leave_name = leave_type.get("LeaveType_Name", "Unknown") + balance[leave_name] = { + "allocated": leave_type.get("Allocated_Days", 0), + "used": leave_type.get("Used_Days", 0), + "available": leave_type.get("Available_Days", 0), + "pending": leave_type.get("Pending_Days", 0) + } + + return balance + + return None + + except Exception as e: + log.error(f"Error getting leave balance: {e}") + return None + + async def get_recent_leave_requests(self, user_id: str, limit: int = 10) -> List[Dict]: + """Get recent leave requests for current employee""" + try: + response = await self._make_request(user_id, "leave/getleaverequests") + + if response and response.get("success"): + leave_requests = response.get("data", []) + + # Sort by date and limit results + recent_requests = [] + for request in leave_requests[:limit]: + leave_info = { + "leave_id": request.get("Leave_Id"), + "leave_type": request.get("LeaveType_Name"), + "from_date": request.get("From_Date"), + "to_date": request.get("To_Date"), + "days": request.get("Days_Count"), + "status": request.get("Leave_Status"), + "reason": request.get("Reason"), + "applied_date": request.get("Applied_Date"), + "approved_by": request.get("Approved_By") + } + recent_requests.append(leave_info) + + return recent_requests + + return [] + + except Exception as e: + log.error(f"Error getting recent leave requests: {e}") + return [] + + async def get_employee_context(self, user_id: str) -> Dict[str, Any]: + """Get comprehensive employee context for personalized responses""" + try: + # Gather all relevant employee data + profile = await self.get_employee_profile(user_id) + team_members = await self.get_team_members(user_id) + attendance = await self.get_attendance_summary(user_id) + leave_balance = await self.get_leave_balance(user_id) + recent_leaves = await self.get_recent_leave_requests(user_id, 5) + + context = { + "profile": profile, + "team": { + "members": team_members, + "count": len(team_members) + }, + "attendance": attendance, + "leave": { + "balance": leave_balance, + "recent_requests": recent_leaves + }, + "timestamp": datetime.now(timezone.utc).isoformat() + } + + return context + + except Exception as e: + log.error(f"Error getting employee context: {e}") + return {} + + +# Global instance +zoho_people_api = ZohoPeopleAPI() \ No newline at end of file diff --git a/openwebui/backend/open_webui/utils/oauth_config_loader.py b/openwebui/backend/open_webui/utils/oauth_config_loader.py new file mode 100644 index 0000000000..435ae054b0 --- /dev/null +++ b/openwebui/backend/open_webui/utils/oauth_config_loader.py @@ -0,0 +1,114 @@ +""" +OAuth Configuration Loader +Handles loading OAuth settings from external JSON configuration file +""" + +import json +import os +from typing import Dict, List, Optional +from pathlib import Path + +from open_webui.config import DATA_DIR + +# Default path for OAuth configuration file +DEFAULT_OAUTH_CONFIG_PATH = Path(__file__).parent.parent.parent.parent / "oauth_config.json" +OAUTH_CONFIG_PATH = os.getenv("OAUTH_CONFIG_PATH", DEFAULT_OAUTH_CONFIG_PATH) + + +class OAuthConfigLoader: + """Loads and manages OAuth configuration from external JSON file""" + + def __init__(self, config_path: str = None): + self.config_path = config_path or OAUTH_CONFIG_PATH + self._config = None + self.load_config() + + def load_config(self) -> Dict: + """Load OAuth configuration from JSON file""" + try: + with open(self.config_path, 'r') as f: + self._config = json.load(f) + print(f"[OAUTH CONFIG] Loaded configuration from {self.config_path}") + return self._config + except FileNotFoundError: + print(f"[OAUTH CONFIG] Configuration file not found at {self.config_path}") + print(f"[OAUTH CONFIG] Using default hardcoded configuration") + self._config = self._get_default_config() + return self._config + except json.JSONDecodeError as e: + print(f"[OAUTH CONFIG] Invalid JSON in configuration file: {e}") + print(f"[OAUTH CONFIG] Using default hardcoded configuration") + self._config = self._get_default_config() + return self._config + + def _get_default_config(self) -> Dict: + """Return default OAuth configuration as fallback""" + return { + "zoho": { + "region": "eu", + "scopes": [ + "ZohoPeople.employee.ALL", + "ZohoPeople.forms.READ", + "ZohoPeople.attendance.ALL", + "ZohoPeople.leave.READ", + "ZohoPeople.timetracker.ALL", + "AaaServer.profile.READ", + "email" + ], + "regions": { + "eu": { + "auth_url": "https://accounts.zoho.eu/oauth/v2/auth", + "token_url": "https://accounts.zoho.eu/oauth/v2/token", + "revoke_url": "https://accounts.zoho.eu/oauth/v2/token/revoke", + "userinfo_url": "https://accounts.zoho.eu/oauth/user/info", + "api_base_url": "https://people.zoho.eu" + } + } + } + } + + def get_zoho_config(self) -> Dict: + """Get Zoho-specific configuration""" + if not self._config: + self.load_config() + + zoho_config = self._config.get("zoho", {}) + region = zoho_config.get("region", "eu") + region_config = zoho_config.get("regions", {}).get(region, {}) + + return { + "region": region, + "scopes": zoho_config.get("scopes", []), + "urls": region_config + } + + def get_zoho_scopes(self) -> List[str]: + """Get Zoho OAuth scopes""" + return self.get_zoho_config().get("scopes", []) + + def get_zoho_region(self) -> str: + """Get Zoho region""" + return self.get_zoho_config().get("region", "eu") + + def get_zoho_urls(self) -> Dict[str, str]: + """Get Zoho URLs for current region""" + return self.get_zoho_config().get("urls", {}) + + def get_service_scopes(self, service: str) -> List[str]: + """Get scopes for any service""" + if not self._config: + self.load_config() + + return self._config.get(service, {}).get("scopes", []) + + def reload_config(self) -> Dict: + """Reload configuration from file""" + return self.load_config() + + def get_config_path(self) -> str: + """Get current configuration file path""" + return str(self.config_path) + + +# Global instance +oauth_config = OAuthConfigLoader() \ No newline at end of file diff --git a/openwebui/backend/open_webui/utils/oauth_services.py b/openwebui/backend/open_webui/utils/oauth_services.py new file mode 100755 index 0000000000..1d25b9c246 --- /dev/null +++ b/openwebui/backend/open_webui/utils/oauth_services.py @@ -0,0 +1,200 @@ +""" +OAuth Service Configuration Module +Handles OAuth 2.0 configuration for external services integration +""" + +import os +from typing import Dict, Optional, List +from enum import Enum + + +class ServiceType(str, Enum): + GOOGLE_DRIVE = "google_drive" + ZOHO = "zoho" + JIRA = "jira" + HUBSPOT = "hubspot" + + +class OAuthConfig: + """Base OAuth configuration for a service""" + + def __init__( + self, + service_id: str, + name: str, + client_id: str, + client_secret: str, + auth_url: str, + token_url: str, + scopes: List[str], + revoke_url: Optional[str] = None, + userinfo_url: Optional[str] = None, + api_base_url: Optional[str] = None + ): + self.service_id = service_id + self.name = name + self.client_id = client_id + self.client_secret = client_secret + self.auth_url = auth_url + self.token_url = token_url + self.scopes = scopes + self.revoke_url = revoke_url + self.userinfo_url = userinfo_url + self.api_base_url = api_base_url + + def is_configured(self) -> bool: + """Check if the service has required credentials configured""" + return bool(self.client_id and self.client_secret) + + def get_scope_string(self) -> str: + """Get scope string for OAuth request (space-separated for most services)""" + return " ".join(self.scopes) + + def get_scope_string_comma_separated(self) -> str: + """Get comma-separated scope string for services that require it (like Zoho)""" + return ",".join(self.scopes) + + +# Google Drive OAuth Configuration - Can also use external config +def _create_google_drive_config(): + """Create Google Drive configuration, with optional external config support""" + from open_webui.utils.oauth_config_loader import oauth_config + + # Try to get scopes from external config, fallback to hardcoded + try: + scopes = oauth_config.get_service_scopes("google_drive") + if not scopes: # If empty or not found, use defaults + raise KeyError("No scopes found") + except: + scopes = [ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ] + + return OAuthConfig( + service_id=ServiceType.GOOGLE_DRIVE, + name="Google Drive", + client_id=os.getenv("GOOGLE_DRIVE_CLIENT_ID", ""), + client_secret=os.getenv("GOOGLE_DRIVE_CLIENT_SECRET", ""), + auth_url="https://accounts.google.com/o/oauth2/v2/auth", + token_url="https://oauth2.googleapis.com/token", + revoke_url="https://oauth2.googleapis.com/revoke", + userinfo_url="https://www.googleapis.com/oauth2/v2/userinfo", + api_base_url="https://www.googleapis.com/drive/v3", + scopes=scopes + ) + +GOOGLE_DRIVE_CONFIG = _create_google_drive_config() + +# Zoho OAuth Configuration - Dynamic based on external config +def _create_zoho_config(): + """Create Zoho configuration from external config file""" + from open_webui.utils.oauth_config_loader import oauth_config + + zoho_config = oauth_config.get_zoho_config() + urls = zoho_config.get("urls", {}) + + return OAuthConfig( + service_id=ServiceType.ZOHO, + name="Zoho", + client_id=os.getenv("ZOHO_CLIENT_ID", ""), + client_secret=os.getenv("ZOHO_CLIENT_SECRET", ""), + auth_url=urls.get("auth_url", "https://accounts.zoho.com/oauth/v2/auth"), + token_url=urls.get("token_url", "https://accounts.zoho.com/oauth/v2/token"), + revoke_url=urls.get("revoke_url", "https://accounts.zoho.com/oauth/v2/token/revoke"), + userinfo_url=urls.get("userinfo_url", "https://accounts.zoho.com/oauth/user/info"), + api_base_url=urls.get("api_base_url", "https://people.zoho.com"), + scopes=zoho_config.get("scopes", [ + "ZohoPeople.employee.ALL", + "ZohoPeople.forms.READ", + "ZohoPeople.attendance.ALL", + "ZohoPeople.leave.READ", + "ZohoPeople.timetracker.ALL", + "AaaServer.profile.READ", + "email" + ]) + ) + +ZOHO_CONFIG = _create_zoho_config() + +# Jira OAuth Configuration +JIRA_CONFIG = OAuthConfig( + service_id=ServiceType.JIRA, + name="Jira", + client_id=os.getenv("JIRA_CLIENT_ID", ""), + client_secret=os.getenv("JIRA_CLIENT_SECRET", ""), + auth_url="https://auth.atlassian.com/authorize", + token_url="https://auth.atlassian.com/oauth/token", + userinfo_url="https://api.atlassian.com/me", + api_base_url="https://api.atlassian.com", + scopes=[ + "read:jira-work", + "read:jira-user", + "offline_access", # For refresh tokens + "read:me" # User info + ] +) + +# HubSpot OAuth Configuration +HUBSPOT_CONFIG = OAuthConfig( + service_id=ServiceType.HUBSPOT, + name="HubSpot", + client_id=os.getenv("HUBSPOT_CLIENT_ID", ""), + client_secret=os.getenv("HUBSPOT_CLIENT_SECRET", ""), + auth_url="https://app.hubspot.com/oauth/authorize", + token_url="https://api.hubapi.com/oauth/v1/token", + api_base_url="https://api.hubapi.com", + scopes=[ + "contacts", + "content", + "forms", + "tickets", + "crm.objects.contacts.read", + "crm.objects.companies.read", + "crm.objects.deals.read" + ] +) + +# Service configuration registry +SERVICE_CONFIGS: Dict[str, OAuthConfig] = { + ServiceType.GOOGLE_DRIVE: GOOGLE_DRIVE_CONFIG, + ServiceType.ZOHO: ZOHO_CONFIG, + ServiceType.JIRA: JIRA_CONFIG, + ServiceType.HUBSPOT: HUBSPOT_CONFIG +} + + +def reload_oauth_configs(): + """Reload OAuth configurations from external config file""" + global GOOGLE_DRIVE_CONFIG, ZOHO_CONFIG, SERVICE_CONFIGS + + # Reload the external config + from open_webui.utils.oauth_config_loader import oauth_config + oauth_config.reload_config() + + # Recreate configurations + GOOGLE_DRIVE_CONFIG = _create_google_drive_config() + ZOHO_CONFIG = _create_zoho_config() + + # Update registry + SERVICE_CONFIGS[ServiceType.GOOGLE_DRIVE] = GOOGLE_DRIVE_CONFIG + SERVICE_CONFIGS[ServiceType.ZOHO] = ZOHO_CONFIG + + print("[OAUTH SERVICES] Configurations reloaded from external config") + + +def get_service_config(service_id: str) -> Optional[OAuthConfig]: + """Get OAuth configuration for a specific service""" + return SERVICE_CONFIGS.get(service_id) + + +def get_configured_services() -> List[str]: + """Get list of services that have credentials configured""" + return [ + service_id + for service_id, config in SERVICE_CONFIGS.items() + if config.is_configured() + ] \ No newline at end of file diff --git a/openwebui/backend/start-with-oauth.sh b/openwebui/backend/start-with-oauth.sh new file mode 100755 index 0000000000..b869c037bc --- /dev/null +++ b/openwebui/backend/start-with-oauth.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +cd "$SCRIPT_DIR" || exit + +# Activate virtual environment +source "../.venv/bin/activate" + +# Load environment variables from parent directory .env file +if [ -f "../.env" ]; then + echo "Loading environment variables from .env file..." + set -a # automatically export all variables + source "../.env" + set +a # stop automatically exporting + echo "✓ Google OAuth Client ID: ${GOOGLE_CLIENT_ID:0:30}..." +fi + +# Add conditional Playwright browser installation +if [[ "${WEB_LOADER_ENGINE,,}" == "playwright" ]]; then + if [[ -z "${PLAYWRIGHT_WS_URL}" ]]; then + echo "Installing Playwright browsers..." + playwright install chromium + playwright install-deps chromium + fi + + python -c "import nltk; nltk.download('punkt_tab')" +fi + +if [ -n "${WEBUI_SECRET_KEY_FILE}" ]; then + KEY_FILE="${WEBUI_SECRET_KEY_FILE}" +else + KEY_FILE=".webui_secret_key" +fi + +PORT="${PORT:-8000}" +HOST="${HOST:-0.0.0.0}" +if test "$WEBUI_SECRET_KEY $WEBUI_JWT_SECRET_KEY" = " "; then + echo "Loading WEBUI_SECRET_KEY from file, not provided as an environment variable." + + if ! [ -e "$KEY_FILE" ]; then + echo "Generating WEBUI_SECRET_KEY" + # Generate a random value to use as a WEBUI_SECRET_KEY in case the user didn't provide one. + echo $(head -c 12 /dev/random | base64) > "$KEY_FILE" + fi + + echo "Loading WEBUI_SECRET_KEY from $KEY_FILE" + WEBUI_SECRET_KEY=$(cat "$KEY_FILE") +fi + +if [[ "${USE_OLLAMA_DOCKER,,}" == "true" ]]; then + echo "USE_OLLAMA is set to true, starting ollama serve." + ollama serve & +fi + +if [[ "${USE_CUDA_DOCKER,,}" == "true" ]]; then + echo "CUDA is enabled, appending LD_LIBRARY_PATH to include torch/cudnn & cublas libraries." + export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/local/lib/python3.11/site-packages/torch/lib:/usr/local/lib/python3.11/site-packages/nvidia/cudnn/lib" +fi + +# Check if SPACE_ID is set, if so, configure for space +if [ -n "$SPACE_ID" ]; then + echo "Configuring for HuggingFace Space deployment" + if [ -n "$ADMIN_USER_EMAIL" ] && [ -n "$ADMIN_USER_PASSWORD" ]; then + echo "Admin user configured, creating" + WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' & + webui_pid=$! + echo "Waiting for webui to start..." + while ! curl -s "http://localhost:${PORT}/health" > /dev/null; do + sleep 1 + done + echo "Creating admin user..." + curl \ + -X POST "http://localhost:${PORT}/api/v1/auths/signup" \ + -H "accept: application/json" \ + -H "Content-Type: application/json" \ + -d "{ \"email\": \"${ADMIN_USER_EMAIL}\", \"password\": \"${ADMIN_USER_PASSWORD}\", \"name\": \"Admin\" }" + echo "Shutting down webui..." + kill $webui_pid + fi + + export WEBUI_URL=${SPACE_HOST} +fi + +PYTHON_CMD=$(command -v python3 || command -v python) + +WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec "$PYTHON_CMD" -m uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' --workers "${UVICORN_WORKERS:-1}" diff --git a/openwebui/corporate_config.json b/openwebui/corporate_config.json new file mode 100755 index 0000000000..534e88e6fd --- /dev/null +++ b/openwebui/corporate_config.json @@ -0,0 +1,25 @@ +{ + "company_name": "CloudGeometry", + "google_workspace": { + "domain": "cloudgeometry.com", + "admin_email": "admin@cloudgeometry.com", + "service_account_key_file": "/app/secrets/google-service-account.json", + "customer_id": "", + "api_scopes": [ + "https://www.googleapis.com/auth/admin.directory.user.readonly", + "https://www.googleapis.com/auth/admin.directory.group.readonly" + ], + "cache_duration_minutes": 30 + }, + "require_workspace_verification": false, + "auto_approve_verified_users": true, + "default_role_for_verified_users": "user", + "oauth_client_id": "", + "oauth_client_secret": "", + "group_to_role_mapping": { + "admin@cloudgeometry.com": "admin", + "users@cloudgeometry.com": "user", + "engineering@cloudgeometry.com": "user", + "management@cloudgeometry.com": "admin" + } +} \ No newline at end of file diff --git a/openwebui/corporate_config_template.json b/openwebui/corporate_config_template.json new file mode 100755 index 0000000000..edc831c7fd --- /dev/null +++ b/openwebui/corporate_config_template.json @@ -0,0 +1,25 @@ +{ + "company_name": "COMPANY_NAME", + "google_workspace": { + "domain": "company.com", + "admin_email": "admin@company.com", + "service_account_key_file": "/app/secrets/company-service-account.json", + "customer_id": "", + "api_scopes": [ + "https://www.googleapis.com/auth/admin.directory.user.readonly", + "https://www.googleapis.com/auth/admin.directory.group.readonly" + ], + "cache_duration_minutes": 30 + }, + "require_workspace_verification": true, + "auto_approve_verified_users": true, + "default_role_for_verified_users": "user", + "oauth_client_id": "YOUR_GOOGLE_OAUTH_CLIENT_ID", + "oauth_client_secret": "YOUR_GOOGLE_OAUTH_CLIENT_SECRET", + "group_to_role_mapping": { + "admin@company.com": "admin", + "users@company.com": "user", + "managers@company.com": "user", + "engineering@company.com": "user" + } +} \ No newline at end of file diff --git a/openwebui/oauth_config.json b/openwebui/oauth_config.json new file mode 100644 index 0000000000..010bf11b41 --- /dev/null +++ b/openwebui/oauth_config.json @@ -0,0 +1,74 @@ +{ + "zoho": { + "region": "us", + "scopes": [ + "ZohoPeople.employee.ALL", + "ZohoPeople.forms.READ", + "ZohoPeople.attendance.ALL", + "ZohoPeople.leave.READ", + "ZohoPeople.timetracker.ALL", + "AaaServer.profile.READ", + "email" + ], + "regions": { + "us": { + "auth_url": "https://accounts.zoho.com/oauth/v2/auth", + "token_url": "https://accounts.zoho.com/oauth/v2/token", + "revoke_url": "https://accounts.zoho.com/oauth/v2/token/revoke", + "userinfo_url": "https://accounts.zoho.com/oauth/user/info", + "api_base_url": "https://people.zoho.com" + }, + "eu": { + "auth_url": "https://accounts.zoho.eu/oauth/v2/auth", + "token_url": "https://accounts.zoho.eu/oauth/v2/token", + "revoke_url": "https://accounts.zoho.eu/oauth/v2/token/revoke", + "userinfo_url": "https://accounts.zoho.eu/oauth/user/info", + "api_base_url": "https://people.zoho.eu" + }, + "in": { + "auth_url": "https://accounts.zoho.in/oauth/v2/auth", + "token_url": "https://accounts.zoho.in/oauth/v2/token", + "revoke_url": "https://accounts.zoho.in/oauth/v2/token/revoke", + "userinfo_url": "https://accounts.zoho.in/oauth/user/info", + "api_base_url": "https://people.zoho.in" + }, + "au": { + "auth_url": "https://accounts.zoho.com.au/oauth/v2/auth", + "token_url": "https://accounts.zoho.com.au/oauth/v2/token", + "revoke_url": "https://accounts.zoho.com.au/oauth/v2/token/revoke", + "userinfo_url": "https://accounts.zoho.com.au/oauth/user/info", + "api_base_url": "https://people.zoho.com.au" + }, + "jp": { + "auth_url": "https://accounts.zoho.jp/oauth/v2/auth", + "token_url": "https://accounts.zoho.jp/oauth/v2/token", + "revoke_url": "https://accounts.zoho.jp/oauth/v2/token/revoke", + "userinfo_url": "https://accounts.zoho.jp/oauth/user/info", + "api_base_url": "https://people.zoho.jp" + } + } + }, + "google_drive": { + "scopes": [ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile" + ] + }, + "jira": { + "scopes": [ + "read:jira-user", + "read:jira-work", + "write:jira-work" + ] + }, + "hubspot": { + "scopes": [ + "crm.objects.contacts.read", + "crm.objects.companies.read", + "crm.objects.deals.read" + ] + } +} diff --git a/openwebui/setup_openwebui.sh b/openwebui/setup_openwebui.sh new file mode 100755 index 0000000000..8946813e3e --- /dev/null +++ b/openwebui/setup_openwebui.sh @@ -0,0 +1,116 @@ +#!/bin/bash + +# Setup script for Open-WebUI with OpenAI API + +echo "================================================" +echo "Open-WebUI Local Installation Script" +echo "================================================" +echo "" + +# Check Python version +PYTHON_VERSION=$(python3 --version 2>&1 | grep -oP '\d+\.\d+' | head -1) +REQUIRED_VERSION="3.11" + +if command -v python3.12 &> /dev/null; then + PYTHON_CMD="python3.12" + echo "✓ Found Python 3.12" +elif command -v python3.11 &> /dev/null; then + PYTHON_CMD="python3.11" + echo "✓ Found Python 3.11" +else + echo "❌ Error: Python 3.11 or higher is required" + echo "Please install Python 3.11+ first:" + echo " sudo apt update && sudo apt install python3.11 python3.11-venv" + exit 1 +fi + +# Create virtual environment +echo "" +echo "Step 1: Creating Python virtual environment..." +if [ -d ".venv" ]; then + echo "Virtual environment already exists. Removing old one..." + rm -rf .venv +fi +$PYTHON_CMD -m venv .venv + +# Activate virtual environment +echo "Step 2: Activating virtual environment..." +source .venv/bin/activate + +# Upgrade pip +echo "Step 3: Upgrading pip..." +pip install --upgrade pip setuptools wheel + +# Install Open-WebUI +echo "" +echo "Step 4: Installing Open-WebUI (this may take 5-10 minutes)..." +echo "Installing core dependencies..." +pip install open-webui + +# Create .env file for OpenAI API key +echo "" +echo "Step 5: Setting up configuration..." +if [ -f ".env" ]; then + echo "Backing up existing .env file to .env.backup" + cp .env .env.backup +fi + +# Prompt for OpenAI API key +read -p "Enter your OpenAI API key (or press Enter to set it later): " OPENAI_KEY + +if [ ! -z "$OPENAI_KEY" ]; then + echo "OPENAI_API_KEY=$OPENAI_KEY" > .env + echo "✓ OpenAI API key saved to .env file" +else + echo "# Add your OpenAI API key here:" > .env + echo "OPENAI_API_KEY=your_api_key_here" >> .env + echo "⚠️ Remember to add your OpenAI API key to the .env file" +fi + +# Add other common environment variables +echo "" >> .env +echo "# Server Configuration" >> .env +echo "HOST=0.0.0.0" >> .env +echo "PORT=8000" >> .env +echo "" >> .env +echo "# Optional: Set data directory" >> .env +echo "DATA_DIR=./data" >> .env + +# Create start script +echo "" +echo "Step 6: Creating start script..." +cat > start_openwebui.sh << 'EOF' +#!/bin/bash +echo "Starting Open-WebUI server..." +source .venv/bin/activate + +# Load environment variables +if [ -f .env ]; then + export $(cat .env | grep -v '^#' | xargs) +fi + +# Start the server +open-webui serve --host ${HOST:-0.0.0.0} --port ${PORT:-8000} +EOF + +chmod +x start_openwebui.sh + +echo "" +echo "================================================" +echo "✓ Installation Complete!" +echo "================================================" +echo "" +echo "To start Open-WebUI:" +echo " 1. Make sure your OpenAI API key is set in .env file" +echo " 2. Run: ./start_openwebui.sh" +echo "" +echo "The server will be available at:" +echo " - http://localhost:8000" +echo " - http://0.0.0.0:8000 (accessible from other devices on your network)" +echo "" +echo "To manually start the server:" +echo " source .venv/bin/activate" +echo " open-webui serve" +echo "" +echo "For more configuration options, check the .env file" +echo "================================================" \ No newline at end of file diff --git a/openwebui/src/routes/(app)/intrasearch/+page.svelte b/openwebui/src/routes/(app)/intrasearch/+page.svelte new file mode 100755 index 0000000000..4252020259 --- /dev/null +++ b/openwebui/src/routes/(app)/intrasearch/+page.svelte @@ -0,0 +1,23 @@ + + + + IntraSearch - Enterprise Internal Search + + +{#if sessionId} + +{/if} \ No newline at end of file diff --git a/openwebui/start-openai-only.sh b/openwebui/start-openai-only.sh new file mode 100755 index 0000000000..2c88894ca7 --- /dev/null +++ b/openwebui/start-openai-only.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# ActionBridge - OpenAI Only Startup Script + +echo "🚀 Starting ActionBridge with OpenAI-only configuration..." + +# Check if .env file exists +if [ ! -f .env ]; then + echo "📋 Creating .env file from .env.example..." + cp .env.example .env + echo "⚠️ Please edit .env file with your OpenAI API key and Google OAuth credentials!" + echo "" + echo "Required variables to set in .env:" + echo " - OPENAI_API_KEY=sk-your-openai-key" + echo " - GOOGLE_CLIENT_ID=your-google-client-id" + echo " - GOOGLE_CLIENT_SECRET=your-google-client-secret" + echo " - WEBUI_SECRET_KEY=your-random-secret-key" + echo "" + read -p "Press Enter after updating .env file..." +fi + +# Check if OpenAI API key is set +if ! grep -q "sk-" .env; then + echo "❌ OpenAI API key not found in .env file!" + echo "Please set OPENAI_API_KEY=sk-your-actual-key" + exit 1 +fi + +# Create secrets directory +mkdir -p secrets + +# Check if Google service account exists +if [ ! -f secrets/google-service-account.json ]; then + echo "⚠️ Google service account key not found at secrets/google-service-account.json" + echo "This is needed for Google Workspace employee verification." + echo "You can continue without it, but only manual user approval will work." + read -p "Continue anyway? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Please add your Google service account JSON file to secrets/google-service-account.json" + exit 1 + fi +fi + +# Make sure corporate config exists +if [ ! -f corporate_config.json ]; then + echo "📋 Creating corporate_config.json..." + # corporate_config.json should already exist from our setup +fi + +echo "" +echo "🐳 Starting ActionBridge with Docker Compose..." +echo "" + +# Start with docker-compose +docker-compose up -d + +echo "" +echo "✅ ActionBridge is starting!" +echo "" +echo "📍 Access the application:" +echo " Web UI: http://localhost:3000" +echo " API: http://localhost:3000/api/v1" +echo "" +echo "🔐 Authentication:" +echo " - Go to http://localhost:3000/auth" +echo " - Click 'Sign in with Google'" +echo " - Use your @actionbridge.com email" +echo "" +echo "📋 To view logs: docker-compose logs -f actionbridge" +echo "🛑 To stop: docker-compose down" +echo "" + +# Show logs +echo "📋 Live logs (press Ctrl+C to exit):" +docker-compose logs -f actionbridge \ No newline at end of file diff --git a/openwebui/start-with-oauth.sh b/openwebui/start-with-oauth.sh new file mode 100755 index 0000000000..a04a59d40b --- /dev/null +++ b/openwebui/start-with-oauth.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Navigate to the OpenWebUI directory +cd /home/eugene/proj/CG_OpenChatUI/open-webui + +echo "🚀 Starting OpenWebUI with Google OAuth..." +echo "======================================" + +# Activate virtual environment +source .venv/bin/activate + +# Load environment variables (safer method for JSON values) +set -a && source .env && set +a + +echo "✓ Google OAuth Client ID: ${GOOGLE_CLIENT_ID:0:30}..." +echo "" + +echo "Starting Backend (API Server)..." +echo "Backend will be available at: http://localhost:8000" +echo "API documentation at: http://localhost:8000/docs" +echo "" + +# Start the backend +cd backend && ./start-with-oauth.sh \ No newline at end of file diff --git a/openwebui/vite.config.ts b/openwebui/vite.config.ts index df802c081c..ef46510c3e 100644 --- a/openwebui/vite.config.ts +++ b/openwebui/vite.config.ts @@ -28,5 +28,26 @@ export default defineConfig({ }, esbuild: { pure: process.env.ENV === 'dev' ? [] : ['console.log', 'console.debug', 'console.error'] + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8002', + changeOrigin: true + }, + '/oauth': { + target: 'http://localhost:8002', + changeOrigin: true + }, + '/ollama': { + target: 'http://localhost:8002', + changeOrigin: true + }, + '/socket.io': { + target: 'http://localhost:8002', + changeOrigin: true, + ws: true + } + } } });