diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..98ab53d --- /dev/null +++ b/.env.example @@ -0,0 +1,86 @@ +# FreeQwenApi — справочник переменных окружения. +# +# Переменные читаются напрямую из окружения процесса (process.env). +# Автозагрузка файла .env в Node-приложении не настроена — задавайте значения +# одним из способов: +# • локально (bash): export PORT=3264 && npm start +# • локально (PowerShell): $env:PORT=3264; npm start +# • docker-compose: блок environment: в docker-compose.yml +# • docker run: docker run -e PORT=3264 ... +# +# Все переменные опциональны — ниже указаны значения по умолчанию. +# Файл можно скопировать в .env как личную шпаргалку (он в .gitignore). + +# ─── Сервер ────────────────────────────────────────────────────────────────── +PORT=3264 +HOST=0.0.0.0 +DEFAULT_MODEL=qwen3.7-max +# Разрешить восстановление чата из сессии без привязки к аккаунту (1/true/yes/on) +ALLOW_UNSCOPED_SESSION_CHAT_RESTORE=false + +# ─── Запуск / меню аккаунтов ───────────────────────────────────────────────── +# Пропустить интерактивное меню выбора аккаунта при старте (нужно для headless/CI/Docker) +SKIP_ACCOUNT_MENU=false +# Полностью неинтерактивный режим (не запрашивать ввод в консоли) +NON_INTERACTIVE=false + +# ─── Лимиты ────────────────────────────────────────────────────────────────── +# Фолбэк-длительность блокировки токена по rate-limit (часы), когда Qwen не +# прислал точное значение. Реализуется отдельным улучшением (feat/qwen-ratelimit-env). +QWEN_RATELIMIT_HOURS=24 +MAX_RETRY_COUNT=3 +MAX_HISTORY_LENGTH=100 +MAX_FILE_SIZE=10485760 +PAGE_POOL_SIZE=3 +TASK_POLL_MAX_ATTEMPTS=90 +TASK_POLL_INTERVAL=2000 + +# ─── Таймауты (мс) ─────────────────────────────────────────────────────────── +PAGE_TIMEOUT=120000 +AUTH_TIMEOUT=120000 +NAVIGATION_TIMEOUT=60000 +RETRY_DELAY=2000 +STREAMING_CHUNK_DELAY=20 + +# ─── Пути (относительно корня проекта) ─────────────────────────────────────── +SESSION_DIR=session +UPLOADS_DIR=uploads +LOGS_DIR=logs + +# ─── Браузер ───────────────────────────────────────────────────────────────── +# Путь к исполняемому файлу Chrome/Chromium. В Docker задаётся = /usr/bin/chromium. +CHROME_PATH= +VIEWPORT_WIDTH=1920 +VIEWPORT_HEIGHT=1080 +USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 + +# ─── Логирование ───────────────────────────────────────────────────────────── +LOG_LEVEL=info +LOG_MAX_SIZE=5242880 +LOG_MAX_FILES=5 + +# ─── Генерация изображений/видео (опционально) ─────────────────────────────── +# Прямой ключ DashScope для image/video API. Не обязателен: есть browser-based +# генерация через Qwen Chat. Оставьте пустым, если не используете DashScope. +DASHSCOPE_API_KEY= + +# ─── Адреса Qwen (продвинутое — обычно менять не нужно) ─────────────────────── +# Остальные URL по умолчанию выводятся из QWEN_BASE_URL. +QWEN_BASE_URL=https://chat.qwen.ai +# CHAT_API_URL= +# CREATE_CHAT_URL= +# CHAT_PAGE_URL= +# TASK_STATUS_URL= +# STS_TOKEN_API_URL= +# AUTH_SIGNIN_URL= +# OSS_SDK_URL=https://gosspublic.alicdn.com/aliyun-oss-sdk-6.20.0.min.js + +# ─── Скрипты обслуживания (опционально) ────────────────────────────────────── +# scripts/sync_models.js — синхронизация списка моделей +# QWEN_CHAT_URL= +# QWEN_MODELS_FILE= +# QWEN_MODELS_DOC= +# scripts/smoke_test.js — дымовой тест API +# QWEN_PROXY_BASE_URL=http://localhost:3264 +# QWEN_PROXY_SMOKE_MODEL= +# QWEN_PROXY_API_KEY= diff --git a/README.md b/README.md index 08a2d1d..b2f3a6b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ -# FreeQwenApi — ForgetMeAI fork +# FreeQwenApi -> **Локальный OpenAI-compatible прокси к Qwen Chat** от [t.me/forgetmeai](https://t.me/forgetmeai). +> **Локальный OpenAI-compatible прокси к Qwen Chat** > Текст, модели Qwen 3.7, файлы, Open WebUI, Hermes/LiteLLM, а теперь ещё генерация изображений и видео через Qwen Chat. -![ForgetMeAI](https://img.shields.io/badge/ForgetMeAI-t.me%2Fforgetmeai-blue) ![API](https://img.shields.io/badge/API-OpenAI--compatible-green) ![Qwen](https://img.shields.io/badge/Qwen-Chat-purple) @@ -53,6 +52,19 @@ npm run smoke http://localhost:3264/api ``` +## Конфигурация + +Все настройки задаются переменными окружения. Полный список с дефолтами и комментариями — в [`.env.example`](.env.example): порт, модель по умолчанию, таймауты, лимиты, пути, логирование, путь к Chrome и т.д. + +Переменные читаются из окружения процесса. Задайте нужные удобным способом: + +```bash +export PORT=3264 DEFAULT_MODEL=qwen3.7-max # bash +$env:PORT=3264; npm start # PowerShell +``` + +либо через блок `environment:` в `docker-compose.yml` / флаги `-e` у `docker run`. + ## Авторизация Qwen Chat Добавить аккаунт: @@ -364,8 +376,3 @@ curl http://localhost:3264/api/videos/status - URL сгенерированных медиа могут быть временными. - Для production используйте осторожно: это инструмент для экспериментов, демо и локальных workflow. -## От ForgetMeAI - -Если fork помог — подпишитесь: [t.me/forgetmeai](https://t.me/forgetmeai) - -Там практичные AI-инструменты, локальные агенты, open-source находки и честные тесты без корпоративной лапши. diff --git a/index.js b/index.js index c5d1270..facbb4a 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,7 @@ import express from 'express'; import bodyParser from 'body-parser'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { initBrowser, shutdownBrowser } from './src/browser/browser.js'; import apiRoutes from './src/api/routes.js'; @@ -12,6 +14,7 @@ import { FORGETMEAI_WATERMARK } from './src/utils/branding.js'; import { PORT, HOST } from './src/config.js'; const app = express(); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const port = Number.parseInt(process.env.PORT ?? PORT, 10); const host = process.env.HOST || HOST; @@ -68,6 +71,10 @@ app.use((req, res, next) => { next(); }); +app.get(['/', '/dashboard'], (req, res) => { + res.sendFile(path.join(__dirname, 'src', 'dashboard', 'index.html')); +}); + app.use('/api', apiRoutes); app.use((req, res) => { @@ -160,7 +167,11 @@ async function startServer() { ensureNonInteractiveTokens(); } - const browserInitialized = await initBrowser(false); + // Qwen blocks headless automation (CDP evaluate hangs). Allow a headed run + // via QWEN_VISIBLE=1 so the page renders normally and any one-time + // verification can be passed; the session then persists. + const visibleMode = ['1', 'true', 'yes', 'on'].includes(String(process.env.QWEN_VISIBLE || '').toLowerCase()); + const browserInitialized = await initBrowser(visibleMode); if (!browserInitialized) { logError('Не удалось инициализировать браузер. Завершение работы.'); process.exit(1); diff --git a/src/api/chat.js b/src/api/chat.js index 4d19743..5545107 100644 --- a/src/api/chat.js +++ b/src/api/chat.js @@ -12,7 +12,8 @@ import { CHAT_API_URL, CREATE_CHAT_URL, CHAT_PAGE_URL, TASK_STATUS_URL, PAGE_TIMEOUT, RETRY_DELAY, PAGE_POOL_SIZE, DEFAULT_MODEL, MAX_RETRY_COUNT, - TASK_POLL_MAX_ATTEMPTS, TASK_POLL_INTERVAL + TASK_POLL_MAX_ATTEMPTS, TASK_POLL_INTERVAL, + RATE_LIMIT_HOURS } from '../config.js'; const __filename = fileURLToPath(import.meta.url); @@ -558,11 +559,20 @@ async function executeApiRequest(page, apiUrl, payload, token, onChunk = null) { if (payload?.stream !== false && typeof onChunk === 'function') { const streamedResponse = await executeApiRequestWithNodeStreaming(apiUrl, payload, token, onChunk); - const canReturnDirectly = + // Qwen's Aliyun WAF blocks the browserless node-streaming fetch and returns a + // non-SSE captcha page (errorBody = WAF html). Do NOT return that — fall through + // to the browser (page.evaluate) path, which carries the real session and is not + // challenged. Without this, streaming requests surface the captcha as the answer. + const wafBlockedNodeStream = + streamedResponse.success !== true && + /Unexpected non-SSE 200/i.test(String(streamedResponse.error || '')); + + const canReturnDirectly = !wafBlockedNodeStream && ( streamedResponse.success || Boolean(streamedResponse.status) || Boolean(streamedResponse.errorBody) || - streamedResponse.hasStreamedChunks === true; + streamedResponse.hasStreamedChunks === true + ); if (canReturnDirectly) { return streamedResponse; @@ -729,17 +739,19 @@ async function handleApiError(response, tokenObj, message, model, chatId, parent } const { hasValidTokens } = await import('./tokenManager.js'); if (hasValidTokens() && retryCount < MAX_RETRY_COUNT) { - return sendMessage(message, model, chatId, parentId, files, null, null, null, chatType, size, waitForCompletion, retryCount + 1, onChunk); + // chatId/parentId сбрасываем: при смене аккаунта старый чат + // принадлежит прежнему токену и под новым «не существует». + return sendMessage(message, model, null, null, files, null, null, null, chatType, size, waitForCompletion, retryCount + 1, onChunk); } logError('Не осталось валидных токенов или исчерпаны попытки.'); return { error: 'Все токены недействительны (401). Требуется повторная авторизация.', chatId }; } if (response.status === 429 || (response.errorBody && response.errorBody.includes('RateLimited'))) { - let hours = 24; + let hours = RATE_LIMIT_HOURS; try { const rateInfo = JSON.parse(response.errorBody); - hours = Number(rateInfo.num) || 24; + hours = Number(rateInfo.num) || RATE_LIMIT_HOURS; } catch { /* errorBody might not be valid JSON */ } if (tokenObj?.id === 'browser') { @@ -753,7 +765,9 @@ async function handleApiError(response, tokenObj, message, model, chatId, parent authToken = null; const { hasValidTokens } = await import('./tokenManager.js'); if (hasValidTokens() && retryCount < MAX_RETRY_COUNT) { - return sendMessage(message, model, chatId, parentId, files, null, null, null, chatType, size, waitForCompletion, retryCount + 1, onChunk); + // chatId/parentId сбрасываем: при смене аккаунта старый чат + // принадлежит прежнему токену и под новым «не существует». + return sendMessage(message, model, null, null, files, null, null, null, chatType, size, waitForCompletion, retryCount + 1, onChunk); } return { error: `Все токены заблокированы по лимиту (${hours}ч)`, chatId }; } @@ -766,8 +780,17 @@ async function handleApiError(response, tokenObj, message, model, chatId, parent export async function sendMessage(message, model = DEFAULT_MODEL, chatId = null, parentId = null, files = null, tools = null, toolChoice = null, systemMessage = null, chatType = 't2t', size = null, waitForCompletion = true, retryCount = 0, onChunk = null) { if (!availableModels) availableModels = getAvailableModelsFromFile(); + const browserContext = getBrowserContext(); + if (!browserContext) return { error: 'Браузер не инициализирован', chatId }; + + // Резолвим аккаунт ОДИН раз: одним и тем же токеном создаём чат и + // отправляем сообщение — иначе round-robin разнесёт их по разным + // аккаунтам и Qwen вернёт «chat is not exist». + const tokenObj = await resolveAuthToken(browserContext); + if (!tokenObj) return { error: 'Ошибка авторизации: не удалось получить токен', chatId }; + if (!chatId) { - const newChatResult = await createChatV2(model, 'Новый чат', 0, chatType); + const newChatResult = await createChatV2(model, 'Новый чат', 0, chatType, tokenObj); if (newChatResult.error) return { error: 'Не удалось создать чат: ' + newChatResult.error }; chatId = newChatResult.chatId; logInfo(`Создан новый чат v2 с ID: ${chatId}`); @@ -792,12 +815,6 @@ export async function sendMessage(message, model = DEFAULT_MODEL, chatId = null, logInfo(`Тип генерации: ${chatType} (${typeLabels[chatType] || chatType})${size ? `, размер: ${size}` : ''}`); } - const browserContext = getBrowserContext(); - if (!browserContext) return { error: 'Браузер не инициализирован', chatId }; - - const tokenObj = await resolveAuthToken(browserContext); - if (!tokenObj) return { error: 'Ошибка авторизации: не удалось получить токен', chatId }; - let page = null; try { page = await pagePool.getPage(browserContext); @@ -1005,11 +1022,14 @@ export function getAuthToken() { // ─── createChatV2 ──────────────────────────────────────────────────────────── -export async function createChatV2(model = DEFAULT_MODEL, title = 'Новый чат', retryCount = 0, chatType = 't2t') { +export async function createChatV2(model = DEFAULT_MODEL, title = 'Новый чат', retryCount = 0, chatType = 't2t', tokenObj = null) { const browserContext = getBrowserContext(); if (!browserContext) return { error: 'Браузер не инициализирован' }; - const tokenObj = await getAvailableToken(); + // tokenObj может прийти от sendMessage — тогда создание чата и отправка + // идут под ОДНИМ аккаунтом (иначе round-robin создаст чат на одном + // аккаунте, а сообщение уйдёт под другим → «chat is not exist»). + if (!tokenObj) tokenObj = await getAvailableToken(); if (tokenObj?.token) { authToken = tokenObj.token; logInfo(`Используется аккаунт для создания чата: ${tokenObj.id}`); @@ -1054,7 +1074,7 @@ export async function createChatV2(model = DEFAULT_MODEL, title = 'Новый ч if (isTransient && retryCount < MAX_RETRY_COUNT) { logWarn(`Создание чата: ${result.status}, ретрай ${retryCount + 1}/${MAX_RETRY_COUNT} через ${RETRY_DELAY}мс...`); await delay(RETRY_DELAY); - return createChatV2(model, title, retryCount + 1, chatType); + return createChatV2(model, title, retryCount + 1, chatType, tokenObj); } const cleanError = isTransient diff --git a/src/api/chatHistory.js b/src/api/chatHistory.js index a816af3..59bc9d4 100644 --- a/src/api/chatHistory.js +++ b/src/api/chatHistory.js @@ -33,8 +33,33 @@ export function createChat(chatName) { return chatId; } +/** + * Sanitize chatId to prevent path traversal (CWE-22). + * Rejects any value containing path separators, traversal sequences, + * or characters outside the allowed set [a-zA-Z0-9_-]. + * Returns null for invalid values — callers must handle null gracefully. + */ +function sanitizeChatId(chatId) { + if (typeof chatId !== 'string' || !chatId) return null; + // Reject if it contains path separators or traversal sequences + if (chatId.includes('/') || chatId.includes('\\') || chatId.includes('..')) return null; + // Whitelist: only allow alphanumeric, hyphens, and underscores + if (!/^[a-zA-Z0-9_-]+$/.test(chatId)) return null; + return chatId; +} + function getHistoryFilePath(chatId) { - return path.join(HISTORY_DIR, `${chatId}.json`); + const safeChatId = sanitizeChatId(chatId); + if (!safeChatId) { + throw new Error(`Invalid chatId: ${String(chatId).substring(0, 50)}`); + } + const filePath = path.join(HISTORY_DIR, `${safeChatId}.json`); + // Defense-in-depth: verify the resolved path is still inside HISTORY_DIR + const resolved = path.resolve(filePath); + if (!resolved.startsWith(path.resolve(HISTORY_DIR) + path.sep)) { + throw new Error(`Path traversal blocked for chatId: ${String(chatId).substring(0, 50)}`); + } + return filePath; } export function saveHistory(chatId, data) { @@ -120,10 +145,15 @@ export function loadHistory(chatId) { } export function chatExists(chatId) { - const historyFilePath = getHistoryFilePath(chatId); - const exists = fs.existsSync(historyFilePath); - logDebug(`Проверка существования чата ${chatId}: ${exists ? 'найден' : 'не найден'}`); - return exists; + try { + const historyFilePath = getHistoryFilePath(chatId); + const exists = fs.existsSync(historyFilePath); + logDebug(`Проверка существования чата ${chatId}: ${exists ? 'найден' : 'не найден'}`); + return exists; + } catch (error) { + logError(`Invalid chatId in chatExists: ${error.message}`); + return false; + } } export function renameChat(chatId, newName) { @@ -341,4 +371,4 @@ export function deleteChatsAutomatically(criteria = {}) { error: error.message }; } -} \ No newline at end of file +} diff --git a/src/api/routes.js b/src/api/routes.js index 4d01858..5181cd9 100644 --- a/src/api/routes.js +++ b/src/api/routes.js @@ -2,7 +2,7 @@ import express from 'express'; import { sendMessage, getAllModels, getApiKeys, createChatV2, pollQwenTaskStatus, extractMediaUrl, pagePool, extractAuthToken } from './chat.js'; import { getAuthenticationStatus, getBrowserContext } from '../browser/browser.js'; import { checkAuthentication } from '../browser/auth.js'; -import { logInfo, logError, logDebug } from '../logger/index.js'; +import { logInfo, logError, logDebug, logWarn } from '../logger/index.js'; import { getMappedModel } from './modelMapping.js'; import { getStsToken, uploadFileToQwen } from './fileUpload.js'; import { loadHistory, saveHistory } from './chatHistory.js'; @@ -12,7 +12,7 @@ import multer from 'multer'; import path from 'path'; import fs from 'fs'; import crypto from 'crypto'; -import { listTokens, markInvalid, markRateLimited, markValid } from './tokenManager.js'; +import { listTokens, markInvalid, markRateLimited, markValid, addTokenFromString, deleteAccount, decodeTokenInfo, updateAccountToken, setLabel } from './tokenManager.js'; import { FORGETMEAI_WATERMARK } from '../utils/branding.js'; // Функция для генерирования детерминированного chatId на основе истории @@ -593,7 +593,7 @@ function buildOpenAIToolResponse(result, mappedModel, toolCalls) { id: result.id || 'chatcmpl-' + Date.now(), object: 'chat.completion', created: Math.floor(Date.now() / 1000), - model: result.model || mappedModel || 'qwen-max-latest', + model: result.model || mappedModel || DEFAULT_MODEL, choices: [{ index: 0, message: { @@ -616,7 +616,7 @@ function writeToolCallsSse(res, mappedModel, result, toolCalls) { id: result.id || 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: result.model || mappedModel || 'qwen-max-latest' + model: result.model || mappedModel || DEFAULT_MODEL }; res.write('data: ' + JSON.stringify({ ...base, @@ -757,7 +757,7 @@ router.post('/chat', async (req, res) => { logInfo(`История содержит ${allMessages.length} сообщений`); } - let mappedModel = model || "qwen-max-latest"; + let mappedModel = model || DEFAULT_MODEL; if (model) { mappedModel = getMappedModel(model); if (mappedModel !== model) { @@ -789,7 +789,7 @@ router.post('/chat', async (req, res) => { id: 'chatcmpl-' + Date.now(), object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: chunk }, finish_reason: null } ] @@ -818,7 +818,7 @@ router.post('/chat', async (req, res) => { id: 'chatcmpl-' + Date.now(), object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: `Ошибка: ${result.error}` }, finish_reason: 'stop' } ] @@ -834,7 +834,7 @@ router.post('/chat', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content }, finish_reason: null } ] @@ -850,7 +850,7 @@ router.post('/chat', async (req, res) => { id: 'chatcmpl-' + Date.now(), object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: {}, finish_reason: 'stop' } ] @@ -864,7 +864,7 @@ router.post('/chat', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: 'Internal server error' }, finish_reason: 'stop' } ] @@ -954,6 +954,67 @@ router.get('/models', async (req, res) => { } }); +// Прокси скачивания внешних медиа (картинки/видео/файлы Qwen CDN). +// Нужен, т.к. fetch-blob с фронта к CDN блокируется CORS. Строгий whitelist + SSRF-guard. +const DOWNLOAD_HOSTS = ['qwenlm.ai', 'aliyuncs.com', 'alicdn.com', 'aliyun.com']; +// Валидация URL для прокси: только https, без IP-литералов/localhost, hostname в whitelist +// доверенных CDN Qwen/Aliyun (домены не контролируются третьими лицами → DNS-rebinding неприменим). +function validateDownloadUrl(raw) { + let u; + try { u = new URL(String(raw)); } catch { return null; } + if (u.protocol !== 'https:') return null; + const host = u.hostname.toLowerCase(); + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(':') || host === 'localhost') return null; + if (!DOWNLOAD_HOSTS.some(d => host === d || host.endsWith('.' + d))) return null; + return u; +} +router.get('/download', async (req, res) => { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 60_000); + try { + if (!req.query.url) { clearTimeout(timer); return res.status(400).json({ error: 'Параметр url обязателен' }); } + let u = validateDownloadUrl(req.query.url); + if (!u) { clearTimeout(timer); return res.status(403).json({ error: 'URL не разрешён (только https и домены Qwen/Aliyun CDN)' }); } + + // Редиректы не следуем автоматически — каждый hop валидируем заново (защита от SSRF через redirect). + let upstream, hops = 0; + for (;;) { + upstream = await fetch(u.toString(), { redirect: 'manual', signal: ctrl.signal }); + if (upstream.status >= 300 && upstream.status < 400) { + const loc = upstream.headers.get('location'); + let next = null; + if (loc) { try { next = validateDownloadUrl(new URL(loc, u).toString()); } catch { next = null; } } + if (!loc || ++hops > 3) { clearTimeout(timer); return res.status(502).json({ error: 'Недопустимая цепочка редиректов' }); } + if (!next) { clearTimeout(timer); return res.status(403).json({ error: 'Редирект на недопустимый адрес' }); } + u = next; continue; + } + break; + } + if (!upstream.ok || !upstream.body) { clearTimeout(timer); return res.status(502).json({ error: `Источник вернул ${upstream.status}` }); } + + const name = String(req.query.name || u.pathname.split('/').pop() || 'download') + .replace(/[^\w.\-]+/g, '_').slice(0, 120) || 'download'; + res.setHeader('Content-Disposition', `attachment; filename="${name}"`); + const ct = upstream.headers.get('content-type'); if (ct) res.setHeader('Content-Type', ct); + const len = upstream.headers.get('content-length'); if (len) res.setHeader('Content-Length', len); + + const reader = upstream.body.getReader(); + res.on('close', () => { reader.cancel().catch(() => {}); }); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!res.write(Buffer.from(value))) await new Promise(r => res.once('drain', r)); + } + clearTimeout(timer); + res.end(); + } catch (error) { + clearTimeout(timer); + logError('Ошибка проксирования скачивания', error); + if (!res.headersSent) res.status(500).json({ error: 'Внутренняя ошибка сервера' }); + else try { res.end(); } catch { /* noop */ } + } +}); + router.get('/status', async (req, res) => { try { logInfo('Запрос статуса авторизации'); @@ -992,6 +1053,135 @@ router.get('/status', async (req, res) => { } }); +// ─── Управление аккаунтами (для дашборда) ────────────────────────────────── +// Управление аккаунтами чувствительно (токены), поэтому доступно только с +// localhost — защита от доступа из LAN (HOST=0.0.0.0 слушает на всех интерфейсах). +function localOnly(req, res, next) { + const ip = req.socket?.remoteAddress || ''; + if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return next(); + logWarn(`Отклонён не-локальный доступ к /accounts с ${ip}`); + return res.status(403).json({ error: 'Управление аккаунтами доступно только с localhost' }); +} + +// CSRF-защита для мутирующих запросов: ACAO:* разрешает cross-origin вызовы, +// поэтому запрещаем запросы, чей Origin не совпадает с хостом сервера. +function sameOriginOnly(req, res, next) { + const origin = req.get('origin'); + if (origin) { + // Browser extensions (the account-import popup) are user-installed and + // declare an explicit host permission for this server. A web page cannot + // forge a *-extension:// Origin, so these are trusted, not a CSRF vector. + if (/^(chrome-extension|moz-extension|safari-web-extension):\/\//i.test(origin)) return next(); + try { + if (new URL(origin).host !== req.get('host')) { + return res.status(403).json({ error: 'Cross-origin запрос запрещён' }); + } + } catch { + return res.status(403).json({ error: 'Некорректный Origin' }); + } + } + return next(); +} + +router.get('/accounts', localOnly, sameOriginOnly, (req, res) => { + try { + const now = Date.now(); + const accounts = listTokens().map(t => { + const info = decodeTokenInfo(t.token); + let status = 'OK'; + if (t.invalid) status = 'INVALID'; + else if (t.resetAt && new Date(t.resetAt).getTime() > now) status = 'WAIT'; + else if (info.exp && info.exp < now) status = 'EXPIRED'; + return { + id: t.id, + label: t.label || '', + status, + exp: info.exp, + resetAt: t.resetAt || null, + preview: String(t.token).slice(0, 10) + '…' + String(t.token).slice(-4) + }; + }); + res.json({ accounts }); + } catch (error) { + logError('Ошибка при получении списка аккаунтов', error); + res.status(500).json({ error: 'Внутренняя ошибка сервера' }); + } +}); + +router.post('/accounts', localOnly, sameOriginOnly, (req, res) => { + try { + const token = req.body?.token; + if (!token) return res.status(400).json({ error: 'Не передан token' }); + const result = addTokenFromString(token, req.body?.label); + if (result.error) return res.status(400).json(result); + logInfo(`Добавлен аккаунт через дашборд: ${result.id}`); + res.json({ ok: true, id: result.id }); + } catch (error) { + logError('Ошибка при добавлении аккаунта', error); + res.status(500).json({ error: 'Внутренняя ошибка сервера' }); + } +}); + +router.delete('/accounts/:id', localOnly, sameOriginOnly, (req, res) => { + try { + const result = deleteAccount(req.params.id); + if (result.error) return res.status(400).json(result); + logInfo(`Удалён аккаунт через дашборд: ${req.params.id}`); + res.json({ ok: true }); + } catch (error) { + logError('Ошибка при удалении аккаунта', error); + res.status(500).json({ error: 'Внутренняя ошибка сервера' }); + } +}); + +// Проверить валидность одного аккаунта (реальный запрос к Qwen). +// POST, т.к. меняет состояние (mark*); за sameOriginOnly — защита от CSRF. +router.post('/accounts/:id/check', localOnly, sameOriginOnly, async (req, res) => { + try { + const id = req.params.id; + if (!/^acc_[a-zA-Z0-9]+$/.test(id)) return res.status(400).json({ error: 'Некорректный id аккаунта' }); + const t = listTokens().find(x => x.id === id); + if (!t) return res.status(404).json({ error: 'Аккаунт не найден' }); + const info = decodeTokenInfo(t.token); + const r = await testToken(t.token); + let status = 'ERROR'; + if (r === 'OK') { status = 'OK'; if (t.invalid || t.resetAt) markValid(t.id); } + else if (r === 'RATELIMIT') { status = 'WAIT'; markRateLimited(t.id, 24); } + else if (r === 'UNAUTHORIZED') { status = 'INVALID'; if (!t.invalid) markInvalid(t.id); } + const resetAt = status === 'WAIT' ? new Date(Date.now() + 24 * 3600 * 1000).toISOString() : (t.resetAt || null); + res.json({ id, status, exp: info.exp, resetAt }); + } catch (error) { + logError('Ошибка проверки аккаунта', error); + res.status(500).json({ error: 'Внутренняя ошибка сервера' }); + } +}); + +// Обновить токен аккаунта (relogin вручную из дашборда). +router.post('/accounts/:id/update', localOnly, sameOriginOnly, (req, res) => { + try { + const result = updateAccountToken(req.params.id, req.body?.token); + if (result.error) return res.status(400).json(result); + logInfo(`Обновлён токен аккаунта через дашборд: ${req.params.id}`); + res.json(result); + } catch (error) { + logError('Ошибка обновления токена аккаунта', error); + res.status(500).json({ error: 'Внутренняя ошибка сервера' }); + } +}); + +// Установить/изменить человекочитаемый ярлык аккаунта (для различения в пуле). +router.post('/accounts/:id/label', localOnly, sameOriginOnly, (req, res) => { + try { + const result = setLabel(req.params.id, req.body?.label); + if (result.error) return res.status(400).json(result); + logInfo(`Изменён ярлык аккаунта через дашборд: ${req.params.id}`); + res.json(result); + } catch (error) { + logError('Ошибка изменения ярлыка аккаунта', error); + res.status(500).json({ error: 'Внутренняя ошибка сервера' }); + } +}); + router.post('/chats', async (req, res) => { try { const { name, model } = req.body; @@ -1117,7 +1307,7 @@ router.post('/chat/completions', async (req, res) => { logDebug('OpenWebUI meta-запрос: используем отдельный чат (без привязки к сессии)'); } - let mappedModel = model ? getMappedModel(model) : "qwen-max-latest"; + let mappedModel = model ? getMappedModel(model) : DEFAULT_MODEL; if (model && mappedModel !== model) { logInfo(`Модель "${model}" заменена на "${mappedModel}"`); } @@ -1164,7 +1354,7 @@ router.post('/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: chunk }, finish_reason: null } ] @@ -1213,7 +1403,7 @@ router.post('/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: `Ошибка: ${result.error}` }, finish_reason: null } ] @@ -1229,7 +1419,7 @@ router.post('/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content }, finish_reason: null } ] @@ -1244,7 +1434,7 @@ router.post('/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: {}, finish_reason: 'stop' } ] @@ -1258,7 +1448,7 @@ router.post('/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: 'Internal server error' }, finish_reason: 'stop' } ] @@ -1296,7 +1486,7 @@ router.post('/chat/completions', async (req, res) => { id: result.id || "chatcmpl-" + Date.now(), object: "chat.completion", created: Math.floor(Date.now() / 1000), - model: result.model || mappedModel || "qwen-max-latest", + model: result.model || mappedModel || DEFAULT_MODEL, choices: result.choices || [{ index: 0, message: { @@ -1442,7 +1632,7 @@ router.post('/v1/chat/completions', async (req, res) => { logDebug('OpenWebUI meta-запрос: используем отдельный чат (без привязки к сессии)'); } - let mappedModel = model ? getMappedModel(model) : "qwen-max-latest"; + let mappedModel = model ? getMappedModel(model) : DEFAULT_MODEL; if (model && mappedModel !== model) { logInfo(`Модель "${model}" заменена на "${mappedModel}"`); } @@ -1492,7 +1682,7 @@ router.post('/v1/chat/completions', async (req, res) => { id: 'chatcmpl-' + Date.now(), object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: chunk }, finish_reason: null } ] @@ -1536,7 +1726,7 @@ router.post('/v1/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: `Ошибка: ${result.error}` }, finish_reason: 'stop' } ] @@ -1552,7 +1742,7 @@ router.post('/v1/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content }, finish_reason: null } ] @@ -1567,7 +1757,7 @@ router.post('/v1/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: {}, finish_reason: 'stop' } ] @@ -1581,7 +1771,7 @@ router.post('/v1/chat/completions', async (req, res) => { id: 'chatcmpl-stream', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), - model: mappedModel || 'qwen-max-latest', + model: mappedModel || DEFAULT_MODEL, choices: [ { index: 0, delta: { content: 'Internal server error' }, finish_reason: 'stop' } ] @@ -1629,7 +1819,7 @@ router.post('/v1/chat/completions', async (req, res) => { id: result.id || "chatcmpl-" + Date.now(), object: "chat.completion", created: Math.floor(Date.now() / 1000), - model: result.model || mappedModel || "qwen-max-latest", + model: result.model || mappedModel || DEFAULT_MODEL, choices: [{ index: 0, message: { diff --git a/src/api/tokenManager.js b/src/api/tokenManager.js index c82fbea..4f8922c 100644 --- a/src/api/tokenManager.js +++ b/src/api/tokenManager.js @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { logError } from '../logger/index.js'; -import { SESSION_DIR, ACCOUNTS_DIR } from '../config.js'; +import { SESSION_DIR, ACCOUNTS_DIR, RATE_LIMIT_HOURS } from '../config.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -53,7 +53,7 @@ export function hasValidTokens() { return tokens.some(t => (!t.resetAt || new Date(t.resetAt).getTime() <= now) && !t.invalid); } -export function markRateLimited(id, hours = 24) { +export function markRateLimited(id, hours = RATE_LIMIT_HOURS) { const tokens = loadTokens(); const idx = tokens.findIndex(t => t.id === id); if (idx !== -1) { @@ -85,6 +85,106 @@ export function markValid(id, newToken) { } } +// Устанавливает человекочитаемый ярлык аккаунта (для различения в пуле). +// Пустая строка очищает ярлык. Возвращает { ok, id, label } либо { error }. +export function setLabel(id, rawLabel) { + if (typeof id !== 'string' || !/^acc_[a-zA-Z0-9]+$/.test(id)) { + return { error: 'Некорректный id аккаунта' }; + } + const label = String(rawLabel ?? '').trim().slice(0, 60); + const tokens = loadTokens(); + const idx = tokens.findIndex(t => t.id === id); + if (idx === -1) return { error: 'Аккаунт не найден' }; + tokens[idx].label = label; + saveTokens(tokens); + return { ok: true, id, label }; +} + +// Обновляет токен существующего аккаунта (relogin из дашборда): +// markValid (обновляет token + сбрасывает invalid/resetAt) + перезапись token.txt. +export function updateAccountToken(id, rawToken) { + if (typeof id !== 'string' || !/^acc_[a-zA-Z0-9]+$/.test(id)) { + return { error: 'Некорректный id аккаунта' }; + } + const token = String(rawToken || '').trim(); + if (!token.startsWith('eyJ') || token.split('.').length !== 3) { + return { error: 'Невалидный токен: ожидается JWT (eyJ...)' }; + } + const tokens = loadTokens(); + const acc = tokens.find(t => t.id === id); + if (!acc) return { error: 'Аккаунт не найден' }; + if (tokens.some(t => t.id !== id && t.token === token)) { + return { error: 'Этот токен уже используется другим аккаунтом' }; + } + const dir = path.join(ACCOUNTS_PATH, id); + if (!path.resolve(dir).startsWith(path.resolve(ACCOUNTS_PATH) + path.sep)) { + return { error: 'Недопустимый путь аккаунта' }; + } + markValid(id, token); + try { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'token.txt'), token, 'utf8'); + } catch (e) { + logError('TokenManager: не удалось записать token.txt для ' + id, e); + } + const info = decodeTokenInfo(token); + return { ok: true, id, exp: info.exp }; +} + export function listTokens() { return loadTokens(); } + +// Декодирует payload JWT без проверки подписи — для отображения срока и id аккаунта. +export function decodeTokenInfo(token) { + try { + const payload = JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString()); + return { exp: payload.exp ? payload.exp * 1000 : null, accountId: payload.id || null }; + } catch { + return { exp: null, accountId: null }; + } +} + +// Добавляет токен вручную (из дашборда), без запуска браузера. +// Возвращает { id } при успехе либо { error }. +export function addTokenFromString(rawToken, label) { + const token = String(rawToken || '').trim(); + if (!token.startsWith('eyJ') || token.split('.').length !== 3) { + return { error: 'Невалидный токен: ожидается JWT (eyJ...)' }; + } + const tokens = loadTokens(); + if (tokens.some(t => t.token === token)) { + return { error: 'Этот токен уже добавлен' }; + } + let n = 2; + const ids = new Set(tokens.map(t => t.id)); + while (ids.has('acc_' + n)) n++; + const id = 'acc_' + n; + + const accDir = path.join(ACCOUNTS_PATH, id); + fs.mkdirSync(accDir, { recursive: true }); + fs.writeFileSync(path.join(accDir, 'token.txt'), token, 'utf8'); + + tokens.push({ id, token, resetAt: null, label: String(label || '').trim().slice(0, 60) }); + saveTokens(tokens); + return { id }; +} + +// Полностью удаляет аккаунт: запись в tokens.json и папку с token.txt. +export function deleteAccount(id) { + // Защита от path traversal: id попадает в путь файловой системы. + if (typeof id !== 'string' || !/^acc_[a-zA-Z0-9]+$/.test(id)) { + return { error: 'Некорректный id аккаунта' }; + } + const dir = path.join(ACCOUNTS_PATH, id); + if (!path.resolve(dir).startsWith(path.resolve(ACCOUNTS_PATH) + path.sep)) { + return { error: 'Недопустимый путь аккаунта' }; + } + removeToken(id); + try { + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + } catch (e) { + logError('TokenManager: не удалось удалить папку аккаунта ' + id, e); + } + return { ok: true }; +} diff --git a/src/browser/browser.js b/src/browser/browser.js index 28c215e..5a5dbb5 100644 --- a/src/browser/browser.js +++ b/src/browser/browser.js @@ -23,29 +23,45 @@ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); export async function initBrowser(visibleMode = true, skipManualRestart = false) { if (browserInstance) return true; - logInfo('Инициализация браузера с Puppeteer Stealth...'); + const cdpUrl = (process.env.QWEN_CDP_URL || '').trim(); + logInfo(cdpUrl + ? `Инициализация браузера: подключение к Chrome по CDP (${cdpUrl})...` + : 'Инициализация браузера с Puppeteer Stealth...'); try { - browserInstance = await puppeteer.launch({ - headless: !visibleMode, - slowMo: visibleMode ? 30 : 0, - executablePath: process.env.CHROME_PATH || undefined, - args: [ - '--no-sandbox', '--disable-setuid-sandbox', - '--disable-blink-features=AutomationControlled', - '--disable-dev-shm-usage', '--disable-web-security', - '--disable-features=IsolateOrigins,site-per-process', - `--window-size=${VIEWPORT_WIDTH},${VIEWPORT_HEIGHT}`, - '--start-maximized', '--disable-infobars', - '--disable-extensions', '--disable-gpu', - '--no-first-run', '--no-default-browser-check', - '--ignore-certificate-errors', '--ignore-certificate-errors-spki-list' - ], - defaultViewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT }, - ignoreHTTPSErrors: true - }); + if (cdpUrl) { + // Attach to YOUR already-running, logged-in Chrome (launch it with + // --remote-debugging-port=PORT --user-data-dir=). Reuses a + // human-trusted session, so qwen serves no captcha and the headless + // CDP evaluate no longer hangs — no empty-browser login required. + browserInstance = await puppeteer.connect({ + browserURL: cdpUrl, + defaultViewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT } + }); + } else { + browserInstance = await puppeteer.launch({ + headless: !visibleMode, + slowMo: visibleMode ? 30 : 0, + executablePath: process.env.CHROME_PATH || undefined, + args: [ + '--no-sandbox', '--disable-setuid-sandbox', + '--disable-blink-features=AutomationControlled', + '--disable-dev-shm-usage', '--disable-web-security', + '--disable-features=IsolateOrigins,site-per-process', + `--window-size=${VIEWPORT_WIDTH},${VIEWPORT_HEIGHT}`, + '--start-maximized', '--disable-infobars', + '--disable-extensions', '--disable-gpu', + '--no-first-run', '--no-default-browser-check', + '--ignore-certificate-errors', '--ignore-certificate-errors-spki-list' + ], + defaultViewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT }, + ignoreHTTPSErrors: true + }); + } - const pages = await browserInstance.pages(); - const page = pages.length > 0 ? pages[0] : await browserInstance.newPage(); + // In CDP mode open a fresh tab (don't hijack the user's current tab). + const page = cdpUrl + ? await browserInstance.newPage() + : ((await browserInstance.pages())[0] || await browserInstance.newPage()); await page.setUserAgent(USER_AGENT); await page.setViewport({ width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT, deviceScaleFactor: 1 }); @@ -101,7 +117,9 @@ export async function initBrowser(visibleMode = true, skipManualRestart = false) browserContext = page; logInfo('Браузер инициализирован с максимальной защитой от обнаружения'); - if (visibleMode) { + // CDP-connected Chrome is already a real, logged-in session — skip the + // interactive empty-browser login/captcha flow. + if (visibleMode && !cdpUrl) { await startManualAuthenticationPuppeteer(page, skipManualRestart); } // loadSessionPuppeteer removed — was dead code (always returned false) diff --git a/src/config.js b/src/config.js index 7b8e11a..07bad57 100644 --- a/src/config.js +++ b/src/config.js @@ -33,6 +33,8 @@ export const MAX_HISTORY_LENGTH = Number(process.env.MAX_HISTORY_LENGTH) || 100; export const MAX_RETRY_COUNT = Number(process.env.MAX_RETRY_COUNT) || 3; export const TASK_POLL_MAX_ATTEMPTS = Number(process.env.TASK_POLL_MAX_ATTEMPTS) || 90; export const TASK_POLL_INTERVAL = Number(process.env.TASK_POLL_INTERVAL) || 2_000; +// Фолбэк-длительность блокировки токена по rate-limit (часы), когда Qwen не прислал точное значение в ответе. +export const RATE_LIMIT_HOURS = Number(process.env.QWEN_RATELIMIT_HOURS) || 24; // ─── Пути (относительно корня проекта) ─────────────────────────────────────── export const SESSION_DIR = process.env.SESSION_DIR || 'session'; @@ -48,7 +50,7 @@ export const USER_AGENT = process.env.USER_AGENT || 'Mozilla/5.0 (Windows NT 10. // ─── Сервер ────────────────────────────────────────────────────────────────── export const PORT = Number(process.env.PORT) || 3264; export const HOST = process.env.HOST || '0.0.0.0'; -export const DEFAULT_MODEL = process.env.DEFAULT_MODEL || 'qwen-max-latest'; +export const DEFAULT_MODEL = process.env.DEFAULT_MODEL || 'qwen3.7-max'; export const ALLOW_UNSCOPED_SESSION_CHAT_RESTORE = toBoolean(process.env.ALLOW_UNSCOPED_SESSION_CHAT_RESTORE); // ─── Логирование ───────────────────────────────────────────────────────────── diff --git a/src/dashboard/index.html b/src/dashboard/index.html new file mode 100644 index 0000000..6f5470f --- /dev/null +++ b/src/dashboard/index.html @@ -0,0 +1,587 @@ + + + + + +FreeQwenApi — Дашборд + + + + + + + + +
+
+

FreeQwenApi

+ проверка… +
+ + + + +
+
+

Плейграунд чата + + +

+
+
+
+ + + + +
+
+
+ + +
+
+

Генерация картинок

+
+ + + +
+ +
Сгенерированные картинки появятся здесь.
+ +
+
+ + +
+
+

Генерация видео

+
+ + + +
+ + +
Видео генерируется дольше — запустите и дождитесь.
+
+
+ + +
+
+
статус
+
моделей
+
аккаунтов онлайн
+
в лимите / мертвы
+
+
+
+

Как подключить

+
+
+
любой непустой (sk-qwen)
+
qwen3.7-max
+
+ + +
+
OpenAI SDK — /api/v1, Open WebUI — /api.
+
+
+

Модели (0) клик копирует

+
загрузка…
+
+
+

Аккаунты Qwen +

+
загрузка…
+
+ + +
+
Токен: chat.qwen.ai → F12 → Console → copy(localStorage.token)
+
+
+
+ + +
+
+ + + +