#!/usr/bin/env node
|
|
import fs from 'node:fs';
|
import path from 'node:path';
|
import process from 'node:process';
|
|
const GUID = 'asc.{7F4E8F35-5D66-47F8-A5B4-6AB3FAACF565}';
|
const VERSION = '0.1.0';
|
const ICONS = ['icon.png', 'icon@2x.png'];
|
const ASSET_PATTERN = /^index-[\w-]+\.(?:css|js)$/;
|
|
function fail(message) {
|
throw new Error(message);
|
}
|
|
function ordinaryEntries(directory) {
|
return fs.readdirSync(directory, { withFileTypes: true }).map((entry) => {
|
if (entry.isSymbolicLink()) fail(`Symbolic links are not allowed: ${path.join(directory, entry.name)}`);
|
return entry;
|
});
|
}
|
|
function assertRegularFile(file) {
|
const status = fs.lstatSync(file);
|
if (!status.isFile() || status.isSymbolicLink()) fail(`Expected an ordinary file: ${file}`);
|
}
|
|
function assertOrdinaryDirectory(directory) {
|
const status = fs.lstatSync(directory);
|
if (!status.isDirectory() || status.isSymbolicLink()) fail(`Expected an ordinary directory: ${directory}`);
|
}
|
|
function assertManifest(directory) {
|
const manifestFile = path.join(directory, 'config.json');
|
assertRegularFile(manifestFile);
|
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
if (manifest.version !== VERSION || manifest.guid !== GUID || manifest.variations?.[0]?.url !== 'index.html?v=0.1.0') {
|
fail('Production manifest identity, version, or iframe URL is invalid');
|
}
|
}
|
|
function assertHtml(directory, assets) {
|
const htmlFile = path.join(directory, 'index.html');
|
assertRegularFile(htmlFile);
|
const html = fs.readFileSync(htmlFile, 'utf8');
|
for (const asset of assets) {
|
if (!html.includes(`./assets/${asset}`)) fail(`Production HTML does not reference ${asset}`);
|
}
|
if (!html.includes('href="../v1/plugins.css"') || !html.includes('src="../v1/plugins.js"')) {
|
fail('Production HTML must retain relative ONLYOFFICE SDK references');
|
}
|
}
|
|
function removeAntDesignAllowlist(source) {
|
const localhostContexts =
|
source.match(
|
/[A-Za-z_$][\w$]*="\(\?:"\+[A-Za-z_$][\w$]*\+"\|www\\\.\)"\+[A-Za-z_$][\w$]*\+"\(\?:localhost\|"\+[A-Za-z_$][\w$]*\+"\|"\+[A-Za-z_$][\w$]*\+"\|"\+[A-Za-z_$][\w$]*\+[A-Za-z_$][\w$]*\+[A-Za-z_$][\w$]*\+"\)"\+[A-Za-z_$][\w$]*\+[A-Za-z_$][\w$]*/g,
|
) ?? [];
|
const secret = 'SECRET_COMBOBOX_MODE_DO_NOT_USE';
|
const secretContexts = [
|
new RegExp(`[A-Za-z_$][\\w$]*="${secret}",[A-Za-z_$][\\w$]*=[A-Za-z_$][\\w$]*\\(`, 'g'),
|
new RegExp(`,${secret}:[A-Za-z_$][\\w$]*,slots:Object`, 'g'),
|
new RegExp(`\\.${secret},getInputElement:`, 'g'),
|
].flatMap((pattern) => source.match(pattern) ?? []);
|
return [...localhostContexts, ...secretContexts].reduce((text, context) => text.replace(context, ''), source);
|
}
|
|
function assertNoSensitiveText(directory, textFiles) {
|
const source = textFiles.map((file) => fs.readFileSync(path.join(directory, file), 'utf8')).join('\n');
|
const audited = removeAntDesignAllowlist(source);
|
const forbidden = [
|
/0\.1\.0-dev|\/onlyoffice-sdk\/|\/@vite\/client/i,
|
/ONLYOFFICE_PLUGIN_DEV_|ONLYOFFICE_DOCS_URL/,
|
/\b[A-Z0-9_]*(?:SECRET|TOKEN)[A-Z0-9_]*\b/,
|
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
/(?:https?|wss?):\/\/(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\]|10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2})(?::|\/|$)/i,
|
/客户数据样例|客户样例|customer[-_ ]?(?:data|sample)/i,
|
];
|
for (const pattern of forbidden) {
|
if (pattern.test(audited)) fail(`Production files contain forbidden development or sensitive text: ${pattern.source}`);
|
}
|
}
|
|
function validate(directory, allowPackage) {
|
assertOrdinaryDirectory(directory);
|
const topEntries = ordinaryEntries(directory);
|
const allowedTop = new Set(['assets', 'config.json', 'index.html', 'resources', 'translations']);
|
if (allowPackage) allowedTop.add('eln-template-filler.plugin');
|
for (const entry of topEntries) {
|
if (!allowedTop.has(entry.name)) fail(`Unexpected production entry: ${entry.name}`);
|
}
|
for (const required of ['assets', 'config.json', 'index.html', 'resources', 'translations']) {
|
if (!topEntries.some((entry) => entry.name === required)) fail(`Missing production entry: ${required}`);
|
}
|
|
const assetsDirectory = path.join(directory, 'assets');
|
assertOrdinaryDirectory(assetsDirectory);
|
const assetEntries = ordinaryEntries(assetsDirectory);
|
if (assetEntries.some((entry) => !entry.isFile() || !ASSET_PATTERN.test(entry.name))) fail('Production assets must be direct hashed JS/CSS files');
|
const assets = assetEntries.map((entry) => entry.name).sort();
|
if (assets.length !== 2 || assets.filter((asset) => asset.endsWith('.js')).length !== 1 || assets.filter((asset) => asset.endsWith('.css')).length !== 1) {
|
fail('Production assets require exactly one hashed JavaScript file and one hashed CSS file');
|
}
|
|
const resourcesDirectory = path.join(directory, 'resources');
|
assertOrdinaryDirectory(resourcesDirectory);
|
const resources = ordinaryEntries(resourcesDirectory);
|
if (
|
resources.some((entry) => !entry.isFile()) ||
|
resources
|
.map((entry) => entry.name)
|
.sort()
|
.join('\n') !== [...ICONS].sort().join('\n')
|
) {
|
fail('Production resources must contain only the two fixed icons');
|
}
|
for (const icon of ICONS) assertRegularFile(path.join(resourcesDirectory, icon));
|
|
const translationsDirectory = path.join(directory, 'translations');
|
assertOrdinaryDirectory(translationsDirectory);
|
const translations = ordinaryEntries(translationsDirectory);
|
if (translations.length !== 1 || translations[0].name !== 'langs.json' || !translations[0].isFile()) {
|
fail('Production translations must contain only langs.json');
|
}
|
const languages = JSON.parse(fs.readFileSync(path.join(translationsDirectory, 'langs.json'), 'utf8'));
|
if (!Array.isArray(languages) || languages.length !== 0) fail('Production translations/langs.json must be an empty array');
|
|
assertManifest(directory);
|
assertHtml(directory, assets);
|
assertNoSensitiveText(directory, ['config.json', 'index.html', ...assets.map((asset) => path.join('assets', asset))]);
|
return assets;
|
}
|
|
function copyRelease(source, target) {
|
const assets = validate(source, true);
|
fs.mkdirSync(path.join(target, 'assets'), { recursive: true });
|
fs.mkdirSync(path.join(target, 'resources'), { recursive: true });
|
fs.mkdirSync(path.join(target, 'translations'), { recursive: true });
|
for (const file of ['config.json', 'index.html']) fs.copyFileSync(path.join(source, file), path.join(target, file));
|
for (const icon of ICONS) fs.copyFileSync(path.join(source, 'resources', icon), path.join(target, 'resources', icon));
|
fs.copyFileSync(path.join(source, 'translations/langs.json'), path.join(target, 'translations/langs.json'));
|
for (const asset of assets) fs.copyFileSync(path.join(source, 'assets', asset), path.join(target, 'assets', asset));
|
}
|
|
function normalizePermissions(directory) {
|
const visit = (current) => {
|
fs.chmodSync(current, 0o755);
|
for (const entry of ordinaryEntries(current)) {
|
const file = path.join(current, entry.name);
|
if (entry.isDirectory()) visit(file);
|
else if (entry.isFile()) fs.chmodSync(file, 0o644);
|
else fail(`Unsupported release entry: ${file}`);
|
}
|
};
|
visit(directory);
|
}
|
|
function assertPermissions(directory) {
|
const visit = (current) => {
|
if ((fs.statSync(current).mode & 0o777) !== 0o755) fail(`Release directory must be 0755: ${current}`);
|
for (const entry of ordinaryEntries(current)) {
|
const file = path.join(current, entry.name);
|
if (entry.isDirectory()) visit(file);
|
else if (!entry.isFile() || (fs.statSync(file).mode & 0o777) !== 0o644) fail(`Release file must be 0644: ${file}`);
|
}
|
};
|
visit(directory);
|
}
|
|
function prepareReleasePath(rootInput) {
|
const root = fs.realpathSync(rootInput);
|
const components = ['onlyoffice-plugins', VERSION];
|
let current = root;
|
for (const component of [...components, 'eln-template-filler']) {
|
current = path.join(current, component);
|
if (!fs.existsSync(current)) continue;
|
const status = fs.lstatSync(current);
|
if (status.isSymbolicLink() || !status.isDirectory()) fail(`Release path component must be an ordinary directory: ${current}`);
|
}
|
current = root;
|
for (const component of components) {
|
current = path.join(current, component);
|
if (!fs.existsSync(current)) fs.mkdirSync(current, { mode: 0o755 });
|
const physical = fs.realpathSync(current);
|
if (physical !== root && !physical.startsWith(`${root}${path.sep}`)) fail(`Release path escaped its root: ${current}`);
|
}
|
process.stdout.write(path.join(current, 'eln-template-filler'));
|
}
|
|
const [command, directory, target] = process.argv.slice(2);
|
try {
|
switch (command) {
|
case 'check-release': {
|
validate(path.resolve(directory), false);
|
assertPermissions(path.resolve(directory));
|
break;
|
}
|
case 'copy-release': {
|
copyRelease(path.resolve(directory), path.resolve(target));
|
break;
|
}
|
case 'prepare-release-path': {
|
prepareReleasePath(path.resolve(directory));
|
break;
|
}
|
case 'validate-dist': {
|
validate(path.resolve(directory), true);
|
break;
|
}
|
case 'validate-release': {
|
validate(path.resolve(directory), false);
|
normalizePermissions(path.resolve(directory));
|
validate(path.resolve(directory), false);
|
assertPermissions(path.resolve(directory));
|
break;
|
}
|
default: {
|
fail(`Unknown production contract command: ${command}`);
|
}
|
}
|
} catch (error) {
|
console.error(error instanceof Error ? error.message : String(error));
|
process.exitCode = 1;
|
}
|