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'; }