diff --git a/SOME_EXPLANATION.md b/SOME_EXPLANATION.md new file mode 100644 index 0000000..fb1a8ff --- /dev/null +++ b/SOME_EXPLANATION.md @@ -0,0 +1,56 @@ +# Some explanation + +## Changes + +I modified the following files. + +### `src/db/schema.ts` + +I modified the database schema as we have began during our call. Here is what I did: + +- create a new table `hashes` +- create a one-to-many relation from one hash to many files + +This structure enables to have multiple file references that have the same hash and file stored in the system. + + +### `src/app/api/upload/route.ts` + +I added the logic to process the new files: + +- get the file as a buffer +- generate the hash from the buffer using the crypto module +- check if the hash exists in the `hashes` table + - if the hash exists, then + - get the hash id + - else (if the hash does not exist), then + - create a new record in the table `hashes` and get its id + - store the file in the storage with this id +- create a new record in the `files` table with the foreign id of the hash + +### `src/app/FileTree.tsx` + +I updated the file tree display : + +- retrieve all rows from the `files` table +- build a tree from each file’s `path` +- render nested lists with indentation +- display a simple empty state when no files are present +- display the file size for the leaf nodes + + +## BONUS + +Of course, as this is a coding challenge (or a small POC), it does not reflect a convenient and user-friendly interface of a final product. + +We may think about the following directions to implement the UX for a first release: + +- implement a responsive design with Tailwind / shadcn +- enable the user to drag and drop files and folders directly on an item of the tree to be able to complete it after a previous upload +- flag duplicated files with a chip with a unique color and containing the reference or an id to identify the duplicates between them +- add a step after the file selection and before the upload to warn the user that there are duplicate files in the selection and to wait for approval before continuing (it may encourage the user to clean files before uploading them) +- display an import report after each upload with the list of imported files and a flag "duplicated / reused" to inform the user that this file has already been stored +- be able to fold / unfold the nodes in the tree as the list becomes large +- add a malware analysis before storing the file with ClamAV and inform the user that the file has been analyzed and no malware has been detected (it may reassure the customer) +- be able to rename in place the files and the directories by clicking on them +- enable the user to download a certified / signed / timestamped report to testify which files with which hash have been uploaded \ No newline at end of file diff --git a/src/app/FileTree.tsx b/src/app/FileTree.tsx index 10c2c03..cc7964e 100644 --- a/src/app/FileTree.tsx +++ b/src/app/FileTree.tsx @@ -1,21 +1,94 @@ +import { db, files } from "@/db"; + export { FileTree }; -type TreeNode = { name: string; children?: TreeNode[] }; +type TreeNode = { name: string; children?: TreeNode[], size?: number }; const USER_ID = 1; -const FileTree = () => { - const paths: string[] = []; - const nodes: TreeNode[] = []; +const FileTree = async () => { + const fileRecords = await db.select().from(files); + const nodes = buildTree( + fileRecords.map((file) => ({ + path: file.path, + size: file.size, + })) + ); return (

File Tree

- + {nodes.length === 0 ? ( +

No files uploaded yet.

+ ) : ( + + )}
); }; -const FileNodes = ({ nodes }: { nodes: TreeNode[] }) => { - return nodes.map((node) =>

{node.name}

); +const buildTree = (paths: { path: string; size: number }[]): TreeNode[] => { + type NodeMap = Record; + const root: NodeMap = {}; + + for (const { path, size } of paths) { + const parts = path.split("/").filter(Boolean); + let current = root; + + parts.forEach((part, index) => { + if (!current[part]) { + current[part] = { name: part, children: {} }; + } + + if (index === parts.length - 1) { + current[part].size = size; + } + + if (!current[part].children) { + current[part].children = {}; + } + + current = current[part].children as NodeMap; + }); + } + + const toArray = (nodeMap: NodeMap): TreeNode[] => + Object.values(nodeMap).map((node) => ({ + name: node.name, + size: node.size, + children: node.children ? toArray(node.children) : undefined, + })); + + return toArray(root); +}; + +const FileNodes = ({ + nodes, + basePath, +}: { + nodes: TreeNode[]; + basePath: string; +}) => { + return ( + + ); }; diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index e6f5495..0c65328 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -1,5 +1,9 @@ -import { db, files, users } from "@/db"; +import { db, files, hashes, users } from "@/db"; +import { eq } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; +import { createHash } from "crypto"; +import { promises as fs } from "fs"; +import { join } from "path"; const USER_ID = 1; @@ -14,20 +18,37 @@ export async function POST(request: NextRequest) { return NextResponse.json({ message: "No file uploaded" }, { status: 400 }); } - await db - .insert(users) - .values({ - id: 1, - email: "test@test.com", - name: "Test User", - }) - .onConflictDoNothing(); + const buffer = Buffer.from(await formFile.arrayBuffer()); + const hash = createHash("sha256").update(buffer).digest("hex"); + + const existingHash = await db + .select() + .from(hashes) + .where(eq(hashes.hash, hash)) + .limit(1); + + let hashId: number; + + if (existingHash.length > 0) { + hashId = existingHash[0].id; + } else { + const [createdHash] = await db + .insert(hashes) + .values({ hash: hash }) + .returning(); + + hashId = createdHash.id; + const uploadDir = join(process.cwd(), "uploads") + await fs.mkdir(uploadDir, { recursive: true }); + await fs.writeFile(join(uploadDir, String(hashId)), buffer); + } const [createdFile] = await db .insert(files) .values({ - ownerId: 1, - path, + ownerId: USER_ID, + hashId, + path: path, size: formFile.size, }) .returning(); diff --git a/src/db/schema.ts b/src/db/schema.ts index 29fa4dc..a49cd10 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -16,6 +16,7 @@ export const files = sqliteTable( { id: integer().primaryKey({ autoIncrement: true }), ownerId: integer("owner_id").notNull(), + hashId: integer("hash_id").notNull(), path: text().notNull(), size: integer().notNull(), }, @@ -24,5 +25,17 @@ export const files = sqliteTable( columns: [table.ownerId], foreignColumns: [users.id], }), + foreignKey({ + columns: [table.hashId], + foreignColumns: [hashes.id], + }), ] ); + +export const hashes = sqliteTable( + "hashes", + { + id: integer().primaryKey({ autoIncrement: true }), + hash: text().notNull().unique(), + } +); \ No newline at end of file diff --git a/uploads/1 b/uploads/1 new file mode 100644 index 0000000..33a9488 --- /dev/null +++ b/uploads/1 @@ -0,0 +1 @@ +example diff --git a/uploads/2 b/uploads/2 new file mode 100644 index 0000000..5224d81 --- /dev/null +++ b/uploads/2 @@ -0,0 +1 @@ +other_file