1373 lines
53 KiB
JavaScript
Executable File
1373 lines
53 KiB
JavaScript
Executable File
const fs = require("fs-extra");
|
|
const path = require("path");
|
|
const crypto = require("crypto");
|
|
const { execSync } = require("child_process");
|
|
const { minify: minifyHTML } = require("html-minifier-terser");
|
|
const { minify: minifyJS } = require("terser");
|
|
const { glob } = require("glob");
|
|
const Odmdb = require("./Odmdb.js");
|
|
const logger = require("../utils/logger.js");
|
|
const { getWebappSource, listWebappNames } = require("../utils/webapps.js");
|
|
|
|
const DATA_PATH = process.env.DATAPATH;
|
|
const NODE_PATH = process.env.NODEPATH;
|
|
|
|
class Wwws {
|
|
|
|
static async _setupCdnFiles(tribe) {
|
|
const linkConfigPath = path.join(DATA_PATH, tribe, 'objects', 'wwws', 'cdn', 'lib', 'libsource.json');
|
|
|
|
try {
|
|
if (await fs.pathExists(linkConfigPath)) {
|
|
logger.debug(`Found CDN library config at ${linkConfigPath}`);
|
|
const links = await fs.readJson(linkConfigPath);
|
|
|
|
if (!Array.isArray(links)) {
|
|
logger.warn(`libsource.json is not an array. Skipping CDN setup.`);
|
|
return;
|
|
}
|
|
|
|
const projectRoot = path.join(NODE_PATH, 'apxtri');
|
|
const cdnLibPath = path.join(DATA_PATH, tribe, 'objects', 'wwws', 'cdn', 'lib');
|
|
|
|
for (const link of links) {
|
|
if (link.pathsrc && link.pathdest) {
|
|
const sourcePath = path.join(projectRoot, link.pathsrc);
|
|
const destPath = path.join(cdnLibPath, link.pathdest);
|
|
|
|
if (await fs.pathExists(sourcePath)) {
|
|
await fs.remove(destPath);
|
|
await fs.copy(sourcePath, destPath);
|
|
logger.debug(`Copied CDN asset: ${destPath} <- ${sourcePath}`);
|
|
} else {
|
|
logger.warn(`Source path for CDN asset does not exist, skipping: ${sourcePath}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed during CDN file setup for tribe ${tribe}:`, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get cache file path for localdb (versioned filename)
|
|
*/
|
|
static _getCachePath(tribe, appname, subappName, coreVersion, subappVersion, lg) {
|
|
const tmpDir = path.join(DATA_PATH, tribe, 'tmp', 'localdb');
|
|
return path.join(tmpDir, `core${coreVersion}-${subappName}${subappVersion}_${lg}.json`);
|
|
}
|
|
|
|
/**
|
|
* Read a single option document of a tribe (e.g. 'profil', 'conditiontojoin').
|
|
* Resolves the same on-disk layout as the localdb loader: {DATAPATH}/{tribe}/
|
|
* objects/options/{optionid}.json (or itm/, or the _lg language variant).
|
|
* @param {string} tribe - Tribe identifier
|
|
* @param {string} optionid - Option identifier (optionid)
|
|
* @param {string} [lg] - Language code (en/fr/...), default en
|
|
* @returns {Promise<object>} {status, ref, msg, data}
|
|
*/
|
|
static async readOption(tribe, optionid, lg) {
|
|
const name = String(optionid || "").replace(/[^a-zA-Z0-9_-]/g, "");
|
|
if (!tribe || !name) {
|
|
return { status: 400, ref: "Wwws", msg: "option_invalid_params", data: { tribe, optionid } };
|
|
}
|
|
const lgCode = String(lg || "en").replace(/[^a-zA-Z-]/g, "").slice(0, 5);
|
|
const dir = path.join(DATA_PATH, tribe, "objects", "options");
|
|
const tryPaths = [
|
|
path.join(dir, `${name}.json`),
|
|
path.join(dir, "itm", `${name}.json`),
|
|
path.join(dir, `${name}_${lgCode}.json`),
|
|
path.join(dir, "itm", `${name}_${lgCode}.json`),
|
|
path.join(dir, `${name}_en.json`),
|
|
path.join(dir, "itm", `${name}_en.json`),
|
|
];
|
|
for (const p of tryPaths) {
|
|
if (await fs.pathExists(p)) {
|
|
try {
|
|
return { status: 200, ref: "Wwws", msg: "option_found", data: await fs.readJson(p) };
|
|
} catch (e) {
|
|
return { status: 500, ref: "Wwws", msg: "option_read_failed", data: { tribe, optionid, error: e.message } };
|
|
}
|
|
}
|
|
}
|
|
return { status: 404, ref: "Wwws", msg: "option_not_found", data: { tribe, optionid } };
|
|
}
|
|
|
|
/**
|
|
* Delete old cache files for a subapp (cleanup)
|
|
*/
|
|
static async _cleanupOldCache(tribe, appname, subappName, keepCoreVer, keepSubappVer, lg) {
|
|
const tmpDir = path.join(DATA_PATH, tribe, 'tmp', 'localdb');
|
|
if (!await fs.pathExists(tmpDir)) return;
|
|
const files = await fs.readdir(tmpDir);
|
|
const prefix = `${appname}-`;
|
|
for (const file of files) {
|
|
if (!file.endsWith(`_${lg}.json`)) continue;
|
|
// Match: core{ver}-{subapp}{ver}_{lg}.json
|
|
const match = file.match(/^core(\d+)-(\w+)(\d+)_/);
|
|
if (!match) continue;
|
|
const [, fileCoreVer, fileSubapp, fileSubVer] = match;
|
|
if (fileSubapp === subappName && (fileCoreVer !== String(keepCoreVer) || fileSubVer !== String(keepSubappVer))) {
|
|
await fs.remove(path.join(tmpDir, file));
|
|
logger.debug(`Cleaned old cache: ${file}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save versioned cache
|
|
*/
|
|
static async _saveCache(tribe, appname, subappName, coreVersion, subappVersion, lg, data) {
|
|
const cachePath = this._getCachePath(tribe, appname, subappName, coreVersion, subappVersion, lg);
|
|
await fs.ensureDir(path.dirname(cachePath));
|
|
await fs.writeJson(cachePath, { ts: Date.now(), data });
|
|
logger.debug(`Saved localdb cache: ${cachePath}`);
|
|
// Cleanup old versions
|
|
await this._cleanupOldCache(tribe, appname, subappName, coreVersion, subappVersion, lg);
|
|
}
|
|
|
|
/**
|
|
* Build local data object from core + subapp config
|
|
*/
|
|
static async _buildLocalData(tribe, appname, subappName, conf, lg) {
|
|
const coreConf = conf.core || {};
|
|
const subappConf = conf.subapp?.[subappName];
|
|
|
|
const localData = {
|
|
coreVersion: coreConf.version || 0,
|
|
subappVersion: subappConf?.version || 0,
|
|
town: process.env.TOWN,
|
|
nation: process.env.NATION,
|
|
headers: { xlang: lg },
|
|
core: { schema: {}, options: {}, ref: {} },
|
|
subapp: { schema: {}, options: {}, ref: {} }
|
|
};
|
|
|
|
const promises = [];
|
|
|
|
const TOWN = process.env.TOWN || 'apxtri';
|
|
const loadResource = (collection, section, key, basePath, rootPath, extension) => {
|
|
promises.push((async () => {
|
|
const dir = path.dirname(basePath.replace(/\$town\b/g, TOWN));
|
|
const name = path.basename(basePath);
|
|
const tryPaths = [
|
|
path.join(rootPath, dir, name),
|
|
path.join(rootPath, dir, 'itm', name),
|
|
path.join(rootPath, dir, name + `_${lg}`),
|
|
path.join(rootPath, dir, 'itm', name + `_${lg}`),
|
|
];
|
|
let fullPath = null;
|
|
for (const p of tryPaths) {
|
|
const withExt = extension ? p + extension : p;
|
|
if (await fs.pathExists(withExt)) { fullPath = withExt; break; }
|
|
}
|
|
try {
|
|
if (fullPath) {
|
|
logger.debug(`Loading ${section} resource: ${fullPath}`);
|
|
localData[section][collection][key] = extension === '.json'
|
|
? await fs.readJson(fullPath)
|
|
: await fs.readFile(fullPath, 'utf-8');
|
|
} else {
|
|
logger.warn(`Resource not found: ${path.join(rootPath, dir, name)} (tried itm/ and _${lg} variants)`);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to load resource ${fullPath}: ${error.message}`);
|
|
}
|
|
})());
|
|
};
|
|
|
|
const loadSection = (section, config) => {
|
|
if (!config) return;
|
|
if (config.schema) {
|
|
for (const schemaPath of config.schema) {
|
|
promises.push((async () => {
|
|
const objname = path.basename(schemaPath, '.json');
|
|
const odmdb = new Odmdb(objname, tribe);
|
|
if (await odmdb._loadSchema()) {
|
|
localData[section].schema[objname] = odmdb.schema;
|
|
}
|
|
})());
|
|
}
|
|
}
|
|
if (config.options) {
|
|
for (const key in config.options) {
|
|
loadResource('options', section, key, config.options[key], DATA_PATH, '.json');
|
|
}
|
|
}
|
|
if (config.ref) {
|
|
for (const key in config.ref) {
|
|
loadResource('ref', section, key, config.ref[key], NODE_PATH, '.json');
|
|
}
|
|
}
|
|
if (config.itms) {
|
|
for (const key in config.itms) {
|
|
loadResource('itms', section, key, config.itms[key], DATA_PATH, '.json');
|
|
}
|
|
}
|
|
if (config.tpldata) {
|
|
for (const key in config.tpldata) {
|
|
loadResource('tpldata', section, key, config.tpldata[key], DATA_PATH, '.json');
|
|
}
|
|
}
|
|
if (config.screens) {
|
|
for (const key in config.screens) {
|
|
loadResource('screens', section, key, config.screens[key], DATA_PATH, '.json');
|
|
}
|
|
}
|
|
if (config.tpl) {
|
|
for (const key in config.tpl) {
|
|
loadResource('tpl', section, key, config.tpl[key], DATA_PATH, '.mustache');
|
|
}
|
|
}
|
|
};
|
|
|
|
loadSection('core', coreConf);
|
|
loadSection('subapp', subappConf);
|
|
|
|
await Promise.all(promises);
|
|
return localData;
|
|
}
|
|
|
|
/**
|
|
* Collect all referenced file paths from core + subapp config
|
|
*/
|
|
static _getReferencedFiles(tribe, appname, conf) {
|
|
const files = [];
|
|
const add = (basePath, rootPath) => {
|
|
const full = path.join(rootPath, basePath);
|
|
files.push(full);
|
|
const lgVariants = ['en', 'fr', 'de', 'it', 'es', 'nl'];
|
|
for (const lg of lgVariants) {
|
|
const ext = path.extname(full);
|
|
const base = full.slice(0, -ext.length);
|
|
files.push(`${base}_${lg}${ext}`);
|
|
}
|
|
};
|
|
|
|
const walk = (section) => {
|
|
if (!section) return;
|
|
if (section.schema) {
|
|
for (const s of section.schema) {
|
|
const objname = path.basename(s, '.json');
|
|
// Simulate Odmdb schema resolution
|
|
const possible = [
|
|
path.join(NODE_PATH, tribe, 'schema', `${objname}.json`),
|
|
path.join(NODE_PATH, 'apxtri', 'schema', `${objname}.json`),
|
|
path.join(NODE_PATH, `${objname}.json`),
|
|
path.join(process.env.NODEPATH || NODE_PATH, 'schema', `${objname}.json`),
|
|
];
|
|
for (const p of possible) files.push(p);
|
|
}
|
|
}
|
|
if (section.options) for (const k in section.options) add(section.options[k], DATA_PATH);
|
|
if (section.ref) for (const k in section.ref) add(section.ref[k], NODE_PATH);
|
|
if (section.itms) for (const k in section.itms) add(section.itms[k], DATA_PATH);
|
|
if (section.tpldata) for (const k in section.tpldata) add(section.tpldata[k], DATA_PATH);
|
|
if (section.screens) for (const k in section.screens) add(section.screens[k], DATA_PATH);
|
|
if (section.tpl) for (const k in section.tpl) add(section.tpl[k], DATA_PATH);
|
|
};
|
|
|
|
const appconfPath = path.join(getWebappSource(tribe, appname), 'appconf.json');
|
|
files.push(appconfPath);
|
|
|
|
walk(conf.core);
|
|
if (conf.subapp) for (const name in conf.subapp) walk(conf.subapp[name]);
|
|
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* Detect file changes by comparing mtime snapshots.
|
|
* Returns { changed: boolean, section: 'core'|'subapp.{name}'|null }
|
|
*/
|
|
static async detectChanges(tribe, appname) {
|
|
const statePath = path.join(DATA_PATH, tribe, 'tmp', 'localdb', 'file_versions.json');
|
|
const oldState = await fs.readJson(statePath).catch(() => ({}));
|
|
const newState = {};
|
|
let anythingChanged = false;
|
|
|
|
const codeConfPath = path.join(getWebappSource(tribe, appname), 'appconf.json');
|
|
let conf;
|
|
try { conf = await fs.readJson(codeConfPath); } catch { return { changed: false }; }
|
|
|
|
const files = this._getReferencedFiles(tribe, appname, conf);
|
|
|
|
for (const f of files) {
|
|
try {
|
|
const stat = await fs.stat(f);
|
|
newState[f] = stat.mtimeMs;
|
|
} catch { /* file may not exist */ }
|
|
}
|
|
|
|
for (const f in newState) {
|
|
if (oldState[f] !== newState[f]) { anythingChanged = true; break; }
|
|
}
|
|
// Also check for deleted files
|
|
for (const f in oldState) {
|
|
if (!(f in newState)) { anythingChanged = true; break; }
|
|
}
|
|
|
|
await fs.ensureDir(path.dirname(statePath));
|
|
await fs.writeJson(statePath, newState);
|
|
|
|
return { changed: anythingChanged };
|
|
}
|
|
|
|
/**
|
|
* Increment version in appconf.json (core or subapp.{name})
|
|
*/
|
|
static async incrementVersion(tribe, appname, section) {
|
|
const codeConfPath = path.join(getWebappSource(tribe, appname), 'appconf.json');
|
|
if (!await fs.pathExists(codeConfPath)) return null;
|
|
|
|
try {
|
|
const conf = await fs.readJson(codeConfPath);
|
|
|
|
if (section === 'core') {
|
|
conf.core = conf.core || {};
|
|
conf.core.version = (conf.core.version || 0) + 1;
|
|
} else if (section && conf.subapp?.[section]) {
|
|
conf.subapp[section].version = (conf.subapp[section].version || 0) + 1;
|
|
}
|
|
|
|
await fs.writeJson(codeConfPath, conf, { spaces: 2 });
|
|
const newVer = section === 'core' ? conf.core.version : conf.subapp?.[section]?.version;
|
|
logger.info(`Version incremented: ${appname}/${section} → v${newVer}`);
|
|
return newVer;
|
|
} catch (e) {
|
|
logger.error(`Failed to increment version: ${e.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static async initlocaldata(tribe, appname, subappName, coreVersion, subappVersion, profils, lg, xmode) {
|
|
await this._setupCdnFiles(tribe);
|
|
|
|
logger.debug(`initlocaldata: tribe=${tribe} app=${appname} subapp=${subappName} coreVer=${coreVersion} subappVer=${subappVersion} mode=${xmode||'prod'}`);
|
|
|
|
// Read appconf.json
|
|
const codeConfPath = path.join(getWebappSource(tribe, appname), 'appconf.json');
|
|
if (!await fs.pathExists(codeConfPath)) {
|
|
return { status: 404, ref: "Wwws", msg: "app_config_not_found" };
|
|
}
|
|
|
|
let conf;
|
|
try {
|
|
conf = await fs.readJson(codeConfPath);
|
|
} catch (e) {
|
|
return { status: 500, ref: "Wwws", msg: "app_config_invalid" };
|
|
}
|
|
|
|
const coreConf = conf.core || {};
|
|
const subappConf = conf.subapp?.[subappName];
|
|
|
|
if (!subappConf) {
|
|
logger.warn(`Subapp config not found: ${subappName} in app: ${appname}`);
|
|
return { status: 404, ref: "Wwws", msg: "page_config_not_found" };
|
|
}
|
|
|
|
// DEV MODE: always rebuild, ignore cache and version check
|
|
if (xmode === 'dev') {
|
|
logger.debug(`Dev mode: rebuilding ${subappName} from source`);
|
|
const localData = await this._buildLocalData(tribe, appname, subappName, conf, lg);
|
|
return { status: 200, ref: "Wwws", msg: "data_model_update", data: localData };
|
|
}
|
|
|
|
// Check profile access BEFORE the version short-circuit: a client already
|
|
// holding the current versions must still be denied when its profile does
|
|
// not match the app/subapp gate, otherwise "no_new_data_model" would leak
|
|
// access to an unauthorized page.
|
|
const allowedProfils = [...(coreConf.profils || []), ...(subappConf.profils || [])];
|
|
if (allowedProfils.length > 0 && !profils.some(p => allowedProfils.includes(p))) {
|
|
return { status: 403, ref: "Wwws", msg: "permission_denied_for_page" };
|
|
}
|
|
|
|
// Check if versions match
|
|
if (coreConf.version == coreVersion && subappConf.version == subappVersion) {
|
|
logger.debug(`Versions up to date for ${subappName}: core=${coreVersion} subapp=${subappVersion}`);
|
|
return { status: 200, ref: "Wwws", msg: "no_new_data_model" };
|
|
}
|
|
|
|
// Check versioned cache file
|
|
const cachePath = this._getCachePath(tribe, appname, subappName, coreConf.version, subappConf.version, lg);
|
|
if (await fs.pathExists(cachePath)) {
|
|
try {
|
|
const cached = await fs.readJson(cachePath);
|
|
logger.debug(`Cache hit: ${cachePath}`);
|
|
return { status: 200, ref: "Wwws", msg: "data_model_update", data: cached.data };
|
|
} catch (e) {
|
|
logger.warn(`Cache read failed, rebuilding: ${cachePath}`);
|
|
}
|
|
}
|
|
|
|
// Build data
|
|
const localData = await this._buildLocalData(tribe, appname, subappName, conf, lg);
|
|
|
|
// Save cache
|
|
await this._saveCache(tribe, appname, subappName, coreConf.version, subappConf.version, lg, localData);
|
|
|
|
logger.info(`LocalDB data built for ${subappName}: core=${coreConf.version} subapp=${subappConf.version}`);
|
|
return { status: 200, ref: "Wwws", msg: "data_model_update", data: localData };
|
|
}
|
|
|
|
/**
|
|
* Cleanup old tmp files in localdb directory
|
|
*/
|
|
static async cleanupOldCaches(tribe) {
|
|
const tmpDir = path.join(DATA_PATH, tribe, 'tmp', 'localdb');
|
|
if (!await fs.pathExists(tmpDir)) return;
|
|
const files = await fs.readdir(tmpDir);
|
|
const now = Date.now();
|
|
let removed = 0;
|
|
for (const file of files) {
|
|
if (file === 'file_versions.json') continue;
|
|
const fpath = path.join(tmpDir, file);
|
|
try {
|
|
const stat = await fs.stat(fpath);
|
|
if (now - stat.mtimeMs > 7 * 24 * 60 * 60 * 1000) {
|
|
await fs.remove(fpath);
|
|
removed++;
|
|
}
|
|
} catch {}
|
|
}
|
|
if (removed > 0) logger.info(`Cleaned ${removed} old localdb cache(s) for tribe ${tribe}`);
|
|
return removed;
|
|
}
|
|
static async createSubapp(tribe, app, subapp) {
|
|
const np = process.env.NODEPATH;
|
|
|
|
if (!/^[a-z0-9-]+$/.test(subapp)) {
|
|
return { status: 400, ref: "Wwws", msg: "invalid_subapp_name", data: { name: subapp } };
|
|
}
|
|
|
|
const srcPath = path.join(getWebappSource(tribe, app), subapp);
|
|
|
|
if (await fs.pathExists(srcPath)) {
|
|
return { status: 200, ref: "Wwws", msg: "subapp_already_exists", data: { name: subapp } };
|
|
}
|
|
|
|
const appDir = getWebappSource(tribe, app);
|
|
if (!await fs.pathExists(appDir)) {
|
|
return { status: 404, ref: "Wwws", msg: "webapp_not_found", data: { app } };
|
|
}
|
|
|
|
const skeletonPath = path.join(np, 'apxtri', 'data', 'wwws', 'newwebapp', 'bdc');
|
|
if (!await fs.pathExists(skeletonPath)) {
|
|
return { status: 500, ref: "Wwws", msg: "skeleton_not_found", data: { skeletonPath } };
|
|
}
|
|
|
|
await fs.copy(skeletonPath, srcPath);
|
|
|
|
const placeholderVars = { WEBAPPNAME: app, TRIBE: tribe };
|
|
const templateFiles = await glob('**/*.{html,json,js}', { cwd: srcPath, nodir: true });
|
|
for (const file of templateFiles) {
|
|
const filePath = path.join(srcPath, file);
|
|
try {
|
|
let content = await fs.readFile(filePath, 'utf8');
|
|
let modified = false;
|
|
for (const [key, val] of Object.entries(placeholderVars)) {
|
|
const regex = new RegExp('\\{\\{' + key + '\\}\\}', 'g');
|
|
if (regex.test(content)) {
|
|
content = content.replace(regex, val);
|
|
modified = true;
|
|
}
|
|
}
|
|
if (modified) {
|
|
await fs.writeFile(filePath, content, 'utf8');
|
|
}
|
|
} catch (e) {
|
|
logger.warn(`Skipping placeholder replacement for ${file}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: 201,
|
|
ref: "Wwws",
|
|
msg: "subapp_created",
|
|
data: { name: subapp }
|
|
};
|
|
}
|
|
|
|
static async saveStyle(tribe, app, cssContent) {
|
|
const np = process.env.NODEPATH;
|
|
|
|
const appDir = getWebappSource(tribe, app);
|
|
if (!await fs.pathExists(appDir)) {
|
|
return { status: 404, ref: "Wwws", msg: "webapp_not_found", data: { app } };
|
|
}
|
|
|
|
// Save webapp-level style.css (remove first to avoid cross-user ownership issues)
|
|
const webappStylePath = path.join(appDir, 'style.css');
|
|
if (await fs.pathExists(webappStylePath)) {
|
|
await fs.remove(webappStylePath);
|
|
}
|
|
await fs.writeFile(webappStylePath, cssContent, 'utf8');
|
|
|
|
// Copy to all subapp css/style.css
|
|
const updated = [];
|
|
const entries = await fs.readdir(appDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const subappCssPath = path.join(appDir, entry.name, 'css', 'style.css');
|
|
if (await fs.pathExists(path.dirname(subappCssPath))) {
|
|
if (await fs.pathExists(subappCssPath)) {
|
|
await fs.remove(subappCssPath);
|
|
}
|
|
await fs.writeFile(subappCssPath, cssContent, 'utf8');
|
|
updated.push(entry.name);
|
|
}
|
|
}
|
|
|
|
// Rebuild all subapps via buildpwa
|
|
const buildResult = await this.buildpwa(tribe, app);
|
|
|
|
return {
|
|
status: 200,
|
|
ref: "Wwws",
|
|
msg: "style_saved",
|
|
data: { updated, buildStatus: buildResult.status }
|
|
};
|
|
}
|
|
|
|
static async renameSubapp(tribe, app, oldSubapp, newSubapp) {
|
|
const np = process.env.NODEPATH;
|
|
const dp = process.env.DATAPATH;
|
|
|
|
const confPath = path.join(getWebappSource(tribe, app), 'appconf.json');
|
|
if (!await fs.pathExists(confPath)) {
|
|
return { status: 404, ref: "Wwws", msg: "app_config_not_found", data: { app } };
|
|
}
|
|
|
|
let conf;
|
|
try {
|
|
conf = await fs.readJson(confPath);
|
|
} catch (e) {
|
|
return { status: 500, ref: "Wwws", msg: "app_config_invalid", data: { app } };
|
|
}
|
|
|
|
if (!conf.subapp || !conf.subapp[oldSubapp]) {
|
|
return { status: 404, ref: "Wwws", msg: "subapp_rename_not_found", data: { old: oldSubapp } };
|
|
}
|
|
|
|
if (conf.subapp[newSubapp]) {
|
|
return { status: 409, ref: "Wwws", msg: "subapp_rename_exists", data: { name: newSubapp } };
|
|
}
|
|
|
|
if (!/^[a-z0-9-]+$/.test(newSubapp)) {
|
|
return { status: 400, ref: "Wwws", msg: "invalid_subapp_name", data: { name: newSubapp } };
|
|
}
|
|
|
|
// Rename source directory
|
|
const srcOld = path.join(getWebappSource(tribe, app), oldSubapp);
|
|
const srcNew = path.join(getWebappSource(tribe, app), newSubapp);
|
|
if (await fs.pathExists(srcOld)) {
|
|
await fs.move(srcOld, srcNew);
|
|
}
|
|
|
|
// Rename dist directory if it exists
|
|
const distOld = path.join(dp, tribe, 'wwws', app, oldSubapp);
|
|
const distNew = path.join(dp, tribe, 'wwws', app, newSubapp);
|
|
if (await fs.pathExists(distOld)) {
|
|
await fs.move(distOld, distNew);
|
|
}
|
|
|
|
// Update appconf key and increment version
|
|
conf.subapp[newSubapp] = conf.subapp[oldSubapp];
|
|
delete conf.subapp[oldSubapp];
|
|
conf.subapp[newSubapp].version = (conf.subapp[newSubapp].version || 0) + 1;
|
|
|
|
await fs.writeJson(confPath, conf, { spaces: 2 });
|
|
|
|
// Clean old localdb caches matching the old subapp name
|
|
const tmpDir = path.join(dp, tribe, 'tmp', 'localdb');
|
|
if (await fs.pathExists(tmpDir)) {
|
|
const files = await fs.readdir(tmpDir);
|
|
for (const file of files) {
|
|
if (file.includes(`-${oldSubapp}`)) {
|
|
await fs.remove(path.join(tmpDir, file));
|
|
logger.debug(`Cleaned old cache: ${file}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
ref: "Wwws",
|
|
msg: "subapp_renamed",
|
|
data: { old: oldSubapp, new: newSubapp, version: conf.subapp[newSubapp].version }
|
|
};
|
|
}
|
|
|
|
static async deleteSubapp(tribe, app, subapp) {
|
|
const np = process.env.NODEPATH;
|
|
const dp = process.env.DATAPATH;
|
|
|
|
const confPath = path.join(getWebappSource(tribe, app), 'appconf.json');
|
|
if (!await fs.pathExists(confPath)) {
|
|
return { status: 404, ref: "Wwws", msg: "app_config_not_found", data: { app } };
|
|
}
|
|
|
|
let conf;
|
|
try {
|
|
conf = await fs.readJson(confPath);
|
|
} catch (e) {
|
|
return { status: 500, ref: "Wwws", msg: "app_config_invalid", data: { app } };
|
|
}
|
|
|
|
if (!conf.subapp || !conf.subapp[subapp]) {
|
|
return { status: 404, ref: "Wwws", msg: "subapp_delete_not_found", data: { name: subapp } };
|
|
}
|
|
|
|
// Remove source directory
|
|
const srcPath = path.join(getWebappSource(tribe, app), subapp);
|
|
if (await fs.pathExists(srcPath)) {
|
|
await fs.remove(srcPath);
|
|
logger.debug(`Deleted subapp source: ${srcPath}`);
|
|
}
|
|
|
|
// Remove dist directory if it exists
|
|
const distPath = path.join(dp, tribe, 'wwws', app, subapp);
|
|
if (await fs.pathExists(distPath)) {
|
|
await fs.remove(distPath);
|
|
logger.debug(`Deleted subapp dist: ${distPath}`);
|
|
}
|
|
|
|
// Remove from appconf
|
|
delete conf.subapp[subapp];
|
|
await fs.writeJson(confPath, conf, { spaces: 2 });
|
|
|
|
// Clean localdb caches
|
|
const tmpDir = path.join(dp, tribe, 'tmp', 'localdb');
|
|
if (await fs.pathExists(tmpDir)) {
|
|
const files = await fs.readdir(tmpDir);
|
|
for (const file of files) {
|
|
if (file.includes(`-${subapp}`)) {
|
|
await fs.remove(path.join(tmpDir, file));
|
|
logger.debug(`Cleaned old cache: ${file}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
ref: "Wwws",
|
|
msg: "subapp_deleted",
|
|
data: { name: subapp }
|
|
};
|
|
}
|
|
|
|
/** Cached uid/gid -> name maps, so error messages can say "phil:phil" not "1001:1001". */
|
|
static _userNameCache = null;
|
|
|
|
static _initUserNameCache() {
|
|
if (Wwws._userNameCache) return Wwws._userNameCache;
|
|
// Keys are STRINGIFIED: stat() hands back numeric uid/gid, and Map.get(997) does
|
|
// not match a "997" key — the fallback would silently print raw ids forever.
|
|
const read = (file, sep, idx) => {
|
|
const map = new Map();
|
|
try {
|
|
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
if (!line.trim() || line.startsWith('#')) continue;
|
|
const parts = line.split(sep);
|
|
if (parts.length > idx) map.set(String(parts[idx]), parts[0]);
|
|
}
|
|
} catch (e) { /* no passwd/group file — fall back to numeric ids */ }
|
|
return map;
|
|
};
|
|
Wwws._userNameCache = { users: read('/etc/passwd', ':', 2), groups: read('/etc/group', ':', 2) };
|
|
return Wwws._userNameCache;
|
|
}
|
|
|
|
static _userName(id) {
|
|
const { users } = Wwws._initUserNameCache();
|
|
return users.get(String(id)) || String(id);
|
|
}
|
|
|
|
static _groupName(id) {
|
|
const { groups } = Wwws._initUserNameCache();
|
|
return groups.get(String(id)) || String(id);
|
|
}
|
|
|
|
static _ownerOf(p) {
|
|
try {
|
|
const st = fs.statSync(p);
|
|
return `${Wwws._userName(st.uid)}:${Wwws._groupName(st.gid)}`;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pre-flight access check on a directory tree, run before the build writes anything.
|
|
*
|
|
* WHY THIS EXISTS — the build must run as the runtime user (apxtri, the owner of
|
|
* {DATAPATH}) while the sources under {NODEPATH} are written by the dev user,
|
|
* opencode and `git pull`. Two failure modes follow, and neither is self-explanatory:
|
|
*
|
|
* - write: fs.copy({overwrite:true}) unlinks the destination before writing, so it
|
|
* needs W_OK on the destination *directory*, not on the file. A directory created
|
|
* by the dev user is owned by that user, and setup/grant-dev.sh only ever adds a
|
|
* per-user ACL entry (`u:{user}:rwx`) — apxtri is not in it. With no setgid bit and
|
|
* no `group:apxtri` entry on the tree, apxtri falls back to `other::r-x` (read
|
|
* only) and the build dies with a bare EACCES on an `unlink`, halfway through the
|
|
* copy, leaving a half-provisioned dist.
|
|
* - read: the build reads the sources under {NODEPATH}, so a source file the
|
|
* runtime user cannot read breaks the build with no self-explanatory error.
|
|
*
|
|
* mode 'w' tests W_OK|X_OK on directories (what copy/symlink/unlink need);
|
|
* mode 'r' tests R_OK|X_OK on directories and R_OK on files (what the build reads need).
|
|
* Symlinks are skipped: the target's permissions govern, and following `css/`
|
|
* would walk out of the tree being checked.
|
|
*/
|
|
static async _checkTreeAccess(root, mode) {
|
|
const failures = [];
|
|
const cap = 10;
|
|
let count = 0;
|
|
if (!root || !await fs.pathExists(root)) return { ok: true, count: 0, failures: [] };
|
|
|
|
const dirFlag = mode === 'w' ? (fs.constants.W_OK | fs.constants.X_OK) : (fs.constants.R_OK | fs.constants.X_OK);
|
|
const record = async (p, err) => {
|
|
count++;
|
|
if (failures.length < cap) failures.push({ path: p, owner: Wwws._ownerOf(p), error: (err && err.code) || String(err) });
|
|
};
|
|
|
|
const walk = async (dir) => {
|
|
try {
|
|
await fs.access(dir, dirFlag);
|
|
} catch (e) {
|
|
await record(dir, e);
|
|
return; // unreadable dir: stop, its content is already reported
|
|
}
|
|
let entries = [];
|
|
try {
|
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
} catch (e) {
|
|
if (mode === 'r') await record(dir, e);
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.isSymbolicLink()) continue;
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await walk(full);
|
|
} else if (mode === 'r') {
|
|
try {
|
|
await fs.access(full, fs.constants.R_OK);
|
|
} catch (e) {
|
|
await record(full, e);
|
|
}
|
|
}
|
|
// mode 'w': the file itself is never the problem — the containing directory is.
|
|
}
|
|
};
|
|
|
|
await walk(root);
|
|
return { ok: count === 0, count, failures };
|
|
}
|
|
|
|
/**
|
|
* Resolve the uid that must own everything the build produces. {DATAPATH} itself is
|
|
* created and owned by the runtime user (apxtri), so it is the authoritative reference.
|
|
*/
|
|
static _runtimeUid() {
|
|
try {
|
|
return fs.statSync(process.env.DATAPATH).uid;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Username owning {DATAPATH} — the account that must own everything the build writes. */
|
|
static _runtimeUser() {
|
|
const uid = Wwws._runtimeUid();
|
|
return uid === null ? null : Wwws._userName(uid);
|
|
}
|
|
|
|
/**
|
|
* Pre-flight gate for buildpwa: fails with a structured error, before the first write,
|
|
* when the source is not readable by the runtime user or a destination is not writable
|
|
* by whoever runs the build. See _checkTreeAccess for the two failure modes.
|
|
*/
|
|
static async _preflight({ srcBase, cdnCssRoot, servedCdn, distBase, tribeId, appName }) {
|
|
const buildCmd = `sudo -u apxtri env PATH="$NODE_BIN:$PATH" "$NODE_BIN/node" models/Wwws.js ${tribeId} ${appName} pwa`;
|
|
|
|
// 1. Wrong user — the build would create files the runtime user cannot later replace.
|
|
// Cheapest and most important check: everything below is evaluated against the
|
|
// uid of the *current* process, so a build run as phil would report "all good".
|
|
const runtimeUid = Wwws._runtimeUid();
|
|
const currentUid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
if (runtimeUid !== null && currentUid !== null && currentUid !== runtimeUid) {
|
|
const as = Wwws._userName(currentUid);
|
|
const expected = Wwws._userName(runtimeUid);
|
|
logger.error(`buildpwa running as ${as} (uid ${currentUid}) but ${process.env.DATAPATH} is owned by ${expected} (uid ${runtimeUid})`);
|
|
return {
|
|
status: 500,
|
|
ref: "Wwws",
|
|
msg: "build_wrong_user",
|
|
data: {
|
|
root: process.env.DATAPATH,
|
|
paths: [process.env.DATAPATH],
|
|
count: 1,
|
|
owner: `${as}:${as}`,
|
|
expectedUser: expected,
|
|
buildCmd,
|
|
chown: ''
|
|
}
|
|
};
|
|
}
|
|
|
|
// 2. Read: the build reads the sources, so unreadable files break the build
|
|
// with no self-explanatory error.
|
|
const readable = await Wwws._checkTreeAccess(srcBase, 'r');
|
|
if (!readable.ok) {
|
|
return {
|
|
status: 500,
|
|
ref: "Wwws",
|
|
msg: "source_not_readable",
|
|
data: {
|
|
root: srcBase,
|
|
paths: readable.failures.map(f => f.path),
|
|
count: readable.count,
|
|
owner: readable.failures[0].owner,
|
|
chown: `sudo chown -R ${Wwws._runtimeUser()} ${srcBase}`,
|
|
buildCmd
|
|
}
|
|
};
|
|
}
|
|
|
|
// 3. Write: every directory the build will copy into, symlink or replace.
|
|
for (const target of [cdnCssRoot, servedCdn, distBase]) {
|
|
if (!target) continue;
|
|
// A destination that does not exist yet is created by ensureDir, so its existing
|
|
// parent is what must be writable — check the parent instead of passing vacuously.
|
|
const root = await fs.pathExists(target) ? target : path.dirname(target);
|
|
const writable = await Wwws._checkTreeAccess(root, 'w');
|
|
if (writable.ok) continue;
|
|
logger.error(`buildpwa cannot write in ${root}: ${writable.count} path(s) denied`);
|
|
return {
|
|
status: 500,
|
|
ref: "Wwws",
|
|
msg: "dist_not_writable",
|
|
data: {
|
|
root,
|
|
paths: writable.failures.map(f => f.path),
|
|
count: writable.count,
|
|
owner: writable.failures[0].owner,
|
|
chown: `sudo chown -R ${Wwws._runtimeUser()} ${root}`,
|
|
buildCmd
|
|
}
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Provision the served CDN for a tribe: {DATAPATH}/{tribe}/wwws/cdn.
|
|
*
|
|
* A tribe's CDN is the union of two sources:
|
|
* - the core cdn (apxtri/cdn): framework libs (lib/apxtri/*), the base css,
|
|
* and the Inter-*.ttf fonts referenced by the compiled base.css;
|
|
* - the tribe's own `wwws/cdn` in its git repo (brand fonts/logos, its lib
|
|
* subset, shared/, static/, conf.json).
|
|
* setup/init.sh only provisions the TOWN cdn and Tribes.pull() only runs git,
|
|
* so a tribe deployed via git otherwise serves a cdn missing its lib/fonts and
|
|
* every /cdn/{tribe}/lib/* 404's. Core is copied first, the tribe overlay wins
|
|
* on name clashes; the css/ dir stays a symlink to the core (compiled here).
|
|
*/
|
|
static async _provisionTribeCdn(tribeId) {
|
|
const np = process.env.NODEPATH;
|
|
const dp = process.env.DATAPATH;
|
|
if (!np || !dp) return;
|
|
// The town tribe's cdn is provisioned by setup/init.sh (and its lib refreshed
|
|
// by setup/update.sh) and has no code directory of its own — nothing to do here.
|
|
if (tribeId === process.env.TOWN) return;
|
|
const coreCdn = path.join(np, 'apxtri', 'cdn');
|
|
const tribeCdnSrc = path.join(np, tribeId, 'wwws', 'cdn');
|
|
const servedCdn = path.join(dp, tribeId, 'wwws', 'cdn');
|
|
await fs.ensureDir(servedCdn);
|
|
|
|
// css/ → symlink to the core (buildpwa compiles into apxtri/cdn/css).
|
|
const cssSrc = path.join(coreCdn, 'css');
|
|
const cssDest = path.join(servedCdn, 'css');
|
|
if (await fs.pathExists(cssSrc)) {
|
|
try {
|
|
const st = await fs.lstat(cssDest);
|
|
if (!st.isSymbolicLink() || (await fs.realpath(cssDest)) !== await fs.realpath(cssSrc)) {
|
|
await fs.remove(cssDest);
|
|
await fs.symlink(cssSrc, cssDest);
|
|
logger.info(`CDN css symlink: ${cssDest} → ${cssSrc}`);
|
|
}
|
|
} catch (e) {
|
|
await fs.symlink(cssSrc, cssDest);
|
|
logger.info(`CDN css symlink created: ${cssDest} → ${cssSrc}`);
|
|
}
|
|
}
|
|
|
|
// Core base (lib + fonts + static): re-copied on every build so the framework
|
|
// assets stay present AND up to date with the core. The tribe overlay below is
|
|
// applied after, so a tribe's own version of a file always wins.
|
|
for (const name of ['lib', 'fonts', 'static']) {
|
|
const coreDir = path.join(coreCdn, name);
|
|
if (await fs.pathExists(coreDir)) {
|
|
await fs.copy(coreDir, path.join(servedCdn, name), { overwrite: true });
|
|
}
|
|
}
|
|
|
|
// Tribe overlay — the tribe's own cdn assets (brand fonts/logos, its lib
|
|
// subset, shared/, static/, conf.json) win on name clashes.
|
|
if (await fs.pathExists(tribeCdnSrc)) {
|
|
for (const name of await fs.readdir(tribeCdnSrc)) {
|
|
if (name === 'css') continue;
|
|
await fs.copy(path.join(tribeCdnSrc, name), path.join(servedCdn, name), { overwrite: true });
|
|
}
|
|
}
|
|
|
|
// Rewrite /cdn/dev/ → /cdn/{tribe}/ in the served lib (bip39.js references it).
|
|
if (tribeId !== 'dev') {
|
|
const servedLib = path.join(servedCdn, 'lib');
|
|
for (const rel of await glob('**/*.{js,json}', { cwd: servedLib, nodir: true })) {
|
|
const f = path.join(servedLib, rel);
|
|
let content = await fs.readFile(f, 'utf8');
|
|
if (content.includes('/cdn/dev/')) {
|
|
content = content.replace(/\/cdn\/dev\//g, '/cdn/' + tribeId + '/');
|
|
await fs.writeFile(f, content);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static async buildpwa(tribeId, appName, subappName) {
|
|
const np = process.env.NODEPATH;
|
|
const dp = process.env.DATAPATH;
|
|
if (!np || !dp) {
|
|
return { status: 500, ref: "Wwws", msg: "env_not_set", data: { NODEPATH: np, DATAPATH: dp } };
|
|
}
|
|
const srcBase = getWebappSource(tribeId, appName);
|
|
const distBase = path.join(dp, tribeId, 'wwws', appName);
|
|
|
|
logger.info(`Building PWA for tribe=${tribeId} app=${appName}${subappName ? ' subapp=' + subappName : ''}`);
|
|
logger.info(` Source: ${srcBase}`);
|
|
logger.info(` Dist: ${distBase}`);
|
|
|
|
if (!await fs.pathExists(srcBase)) {
|
|
return { status: 404, ref: "Wwws", msg: "source_not_found", data: { srcBase } };
|
|
}
|
|
|
|
const subapps = [];
|
|
const entries = await fs.readdir(srcBase, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
if (subappName && entry.name !== subappName) continue;
|
|
const subSrc = path.join(srcBase, entry.name);
|
|
if (await fs.pathExists(path.join(subSrc, 'manifest.json'))) {
|
|
subapps.push({ name: entry.name, src: subSrc, dist: path.join(distBase, entry.name) });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (subapps.length === 0) {
|
|
logger.warn(`No subapp with manifest.json found in ${srcBase}`);
|
|
return { status: 200, ref: "Wwws", msg: "no_pwa_subapp_found", data: {} };
|
|
}
|
|
|
|
const results = [];
|
|
|
|
// 0. Shared CDN base CSS (webapp themes + fonts) — compiled once when present
|
|
const cdnCssRoot = path.join(np, 'apxtri', 'cdn', 'css');
|
|
const baseSrc = path.join(cdnCssRoot, 'base.src.css');
|
|
const baseDist = path.join(cdnCssRoot, 'base.css');
|
|
const useCdnCss = await fs.pathExists(cdnCssRoot);
|
|
|
|
// 0bis. Access pre-flight — must pass BEFORE any write. Every check below is
|
|
// evaluated against the uid of the current process, so a build run as the dev user
|
|
// would copy phil-owned files into the served tree and lock out the runtime user on
|
|
// the NEXT build. Fails with an actionable message instead of a bare EACCES
|
|
// halfway through the copy. See _preflight / _checkTreeAccess.
|
|
const preflight = await this._preflight({
|
|
srcBase,
|
|
cdnCssRoot: useCdnCss ? cdnCssRoot : null,
|
|
servedCdn: path.join(dp, tribeId, 'wwws', 'cdn'),
|
|
distBase,
|
|
tribeId,
|
|
appName
|
|
});
|
|
if (preflight) return preflight;
|
|
|
|
// Provision the served CDN for this tribe (see _provisionTribeCdn). A tribe
|
|
// deployed via git (update.sh / mytribes "Pull & deploy") never went through
|
|
// init.sh, which only provisions the TOWN cdn — without this every
|
|
// /cdn/{tribe}/lib/* (axios, auth, localdb, menu…) 404's.
|
|
await this._provisionTribeCdn(tribeId);
|
|
|
|
const twCli = path.join(np, 'apxtri', 'node_modules', '@tailwindcss', 'cli', 'dist', 'index.mjs');
|
|
if (useCdnCss && await fs.pathExists(baseSrc)) {
|
|
try {
|
|
logger.info('Compiling shared CDN base CSS (cdn/css/base.css)...');
|
|
const baseTmp = baseDist + '.tmp';
|
|
// Base keeps the default cwd (apxtri): daisyUI + webapp themes + fonts
|
|
// only; any project-utility duplication here is harmless.
|
|
execSync(
|
|
`node "${twCli}" -i "${baseSrc}" -o "${baseTmp}" --minify`,
|
|
{ cwd: path.join(np, 'apxtri'), stdio: 'pipe' }
|
|
);
|
|
if (await fs.pathExists(baseDist)) await fs.remove(baseDist);
|
|
await fs.move(baseTmp, baseDist, { overwrite: true });
|
|
// Rewrite /cdn/dev/ → /cdn/{town}/ in compiled CSS (font URLs etc.)
|
|
if (tribeId !== 'dev') {
|
|
let baseCss = await fs.readFile(baseDist, 'utf8');
|
|
baseCss = baseCss.replace(/\/cdn\/dev\//g, '/cdn/' + tribeId + '/');
|
|
await fs.writeFile(baseDist, baseCss);
|
|
}
|
|
} catch (err) {
|
|
// A base.css compile failure (e.g. missing /opt/apxtowns/main_modules
|
|
// symlink) must NOT abort the whole build: the committed base.css is
|
|
// still served, and the per-subapp HTML/JS/manifest /cdn/dev/ rewrite
|
|
// below is what fixes CDN paths on non-dev towns.
|
|
logger.warn(`Base CSS compile failed, continuing with existing base.css: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
for (const subapp of subapps) {
|
|
logger.info(`--- Building subapp: ${subapp.name || '(root)'} ---`);
|
|
const subResult = { name: subapp.name, files: [] };
|
|
|
|
try {
|
|
await fs.ensureDir(path.join(subapp.dist, 'css'));
|
|
await fs.ensureDir(path.join(subapp.dist, 'js'));
|
|
|
|
// 1. Tailwind CSS compilation — per-webapp output in CDN (cdn/css/{subapp}.css)
|
|
// when cdn/css exists; legacy dist/css/output.css otherwise.
|
|
const styleSrc = path.join(subapp.src, 'css', 'style.css');
|
|
let cssDist, copyBackSrc;
|
|
if (useCdnCss) {
|
|
cssDist = path.join(cdnCssRoot, subapp.name + '.css');
|
|
} else {
|
|
cssDist = path.join(subapp.dist, 'css', 'output.css');
|
|
copyBackSrc = path.join(subapp.src, 'css', 'output.css');
|
|
}
|
|
if (await fs.pathExists(styleSrc)) {
|
|
logger.info(` Compiling Tailwind CSS... -> ${useCdnCss ? path.join('cdn', 'css', subapp.name + '.css') : 'css/output.css'}`);
|
|
const cssTmp = cssDist + '.tmp';
|
|
// --cwd = the webapp root: automatic detection is anchored to the cwd
|
|
// subtree, so each webapp only ships its own utilities (+ shared cdn/lib
|
|
// via the ../css @source globs) instead of a whole-project superset.
|
|
execSync(
|
|
`node "${twCli}" -i "${styleSrc}" -o "${cssTmp}" --cwd "${subapp.src}" --minify`,
|
|
{ cwd: path.join(np, 'apxtri'), stdio: 'pipe' }
|
|
);
|
|
// Remove old output and rename tmp (atomic, avoids losing CSS on build failure)
|
|
if (await fs.pathExists(cssDist)) {
|
|
await fs.remove(cssDist);
|
|
}
|
|
await fs.move(cssTmp, cssDist, { overwrite: true });
|
|
subResult.files.push(useCdnCss
|
|
? path.join('cdn', 'css', subapp.name + '.css')
|
|
: 'css/output.css');
|
|
// CDN mode: drop stale legacy dist output if present
|
|
if (useCdnCss) {
|
|
const legDist = path.join(subapp.dist, 'css', 'output.css');
|
|
if (await fs.pathExists(legDist)) {
|
|
await fs.remove(legDist);
|
|
}
|
|
}
|
|
// Legacy: copy output.css back to source so the source tree stays in sync
|
|
if (copyBackSrc) {
|
|
try {
|
|
await fs.copyFile(cssDist, copyBackSrc);
|
|
subResult.files.push('css/output.css (→ src)');
|
|
} catch (copyErr) {
|
|
const fixCmd = 'sudo chown -R apxtri:apxtri "' + getWebappSource(tribeId, appName) + '"';
|
|
logger.warn('Cannot copy output.css to source (' + copyBackSrc + '): ' + copyErr.message + '. Fix: ' + fixCmd);
|
|
subResult.warnings = subResult.warnings || [];
|
|
subResult.warnings.push({
|
|
step: 'copy_to_source',
|
|
file: copyBackSrc,
|
|
chown_cmd: fixCmd
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. HTML minification with path rewriting
|
|
const htmlSrc = path.join(subapp.src, 'index.html');
|
|
if (await fs.pathExists(htmlSrc)) {
|
|
logger.info(` Minifying index.html...`);
|
|
let html = await fs.readFile(htmlSrc, 'utf8');
|
|
// Rewrite /cdn/dev/ → /cdn/{town}/ for non-dev instances
|
|
if (tribeId !== 'dev') {
|
|
html = html.replace(/\/cdn\/dev\//g, '/cdn/' + tribeId + '/');
|
|
}
|
|
// Rewrite hardcoded per-instance meta (xtribe / xnation)
|
|
html = html.replace(/<meta name="xtribe" content="[^"]*"/g, `<meta name="xtribe" content="${tribeId}"`);
|
|
html = html.replace(/<meta name="xnation" content="[^"]*"/g, `<meta name="xnation" content="${process.env.NATION || tribeId}"`);
|
|
// Rewrite relative paths for dist
|
|
html = html.replace(/href="\.\.\/dist\//g, 'href="');
|
|
html = html.replace(/src="\.\.\/dist\//g, 'src="');
|
|
html = html.replace(/href="\.\.\/src\//g, 'href="');
|
|
html = html.replace(/src="\.\.\/src\//g, 'src="');
|
|
html = html.replace(/href="dist\//g, 'href="');
|
|
html = html.replace(/src="dist\//g, 'src="');
|
|
html = html.replace(/href="src\//g, 'href="');
|
|
html = html.replace(/src="src\//g, 'src="');
|
|
html = html.replace(/href="manifest\.json"/g, 'href="manifest.json"');
|
|
html = html.replace(/src="sw\.js"/g, 'src="sw.js"');
|
|
|
|
const minified = await minifyHTML(html, {
|
|
collapseWhitespace: true,
|
|
removeComments: true,
|
|
removeRedundantAttributes: true,
|
|
minifyCSS: true,
|
|
minifyJS: true,
|
|
});
|
|
await fs.writeFile(path.join(subapp.dist, 'index.html'), minified);
|
|
subResult.files.push('index.html');
|
|
}
|
|
|
|
// 3. JS minification
|
|
const jsSrc = path.join(subapp.src, 'js', 'app.js');
|
|
if (await fs.pathExists(jsSrc)) {
|
|
logger.info(` Minifying js/app.js...`);
|
|
const js = await fs.readFile(jsSrc, 'utf8');
|
|
// Rewrite /cdn/dev/ → /cdn/{town}/ for non-dev instances
|
|
const jsRewritten = tribeId !== 'dev'
|
|
? js.replace(/\/cdn\/dev\//g, '/cdn/' + tribeId + '/')
|
|
: js;
|
|
const compressOpts = { compress: true, mangle: true };
|
|
compressOpts.compress = { drop_console: true, drop_debugger: true };
|
|
const minified = await minifyJS(jsRewritten, compressOpts);
|
|
await fs.writeFile(path.join(subapp.dist, 'js', 'app.js'), minified.code);
|
|
subResult.files.push('js/app.js');
|
|
}
|
|
|
|
// 4. Copy static/ directory if exists
|
|
const staticSrc = path.join(subapp.src, 'static');
|
|
if (await fs.pathExists(staticSrc)) {
|
|
const staticDist = path.join(subapp.dist, 'static');
|
|
const realSrc = await fs.realpath(staticSrc);
|
|
const realDist = await fs.realpath(staticDist).catch(() => null);
|
|
if (realSrc !== realDist) {
|
|
logger.info(` Copying static/...`);
|
|
await fs.copy(staticSrc, staticDist, { overwrite: true });
|
|
subResult.files.push('static/*');
|
|
} else {
|
|
logger.info(` Skipping static/ (same path via symlink)`);
|
|
}
|
|
}
|
|
|
|
// 5. Copy fonts/ directory if exists
|
|
const fontsSrc = path.join(subapp.src, 'fonts');
|
|
if (await fs.pathExists(fontsSrc)) {
|
|
const fontsDist = path.join(subapp.dist, 'fonts');
|
|
const realSrc = await fs.realpath(fontsSrc);
|
|
const realDist = await fs.realpath(fontsDist).catch(() => null);
|
|
if (realSrc !== realDist) {
|
|
logger.info(` Copying fonts/...`);
|
|
await fs.copy(fontsSrc, fontsDist, { overwrite: true });
|
|
subResult.files.push('fonts/*');
|
|
} else {
|
|
logger.info(` Skipping fonts/ (same path via symlink)`);
|
|
}
|
|
}
|
|
|
|
// 6. Copy manifest.json
|
|
const manifestSrc = path.join(subapp.src, 'manifest.json');
|
|
if (await fs.pathExists(manifestSrc)) {
|
|
logger.info(` Copying manifest.json...`);
|
|
if (tribeId !== 'dev') {
|
|
let manifest = await fs.readFile(manifestSrc, 'utf8');
|
|
manifest = manifest.replace(/\/cdn\/dev\//g, '/cdn/' + tribeId + '/');
|
|
await fs.writeFile(path.join(subapp.dist, 'manifest.json'), manifest);
|
|
} else {
|
|
await fs.copyFile(manifestSrc, path.join(subapp.dist, 'manifest.json'));
|
|
}
|
|
subResult.files.push('manifest.json');
|
|
}
|
|
|
|
// 7. Copy and adjust sw.js
|
|
const swSrc = path.join(subapp.src, 'sw.js');
|
|
if (await fs.pathExists(swSrc)) {
|
|
logger.info(` Copying sw.js...`);
|
|
let sw = await fs.readFile(swSrc, 'utf8');
|
|
sw = sw.replace(/'\.\//g, "'./");
|
|
await fs.writeFile(path.join(subapp.dist, 'sw.js'), sw);
|
|
subResult.files.push('sw.js');
|
|
}
|
|
|
|
subResult.status = 'ok';
|
|
logger.info(` ✓ Subapp ${subapp.name} built successfully`);
|
|
} catch (err) {
|
|
subResult.status = 'error';
|
|
subResult.error = err.message;
|
|
logger.error(` ✗ Subapp ${subapp.name} build failed: ${err.message}`);
|
|
}
|
|
|
|
results.push(subResult);
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
ref: "Wwws",
|
|
msg: "pwa_build_complete",
|
|
data: { subapps: results }
|
|
};
|
|
}
|
|
|
|
// === Tribe webapp style (colors + fonts) ===
|
|
|
|
static _tribestyleCache = new Map();
|
|
|
|
static async getTribestyle(tribeId) {
|
|
const np = process.env.NODEPATH;
|
|
const cacheKey = tribeId || '';
|
|
if (this._tribestyleCache.has(cacheKey)) {
|
|
const cached = this._tribestyleCache.get(cacheKey);
|
|
return { status: 200, ref: "Wwws", msg: cached.found ? "tribestyle_found" : "tribestyle_default", data: cached.data };
|
|
}
|
|
|
|
const skipApps = ['apidoc', 'cdn'];
|
|
const candidates = [];
|
|
if (tribeId) candidates.push(tribeId);
|
|
const town = process.env.TOWN;
|
|
if (town && town !== tribeId) candidates.push(town);
|
|
|
|
for (const candidate of candidates) {
|
|
let apps;
|
|
try { apps = listWebappNames(candidate).filter((a) => !skipApps.includes(a)).sort(); }
|
|
catch { continue; }
|
|
|
|
for (const app of apps) {
|
|
const appDir = getWebappSource(candidate, app);
|
|
let st;
|
|
try { st = await fs.stat(appDir); } catch { continue; }
|
|
if (!st.isDirectory()) continue;
|
|
|
|
let subs;
|
|
try { subs = await fs.readdir(appDir); } catch { continue; }
|
|
for (const sub of subs.sort()) {
|
|
const subDir = path.join(appDir, sub);
|
|
try { st = await fs.stat(subDir); } catch { continue; }
|
|
if (!st.isDirectory()) continue;
|
|
if (!await fs.pathExists(path.join(subDir, 'manifest.json'))) continue;
|
|
|
|
const cssPath = path.join(subDir, 'css', 'style.css');
|
|
if (!await fs.pathExists(cssPath)) continue;
|
|
|
|
let css;
|
|
try { css = await fs.readFile(cssPath, 'utf8'); } catch { continue; }
|
|
const parsed = this._parseTribestyle(css);
|
|
if (!parsed.themes.light && !parsed.themes.dark) continue;
|
|
|
|
const data = { tribe: candidate, app, subapp: sub, path: cssPath, ...parsed };
|
|
this._tribestyleCache.set(cacheKey, { found: true, data });
|
|
return { status: 200, ref: "Wwws", msg: "tribestyle_found", data };
|
|
}
|
|
}
|
|
}
|
|
|
|
const empty = { themes: { light: null, dark: null }, fonts: { sans: '', serif: '', mono: '', custom: [] } };
|
|
this._tribestyleCache.set(cacheKey, { found: false, data: empty });
|
|
return { status: 200, ref: "Wwws", msg: "tribestyle_default", data: empty };
|
|
}
|
|
|
|
static _parseTribestyle(css) {
|
|
const themes = { light: null, dark: null };
|
|
const fonts = { sans: '', serif: '', mono: '', custom: [] };
|
|
|
|
// Custom fonts via @font-face
|
|
const fontFaces = this._extractDirectiveBlocks(css, 'font-face');
|
|
for (const fb of fontFaces) {
|
|
const m = fb.body.match(/font-family\s*:\s*['"]([^'"]+)['"]/i);
|
|
if (m && !fonts.custom.includes(m[1])) fonts.custom.push(m[1]);
|
|
}
|
|
|
|
// Fonts from @theme block
|
|
const themeBlocks = this._extractDirectiveBlocks(css, 'theme');
|
|
if (themeBlocks.length) {
|
|
fonts.sans = this._getCssVar(themeBlocks[0].body, 'font-sans');
|
|
fonts.serif = this._getCssVar(themeBlocks[0].body, 'font-serif');
|
|
fonts.mono = this._getCssVar(themeBlocks[0].body, 'font-mono');
|
|
}
|
|
|
|
// Colors from @plugin ".../theme" blocks (DaisyUI themes)
|
|
const pluginBlocks = this._extractDirectiveBlocks(css, 'plugin');
|
|
for (const pb of pluginBlocks) {
|
|
if (!/\/theme\b/.test(pb.header)) continue;
|
|
const colors = {};
|
|
const re = /--color-([a-z0-9-]+)\s*:\s*([^;]+);/gi;
|
|
let m;
|
|
while ((m = re.exec(pb.body))) colors[m[1].toLowerCase()] = m[2].trim();
|
|
if (!Object.keys(colors).length) continue;
|
|
|
|
const name = (this._getCssProp(pb.body, 'name') || '').replace(/["']/g, '').trim();
|
|
const colorScheme = (this._getCssProp(pb.body, 'color-scheme') || '').replace(/["']/g, '').trim();
|
|
const isDefault = /default\s*:\s*true/i.test(pb.body);
|
|
const entry = { name, colorScheme, colors };
|
|
|
|
if (/dark/i.test(name) || colorScheme === 'dark') {
|
|
if (!themes.dark) themes.dark = entry;
|
|
} else if (/light/i.test(name) || colorScheme === 'light') {
|
|
if (!themes.light) themes.light = entry;
|
|
} else if (isDefault) {
|
|
if (!themes.light) themes.light = entry;
|
|
else if (!themes.dark) themes.dark = entry;
|
|
}
|
|
}
|
|
|
|
return { themes, fonts };
|
|
}
|
|
|
|
static _extractDirectiveBlocks(css, directive) {
|
|
const blocks = [];
|
|
const re = new RegExp('@' + directive + '\\b[^{]*\\{', 'gi');
|
|
let m;
|
|
while ((m = re.exec(css))) {
|
|
const bodyStart = css.indexOf('{', m.index) + 1;
|
|
let depth = 1, i = bodyStart;
|
|
while (i < css.length && depth > 0) {
|
|
if (css[i] === '{') depth++;
|
|
else if (css[i] === '}') depth--;
|
|
i++;
|
|
}
|
|
blocks.push({ header: css.slice(m.index, bodyStart - 1).trim(), body: css.slice(bodyStart, i - 1) });
|
|
re.lastIndex = i;
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
static _getCssVar(block, name) {
|
|
const m = block.match(new RegExp('--' + name + '\\s*:\\s*([^;]+);'));
|
|
return m ? m[1].trim() : '';
|
|
}
|
|
|
|
static _getCssProp(block, prop) {
|
|
const m = block.match(new RegExp(prop + '\\s*:\\s*([^;]+);'));
|
|
return m ? m[1].trim() : '';
|
|
}
|
|
}
|
|
|
|
module.exports = Wwws;
|
|
|
|
// CLI support: node apxtri/models/Wwws.js {tribeId} {appName} pwa
|
|
if (require.main === module && process.argv.length === 5 && process.argv[4] === 'pwa') {
|
|
const dotenv = require('dotenv');
|
|
const envPath = path.join(__dirname, '..', '.env');
|
|
if (fs.existsSync(envPath)) {
|
|
dotenv.config({ path: envPath });
|
|
}
|
|
const cliNodePath = process.env.NODEPATH;
|
|
const cliDataPath = process.env.DATAPATH;
|
|
const tribeId = process.argv[2];
|
|
const appName = process.argv[3];
|
|
console.log(`[CLI] Wwws.buildpwa tribe=${tribeId} app=${appName}`);
|
|
console.log(` NODEPATH=${cliNodePath} DATAPATH=${cliDataPath}`);
|
|
(async () => {
|
|
const result = await Wwws.buildpwa(tribeId, appName);
|
|
console.log(JSON.stringify(result, null, 2));
|
|
if (result.status !== 200) process.exit(1);
|
|
})().catch(err => {
|
|
console.error('Build failed:', err);
|
|
process.exit(1);
|
|
});
|
|
} |