Skip to content

Commit 5c4a14c

Browse files
authored
Merge commit from fork
1 parent 898213b commit 5c4a14c

6 files changed

Lines changed: 151 additions & 76 deletions

File tree

src/lib/api/upload.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,12 @@ export async function checkQuota(
3636
user: Pick<User, 'id' | 'quota'> | null,
3737
newSize: number,
3838
fileCount: number,
39+
db: Pick<typeof prisma, 'file'> = prisma,
3940
): Promise<true | string> {
4041
if (!user?.quota) return true;
4142

4243
if (user.quota.filesQuota === 'BY_BYTES') {
43-
const stats = await prisma.file.aggregate({
44+
const stats = await db.file.aggregate({
4445
where: { userId: user.id },
4546
_sum: { size: true },
4647
});
@@ -51,7 +52,7 @@ export async function checkQuota(
5152
return true;
5253
}
5354

54-
const count = await prisma.file.count({ where: { userId: user.id } });
55+
const count = await db.file.count({ where: { userId: user.id } });
5556
if (count + fileCount > user.quota.maxFiles!)
5657
return `uploading will exceed your file count quota of ${user.quota.maxFiles} files`;
5758

src/offload/partial.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,8 @@ async function runComplete(id: string, size: number) {
228228
async function failPartial(config: Config, incompleteFile: IncompleteFile) {
229229
logger.error('failing incomplete file', { id: incompleteFile.id });
230230

231+
await dbProxy('file.delete', { where: { id: file.id } });
232+
231233
const partials = await readdir(config.core.tempDirectory).then((files) =>
232234
files.filter((file) => file.startsWith(`zipline_partial_${options.partial!.identifier}`)),
233235
);

src/server/routes/api/auth/register.ts

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -56,46 +56,56 @@ export default typedPlugin(
5656
});
5757
if (oUser) throw new ApiError(1039);
5858

59-
if (code) {
60-
const invite = await prisma.invite.findFirst({
61-
where: {
62-
OR: [{ id: code }, { code }],
59+
const hashedPassword = await hashPassword(password);
60+
const token = createToken();
61+
62+
const createUser = (db: Pick<typeof prisma, 'user'>) =>
63+
db.user.create({
64+
data: {
65+
username,
66+
password: hashedPassword,
67+
role: 'USER',
68+
token,
69+
},
70+
select: {
71+
...userSelect,
72+
password: true,
73+
token: true,
6374
},
6475
});
6576

66-
if (!invite) throw new ApiError(1035);
67-
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) throw new ApiError(1035);
68-
if (invite.maxUses && invite.uses >= invite.maxUses) throw new ApiError(1035);
77+
let user;
78+
if (code) {
79+
const result = await prisma.$transaction(async (tx) => {
80+
const [invite] = await tx.invite.updateManyAndReturn({
81+
where: {
82+
AND: [
83+
{ OR: [{ id: code }, { code }] },
84+
{ OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }] },
85+
{
86+
OR: [{ maxUses: null }, { uses: { lt: tx.invite.fields.maxUses } }],
87+
},
88+
],
89+
},
90+
data: { uses: { increment: 1 } },
91+
select: { id: true },
92+
});
6993

70-
await prisma.invite.update({
71-
where: {
72-
id: invite.id,
73-
},
74-
data: {
75-
uses: invite.uses + 1,
76-
},
94+
if (!invite) throw new ApiError(1035);
95+
96+
return { inviteId: invite.id, user: await createUser(tx) };
7797
});
7898

99+
user = result.user;
100+
79101
logger.info('invite used', {
80102
user: username,
81-
invite: invite.id,
103+
invite: result.inviteId,
82104
});
105+
} else {
106+
user = await createUser(prisma);
83107
}
84108

85-
const user = await prisma.user.create({
86-
data: {
87-
username,
88-
password: await hashPassword(password),
89-
role: 'USER',
90-
token: createToken(),
91-
},
92-
select: {
93-
...userSelect,
94-
password: true,
95-
token: true,
96-
},
97-
});
98-
99109
await saveSession(session, <User>user);
100110

101111
delete (user as any).password;

src/server/routes/api/upload/index.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,8 @@ export default typedPlugin(
232232
named.push({ ...item, fileName: nameResult.fileName });
233233
}
234234

235-
response.files = await mapConcurrent(named, 4, async (item, i) => {
235+
const password = options.password ? await hashPassword(options.password) : undefined;
236+
const uploads = named.map((item, i) => {
236237
const { file, fileName, extension, mimetype, size, compressed, removedGps } = item;
237238

238239
const data: Prisma.FileCreateInput = {
@@ -245,7 +246,7 @@ export default typedPlugin(
245246
if (!req.user && folder) data.anonymous = true;
246247

247248
if (options.maxViews) data.maxViews = options.maxViews;
248-
if (options.password) data.password = await hashPassword(options.password);
249+
if (password) data.password = password;
249250
if (folder) data.Folder = { connect: { id: folder.id } };
250251
if (options.addOriginalName) {
251252
const sanitizedOG = sanitizeFilename(file.filename);
@@ -256,10 +257,39 @@ export default typedPlugin(
256257

257258
data.deletesAt = options.deletesAt && options.deletesAt !== 'never' ? options.deletesAt : null;
258259

259-
const fileUpload = await prisma.file.create({
260-
data,
261-
select: fileSelect,
262-
});
260+
return { compressed, data, extension, file, removedGps, size };
261+
});
262+
263+
const fileUploads = await prisma.$transaction(async (tx) => {
264+
if (quotaUser?.quota) {
265+
await tx.$queryRaw`SELECT "id" FROM "User" WHERE "id" = ${quotaUser.id} FOR UPDATE`;
266+
267+
const quotaCheck = await checkQuota(
268+
quotaUser,
269+
uploads.reduce((total, upload) => total + upload.size, 0),
270+
uploads.length,
271+
tx,
272+
);
273+
if (quotaCheck !== true)
274+
throw new ApiError(5002, typeof quotaCheck === 'string' ? quotaCheck : undefined);
275+
}
276+
277+
const created = [];
278+
for (const upload of uploads) {
279+
created.push(
280+
await tx.file.create({
281+
data: upload.data,
282+
select: fileSelect,
283+
}),
284+
);
285+
}
286+
287+
return created;
288+
});
289+
290+
response.files = await mapConcurrent(uploads, 4, async (upload, uploadIndex) => {
291+
const { compressed, extension, file, removedGps } = upload;
292+
const fileUpload = fileUploads[uploadIndex];
263293

264294
const storageData = compressed?.buffer ?? file.filepath;
265295
await datasource.put(fileUpload.name, storageData, {

src/server/routes/api/upload/partial.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,25 @@ function createPartial(options: UploadOptions, actorKey: string, quotaUserId: st
7070
}
7171

7272
function activePartials(actorKey: string) {
73-
return [...partialsCache.values()].filter((partial) => partial.actorKey === actorKey).length;
73+
let count = 0;
74+
for (const partial of partialsCache.values()) {
75+
if (partial.actorKey === actorKey && ++count >= MAX_PARTIALS) return count;
76+
}
77+
78+
return count;
7479
}
7580

7681
function quotaReservations(quotaUserId: string) {
77-
const reservations = [...partialsCache.values()].filter((partial) => partial.quotaUserId === quotaUserId);
82+
let size = 0;
83+
let files = 0;
84+
for (const partial of partialsCache.values()) {
85+
if (partial.quotaUserId !== quotaUserId || partial.finalized) continue;
86+
87+
size += partial.total;
88+
files++;
89+
}
7890

79-
return {
80-
size: reservations.reduce((total, partial) => total + partial.total, 0),
81-
files: reservations.filter((partial) => !partial.finalized).length,
82-
};
91+
return { size, files };
8392
}
8493

8594
async function deletePartial(identifier: string, deleteFiles = true) {
@@ -98,12 +107,16 @@ async function deletePartial(identifier: string, deleteFiles = true) {
98107
}
99108

100109
async function deleteOrphanedPartialFiles() {
101-
const activePrefixes = [...partialsCache.values()].map((partial) => partial.prefix);
102110
const tempFiles = await readdir(config.core.tempDirectory);
103-
const orphaned = tempFiles.filter(
104-
(file) =>
105-
file.startsWith('zipline_partial_') && !activePrefixes.some((prefix) => file.startsWith(prefix)),
106-
);
111+
const orphaned = tempFiles.filter((file) => {
112+
if (!file.startsWith('zipline_partial_')) return false;
113+
114+
for (const partial of partialsCache.values()) {
115+
if (file.startsWith(partial.prefix)) return false;
116+
}
117+
118+
return true;
119+
});
107120

108121
await Promise.all(orphaned.map((file) => rm(join(config.core.tempDirectory, file), { force: true })));
109122

@@ -307,7 +320,7 @@ export default typedPlugin(
307320

308321
const data: Prisma.FileCreateInput = {
309322
name: `${fileName}${extension}`,
310-
size: 0,
323+
size: total,
311324
type: mimetype,
312325
User: {
313326
connect: {
@@ -327,9 +340,23 @@ export default typedPlugin(
327340
}
328341
if (!req.user && folder) data.anonymous = true;
329342

330-
const fileUpload = await prisma.file.create({
331-
data,
332-
});
343+
let fileUpload;
344+
try {
345+
fileUpload = await prisma.$transaction(async (tx) => {
346+
if (quotaUser?.quota) {
347+
await tx.$queryRaw`SELECT "id" FROM "User" WHERE "id" = ${quotaUser.id} FOR UPDATE`;
348+
349+
const quotaCheck = await checkQuota(quotaUser, total, 1, tx);
350+
if (quotaCheck !== true)
351+
throw new ApiError(5002, typeof quotaCheck === 'string' ? quotaCheck : undefined);
352+
}
353+
354+
return tx.file.create({ data });
355+
});
356+
} catch (error) {
357+
await deletePartial(options.partial.identifier);
358+
throw error;
359+
}
333360

334361
const urlPath =
335362
options.extensionless && config.files.extensionlessUrls
@@ -378,6 +405,9 @@ export default typedPlugin(
378405
result = await prisma.file.update(msg.data);
379406
await deletePartial(partialIdentifier, false);
380407
break;
408+
case 'file.delete':
409+
result = await prisma.file.delete(msg.data);
410+
break;
381411
case 'user.findUnique':
382412
result = await prisma.user.findUnique(msg.data);
383413
break;

src/server/routes/api/user/urls/index.ts

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -76,17 +76,6 @@ export default typedPlugin(
7676
const { vanity, destination, enabled } = req.body;
7777
const noJson = req.headers['x-zipline-no-json'];
7878

79-
const countUrls = await prisma.url.count({
80-
where: {
81-
userId: req.user.id,
82-
},
83-
});
84-
if (req.user.quota && req.user.quota.maxUrls && countUrls + 1 > req.user.quota.maxUrls)
85-
throw new ApiError(
86-
3012,
87-
`Shortening this URL would exceed your quota of ${req.user.quota.maxUrls} URLs.`,
88-
);
89-
9079
let returnDomain;
9180
const headerDomain = req.headers['x-zipline-domain'];
9281
if (headerDomain) {
@@ -116,19 +105,32 @@ export default typedPlugin(
116105
existingCode = await prisma.url.findFirst({ where: { code } });
117106
} while (existingCode);
118107

119-
const url = await prisma.url.create({
120-
data: {
121-
userId: req.user.id,
122-
destination: destination,
123-
code,
124-
...(vanity && { vanity: vanity }),
125-
...(maxViews && { maxViews: maxViews }),
126-
...(password && { password: password }),
127-
...(enabled !== undefined && { enabled: enabled }),
128-
},
129-
omit: {
130-
password: true,
131-
},
108+
const url = await prisma.$transaction(async (tx) => {
109+
await tx.$queryRaw`SELECT "id" FROM "User" WHERE "id" = ${req.user.id} FOR UPDATE`;
110+
111+
const countUrls = await tx.url.count({
112+
where: { userId: req.user.id },
113+
});
114+
if (req.user.quota?.maxUrls && countUrls + 1 > req.user.quota.maxUrls)
115+
throw new ApiError(
116+
3012,
117+
`Shortening this URL would exceed your quota of ${req.user.quota.maxUrls} URLs.`,
118+
);
119+
120+
return tx.url.create({
121+
data: {
122+
userId: req.user.id,
123+
destination: destination,
124+
code,
125+
...(vanity && { vanity: vanity }),
126+
...(maxViews && { maxViews: maxViews }),
127+
...(password && { password: password }),
128+
...(enabled !== undefined && { enabled: enabled }),
129+
},
130+
omit: {
131+
password: true,
132+
},
133+
});
132134
});
133135

134136
let domain;

0 commit comments

Comments
 (0)