Migrated admin and media APIs to Nitro
This commit is contained in:
1 parent
ee5809a489
commit
bca41b5e6f
18 files changed
+786
-1
No files matched your search
+1
-1
@@ -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_FILE = (file: string) => `${BACKEND_SERVE}${file}`
|
||||||
export const GET_AUDIO_FILE = (file: string) => {
|
export const GET_AUDIO_FILE = (file: string) => {
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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 {}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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 {}
|
||||||
|
})
|
||||||
@@ -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<unknown>(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<string, unknown>
|
||||||
|
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
|
||||||
|
})
|
||||||
@@ -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<unknown>(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" }
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 {}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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<string, string> = {
|
||||||
|
".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<typeof setResponseStatus>[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 }))
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<number> {
|
||||||
|
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<void>((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)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
Vendored
+22
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user