#!/usr/bin/env node import { createHmac, createHash } from 'node:crypto' import { createReadStream, createWriteStream, statSync } from 'node:fs' import { request } from 'node:https' import { pipeline } from 'node:stream/promises' function fail(message) { console.error(`oss_object=FAIL ${message}`) process.exit(1) } function requiredEnv(name, pattern) { const value = process.env[name] || '' if (!value || (pattern && !pattern.test(value))) fail(`invalid or missing ${name}`) return value } function encodeObjectKey(value) { if (!value || value.startsWith('/') || value.includes('..')) fail('invalid object key') return value.split('/').map(encodeURIComponent).join('/') } const [operation, objectKeyValue, filePath] = process.argv.slice(2) if (!['put', 'get', 'head', 'delete'].includes(operation || '')) { fail('usage: oss-object.mjs {put|get|head|delete} [file]') } if ((operation === 'put' || operation === 'get') && !filePath) fail(`${operation} requires a file`) const accessKey = requiredEnv('ALI_KEY') const secretKey = requiredEnv('ALI_SECRET') const region = requiredEnv('ALI_REGION', /^[a-z0-9-]+$/) const bucket = requiredEnv('ALI_BUCKET', /^[A-Za-z0-9.-]+$/) const objectKey = encodeObjectKey(objectKeyValue) const hostname = `${bucket}.${region}.aliyuncs.com` const canonicalResource = `/${bucket}/${objectKey}` const date = new Date().toUTCString() const method = operation.toUpperCase() const contentType = operation === 'put' ? 'application/octet-stream' : '' const stringToSign = `${method}\n\n${contentType}\n${date}\n${canonicalResource}` const signature = createHmac('sha1', secretKey).update(stringToSign).digest('base64') const headers = { Authorization: `OSS ${accessKey}:${signature}`, Date: date, } let expectedDigest = '' if (operation === 'put') { let details try { details = statSync(filePath) } catch (error) { fail(`cannot stat upload file: ${error.message}`) } if (!details.isFile()) fail('upload source must be a regular file') headers['Content-Type'] = contentType headers['Content-Length'] = String(details.size) expectedDigest = createHash('sha256') } const response = await new Promise((resolve, reject) => { const outgoing = request({ method, hostname, path: `/${objectKey}`, headers, timeout: 120_000, }, resolve) outgoing.once('timeout', () => outgoing.destroy(new Error('request timed out'))) outgoing.once('error', reject) if (operation === 'put') { const source = createReadStream(filePath) source.on('data', (chunk) => expectedDigest.update(chunk)) source.once('error', reject) source.pipe(outgoing) } else { outgoing.end() } }).catch((error) => fail(error.message)) const successStatus = operation === 'get' || operation === 'head' ? 200 : [200, 204].includes(response.statusCode) if (!successStatus) { let responseBody = '' for await (const chunk of response) responseBody += chunk fail(`HTTP ${response.statusCode} ${responseBody.slice(0, 240).replaceAll(/\s+/g, ' ')}`) } if (operation === 'get') { const destination = createWriteStream(filePath, { flags: 'wx', mode: 0o600 }) await pipeline(response, destination).catch((error) => fail(`download failed: ${error.message}`)) } if (operation === 'put') { for await (const _chunk of response) { // Drain the response so the request completes before reporting success. } console.log(`oss_object=PASS operation=put sha256=${expectedDigest.digest('hex')}`) } else { for await (const _chunk of response) { // GET is consumed by pipeline; other successful response bodies are empty. } console.log(`oss_object=PASS operation=${operation}`) }