From 56a500a3d8ccf6f20081342248a6e32874c14554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Lavi=C3=A9ville?= Date: Mon, 23 Feb 2026 20:53:50 +0100 Subject: [PATCH 1/3] feat: using zip as default S3 import format --- package-lock.json | 213 ++++- package.json | 4 + server/src/routes/admin/import-pack.ts | 335 ++++++++ server/src/routes/admin/index.ts | 2 + .../src/components/admin/SampleUploader.vue | 283 ------- .../src/components/admin/ZipPackImporter.vue | 742 ++++++++++++++++++ webapp/src/stores/adminStore.ts | 61 ++ webapp/src/views/admin/AdminFolderDetail.vue | 29 +- webapp/src/views/admin/AdminSamples.vue | 58 +- 9 files changed, 1393 insertions(+), 334 deletions(-) create mode 100644 server/src/routes/admin/import-pack.ts delete mode 100644 webapp/src/components/admin/SampleUploader.vue create mode 100644 webapp/src/components/admin/ZipPackImporter.vue diff --git a/package-lock.json b/package-lock.json index 8b432db..f9e6e8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@tailwindcss/vite": "^4.1.11", "@vueuse/components": "^13.2.0", "@vueuse/core": "^13.2.0", + "adm-zip": "^0.5.16", "axios": "^1.7.7", "bcrypt": "^6.0.0", "connect-redis": "^8.0.0", @@ -23,10 +24,12 @@ "express": "^4.21.1", "express-session": "^1.18.1", "express-session-sqlite": "^2.1.1", + "file-type": "^21.3.0", "grid-layout-plus": "^1.1.0", "gsap": "^3.14.2", "idb": "^8.0.3", "jest": "^29.7.0", + "jszip": "^3.10.1", "lenis": "^1.3.17", "lodash": "^4.17.21", "multer": "^2.0.2", @@ -52,6 +55,7 @@ }, "devDependencies": { "@eslint/js": "^9.31.0", + "@types/adm-zip": "^0.5.7", "@types/bcrypt": "^5.0.2", "@types/connect-redis": "^0.0.23", "@types/cookie-parser": "^1.4.9", @@ -1534,6 +1538,16 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "license": "MIT" }, + "node_modules/@borewit/text-codec": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz", + "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.6.2.tgz", @@ -4339,6 +4353,29 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -4387,6 +4424,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5635,6 +5682,15 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -7017,6 +7073,12 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -7166,9 +7228,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -8852,6 +8914,24 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-type": { + "version": "21.3.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz", + "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -9650,6 +9730,12 @@ "dev": true, "license": "ISC" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immutable": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", @@ -11156,6 +11242,54 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -11244,6 +11378,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", @@ -12681,6 +12824,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -13251,6 +13400,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -14368,6 +14523,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -15291,6 +15452,22 @@ ], "license": "MIT" }, + "node_modules/strtok3": { + "version": "10.3.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", + "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/superjson": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz", @@ -15647,6 +15824,24 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/tone": { "version": "15.1.22", "resolved": "https://registry.npmjs.org/tone/-/tone-15.1.22.tgz", @@ -16149,6 +16344,18 @@ "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", "license": "MIT" }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index dc5f423..03be8a1 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@tailwindcss/vite": "^4.1.11", "@vueuse/components": "^13.2.0", "@vueuse/core": "^13.2.0", + "adm-zip": "^0.5.16", "axios": "^1.7.7", "bcrypt": "^6.0.0", "connect-redis": "^8.0.0", @@ -28,10 +29,12 @@ "express": "^4.21.1", "express-session": "^1.18.1", "express-session-sqlite": "^2.1.1", + "file-type": "^21.3.0", "grid-layout-plus": "^1.1.0", "gsap": "^3.14.2", "idb": "^8.0.3", "jest": "^29.7.0", + "jszip": "^3.10.1", "lenis": "^1.3.17", "lodash": "^4.17.21", "multer": "^2.0.2", @@ -57,6 +60,7 @@ }, "devDependencies": { "@eslint/js": "^9.31.0", + "@types/adm-zip": "^0.5.7", "@types/bcrypt": "^5.0.2", "@types/connect-redis": "^0.0.23", "@types/cookie-parser": "^1.4.9", diff --git a/server/src/routes/admin/import-pack.ts b/server/src/routes/admin/import-pack.ts new file mode 100644 index 0000000..e8fb2b7 --- /dev/null +++ b/server/src/routes/admin/import-pack.ts @@ -0,0 +1,335 @@ +import { Router, Request, Response, NextFunction } from "express"; +import multer from "multer"; +import AdmZip from "adm-zip"; +import path from "path"; +import { fileTypeFromBuffer } from "file-type"; +import { uploadToR2, deleteFromR2, isR2Configured } from "../../services/r2.service"; +import pg from "../../config/db.config"; +import { SamplePack } from "../../config/entities/SamplePack"; +import { SampleFolder } from "../../config/entities/SampleFolder"; +import { AudioSample } from "../../config/entities/AudioSample"; + +const importPackRouter = Router(); + +const ALLOWED_AUDIO_MIMES = [ + "audio/mpeg", + "audio/mp3", + "audio/wav", + "audio/wave", + "audio/x-wav", + "audio/ogg", + "audio/flac", + "audio/aiff", + "audio/x-aiff", +]; + +const ALLOWED_IMAGE_MIMES = ["image/jpeg", "image/png", "image/webp"]; + +const AUDIO_EXTENSIONS = [".wav", ".mp3", ".ogg", ".flac", ".aiff"]; +const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp"]; + +interface ParsedFile { + path: string; + folderName: string; + fileName: string; + buffer: Buffer; + mimeType: string; +} + +interface ParsedStructure { + folders: Map; + cover: ParsedFile | null; + warnings: string[]; +} + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 500 * 1024 * 1024 }, + fileFilter: (_, file, cb) => { + if ( + file.mimetype === "application/zip" || + file.mimetype === "application/x-zip-compressed" || + file.originalname.toLowerCase().endsWith(".zip") + ) { + cb(null, true); + } else { + cb(new Error("Only ZIP files are allowed")); + } + }, +}); + +function isPathSafe(entryPath: string): boolean { + const normalized = path.normalize(entryPath); + if (normalized.includes("..")) return false; + if (path.isAbsolute(normalized)) return false; + if (entryPath.includes("\\")) return false; + if (entryPath.startsWith("/")) return false; + return true; +} + +function sanitizeFilename(name: string): string { + return name + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-zA-Z0-9._-]/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, "") + .toLowerCase(); +} + +async function parseZipStructure(zipBuffer: Buffer): Promise { + const zip = new AdmZip(zipBuffer); + const entries = zip.getEntries(); + + const folders = new Map(); + let cover: ParsedFile | null = null; + const warnings: string[] = []; + + for (const entry of entries) { + if (entry.isDirectory) continue; + + const entryPath = entry.entryName; + + if (!isPathSafe(entryPath)) { + warnings.push(`Skipped unsafe path: ${entryPath}`); + continue; + } + + const compressedSize = entry.header.compressedSize || 1; + const uncompressedSize = entry.header.size || 0; + const ratio = uncompressedSize / compressedSize; + if (ratio > 100) { + warnings.push(`Skipped suspicious compression ratio: ${entryPath}`); + continue; + } + + const ext = path.extname(entryPath).toLowerCase(); + const baseName = path.basename(entryPath, ext); + const pathParts = entryPath.split("/").filter(Boolean); + + if (baseName.startsWith(".") || baseName.startsWith("__MACOSX")) { + continue; + } + + const buffer = entry.getData(); + const fileType = await fileTypeFromBuffer(buffer); + + if (IMAGE_EXTENSIONS.includes(ext)) { + if (!fileType || !ALLOWED_IMAGE_MIMES.includes(fileType.mime)) { + warnings.push(`Invalid image file: ${entryPath}`); + continue; + } + + const lowerName = baseName.toLowerCase(); + if (lowerName === "cover" || lowerName === "artwork" || lowerName === "folder") { + cover = { + path: entryPath, + folderName: "", + fileName: `cover${path.extname(entryPath)}`, + buffer, + mimeType: fileType.mime, + }; + } + continue; + } + + if (!AUDIO_EXTENSIONS.includes(ext)) { + warnings.push(`Skipped non-audio file: ${entryPath}`); + continue; + } + + if (!fileType || !ALLOWED_AUDIO_MIMES.includes(fileType.mime)) { + warnings.push(`Invalid audio file (bad MIME): ${entryPath}`); + continue; + } + + let folderName: string; + if (pathParts.length > 1) { + folderName = pathParts[0]; + } else { + folderName = "featured"; + } + + const parsedFile: ParsedFile = { + path: entryPath, + folderName, + fileName: baseName, + buffer, + mimeType: fileType.mime, + }; + + if (!folders.has(folderName)) { + folders.set(folderName, []); + } + folders.get(folderName)!.push(parsedFile); + } + + return { folders, cover, warnings }; +} + +importPackRouter.post("/", upload.single("zipFile"), async (req, res) => { + const uploadedR2Keys: string[] = []; + + try { + if (!req.file) { + res.status(400).json({ error: "No ZIP file provided" }); + return; + } + + const { name, slug, author } = req.body; + + if (!name || !slug) { + res.status(400).json({ error: "Name and slug are required" }); + return; + } + + if (!isR2Configured()) { + res.status(500).json({ error: "R2 storage not configured" }); + return; + } + + const packRepo = pg.getRepository(SamplePack); + const existing = await packRepo.findOne({ where: { slug } }); + if (existing) { + res.status(400).json({ error: "Slug already exists" }); + return; + } + + const structure = await parseZipStructure(req.file.buffer); + + if (structure.folders.size === 0) { + res.status(400).json({ + error: "No valid audio files found in ZIP", + warnings: structure.warnings, + }); + return; + } + + const queryRunner = pg.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const pack = queryRunner.manager.create(SamplePack, { + name, + slug, + author: author || null, + featured: false, + isActive: true, + }); + const savedPack = await queryRunner.manager.save(pack); + + let foldersCount = 0; + let samplesCount = 0; + + const sortedFolders = Array.from(structure.folders.keys()).sort(); + + for (let i = 0; i < sortedFolders.length; i++) { + const folderName = sortedFolders[i]; + const files = structure.folders.get(folderName)!; + + const folder = queryRunner.manager.create(SampleFolder, { + name: folderName, + order: i, + packId: savedPack.id, + }); + const savedFolder = await queryRunner.manager.save(folder); + foldersCount++; + + for (const file of files) { + const timestamp = Date.now(); + const safeFilename = sanitizeFilename(file.fileName); + const ext = path.extname(file.path).toLowerCase(); + const r2Key = `samples/${slug}/${sanitizeFilename(folderName)}/${timestamp}-${safeFilename}${ext}`; + + const r2Result = await uploadToR2(file.buffer, r2Key, file.mimeType); + uploadedR2Keys.push(r2Key); + + const sample = queryRunner.manager.create(AudioSample, { + name: file.fileName, + filename: `${timestamp}-${safeFilename}${ext}`, + duration: 0, + folderId: savedFolder.id, + fullUrl: r2Result.url, + previewUrl: r2Result.url, + }); + await queryRunner.manager.save(sample); + samplesCount++; + } + } + + if (structure.cover) { + const coverKey = `samples/${slug}/cover${path.extname(structure.cover.path)}`; + await uploadToR2(structure.cover.buffer, coverKey, structure.cover.mimeType); + uploadedR2Keys.push(coverKey); + + savedPack.cover = `cover${path.extname(structure.cover.path)}`; + await queryRunner.manager.save(savedPack); + } + + await queryRunner.commitTransaction(); + + res.status(201).json({ + body: { + success: true, + pack: { + id: savedPack.id, + name: savedPack.name, + slug: savedPack.slug, + foldersCount, + samplesCount, + }, + warnings: structure.warnings.length > 0 ? structure.warnings : undefined, + }, + }); + } catch (dbError) { + await queryRunner.rollbackTransaction(); + + for (const key of uploadedR2Keys) { + try { + await deleteFromR2(key); + } catch (cleanupError) { + console.error(`Failed to cleanup R2 key ${key}:`, cleanupError); + } + } + + throw dbError; + } finally { + await queryRunner.release(); + } + } catch (error) { + console.error("Import pack error:", error); + + for (const key of uploadedR2Keys) { + try { + await deleteFromR2(key); + } catch (cleanupError) { + console.error(`Failed to cleanup R2 key ${key}:`, cleanupError); + } + } + + res.status(500).json({ + error: error instanceof Error ? error.message : "Import failed", + }); + } +}); + +importPackRouter.use( + (err: Error & { code?: string }, _: Request, res: Response, next: NextFunction) => { + if (err instanceof multer.MulterError) { + if (err.code === "LIMIT_FILE_SIZE") { + res.status(400).json({ error: "ZIP file too large. Maximum size is 500MB." }); + return; + } + res.status(400).json({ error: err.message }); + return; + } + if (err) { + res.status(400).json({ error: err.message }); + return; + } + next(); + } +); + +export default importPackRouter; diff --git a/server/src/routes/admin/index.ts b/server/src/routes/admin/index.ts index a1541ea..ef70d53 100644 --- a/server/src/routes/admin/index.ts +++ b/server/src/routes/admin/index.ts @@ -3,6 +3,7 @@ import { isAuth, isAdmin } from "../../middleware/auth.middleware"; import adminUsersRouter from "./users"; import adminSamplesRouter from "./samples"; import adminUploadRouter from "./upload"; +import importPackRouter from "./import-pack"; import pg from "../../config/db.config"; import { User } from "../../config/entities/User"; import { SamplePack } from "../../config/entities/SamplePack"; @@ -16,6 +17,7 @@ adminRouter.use(isAuth, isAdmin); adminRouter.use("/users", adminUsersRouter); adminRouter.use("/samples", adminSamplesRouter); adminRouter.use("/upload", adminUploadRouter); +adminRouter.use("/import-pack", importPackRouter); // GET /api/admin/stats - Dashboard stats adminRouter.get("/stats", async (_, res) => { diff --git a/webapp/src/components/admin/SampleUploader.vue b/webapp/src/components/admin/SampleUploader.vue deleted file mode 100644 index 8d62c2f..0000000 --- a/webapp/src/components/admin/SampleUploader.vue +++ /dev/null @@ -1,283 +0,0 @@ - - - - - diff --git a/webapp/src/components/admin/ZipPackImporter.vue b/webapp/src/components/admin/ZipPackImporter.vue new file mode 100644 index 0000000..b2c7d5b --- /dev/null +++ b/webapp/src/components/admin/ZipPackImporter.vue @@ -0,0 +1,742 @@ + + + + + diff --git a/webapp/src/stores/adminStore.ts b/webapp/src/stores/adminStore.ts index 4ad9e19..ee8af40 100644 --- a/webapp/src/stores/adminStore.ts +++ b/webapp/src/stores/adminStore.ts @@ -358,6 +358,66 @@ export const useAdminStore = defineStore("admin", () => { } } + // ===== IMPORT ZIP ===== + + interface ImportPackResult { + success: boolean; + pack?: { + id: string; + name: string; + slug: string; + foldersCount: number; + samplesCount: number; + }; + warnings?: string[]; + error?: string; + } + + async function importPackFromZip( + file: File, + data: { name: string; slug: string; author?: string }, + ): Promise { + const formData = new FormData(); + formData.append("zipFile", file); + formData.append("name", data.name); + formData.append("slug", data.slug); + if (data.author) formData.append("author", data.author); + + try { + const response = await fetch("/api/admin/import-pack", { + method: "POST", + body: formData, + credentials: "include", + }); + + const result = await response.json(); + + if (!response.ok) { + return { + success: false, + error: result.error || "Import failed", + warnings: result.warnings, + }; + } + + if (result.body?.pack) { + await fetchPacks(); + } + + return { + success: true, + pack: result.body.pack, + warnings: result.body.warnings, + }; + } catch (error) { + console.error("Import error:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Import failed", + }; + } + } + // ===== STATS ===== async function fetchStats() { @@ -426,6 +486,7 @@ export const useAdminStore = defineStore("admin", () => { // Upload uploadFile, + importPackFromZip, // Stats fetchStats, diff --git a/webapp/src/views/admin/AdminFolderDetail.vue b/webapp/src/views/admin/AdminFolderDetail.vue index 63436ce..2c83d7c 100644 --- a/webapp/src/views/admin/AdminFolderDetail.vue +++ b/webapp/src/views/admin/AdminFolderDetail.vue @@ -29,20 +29,10 @@ -
-

