Migrated admin and media APIs to Nitro

This commit is contained in:
lucast committed 2026-09-29 01:22:29 +02:00
1 parent ee5809a489
commit bca41b5e6f
18 files changed
+786 -1

No files matched your search

+30
View File
@@ -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 {}
})
+45
View File
@@ -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
})
+37
View File
@@ -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
})
+42
View File
@@ -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 {}
})
+35
View File
@@ -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
})
+25
View File
@@ -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
})
+42
View File
@@ -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 {}
})
+34
View File
@@ -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
})
+31
View File
@@ -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" }
}
})
+19
View File
@@ -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 {}
})
+56
View File
@@ -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
})
+64
View File
@@ -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
}
})
+98
View File
@@ -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 }))
})
+37
View File
@@ -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 }
}
}
+47
View File
@@ -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)
}
+121
View File
@@ -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 }
}