Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,23 @@ TTS_PORT=5002
# uncomment and provide your district's API key.
# (WARNING: Doing so will route prompts outside the local network).
# GOOGLE_API_KEY=your_key_here

# ─── Container Resource Limits ─────────────────────────────────────
# Ceilings so one runaway container cannot take the whole host (CIS 16.7).
# These are safety caps, not workload sizing — raise them if a large model
# gets OOM-killed, lower them on small hardware. Values use Docker syntax
# ("2.0" CPUs, "4g"/"512m" memory).
# FRONTEND_CPUS=1.0
# FRONTEND_MEMORY=512m
# POCKETBASE_CPUS=2.0
# POCKETBASE_MEMORY=1g
# OLLAMA_CPUS=8.0
# OLLAMA_MEMORY=16g # holds model weights in RAM; raise for 30B+ models
# FLUX_CPUS=8.0
# FLUX_MEMORY=24g
# EDGE_TTS_CPUS=2.0
# EDGE_TTS_MEMORY=1g
# PIPER_CPUS=2.0
# PIPER_MEMORY=2g
# SEARXNG_CPUS=2.0
# SEARXNG_MEMORY=1g
79 changes: 62 additions & 17 deletions AlloFlowANTI.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5724,6 +5724,53 @@ function _buildGlossaryPreamble(glossary, targetLanguage) {
''
].join('\n');
}
// Language packs are untrusted: they arrive from a user-chosen file, a CDN fetch, or LLM
// translation output, and t() resolves them BEFORE the static UI_STRINGS. Some consumers
// concatenate t() output into innerHTML, so a pack can carry script into the page.
// Packs legitimately contain inline markup (<strong>, and literal "</>" / "<title>" as prose),
// so this neutralizes only executable constructs and leaves ordinary text byte-identical.
// Only elements that execute or load code are removed outright. Presentational and
// structural tags survive: the accessibility lab ships deliberately-bad HTML samples
// (<img> with no alt, <html>/<title> skeletons) as lesson content in every language.
const _I18N_EXECUTABLE_TAGS = 'script|style|iframe|object|embed|applet|frame|frameset|base|meta|link|portal';
function _sanitizeI18nString(value) {
if (value.indexOf('<') === -1) return value;
return value
// Paired executable elements, including their contents.
.replace(new RegExp('<(' + _I18N_EXECUTABLE_TAGS + ')\\b[^>]*>[\\s\\S]*?<\\/\\1\\s*>', 'gi'), '')
// Unpaired or self-closing leftovers.
.replace(new RegExp('<\\/?(' + _I18N_EXECUTABLE_TAGS + ')\\b[^>]*>', 'gi'), '')
// Scrub attributes only INSIDE tag markup, so prose like "10 ones = 1 ten" is untouched.
.replace(/<[a-zA-Z][^>]*>/g, (tag) => tag
// "/" separates attributes as validly as whitespace does: <svg/onload=...> executes.
.replace(/[\s/]on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ' ')
.replace(/((?:href|src|xlink:href|action|formaction)\s*=\s*)(?:"\s*(?:javascript|vbscript|data:text\/html)[^"]*"|'\s*(?:javascript|vbscript|data:text\/html)[^']*'|(?:javascript|vbscript|data:text\/html)[^\s>]+)/gi, '$1"#"'));
}
function sanitizeLanguagePack(pack) {
if (!pack || typeof pack !== 'object') return pack;
const seen = new WeakSet();
const walk = (node) => {
if (typeof node === 'string') return _sanitizeI18nString(node);
if (!node || typeof node !== 'object') return node;
if (seen.has(node)) return node;
seen.add(node);
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) node[i] = walk(node[i]);
return node;
}
for (const key of Object.keys(node)) {
// Never let a pack redefine __proto__/constructor through later merges.
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
delete node[key];
continue;
}
node[key] = walk(node[key]);
}
return node;
};
return walk(pack);
}