Upload Samples

- -
-

Samples ({{ currentSamples.length }})

-

No samples yet. Upload some audio files above.

+

No samples in this folder.

@@ -111,7 +101,6 @@ import { ref, reactive, computed, onMounted, onUnmounted } from "vue"; import { useRoute } from "vue-router"; import AdminLayout from "../../layouts/AdminLayout.vue"; -import SampleUploader from "../../components/admin/SampleUploader.vue"; import { useAdminStore } from "../../stores/adminStore"; const route = useRoute(); @@ -149,10 +138,6 @@ onUnmounted(() => { adminStore.resetFolderDetail(); }); -function onSampleUploaded() { - // Samples are automatically added to the store by createSample -} - function editSample(sample: any) { editingSample.value = sample; sampleForm.name = sample.name; @@ -263,18 +248,6 @@ function formatDuration(seconds: number): string { color: rgba(255, 255, 255, 0.5); } -.upload-section { - background: #2a1520; - border-radius: 12px; - padding: 20px; - margin-top: 24px; - border: 1px solid rgba(122, 15, 62, 0.3); - - h2 { - margin: 0 0 16px; - } -} - .samples-list { display: flex; flex-direction: column; diff --git a/webapp/src/views/admin/AdminSamples.vue b/webapp/src/views/admin/AdminSamples.vue index 6b91619..abef33e 100644 --- a/webapp/src/views/admin/AdminSamples.vue +++ b/webapp/src/views/admin/AdminSamples.vue @@ -3,8 +3,8 @@
@@ -12,8 +12,8 @@

No sample packs yet

-
@@ -74,14 +74,29 @@
- + + + +