1
0

Upload script
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
Aiden
2026-06-11 08:24:36 +10:00
parent fbdb733f13
commit 776c7c0629
5 changed files with 289 additions and 1 deletions

261
scripts/upload-r2.mjs Normal file
View File

@@ -0,0 +1,261 @@
import { createHmac, createHash } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises';
import { basename, dirname, join, relative, 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();
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: join(rootDir, process.env.R2_SOURCE_DIR?.trim() || 'vr180player')
};
}
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';
}