Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: File upload #54

Merged
merged 8 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions front/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "1.0.0",
"eciesjs": "^0.4.10",
"lucide-react": "^0.453.0",
"next": "14.2.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.4",
"sonner": "^1.7.0",
"tailwind-merge": "^2.5.3",
"tailwindcss-animate": "^1.0.7",
Expand Down
6 changes: 4 additions & 2 deletions front/src/app/(drive)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"use client";

import { SidebarTrigger } from "@/components/ui/sidebar";

export default function Home() {
return (
<>
<section className="h-full">
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
</div>
</header>
</>
</section>
);
}
6 changes: 4 additions & 2 deletions front/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { type Metadata } from "next";
import localFont from "next/font/local";
import { ReactNode } from "react";

import { Providers } from "@/layouts/providers";

import "./globals.css";

import { Toaster } from "@/components/ui/sonner";
import { Providers } from "@/layouts/providers";

const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
Expand All @@ -31,6 +32,7 @@ export default function RootLayout({
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<Providers>{children}</Providers>
<Toaster richColors />
</body>
</html>
);
Expand Down
4 changes: 2 additions & 2 deletions front/src/components/BedrockAccountMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { shrinkEtAddress } from "@/utils/ethereum";
import { shrinkEthAddress } from "@/utils/ethereum";

const BedrockAccountAvatar = () => {
const account = useAccount();
Expand All @@ -28,7 +28,7 @@ const BedrockAccountAvatar = () => {
{/*TODO: add default avatar picture*/}
<Avatar className="h-8 w-8 rounded-lg" src={ensAvatar ?? undefined} alt="CN" />
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{ensName ?? shrinkEtAddress(account.address ?? "")}</span>
<span className="truncate font-semibold">{ensName ?? shrinkEthAddress(account.address ?? "")}</span>
{privyUser?.email?.address && <span className="truncate text-xs">{privyUser?.email?.address}</span>}
</div>
</>
Expand Down
37 changes: 37 additions & 0 deletions front/src/hooks/useBedrockFileUploadDropzone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useCallback } from "react";
import { DropzoneOptions, useDropzone } from "react-dropzone";
import { toast } from "sonner";

import BedrockService, { DirectoryPath } from "@/services/bedrock";

export type FileUploadOptions = { current_directory: DirectoryPath; bedrockService: BedrockService } & DropzoneOptions;

export default function useBedrockFileUploadDropzone({
current_directory,
bedrockService,
...options
}: FileUploadOptions) {
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
if (!bedrockService) return;
const fileInfos = bedrockService.uploadFiles(current_directory, ...acceptedFiles);
toast.promise(fileInfos, {
loading: `Uploading ${acceptedFiles.length} files...`,
success: (uploadedFiles) => `Successfully uploaded ${uploadedFiles.length} files`,
error: (err) => `Failed to upload files: ${err}`,
});
const awaitedFileInfos = await fileInfos;
if (awaitedFileInfos.length === 0) return;
const fileEntries = bedrockService.saveFiles(...awaitedFileInfos);
toast.promise(fileEntries, {
loading: `Saving ${awaitedFileInfos.length} files...`,
success: (savedFiles) => `Successfully saved ${savedFiles.length} files`,
error: (err) => `Failed to save files: ${err}`,
});
},
[current_directory, bedrockService],
);

// Placing the onDrop function first allows the user to override it in the hooks' options
return useDropzone({ onDrop, ...options });
}
5 changes: 3 additions & 2 deletions front/src/layouts/watchers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { useAccountEffect } from "wagmi";

import { wagmiConfig } from "@/config/wagmi";
import { AlephService, BEDROCK_MESSAGE } from "@/services/aleph";
import { useAccountStore } from "@/stores/account";
import BedrockService from "@/services/bedrock";
import { useAccountStore } from "@/stores/bedrockAccount";

type WatchersProps = {
children: ReactNode;
Expand All @@ -24,7 +25,7 @@ export function Watchers({ children }: WatchersProps) {
return;
}

accountStore.alephService = alephService;
accountStore.connect(new BedrockService(alephService));
},
onDisconnect() {
accountStore.onDisconnect();
Expand Down
59 changes: 54 additions & 5 deletions front/src/services/aleph.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AuthenticatedAlephHttpClient } from "@aleph-sdk/client";
import { ETHAccount, getAccountFromProvider, importAccountFromPrivateKey } from "@aleph-sdk/ethereum";
import { ForgetMessage, ItemType, StoreMessage } from "@aleph-sdk/message";
import { AggregateMessage, ForgetMessage, ItemType, PostMessage, StoreMessage } from "@aleph-sdk/message";
import { PrivateKey } from "eciesjs";
import { SignMessageReturnType } from "viem";
import web3 from "web3";
Expand All @@ -15,12 +15,9 @@ const ALEPH_GENERAL_CHANNEL = env.ALEPH_GENERAL_CHANNEL;

