Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3ed3acb
Merge pull request #58 from ForgetMeAI/main
y13sint Jun 6, 2026
6565f1f
Update README.md
y13sint Jun 6, 2026
62a9d2c
Update README.md
y13sint Jun 6, 2026
c1a6d6a
fix: pin one account per request so multi-account chat works
danscMax Jun 6, 2026
5a99cdb
fix: use configurable DEFAULT_MODEL instead of hardcoded qwen-max-latest
danscMax Jun 6, 2026
5180329
feat: built-in dashboard with account management
danscMax Jun 6, 2026
035022b
feat(dashboard): tabs UI, media generation, account management, markd…
danscMax Jun 7, 2026
e43a7de
Merge pull request #59 from danscMax/fix/multi-account-and-default-model
y13sint Jun 7, 2026
8348479
docs: add .env.example documenting all environment variables
danscMax Jun 8, 2026
685372e
feat: make rate-limit cooldown hours configurable via env
danscMax Jun 8, 2026
2fb75f2
fix(security): require same-origin for GET /api/accounts
danscMax Jun 8, 2026
d127d4a
fix: sanitize chatId to prevent path traversal in chat history (CWE-22)
sebastiondev Jun 9, 2026
2abe859
Merge pull request #63 from danscMax/feat/qwen-ratelimit-env
y13sint Jun 9, 2026
2c9643c
Merge pull request #62 from danscMax/feat/env-example
y13sint Jun 9, 2026
2723eb8
Merge pull request #66 from sebastiondev/fix/cwe22-chathistory-unsani…
y13sint Jun 10, 2026
7eb74d6
fix(api): allow browser-extension origins through the account CSRF guard
Jun 14, 2026
db0f956
Merge pull request #61 from danscMax/feat/dashboard
y13sint Jun 17, 2026
fb33fe2
fix(chat): fall back to browser path when Aliyun WAF blocks node-stre…
danscMax Jun 30, 2026
60e17d9
feat(browser): QWEN_VISIBLE (headed) and QWEN_CDP_URL (attach to runn…
danscMax Jun 30, 2026
0c7515a
feat(accounts): human-readable account labels
danscMax Jun 30, 2026
dc4dbcd
Merge pull request #69 from danscMax/feat/stream-waf-fallback
y13sint Jun 30, 2026
122f5db
Merge pull request #70 from danscMax/feat/browser-auth-modes
y13sint Jun 30, 2026
61d0501
Merge pull request #71 from danscMax/feat/account-labels
y13sint Jun 30, 2026
50ce57d
Revise README for Qwen Chat features
y13sint Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
23 changes: 15 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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

Добавить аккаунт:
Expand Down Expand Up @@ -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 находки и честные тесты без корпоративной лапши.
13 changes: 12 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down
54 changes: 37 additions & 17 deletions src/api/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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') {
Expand All @@ -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 };
}
Expand All @@ -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}`);
Expand All @@ -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);
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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
Expand Down
42 changes: 36 additions & 6 deletions src/api/chatHistory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -341,4 +371,4 @@ export function deleteChatsAutomatically(criteria = {}) {
error: error.message
};
}
}
}
Loading