Primera tanda de la integracion 0.9.1 -> 0.9.5. Se actualizan los 57 ficheros que upstream cambio y la rama no tocaba, entre ellos los 11 de traduccion: eso ya solo es posible gracias al overlay de i18n, antes eran 11 conflictos garantizados. Se portan ademas las dependencias que 0.9.5 introduce y de las que dependen los ficheros actualizados: pdfDocument, content_favorites, recurrence, media_gallery, comments_view, gallery_view y polls (modelo, limites y vista). Quedan 48 ficheros con divergencia real por resolver a mano (backend.js, main_views.js, OasisMobile.css y 45 vistas y modelos). Verificado: 0 errores de sintaxis en src/, 0 requires relativos rotos, la app arranca declarando 0.9.5 y las 47 rutas principales responden 200/302.
340 lines
14 KiB
JavaScript
340 lines
14 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const { getConfig } = require('../configs/config-manager.js');
|
|
|
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
|
|
const ALLOWED_ROOTS = ['src', 'docs', 'scripts', 'test'];
|
|
const ALLOWED_ROOT_FILES = ['README.md', 'LICENSE', 'install.sh', 'oasis.sh'];
|
|
const DENIED_SEGMENTS = new Set(['node_modules', '.git', '.ssb', 'packages', 'configs', 'embeddings', 'tiles', 'cache', 'results']);
|
|
const DENIED_NAMES = new Set(['secret', '.env', '.npmrc', 'package-lock.json']);
|
|
const DENIED_EXTENSIONS = new Set(['.gguf', '.jks', '.keystore', '.enc', '.pem', '.key', '.p12', '.crt']);
|
|
const VIEWABLE_EXTENSIONS = new Set(['.js', '.mjs', '.json', '.css', '.md', '.sh', '.html', '.txt', '.yml', '.yaml']);
|
|
|
|
const STATS_TTL = 60 * 1000;
|
|
let statsCache = null;
|
|
let statsCacheAt = 0;
|
|
|
|
const MAX_VIEW_BYTES = 2 * 1024 * 1024;
|
|
const PAGE_LINES = 500;
|
|
const SEARCH_MAX = 200;
|
|
const SEARCH_SNIPPET = 200;
|
|
|
|
const extOf = (name) => {
|
|
const i = String(name || '').lastIndexOf('.');
|
|
return i > 0 ? String(name).slice(i).toLowerCase() : '';
|
|
};
|
|
|
|
const isDeniedEntry = (name) => {
|
|
const n = String(name || '');
|
|
if (!n || n === '.' || n === '..') return true;
|
|
if (n.toLowerCase().startsWith('.git')) return true;
|
|
if (DENIED_SEGMENTS.has(n)) return true;
|
|
if (DENIED_NAMES.has(n)) return true;
|
|
if (DENIED_EXTENSIONS.has(extOf(n))) return true;
|
|
return false;
|
|
};
|
|
|
|
const PLAIN_TEXT_NAME = /^[A-Z][A-Z0-9_-]*$/;
|
|
|
|
const isViewable = (name) => (VIEWABLE_EXTENSIONS.has(extOf(name)) || PLAIN_TEXT_NAME.test(String(name || ''))) && !isDeniedEntry(name);
|
|
|
|
function realRoot() {
|
|
try { return fs.realpathSync(ROOT); } catch (_) { return ROOT; }
|
|
}
|
|
|
|
function splitPath(rel) {
|
|
const raw = String(rel || '');
|
|
for (let i = 0; i < raw.length; i++) { if (raw.charCodeAt(i) < 32) throw new Error('Invalid path'); }
|
|
return raw.replace(/^[/\\]+/, '').split(/[/\\]+/).filter(s => s && s !== '.');
|
|
}
|
|
|
|
function resolveSafe(rel) {
|
|
const segments = splitPath(rel);
|
|
if (segments.some(s => s === '..')) throw new Error('Invalid path');
|
|
if (segments.some(isDeniedEntry)) throw new Error('Path not available');
|
|
if (segments.length) {
|
|
const head = segments[0];
|
|
const isRootFile = segments.length === 1 && ALLOWED_ROOT_FILES.includes(head);
|
|
if (!ALLOWED_ROOTS.includes(head) && !isRootFile) throw new Error('Path not available');
|
|
}
|
|
const target = path.resolve(ROOT, segments.join(path.sep));
|
|
let real;
|
|
try { real = fs.realpathSync(target); } catch (_) { throw new Error('Not found'); }
|
|
const base = realRoot();
|
|
if (real !== base && !real.startsWith(base + path.sep)) throw new Error('Path not available');
|
|
return { abs: real, rel: segments.join('/'), segments };
|
|
}
|
|
|
|
function parentsOf(segments) {
|
|
const out = [];
|
|
let acc = '';
|
|
for (const s of segments) {
|
|
acc = acc ? `${acc}/${s}` : s;
|
|
out.push({ name: s, path: acc });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function rootListing() {
|
|
const dirs = [];
|
|
const files = [];
|
|
for (const name of ALLOWED_ROOTS) {
|
|
const abs = path.join(ROOT, name);
|
|
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) dirs.push({ name, path: name });
|
|
}
|
|
for (const name of ALLOWED_ROOT_FILES) {
|
|
const abs = path.join(ROOT, name);
|
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
|
|
files.push({ name, path: name, size: fs.statSync(abs).size, ext: extOf(name), viewable: isViewable(name) });
|
|
}
|
|
return { path: '', parents: [], dirs, files, hidden: 0 };
|
|
}
|
|
|
|
function walkFiles(relDir, onFile) {
|
|
let entries;
|
|
try { entries = fs.readdirSync(path.resolve(ROOT, relDir), { withFileTypes: true }); } catch (_) { return true; }
|
|
for (const e of entries) {
|
|
if (isDeniedEntry(e.name)) continue;
|
|
const rel = relDir ? `${relDir}/${e.name}` : e.name;
|
|
if (e.isSymbolicLink()) continue;
|
|
if (e.isDirectory()) { if (walkFiles(rel, onFile) === false) return false; continue; }
|
|
if (!e.isFile()) continue;
|
|
if (onFile(rel, e.name) === false) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const MODULE_ALIASES = {
|
|
ai: { model: 'src/AI/ai_service.mjs', view: 'src/views/AI_view.js' },
|
|
aiNav: { model: 'src/AI/routes_index.js', view: 'src/views/AI_view.js', paths: ['/ai/ask'] },
|
|
docs: { paths: ['/documents'] },
|
|
blogs: { model: 'src/models/blog_model.js', view: 'src/views/blog_view.js', paths: ['/blogs'] },
|
|
polls: { model: 'src/models/polls_model.js', view: 'src/views/polls_view.js', paths: ['/polls'] },
|
|
data: { model: 'src/models/data_model.js', view: 'src/views/data_view.js', paths: ['/data'] },
|
|
invites: { model: 'src/models/main_models.js', view: 'src/views/invites_view.js' },
|
|
graphos: { model: 'src/models/main_models.js', view: 'src/views/graphos_view.js' }
|
|
};
|
|
|
|
const GROUPS = {
|
|
translations: ['src/client/assets/translations'],
|
|
styles: ['src/client/assets/styles', 'src/client/assets/themes'],
|
|
tests: ['test']
|
|
};
|
|
|
|
module.exports = {
|
|
ROOT,
|
|
PAGE_LINES,
|
|
GROUPS,
|
|
|
|
listGroup(key) {
|
|
const roots = GROUPS[String(key || '')];
|
|
if (!roots) throw new Error('Not found');
|
|
const dirs = [];
|
|
const files = [];
|
|
for (const rel of roots) {
|
|
let listing;
|
|
try { listing = module.exports.listTree(rel); } catch (_) { continue; }
|
|
dirs.push(...listing.dirs);
|
|
files.push(...listing.files);
|
|
}
|
|
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
return { group: String(key), path: '', parents: [], dirs, files, hidden: 0 };
|
|
},
|
|
|
|
listTree(relPath) {
|
|
const segments = splitPath(relPath);
|
|
if (!segments.length) return rootListing();
|
|
const target = resolveSafe(relPath);
|
|
const stat = fs.statSync(target.abs);
|
|
if (!stat.isDirectory()) throw new Error('Not a directory');
|
|
const entries = fs.readdirSync(target.abs, { withFileTypes: true });
|
|
const dirs = [];
|
|
const files = [];
|
|
let hidden = 0;
|
|
for (const e of entries) {
|
|
if (isDeniedEntry(e.name) || e.isSymbolicLink()) { hidden++; continue; }
|
|
const rel = `${target.rel}/${e.name}`;
|
|
if (e.isDirectory()) { dirs.push({ name: e.name, path: rel }); continue; }
|
|
if (!e.isFile()) { hidden++; continue; }
|
|
let size = 0;
|
|
try { size = fs.statSync(path.join(target.abs, e.name)).size; } catch (_) { size = 0; }
|
|
files.push({ name: e.name, path: rel, size, ext: extOf(e.name), viewable: isViewable(e.name) });
|
|
}
|
|
dirs.sort((a, b) => a.name.localeCompare(b.name));
|
|
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
return { path: target.rel, parents: parentsOf(target.segments), dirs, files, hidden };
|
|
},
|
|
|
|
readFile(relPath, opts = {}) {
|
|
const target = resolveSafe(relPath);
|
|
const stat = fs.statSync(target.abs);
|
|
if (!stat.isFile()) throw new Error('Not a file');
|
|
if (!isViewable(path.basename(target.abs))) throw new Error('This file type cannot be displayed');
|
|
if (stat.size > MAX_VIEW_BYTES) throw new Error('This file is too large to be displayed');
|
|
const all = fs.readFileSync(target.abs, 'utf8').split('\n');
|
|
const totalLines = all.length;
|
|
const requested = parseInt(opts.from, 10);
|
|
const from = Math.min(Math.max(Number.isFinite(requested) ? requested : 1, 1), Math.max(1, totalLines));
|
|
const to = Math.min(from + PAGE_LINES - 1, totalLines);
|
|
const lines = [];
|
|
for (let n = from; n <= to; n++) lines.push({ n, text: all[n - 1] });
|
|
return {
|
|
path: target.rel,
|
|
parents: parentsOf(target.segments),
|
|
name: path.basename(target.abs),
|
|
size: stat.size,
|
|
totalLines,
|
|
from,
|
|
to,
|
|
pageLines: PAGE_LINES,
|
|
hasPrev: from > 1,
|
|
hasNext: to < totalLines,
|
|
lines
|
|
};
|
|
},
|
|
|
|
rawFile(relPath) {
|
|
const target = resolveSafe(relPath);
|
|
const stat = fs.statSync(target.abs);
|
|
if (!stat.isFile()) throw new Error('Not a file');
|
|
if (!isViewable(path.basename(target.abs))) throw new Error('This file type cannot be displayed');
|
|
if (stat.size > MAX_VIEW_BYTES) throw new Error('This file is too large to be displayed');
|
|
return { name: path.basename(target.abs), content: fs.readFileSync(target.abs, 'utf8') };
|
|
},
|
|
|
|
searchCode(query, opts = {}) {
|
|
const q = String(query || '').trim();
|
|
if (q.length < 2) return { query: q, results: [], truncated: false, scanned: 0 };
|
|
const wanted = String(opts.ext || '').trim().toLowerCase();
|
|
const max = Math.min(Math.max(parseInt(opts.max, 10) || SEARCH_MAX, 1), SEARCH_MAX);
|
|
const needle = q.toLowerCase();
|
|
const results = [];
|
|
let scanned = 0;
|
|
let truncated = false;
|
|
for (const root of ALLOWED_ROOTS) {
|
|
if (truncated) break;
|
|
walkFiles(root, (rel, name) => {
|
|
if (!isViewable(name)) return true;
|
|
if (wanted && extOf(name) !== wanted) return true;
|
|
let stat;
|
|
try { stat = fs.statSync(path.resolve(ROOT, rel)); } catch (_) { return true; }
|
|
if (stat.size > MAX_VIEW_BYTES) return true;
|
|
scanned++;
|
|
let content;
|
|
try { content = fs.readFileSync(path.resolve(ROOT, rel), 'utf8'); } catch (_) { return true; }
|
|
if (content.toLowerCase().indexOf(needle) < 0) return true;
|
|
const lines = content.split('\n');
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i].toLowerCase().indexOf(needle) < 0) continue;
|
|
results.push({ path: rel, line: i + 1, text: lines[i].trim().slice(0, SEARCH_SNIPPET) });
|
|
if (results.length >= max) { truncated = true; return false; }
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
return { query: q, results, truncated, scanned };
|
|
},
|
|
|
|
moduleMap() {
|
|
const cfg = getConfig();
|
|
const mods = Object.keys((cfg && cfg.modules) || {}).map(k => k.replace(/Mod$/, '')).sort();
|
|
let backend = '';
|
|
try { backend = fs.readFileSync(path.join(ROOT, 'src', 'backend', 'backend.js'), 'utf8'); } catch (_) { backend = ''; }
|
|
const modelFiles = fs.existsSync(path.join(ROOT, 'src', 'models')) ? fs.readdirSync(path.join(ROOT, 'src', 'models')) : [];
|
|
const viewFiles = fs.existsSync(path.join(ROOT, 'src', 'views')) ? fs.readdirSync(path.join(ROOT, 'src', 'views')) : [];
|
|
const stem = (s) => String(s || '').toLowerCase().replace(/s$/, '');
|
|
const pick = (files, name, suffixes) => {
|
|
for (const suf of suffixes) {
|
|
const exact = files.find(f => f.toLowerCase() === `${name.toLowerCase()}${suf}`);
|
|
if (exact) return exact;
|
|
}
|
|
const singular = name.replace(/s$/, '');
|
|
for (const suf of suffixes) {
|
|
const short = files.find(f => f.toLowerCase() === `${singular.toLowerCase()}${suf}`);
|
|
if (short) return short;
|
|
}
|
|
const target = stem(name);
|
|
return files.find(f => {
|
|
const suf = suffixes.find(x => f.toLowerCase().endsWith(x));
|
|
if (!suf) return false;
|
|
const base = stem(f.slice(0, f.length - suf.length));
|
|
return base.startsWith(target) || target.startsWith(base);
|
|
}) || null;
|
|
};
|
|
const testsIndex = new Map();
|
|
const modsDir = path.join(ROOT, 'test', 'mods');
|
|
const indexTests = (dir, prefix) => {
|
|
let entries;
|
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
|
|
for (const e of entries) {
|
|
if (!e.isDirectory()) continue;
|
|
const rel = `${prefix}/${e.name}`;
|
|
if (!testsIndex.has(e.name)) testsIndex.set(e.name, rel);
|
|
indexTests(path.join(dir, e.name), rel);
|
|
}
|
|
};
|
|
indexTests(modsDir, 'test/mods');
|
|
return mods.map(name => {
|
|
const alias = MODULE_ALIASES[name] || {};
|
|
const routePaths = Array.isArray(alias.paths) && alias.paths.length ? alias.paths : [`/${name}`];
|
|
let routes = 0;
|
|
for (const routePath of routePaths) {
|
|
const escaped = routePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const re = new RegExp(`\\.(get|post)\\(\\s*["'\`]${escaped}(?=["'/?])`, 'g');
|
|
routes += backend ? (backend.match(re) || []).length : 0;
|
|
}
|
|
const model = alias.model !== undefined ? alias.model : (pick(modelFiles, name, ['_model.js', '_models.js']) ? `src/models/${pick(modelFiles, name, ['_model.js', '_models.js'])}` : null);
|
|
const view = alias.view !== undefined ? alias.view : (pick(viewFiles, name, ['_view.js', '_views.js']) ? `src/views/${pick(viewFiles, name, ['_view.js', '_views.js'])}` : null);
|
|
return {
|
|
name,
|
|
enabled: (cfg.modules || {})[`${name}Mod`] !== 'off',
|
|
model,
|
|
view,
|
|
routes,
|
|
tests: testsIndex.get(name) || testsIndex.get(name.replace(/s$/, '')) || (() => {
|
|
const target = stem(name);
|
|
for (const [dir, rel] of testsIndex) {
|
|
const base = stem(dir);
|
|
if (base.startsWith(target) || target.startsWith(base)) return rel;
|
|
}
|
|
return null;
|
|
})()
|
|
};
|
|
});
|
|
},
|
|
|
|
projectStats() {
|
|
const now = Date.now();
|
|
if (statsCache && now - statsCacheAt < STATS_TTL) return statsCache;
|
|
let files = 0;
|
|
let lines = 0;
|
|
let bytes = 0;
|
|
for (const root of ALLOWED_ROOTS) {
|
|
walkFiles(root, (rel, name) => {
|
|
if (!isViewable(name)) return true;
|
|
try {
|
|
const abs = path.resolve(ROOT, rel);
|
|
const stat = fs.statSync(abs);
|
|
if (stat.size > MAX_VIEW_BYTES) return true;
|
|
files++;
|
|
bytes += stat.size;
|
|
lines += fs.readFileSync(abs, 'utf8').split('\n').length;
|
|
} catch (_) {}
|
|
return true;
|
|
});
|
|
}
|
|
const cfg = getConfig();
|
|
const modules = Object.keys((cfg && cfg.modules) || {}).length;
|
|
const translations = path.join(ROOT, 'src', 'client', 'assets', 'translations');
|
|
const languages = fs.existsSync(translations) ? fs.readdirSync(translations).filter(f => /^oasis_[a-z]{2}\.js$/.test(f)).length : 0;
|
|
const testsDir = path.join(ROOT, 'test', 'mods');
|
|
const testMods = fs.existsSync(testsDir) ? fs.readdirSync(testsDir).length : 0;
|
|
let version = '';
|
|
try { version = String(JSON.parse(fs.readFileSync(path.join(ROOT, 'src', 'server', 'package.json'), 'utf8')).version || ''); } catch (_) { version = ''; }
|
|
statsCache = { files, lines, bytes, modules, languages, testMods, version, node: process.version };
|
|
statsCacheAt = now;
|
|
return statsCache;
|
|
}
|
|
};
|