From 776c7c062936db0d769f607c67b82fe9334de2db Mon Sep 17 00:00:00 2001 From: Aiden <68633820+awils27@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:24:36 +1000 Subject: [PATCH] Upload script --- .env.r2.example | 13 +++ .gitignore | 1 + README.md | 10 ++ package.json | 5 +- scripts/upload-r2.mjs | 261 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 .env.r2.example create mode 100644 scripts/upload-r2.mjs diff --git a/.env.r2.example b/.env.r2.example new file mode 100644 index 0000000..0da002f --- /dev/null +++ b/.env.r2.example @@ -0,0 +1,13 @@ +# Copy this file to .env.r2 and fill in your R2 details. +# Do not commit .env.r2; it is ignored by git. + +R2_ACCOUNT_ID=your_cloudflare_account_id +R2_ACCESS_KEY_ID=your_r2_access_key_id +R2_SECRET_ACCESS_KEY=your_r2_secret_access_key +R2_BUCKET=your-r2-bucket-name + +# Optional settings. +R2_SOURCE_DIR=vr180player +R2_PREFIX=vr180player +R2_ENDPOINT= +R2_CACHE_CONTROL=public, max-age=31536000, immutable diff --git a/.gitignore b/.gitignore index 4f48824..f6990d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.env.r2 # Generated by `npm run build`. vr180player/*.css diff --git a/README.md b/README.md index 323b45e..c7b3653 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,13 @@ npm run build ``` Edit the TypeScript source files rather than generated JavaScript. A typical CI/CD publish step should run `npm ci`, `npm run build`, then publish `vr180player/` with its generated `.js` files and CSS. + +## Upload to Cloudflare R2 +Copy `.env.r2.example` to `.env.r2`, then fill in your R2 account, bucket, and S3-compatible access key credentials. + +```sh +npm run upload:r2:dry-run +npm run deploy:r2 +``` + +By default this uploads the built `vr180player/` folder under the `vr180player/` object prefix. Change `R2_SOURCE_DIR` or `R2_PREFIX` in `.env.r2` if you want a different source folder or CDN path. diff --git a/package.json b/package.json index 8050766..f0381e8 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,11 @@ "dev": "npm run build && vite --host 0.0.0.0", "build": "tsc && node scripts/copy-styles.mjs", "check": "tsc --noEmit", + "deploy:r2": "npm run build && npm run upload:r2", "test": "npm run build && node --test tests/time.test.mjs tests/projection.test.mjs tests/media-controller.test.mjs tests/texture-manager.test.mjs tests/media-adapter.test.mjs tests/hand-aim.test.mjs tests/input-mode.test.mjs tests/icons.test.mjs tests/control-panel-timing.test.mjs", - "preview": "npm run build && vite preview --host 127.0.0.1" + "preview": "npm run build && vite preview --host 127.0.0.1", + "upload:r2": "node scripts/upload-r2.mjs", + "upload:r2:dry-run": "node scripts/upload-r2.mjs --dry-run" }, "devDependencies": { "typescript": "^5.8.3", diff --git a/scripts/upload-r2.mjs b/scripts/upload-r2.mjs new file mode 100644 index 0000000..cf580ef --- /dev/null +++ b/scripts/upload-r2.mjs @@ -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'; +}