const originalConsoleLog = console.log; const originalConsoleError = console.error; const originalConsoleWarn = console.warn; const originalConsoleInfo = console.info; console.log = function(...args) { originalConsoleLog.apply(console, ['[SCHOOLMIND]', ...args]); }; console.error = function(...args) { originalConsoleError.apply(console, ['[SCHOOLMIND]', ...args]); }; console.warn = function(...args) { originalConsoleWarn.apply(console, ['[SCHOOLMIND]', ...args]); }; console.info = function(...args) { originalConsoleInfo.apply(console, ['[SCHOOLMIND]', ...args]); }; console.log("--- server.js loading started ---"); import express from 'express'; import cors from 'cors'; import path from 'path'; import { fileURLToPath } from 'url'; import { spawn } from 'child_process'; import fs from 'fs'; import { registerIServFiltered } from './src/api/iserv-filtered.js'; console.log("--- Imports loaded ---"); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); console.log("--- Express app created ---"); // Increase JSON payload limit to avoid Payload Too Large errors for PDFs app.use(express.json({ limit: '50mb' })); app.use(cors()); // Serve static files from the dist directory app.use(express.static(path.join(__dirname, 'dist'))); // Also serve public/ as fallback for assets downloaded during Docker build (e.g. sql-wasm.wasm) app.use(express.static(path.join(__dirname, 'public'))); // ========================================== // Proxy Middlewares (Migrated from vite.config.js) // ========================================== import { initNewsService, registerNewsRoutes } from './server/news-service.js'; // Init News Service console.log("--- initNewsService calling ---"); initNewsService(); console.log("--- initNewsService called ---"); registerNewsRoutes(app); console.log("--- registerNewsRoutes called ---"); // Open Source Piper TTS Proxy (Local) app.get('/api/tts', async (req, res) => { const text = req.query.text; if (!text) return res.status(400).json({ error: 'Missing text' }); try { const piperPath = path.join(__dirname, 'piper_tts', 'piper'); let modelPath = path.join(__dirname, 'piper_tts', 'de_DE-thorsten-high.onnx'); // Check if model is in RAM disk (Linux /dev/shm) const shmModelPath = '/dev/shm/de_DE-thorsten-high.onnx'; if (fs.existsSync(shmModelPath)) { modelPath = shmModelPath; } // Wenn Piper lokal nicht existiert (z.B. im lokalen Windows Dev-Server) if (!fs.existsSync(piperPath)) { console.warn("Piper executable not found at " + piperPath); return res.status(503).json({error: 'TTS Engine offline (Piper not installed locally)'}); } res.setHeader('Content-Type', 'audio/wav'); // Clean text to prevent Piper from choking on URLs or Emojis let cleanText = text; cleanText = cleanText.replace(/https?:\/\/[^\s]+/g, ' Link.'); cleanText = cleanText.replace(/[\u{1F600}-\u{1F64F}]/gu, ''); cleanText = cleanText.replace(/[\u{1F300}-\u{1F5FF}]/gu, ''); cleanText = cleanText.replace(/[\u{1F680}-\u{1F6FF}]/gu, ''); cleanText = cleanText.replace(/[\u{1F700}-\u{1F77F}]/gu, ''); cleanText = cleanText.replace(/[\u{1F780}-\u{1F7FF}]/gu, ''); cleanText = cleanText.replace(/[\u{1F800}-\u{1F8FF}]/gu, ''); cleanText = cleanText.replace(/[\u{1F900}-\u{1F9FF}]/gu, ''); cleanText = cleanText.replace(/[\u{1FA00}-\u{1FA6F}]/gu, ''); cleanText = cleanText.replace(/[\u{1FA70}-\u{1FAFF}]/gu, ''); cleanText = cleanText.replace(/[\u{2600}-\u{26FF}]/gu, ''); cleanText = cleanText.replace(/[\u{2700}-\u{27BF}]/gu, ''); // Piper liest von stdin und schreibt die WAV an stdout const piper = spawn(piperPath, ['--model', modelPath, '--output_file', '-'], { cwd: path.join(__dirname, 'piper_tts') }); piper.stdout.pipe(res); piper.stderr.on('data', (data) => { // Piper debug output is written to stderr, can be ignored or logged // console.log(`Piper log: ${data}`); }); piper.on('close', (code) => { if (code !== 0) console.error(`Piper exited with code ${code}`); }); piper.stdin.write(cleanText.trim()); piper.stdin.end(); } catch (error) { console.error('TTS Error:', error); if (!res.headersSent) res.status(500).json({ error: error.message }); } }); // TTS Debug Endpoint app.get('/api/tts/debug', (req, res) => { try { const piperPath = path.join(__dirname, 'piper_tts', 'piper'); const modelPath = path.join(__dirname, 'piper_tts', 'de_DE-thorsten-high.onnx'); const configPath = path.join(__dirname, 'piper_tts', 'de_DE-thorsten-high.onnx.json'); res.json({ piperExists: fs.existsSync(piperPath), modelExists: fs.existsSync(modelPath), configExists: fs.existsSync(configPath), piperPath, dirname: __dirname, stats: fs.existsSync(piperPath) ? fs.statSync(piperPath) : null }); } catch (err) { res.status(500).json({ error: err.message }); } }); // WebUntis Proxy import { WebUntis } from 'webuntis'; app.post('/api/untis', async (req, res) => { const { school, username, password, server, date } = req.body; if (!school || !username || !password || !server) { return res.status(400).json({ error: 'Missing credentials' }); } // Clean server string in case user pasted a full URL let cleanServer = server.replace(/^https?:\/\//, '').replace(/\/.*$/, '').trim(); const untis = new WebUntis(school, username, password, cleanServer); try { await untis.login(); // Parse date if provided, else use today const targetDate = date ? new Date(date) : new Date(); // Week range Monday–Sunday containing targetDate const weekStart = new Date(targetDate); const dayOfWeek = weekStart.getDay(); // 0 = Sunday const diffToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; weekStart.setDate(weekStart.getDate() + diffToMonday); weekStart.setHours(0, 0, 0, 0); const weekEnd = new Date(weekStart); weekEnd.setDate(weekEnd.getDate() + 6); const timetable = await untis.getOwnTimetableForRange(weekStart, weekEnd); // Optionally fetch more data like subject names or teachers, but the timetable usually contains what we need await untis.logout(); res.json({ success: true, timetable }); } catch (error) { console.error('Untis API Error:', error); // If login fails, webuntis usually throws an error res.status(401).json({ error: error.message || 'Login failed' }); } }); // Shared by every /api/notes/* route: same access key as chat/completions. function checkNotesAccess(req, res) { const accessToken = process.env.NOTES_ACCESS_TOKEN; if (!accessToken) return true; const provided = req.headers['x-app-key'] || (req.headers.authorization || '').replace(/^Bearer\s+/i, ''); if (provided === accessToken) return true; res.status(401).json({ error: { message: 'Zugriffsschluessel fehlt oder ist falsch' } }); return false; } // Notes-App AI Agent/Chat Proxy � keeps OPENROUTER_API_KEY server-side. // The client (Notizen-App) only ever sees this URL, never the key. app.post('/api/notes/chat/completions', async (req, res) => { if (!checkNotesAccess(req, res)) return; const apiKey = process.env.OPENROUTER_API_KEY; if (!apiKey) { return res.status(500).json({ error: { message: 'OPENROUTER_API_KEY ist im Space nicht gesetzt' } }); } const messages = Array.isArray(req.body.messages) ? req.body.messages : []; if (messages.length === 0 || messages.length > 60) { return res.status(400).json({ error: { message: 'messages fehlt oder ist zu lang' } }); } const NOTES_TEXT_MODELS = ['deepseek/deepseek-v4-flash-0731', 'deepseek/deepseek-v4-flash']; const NOTES_VISION_MODEL = 'deepseek/deepseek-v4-flash-vision-exp'; const hasImage = messages.some( (message) => Array.isArray(message.content) && message.content.some((part) => part?.type === 'image_url'), ); // Die App schickt ihr gewähltes Modell samt Fallback-Kette mit. Nur freigegebene // IDs durchlassen, damit niemand über den Proxy teure Modelle auf unsere Kosten ruft. const NOTES_ALLOWED_MODELS = new Set([ 'google/gemini-3.8-flash', 'google/gemini-3.7-flash', 'google/gemini-3.6-flash', 'google/gemini-3.5-flash-lite', 'google/gemini-3.1-flash-lite', 'deepseek/deepseek-v4-flash', ]); const requested = [req.body.model, ...(Array.isArray(req.body.models) ? req.body.models : [])] .filter((id) => NOTES_ALLOWED_MODELS.has(id)); const clientModels = [...new Set(requested)].slice(0, 3); // Gemini kann Bilder, DeepSeek V4 Flash nicht — dort bleibt das Vision-Modell. const clientCanSee = clientModels.length > 0 && clientModels[0].startsWith('google/'); const models = process.env.NOTES_MODEL ? [process.env.NOTES_MODEL] : clientModels.length > 0 && (!hasImage || clientCanSee) ? clientModels : hasImage ? [NOTES_VISION_MODEL] : NOTES_TEXT_MODELS; const wantsStream = req.body.stream === true; try { const upstream = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: models[0], models, messages, stream: wantsStream, ...(Array.isArray(req.body.tools) && req.body.tools.length ? { tools: req.body.tools, tool_choice: 'auto' } : {}), // require_parameters: nur Provider nutzen, die tools wirklich unterstützen. // Ohne das routet der billigste Provider auch bei aktiven Tools durch und // "erfindet" dann Tool-Aufrufe als Klartext (...) // statt sie strukturiert als tool_calls zurückzugeben. // Bei Gemini fest auf Google AI Studio pinnen (dort liegt unser BYOK-Key, // kostet nichts) - sonst routet "sort: price" zeitweise auf Google Vertex // und das zieht echtes OpenRouter-Guthaben. provider: models[0].startsWith('google/') ? { data_collection: 'deny', only: ['google-ai-studio'], require_parameters: true } : { data_collection: 'deny', sort: 'price', require_parameters: true }, // Reasoning ist bei Gemini 3.x Pflicht (Anfrage schlägt sonst mit 400 fehl), // DeepSeek läuft ohne. Nur für Nicht-Gemini-Modelle abschalten. ...(models[0].startsWith('google/') ? {} : { reasoning: { enabled: false } }), }), }); if (!wantsStream) { const data = await upstream.json(); res.status(upstream.status).json(data); return; } if (!upstream.ok || !upstream.body) { const data = await upstream.json().catch(() => null); res.status(upstream.status).json(data || { error: { message: 'OpenRouter-Stream fehlgeschlagen' } }); return; } // SSE passthrough: forward OpenRouter's chunks to the client as they arrive. res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders?.(); for await (const chunk of upstream.body) { res.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } res.end(); } catch (error) { console.error('Notes agent proxy error:', error); if (res.headersSent) { res.end(); } else { res.status(502).json({ error: { message: `OpenRouter nicht erreichbar: ${error.message}` } }); } } }); // General Web Search Proxy (Tavily) � keeps TAVILY_API_KEY_2 server-side. // Fallback for the Notes agent's search_web tool when Wikipedia has no hit // (current events, niche topics). Trimmed to a few short extracts per result // here already, so the token cost is paid once, not per client. app.post('/api/notes/search', async (req, res) => { if (!checkNotesAccess(req, res)) return; const apiKey = process.env.TAVILY_API_KEY_2; if (!apiKey) { return res.status(500).json({ error: { message: 'TAVILY_API_KEY_2 ist im Space nicht gesetzt' } }); } const query = String(req.body?.query || '').trim(); if (!query) return res.status(400).json({ error: { message: 'query fehlt' } }); try { const upstream = await fetch('https://api.tavily.com/search', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ query, search_depth: 'basic', max_results: 5, // Kein von Tavily generiertes Answer-Feld: der Agent soll aus den // Quellen selbst schließen, nicht eine fremde Zusammenfassung // ungeprüft übernehmen. include_answer: false, include_raw_content: false, }), }); const data = await upstream.json(); if (!upstream.ok) { return res.status(upstream.status).json({ error: { message: data?.error || 'Tavily-Fehler' } }); } const results = Array.isArray(data.results) ? data.results .filter((result) => result?.title && result?.url) .map((result) => ({ title: result.title, url: result.url, extract: String(result.content || '').trim().slice(0, 500), })) : []; res.json({ results }); } catch (error) { console.error('Notes search proxy error:', error); res.status(502).json({ error: { message: `Tavily nicht erreichbar: ${error.message}` } }); } }); // Wolfram|Alpha Proxy (LLM-API) - keeps WOLFRAM_APPID server-side. // Exact maths/physics/chemistry answers for the Notes agent's wolfram_alpha // tool. The LLM-API returns plain text already shaped for a model. app.post('/api/notes/wolfram', async (req, res) => { if (!checkNotesAccess(req, res)) return; const appId = process.env.WOLFRAM_APPID; if (!appId) { return res.status(500).json({ error: { message: 'WOLFRAM_APPID ist im Space nicht gesetzt' } }); } const query = String(req.body?.query || '').trim().slice(0, 500); if (!query) return res.status(400).json({ error: { message: 'query fehlt' } }); try { const params = new URLSearchParams({ input: query, appid: appId, maxchars: '3000' }); const upstream = await fetch(`https://www.wolframalpha.com/api/v1/llm-api?${params}`, { signal: AbortSignal.timeout(15000), }); const result = (await upstream.text()).trim(); // 501 = Wolfram konnte die Eingabe nicht deuten; der Text erklärt warum. if (!upstream.ok && upstream.status !== 501) { return res.status(502).json({ error: { message: `Wolfram-Fehler ${upstream.status}` } }); } res.json({ result: result.slice(0, 3000) }); } catch (error) { console.error('Notes wolfram proxy error:', error); res.status(502).json({ error: { message: `Wolfram nicht erreichbar: ${error.message}` } }); } }); // Google Docs Proxy - keeps the OAuth refresh token server-side. // The app sends a finished .docx (base64); Drive converts it to a real Google // Doc in the owner's Drive (scope drive.file: only files created by this app). let googleToken = { value: null, expires: 0 }; async function googleAccessToken() { if (googleToken.value && Date.now() < googleToken.expires - 60000) return googleToken.value; const upstream = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', body: new URLSearchParams({ client_id: process.env.GOOGLE_CLIENT_ID, client_secret: process.env.GOOGLE_CLIENT_SECRET, refresh_token: process.env.GOOGLE_REFRESH_TOKEN, grant_type: 'refresh_token', }), signal: AbortSignal.timeout(15000), }); const data = await upstream.json(); if (!upstream.ok) throw new Error(data.error_description || data.error || `Token-Fehler ${upstream.status}`); googleToken = { value: data.access_token, expires: Date.now() + data.expires_in * 1000 }; return googleToken.value; } app.post('/api/notes/gdoc', async (req, res) => { if (!checkNotesAccess(req, res)) return; if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET || !process.env.GOOGLE_REFRESH_TOKEN) { return res.status(500).json({ error: { message: 'Google ist im Space nicht eingerichtet (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN)' }, }); } const title = String(req.body?.title || '').trim().slice(0, 150) || 'Dokument'; const docx = Buffer.from(String(req.body?.docx || ''), 'base64'); // A .docx is a zip: "PK" up front. Rejects empty or foreign payloads early. if (docx.length < 4 || docx.length > 10 * 1024 * 1024 || docx.subarray(0, 2).toString('latin1') !== 'PK') { return res.status(400).json({ error: { message: 'docx fehlt oder ist ungültig' } }); } try { const token = await googleAccessToken(); const boundary = `notes${Math.random().toString(36).slice(2)}`; const body = Buffer.concat([ Buffer.from( `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n` + JSON.stringify({ name: title, mimeType: 'application/vnd.google-apps.document' }) + `\r\n--${boundary}\r\nContent-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document\r\n\r\n`, ), docx, Buffer.from(`\r\n--${boundary}--`), ]); const upstream = await fetch( 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id,name,webViewLink', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': `multipart/related; boundary=${boundary}` }, body, signal: AbortSignal.timeout(30000), }, ); const data = await upstream.json(); if (!upstream.ok) { return res.status(502).json({ error: { message: data?.error?.message || `Drive-Fehler ${upstream.status}` } }); } res.json({ id: data.id, title: data.name, url: data.webViewLink }); } catch (error) { console.error('Notes gdoc proxy error:', error); res.status(502).json({ error: { message: `Google nicht erreichbar: ${error.message}` } }); } }); // Bestehende Google Docs lesen/bearbeiten: eine Route, Aktion im Body. // Braucht Docs-API + Scope documents (+ drive.readonly zum Suchen); mit drive.file // sieht der Token nur Docs, die diese App selbst angelegt hat. const GDOC_ID = /^[\w-]{10,100}$/; async function googleJson(url, init = {}) { const upstream = await fetch(url, { ...init, headers: { Authorization: `Bearer ${await googleAccessToken()}`, 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(30000), }); if (!upstream.ok) { const data = await upstream.json().catch(() => null); throw new Error(data?.error?.message || `Google-Fehler ${upstream.status}`); } return upstream; } app.post('/api/notes/gdoc/edit', async (req, res) => { if (!checkNotesAccess(req, res)) return; if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET || !process.env.GOOGLE_REFRESH_TOKEN) { return res.status(500).json({ error: { message: 'Google ist im Space nicht eingerichtet (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN)' }, }); } const { action, id } = req.body || {}; if (action !== 'list' && !GDOC_ID.test(String(id || ''))) { return res.status(400).json({ error: { message: 'id fehlt oder ist ungültig' } }); } try { if (action === 'list') { const name = String(req.body.query || '').trim().replace(/[\\']/g, ' ').slice(0, 100); const q = `mimeType='application/vnd.google-apps.document' and trashed=false${name ? ` and name contains '${name}'` : ''}`; const upstream = await googleJson( `https://www.googleapis.com/drive/v3/files?pageSize=15&orderBy=modifiedTime%20desc&fields=files(id,name,modifiedTime,webViewLink)&q=${encodeURIComponent(q)}`, ); return res.json({ files: (await upstream.json()).files || [] }); } if (action === 'read') { const upstream = await googleJson(`https://www.googleapis.com/drive/v3/files/${id}/export?mimeType=text%2Fplain`); return res.json({ text: (await upstream.text()).slice(0, 30000) }); } if (action === 'replace' || action === 'append') { const text = String(req.body.text ?? ''); const find = String(req.body.find ?? ''); if (action === 'replace' && !find) return res.status(400).json({ error: { message: 'find fehlt' } }); if (action === 'append' && !text.trim()) return res.status(400).json({ error: { message: 'text fehlt' } }); const request = action === 'replace' ? { replaceAllText: { containsText: { text: find, matchCase: true }, replaceText: text } } : { insertText: { endOfSegmentLocation: {}, text: `\n${text}` } }; const upstream = await googleJson(`https://docs.googleapis.com/v1/documents/${id}:batchUpdate`, { method: 'POST', body: JSON.stringify({ requests: [request] }), }); const changed = (await upstream.json()).replies?.[0]?.replaceAllText?.occurrencesChanged ?? 0; const meta = await (await googleJson(`https://www.googleapis.com/drive/v3/files/${id}?fields=name,webViewLink`)).json(); return res.json({ title: meta.name, url: meta.webViewLink, changed }); } res.status(400).json({ error: { message: 'action ungültig' } }); } catch (error) { console.error('Notes gdoc edit proxy error:', error); res.status(502).json({ error: { message: error.message } }); } }); // Web Search Proxy app.get('/api/proxy/search', async (req, res) => { const query = req.query.q; if (!query) return res.status(400).json({ error: 'Missing q' }); try { const ddgRes = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`); const html = await ddgRes.text(); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(html); } catch (e) { res.status(500).send(e.message); } }); // Gravity Browser Agent Proxy (Traffic Cop) app.use('/api/agent', async (req, res) => { try { const targetUrl = 'http://127.0.0.1:8765' + req.originalUrl.replace('/api/agent', ''); const fetchOptions = { method: req.method, headers: { ...req.headers } }; // Remove host to avoid conflicts delete fetchOptions.headers.host; delete fetchOptions.headers['content-length']; delete fetchOptions.headers['content-type']; if (['POST', 'PUT', 'PATCH'].includes(req.method)) { fetchOptions.body = JSON.stringify(req.body); fetchOptions.headers['Content-Type'] = 'application/json'; } const response = await fetch(targetUrl, fetchOptions); const text = await response.text(); res.status(response.status); for (const [key, value] of response.headers.entries()) { res.setHeader(key, value); } res.send(text); } catch (error) { console.error('Agent Proxy Error:', error); res.status(502).json({ error: 'Browser Agent is offline or unreachable' }); } }); // JSON-based DuckDuckGo endpoint using cheerio app.get('/api/ddg-search', async (req, res) => { const query = req.query.q; if (!query) return res.status(400).json({ error: 'Missing query' }); try { const cheerio = await import('cheerio'); // Using html.duckduckgo.com const response = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' } }); const html = await response.text(); const $ = cheerio.load(html); const results = []; $('.result__body').each((i, el) => { if (i >= 10) return; // limit to top 10 const title = $(el).find('.result__title .result__a').text().trim(); const link = $(el).find('.result__title .result__a').attr('href'); const snippet = $(el).find('.result__snippet').text().trim(); if (title && link) { // DDG wraps links in //duckduckgo.com/l/?uddg=... let realLink = link; if (link.includes('uddg=')) { try { const urlObj = new URL(link, 'https://duckduckgo.com'); realLink = decodeURIComponent(urlObj.searchParams.get('uddg')); } catch(e) {} } results.push({ title, link: realLink, snippet }); } }); res.json({ results }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Web Image Search API app.get('/api/search-image', async (req, res) => { const query = req.query.q; if (!query) return res.status(400).json({ error: 'Missing query' }); try { const cheerio = await import('cheerio'); const url = `https://www.google.com/search?tbm=isch&q=${encodeURIComponent(query)}`; const response = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' } }); const html = await response.text(); const $ = cheerio.load(html); let imageUrl = null; let sourceLink = `https://www.google.com/search?tbm=isch&q=${encodeURIComponent(query)}`; $('img').each((i, el) => { const src = $(el).attr('src'); if (src && src.startsWith('https://encrypted-tbn0.gstatic.com/images')) { if (!imageUrl) imageUrl = src; } }); if (imageUrl) { res.json({ imageUrl, sourceLink }); } else { res.status(404).json({ error: 'No image found' }); } } catch (e) { res.status(500).json({ error: e.message }); } }); // Shopping Search Proxy app.get('/api/proxy/shop', async (req, res) => { const targetUrl = req.query.url; if (!targetUrl) return res.status(400).json({ error: 'Missing url' }); try { const shopRes = await fetch(targetUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', 'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7', 'Sec-Ch-Ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', 'Sec-Ch-Ua-Mobile': '?0', 'Sec-Ch-Ua-Platform': '"Windows"', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'none', 'Sec-Fetch-User': '?1', 'Upgrade-Insecure-Requests': '1' } }); const text = await shopRes.text(); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(text); } catch (e) { res.status(500).send(e.message); } }); // Reader/Scrape Proxy app.get('/api/proxy/scrape', async (req, res) => { const targetUrl = req.query.url; if (!targetUrl) return res.status(400).json({ error: 'Missing url' }); try { const jinaRes = await fetch(`https://r.jina.ai/${targetUrl}`); const text = await jinaRes.text(); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.send(text); } catch (e) { res.status(500).send(e.message); } }); // IServ Puppeteer Endpoint app.post('/api/iserv', async (req, res) => { try { const { fetchIServTasks } = await import('./src/api/iserv.js'); const { url, username, password } = req.body; if (!url || !username || !password) { return res.status(400).json({ error: 'Missing credentials' }); } const tasks = await fetchIServTasks(url, username, password); res.setHeader('Content-Type', 'application/json'); res.json(tasks); } catch (e) { res.status(500).json({ error: e.message || 'Scraping failed' }); } }); // PDF Generation Endpoint app.post('/api/pdf', async (req, res) => { try { const { markdown, title } = req.body; if (!markdown) { return res.status(400).json({ error: "Missing markdown content" }); } const { generatePdf } = await import('./src/api/pdf.js'); const pdfBuffer = await generatePdf(markdown, title); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(title || 'loesung')}.pdf"`); res.send(pdfBuffer); } catch (e) { console.error("PDF generation failed:", e); res.status(500).json({ error: e.message }); } }); // YouTube Search API app.get('/api/youtube-search', async (req, res) => { const query = req.query.q; if (!query) return res.status(400).send('Missing query'); try { const { default: ytSearch } = await import('youtube-sr'); // Force IPv4 for YouTube requests to bypass HF IPv6 blocks const dns = await import('dns'); dns.setDefaultResultOrder('ipv4first'); // Helper to fetch via DDG HTML const fetchFromDDG = async () => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 8000); try { const response = await fetch(`https://html.duckduckgo.com/html/?q=site:youtube.com+${encodeURIComponent(query)}`, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }, signal: controller.signal }); clearTimeout(timeoutId); const html = await response.text(); const matches = Array.from(html.matchAll(/v=([a-zA-Z0-9_-]{11})/g)); if (matches.length > 0) return matches[0][1]; } catch(err) { clearTimeout(timeoutId); console.error('DDG fallback error:', err.message); } return null; }; // Race ytSearch against a 10s timeout let videoId = null; try { const ytPromise = ytSearch.YouTube.search(query, { limit: 1 }).then(v => v && v.length > 0 ? v[0].id : null); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 10000)); videoId = await Promise.race([ytPromise, timeoutPromise]); } catch (e) { console.warn('ytSearch failed or timed out:', e.message); } if (!videoId) { console.warn('Falling back to DDG...'); videoId = await fetchFromDDG(); } if (videoId) { if (req.query.json) { res.setHeader('Content-Type', 'application/json'); res.json({ videoId }); } else { res.redirect(`https://www.youtube.com/embed/${videoId}?autoplay=1&mute=1`); } } else { res.status(404).send('No videos found'); } } catch (err) { res.status(500).send(err.message); } }); // Deep Research Search Proxy (Google News RSS) app.get('/api/research/search', async (req, res) => { const query = req.query.q; if (!query) return res.status(400).json({ error: 'Missing query' }); try { const cheerio = await import('cheerio'); const url = `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=de&gl=DE&ceid=DE:de`; const response = await fetch(url); const xml = await response.text(); const $ = cheerio.load(xml, { xmlMode: true }); const results = []; $('item').each((i, el) => { if (i >= 5) return; const title = $(el).find('title').text(); const link = $(el).find('link').text(); const description = $(el).find('description').text(); const snippet = cheerio.load(description).text().trim() || title; if (title && link) { results.push({ title, body: snippet, href: link }); } }); res.json({ results }); } catch (e) { res.status(500).json({ error: e.message }); } }); // YouTube Transcript API app.get('/api/youtube-transcript', async (req, res) => { const videoId = req.query.v; if (!videoId) return res.status(400).json({ error: 'Missing video ID' }); try { const { YoutubeTranscript } = await import('youtube-transcript'); const transcript = await YoutubeTranscript.fetchTranscript(videoId); res.setHeader('Content-Type', 'application/json'); res.json(transcript); } catch (err) { res.status(500).json({ error: err.message }); } }); // IServ tasks without names for the assistant (token protected, credentials from the Space secrets) registerIServFiltered(app); // Fallback for SPA routing - only for navigation requests, NOT for assets app.use((req, res) => { // If the request looks like a static asset (has a file extension), return 404 if (req.path.match(/\.\w+$/)) { return res.status(404).send('Not found'); } res.sendFile(path.join(__dirname, 'dist', 'index.html')); }); // Start Server const PORT = process.env.PORT || 7860; // Force Hugging Face Spaces port unless set // Preload Piper into RAM disk for faster generation try { if (fs.existsSync('/dev/shm')) { const shmModelPath = '/dev/shm/de_DE-thorsten-high.onnx'; const shmConfigPath = '/dev/shm/de_DE-thorsten-high.onnx.json'; if (!fs.existsSync(shmModelPath)) { console.log('Copying Piper model to RAM disk (/dev/shm)...'); fs.copyFileSync(path.join(__dirname, 'piper_tts', 'de_DE-thorsten-high.onnx'), shmModelPath); } if (!fs.existsSync(shmConfigPath)) { fs.copyFileSync(path.join(__dirname, 'piper_tts', 'de_DE-thorsten-high.onnx.json'), shmConfigPath); } } } catch (e) { console.log('Failed to copy Piper model to RAM disk:', e.message); } app.listen(PORT, '0.0.0.0', () => { console.log(`🚀 SchoolMind AI Server is running on port ${PORT}`); });