228 lines
7.3 KiB
JavaScript
228 lines
7.3 KiB
JavaScript
import { createHash } from 'node:crypto';
|
|
import { createReadStream } from 'node:fs';
|
|
import {
|
|
chmod,
|
|
mkdir,
|
|
open,
|
|
readFile,
|
|
rename,
|
|
rm,
|
|
stat
|
|
} from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { Readable } from 'node:stream';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const scriptPath = fileURLToPath(import.meta.url);
|
|
const frontendRoot = path.resolve(path.dirname(scriptPath), '..');
|
|
|
|
function parseArguments(argv) {
|
|
const options = {
|
|
metadataPath: path.join(frontendRoot, 'desktop-release.json'),
|
|
destinationDirectory: path.join(frontendRoot, 'downloads'),
|
|
checkOnly: false
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
if (argument === '--check-only') {
|
|
options.checkOnly = true;
|
|
continue;
|
|
}
|
|
if (argument === '--metadata' || argument === '--destination-dir') {
|
|
const value = argv[index + 1];
|
|
if (!value) throw new Error(`${argument} requires a path`);
|
|
index += 1;
|
|
if (argument === '--metadata') options.metadataPath = path.resolve(value);
|
|
if (argument === '--destination-dir') options.destinationDirectory = path.resolve(value);
|
|
continue;
|
|
}
|
|
throw new Error(`Unknown argument: ${argument}`);
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
export function validateMetadata(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new Error('Desktop release metadata must be an object');
|
|
}
|
|
|
|
const metadata = {
|
|
version: String(value.version || '').trim(),
|
|
tag: String(value.tag || '').trim(),
|
|
asset: String(value.asset || '').trim(),
|
|
downloadUrl: String(value.downloadUrl || '').trim(),
|
|
publicPath: String(value.publicPath || '').trim(),
|
|
sha256: String(value.sha256 || '').trim().toLowerCase(),
|
|
size: Number(value.size)
|
|
};
|
|
|
|
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(metadata.version)) {
|
|
throw new Error('Desktop release version is invalid');
|
|
}
|
|
if (metadata.tag !== `v${metadata.version}`) {
|
|
throw new Error('Desktop release tag must match the version');
|
|
}
|
|
if (!metadata.asset || path.basename(metadata.asset) !== metadata.asset) {
|
|
throw new Error('Desktop release asset must be a file name');
|
|
}
|
|
if (!/\.exe$/i.test(metadata.asset)) {
|
|
throw new Error('Desktop release asset must be an EXE file');
|
|
}
|
|
if (!/^[a-f0-9]{64}$/.test(metadata.sha256)) {
|
|
throw new Error('Desktop release SHA256 is invalid');
|
|
}
|
|
if (!Number.isSafeInteger(metadata.size) || metadata.size <= 0) {
|
|
throw new Error('Desktop release size is invalid');
|
|
}
|
|
|
|
let releaseUrl;
|
|
try {
|
|
releaseUrl = new URL(metadata.downloadUrl);
|
|
} catch {
|
|
throw new Error('Desktop release download URL is invalid');
|
|
}
|
|
if (releaseUrl.protocol !== 'https:') {
|
|
throw new Error('Desktop release download URL must use HTTPS');
|
|
}
|
|
if (decodeURIComponent(path.basename(releaseUrl.pathname)) !== metadata.asset) {
|
|
throw new Error('Desktop release URL does not match the asset name');
|
|
}
|
|
if (metadata.publicPath !== `/downloads/${metadata.asset}`) {
|
|
throw new Error('Desktop release public path does not match the asset name');
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
|
|
export async function computeFileSha256(filePath) {
|
|
const hash = createHash('sha256');
|
|
for await (const chunk of createReadStream(filePath)) {
|
|
hash.update(chunk);
|
|
}
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
async function inspectInstaller(filePath, metadata) {
|
|
try {
|
|
const fileStats = await stat(filePath);
|
|
if (!fileStats.isFile() || fileStats.size !== metadata.size) {
|
|
return { valid: false, reason: 'size_mismatch' };
|
|
}
|
|
const digest = await computeFileSha256(filePath);
|
|
return {
|
|
valid: digest === metadata.sha256,
|
|
reason: digest === metadata.sha256 ? 'verified' : 'sha256_mismatch'
|
|
};
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return { valid: false, reason: 'missing' };
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function downloadInstaller(metadata, temporaryPath) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 120_000);
|
|
try {
|
|
const response = await fetch(metadata.downloadUrl, {
|
|
headers: { 'user-agent': 'wanwanyun-release-sync/1.0' },
|
|
redirect: 'follow',
|
|
signal: controller.signal
|
|
});
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`Desktop release download failed with HTTP ${response.status}`);
|
|
}
|
|
if (new URL(response.url).protocol !== 'https:') {
|
|
throw new Error('Desktop release redirected to an insecure URL');
|
|
}
|
|
|
|
const hash = createHash('sha256');
|
|
const output = await open(temporaryPath, 'wx', 0o600);
|
|
let downloadedBytes = 0;
|
|
try {
|
|
for await (const chunk of Readable.fromWeb(response.body)) {
|
|
const buffer = Buffer.from(chunk);
|
|
downloadedBytes += buffer.length;
|
|
hash.update(buffer);
|
|
let offset = 0;
|
|
while (offset < buffer.length) {
|
|
const { bytesWritten } = await output.write(buffer, offset);
|
|
if (bytesWritten <= 0) throw new Error('Desktop release write made no progress');
|
|
offset += bytesWritten;
|
|
}
|
|
}
|
|
await output.sync();
|
|
} finally {
|
|
await output.close();
|
|
}
|
|
|
|
const digest = hash.digest('hex');
|
|
if (downloadedBytes !== metadata.size) {
|
|
throw new Error(`Desktop release size mismatch: expected ${metadata.size}, received ${downloadedBytes}`);
|
|
}
|
|
if (digest !== metadata.sha256) {
|
|
throw new Error('Desktop release SHA256 verification failed');
|
|
}
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
async function replaceInstaller(temporaryPath, destinationPath) {
|
|
try {
|
|
await rename(temporaryPath, destinationPath);
|
|
} catch (error) {
|
|
if (!['EEXIST', 'EPERM'].includes(error?.code)) throw error;
|
|
await rm(destinationPath, { force: true });
|
|
await rename(temporaryPath, destinationPath);
|
|
}
|
|
await chmod(destinationPath, 0o644);
|
|
}
|
|
|
|
export async function syncDesktopRelease(options) {
|
|
const rawMetadata = JSON.parse(await readFile(options.metadataPath, 'utf8'));
|
|
const metadata = validateMetadata(rawMetadata);
|
|
const destinationDirectory = path.resolve(options.destinationDirectory);
|
|
const destinationPath = path.join(destinationDirectory, metadata.asset);
|
|
await mkdir(destinationDirectory, { recursive: true });
|
|
|
|
const existing = await inspectInstaller(destinationPath, metadata);
|
|
if (existing.valid) {
|
|
return { status: 'verified', destinationPath, metadata };
|
|
}
|
|
if (options.checkOnly) {
|
|
throw new Error(`Desktop release check failed: ${existing.reason}`);
|
|
}
|
|
|
|
const temporaryPath = path.join(
|
|
destinationDirectory,
|
|
`.${metadata.asset}.${process.pid}.${Date.now()}.tmp`
|
|
);
|
|
try {
|
|
await downloadInstaller(metadata, temporaryPath);
|
|
await replaceInstaller(temporaryPath, destinationPath);
|
|
} catch (error) {
|
|
await rm(temporaryPath, { force: true });
|
|
throw error;
|
|
}
|
|
|
|
return { status: 'downloaded', destinationPath, metadata };
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArguments(process.argv.slice(2));
|
|
const result = await syncDesktopRelease(options);
|
|
console.log(
|
|
`Desktop release ${result.status}: ${result.metadata.asset} ` +
|
|
`(${result.metadata.sha256})`
|
|
);
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
|
|
main().catch((error) => {
|
|
console.error(`Desktop release sync failed: ${error.message}`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|