diff --git a/prisma/migrations/20240912180249_exports/migration.sql b/prisma/migrations/20240912180249_exports/migration.sql
new file mode 100644
index 000000000..b82340218
--- /dev/null
+++ b/prisma/migrations/20240912180249_exports/migration.sql
@@ -0,0 +1,14 @@
+-- CreateTable
+CREATE TABLE "Export" (
+ "id" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "complete" BOOLEAN NOT NULL DEFAULT false,
+ "path" TEXT NOT NULL,
+ "userId" INTEGER NOT NULL,
+
+ CONSTRAINT "Export_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "Export" ADD CONSTRAINT "Export_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 8e25c5c94..a8a507a10 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -27,6 +27,20 @@ model User {
Invite Invite[]
Folder Folder[]
IncompleteFile IncompleteFile[]
+ Exports Export[]
+}
+
+model Export {
+ id String @id @default(cuid())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ complete Boolean @default(false)
+
+ path String
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ userId Int
}
model Folder {
diff --git a/src/components/pages/Manage/index.tsx b/src/components/pages/Manage/index.tsx
index b25e4d913..a7487d713 100644
--- a/src/components/pages/Manage/index.tsx
+++ b/src/components/pages/Manage/index.tsx
@@ -41,6 +41,7 @@ import {
IconUserExclamation,
IconUserMinus,
IconUserX,
+ IconX,
} from '@tabler/icons-react';
import AnchorNext from 'components/AnchorNext';
import { FlameshotIcon, ShareXIcon } from 'components/icons';
@@ -264,7 +265,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
setExports(
res.exports
?.map((s) => ({
- date: new Date(Number(s.name.split('_')[3].slice(0, -4))),
+ date: new Date(s.createdAt),
size: s.size,
full: s.name,
}))
@@ -272,6 +273,26 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
);
};
+ const deleteExport = async (name) => {
+ const res = await useFetch('/api/user/export?name=' + name, 'DELETE');
+ if (res.error) {
+ showNotification({
+ title: 'Error deleting export',
+ message: res.error,
+ color: 'red',
+ icon: ,
+ });
+ } else {
+ showNotification({
+ message: 'Deleted export',
+ color: 'green',
+ icon: ,
+ });
+
+ await getExports();
+ }
+ };
+
const handleDelete = async () => {
const res = await useFetch('/api/user/files', 'DELETE', {
all: true,
@@ -580,6 +601,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
{ id: 'name', name: 'Name' },
{ id: 'date', name: 'Date' },
{ id: 'size', name: 'Size' },
+ { id: 'actions', name: '' },
]}
rows={
exports
@@ -591,6 +613,11 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
),
date: x.date.toLocaleString(),
size: bytesToHuman(x.size),
+ actions: (
+ deleteExport(x.full)}>
+
+
+ ),
}))
: []
}
diff --git a/src/pages/api/user/export.ts b/src/pages/api/user/export.ts
index 9415488aa..2e90ac6ca 100644
--- a/src/pages/api/user/export.ts
+++ b/src/pages/api/user/export.ts
@@ -1,6 +1,6 @@
import { Zip, ZipPassThrough } from 'fflate';
import { createReadStream, createWriteStream } from 'fs';
-import { readdir, stat } from 'fs/promises';
+import { rm, stat } from 'fs/promises';
import datasource from 'lib/datasource';
import Logger from 'lib/logger';
import prisma from 'lib/prisma';
@@ -23,6 +23,13 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
const export_name = `zipline_export_${user.id}_${Date.now()}.zip`;
const path = join(config.core.temp_directory, export_name);
+ const exportDb = await prisma.export.create({
+ data: {
+ path: export_name,
+ userId: user.id,
+ },
+ });
+
logger.debug(`creating write stream at ${path}`);
const write_stream = createWriteStream(path);
@@ -79,11 +86,27 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
logger.info(
`Export for ${user.username} (${user.id}) has completed and is available at ${export_name}`,
);
+
+ await prisma.export.update({
+ where: {
+ id: exportDb.id,
+ },
+ data: {
+ complete: true,
+ },
+ });
}
} else {
write_stream.close();
- logger.debug(`error while writing to zip: ${err}`);
- logger.error(`Export for ${user.username} (${user.id}) has failed\n${err}`);
+ logger.error(
+ `Export for ${user.username} (${user.id}) has failed and has been removed from the database\n${err}`,
+ );
+
+ await prisma.export.delete({
+ where: {
+ id: exportDb.id,
+ },
+ });
}
};
@@ -114,27 +137,62 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
res.json({
url: '/api/user/export?name=' + export_name,
});
+ } else if (req.method === 'DELETE') {
+ const name = req.query.name as string;
+ if (!name) return res.badRequest('no name provided');
+
+ const exportDb = await prisma.export.findFirst({
+ where: {
+ userId: user.id,
+ path: name,
+ },
+ });
+
+ if (!exportDb) return res.notFound('export not found');
+
+ await prisma.export.delete({
+ where: {
+ id: exportDb.id,
+ },
+ });
+
+ try {
+ await rm(join(config.core.temp_directory, exportDb.path));
+ } catch (e) {
+ logger
+ .error(`export file ${exportDb.path} has been removed from the database`)
+ .error(`but failed to remove the file from the filesystem: ${e}`);
+ }
+
+ res.json({
+ success: true,
+ });
} else {
- const export_name = req.query.name as string;
- if (export_name) {
- const parts = export_name.split('_');
- if (Number(parts[2]) !== user.id) return res.unauthorized('cannot access export owned by another user');
+ const exportsDb = await prisma.export.findMany({
+ where: {
+ userId: user.id,
+ },
+ });
+
+ const name = req.query.name as string;
+ if (name) {
+ const exportDb = exportsDb.find((e) => e.path === name);
+ if (!exportDb) return res.notFound('export not found');
- const stream = createReadStream(join(config.core.temp_directory, export_name));
+ const stream = createReadStream(join(config.core.temp_directory, exportDb.path));
res.setHeader('Content-Type', 'application/zip');
- res.setHeader('Content-Disposition', `attachment; filename="${export_name}"`);
+ res.setHeader('Content-Disposition', `attachment; filename="${exportDb.path}"`);
stream.pipe(res);
} else {
- const files = await readdir(config.core.temp_directory);
- const exp = files.filter((f) => f.startsWith('zipline_export_'));
const exports = [];
- for (let i = 0; i !== exp.length; ++i) {
- const name = exp[i];
- const stats = await stat(join(config.core.temp_directory, name));
- if (Number(exp[i].split('_')[2]) !== user.id) continue;
- exports.push({ name, size: stats.size });
+ for (let i = 0; i !== exportsDb.length; ++i) {
+ const exportDb = exportsDb[i];
+ if (!exportDb.complete) continue;
+
+ const stats = await stat(join(config.core.temp_directory, exportDb.path));
+ exports.push({ name: exportDb.path, size: stats.size, createdAt: exportDb.createdAt });
}
res.json({
@@ -145,6 +203,6 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
}
export default withZipline(handler, {
- methods: ['GET', 'POST'],
+ methods: ['GET', 'POST', 'DELETE'],
user: true,
});