export class AlephService {
constructor(
/* eslint-disable-next-line no-unused-vars */
private account: ETHAccount,
/* eslint-disable-next-line no-unused-vars */
private subAccountClient: AuthenticatedAlephHttpClient,
// eslint-disable-next-line no-unused-vars
private encryptionPrivateKey: PrivateKey,
public encryptionPrivateKey: PrivateKey,
Croos3r marked this conversation as resolved.
Show resolved Hide resolved
) {}

static async initialize(hash: SignMessageReturnType) {
Expand Down Expand Up @@ -119,4 +116,56 @@ export class AlephService {
async deleteFiles(itemHashes: string[]): Promise<ForgetMessage> {
return this.subAccountClient.forget({ hashes: itemHashes });
}

async createAggregate<T extends Record<string, unknown>>(key: string, content: T): Promise<AggregateMessage<T>> {
return this.subAccountClient.createAggregate({
key,
content,
channel: ALEPH_GENERAL_CHANNEL,
address: this.account.address,
});
}

async fetchAggregate<T extends z.ZodTypeAny>(key: string, schema: T) {
const { success, data, error } = schema.safeParse(
await this.subAccountClient.fetchAggregate(this.account.address, key).catch(() => {}),
Croos3r marked this conversation as resolved.
Show resolved Hide resolved
);
if (!success) throw new Error(`Invalid data from Aleph: ${error.message}`);
return data as z.infer<T>;
}

async updateAggregate<S extends z.ZodTypeAny, T extends z.infer<S>>(
key: string,
schema: S,
update_content: (content: T) => T,
): Promise<AggregateMessage<T>> {
const currentContent = await this.fetchAggregate(key, schema);
const newContent = update_content(currentContent);

return await this.createAggregate(key, newContent);
}

async createPost<T extends Record<string, unknown>>(type: string, content: T): Promise<PostMessage<T>> {
return this.subAccountClient.createPost({
postType: type,
content,
channel: ALEPH_GENERAL_CHANNEL,
});
}

async fetchPosts<T extends z.ZodTypeAny>(
type: string,
schema: T,
addresses: string[] = [this.account.address],
hashes: string[] = [],
) {
return schema.parse(
await this.subAccountClient.getPost({
channels: [ALEPH_GENERAL_CHANNEL],
types: [type],
addresses,
hashes,
}),
) as z.infer<T>[];
}
}
175 changes: 175 additions & 0 deletions front/src/services/bedrock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { toast } from "sonner";
import { z } from "zod";

import { AlephService } from "@/services/aleph";
import { EncryptionService } from "@/services/encryption";

export const FileEntrySchema = z.object({
path: z.string().regex(/^(\/[A-Za-z0-9-_+.]+)+$/, {
message: "Invalid URI format. It should start with '/', contain valid path segments and do not end with a '/'.",
}),
post_hash: z.string().regex(/^[a-f0-9]{64}$/, {
message: "Invalid hash format. It should be a 64-character hexadecimal string.",
}),
});

export const EncryptedFileEntrySchema = z.object({
path: z.string().regex(/^[a-f0-9]+$/, { message: "Invalid data, it should be encrypted and in hex format." }),
post_hash: z.string().regex(/^[a-f0-9]{64}$/, {
message: "Invalid hash format. It should be a 64-character hexadecimal string.",
}),
});

export const FileMetaSchema = z.object({
key: z.string().regex(/^[a-f0-9]{64}$/, {
message: "Invalid key format. It should be a 64-character hexadecimal string.",
}),
iv: z.string().regex(/^[a-f0-9]{32}$/, {
message: "Invalid IV format. It should be a 32-character hexadecimal string.",
}),
store_hash: z.string().regex(/^[a-f0-9]{64}$/, {
message: "Invalid hash format. It should be a 64-character hexadecimal string.",
}),
});

export const EncryptedFileMetaSchema = z.object({
key: z.string().regex(/^[a-f0-9]+$/, { message: "Invalid data, it should be encrypted and in hex format." }),
iv: z.string().regex(/^[a-f0-9]+$/, { message: "Invalid data, it should be encrypted and in hex format." }),
store_hash: z.string().regex(/^[a-f0-9]+$/, { message: "Invalid data, it should be encrypted and in hex format." }),
});

export const FileFullInfosSchema = FileEntrySchema.merge(FileMetaSchema);
export const EncryptedFileFullInfosSchema = EncryptedFileEntrySchema.merge(EncryptedFileMetaSchema);

export const FileEntriesSchema = z.object({
files: z.array(FileEntrySchema).default([]),
});

export const EncryptedFileEntriesSchema = z.object({
files: z.array(EncryptedFileEntrySchema).default([]),
});

const FILE_ENTRIES_AGGREGATE_KEY = "bedrock_file_entries";
const FILE_POST_TYPE = "bedrock_file";

export type FileEntry = z.infer<typeof FileEntrySchema>;
export type FileMeta = z.infer<typeof FileMetaSchema>;
export type EncryptedFileEntry = z.infer<typeof EncryptedFileEntrySchema>;
export type EncryptedFileMeta = z.infer<typeof EncryptedFileMetaSchema>;
export type FileFullInfos = z.infer<typeof FileFullInfosSchema>;
export type DirectoryPath = `${string}/`;

export default class BedrockService {
constructor(private alephService: AlephService) {}

async uploadFiles(directory_path: DirectoryPath, ...files: File[]): Promise<Omit<FileFullInfos, "post_hash">[]> {
const uploadedFiles = await this.fetchFileEntries();
const results = await Promise.allSettled(
files
.map((file) => ({ file, path: `${directory_path}${file.name}` }))
.filter(({ path }) => {
if (this.fileExists(uploadedFiles, path)) {
console.error(`File already exists at path: ${path}`);
Croos3r marked this conversation as resolved.
Show resolved Hide resolved
toast.error(`File already exists at path: ${path}`);
return false;
}
return true;
})
.map(({ file, path }) => ({
file,
key: EncryptionService.generateKey(),
iv: EncryptionService.generateIv(),
path,
}))
.map(({ file, key, iv, path }) => ({ file: EncryptionService.encryptFile(file, key, iv), iv, key, path }))
.map(async ({ file, key, iv, path }) => {
const { item_hash } = await this.alephService.uploadFile(await file);
return {
key: key.toString("hex"),
iv: iv.toString("hex"),
store_hash: item_hash,
path,
};
}),
);

if (results.length === 0) return Promise.reject("No files were uploaded");

return results
.filter((result, index): result is PromiseFulfilledResult<Omit<FileFullInfos, "post_hash">> => {
if (result.status === "rejected") {
console.error(`Failed to upload file: ${files[index].name}`, result.reason);
toast.error(`Failed to upload file: ${files[index].name}`);
return false;
}
return true;
})
.map((result) => result.value);
}

// The difference between this method and the uploadFiles method is that this method saves the file hash to blockchain so that it can be fetched later
async saveFiles(...fileInfos: Omit<FileFullInfos, "post_hash">[]): Promise<FileEntry[]> {
fileInfos = z.array(FileFullInfosSchema.omit({ post_hash: true })).parse(fileInfos); // Validate the input because fileEntry can be built without using the parse method

const fileEntries = (
await Promise.allSettled(
fileInfos.map(async ({ path, ...rest }) => ({ post_hash: await this.postFile(rest), path })),
)
)
.filter((result): result is PromiseFulfilledResult<FileEntry> => result.status === "fulfilled")
.map((result) => result.value);
let newFiles: FileEntry[] = [];
await this.alephService.updateAggregate(FILE_ENTRIES_AGGREGATE_KEY, EncryptedFileEntriesSchema, ({ files }) => {
const decryptedFiles = this.decryptFilesPaths(files);
newFiles = fileEntries.filter(({ path }) => {
if (this.fileExists(decryptedFiles, path)) {
console.error(`File already exists at path: ${path}`);
toast.error(`File already exists at path: ${path}`);
return false;
}
return true;
});

return {
files: [
...files,
...newFiles.map(({ post_hash, path }) => ({
post_hash,
path: EncryptionService.encryptEcies(path, this.alephService.encryptionPrivateKey.publicKey.compressed),
})),
],
};
});
if (newFiles.length === 0) return Promise.reject("No new files were saved");
return newFiles;
}

private async postFile({ key, iv, store_hash }: Omit<FileFullInfos, "post_hash" | "path">): Promise<string> {
const { item_hash } = await this.alephService.createPost(FILE_POST_TYPE, {
key: EncryptionService.encryptEcies(key, this.alephService.encryptionPrivateKey.publicKey.compressed),
iv: EncryptionService.encryptEcies(iv, this.alephService.encryptionPrivateKey.publicKey.compressed),
store_hash,
});
return item_hash;
}

fileExists(files: FileEntry[], path: string): boolean {
return files.some((entry) => entry.path === path);
}

private decryptFilesPaths(files: EncryptedFileEntry[]): FileEntry[] {
return files.map(({ post_hash, path }) => ({
post_hash,
path: EncryptionService.decryptEcies(path, this.alephService.encryptionPrivateKey.secret),
}));
}

async fetchFileEntries(): Promise<FileEntry[]> {
return (await this.alephService.fetchAggregate(FILE_ENTRIES_AGGREGATE_KEY, EncryptedFileEntriesSchema)).files.map(
({ post_hash, path }) => ({
post_hash,
path: EncryptionService.decryptEcies(path, this.alephService.encryptionPrivateKey.secret),
}),
);
}
}
Croos3r marked this conversation as resolved.
Show resolved Hide resolved
Loading