From de388a937a9f955d2520073d83ad3933634b5811 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Wed, 9 Oct 2024 13:47:28 +1100 Subject: [PATCH] object storage interface + utility functions New ObjectBackend class that requires implementors to specify a few basic functions, and it handles the permission logic on top of that. Hopefully there is enough abstraction to suite further use cases! --- package.json | 1 + prisma/schema.prisma | 3 + server/internal/metadata/types.d.ts | 18 ++-- server/internal/objects/index.ts | 142 ++++++++++++++++++++++++++++ yarn.lock | 22 +++++ 5 files changed, 177 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index c15aaca..e12a51d 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "moment": "^2.30.1", "nuxt": "^3.13.0", "prisma": "^5.20.0", + "stream": "^0.0.3", "turndown": "^7.2.0", "uuid": "^10.0.0", "vue": "latest", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 26dbd2b..0488f15 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,6 +18,9 @@ model User { username String @unique admin Boolean @default(false) + email String + displayName String + authMecs LinkedAuthMec[] clients Client[] } diff --git a/server/internal/metadata/types.d.ts b/server/internal/metadata/types.d.ts index 9792ac0..8e3d00d 100644 --- a/server/internal/metadata/types.d.ts +++ b/server/internal/metadata/types.d.ts @@ -1,4 +1,5 @@ import { Developer, Publisher } from "@prisma/client"; +import { ObjectReference } from "../objects"; export interface GameMetadataSearchResult { id: string; @@ -14,7 +15,6 @@ export interface GameMetadataSource { } export type InternalGameMetadataResult = GameMetadataSearchResult & GameMetadataSource; -export type RemoteObject = string; export interface GameMetadata { id: string; @@ -31,10 +31,10 @@ export interface GameMetadata { reviewRating: number; // Created with another utility function - icon: RemoteObject, - banner: RemoteObject, - art: RemoteObject[], - screenshots: RemoteObject[], + icon: ObjectReference, + banner: ObjectReference, + art: ObjectReference[], + screenshots: ObjectReference[], } export interface PublisherMetadata { @@ -43,8 +43,8 @@ export interface PublisherMetadata { shortDescription: string; description: string; - logo: RemoteObject; - banner: RemoteObject; + logo: ObjectReference; + banner: ObjectReference; } export type DeveloperMetadata = PublisherMetadata; @@ -55,12 +55,12 @@ export interface _FetchGameMetadataParams { publisher: (query: string) => Promise developer: (query: string) => Promise - createObject: (url: string) => RemoteObject + createObject: (url: string) => ObjectReference } export interface _FetchPublisherMetadataParams { query: string; - createObject: (url: string) => RemoteObject; + createObject: (url: string) => ObjectReference; } export type _FetchDeveloperMetadataParams = _FetchPublisherMetadataParams; \ No newline at end of file diff --git a/server/internal/objects/index.ts b/server/internal/objects/index.ts index e69de29..bf71b6f 100644 --- a/server/internal/objects/index.ts +++ b/server/internal/objects/index.ts @@ -0,0 +1,142 @@ +/** + * 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 { Readable } from "stream"; + +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 Source = Readable | Buffer; + +export type Object = { metadata: ObjectMetadata }; +export type StreamObject = Object & { stream: Readable }; +export type BufferObject = Object & { buffer: 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 create( + source: Source, + metadata: ObjectMetadata + ): Promise; + abstract delete(id: ObjectReference): Promise; + abstract fetchMetadata( + id: ObjectReference + ): Promise; + abstract writeMetadata( + id: ObjectReference, + metadata: ObjectMetadata + ): Promise; + + 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 (e.startsWith(userId)) 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 + return await this.fetch(id); + } + + // 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 + // () => $fetch('/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; + + const myPermissions = metadata.permissions + .filter((e) => { + if (e.startsWith(userId)) 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 (e.startsWith(userId)) 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; + } +} diff --git a/yarn.lock b/yarn.lock index f1e0562..002cbd5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -296,6 +296,16 @@ dependencies: mime "^3.0.0" +"@drop/droplet-linux-x64-gnu@0.3.2": + version "0.3.2" + resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-linux-x64-gnu/-/@drop/droplet-linux-x64-gnu-0.3.2.tgz#9be12f1e61df67837bb225ec67cc98f5af8f703b" + integrity sha1-m+EvHmHfZ4N7siXsZ8yY9a+PcDs= + +"@drop/droplet-win32-x64-msvc@0.3.2": + version "0.3.2" + resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-win32-x64-msvc/-/@drop/droplet-win32-x64-msvc-0.3.2.tgz#dc5d0fa8334bf211666e99ca365c900d363f7823" + integrity sha1-3F0PqDNL8hFmbpnKNlyQDTY/eCM= + "@drop/droplet@^0.3.2": version "0.3.2" resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet/-/@drop/droplet-0.3.2.tgz#f57cf35e50dfd448b7837f9b4712543ff160e769" @@ -2094,6 +2104,11 @@ compatx@^0.1.8: resolved "https://registry.yarnpkg.com/compatx/-/compatx-0.1.8.tgz#af6f61910ade6ce1073c0fdff23c786bcd75c026" integrity sha512-jcbsEAR81Bt5s1qOFymBufmCbXCXbk0Ql+K5ouj6gCyx2yHlu6AgmGIi9HxfKixpUDO5bCFJUHQ5uM6ecbTebw== +component-emitter@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-2.0.0.tgz#3a137dfe66fcf2efe3eab7cb7d5f51741b3620c6" + integrity sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw== + compress-commons@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-6.0.2.tgz#26d31251a66b9d6ba23a84064ecd3a6a71d2609e" @@ -4749,6 +4764,13 @@ std-env@^3.7.0: resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.7.0.tgz#c9f7386ced6ecf13360b6c6c55b8aaa4ef7481d2" integrity sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg== +stream@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/stream/-/stream-0.0.3.tgz#3f3934a900a561ce3e2b9ffbd2819cead32699d9" + integrity sha512-aMsbn7VKrl4A2T7QAQQbzgN7NVc70vgF5INQrBXqn4dCXN1zy3L9HGgLO5s7PExmdrzTJ8uR/27aviW8or8/+A== + dependencies: + component-emitter "^2.0.0" + streamx@^2.15.0: version "2.20.1" resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.20.1.tgz#471c4f8b860f7b696feb83d5b125caab2fdbb93c"