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

13
.env.r2.example Normal file
View File

@@ -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

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules/ node_modules/
.env.r2
# Generated by `npm run build`. # Generated by `npm run build`.
vr180player/*.css vr180player/*.css

View File

@@ -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. 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.

View File

@@ -7,8 +7,11 @@
"dev": "npm run build && vite --host 0.0.0.0", "dev": "npm run build && vite --host 0.0.0.0",
"build": "tsc && node scripts/copy-styles.mjs", "build": "tsc && node scripts/copy-styles.mjs",
"check": "tsc --noEmit", "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", "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": { "devDependencies": {
"typescript": "^5.8.3", "typescript": "^5.8.3",

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