diff --git a/server/api/admin/track.delete.ts b/server/api/admin/track.delete.ts index 76dfa98..8bcf357 100644 --- a/server/api/admin/track.delete.ts +++ b/server/api/admin/track.delete.ts @@ -2,7 +2,7 @@ import { getQuery, setResponseStatus } from "h3" import { Track } from "../../models/track" import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth" import { parseMongoId, validationResponse } from "../../utils/api-validation" -import { tryCleanUploadFileOrDirectory } from "../../utils/upload-files" +import { tryDeleteObjectPrefix } from "../../utils/object-storage" export default defineAuthenticatedEventHandler(async (event) => { await requireAuthenticatedUser(event, true) @@ -14,6 +14,6 @@ export default defineAuthenticatedEventHandler(async (event) => { setResponseStatus(event, 404) return {} } - void tryCleanUploadFileOrDirectory(event, track.fileDir) + await tryDeleteObjectPrefix(event, `${track.fileDir}/`) return {} }) diff --git a/server/api/admin/track.patch.ts b/server/api/admin/track.patch.ts index 80bea53..fbc1a92 100644 --- a/server/api/admin/track.patch.ts +++ b/server/api/admin/track.patch.ts @@ -1,9 +1,8 @@ -import path from "node:path" import mongoose from "mongoose" import { getQuery } from "h3" import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, safeFileExtension, parseOptionalJson, splitIds } from "../../utils/admin-form" -import { saveUploadFile, tryCleanUploadFileOrDirectory } from "../../utils/upload-files" -import { getAudioDuration, processAudioFile } from "../../utils/audio-files" +import { prepareAudioUpload } from "../../utils/audio-files" +import { putObjects, tryDeleteObjectPrefix } from "../../utils/object-storage" import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth" import { parseMongoId, validationResponse } from "../../utils/api-validation" import { Track } from "../../models/track" @@ -28,29 +27,42 @@ export default defineAuthenticatedEventHandler(async (event) => { setResponseStatus(event, 400) return { message: "One or more ID(s) are invalid" } } + const parsedLyrics = lyrics ? parseOptionalJson(lyrics) : undefined + if (parsedLyrics && !parsedLyrics.ok) throw new Error("Invalid lyrics JSON") const track = await Track.findById(parsedId.value) if (!track) { setResponseStatus(event, 404) return {} } + let replacementDirectory: string | undefined + let previousDirectory: string | undefined if (file) { - void tryCleanUploadFileOrDirectory(event, track.fileDir) - const relativeFilePath = `tracks/${crypto.randomUUID()}/original.${safeFileExtension(file.name)}` - await saveUploadFile(event, relativeFilePath, file.data) - const duration = await getAudioDuration(event, relativeFilePath) - void processAudioFile(event, relativeFilePath).catch((error) => console.error(error)) - track.fileDir = path.dirname(relativeFilePath) - track.durationInSeconds = Math.round(duration) + replacementDirectory = `tracks/${crypto.randomUUID()}` + const extension = safeFileExtension(file.name) + const processed = await prepareAudioUpload(file.data, extension) + await putObjects(event, [ + { key: `${replacementDirectory}/original${extension ? `.${extension}` : ""}`, body: processed.original, contentType: file.type }, + ...processed.renditions.map((rendition) => ({ + key: `${replacementDirectory}/${rendition.name}`, + body: rendition.body, + contentType: rendition.contentType, + })), + ]) + previousDirectory = track.fileDir + track.fileDir = replacementDirectory + track.durationInSeconds = Math.round(processed.duration) } if (name) track.name = name if (album) track.album = new mongoose.Types.ObjectId(album) if (artists) track.artists = artists.map((id) => new mongoose.Types.ObjectId(id)) - if (lyrics) { - const parsedLyrics = parseOptionalJson(lyrics) - if (!parsedLyrics.ok) throw new Error("Invalid lyrics JSON") - track.lyrics = parsedLyrics.value as any + if (parsedLyrics?.ok && parsedLyrics.value !== undefined) track.lyrics = parsedLyrics.value as any + try { + await track.save() + } catch (error) { + if (replacementDirectory) await tryDeleteObjectPrefix(event, `${replacementDirectory}/`) + throw error } - await track.save() + if (previousDirectory) await tryDeleteObjectPrefix(event, `${previousDirectory}/`) return track }) diff --git a/server/api/admin/track.post.ts b/server/api/admin/track.post.ts index 7650c1d..6e83809 100644 --- a/server/api/admin/track.post.ts +++ b/server/api/admin/track.post.ts @@ -1,8 +1,7 @@ -import path from "node:path" import mongoose from "mongoose" import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, parseOptionalJson, splitIds } from "../../utils/admin-form" -import { saveUploadFile, tryCleanUploadFileOrDirectory } from "../../utils/upload-files" -import { getAudioDuration, processAudioFile } from "../../utils/audio-files" +import { prepareAudioUpload } from "../../utils/audio-files" +import { putObjects, tryDeleteObjectPrefix } from "../../utils/object-storage" import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth" import { Track } from "../../models/track" @@ -37,27 +36,36 @@ export default defineAuthenticatedEventHandler(async (event) => { return { message: "Only audio files are accepted" } } - const relativeFilePath = `tracks/${crypto.randomUUID()}/original` - const fileDirectory = path.dirname(relativeFilePath) + const fileDirectory = `tracks/${crypto.randomUUID()}` try { - console.log("Processing audio") - await saveUploadFile(event, relativeFilePath, file.data) - const duration = await getAudioDuration(event, relativeFilePath) - void processAudioFile(event, relativeFilePath).catch((error) => console.error(error)) + const extension = safeFileExtension(file.name) + const processed = await prepareAudioUpload(file.data, extension) + await putObjects(event, [ + { key: `${fileDirectory}/original${extension ? `.${extension}` : ""}`, body: processed.original, contentType: file.type }, + ...processed.renditions.map((rendition) => ({ + key: `${fileDirectory}/${rendition.name}`, + body: rendition.body, + contentType: rendition.contentType, + })), + ]) const track = new Track({ name, album: new mongoose.Types.ObjectId(album), artists: artists.map((id) => new mongoose.Types.ObjectId(id)), fileDir: fileDirectory, - durationInSeconds: Math.round(duration), + durationInSeconds: Math.round(processed.duration), lyrics: parsedLyrics.value || undefined, }) - await track.save() + try { + await track.save() + } catch (error) { + await tryDeleteObjectPrefix(event, `${fileDirectory}/`) + throw error + } setResponseStatus(event, 201) return track } catch (error) { - void tryCleanUploadFileOrDirectory(event, fileDirectory) - console.log(error) + console.error(error) setResponseStatus(event, 500) return undefined } diff --git a/server/utils/admin-form.ts b/server/utils/admin-form.ts index 2fc84d0..3d8da8c 100644 --- a/server/utils/admin-form.ts +++ b/server/utils/admin-form.ts @@ -24,7 +24,8 @@ export function splitIds(value: string | undefined) { } export function safeFileExtension(fileName: string) { - return fileName.split(".")[1]?.replace(/[^A-Za-z0-9]/g, "") ?? "" + if (!fileName.includes(".")) return "" + return fileName.split(".").pop()?.replace(/[^A-Za-z0-9]/g, "") ?? "" } export function parseOptionalJson(value: string | undefined) { diff --git a/server/utils/audio-files.ts b/server/utils/audio-files.ts index 1c14fba..fc086be 100644 --- a/server/utils/audio-files.ts +++ b/server/utils/audio-files.ts @@ -1,47 +1,57 @@ -import { unlink } from "node:fs/promises" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import os from "node:os" import path from "node:path" import Ffmpeg from "fluent-ffmpeg" -import type { H3Event } from "h3" -import { resolveUploadFile } from "./upload-files" -export async function getAudioDuration(event: H3Event, relativePath: string): Promise { - if (!relativePath) throw new Error("filePath is undefined") - const { path: sourcePath } = await resolveUploadFile(event, relativePath) +const jobs: Array<[string, string, string, string, string]> = [ + ["128k", "mp3", "libmp3lame", "low.mp3", "audio/mpeg"], + ["256k", "mp3", "libmp3lame", "mid.mp3", "audio/mpeg"], + ["96k", "mp4", "aac", "low.m4a", "audio/mp4"], + ["192k", "mp4", "aac", "mid.m4a", "audio/mp4"], + ["320k", "mp4", "aac", "high.m4a", "audio/mp4"], +] + +function getDuration(sourcePath: string): Promise { return new Promise((resolve, reject) => { Ffmpeg.ffprobe(sourcePath, (error, metadata) => { - if (error) return reject(0) + if (error) return reject(error) if (metadata.format.duration) return resolve(metadata.format.duration) - reject(0) + reject(new Error("Unable to read audio duration")) }) }) } -export async function processAudioFile(event: H3Event, relativePath: string) { - if (!relativePath) throw new Error("filePath is undefined") - const { path: sourcePath } = await resolveUploadFile(event, relativePath) - const directory = path.dirname(sourcePath) - const jobs: Array<[string, string, string]> = [ - ["128k", "mp3", "libmp3lame"], - ["256k", "mp3", "libmp3lame"], - ["96k", "mp4", "aac"], - ["192k", "mp4", "aac"], - ["320k", "mp4", "aac"], - ] - const names = ["low.mp3", "mid.mp3", "low.m4a", "mid.m4a", "high.m4a"] +function transcode(sourcePath: string, outputPath: string, bitrate: string, format: string, codec: string) { + return new Promise((resolve, reject) => { + Ffmpeg(sourcePath) + .noVideo() + .audioBitrate(bitrate) + .toFormat(format) + .audioCodec(codec) + .on("end", () => resolve()) + .on("error", reject) + .save(outputPath) + }) +} - await Promise.all( - jobs.map(([bitrate, format, codec], index) => - new Promise((resolve, reject) => { - Ffmpeg(sourcePath) - .noVideo() - .audioBitrate(bitrate) - .toFormat(format) - .audioCodec(codec) - .on("end", () => resolve()) - .on("error", reject) - .save(path.join(directory, names[index]!)) - }), - ), - ) - await unlink(sourcePath) +export async function prepareAudioUpload(data: Buffer, extension: string) { + const directory = await mkdtemp(path.join(os.tmpdir(), "music-v2-audio-")) + const sourcePath = path.join(directory, `original${extension ? `.${extension}` : ""}`) + try { + await writeFile(sourcePath, data) + const duration = await getDuration(sourcePath) + const completed = await Promise.allSettled(jobs.map(([bitrate, format, codec, name]) => + transcode(sourcePath, path.join(directory, name), bitrate, format, codec), + )) + const failedJob = completed.find((result) => result.status === "rejected") + if (failedJob?.status === "rejected") throw failedJob.reason + const renditions = await Promise.all(jobs.map(async ([, , , name, contentType]) => ({ + name, + contentType, + body: await readFile(path.join(directory, name)), + }))) + return { duration, original: data, renditions } + } finally { + await rm(directory, { recursive: true, force: true }) + } } diff --git a/server/utils/upload-files.ts b/server/utils/upload-files.ts index 7fb56f5..b473f86 100644 --- a/server/utils/upload-files.ts +++ b/server/utils/upload-files.ts @@ -1,108 +1,7 @@ -import { mkdir, realpath, rm, writeFile } from "node:fs/promises" -import path from "node:path" -import type { H3Event, MultiPartData } from "h3" -import { getBackendRuntimeConfig } from "./backend-config" - -export class InvalidUploadPathError extends Error { - constructor() { - super("filePath is invalid") - this.name = "InvalidUploadPathError" - } -} - -export class UploadFileNotFoundError extends Error { - code = "ENOENT" - constructor() { - super("File or directory doesn't exist") - this.name = "UploadFileNotFoundError" - } -} - -function rootDirectory(event: H3Event) { - const configured = getBackendRuntimeConfig(event).uploadDir - if (!configured) throw new Error("Environment variable UPLOAD_DIR not found") - return path.resolve(configured) -} - -function isWithin(root: string, target: string) { - const relative = path.relative(root, target) - return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) -} - -function resolveLexically(root: string, relativePath: string) { - if (!relativePath || path.isAbsolute(relativePath)) throw new InvalidUploadPathError() - const target = path.resolve(root, relativePath) - if (!isWithin(root, target)) throw new InvalidUploadPathError() - return target -} - -export async function resolveUploadFile(event: H3Event, relativePath: string) { - const root = rootDirectory(event) - const target = resolveLexically(root, relativePath) - // Resolve the configured root separately: a missing/unreadable upload root is - // an operational failure, while a missing requested file is a normal 404. - const realRoot = await realpath(root) - let realTarget: string - try { - realTarget = await realpath(target) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new UploadFileNotFoundError() - throw error - } - if (!isWithin(realRoot, realTarget)) throw new InvalidUploadPathError() - return { root: realRoot, path: realTarget } -} - -export async function saveUploadFile(event: H3Event, relativePath: string, data: Buffer) { - const root = rootDirectory(event) - const target = resolveLexically(root, relativePath) - await mkdir(path.dirname(target), { recursive: true }) - const realRoot = await realpath(root) - const realParent = await realpath(path.dirname(target)) - if (!isWithin(realRoot, realParent)) throw new InvalidUploadPathError() - try { - const existing = await realpath(target) - if (!isWithin(realRoot, existing)) throw new InvalidUploadPathError() - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error - } - await writeFile(target, data) -} - -export async function cleanUploadFileOrDirectory(event: H3Event, relativePath: string) { - const root = rootDirectory(event) - const target = resolveLexically(root, relativePath) - let realTarget: string - try { - const [realRoot, resolved] = await Promise.all([realpath(root), realpath(target)]) - if (!isWithin(realRoot, resolved)) throw new InvalidUploadPathError() - realTarget = resolved - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - console.error("File or directory doesn't exist") - return - } - throw error - } - try { - await rm(realTarget, { recursive: true }) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - console.error("File or directory doesn't exist") - return - } - throw error - } -} - -export async function tryCleanUploadFileOrDirectory(event: H3Event, relativePath: string) { - try { - await cleanUploadFileOrDirectory(event, relativePath) - } catch (error) { - console.error("Unable to clean uploaded file or directory", error) - } -} +import type { MultiPartData } from "h3" +// Keep the established utility module path for Nitro's generated auto-imports. +// File persistence is handled by object-storage.ts. export function getFormField(parts: MultiPartData[] | undefined, name: string) { return parts?.find((part) => part.name === name) }