Add bucket backed media reads

This commit is contained in:
lucast committed 2026-09-29 16:22:22 +02:00
1 parent 8cd0d5ca89
commit 19e84d774c
5 files changed
+174 -39

No files matched your search

+2
View File
@@ -1,4 +1,6 @@
# Copy this file to .env for local development. Use real, private values locally. # Copy this file to .env for local development. Use real, private values locally.
NUXT_MONGODB_URI=mongodb://127.0.0.1:27017/music-v2 NUXT_MONGODB_URI=mongodb://127.0.0.1:27017/music-v2
NUXT_UPLOAD_DIR=./upload NUXT_UPLOAD_DIR=./upload
NUXT_MEDIA_BASE_URL=https://s3-seaweedfs.lucasskt.dk/music
NUXT_PUBLIC_MEDIA_BASE_URL=https://s3-seaweedfs.lucasskt.dk/music
NUXT_TOKEN_EXPIRE=1h NUXT_TOKEN_EXPIRE=1h
+3 -1
View File
@@ -27,9 +27,11 @@ The private settings are declared in `nuxt.config.ts` and can be set with matchi
| --- | --- | --- | | --- | --- | --- |
| `NUXT_MONGODB_URI` | Yes | MongoDB connection URI. | | `NUXT_MONGODB_URI` | Yes | MongoDB connection URI. |
| `NUXT_UPLOAD_DIR` | Yes for upload/media operations | Writable path where uploaded media is stored. Prefer an absolute path. | | `NUXT_UPLOAD_DIR` | Yes for upload/media operations | Writable path where uploaded media is stored. Prefer an absolute path. |
| `NUXT_MEDIA_BASE_URL` | No | Optional anonymous media read base URL. Object paths are appended as-is, for example `https://s3-seaweedfs.lucasskt.dk/music`. When configured, media reads use this URL first and fall back to local uploads when the object is missing. |
| `NUXT_PUBLIC_MEDIA_BASE_URL` | Yes for browser media URLs | Public base URL used by `GET_FILE` and `GET_AUDIO_FILE` to build direct bucket URLs. |
| `NUXT_TOKEN_EXPIRE` | No | Session lifetime accepted by `parse-duration`, such as `1h` or `7d`; defaults to `1h`. | | `NUXT_TOKEN_EXPIRE` | No | Session lifetime accepted by `parse-duration`, such as `1h` or `7d`; defaults to `1h`. |
For a short migration window, the server also accepts the standalone backend names `MONGODB_URI`, `UPLOAD_DIR`, and `TOKEN_EXPIRE` as fallbacks. Prefer the `NUXT_` names for all new deployments. `VITE_PUBLIC_BACKEND` is no longer used: browser API requests are same-origin. For a short migration window, the server also accepts the standalone backend names `MONGODB_URI`, `UPLOAD_DIR`, `MEDIA_BASE_URL`, and `TOKEN_EXPIRE` as fallbacks. Prefer the `NUXT_` names for all new deployments. `VITE_PUBLIC_BACKEND` is no longer used: browser API requests are same-origin.
Nuxt loads `.env` while running its CLI for development and local production preview, but a built server does not load `.env`. Configure these variables in the production process manager or hosting environment. See the [Nuxt runtime config guide](https://nuxt.com/docs/4.x/guide/going-further/runtime-config) and [deployment guide](https://nuxt.com/docs/4.x/getting-started/deployment). Nuxt loads `.env` while running its CLI for development and local production preview, but a built server does not load `.env`. Configure these variables in the production process manager or hosting environment. See the [Nuxt runtime config guide](https://nuxt.com/docs/4.x/guide/going-further/runtime-config) and [deployment guide](https://nuxt.com/docs/4.x/getting-started/deployment).
+4
View File
@@ -13,7 +13,11 @@ export default defineNuxtConfig({
// are resolved by server utilities at runtime, never during this build. // are resolved by server utilities at runtime, never during this build.
mongodbUri: "", mongodbUri: "",
uploadDir: "", uploadDir: "",
mediaBaseUrl: "",
tokenExpire: "", tokenExpire: "",
public: {
mediaBaseUrl: "",
},
}, },
modules: ["@nuxt/fonts", "@nuxt/icon", "@nuxt/image"], modules: ["@nuxt/fonts", "@nuxt/icon", "@nuxt/image"],
components: [ components: [
+164 -38
View File
@@ -1,8 +1,22 @@
import { createReadStream } from "node:fs" import { createReadStream } from "node:fs"
import { stat } from "node:fs/promises" import { stat } from "node:fs/promises"
import { Readable } from "node:stream"
import path from "node:path" import path from "node:path"
import { assertMethod, getRouterParam, getRequestHeader, sendStream, setHeader, setResponseStatus } from "h3" import {
import { InvalidUploadPathError, resolveUploadFile, UploadFileNotFoundError } from "../../utils/upload-files" assertMethod,
createError,
getRouterParam,
getRequestHeader,
sendStream,
setHeader,
setResponseStatus,
} from "h3"
import { getBackendRuntimeConfig } from "../../utils/backend-config"
import {
InvalidUploadPathError,
resolveUploadFile,
UploadFileNotFoundError,
} from "../../utils/upload-files"
const contentTypes: Record<string, string> = { const contentTypes: Record<string, string> = {
".aac": "audio/aac", ".aac": "audio/aac",
@@ -18,7 +32,10 @@ const contentTypes: Record<string, string> = {
".webp": "image/webp", ".webp": "image/webp",
} }
function sendRangeNotSatisfiable(event: Parameters<typeof setResponseStatus>[0], size: number) { function sendRangeNotSatisfiable(
event: Parameters<typeof setResponseStatus>[0],
size: number,
) {
setResponseStatus(event, 416) setResponseStatus(event, 416)
setHeader(event, "Content-Range", `bytes */${size}`) setHeader(event, "Content-Range", `bytes */${size}`)
setHeader(event, "Accept-Ranges", "bytes") setHeader(event, "Accept-Ranges", "bytes")
@@ -26,6 +43,112 @@ function sendRangeNotSatisfiable(event: Parameters<typeof setResponseStatus>[0],
return null return null
} }
function parseRange(rangeHeader: string | undefined, size: number) {
let start = 0
let end = size - 1
if (!rangeHeader) return { start, end, partial: false }
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim())
if (!match || (!match[1] && !match[2]) || size === 0) return null
if (!match[1]) {
const suffixLength = Number(match[2])
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0)
return null
start = Math.max(size - suffixLength, 0)
} else {
start = Number(match[1])
if (!Number.isSafeInteger(start)) return null
if (match[2]) {
end = Number(match[2])
if (!Number.isSafeInteger(end)) return null
}
}
if (start >= size || end < start) return null
return { start, end: Math.min(end, size - 1), partial: true }
}
function remoteObjectUrl(baseUrl: string, relativePath: string) {
if (
!relativePath ||
relativePath.includes("\\") ||
relativePath
.split("/")
.some((part) => !part || part === "." || part === "..")
) {
throw new InvalidUploadPathError()
}
const base = baseUrl.replace(/\/+$/, "")
return `${base}/${relativePath.split("/").map(encodeURIComponent).join("/")}`
}
async function serveRemote(
event: Parameters<typeof setResponseStatus>[0],
url: string,
extension: string,
) {
const method = event.node.req.method === "HEAD" ? "HEAD" : "GET"
const head = await fetch(url, { method: "HEAD" })
if (head.status === 404) return { found: false as const, body: null }
if (!head.ok)
throw createError({
statusCode: 502,
statusMessage: `Media bucket returned ${head.status}`,
})
const size = Number(head.headers.get("content-length"))
if (!Number.isSafeInteger(size) || size < 0)
throw createError({
statusCode: 502,
statusMessage: "Media bucket returned invalid content length",
})
setHeader(
event,
"Accept-Ranges",
head.headers.get("accept-ranges") || "bytes",
)
setHeader(
event,
"Content-Type",
head.headers.get("content-type") ||
contentTypes[extension] ||
"application/octet-stream",
)
const range = parseRange(getRequestHeader(event, "range"), size)
if (!range)
return {
found: true as const,
body: sendRangeNotSatisfiable(event, size),
}
const length = size === 0 ? 0 : range.end - range.start + 1
if (range.partial) {
setResponseStatus(event, 206)
setHeader(
event,
"Content-Range",
`bytes ${range.start}-${range.end}/${size}`,
)
}
setHeader(event, "Content-Length", length)
if (method === "HEAD" || length === 0)
return { found: true as const, body: null }
const response = await fetch(url, {
headers: { Range: `bytes=${range.start}-${range.end}` },
})
if (response.status !== 206 || !response.body)
throw createError({
statusCode: 502,
statusMessage:
"Media bucket failed to return the requested byte range",
})
return {
found: true as const,
body: sendStream(
event,
Readable.fromWeb(
response.body as import("node:stream/web").ReadableStream,
),
),
}
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
assertMethod(event, ["GET", "HEAD"]) assertMethod(event, ["GET", "HEAD"])
const relativePath = getRouterParam(event, "path") const relativePath = getRouterParam(event, "path")
@@ -33,18 +156,31 @@ export default defineEventHandler(async (event) => {
setResponseStatus(event, 404) setResponseStatus(event, 404)
return null return null
} }
const config = getBackendRuntimeConfig(event)
if (config.mediaBaseUrl) {
const url = remoteObjectUrl(config.mediaBaseUrl, relativePath)
const remote = await serveRemote(
event,
url,
path.extname(relativePath).toLowerCase(),
)
if (remote.found) return remote.body
// Missing bucket objects fall through to local media during the read migration.
}
let file: { path: string } let file: { path: string }
try { try {
file = await resolveUploadFile(event, relativePath) file = await resolveUploadFile(event, relativePath)
} catch (error) { } catch (error) {
if (error instanceof InvalidUploadPathError || error instanceof UploadFileNotFoundError) { if (
error instanceof InvalidUploadPathError ||
error instanceof UploadFileNotFoundError
) {
setResponseStatus(event, 404) setResponseStatus(event, 404)
return null return null
} }
throw error throw error
} }
let metadata let metadata
try { try {
metadata = await stat(file.path) metadata = await stat(file.path)
@@ -59,41 +195,31 @@ export default defineEventHandler(async (event) => {
setResponseStatus(event, 404) setResponseStatus(event, 404)
return null return null
} }
setHeader(event, "Accept-Ranges", "bytes") setHeader(event, "Accept-Ranges", "bytes")
setHeader(event, "Content-Type", contentTypes[path.extname(file.path).toLowerCase()] ?? "application/octet-stream") setHeader(
const rangeHeader = getRequestHeader(event, "range") event,
let start = 0 "Content-Type",
let end = metadata.size - 1 contentTypes[path.extname(file.path).toLowerCase()] ??
"application/octet-stream",
if (rangeHeader) { )
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()) const range = parseRange(getRequestHeader(event, "range"), metadata.size)
if (!match || (!match[1] && !match[2]) || metadata.size === 0) { if (!range) return sendRangeNotSatisfiable(event, metadata.size)
return sendRangeNotSatisfiable(event, metadata.size) const length = metadata.size === 0 ? 0 : range.end - range.start + 1
} if (range.partial) {
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) setResponseStatus(event, 206)
setHeader(event, "Content-Range", `bytes ${start}-${end}/${metadata.size}`) setHeader(
setHeader(event, "Content-Length", end - start + 1) event,
} else { "Content-Range",
setHeader(event, "Content-Length", metadata.size) `bytes ${range.start}-${range.end}/${metadata.size}`,
)
} }
setHeader(event, "Content-Length", length)
if (event.node.req.method === "HEAD" || length === 0) return null
if (event.node.req.method === "HEAD") return null console.log(file.path)
if (metadata.size === 0) return null
return sendStream(event, createReadStream(file.path, { start, end })) return sendStream(
event,
createReadStream(file.path, { start: range.start, end: range.end }),
)
}) })
+1
View File
@@ -11,6 +11,7 @@ export function getBackendRuntimeConfig(event?: H3Event) {
return { return {
mongodbUri: config.mongodbUri || process.env.MONGODB_URI || "", mongodbUri: config.mongodbUri || process.env.MONGODB_URI || "",
uploadDir: config.uploadDir || process.env.UPLOAD_DIR || "", uploadDir: config.uploadDir || process.env.UPLOAD_DIR || "",
mediaBaseUrl: config.mediaBaseUrl || process.env.MEDIA_BASE_URL || "",
tokenExpire: config.tokenExpire || process.env.TOKEN_EXPIRE || "1h", tokenExpire: config.tokenExpire || process.env.TOKEN_EXPIRE || "1h",
} }
} }