Files
server/src/cdn/util/S3Storage.ts
T
2026-04-29 19:35:38 +02:00

128 lines
4.3 KiB
TypeScript

/*
Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
Copyright (C) 2025 Spacebar and Spacebar Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Readable } from "node:stream";
import { Storage } from "./Storage";
const readableToBuffer = (readable: Readable): Promise<Buffer> =>
new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
readable.on("data", (chunk) => chunks.push(chunk));
readable.on("error", reject);
readable.on("end", () => resolve(Buffer.concat(chunks)));
});
export class S3Storage implements Storage {
private client: unknown;
public constructor(
private region: string,
private bucket: string,
private endpoint: string,
private forcePathStyle: boolean,
private basePath?: string,
) {
const { S3 } = require("@aws-sdk/client-s3");
this.client = new S3({ region: region, endpoint: endpoint, forcePathStyle: forcePathStyle });
}
isFile(path: string): Promise<boolean> {
return this.exists(path);
}
/**
* Always return a string, to ensure consistency.
*/
get bucketBasePath() {
return this.basePath ?? "";
}
async set(path: string, data: Buffer): Promise<void> {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
await this.client.putObject({
Bucket: this.bucket,
Key: `${this.bucketBasePath}${path}`,
Body: data,
});
}
async clone(path: string, newPath: string): Promise<void> {
// TODO: does this even work?
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
await this.client.copyObject({
Bucket: this.bucket,
CopySource: `/${this.bucket}/${this.bucketBasePath}${path}`,
Key: `${this.bucketBasePath}${newPath}`,
});
}
async get(path: string): Promise<Buffer | null> {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const s3Object = await this.client.getObject({
Bucket: this.bucket,
Key: `${this.bucketBasePath ?? ""}${path}`,
});
if (!s3Object.Body) return null;
const body = s3Object.Body;
return await readableToBuffer(<Readable>body);
} catch (err) {
console.error(`[CDN] Unable to get S3 object at path ${path}.`);
console.error(err);
return null;
}
}
async delete(path: string): Promise<void> {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
await this.client.deleteObject({
Bucket: this.bucket,
Key: `${this.bucketBasePath}${path}`,
});
}
async exists(path: string): Promise<boolean> {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
await this.client.headObject({
Bucket: this.bucket,
Key: `${this.bucketBasePath}${path}`,
});
return true;
} catch (err) {
if (err && typeof err === "object" && "name" in err && (err as { [key: string]: string }).name === "NotFound") {
return false;
}
console.error(`[CDN] Unable to check existence of S3 object at path ${path}.`);
console.error(err);
return false;
}
}
async move(path: string, newPath: string): Promise<void> {
await this.clone(path, newPath);
await this.delete(path);
}
}