Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4faba8fdc9 |
56
SOME_EXPLANATION.md
Normal file
56
SOME_EXPLANATION.md
Normal file
@@ -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
|
||||
@@ -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 (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold mb-2">File Tree</h3>
|
||||
<FileNodes nodes={nodes} />
|
||||
{nodes.length === 0 ? (
|
||||
<p className="text-sm text-gray-600">No files uploaded yet.</p>
|
||||
) : (
|
||||
<FileNodes nodes={nodes} basePath="" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FileNodes = ({ nodes }: { nodes: TreeNode[] }) => {
|
||||
return nodes.map((node) => <p key={node.name}>{node.name}</p>);
|
||||
const buildTree = (paths: { path: string; size: number }[]): TreeNode[] => {
|
||||
type NodeMap = Record<string, { name: string; size?: number; children?: NodeMap }>;
|
||||
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 (
|
||||
<ul className={basePath ? "ml-4" : ""}>
|
||||
{nodes.map((node) => {
|
||||
const fullPath = basePath ? `${basePath}/${node.name}` : node.name;
|
||||
const isLeaf = !node.children || node.children.length === 0;
|
||||
|
||||
return (
|
||||
<li key={fullPath} className="text-sm">
|
||||
<span>
|
||||
{node.name}
|
||||
{isLeaf && typeof node.size === "number"
|
||||
? ` (${node.size} bytes)`
|
||||
: ""}
|
||||
</span>
|
||||
{node.children && node.children.length > 0 && (
|
||||
<FileNodes nodes={node.children} basePath={fullPath} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user