diff --git a/package.json b/package.json index b22e537..5cb705b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smallclaw", - "version": "4.1.11", + "version": "4.1.12", "description": "Local AI agent framework powered by Ollama - OpenClaw alternative", "main": "dist/index.js", "bin": { diff --git a/src/gateway/routes-language.ts b/src/gateway/routes-language.ts index 50b40a2..fafd857 100644 --- a/src/gateway/routes-language.ts +++ b/src/gateway/routes-language.ts @@ -104,9 +104,14 @@ export function registerLanguageRoutes(app: express.Application): void { } }); + // NOTE: the :userId route param is intentionally ignored for workspace resolution — + // progress always belongs to the authenticated session, never an arbitrary URL param, + // otherwise any logged-in user could read/overwrite any other user's progress file. app.get('/api/language/progress/:userId', (req: express.Request, res: express.Response) => { try { - const workspace = getWorkspacePath(String(req.params.userId)); + const sessionUser = (req as any).user; + if (!sessionUser) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; } + const workspace = getWorkspacePath(sessionUser.username); const progressPath = path.join(workspace, 'language', 'progress.json'); if (!fs.existsSync(progressPath)) { res.json({ success: true, progress: null }); return; } res.json({ success: true, progress: JSON.parse(fs.readFileSync(progressPath, 'utf-8')) }); @@ -117,9 +122,11 @@ export function registerLanguageRoutes(app: express.Application): void { app.post('/api/language/progress/:userId', (req: express.Request, res: express.Response) => { try { + const sessionUser = (req as any).user; + if (!sessionUser) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; } const body = req.body; if (!body || typeof body !== 'object') { res.status(400).json({ success: false, error: 'body required' }); return; } - const workspace = getWorkspacePath(String(req.params.userId)); + const workspace = getWorkspacePath(sessionUser.username); const langDir = path.join(workspace, 'language'); fs.mkdirSync(langDir, { recursive: true }); fs.writeFileSync(path.join(langDir, 'progress.json'), JSON.stringify(body, null, 2), 'utf-8'); @@ -129,6 +136,41 @@ export function registerLanguageRoutes(app: express.Application): void { } }); + // POST /api/language/interpret — 실시간 한국어↔우즈베크어 통역 (언어 자동 판별) + app.post('/api/language/interpret', async (req: express.Request, res: express.Response) => { + try { + const text = String(req.body?.text || '').trim().slice(0, 500); + if (!text) { res.status(400).json({ success: false, error: 'text required' }); return; } + + const ollama = getOllamaClient(); + const prompt = `다음 텍스트가 한국어인지 우즈베크어인지 판별하고, 반대 언어로 번역하세요. +텍스트: "${text}" +JSON으로만 응답하세요 (코드 블록 없이): +{"source_lang":"ko 또는 uz","translated":"번역문","romanization":"번역문이 우즈베크어면 한글 발음 표기, 한국어면 빈 문자열"} +우즈베크어는 1993년 이후 표준 라틴 문자(oʻ, gʻ 포함)를 사용하세요. 자연스럽고 간결하게 번역하세요.`; + + const result = await ollama.chatWithThinking( + [{ role: 'user' as const, content: prompt }], + 'executor', + { num_predict: 400, temperature: 0.2, think: false } + ); + const raw = String(result.message?.content || '').trim(); + let parsed: any; + try { parsed = extractJson(raw, 'object'); } catch { parsed = null; } + if (!parsed?.translated) { + res.json({ success: false, error: '번역 실패', raw }); return; + } + res.json({ + success: true, + source_lang: parsed.source_lang === 'uz' ? 'uz' : 'ko', + translated: String(parsed.translated), + romanization: String(parsed.romanization || ''), + }); + } catch (err: any) { + res.status(500).json({ success: false, error: String(err?.message || err) }); + } + }); + // POST /api/language/tts — Uzbek TTS via edge-tts app.post('/api/language/tts', async (req: express.Request, res: express.Response) => { try { diff --git a/src/gateway/routes/imagegen.ts b/src/gateway/routes/imagegen.ts index 14efbf0..39bf215 100644 --- a/src/gateway/routes/imagegen.ts +++ b/src/gateway/routes/imagegen.ts @@ -2,7 +2,7 @@ import { Express, Request, Response } from 'express'; import fs from 'fs'; import path from 'path'; import { imageGenerateTool, videoGenerateTool, imageStyleTransformTool } from '../../tools/imagegen.js'; -import { imageEditTool } from '../../tools/image.js'; +import { imageEditTool, isPathInsideDir } from '../../tools/image.js'; const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']); const VIDEO_EXTS = new Set(['.mp4']); @@ -232,6 +232,7 @@ export function registerImagegenRoutes(app: Express): void { if (!srcRel) return res.status(400).json({ error: 'path is required' }); const srcFull = path.join(user.workspace, srcRel); + if (!isPathInsideDir(user.workspace, srcFull)) return res.status(403).json({ error: 'Access denied: path escapes workspace' }); if (!fs.existsSync(srcFull)) return res.status(404).json({ error: 'Source file not found' }); console.log(`[imagegen] save-result start user=${user.username} path=${srcRel} alsoUploads=${alsoUploads}`); diff --git a/src/tools/image.ts b/src/tools/image.ts index cf769a3..aaef8f9 100644 --- a/src/tools/image.ts +++ b/src/tools/image.ts @@ -4,7 +4,7 @@ import fs from 'fs'; import { ToolResult } from '../types.js'; import { getWorkspacePath } from '../config/paths.js'; -function isPathInsideDir(base: string, target: string): boolean { +export function isPathInsideDir(base: string, target: string): boolean { const resolvedBase = path.resolve(base); const resolvedTarget = path.resolve(target); if (resolvedBase === resolvedTarget) return true; @@ -12,6 +12,17 @@ function isPathInsideDir(base: string, target: string): boolean { return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); } +// EDIT_SCRIPT interpolates these directly into Python source (e.g. `int(${params.x})`) run +// via `python3 -c