// We're explicitly using 0.4.0 to be able to hijack the path encoding, which is inconsisently broken in 0.5.0 import { S3Client } from "https://deno.land/x/s3_lite_client@0.4.0/mod.ts"; import type { ClientOptions } from "https://deno.land/x/s3_lite_client@0.4.0/client.ts"; import { SpacePrimitives } from "../../common/spaces/space_primitives.ts"; import { mime } from "../deps.ts"; import { FileMeta } from "$sb/types.ts"; export class S3SpacePrimitives implements SpacePrimitives { client: S3Client; constructor(options: ClientOptions) { this.client = new S3Client(options); } private encodePath(name: string): string { return uriEscapePath(name); } private decodePath(encoded: string): string { // AWS only returns ' replace dwith ' return encoded.replaceAll("'", "'"); } async fetchFileList(): Promise { const allFiles: FileMeta[] = []; for await (const obj of this.client.listObjects({ prefix: "" })) { allFiles.push({ name: this.decodePath(obj.key), perm: "rw", lastModified: obj.lastModified.getTime(), contentType: mime.getType(obj.key) || "application/octet-stream", size: obj.size, }); } return allFiles; } async readFile( name: string, ): Promise<{ data: Uint8Array; meta: FileMeta }> { try { // console.log("Fetching object", encodeURI(name)); const obj = await this.client.getObject(this.encodePath(name)); const contentType = mime.getType(name) || "application/octet-stream"; const meta: FileMeta = { name, perm: "rw", lastModified: new Date(obj.headers.get("Last-Modified")!).getTime(), contentType, size: parseInt(obj.headers.get("Content-Length")!), }; return { data: new Uint8Array(await obj.arrayBuffer()), meta, }; } catch (e: any) { console.log("GOt error", e.message); if (e.message.includes("does not exist")) { throw new Error(`Not found`); } throw e; } } async getFileMeta(name: string): Promise { try { const stat = await this.client.statObject(this.encodePath(name)); return { name, perm: "rw", lastModified: new Date(stat.lastModified).getTime(), size: stat.size, contentType: mime.getType(name) || "application/octet-stream", }; } catch (e: any) { if (e.message.includes("404")) { throw new Error(`Not found`); } throw e; } } async writeFile( name: string, data: Uint8Array, ): Promise { if (data.byteLength === 0) { // S3 doesn't like empty files, so we'll put a space in it. Not ideal, but it works. I hope. data = new TextEncoder().encode(" "); } await this.client.putObject(this.encodePath(name), data); // TODO: Dangerous due to eventual consistency? maybe check with etag or versionid? return this.getFileMeta(name); } async deleteFile(name: string): Promise { await this.client.deleteObject(this.encodePath(name)); } } // Stolen from https://github.com/aws/aws-sdk-js/blob/master/lib/util.js export function uriEscapePath(string: string): string { return string.split("/").map(uriEscape).join("/"); } function uriEscape(string: string): string { let output = encodeURIComponent(string); output = output.replace(/[^A-Za-z0-9_.~\-%]+/g, escape); // AWS percent-encodes some extra non-standard characters in a URI output = output.replace(/[*]/g, function (ch) { return "%" + ch.charCodeAt(0).toString(16).toUpperCase(); }); return output; }