/** * Objects are basically files, like images or downloads, that have a set of metadata and permissions attached * They're served by the API from the /api/v1/object/${objectId} endpoint. * * It supports streams and buffers, depending on the use case. Buffers will likely only be used internally if * the data needs to be manipulated somehow. * * Objects are designed to be created once, and link to a single ID. For example, each user gets a single object * that's tied to their profile picture. If they want to update their profile picture, they overwrite that object. * * Permissions are a list of strings. Each permission string is in the id:permission format. Eg * anonymous:read * myUserId:read * anotherUserId:write */ import { parse as getMimeTypeBuffer } from "file-type-mime"; import Stream, { Readable, Writable } from "stream"; import { getMimeType as getMimeTypeStream } from "stream-mime-type"; import { v4 as uuidv4 } from "uuid"; export type ObjectReference = string; export type ObjectMetadata = { mime: string; permissions: string[]; userMetadata: { [key: string]: string }; }; export enum ObjectPermission { Read = "read", Write = "write", Delete = "delete", } export const ObjectPermissionPriority: Array = [ ObjectPermission.Read, ObjectPermission.Write, ObjectPermission.Delete, ]; export type Object = { mime: string; data: Source }; export type Source = Readable | Buffer; export abstract class ObjectBackend { // Interface functions, not designed to be called directly. // They don't check permissions to provide any utilities abstract fetch(id: ObjectReference): Promise; abstract write(id: ObjectReference, source: Source): Promise; abstract startWriteStream(id: ObjectReference): Promise; abstract create( id: string, source: Source, metadata: ObjectMetadata ): Promise; abstract createWithWriteStream( id: string, metadata: ObjectMetadata ): Promise; abstract delete(id: ObjectReference): Promise; abstract fetchMetadata( id: ObjectReference ): Promise; abstract writeMetadata( id: ObjectReference, metadata: ObjectMetadata ): Promise; private async fetchMimeType(source: Source) { if (source instanceof ReadableStream) { source = Readable.from(source); } if (source instanceof Readable) { const { stream, mime } = await getMimeTypeStream(source); return { source: Readable.from(stream), mime: mime }; } if (source instanceof Buffer) { const mime = getMimeTypeBuffer(new Uint8Array(source).buffer)?.mime ?? "application/octet-stream"; return { source: source, mime }; } return { source: undefined, mime: undefined }; } async createFromSource( id: string, sourceFetcher: () => Promise, metadata: { [key: string]: string }, permissions: Array ) { const { source, mime } = await this.fetchMimeType(await sourceFetcher()); if (!mime) throw new Error("Unable to calculate MIME type - is the source empty?"); await this.create(id, source, { permissions, userMetadata: metadata, mime, }); } async createWithStream( id: string, metadata: { [key: string]: string }, permissions: Array ) { return this.createWithWriteStream(id, { permissions, userMetadata: metadata, mime: "application/octet-stream", }); } async fetchWithPermissions(id: ObjectReference, userId?: string) { const metadata = await this.fetchMetadata(id); if (!metadata) return; // We only need one permission, so find instead of filter is faster const myPermissions = metadata.permissions.find((e) => { if (userId !== undefined && e.startsWith(userId)) return true; if (userId !== undefined && e.startsWith("internal")) return true; if (e.startsWith("anonymous")) return true; return false; }); if (!myPermissions) { // We do not have access to this object return; } // Because any permission can be read or up, we automatically know we can read this object // So just straight return the object const source = await this.fetch(id); if (!source) return undefined; const object: Object = { data: source, mime: metadata.mime, }; return object; } // If we need to fetch a remote resource, it doesn't make sense // to immediately fetch the object, *then* check permissions. // Instead the caller can pass a simple anonymous funciton, like // () => $dropFetch('/my-image'); // And if we actually have permission to write, it fetches it then. async writeWithPermissions( id: ObjectReference, sourceFetcher: () => Promise, userId?: string ) { const metadata = await this.fetchMetadata(id); if (!metadata) return false; const myPermissions = metadata.permissions .filter((e) => { if (userId !== undefined && e.startsWith(userId)) return true; if (userId !== undefined && e.startsWith("internal")) return true; if (e.startsWith("anonymous")) return true; return false; }) // Strip IDs from permissions .map((e) => e.split(":").at(1)) // Map to priority according to array .map((e) => ObjectPermissionPriority.findIndex((c) => c === e)); const requiredPermissionIndex = 1; const hasPermission = myPermissions.find((e) => e >= requiredPermissionIndex) != undefined; if (!hasPermission) return false; const source = await sourceFetcher(); const result = await this.write(id, source); return result; } async deleteWithPermission(id: ObjectReference, userId?: string) { const metadata = await this.fetchMetadata(id); if (!metadata) return false; const myPermissions = metadata.permissions .filter((e) => { if (userId !== undefined && e.startsWith(userId)) return true; if (userId !== undefined && e.startsWith("internal")) return true; if (e.startsWith("anonymous")) return true; return false; }) // Strip IDs from permissions .map((e) => e.split(":").at(1)) // Map to priority according to array .map((e) => ObjectPermissionPriority.findIndex((c) => c === e)); const requiredPermissionIndex = 2; const hasPermission = myPermissions.find((e) => e >= requiredPermissionIndex) != undefined; if (!hasPermission) return false; const result = await this.delete(id); return result; } }