diff --git a/package-lock.json b/package-lock.json index 8b432db..164a470 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": "^16.5.4", "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", @@ -4339,6 +4343,12 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "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 +4397,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", @@ -5586,6 +5606,18 @@ "license": "ISC", "optional": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -5635,6 +5667,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 +7058,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 +7213,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" @@ -8550,12 +8597,30 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -8852,6 +8917,23 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "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 +9732,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 +11244,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 +11380,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 +12826,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", @@ -12863,6 +13014,19 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -13251,6 +13415,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "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", @@ -13479,6 +13658,62 @@ "node": ">= 6" } }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", + "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -14368,6 +14603,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 +15532,23 @@ ], "license": "MIT" }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "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 +15905,23 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "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", diff --git a/package.json b/package.json index dc5f423..0ac565a 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": "^16.5.4", "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..2ba62f8 --- /dev/null +++ b/server/src/routes/admin/import-pack.ts @@ -0,0 +1,412 @@ +import { Router, Request, Response, NextFunction } from "express"; +import multer from "multer"; +import AdmZip from "adm-zip"; +import path from "path"; +import FileType 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/vnd.wave", + "audio/ogg", + "audio/flac", + "audio/x-flac", + "audio/aiff", + "audio/x-aiff", +]; + +// Fallback MIME types based on extension (when file-type can't detect) +const EXT_TO_MIME: Record = { + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".flac": "audio/flac", + ".aiff": "audio/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 FileType.fromBuffer(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; + } + + // Determine MIME type: use file-type detection or fallback to extension-based + let mimeType: string; + if (fileType && ALLOWED_AUDIO_MIMES.includes(fileType.mime)) { + mimeType = fileType.mime; + } else if (EXT_TO_MIME[ext]) { + // Fallback: trust extension for known audio formats + mimeType = EXT_TO_MIME[ext]; + } else { + 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, + }; + + if (!folders.has(folderName)) { + folders.set(folderName, []); + } + folders.get(folderName)!.push(parsedFile); + } + + return { folders, cover, warnings }; +} + +function sendSSE(res: Response, data: object) { + res.write(`data: ${JSON.stringify(data)}\n\n`); +} + +importPackRouter.post("/", upload.single("zipFile"), async (req, res) => { + const uploadedR2Keys: string[] = []; + + // Validate before starting SSE + 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; + } + + // Start SSE response + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); + + try { + sendSSE(res, { type: "progress", progress: 5, file: "Parsing ZIP..." }); + + const structure = await parseZipStructure(req.file.buffer); + + if (structure.folders.size === 0) { + sendSSE(res, { + type: "error", + error: "No valid audio files found in ZIP", + }); + res.end(); + return; + } + + // Count total files for progress + let totalFiles = 0; + for (const files of structure.folders.values()) { + totalFiles += files.length; + } + if (structure.cover) totalFiles++; + + sendSSE(res, { type: "progress", progress: 10, file: "Creating pack..." }); + + 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; + let processedFiles = 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}`; + + // Send progress + processedFiles++; + const progress = Math.round(10 + (processedFiles / totalFiles) * 85); + sendSSE(res, { + type: "progress", + progress, + file: `${folderName}/${file.fileName}${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) { + sendSSE(res, { + type: "progress", + progress: 98, + file: "Uploading 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(); + + sendSSE(res, { + type: "complete", + pack: { + id: savedPack.id, + name: savedPack.name, + slug: savedPack.slug, + foldersCount, + samplesCount, + }, + warnings: + structure.warnings.length > 0 ? structure.warnings : undefined, + }); + res.end(); + } 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); + } + } + + sendSSE(res, { + type: "error", + error: error instanceof Error ? error.message : "Import failed", + }); + res.end(); + } +}); + +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/server/src/services/r2.service.ts b/server/src/services/r2.service.ts index 93a1ceb..c4b25f3 100644 --- a/server/src/services/r2.service.ts +++ b/server/src/services/r2.service.ts @@ -9,6 +9,7 @@ const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID; const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY; const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME; const CDN_BASE_URL = process.env.CDN_BASE_URL; +const ENVIRONMENT = process.env.NODE_ENV || "development"; const s3Client = new S3Client({ region: "auto", @@ -35,6 +36,10 @@ export async function uploadToR2( Key: key, Body: buffer, ContentType: contentType, + Metadata: { + environment: ENVIRONMENT, + "uploaded-at": new Date().toISOString(), + }, }), ); 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..ac62c75 --- /dev/null +++ b/webapp/src/components/admin/ZipPackImporter.vue @@ -0,0 +1,909 @@ + + + + + diff --git a/webapp/src/components/app/DawLoadingOverlay.vue b/webapp/src/components/app/DawLoadingOverlay.vue index 34d50e5..d2214fe 100644 --- a/webapp/src/components/app/DawLoadingOverlay.vue +++ b/webapp/src/components/app/DawLoadingOverlay.vue @@ -33,7 +33,9 @@ class="task-item" :class="task.status" > - {{ statusIcon(task.status) }} + {{ + statusIcon(task.status) + }} {{ task.label }} @@ -62,8 +64,14 @@ const props = withDefaults( ); const dawLoadingStore = useDawLoadingStore(); -const { isComplete, currentPhase, statusText, overallProgress, allTasks, error } = - storeToRefs(dawLoadingStore); +const { + isComplete, + currentPhase, + statusText, + overallProgress, + allTasks, + error, +} = storeToRefs(dawLoadingStore); function statusIcon(status: string): string { switch (status) { diff --git a/webapp/src/stores/adminStore.ts b/webapp/src/stores/adminStore.ts index 4ad9e19..7f50993 100644 --- a/webapp/src/stores/adminStore.ts +++ b/webapp/src/stores/adminStore.ts @@ -358,6 +358,91 @@ 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 }, + onProgress?: (progress: number, stage: "upload" | "processing") => void, + ): 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); + + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && onProgress) { + const percent = Math.round((event.loaded / event.total) * 100); + onProgress(percent, "upload"); + } + }); + + xhr.upload.addEventListener("load", () => { + if (onProgress) { + onProgress(100, "processing"); + } + }); + + xhr.addEventListener("load", async () => { + try { + const result = JSON.parse(xhr.responseText); + + if (xhr.status >= 400) { + resolve({ + success: false, + error: result.error || "Import failed", + warnings: result.warnings, + }); + return; + } + + if (result.body?.pack) { + await fetchPacks(); + } + + resolve({ + success: true, + pack: result.body.pack, + warnings: result.body.warnings, + }); + } catch (error) { + resolve({ + success: false, + error: "Failed to parse server response", + }); + } + }); + + xhr.addEventListener("error", () => { + resolve({ + success: false, + error: "Network error during import", + }); + }); + + xhr.open("POST", "/api/admin/import-pack"); + xhr.withCredentials = true; + xhr.send(formData); + }); + } + // ===== STATS ===== async function fetchStats() { @@ -426,6 +511,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..39f3264 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,25 @@
- + + + +