213 lines
5.0 KiB
JavaScript
213 lines
5.0 KiB
JavaScript
const fs = require('fs/promises');
|
|
const path = require('path');
|
|
const {
|
|
PutObjectCommand,
|
|
GetObjectCommand,
|
|
HeadObjectCommand,
|
|
ListObjectsV2Command,
|
|
DeleteObjectCommand,
|
|
} = require('@aws-sdk/client-s3');
|
|
|
|
/**
|
|
* Skip a step when its artifact is already stored.
|
|
* @param {{ exists: (key: string) => Promise<boolean> }} storage
|
|
* @param {string} key
|
|
* @param {boolean} force
|
|
*/
|
|
async function shouldSkip(storage, key, force) {
|
|
if (force) return false;
|
|
return storage.exists(key);
|
|
}
|
|
|
|
class LocalFsStorage {
|
|
/**
|
|
* @param {string} root
|
|
*/
|
|
constructor(root) {
|
|
this.root = root;
|
|
}
|
|
|
|
resolve(key) {
|
|
const full = path.resolve(this.root, key);
|
|
const root = path.resolve(this.root);
|
|
if (!full.startsWith(root + path.sep) && full !== root) {
|
|
throw new Error(`Refusing to write outside storage root: ${key}`);
|
|
}
|
|
return full;
|
|
}
|
|
|
|
async putBytes(key, body) {
|
|
const full = this.resolve(key);
|
|
await fs.mkdir(path.dirname(full), { recursive: true });
|
|
await fs.writeFile(full, body);
|
|
}
|
|
|
|
async getBytes(key) {
|
|
return fs.readFile(this.resolve(key));
|
|
}
|
|
|
|
async putJson(key, value) {
|
|
await this.putText(key, `${JSON.stringify(value, null, 2)}\n`);
|
|
}
|
|
|
|
async getJson(key) {
|
|
const text = await this.getText(key);
|
|
return JSON.parse(text);
|
|
}
|
|
|
|
async putText(key, text) {
|
|
await this.putBytes(key, Buffer.from(text, 'utf8'));
|
|
}
|
|
|
|
async getText(key) {
|
|
return fs.readFile(this.resolve(key), 'utf8');
|
|
}
|
|
|
|
async exists(key) {
|
|
try {
|
|
await fs.access(this.resolve(key));
|
|
return true;
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async delete(key) {
|
|
await fs.rm(this.resolve(key), { force: true });
|
|
}
|
|
|
|
async list(prefix) {
|
|
const root = path.resolve(this.root);
|
|
const keys = [];
|
|
async function walk(dir) {
|
|
let entries;
|
|
try {
|
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return;
|
|
throw error;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) await walk(full);
|
|
else {
|
|
const key = path.relative(root, full).split(path.sep).join('/');
|
|
if (key.startsWith(prefix)) keys.push(key);
|
|
}
|
|
}
|
|
}
|
|
await walk(root);
|
|
return keys.sort();
|
|
}
|
|
}
|
|
|
|
class S3Storage {
|
|
/**
|
|
* @param {import('@aws-sdk/client-s3').S3Client} client
|
|
* @param {string} bucket
|
|
*/
|
|
constructor(client, bucket) {
|
|
this.client = client;
|
|
this.bucket = bucket;
|
|
}
|
|
|
|
async putBytes(key, body, contentType = 'application/octet-stream') {
|
|
await this.client.send(
|
|
new PutObjectCommand({
|
|
Bucket: this.bucket,
|
|
Key: key,
|
|
Body: body,
|
|
ContentType: contentType,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async getBytes(key) {
|
|
const response = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
const parts = [];
|
|
for await (const part of response.Body) parts.push(part);
|
|
return Buffer.concat(parts);
|
|
}
|
|
|
|
async putJson(key, value) {
|
|
await this.putBytes(key, Buffer.from(`${JSON.stringify(value, null, 2)}\n`), 'application/json');
|
|
}
|
|
|
|
async getJson(key) {
|
|
return JSON.parse(await this.getText(key));
|
|
}
|
|
|
|
async putText(key, text) {
|
|
await this.putBytes(key, Buffer.from(text, 'utf8'), 'text/plain; charset=utf-8');
|
|
}
|
|
|
|
async getText(key) {
|
|
return (await this.getBytes(key)).toString('utf8');
|
|
}
|
|
|
|
async exists(key) {
|
|
try {
|
|
await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
return true;
|
|
} catch (error) {
|
|
const status = error.$metadata && error.$metadata.httpStatusCode;
|
|
if (status === 404 || error.name === 'NotFound' || error.name === 'NoSuchKey') return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async delete(key) {
|
|
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
}
|
|
|
|
async list(prefix) {
|
|
const keys = [];
|
|
let token;
|
|
do {
|
|
const response = await this.client.send(
|
|
new ListObjectsV2Command({
|
|
Bucket: this.bucket,
|
|
Prefix: prefix,
|
|
ContinuationToken: token,
|
|
}),
|
|
);
|
|
for (const object of response.Contents || []) keys.push(object.Key);
|
|
token = response.IsTruncated ? response.NextContinuationToken : undefined;
|
|
} while (token);
|
|
return keys;
|
|
}
|
|
}
|
|
|
|
class ReadStorage {
|
|
/**
|
|
* Read-only view. Chat uses this so the process has no put or delete method.
|
|
* @param {{ getBytes: Function, getText: Function, getJson: Function, exists: Function, list: Function }} storage
|
|
*/
|
|
constructor(storage) {
|
|
this.storage = storage;
|
|
}
|
|
|
|
getBytes(key) {
|
|
return this.storage.getBytes(key);
|
|
}
|
|
|
|
getText(key) {
|
|
return this.storage.getText(key);
|
|
}
|
|
|
|
getJson(key) {
|
|
return this.storage.getJson(key);
|
|
}
|
|
|
|
exists(key) {
|
|
return this.storage.exists(key);
|
|
}
|
|
|
|
list(prefix) {
|
|
return this.storage.list(prefix);
|
|
}
|
|
}
|
|
|
|
module.exports = { shouldSkip, LocalFsStorage, S3Storage, ReadStorage };
|