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, });