/** * 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 { type } from "arktype"; import { parse as getMimeTypeBuffer } from "file-type-mime"; import type { Writable } from "stream"; import { Readable } from "stream"; import { getMimeType as getMimeTypeStream } from "stream-mime-type"; export type ObjectReference = string; export const objectMetadata = type({ mime: "string", permissions: "string[]", userMetadata: { "[string]": "string", }, }); export type ObjectMetadata = typeof objectMetadata.infer; 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; abstract fetchHash(id: ObjectReference): Promise; abstract listAll(): Promise; abstract cleanupMetadata(): Promise; } export class ObjectHandler { private backend: ObjectBackend; constructor(backend: ObjectBackend) { this.backend = backend; } 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.backend.create(id, source, { permissions, userMetadata: metadata, mime, }); } async createWithStream( id: string, metadata: { [key: string]: string }, permissions: Array, ) { return this.backend.createWithWriteStream(id, { permissions, userMetadata: metadata, mime: "application/octet-stream", }); } // We only need one permission, so find instead of filter is faster private hasAnyPermissions(permissions: string[], userId?: string) { return !!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; }); } private fetchPermissions(permissions: string[], userId?: string) { return ( 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)) ); } /** * Fetches object, but also checks if user has perms to access it * @param id object id * @param userId user to check, or act as anon user * @returns */ async fetchWithPermissions(id: ObjectReference, userId?: string) { const metadata = await this.backend.fetchMetadata(id); if (!metadata) return; if (!this.hasAnyPermissions(metadata.permissions, userId)) 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.backend.fetch(id); if (!source) return undefined; const object: Object = { data: source, mime: metadata.mime, }; return object; } /** * Fetch object hash. Permissions check should be done on read * @param id object id * @returns */ async fetchHash(id: ObjectReference) { return await this.backend.fetchHash(id); } /** * * @param id object id * @param sourceFetcher callback used to provide image * @param userId user to check, or act as anon user * @returns * @description 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.backend.fetchMetadata(id); if (!metadata) return false; const permissions = this.fetchPermissions(metadata.permissions, userId); const requiredPermissionIndex = 1; const hasPermission = permissions.find((e) => e >= requiredPermissionIndex) != undefined; if (!hasPermission) return false; const source = await sourceFetcher(); // TODO: prevent user from overwriting existing object const result = await this.backend.write(id, source); return result; } /** * * @param id object id * @param userId user to check, or act as anon user * @returns */ async deleteWithPermission(id: ObjectReference, userId?: string) { const metadata = await this.backend.fetchMetadata(id); if (!metadata) return false; const permissions = this.fetchPermissions(metadata.permissions, userId); const requiredPermissionIndex = 2; const hasPermission = permissions.find((e) => e >= requiredPermissionIndex) != undefined; if (!hasPermission) return false; const result = await this.backend.delete(id); return result; } /** * Deletes object without checking permission * @param id * @returns */ async deleteAsSystem(id: ObjectReference) { return await this.backend.delete(id); } /** * List all objects */ async listAll() { return await this.backend.listAll(); } /** * Purges metadata for objects that no longer exist * This is useful for cleaning up metadata files that are left behinds * @returns */ async cleanupMetadata() { return await this.backend.cleanupMetadata(); } }