forked from EXT/VR180-Web-Player
285 lines
7.7 KiB
JavaScript
285 lines
7.7 KiB
JavaScript
import { createHmac, createHash } from 'node:crypto';
|
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
const args = new Set(process.argv.slice(2));
|
|
|
|
await loadEnvFile(join(rootDir, '.env.r2'));
|
|
|
|
const config = getConfig();
|
|
await assertReadableSourceDir(config.sourceDir);
|
|
const files = await listFiles(config.sourceDir);
|
|
|
|
if (files.length === 0) {
|
|
console.warn(`R2_UPLOAD: No files found in ${config.sourceDir}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
for (const filePath of files) {
|
|
const objectKey = getObjectKey(config.sourceDir, filePath, config.prefix);
|
|
if (config.dryRun) {
|
|
console.log(`R2_UPLOAD dry-run: ${filePath} -> r2://${config.bucket}/${objectKey}`);
|
|
continue;
|
|
}
|
|
|
|
await uploadFile(config, filePath, objectKey);
|
|
console.log(`R2_UPLOAD: ${filePath} -> r2://${config.bucket}/${objectKey}`);
|
|
}
|
|
|
|
console.log(`R2_UPLOAD: ${config.dryRun ? 'Checked' : 'Uploaded'} ${files.length} file(s).`);
|
|
|
|
function getConfig() {
|
|
const accountId = requireEnv('R2_ACCOUNT_ID');
|
|
const accessKeyId = requireEnv('R2_ACCESS_KEY_ID');
|
|
const secretAccessKey = requireEnv('R2_SECRET_ACCESS_KEY');
|
|
const bucket = requireEnv('R2_BUCKET');
|
|
const endpoint = process.env.R2_ENDPOINT?.trim() ||
|
|
`https://${accountId}.r2.cloudflarestorage.com`;
|
|
|
|
return {
|
|
accessKeyId,
|
|
bucket,
|
|
cacheControl: process.env.R2_CACHE_CONTROL?.trim() || 'public, max-age=31536000, immutable',
|
|
dryRun: args.has('--dry-run') || process.env.R2_DRY_RUN === 'true',
|
|
endpoint: endpoint.replace(/\/+$/, ''),
|
|
prefix: trimSlashes(process.env.R2_PREFIX ?? 'vr180player'),
|
|
secretAccessKey,
|
|
sourceDir: resolve(rootDir, process.env.R2_SOURCE_DIR?.trim() || 'vr180player')
|
|
};
|
|
}
|
|
|
|
async function assertReadableSourceDir(sourceDir) {
|
|
try {
|
|
const sourceStats = await stat(sourceDir);
|
|
if (!sourceStats.isDirectory()) {
|
|
console.error(`R2_UPLOAD: R2_SOURCE_DIR is not a directory: ${sourceDir}`);
|
|
process.exit(1);
|
|
}
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') {
|
|
console.error([
|
|
`R2_UPLOAD: Source directory not found: ${sourceDir}`,
|
|
'R2_SOURCE_DIR is a local folder to upload, not the R2 bucket name.',
|
|
'If "VR-180" is your bucket name, set R2_BUCKET=VR-180 and leave R2_SOURCE_DIR=vr180player.',
|
|
'If vr180player/ is missing, run npm run build first.'
|
|
].join('\n'));
|
|
process.exit(1);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function loadEnvFile(filePath) {
|
|
let contents;
|
|
try {
|
|
contents = await readFile(filePath, 'utf8');
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') {
|
|
return;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
for (const line of contents.split(/\r?\n/)) {
|
|
const trimmedLine = line.trim();
|
|
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
|
continue;
|
|
}
|
|
|
|
const equalsIndex = trimmedLine.indexOf('=');
|
|
if (equalsIndex === -1) {
|
|
continue;
|
|
}
|
|
|
|
const key = trimmedLine.slice(0, equalsIndex).trim();
|
|
const value = unquoteEnvValue(trimmedLine.slice(equalsIndex + 1).trim());
|
|
if (key && process.env[key] === undefined) {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
function requireEnv(name) {
|
|
const value = process.env[name]?.trim();
|
|
if (!value) {
|
|
throw new Error(`R2_UPLOAD: Missing required ${name}. Add it to .env.r2 or your environment.`);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function unquoteEnvValue(value) {
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
return value.slice(1, -1);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
async function listFiles(directory) {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const files = await Promise.all(entries.map(async (entry) => {
|
|
const entryPath = join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
return listFiles(entryPath);
|
|
}
|
|
|
|
if (entry.isFile()) {
|
|
return [entryPath];
|
|
}
|
|
|
|
return [];
|
|
}));
|
|
|
|
return files.flat();
|
|
}
|
|
|
|
function getObjectKey(sourceDir, filePath, prefix) {
|
|
const relativePath = relative(sourceDir, filePath).split(sep).join('/');
|
|
return [prefix, relativePath].filter(Boolean).join('/');
|
|
}
|
|
|
|
async function uploadFile(config, filePath, objectKey) {
|
|
const body = await readFile(filePath);
|
|
const contentType = getContentType(filePath);
|
|
const headers = signPutObject({
|
|
body,
|
|
cacheControl: config.cacheControl,
|
|
contentType,
|
|
endpoint: config.endpoint,
|
|
accessKeyId: config.accessKeyId,
|
|
bucket: config.bucket,
|
|
objectKey,
|
|
secretAccessKey: config.secretAccessKey
|
|
});
|
|
|
|
const response = await fetch(getObjectUrl(config.endpoint, config.bucket, objectKey), {
|
|
body,
|
|
headers,
|
|
method: 'PUT'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const responseText = await response.text();
|
|
throw new Error(`R2_UPLOAD: Failed to upload ${objectKey}: ${response.status} ${response.statusText}\n${responseText}`);
|
|
}
|
|
}
|
|
|
|
function signPutObject({
|
|
body,
|
|
cacheControl,
|
|
contentType,
|
|
endpoint,
|
|
accessKeyId,
|
|
bucket,
|
|
objectKey,
|
|
secretAccessKey
|
|
}) {
|
|
const url = new URL(getObjectUrl(endpoint, bucket, objectKey));
|
|
const now = new Date();
|
|
const amzDate = toAmzDate(now);
|
|
const dateStamp = amzDate.slice(0, 8);
|
|
const payloadHash = sha256Hex(body);
|
|
const canonicalHeaders = [
|
|
['cache-control', cacheControl],
|
|
['content-type', contentType],
|
|
['host', url.host],
|
|
['x-amz-content-sha256', payloadHash],
|
|
['x-amz-date', amzDate]
|
|
];
|
|
const signedHeaders = canonicalHeaders.map(([key]) => key).join(';');
|
|
const canonicalRequest = [
|
|
'PUT',
|
|
url.pathname,
|
|
'',
|
|
canonicalHeaders.map(([key, value]) => `${key}:${value.trim()}\n`).join(''),
|
|
signedHeaders,
|
|
payloadHash
|
|
].join('\n');
|
|
const credentialScope = `${dateStamp}/auto/s3/aws4_request`;
|
|
const stringToSign = [
|
|
'AWS4-HMAC-SHA256',
|
|
amzDate,
|
|
credentialScope,
|
|
sha256Hex(canonicalRequest)
|
|
].join('\n');
|
|
const signingKey = getSignatureKey(secretAccessKey, dateStamp, 'auto', 's3');
|
|
const signature = hmacHex(signingKey, stringToSign);
|
|
const headers = new Headers();
|
|
|
|
canonicalHeaders.forEach(([key, value]) => headers.set(key, value));
|
|
headers.set(
|
|
'authorization',
|
|
`AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
|
|
);
|
|
|
|
return headers;
|
|
}
|
|
|
|
function getObjectUrl(endpoint, bucket, objectKey) {
|
|
return `${endpoint}/${encodePathSegment(bucket)}/${encodeObjectKey(objectKey)}`;
|
|
}
|
|
|
|
function getSignatureKey(secretAccessKey, dateStamp, region, service) {
|
|
const kDate = hmac(`AWS4${secretAccessKey}`, dateStamp);
|
|
const kRegion = hmac(kDate, region);
|
|
const kService = hmac(kRegion, service);
|
|
return hmac(kService, 'aws4_request');
|
|
}
|
|
|
|
function toAmzDate(date) {
|
|
return date.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
}
|
|
|
|
function sha256Hex(value) {
|
|
return createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
function hmac(key, value) {
|
|
return createHmac('sha256', key).update(value).digest();
|
|
}
|
|
|
|
function hmacHex(key, value) {
|
|
return createHmac('sha256', key).update(value).digest('hex');
|
|
}
|
|
|
|
function encodeObjectKey(key) {
|
|
return key.split('/').map(encodePathSegment).join('/');
|
|
}
|
|
|
|
function encodePathSegment(value) {
|
|
return encodeURIComponent(value).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
}
|
|
|
|
function trimSlashes(value) {
|
|
return value.trim().replace(/^\/+|\/+$/g, '');
|
|
}
|
|
|
|
function getContentType(filePath) {
|
|
const extension = basename(filePath).toLowerCase().split('.').pop();
|
|
const types = {
|
|
css: 'text/css; charset=utf-8',
|
|
gif: 'image/gif',
|
|
html: 'text/html; charset=utf-8',
|
|
jpeg: 'image/jpeg',
|
|
jpg: 'image/jpeg',
|
|
js: 'text/javascript; charset=utf-8',
|
|
json: 'application/json; charset=utf-8',
|
|
m4v: 'video/mp4',
|
|
mp4: 'video/mp4',
|
|
png: 'image/png',
|
|
svg: 'image/svg+xml',
|
|
webm: 'video/webm',
|
|
webp: 'image/webp'
|
|
};
|
|
|
|
return types[extension] || 'application/octet-stream';
|
|
}
|