Skip to content
This repository was archived by the owner on Sep 17, 2024. It is now read-only.

Commit bd5c746

Browse files
committed
feat: Create uploads module
1 parent b5c0823 commit bd5c746

File tree

15 files changed

+865
-0
lines changed

15 files changed

+865
-0
lines changed

modules/uploads/config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export interface Config {
2+
maxUploadSize: UploadSize;
3+
maxFilesPerUpload: number;
4+
s3: S3Config;
5+
}
6+
7+
type Units = "b" | "kb" | "mb" | "gb" | "tb" | "kib" | "mib" | "gib" | "tib";
8+
9+
export type UploadSize = {
10+
[unit in Units]: Record<unit, number>;
11+
}[Units];
12+
13+
export interface S3Config {
14+
bucket: string;
15+
region: string;
16+
accessKeyId: string;
17+
secretAccessKey: string;
18+
endpoint: string;
19+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-- CreateTable
2+
CREATE TABLE "Upload" (
3+
"id" UUID NOT NULL,
4+
"userId" UUID,
5+
"bucket" TEXT NOT NULL,
6+
"contentLength" BIGINT NOT NULL,
7+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8+
"updatedAt" TIMESTAMP(3) NOT NULL,
9+
"completedAt" TIMESTAMP(3),
10+
"deletedAt" TIMESTAMP(3),
11+
12+
CONSTRAINT "Upload_pkey" PRIMARY KEY ("id")
13+
);
14+
15+
-- CreateTable
16+
CREATE TABLE "Files" (
17+
"path" TEXT NOT NULL,
18+
"mime" TEXT,
19+
"contentLength" BIGINT NOT NULL,
20+
"nsfwScoreThreshold" DOUBLE PRECISION,
21+
"uploadId" UUID NOT NULL,
22+
23+
CONSTRAINT "Files_pkey" PRIMARY KEY ("uploadId","path")
24+
);
25+
26+
-- AddForeignKey
27+
ALTER TABLE "Files" ADD CONSTRAINT "Files_uploadId_fkey" FOREIGN KEY ("uploadId") REFERENCES "Upload"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Please do not edit this file manually
2+
# It should be added in your version-control system (i.e. Git)
3+
provider = "postgresql"

modules/uploads/db/schema.prisma

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Do not modify this `datasource` block
2+
datasource db {
3+
provider = "postgresql"
4+
url = env("DATABASE_URL")
5+
}
6+
7+
model Upload {
8+
id String @id @default(uuid()) @db.Uuid
9+
userId String? @db.Uuid
10+
11+
bucket String
12+
contentLength BigInt
13+
14+
createdAt DateTime @default(now())
15+
updatedAt DateTime @updatedAt
16+
completedAt DateTime?
17+
deletedAt DateTime?
18+
19+
files Files[] @relation("Files")
20+
}
21+
22+
model Files {
23+
path String
24+
mime String?
25+
contentLength BigInt
26+
nsfwScoreThreshold Float?
27+
28+
uploadId String @db.Uuid
29+
upload Upload @relation("Files", fields: [uploadId], references: [id])
30+
31+
@@id([uploadId, path])
32+
}

modules/uploads/module.yaml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
scripts:
2+
prepare:
3+
name: Prepare Upload
4+
description: Prepare an upload batch for data transfer
5+
complete:
6+
name: Complete Upload
7+
description: Alert the module that the upload has been completed
8+
get:
9+
name: Get Upload Metadata
10+
description: Get the metadata (including contained files) for specified upload IDs
11+
get_file_links:
12+
name: Get File Link
13+
description: Get presigned download links for each of the specified files
14+
list_for_user:
15+
name: List Uploads for Users
16+
description: Get a list of upload IDs associated with the specified user IDs
17+
delete:
18+
name: Delete Upload
19+
description: Removes the upload and deletes the files from the bucket
20+
errors:
21+
no_files:
22+
name: No Files Provided
23+
description: An upload must have at least 1 file
24+
too_many_files:
25+
name: Too Many Files Provided
26+
description: There is a limit to how many files can be put into a single upload (see config)
27+
duplicate_paths:
28+
name: Duplicate Paths Provided
29+
description: An upload cannot contain 2 files with the same paths (see `cause` for offending paths)
30+
size_limit_exceeded:
31+
name: Combined Size Limit Exceeded
32+
description: There is a maximum total size per upload (see config)
33+
upload_not_found:
34+
name: Upload Not Found
35+
description: The provided upload ID didn't match any known existing uploads
36+
upload_already_completed:
37+
name: Upload Already completed
38+
description: \`complete\` was already called on this upload
39+
dependencies:
40+
users: {}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { RuntimeError, ScriptContext } from "../_gen/scripts/complete.ts";
2+
import { prismaToOutput } from "../utils/types.ts";
3+
import { Upload } from "../utils/types.ts";
4+
5+
export interface Request {
6+
uploadId: string;
7+
}
8+
9+
export interface Response {
10+
upload: Upload;
11+
}
12+
13+
export async function run(
14+
ctx: ScriptContext,
15+
req: Request,
16+
): Promise<Response> {
17+
const newUpload = await ctx.db.$transaction(async (db) => {
18+
// Find the upload by ID
19+
const upload = await db.upload.findFirst({
20+
where: {
21+
id: req.uploadId,
22+
},
23+
select: {
24+
id: true,
25+
userId: true,
26+
bucket: true,
27+
contentLength: true,
28+
files: true,
29+
createdAt: true,
30+
updatedAt: true,
31+
completedAt: true,
32+
},
33+
});
34+
35+
// Error if the upload wasn't prepared
36+
if (!upload) {
37+
throw new RuntimeError(
38+
"upload_not_found",
39+
{ cause: `Upload with ID ${req.uploadId} not found` },
40+
);
41+
}
42+
43+
// Error if `complete` was already called with this ID
44+
if (upload.completedAt !== null) {
45+
throw new RuntimeError(
46+
"upload_already_completed",
47+
{ cause: `Upload with ID ${req.uploadId} has already been completed` },
48+
);
49+
}
50+
51+
// Update the upload to mark it as completed
52+
const completedUpload = await db.upload.update({
53+
where: {
54+
id: req.uploadId,
55+
},
56+
data: {
57+
completedAt: new Date().toISOString(),
58+
},
59+
select: {
60+
id: true,
61+
userId: true,
62+
bucket: true,
63+
contentLength: true,
64+
files: true,
65+
createdAt: true,
66+
updatedAt: true,
67+
completedAt: true,
68+
},
69+
});
70+
71+
return completedUpload;
72+
});
73+
74+
return {
75+
upload: prismaToOutput(newUpload),
76+
};
77+
}

modules/uploads/scripts/delete.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { RuntimeError, ScriptContext } from "../_gen/scripts/prepare.ts";
2+
import { getKey } from "../utils/types.ts";
3+
import { deleteKeys } from "../utils/bucket.ts";
4+
5+
export interface Request {
6+
uploadId: string;
7+
}
8+
9+
export interface Response {
10+
bytesDeleted: string;
11+
}
12+
13+
export async function run(
14+
ctx: ScriptContext,
15+
req: Request,
16+
): Promise<Response> {
17+
const bytesDeleted = await ctx.db.$transaction(async (db) => {
18+
const upload = await db.upload.findFirst({
19+
where: {
20+
id: req.uploadId,
21+
completedAt: { not: null },
22+
deletedAt: null,
23+
},
24+
select: {
25+
id: true,
26+
userId: true,
27+
bucket: true,
28+
contentLength: true,
29+
files: true,
30+
createdAt: true,
31+
updatedAt: true,
32+
completedAt: true,
33+
},
34+
});
35+
if (!upload) {
36+
throw new RuntimeError(
37+
"upload_not_found",
38+
{
39+
cause: `Upload with ID ${req.uploadId} not found`,
40+
meta: { modified: false },
41+
},
42+
);
43+
}
44+
45+
const filesToDelete = upload.files.map((file) =>
46+
getKey(file.uploadId, file.path)
47+
);
48+
const deleteResults = await deleteKeys(ctx.userConfig.s3, filesToDelete);
49+
50+
const failures = upload.files
51+
.map((file, i) => [file, deleteResults[i]] as const)
52+
.filter(([, successfullyDeleted]) => !successfullyDeleted)
53+
.map(([file]) => file);
54+
55+
if (failures.length) {
56+
const failedPaths = JSON.stringify(failures.map((file) => file.path));
57+
throw new RuntimeError(
58+
"failed_to_delete",
59+
{
60+
cause: `Failed to delete files with paths ${failedPaths}`,
61+
meta: { modified: failures.length !== filesToDelete.length },
62+
},
63+
);
64+
}
65+
66+
await db.upload.update({
67+
where: {
68+
id: req.uploadId,
69+
},
70+
data: {
71+
deletedAt: new Date().toISOString(),
72+
},
73+
});
74+
75+
return upload.contentLength.toString();
76+
});
77+
return { bytesDeleted };
78+
}

modules/uploads/scripts/get.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { ScriptContext } from "../_gen/scripts/get.ts";
2+
import { prismaToOutput } from "../utils/types.ts";
3+
import { Upload } from "../utils/types.ts";
4+
5+
export interface Request {
6+
uploadIds: string[];
7+
}
8+
9+
export interface Response {
10+
uploads: (Upload | null)[];
11+
}
12+
13+
export async function run(
14+
ctx: ScriptContext,
15+
req: Request,
16+
): Promise<Response> {
17+
// Find uploads that match the IDs in the request
18+
const dbUploads = await ctx.db.upload.findMany({
19+
where: {
20+
id: {
21+
in: req.uploadIds,
22+
},
23+
completedAt: { not: null },
24+
deletedAt: null,
25+
},
26+
select: {
27+
id: true,
28+
userId: true,
29+
bucket: true,
30+
contentLength: true,
31+
files: true,
32+
createdAt: true,
33+
updatedAt: true,
34+
completedAt: true,
35+
},
36+
});
37+
38+
// Create a map of uploads by ID
39+
const uploadMap = new Map(dbUploads.map((upload) => [upload.id, upload]));
40+
41+
// Reorder uploads to match the order of the request
42+
const uploads = req.uploadIds.map((uploadId) => {
43+
const upload = uploadMap.get(uploadId);
44+
// If the upload wasn't found, return null
45+
return upload ? prismaToOutput(upload) : null;
46+
});
47+
48+
return { uploads };
49+
}

0 commit comments

Comments
 (0)