diff --git a/apps/browser/lib/hooks/useEvoService.ts b/apps/browser/lib/hooks/useEvoService.ts index ee1a4b74..36b1812a 100644 --- a/apps/browser/lib/hooks/useEvoService.ts +++ b/apps/browser/lib/hooks/useEvoService.ts @@ -146,7 +146,7 @@ export const useEvoService = ( const workspace = (() => { if (session?.supabaseAccessToken) { const supabase = createSupabaseBrowserClient(session.supabaseAccessToken) - return new SupabaseWorkspace(chatId, supabase.storage) + return new SupabaseWorkspace(chatId, supabase) } else { return new InMemoryWorkspace() } diff --git a/apps/browser/lib/supabase/SupabaseWorkspace.ts b/apps/browser/lib/supabase/SupabaseWorkspace.ts index e7526106..b31f3858 100644 --- a/apps/browser/lib/supabase/SupabaseWorkspace.ts +++ b/apps/browser/lib/supabase/SupabaseWorkspace.ts @@ -1,5 +1,6 @@ import { DirectoryEntry, Workspace } from "@evo-ninja/agent-utils"; -import { StorageClient } from "@supabase/storage-js"; +import { SupabaseClient } from "@supabase/supabase-js"; +import { Database } from "./dbTypes"; import * as path from "path-browserify"; const BUCKET_NAME = "workspaces"; @@ -7,27 +8,18 @@ const BUCKET_NAME = "workspaces"; export class SupabaseWorkspace implements Workspace { constructor( public readonly chatId: string, - public readonly supabaseStorage: StorageClient + public readonly supabase: SupabaseClient ) {} async writeFile(subpath: string, data: string): Promise { const path = this.toWorkspacePath(subpath); - - const { error } = await this.supabaseStorage - .from(BUCKET_NAME) - .upload(path, data, { upsert: true }); - - if (error) { - throw error; - } - - return; + await this.uploadWorkspaceFile(path, data); } async readFile(subpath: string): Promise { const path = this.toWorkspacePath(subpath); - const { data, error } = await this.supabaseStorage + const { data, error } = await this.supabase.storage .from(BUCKET_NAME) .download(path); @@ -45,7 +37,7 @@ export class SupabaseWorkspace implements Workspace { async exists(subpath: string): Promise { const path = this.toWorkspacePath(subpath); - const { data, error } = await this.supabaseStorage + const { data, error } = await this.supabase.storage .from(BUCKET_NAME) .download(path); @@ -60,13 +52,10 @@ export class SupabaseWorkspace implements Workspace { const absOldPath = this.toWorkspacePath(oldPath); const absNewPath = this.toWorkspacePath(newPath); - const { error } = await this.supabaseStorage - .from(BUCKET_NAME) - .move(absOldPath, absNewPath); - - if (error) { - throw error; - } + await this.renameWorkspaceFile( + absOldPath, + absNewPath + ); } async mkdir(_subpath: string): Promise {} @@ -77,48 +66,28 @@ export class SupabaseWorkspace implements Workspace { } const path = this.toWorkspacePath(subpath); - - const { data: list, error: listError } = await this.supabaseStorage - .from(BUCKET_NAME) - .list(path); - - if (listError) { - throw listError; - } - const filesToRemove = list.map((x) => `${path}/${x.name}`); + const filesToRemove = await this.listWorkspaceFiles(path); if (filesToRemove.length === 0) { return; } - const { error: removeError } = await this.supabaseStorage - .from(BUCKET_NAME) - .remove(filesToRemove); + await this.removeWorkspaceFiles(filesToRemove); - if (removeError) { - throw removeError; + if (path === this.toWorkspacePath("")) { + await this.supabase.storage.deleteBucket(this.chatId); } } async readdir(subpath: string): Promise { const path = this.toWorkspacePath(subpath); + const fileNames = (await this.listWorkspaceFiles(path)) + .map((file) => file.replace(`${path}/`, "")); - const { data, error } = await this.supabaseStorage - .from(BUCKET_NAME) - .list(path); - - if (error) { - throw error; - } - - if (!data) { - throw new Error("Directory not found"); - } - - return data - .filter((x) => !x.name.startsWith(".")) + return fileNames + .filter((x) => !x.startsWith(".")) .map((x) => ({ - name: x.name, + name: x, type: "file", })); } @@ -126,7 +95,7 @@ export class SupabaseWorkspace implements Workspace { async appendFile(subpath: string, data: string): Promise { const path = this.toWorkspacePath(subpath); - const { data: existingData, error: readError } = await this.supabaseStorage + const { data: existingData, error: readError } = await this.supabase.storage .from(BUCKET_NAME) .download(path); @@ -136,25 +105,12 @@ export class SupabaseWorkspace implements Workspace { const newData = existingData ? existingData.text() + data : data; - const { error: writeError } = await this.supabaseStorage - .from(BUCKET_NAME) - .upload(path, newData, { upsert: true }); - - if (writeError) { - throw writeError; - } + await this.uploadWorkspaceFile(path, newData); } async rm(subpath: string): Promise { const path = this.toWorkspacePath(subpath); - - const { error } = await this.supabaseStorage - .from(BUCKET_NAME) - .remove([path]); - - if (error) { - throw error; - } + await this.removeWorkspaceFiles([path]); } async exec( @@ -168,6 +124,86 @@ export class SupabaseWorkspace implements Workspace { } private toWorkspacePath(subpath: string): string { - return path.resolve("/", this.chatId, subpath).slice(1); + const result = path.resolve(path.join(this.chatId, subpath)); + return result.replace(/^\/+/, ""); + } + + private async listWorkspaceFiles(path: string): Promise { + const { data: list, error: listError } = await this.supabase + .from("workspace_files") + .select("path, chat_id") + .eq("chat_id", this.chatId); + + if (listError) { + throw listError; + } + + return list + .filter((x) => x) + .filter((x) => x.path.includes(path)) + .map((x) => x.path) as string[]; + } + + private async uploadWorkspaceFile(path: string, data: string): Promise { + const { error: storageError } = await this.supabase.storage + .from(BUCKET_NAME) + .upload(path, data, { upsert: true }); + + if (storageError) { + throw storageError; + } + + const { error: indexError } = await this.supabase + .from("workspace_files") + .upsert({ + chat_id: this.chatId, + path: path + }, { + onConflict: "chat_id, path" + }); + + if (indexError) { + throw indexError; + } + } + + private async removeWorkspaceFiles(paths: string[]): Promise { + const { error: storageError } = await this.supabase.storage + .from(BUCKET_NAME) + .remove(paths); + + if (storageError) { + throw storageError; + } + + const { error: indexError } = await this.supabase + .from("workspace_files") + .delete() + .eq("chat_id", this.chatId) + .in("path", paths); + + if (indexError) { + throw indexError; + } + } + + private async renameWorkspaceFile(oldPath: string, newPath: string): Promise { + const { error: storageError } = await this.supabase.storage + .from(BUCKET_NAME) + .move(oldPath, newPath); + + if (storageError) { + throw storageError; + } + + const { error: indexError } = await this.supabase + .from("workspace_files") + .update({ path: newPath }) + .eq("chat_id", this.chatId) + .eq("path", oldPath) + + if (indexError) { + throw indexError; + } } } diff --git a/apps/browser/lib/supabase/dbTypes.ts b/apps/browser/lib/supabase/dbTypes.ts index 6f3a26ba..ce70613d 100644 --- a/apps/browser/lib/supabase/dbTypes.ts +++ b/apps/browser/lib/supabase/dbTypes.ts @@ -7,6 +7,180 @@ export type Json = | Json[] export interface Database { + graphql_public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + graphql: { + Args: { + operationName?: string + query?: string + variables?: Json + extensions?: Json + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } + next_auth: { + Tables: { + accounts: { + Row: { + access_token: string | null + expires_at: number | null + id: string + id_token: string | null + oauth_token: string | null + oauth_token_secret: string | null + provider: string + providerAccountId: string + refresh_token: string | null + scope: string | null + session_state: string | null + token_type: string | null + type: string + userId: string | null + } + Insert: { + access_token?: string | null + expires_at?: number | null + id?: string + id_token?: string | null + oauth_token?: string | null + oauth_token_secret?: string | null + provider: string + providerAccountId: string + refresh_token?: string | null + scope?: string | null + session_state?: string | null + token_type?: string | null + type: string + userId?: string | null + } + Update: { + access_token?: string | null + expires_at?: number | null + id?: string + id_token?: string | null + oauth_token?: string | null + oauth_token_secret?: string | null + provider?: string + providerAccountId?: string + refresh_token?: string | null + scope?: string | null + session_state?: string | null + token_type?: string | null + type?: string + userId?: string | null + } + Relationships: [ + { + foreignKeyName: "accounts_userId_fkey" + columns: ["userId"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + } + ] + } + sessions: { + Row: { + expires: string + id: string + sessionToken: string + userId: string | null + } + Insert: { + expires: string + id?: string + sessionToken: string + userId?: string | null + } + Update: { + expires?: string + id?: string + sessionToken?: string + userId?: string | null + } + Relationships: [ + { + foreignKeyName: "sessions_userId_fkey" + columns: ["userId"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + } + ] + } + users: { + Row: { + email: string | null + emailVerified: string | null + id: string + image: string | null + name: string | null + } + Insert: { + email?: string | null + emailVerified?: string | null + id?: string + image?: string | null + name?: string | null + } + Update: { + email?: string | null + emailVerified?: string | null + id?: string + image?: string | null + name?: string | null + } + Relationships: [] + } + verification_tokens: { + Row: { + expires: string + identifier: string | null + token: string + } + Insert: { + expires: string + identifier?: string | null + token: string + } + Update: { + expires?: string + identifier?: string | null + token?: string + } + Relationships: [] + } + } + Views: { + [_ in never]: never + } + Functions: { + uid: { + Args: Record + Returns: string + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } public: { Tables: { chats: { @@ -298,6 +472,42 @@ export interface Database { } ] } + workspace_files: { + Row: { + chat_id: string + id: string + path: string + user_id: string | null + } + Insert: { + chat_id: string + id?: string + path: string + user_id?: string | null + } + Update: { + chat_id?: string + id?: string + path?: string + user_id?: string | null + } + Relationships: [ + { + foreignKeyName: "workspace_files_chat_id_fkey" + columns: ["chat_id"] + isOneToOne: false + referencedRelation: "chats" + referencedColumns: ["id"] + }, + { + foreignKeyName: "workspace_files_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + } + ] + } } Views: { [_ in never]: never @@ -312,6 +522,185 @@ export interface Database { [_ in never]: never } } + storage: { + Tables: { + buckets: { + Row: { + allowed_mime_types: string[] | null + avif_autodetection: boolean | null + created_at: string | null + file_size_limit: number | null + id: string + name: string + owner: string | null + owner_id: string | null + public: boolean | null + updated_at: string | null + } + Insert: { + allowed_mime_types?: string[] | null + avif_autodetection?: boolean | null + created_at?: string | null + file_size_limit?: number | null + id: string + name: string + owner?: string | null + owner_id?: string | null + public?: boolean | null + updated_at?: string | null + } + Update: { + allowed_mime_types?: string[] | null + avif_autodetection?: boolean | null + created_at?: string | null + file_size_limit?: number | null + id?: string + name?: string + owner?: string | null + owner_id?: string | null + public?: boolean | null + updated_at?: string | null + } + Relationships: [] + } + migrations: { + Row: { + executed_at: string | null + hash: string + id: number + name: string + } + Insert: { + executed_at?: string | null + hash: string + id: number + name: string + } + Update: { + executed_at?: string | null + hash?: string + id?: number + name?: string + } + Relationships: [] + } + objects: { + Row: { + bucket_id: string | null + created_at: string | null + id: string + last_accessed_at: string | null + metadata: Json | null + name: string | null + owner: string | null + owner_id: string | null + path_tokens: string[] | null + updated_at: string | null + version: string | null + } + Insert: { + bucket_id?: string | null + created_at?: string | null + id?: string + last_accessed_at?: string | null + metadata?: Json | null + name?: string | null + owner?: string | null + owner_id?: string | null + path_tokens?: string[] | null + updated_at?: string | null + version?: string | null + } + Update: { + bucket_id?: string | null + created_at?: string | null + id?: string + last_accessed_at?: string | null + metadata?: Json | null + name?: string | null + owner?: string | null + owner_id?: string | null + path_tokens?: string[] | null + updated_at?: string | null + version?: string | null + } + Relationships: [ + { + foreignKeyName: "objects_bucketId_fkey" + columns: ["bucket_id"] + isOneToOne: false + referencedRelation: "buckets" + referencedColumns: ["id"] + } + ] + } + } + Views: { + [_ in never]: never + } + Functions: { + can_insert_object: { + Args: { + bucketid: string + name: string + owner: string + metadata: Json + } + Returns: undefined + } + extension: { + Args: { + name: string + } + Returns: string + } + filename: { + Args: { + name: string + } + Returns: string + } + foldername: { + Args: { + name: string + } + Returns: unknown + } + get_size_by_bucket: { + Args: Record + Returns: { + size: number + bucket_id: string + }[] + } + search: { + Args: { + prefix: string + bucketname: string + limits?: number + levels?: number + offsets?: number + search?: string + sortcolumn?: string + sortorder?: string + } + Returns: { + name: string + id: string + updated_at: string + created_at: string + last_accessed_at: string + metadata: Json + }[] + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } } export type Tables< diff --git a/apps/browser/package.json b/apps/browser/package.json index 1d0d5279..8912edc0 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -8,7 +8,7 @@ "prebuild": "yarn build:scripts && yarn build:env", "build:scripts": "node scripts/build.scripts.js", "build:env": "node scripts/build.env.js", - "db:generate-types": "supabase gen types typescript --local --schema public > lib/supabase/dbTypes.ts", + "db:generate-types": "supabase gen types typescript --local > lib/supabase/dbTypes.ts", "db:start": "supabase start", "db:reset": "supabase db reset", "db:stop": "supabase stop", diff --git a/apps/browser/supabase/migrations/20240124165527_workspace_files.sql b/apps/browser/supabase/migrations/20240124165527_workspace_files.sql new file mode 100644 index 00000000..5db883f0 --- /dev/null +++ b/apps/browser/supabase/migrations/20240124165527_workspace_files.sql @@ -0,0 +1,41 @@ +CREATE TABLE "public"."workspace_files" ( + "id" "uuid" DEFAULT "extensions"."uuid_generate_v4"() NOT NULL, + "chat_id" "uuid" NOT NULL, + "path" text NOT NULL, + PRIMARY KEY ("id"), + FOREIGN KEY ("chat_id") REFERENCES "public"."chats"("id") ON DELETE CASCADE +); + +ALTER TABLE + "public"."workspace_files" enable ROW LEVEL SECURITY; + +ALTER TABLE "public"."workspace_files" + ADD CONSTRAINT unique_chat_id_path UNIQUE (chat_id, path); + +CREATE INDEX workspace_files_chat_id_idx ON "public"."workspace_files" USING btree ("chat_id"); + +INSERT INTO "public"."workspace_files" ("chat_id", "path") +SELECT + "path_tokens"[1]::uuid as "chat_id", + "name" as "path" +from + storage.objects +where + bucket_id = 'workspaces'; + +CREATE POLICY "Users can only manage their own workspace_files" ON "public"."workspace_files" +USING ( + EXISTS ( + SELECT 1 + FROM public.chats + WHERE chats.id = chat_id AND + chats.user_id = auth.uid() + ) +) WITH CHECK ( + EXISTS ( + SELECT 1 + FROM public.chats + WHERE chats.id = chat_id AND + chats.user_id = auth.uid() + ) +)