diff --git a/.env.example b/.env.example index 93e7da3..4f7c098 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ # 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_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 diff --git a/README.md b/README.md index ab08d7a..29625e5 100644 --- a/README.md +++ b/README.md @@ -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_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`. | -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). diff --git a/nuxt.config.ts b/nuxt.config.ts index 80f0200..1c913f6 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -13,7 +13,11 @@ export default defineNuxtConfig({ // are resolved by server utilities at runtime, never during this build. mongodbUri: "", uploadDir: "", + mediaBaseUrl: "", tokenExpire: "", + public: { + mediaBaseUrl: "", + }, }, modules: ["@nuxt/fonts", "@nuxt/icon", "@nuxt/image"], components: [ diff --git a/server/routes/static/[...path].ts b/server/routes/static/[...path].ts index def7bf1..7dec333 100644 --- a/server/routes/static/[...path].ts +++ b/server/routes/static/[...path].ts @@ -1,8 +1,22 @@ import { createReadStream } from "node:fs" import { stat } from "node:fs/promises" +import { Readable } from "node:stream" import path from "node:path" -import { assertMethod, getRouterParam, getRequestHeader, sendStream, setHeader, setResponseStatus } from "h3" -import { InvalidUploadPathError, resolveUploadFile, UploadFileNotFoundError } from "../../utils/upload-files" +import { + 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 = { ".aac": "audio/aac", @@ -18,7 +32,10 @@ const contentTypes: Record = { ".webp": "image/webp", } -function sendRangeNotSatisfiable(event: Parameters[0], size: number) { +function sendRangeNotSatisfiable( + event: Parameters[0], + size: number, +) { setResponseStatus(event, 416) setHeader(event, "Content-Range", `bytes */${size}`) setHeader(event, "Accept-Ranges", "bytes") @@ -26,6 +43,112 @@ function sendRangeNotSatisfiable(event: Parameters[0], 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[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) => { assertMethod(event, ["GET", "HEAD"]) const relativePath = getRouterParam(event, "path") @@ -33,18 +156,31 @@ export default defineEventHandler(async (event) => { setResponseStatus(event, 404) 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 } try { file = await resolveUploadFile(event, relativePath) } catch (error) { - if (error instanceof InvalidUploadPathError || error instanceof UploadFileNotFoundError) { + if ( + error instanceof InvalidUploadPathError || + error instanceof UploadFileNotFoundError + ) { setResponseStatus(event, 404) return null } throw error } - let metadata try { metadata = await stat(file.path) @@ -59,41 +195,31 @@ export default defineEventHandler(async (event) => { 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) + setHeader( + event, + "Content-Type", + contentTypes[path.extname(file.path).toLowerCase()] ?? + "application/octet-stream", + ) + const range = parseRange(getRequestHeader(event, "range"), metadata.size) + if (!range) return sendRangeNotSatisfiable(event, metadata.size) + const length = metadata.size === 0 ? 0 : range.end - range.start + 1 + if (range.partial) { 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) + setHeader( + event, + "Content-Range", + `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 - if (metadata.size === 0) return null - return sendStream(event, createReadStream(file.path, { start, end })) + console.log(file.path) + + return sendStream( + event, + createReadStream(file.path, { start: range.start, end: range.end }), + ) }) diff --git a/server/utils/backend-config.ts b/server/utils/backend-config.ts index 8806571..ede6703 100644 --- a/server/utils/backend-config.ts +++ b/server/utils/backend-config.ts @@ -11,6 +11,7 @@ export function getBackendRuntimeConfig(event?: H3Event) { return { mongodbUri: config.mongodbUri || process.env.MONGODB_URI || "", uploadDir: config.uploadDir || process.env.UPLOAD_DIR || "", + mediaBaseUrl: config.mediaBaseUrl || process.env.MEDIA_BASE_URL || "", tokenExpire: config.tokenExpire || process.env.TOKEN_EXPIRE || "1h", } }