const translateChunk = async (chunkData, targetLanguage, apiKey, signal) => {
const glossary = await _loadTranslationGlossary(signal);
_translationThrowIfAborted(signal);
Expand Down Expand Up @@ -5846,7 +5893,7 @@ const useTranslation = (targetLanguage, apiKey) => {
const json = JSON.parse(e.target.result);
if (!translationRunRef.current || translationRunRef.current.generation !== importGeneration) return;
if (typeof json === 'object' && json !== null) {
setLanguagePack(json);
setLanguagePack(sanitizeLanguagePack(json));
setStatusMessage(t('language_selector.status_custom_loaded'));
setIsTranslating(false);
} else {
Expand Down Expand Up @@ -5913,7 +5960,7 @@ const useTranslation = (targetLanguage, apiKey) => {
_translationThrowIfAborted(signal);
if (cachedPack) {
debugLog(`[useTranslation] Loaded ${targetLanguage} from local device.`);
setLanguagePack(cachedPack);
setLanguagePack(sanitizeLanguagePack(cachedPack));
setIsTranslating(false);
return;
}
Expand Down Expand Up @@ -5961,21 +6008,19 @@ const useTranslation = (targetLanguage, apiKey) => {
try {
pack = JSON.parse(text);
} catch (parseErr) {
// Packs are JSON; the .js extension is historical. Never eval the response —
// a compromised pack host would get code execution in every client.
try {
pack = new Function('return ' + text)();
} catch (evalErr) {
try {
const cleaned = text.replace(/^\s*\/\/.*$/gm, '').trim();
pack = JSON.parse(cleaned);
} catch (e2) {
warnLog('Pack parse failed for ' + packUrl + ':', e2?.message);
continue;
}
const cleaned = text.replace(/^\s*\/\/.*$/gm, '').trim();
pack = JSON.parse(cleaned);
} catch (e2) {
warnLog('Pack parse failed for ' + packUrl + ':', e2?.message);
continue;
}
}
if (pack && Object.keys(pack).length > 10) {
debugLog('[useTranslation] Loaded ' + resolvedDisplay + ' from ' + packUrl);
setLanguagePack(pack);
setLanguagePack(sanitizeLanguagePack(pack));
setIsTranslating(false);
try { await setOwnedStorage(storageKey, pack); } catch(e) { if (e?.name !== 'AbortError') warnLog('Cache save error:', e?.message || e); }
loaded = true;
Expand Down Expand Up @@ -6042,7 +6087,7 @@ const useTranslation = (targetLanguage, apiKey) => {
// If nothing missing AND we have a substantive existing pack → done.
if (missingCount === 0 && resumeCount > 50) {
const finalPack = unflattenObject(resumeFromFlatPack);
setLanguagePack(finalPack);
setLanguagePack(sanitizeLanguagePack(finalPack));
setStatusMessage(t('language_selector.status_complete'));
await _translationAbortableDelay(500, signal);
setIsTranslating(false);
Expand All @@ -6053,7 +6098,7 @@ const useTranslation = (targetLanguage, apiKey) => {
setStatusMessage(t('language_selector.status_resuming', { done: resumeCount, total: expectedCount }) || ('Resuming translation (' + resumeCount + '/' + expectedCount + ')…'));
// Surface the partial pack immediately so the user sees translations
// for what's already done while the missing keys fill in.
try { setLanguagePack(unflattenObject(resumeFromFlatPack)); } catch (_) {}
try { setLanguagePack(sanitizeLanguagePack(unflattenObject(resumeFromFlatPack))); } catch (_) {}
}

const chunks = chunkObject(missingFlatStrings, 200);
Expand Down Expand Up @@ -6103,7 +6148,7 @@ const useTranslation = (targetLanguage, apiKey) => {
await setOwnedStorage(storageKey, partialPack);
// Also surface the partial pack to the UI so newly-translated
// keys start rendering as they're filled in.
setLanguagePack(partialPack);
setLanguagePack(sanitizeLanguagePack(partialPack));
} catch (e) {
if (e?.name === 'AbortError') throw e;
warnLog('Incremental save failed:', e?.message || e);
Expand All @@ -6113,7 +6158,7 @@ const useTranslation = (targetLanguage, apiKey) => {
}
const accumulatedPack = unflattenObject(accumulatedFlatPack);
if (Object.keys(accumulatedPack).length > 50) {
setLanguagePack(accumulatedPack);
setLanguagePack(sanitizeLanguagePack(accumulatedPack));
setStatusMessage(t('language_selector.status_complete'));
try { await setOwnedStorage(storageKey, accumulatedPack); } catch(e) {
if (e?.name === 'AbortError') throw e;
Expand Down Expand Up @@ -11101,7 +11146,7 @@ const handleGetMathHint = async (resourceId, problemIdx, question, correctAnswer
loadModule('KeyConceptMapModule', 'https://alloflow-cdn.pages.dev/key_concept_map_module.js?v=dc470b857');
loadModule('UtilsPure', 'https://alloflow-cdn.pages.dev/utils_pure_module.js?v=dc470b857');
loadModule('GeminiAPI', 'https://alloflow-cdn.pages.dev/gemini_api_module.js?v=dc470b857');
loadModule('TTS', 'https://alloflow-cdn.pages.dev/tts_module.js?v=69b2ba76');
loadModule('TTS', 'https://alloflow-cdn.pages.dev/tts_module.js?v=6f2c4906');
loadModule('Personas', 'https://alloflow-cdn.pages.dev/personas_module.js?v=0e96a73e');
loadModule('Export', 'https://alloflow-cdn.pages.dev/export_module.js?v=4ced3dc7');
loadModule('MiscComponents', 'https://alloflow-cdn.pages.dev/misc_components_module.js?v=dc470b857');
Expand Down
43 changes: 24 additions & 19 deletions ai_backend_module.js
Original file line number Diff line number Diff line change
Expand Up @@ -1405,11 +1405,20 @@ class AIProvider {
}
}

// Gemini accepts the key as either ?key= or the x-goog-api-key header. Use the
// header: query strings land in browser history, proxy and server access logs,
// and Referer headers, so a URL-borne key leaks to places the request body never
// reaches. Callers must not add the key back into the URL.
_geminiHeaders(base = {}) {
const headers = { 'Content-Type': 'application/json', ...base };
if (this.apiKey) headers['x-goog-api-key'] = this.apiKey;
return headers;
}

async _geminiGenerateText(prompt, { json, search, temperature, maxTokens, signal }) {
const buildUrl = (model) => {
this._debugLog(`[AIProvider] ✉ Using model: ${model}`);
const keyParam = this.apiKey ? `?key=${this.apiKey}` : '';
return `${this.baseUrl}/models/${model}:generateContent${keyParam}`;
return `${this.baseUrl}/models/${model}:generateContent`;
};

const payload = {
Expand All @@ -1433,7 +1442,7 @@ class AIProvider {

const fetchOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: this._geminiHeaders(),
body: JSON.stringify(payload),
signal,
};
Expand Down Expand Up @@ -1901,8 +1910,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr
}

async _geminiGenerateImage(prompt, width, quality) {
const keyParam = this.apiKey ? `?key=${this.apiKey}` : '';
const url = `${this.baseUrl}/models/${this.models.imagen}:predict${keyParam}`;
const url = `${this.baseUrl}/models/${this.models.imagen}:predict`;
const payload = {
instances: [{ prompt }],
parameters: { sampleCount: 1 },
Expand All @@ -1911,7 +1919,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr
const executeRequest = async () => {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: this._geminiHeaders(),
body: JSON.stringify(payload),
});

Expand Down Expand Up @@ -2061,8 +2069,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr
}

async _geminiEditImage(prompt, base64Image, width, quality, referenceBase64) {
const keyParam = this.apiKey ? `?key=${this.apiKey}` : '';
const url = `${this.baseUrl}/models/${this.models.image}:generateContent${keyParam}`;
const url = `${this.baseUrl}/models/${this.models.image}:generateContent`;

const parts = [
{ text: prompt },
Expand All @@ -2081,7 +2088,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: this._geminiHeaders(),
body: JSON.stringify(payload),
});
const data = await response.json();
Expand Down Expand Up @@ -2183,8 +2190,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr
}

async _geminiAnalyzeImage(prompt, base64Data, mimeType) {
const keyParam = this.apiKey ? `?key=${this.apiKey}` : '';
const url = `${this.baseUrl}/models/${this.models.vision}:generateContent${keyParam}`;
const url = `${this.baseUrl}/models/${this.models.vision}:generateContent`;

const payload = {
contents: [{
Expand All @@ -2197,7 +2203,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr

const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: this._geminiHeaders(),
body: JSON.stringify(payload),
});
const data = await response.json();
Expand Down Expand Up @@ -2280,10 +2286,9 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr
}

async _geminiAnalyzeAudio(prompt, base64Data, mimeType) {
const keyParam = this.apiKey ? `?key=${this.apiKey}` : '';
// Use the vision/multimodal model — Gemini's flash/pro vision
// models accept audio in the same payload shape.
const url = `${this.baseUrl}/models/${this.models.vision}:generateContent${keyParam}`;
const url = `${this.baseUrl}/models/${this.models.vision}:generateContent`;

// Strip any data:audio/...;base64, prefix the caller may have left
// on the string (recordAudioBlob returns a full data URI).
Expand All @@ -2305,7 +2310,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr

const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: this._geminiHeaders(),
body: JSON.stringify(payload),
});
const data = await response.json();
Expand Down Expand Up @@ -2391,8 +2396,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr

// Queue for serialization
const task = this._ttsQueue.then(async () => {
const keyParam = this.apiKey ? `?key=${this.apiKey}` : '';
const url = `${this.baseUrl}/models/${this.models.tts}:generateContent${keyParam}`;
const url = `${this.baseUrl}/models/${this.models.tts}:generateContent`;

const payload = {
contents: [{ parts: [{ text }] }],
Expand All @@ -2410,7 +2414,7 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: this._geminiHeaders(),
body: JSON.stringify(payload),
});

Expand Down Expand Up @@ -2540,7 +2544,8 @@ TASK: Fix the syntax errors (missing commas, unclosed braces, escaped quotes, tr
url = `${this.baseUrl}/api/tags`;
break;
case 'gemini':
url = `${this.baseUrl}/models?key=${this.apiKey}`;
url = `${this.baseUrl}/models`;
if (this.apiKey) headers['x-goog-api-key'] = this.apiKey;
break;
case 'claude':
return [
Expand Down
3 changes: 2 additions & 1 deletion desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -937,7 +937,8 @@ async function ensureAdminServerPosture() {
let config = runtime.readConfig();
const lan = (config.liveSession && config.liveSession.lan) || {};
if (!String(lan.pin || '').trim()) {
const pin = String(Math.floor(100000 + Math.random() * 900000));
// This PIN gates LAN access to the live session — Math.random() is not a CSPRNG.
const pin = String(100000 + crypto.randomInt(900000));
config = runtime.writeConfig({
...config,
liveSession: { ...config.liveSession, lan: { ...lan, pin } },
Expand Down
14 changes: 12 additions & 2 deletions desktop/mcp/build_mcpb.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { execSync, spawnSync } = require('child_process');

const MCP_DIR = __dirname;
const REPO_ROOT = path.resolve(MCP_DIR, '..', '..');
Expand Down Expand Up @@ -191,7 +191,17 @@ function main() {
}
const zipTmp = BUNDLE + '.zip';
fs.rmSync(zipTmp, { force: true });
execSync('powershell -NoProfile -Command "Compress-Archive -Path \'' + STAGING + '\\*\' -DestinationPath \'' + zipTmp + '\' -Force"', { stdio: ['ignore', 'inherit', 'inherit'] });
// Pass the paths as arguments rather than splicing them into a command string:
// a build path containing a quote or ';' would otherwise run as PowerShell.
const zip = spawnSync('powershell', [
'-NoProfile', '-NonInteractive', '-Command',
// -Path (not -LiteralPath) so the trailing \* still globs the staging contents.
'Compress-Archive -Path $env:ALLO_SRC -DestinationPath $env:ALLO_DEST -Force',
], {
stdio: ['ignore', 'inherit', 'inherit'],
env: { ...process.env, ALLO_SRC: path.join(STAGING, '*'), ALLO_DEST: zipTmp },
});
if (zip.status !== 0) throw new Error('Compress-Archive failed with status ' + zip.status);
fs.renameSync(zipTmp, BUNDLE);
}
const mb = (fs.statSync(BUNDLE).size / 1024 / 1024).toFixed(1);
Expand Down
8 changes: 7 additions & 1 deletion desktop/runtime/alloflow-desktop-runtime.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2540,6 +2540,12 @@ function prepareSchoolBoxEnv(config) {
created = true;
}

// The env file holds local service settings and credentials, so keep it owner-only.
// writeFileSync's `mode` is ignored when the file already exists and copyFileSync
// inherits the example's permissions, so set it explicitly here — this also tightens
// files created before this was enforced. No-op on Windows ACLs.
try { fs.chmodSync(paths.envFile, 0o600); } catch (_) {}

let lines = fs.readFileSync(paths.envFile, 'utf8').split(/\r?\n/);
const values = readEnvValues(paths.envFile);
const pbPort = sanitizePort(values.PB_PORT, SCHOOLBOX_DEFAULT_PORTS.PB_PORT);
Expand All @@ -2563,7 +2569,7 @@ function prepareSchoolBoxEnv(config) {
}

if (changed) {
fs.writeFileSync(paths.envFile, lines.join('\n').replace(/\n*$/, '\n'), 'utf8');
fs.writeFileSync(paths.envFile, lines.join('\n').replace(/\n*$/, '\n'), { encoding: 'utf8', mode: 0o600 });
}

appendSchoolBoxLog(`${created ? 'Created' : 'Prepared'} School Box environment at ${paths.envFile}`);
Expand Down
20 changes: 20 additions & 0 deletions desktop/schoolbox/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,23 @@ SEARCH_PORT=8888
FLUX_PORT=7860
REACT_APP_DATA_BACKEND=auto
REACT_APP_POCKETBASE_URL=http://localhost:8090

# ─── Container Resource Limits ─────────────────────────────────────
# Ceilings so one runaway container cannot take the whole host (CIS 16.7).
# These are safety caps, not workload sizing — raise them if a large model
# gets OOM-killed, lower them on small hardware. Values use Docker syntax
# ("2.0" CPUs, "4g"/"512m" memory).
# FRONTEND_CPUS=1.0
# FRONTEND_MEMORY=512m
# POCKETBASE_CPUS=2.0
# POCKETBASE_MEMORY=1g
# OLLAMA_CPUS=8.0
# OLLAMA_MEMORY=16g # holds model weights in RAM; raise for 30B+ models
# FLUX_CPUS=8.0
# FLUX_MEMORY=24g
# EDGE_TTS_CPUS=2.0
# EDGE_TTS_MEMORY=1g
# PIPER_CPUS=2.0
# PIPER_MEMORY=2g
# SEARXNG_CPUS=2.0
# SEARXNG_MEMORY=1g
Loading
Loading