From bca41b5e6fef4b1bb1045fc747f21ff6e396d4b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20T=C3=A4kker?= Date: Tue, 29 Sep 2026 01:22:29 +0200 Subject: [PATCH] Migrated admin and media APIs to Nitro --- app/utils/file.ts | 2 +- server/api/admin/album.delete.ts | 30 ++++++++ server/api/admin/album.patch.ts | 45 +++++++++++ server/api/admin/album.post.ts | 37 +++++++++ server/api/admin/artist.delete.ts | 42 +++++++++++ server/api/admin/artist.patch.ts | 35 +++++++++ server/api/admin/artist.post.ts | 25 ++++++ server/api/admin/genre.delete.ts | 42 +++++++++++ server/api/admin/genre.patch.ts | 34 +++++++++ server/api/admin/genre.post.ts | 31 ++++++++ server/api/admin/track.delete.ts | 19 +++++ server/api/admin/track.patch.ts | 56 ++++++++++++++ server/api/admin/track.post.ts | 64 ++++++++++++++++ server/routes/static/[...path].ts | 98 ++++++++++++++++++++++++ server/utils/admin-form.ts | 37 +++++++++ server/utils/audio-files.ts | 47 ++++++++++++ server/utils/upload-files.ts | 121 ++++++++++++++++++++++++++++++ shared/types/fluent-ffmpeg.d.ts | 22 ++++++ 18 files changed, 786 insertions(+), 1 deletion(-) create mode 100644 server/api/admin/album.delete.ts create mode 100644 server/api/admin/album.patch.ts create mode 100644 server/api/admin/album.post.ts create mode 100644 server/api/admin/artist.delete.ts create mode 100644 server/api/admin/artist.patch.ts create mode 100644 server/api/admin/artist.post.ts create mode 100644 server/api/admin/genre.delete.ts create mode 100644 server/api/admin/genre.patch.ts create mode 100644 server/api/admin/genre.post.ts create mode 100644 server/api/admin/track.delete.ts create mode 100644 server/api/admin/track.patch.ts create mode 100644 server/api/admin/track.post.ts create mode 100644 server/routes/static/[...path].ts create mode 100644 server/utils/admin-form.ts create mode 100644 server/utils/audio-files.ts create mode 100644 server/utils/upload-files.ts create mode 100644 shared/types/fluent-ffmpeg.d.ts diff --git a/app/utils/file.ts b/app/utils/file.ts index a31edbe..26786b9 100644 --- a/app/utils/file.ts +++ b/app/utils/file.ts @@ -1,4 +1,4 @@ -export const BACKEND_SERVE = `${import.meta.env.VITE_PUBLIC_BACKEND}/static/` +export const BACKEND_SERVE = "/static/" export const GET_FILE = (file: string) => `${BACKEND_SERVE}${file}` export const GET_AUDIO_FILE = (file: string) => { diff --git a/server/api/admin/album.delete.ts b/server/api/admin/album.delete.ts new file mode 100644 index 0000000..f83083c --- /dev/null +++ b/server/api/admin/album.delete.ts @@ -0,0 +1,30 @@ +import { getQuery, setResponseStatus } from "h3" +import { Album } from "../../models/album" +import { Track } from "../../models/track" +import { requireAuthenticatedUser } from "../../utils/auth" +import { parseMongoId, validationResponse } from "../../utils/api-validation" +import { tryCleanUploadFileOrDirectory } from "../../utils/upload-files" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event, true) + const query = getQuery(event) + const parsedId = parseMongoId(query.id) + if ("error" in parsedId) return validationResponse(event, parsedId.error) + const force = query.force === "true" || query.force === true + const album = await Album.findById(parsedId.value) + if (!album) { + setResponseStatus(event, 404) + return { message: "Album not found" } + } + + const dependents = await Track.find({ album: album._id }).select("_id name").exec() + if (force) await Promise.all(dependents.map((dependent) => Track.findByIdAndDelete(dependent._id))) + else if (dependents.length) { + setResponseStatus(event, 409) + return { message: "Album has one or more dependents", dependentType: "Track", dependents } + } + + await Album.findByIdAndDelete(parsedId.value) + void tryCleanUploadFileOrDirectory(event, album.file) + return {} +}) diff --git a/server/api/admin/album.patch.ts b/server/api/admin/album.patch.ts new file mode 100644 index 0000000..1ad4c91 --- /dev/null +++ b/server/api/admin/album.patch.ts @@ -0,0 +1,45 @@ +import { getQuery, setResponseStatus } from "h3" +import mongoose from "mongoose" +import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, safeFileExtension, splitIds } from "../../utils/admin-form" +import { saveUploadFile, tryCleanUploadFileOrDirectory } from "../../utils/upload-files" +import { requireAuthenticatedUser } from "../../utils/auth" +import { parseMongoId, validationResponse } from "../../utils/api-validation" +import { Album } from "../../models/album" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event, true) + const parsedId = parseMongoId(getQuery(event).id) + if ("error" in parsedId) return validationResponse(event, parsedId.error) + const parts = await readAdminForm(event) + const name = adminFormText(parts, "name") + const artistInput = adminFormTexts(parts, "artists") + const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined + const genre = adminFormText(parts, "genre") + const file = adminFormFile(parts, "file") + if (artists && !artists.every((id) => mongoose.isValidObjectId(id))) { + setResponseStatus(event, 400) + return { message: "One or more ID(s) are invalid" } + } + if (genre && !mongoose.isValidObjectId(genre)) return validationResponse(event, "Invalid id") + if (file && !file.type.includes("image/")) { + setResponseStatus(event, 400) + return { message: "Only image files are accepted" } + } + + const album = await Album.findById(parsedId.value) + if (!album) { + setResponseStatus(event, 404) + return {} + } + if (file) { + void tryCleanUploadFileOrDirectory(event, album.file) + const filePath = `album/${crypto.randomUUID()}.${safeFileExtension(file.name)}` + await saveUploadFile(event, filePath, file.data) + album.file = filePath + } + if (name) album.name = name + if (artists) album.artists = artists.map((id) => new mongoose.Types.ObjectId(id)) + if (genre) album.genre = new mongoose.Types.ObjectId(genre) + await album.save() + return album +}) diff --git a/server/api/admin/album.post.ts b/server/api/admin/album.post.ts new file mode 100644 index 0000000..9232739 --- /dev/null +++ b/server/api/admin/album.post.ts @@ -0,0 +1,37 @@ +import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, safeFileExtension, splitIds } from "../../utils/admin-form" +import { saveUploadFile } from "../../utils/upload-files" +import { requireAuthenticatedUser } from "../../utils/auth" +import { Album } from "../../models/album" +import mongoose from "mongoose" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event, true) + const parts = await readAdminForm(event) + const name = adminFormText(parts, "name") + const artistInput = adminFormTexts(parts, "artists") + const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined + const genre = adminFormText(parts, "genre") + const file = adminFormFile(parts, "file") + if (name === undefined || !artists || !genre || !file) { + setResponseStatus(event, 400) + return { message: "Required" } + } + if (!mongoose.isValidObjectId(genre)) { + setResponseStatus(event, 400) + return { message: "Invalid id" } + } + if (!artists.every((id) => mongoose.isValidObjectId(id))) { + setResponseStatus(event, 400) + return { message: "One or more ID(s) are invalid" } + } + if (!file.type.includes("image/")) { + setResponseStatus(event, 400) + return { message: "Only image files are accepted" } + } + + const filePath = `album/${crypto.randomUUID()}.${safeFileExtension(file.name)}` + await saveUploadFile(event, filePath, file.data) + const album = await Album.create({ name, artists, file: filePath, genre }) + setResponseStatus(event, 201) + return album +}) diff --git a/server/api/admin/artist.delete.ts b/server/api/admin/artist.delete.ts new file mode 100644 index 0000000..f7f807b --- /dev/null +++ b/server/api/admin/artist.delete.ts @@ -0,0 +1,42 @@ +import { getQuery, setResponseStatus } from "h3" +import { Album } from "../../models/album" +import { Artist } from "../../models/artist" +import { Track } from "../../models/track" +import { requireAuthenticatedUser } from "../../utils/auth" +import { parseMongoId, validationResponse } from "../../utils/api-validation" +import { tryCleanUploadFileOrDirectory } from "../../utils/upload-files" + +async function findDependents(model: typeof Track | typeof Album, field: string, value: unknown) { + return model.find({ [field]: value }).select("_id name").exec() +} + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event, true) + const query = getQuery(event) + const parsedId = parseMongoId(query.id) + if ("error" in parsedId) return validationResponse(event, parsedId.error) + const force = query.force === "true" || query.force === true + const artist = await Artist.findById(parsedId.value) + if (!artist) { + setResponseStatus(event, 404) + return { message: "Artist not found" } + } + + let dependents = await findDependents(Track, "artists", artist._id) + if (force) await Promise.all(dependents.map((dependent) => Track.findByIdAndDelete(dependent._id))) + else if (dependents.length) { + setResponseStatus(event, 409) + return { message: "Artist has one or more dependents", dependentType: "Track", dependents } + } + + dependents = await findDependents(Album, "artists", artist._id) + if (force) await Promise.all(dependents.map((dependent) => Album.findByIdAndDelete(dependent._id))) + else if (dependents.length) { + setResponseStatus(event, 409) + return { message: "Artist has one or more dependents", dependentType: "Artist", dependents } + } + + await Artist.findByIdAndDelete(parsedId.value) + void tryCleanUploadFileOrDirectory(event, artist.file) + return {} +}) diff --git a/server/api/admin/artist.patch.ts b/server/api/admin/artist.patch.ts new file mode 100644 index 0000000..072980e --- /dev/null +++ b/server/api/admin/artist.patch.ts @@ -0,0 +1,35 @@ +import { getQuery, setResponseStatus } from "h3" +import mongoose from "mongoose" +import { readAdminForm, adminFormText, adminFormFile, safeFileExtension } from "../../utils/admin-form" +import { saveUploadFile, tryCleanUploadFileOrDirectory } from "../../utils/upload-files" +import { requireAuthenticatedUser } from "../../utils/auth" +import { parseMongoId, validationResponse } from "../../utils/api-validation" +import { Artist } from "../../models/artist" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event, true) + const parsedId = parseMongoId(getQuery(event).id) + if ("error" in parsedId) return validationResponse(event, parsedId.error) + const parts = await readAdminForm(event) + const name = adminFormText(parts, "name") + const file = adminFormFile(parts, "file") + if (file && !file.type.includes("image/")) { + setResponseStatus(event, 400) + return { message: "Only image files are accepted" } + } + + const artist = await Artist.findById(parsedId.value) + if (!artist) { + setResponseStatus(event, 404) + return {} + } + if (file) { + void tryCleanUploadFileOrDirectory(event, artist.file) + const filePath = `artists/${crypto.randomUUID()}.${safeFileExtension(file.name)}` + await saveUploadFile(event, filePath, file.data) + artist.file = filePath + } + if (name) artist.name = name + await artist.save() + return artist +}) diff --git a/server/api/admin/artist.post.ts b/server/api/admin/artist.post.ts new file mode 100644 index 0000000..e4ad3a5 --- /dev/null +++ b/server/api/admin/artist.post.ts @@ -0,0 +1,25 @@ +import { readAdminForm, adminFormText, adminFormFile, safeFileExtension } from "../../utils/admin-form" +import { saveUploadFile } from "../../utils/upload-files" +import { requireAuthenticatedUser } from "../../utils/auth" +import { Artist } from "../../models/artist" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event, true) + const parts = await readAdminForm(event) + const name = adminFormText(parts, "name") + const file = adminFormFile(parts, "file") + if (name === undefined || !file) { + setResponseStatus(event, 400) + return { message: "Required" } + } + if (!file.type.includes("image/")) { + setResponseStatus(event, 400) + return { message: "Only image files are accepted" } + } + + const filePath = `artists/${crypto.randomUUID()}.${safeFileExtension(file.name)}` + await saveUploadFile(event, filePath, file.data) + const artist = await Artist.create({ name, file: filePath }) + setResponseStatus(event, 201) + return artist +}) diff --git a/server/api/admin/genre.delete.ts b/server/api/admin/genre.delete.ts new file mode 100644 index 0000000..44629c8 --- /dev/null +++ b/server/api/admin/genre.delete.ts @@ -0,0 +1,42 @@ +import { getQuery, setResponseStatus } from "h3" +import { Album } from "../../models/album" +import { Genre } from "../../models/genre" +import { requireAuthenticatedUser } from "../../utils/auth" +import { parseMongoId } from "../../utils/api-validation" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event, true) + + const query = getQuery(event) + const parsedId = parseMongoId(query.id) + if ("error" in parsedId) { + setResponseStatus(event, 400) + return { message: parsedId.error } + } + if (query.force !== "true" && query.force !== "false") { + setResponseStatus(event, 400) + return { message: query.force === undefined ? "Required" : "Expected boolean, received string" } + } + const force = query.force === "true" + + const genre = await Genre.findById(parsedId.value) + if (!genre) { + setResponseStatus(event, 404) + return { message: "Genre not found" } + } + + const dependents = await Album.find({ genre: genre._id }).select("_id name") + if (force) { + await Promise.all(dependents.map((dependent) => Album.findByIdAndDelete(dependent._id))) + } else if (dependents.length) { + setResponseStatus(event, 409) + return { + message: "Genre has one or more dependents", + dependentType: "Album", + dependents, + } + } + + await Genre.findByIdAndDelete(parsedId.value) + return {} +}) diff --git a/server/api/admin/genre.patch.ts b/server/api/admin/genre.patch.ts new file mode 100644 index 0000000..1246759 --- /dev/null +++ b/server/api/admin/genre.patch.ts @@ -0,0 +1,34 @@ +import { getQuery, readBody, setResponseStatus } from "h3" +import { Genre } from "../../models/genre" +import { requireAuthenticatedUser } from "../../utils/auth" +import { parseMongoId } from "../../utils/api-validation" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event, true) + + const parsedId = parseMongoId(getQuery(event).id) + if ("error" in parsedId) { + setResponseStatus(event, 400) + return { message: parsedId.error } + } + + const body = await readBody(event) + if (!body || typeof body !== "object" || Array.isArray(body)) { + setResponseStatus(event, 400) + const received = body === null ? "null" : Array.isArray(body) ? "array" : typeof body + return { message: `Expected object, received ${received}` } + } + + const record = body as Record + if (record.name !== undefined && typeof record.name !== "string") { + setResponseStatus(event, 400) + return { message: `Expected string, received ${record.name === null ? "null" : typeof record.name}` } + } + + const genre = await Genre.findByIdAndUpdate(parsedId.value, record.name === undefined ? {} : { name: record.name }, { new: true }) + if (!genre) { + setResponseStatus(event, 404) + return { message: "Genre not found" } + } + return genre +}) diff --git a/server/api/admin/genre.post.ts b/server/api/admin/genre.post.ts new file mode 100644 index 0000000..c095131 --- /dev/null +++ b/server/api/admin/genre.post.ts @@ -0,0 +1,31 @@ +import { readBody, setResponseStatus } from "h3" +import { Genre } from "../../models/genre" +import { requireAuthenticatedUser } from "../../utils/auth" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event, true) + + const body = await readBody(event) + if (!body || typeof body !== "object" || Array.isArray(body)) { + setResponseStatus(event, 400) + const received = body === null ? "null" : Array.isArray(body) ? "array" : typeof body + return { message: `Expected object, received ${received}` } + } + + const name = (body as { name?: unknown }).name + if (typeof name !== "string") { + setResponseStatus(event, 400) + return { message: name === undefined ? "Required" : `Expected string, received ${name === null ? "null" : typeof name}` } + } + try { + const genre = await Genre.create({ name }) + setResponseStatus(event, 201) + return genre + } catch (error) { + setResponseStatus(event, 400) + if (typeof error === "object" && error !== null && "code" in error && error.code === 11000) { + return { message: "Genre already exists" } + } + return { message: "Unknown error" } + } +}) diff --git a/server/api/admin/track.delete.ts b/server/api/admin/track.delete.ts new file mode 100644 index 0000000..14fe65d --- /dev/null +++ b/server/api/admin/track.delete.ts @@ -0,0 +1,19 @@ +import { getQuery, setResponseStatus } from "h3" +import { Track } from "../../models/track" +import { requireAuthenticatedUser } from "../../utils/auth" +import { parseMongoId, validationResponse } from "../../utils/api-validation" +import { tryCleanUploadFileOrDirectory } from "../../utils/upload-files" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event, true) + const parsedId = parseMongoId(getQuery(event).id) + if ("error" in parsedId) return validationResponse(event, parsedId.error) + + const track = await Track.findByIdAndDelete(parsedId.value) + if (!track) { + setResponseStatus(event, 404) + return {} + } + void tryCleanUploadFileOrDirectory(event, track.fileDir) + return {} +}) diff --git a/server/api/admin/track.patch.ts b/server/api/admin/track.patch.ts new file mode 100644 index 0000000..b1eec75 --- /dev/null +++ b/server/api/admin/track.patch.ts @@ -0,0 +1,56 @@ +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 { requireAuthenticatedUser } from "../../utils/auth" +import { parseMongoId, validationResponse } from "../../utils/api-validation" +import { Track } from "../../models/track" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event, true) + const parsedId = parseMongoId(getQuery(event).id) + if ("error" in parsedId) return validationResponse(event, parsedId.error) + const parts = await readAdminForm(event) + const name = adminFormText(parts, "name") + const album = adminFormText(parts, "album") + const artistInput = adminFormTexts(parts, "artists") + const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined + const file = adminFormFile(parts, "file") + const lyrics = adminFormText(parts, "lyrics") + if (file && !file.type.includes("audio/")) { + setResponseStatus(event, 400) + return { message: "Only audio files are accepted" } + } + if (album && !mongoose.isValidObjectId(album)) return validationResponse(event, "Invalid id") + if (artists && !artists.every((id) => mongoose.isValidObjectId(id))) { + setResponseStatus(event, 400) + return { message: "One or more ID(s) are invalid" } + } + + const track = await Track.findById(parsedId.value) + if (!track) { + setResponseStatus(event, 404) + return {} + } + 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) + } + 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 + } + await track.save() + return track +}) diff --git a/server/api/admin/track.post.ts b/server/api/admin/track.post.ts new file mode 100644 index 0000000..70bcd41 --- /dev/null +++ b/server/api/admin/track.post.ts @@ -0,0 +1,64 @@ +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 { requireAuthenticatedUser } from "../../utils/auth" +import { Track } from "../../models/track" + +export default defineEventHandler(async (event) => { + await requireAuthenticatedUser(event, true) + const parts = await readAdminForm(event) + const name = adminFormText(parts, "name") + const album = adminFormText(parts, "album") + const artistInput = adminFormTexts(parts, "artists") + const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined + const file = adminFormFile(parts, "file") + const lyricsInput = adminFormText(parts, "lyrics") + if (name === undefined || album === undefined || !artists || !file) { + setResponseStatus(event, 400) + return { message: "Required" } + } + if (!mongoose.isValidObjectId(album)) { + setResponseStatus(event, 400) + return { message: "Invalid id" } + } + if (!artists.every((id) => mongoose.isValidObjectId(id))) { + setResponseStatus(event, 400) + return { message: "One or more ID(s) are invalid" } + } + const parsedLyrics = parseOptionalJson(lyricsInput) + if (!parsedLyrics.ok) { + setResponseStatus(event, 400) + return { message: "Lyrics must be a JSON object" } + } + if (!file.type.includes("audio/")) { + setResponseStatus(event, 400) + return { message: "Only audio files are accepted" } + } + + const relativeFilePath = `tracks/${crypto.randomUUID()}/original` + const fileDirectory = path.dirname(relativeFilePath) + 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 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), + lyrics: parsedLyrics.value || undefined, + }) + await track.save() + setResponseStatus(event, 201) + return track + } catch (error) { + void tryCleanUploadFileOrDirectory(event, fileDirectory) + console.log(error) + setResponseStatus(event, 500) + return null + } +}) diff --git a/server/routes/static/[...path].ts b/server/routes/static/[...path].ts new file mode 100644 index 0000000..00a70aa --- /dev/null +++ b/server/routes/static/[...path].ts @@ -0,0 +1,98 @@ +import { createReadStream } from "node:fs" +import { stat } from "node:fs/promises" +import path from "node:path" +import { assertMethod, getRouterParam, getRequestHeader, sendStream, setHeader, setResponseStatus } from "h3" +import { InvalidUploadPathError, resolveUploadFile, UploadFileNotFoundError } from "../../utils/upload-files" + +const contentTypes: Record = { + ".aac": "audio/aac", + ".flac": "audio/flac", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".m4a": "audio/mp4", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".ogg": "audio/ogg", + ".png": "image/png", + ".wav": "audio/wav", + ".webp": "image/webp", +} + +function sendRangeNotSatisfiable(event: Parameters[0], size: number) { + setResponseStatus(event, 416) + setHeader(event, "Content-Range", `bytes */${size}`) + setHeader(event, "Accept-Ranges", "bytes") + setHeader(event, "Content-Length", 0) + return null +} + +export default defineEventHandler(async (event) => { + assertMethod(event, ["GET", "HEAD"]) + const relativePath = getRouterParam(event, "path") + if (!relativePath) { + setResponseStatus(event, 404) + return null + } + + let file: { path: string } + try { + file = await resolveUploadFile(event, relativePath) + } catch (error) { + if (error instanceof InvalidUploadPathError || error instanceof UploadFileNotFoundError) { + setResponseStatus(event, 404) + return null + } + throw error + } + + let metadata + try { + metadata = await stat(file.path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + setResponseStatus(event, 404) + return null + } + throw error + } + if (!metadata.isFile()) { + setResponseStatus(event, 404) + return null + } + + setHeader(event, "Accept-Ranges", "bytes") + setHeader(event, "Content-Type", contentTypes[path.extname(file.path).toLowerCase()] ?? "application/octet-stream") + const rangeHeader = getRequestHeader(event, "range") + let start = 0 + let end = metadata.size - 1 + + if (rangeHeader) { + const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()) + if (!match || (!match[1] && !match[2]) || metadata.size === 0) { + return sendRangeNotSatisfiable(event, metadata.size) + } + + if (!match[1]) { + const suffixLength = Number(match[2]) + if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return sendRangeNotSatisfiable(event, metadata.size) + start = Math.max(metadata.size - suffixLength, 0) + } else { + start = Number(match[1]) + if (!Number.isSafeInteger(start)) return sendRangeNotSatisfiable(event, metadata.size) + if (match[2]) { + end = Number(match[2]) + if (!Number.isSafeInteger(end)) return sendRangeNotSatisfiable(event, metadata.size) + } + } + if (start >= metadata.size || end < start) return sendRangeNotSatisfiable(event, metadata.size) + end = Math.min(end, metadata.size - 1) + setResponseStatus(event, 206) + setHeader(event, "Content-Range", `bytes ${start}-${end}/${metadata.size}`) + setHeader(event, "Content-Length", end - start + 1) + } else { + setHeader(event, "Content-Length", metadata.size) + } + + if (event.node.req.method === "HEAD") return null + return sendStream(event, createReadStream(file.path, { start, end })) +}) diff --git a/server/utils/admin-form.ts b/server/utils/admin-form.ts new file mode 100644 index 0000000..2fc84d0 --- /dev/null +++ b/server/utils/admin-form.ts @@ -0,0 +1,37 @@ +import { readMultipartFormData, type H3Event, type MultiPartData } from "h3" +import { formFieldText, getFormField, getFormFieldValues } from "./upload-files" + +export async function readAdminForm(event: H3Event) { + return (await readMultipartFormData(event)) ?? [] +} + +export function adminFormText(parts: MultiPartData[], key: string) { + return formFieldText(getFormField(parts, key)) +} + +export function adminFormTexts(parts: MultiPartData[], key: string) { + return getFormFieldValues(parts, key).map((part) => part.data.toString("utf8")) +} + +export function adminFormFile(parts: MultiPartData[], key: string) { + const part = getFormField(parts, key) + if (!part?.filename) return undefined + return { name: part.filename, type: part.type ?? "", data: part.data } +} + +export function splitIds(value: string | undefined) { + return value === undefined ? undefined : value.split(",").map((id) => id.trim()).filter(Boolean) +} + +export function safeFileExtension(fileName: string) { + return fileName.split(".")[1]?.replace(/[^A-Za-z0-9]/g, "") ?? "" +} + +export function parseOptionalJson(value: string | undefined) { + if (value === undefined) return { ok: true as const, value: undefined } + try { + return { ok: true as const, value: JSON.parse(value) as unknown } + } catch { + return { ok: false as const } + } +} diff --git a/server/utils/audio-files.ts b/server/utils/audio-files.ts new file mode 100644 index 0000000..1c14fba --- /dev/null +++ b/server/utils/audio-files.ts @@ -0,0 +1,47 @@ +import { unlink } from "node:fs/promises" +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) + return new Promise((resolve, reject) => { + Ffmpeg.ffprobe(sourcePath, (error, metadata) => { + if (error) return reject(0) + if (metadata.format.duration) return resolve(metadata.format.duration) + reject(0) + }) + }) +} + +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"] + + 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) +} diff --git a/server/utils/upload-files.ts b/server/utils/upload-files.ts new file mode 100644 index 0000000..7fb56f5 --- /dev/null +++ b/server/utils/upload-files.ts @@ -0,0 +1,121 @@ +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) + } +} + +export function getFormField(parts: MultiPartData[] | undefined, name: string) { + return parts?.find((part) => part.name === name) +} + +export function getFormFieldValues(parts: MultiPartData[] | undefined, name: string) { + return parts?.filter((part) => part.name === name) ?? [] +} + +export function formFieldText(part: MultiPartData | undefined) { + return part ? part.data.toString("utf8") : undefined +} + +export function formFieldFile(part: MultiPartData | undefined) { + if (!part?.filename) return undefined + return { name: part.filename, type: part.type ?? "", data: part.data } +} diff --git a/shared/types/fluent-ffmpeg.d.ts b/shared/types/fluent-ffmpeg.d.ts new file mode 100644 index 0000000..84cfd5a --- /dev/null +++ b/shared/types/fluent-ffmpeg.d.ts @@ -0,0 +1,22 @@ +declare module "fluent-ffmpeg" { + interface FfmpegCommand { + noVideo(): this + audioBitrate(bitrate: string): this + toFormat(format: string): this + audioCodec(codec: string): this + on(event: string, listener: (...args: any[]) => void): this + save(filePath: string): this + } + + interface FfmpegMetadata { + format: { duration?: number } + } + + interface FfmpegFactory { + (filePath: string): FfmpegCommand + ffprobe(filePath: string, callback: (error: Error | null, metadata: FfmpegMetadata) => void): void + } + + const Ffmpeg: FfmpegFactory + export default Ffmpeg +}