Spaces:
Running
Running
Download server.js from Luca448/APP-Backend: direct link, hf CLI and curl.
- Browser
- Download file 34.3 kB
-
https://huggingface.co/spaces/Luca448/APP-Backend/resolve/main/server.js
- Command line
-
hf download hf://spaces/Luca448/APP-Backend/server.js
-
curl -L -o server.js https://huggingface.co/spaces/Luca448/APP-Backend/resolve/main/server.js
34.3 kB
| 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 (<searchweb>...</searchweb>) | |
| // 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}`); | |
| }); | |