Add logical root asset URLs and harden media path serving
This commit is contained in:
@@ -19,6 +19,7 @@ All media paths are scoped to a **root**, identified by a stable `root_id`.
|
|||||||
| `GET` | `/api/player/status` | Returns whether remote player control is currently available. |
|
| `GET` | `/api/player/status` | Returns whether remote player control is currently available. |
|
||||||
| `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable. |
|
| `GET` | `/api/mpcbe/status` | Reports whether MPC-BE's local web interface is reachable. |
|
||||||
| `GET` | `/api/media/{root_id}/{file_path:path}` | Serves files from the specified root. |
|
| `GET` | `/api/media/{root_id}/{file_path:path}` | Serves files from the specified root. |
|
||||||
|
| `GET` | `/api/roots/{root_id}/assets/{asset_path:path}` | Serves files from `<root>/.mediahive` via logical asset paths. |
|
||||||
| `WS` | `/api/roots/{root_id}/ws` | Streams live index updates and task progress for one root. |
|
| `WS` | `/api/roots/{root_id}/ws` | Streams live index updates and task progress for one root. |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
@@ -26,5 +27,6 @@ All media paths are scoped to a **root**, identified by a stable `root_id`.
|
|||||||
- `PUT /api/roots` accepts `{ "roots": { "name": "/absolute/path", ... } }`, validates paths, and atomically swaps the active set.
|
- `PUT /api/roots` accepts `{ "roots": { "name": "/absolute/path", ... } }`, validates paths, and atomically swaps the active set.
|
||||||
- `POST /api/roots/{root_id}/play` and `POST /api/roots/{root_id}/open-folder` expect JSON request bodies with `file_path` / `folder_path` relative to the root.
|
- `POST /api/roots/{root_id}/play` and `POST /api/roots/{root_id}/open-folder` expect JSON request bodies with `file_path` / `folder_path` relative to the root.
|
||||||
- `GET /api/media/{root_id}/{file_path:path}` is constrained to the specified root; path traversal outside the root is rejected.
|
- `GET /api/media/{root_id}/{file_path:path}` is constrained to the specified root; path traversal outside the root is rejected.
|
||||||
|
- `GET /api/roots/{root_id}/assets/{asset_path:path}` is constrained to `<root>/.mediahive`; metadata image URLs should use this endpoint.
|
||||||
- `GET /api/player/status` returns `{ "remote": true|false }`.
|
- `GET /api/player/status` returns `{ "remote": true|false }`.
|
||||||
- `GET /api/mpcbe/status` returns `false` on non-Windows platforms.
|
- `GET /api/mpcbe/status` returns `false` on non-Windows platforms.
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ Each active root gets an isolated `RootContext` managed by the `Supervisor`:
|
|||||||
| `POST /api/roots/{root_id}/scan` | Trigger scan for one root |
|
| `POST /api/roots/{root_id}/scan` | Trigger scan for one root |
|
||||||
| `WS /api/roots/{root_id}/ws` | Per-root WebSocket (init/upsert/remove/task) |
|
| `WS /api/roots/{root_id}/ws` | Per-root WebSocket (init/upsert/remove/task) |
|
||||||
| `GET /api/media/{root_id}/{path:path}` | Serve media file scoped to root |
|
| `GET /api/media/{root_id}/{path:path}` | Serve media file scoped to root |
|
||||||
|
| `GET /api/roots/{root_id}/assets/{asset_path:path}` | Serve `.mediahive` assets via logical paths |
|
||||||
| `POST /api/roots/{root_id}/play` | Play file within root |
|
| `POST /api/roots/{root_id}/play` | Play file within root |
|
||||||
| `POST /api/roots/{root_id}/open-folder` | Open folder within root |
|
| `POST /api/roots/{root_id}/open-folder` | Open folder within root |
|
||||||
| `GET /api/roots/{root_id}/playback/resume-positions` | Per-root resume positions |
|
| `GET /api/roots/{root_id}/playback/resume-positions` | Per-root resume positions |
|
||||||
@@ -91,4 +92,5 @@ All stored and transmitted paths use forward slashes exclusively:
|
|||||||
- `useMediaWebSocket.ts` manages one WebSocket per active root.
|
- `useMediaWebSocket.ts` manages one WebSocket per active root.
|
||||||
- `App.vue` merges per-root `movieMap`/`seriesMap` into a single `mediaIndex`.
|
- `App.vue` merges per-root `movieMap`/`seriesMap` into a single `mediaIndex`.
|
||||||
- `Header.vue` provides add/remove root UI via `PUT /api/roots`.
|
- `Header.vue` provides add/remove root UI via `PUT /api/roots`.
|
||||||
- All media URLs are root-qualified (`/api/media/{root_id}/...`).
|
- Playback URLs are root-qualified (`/api/media/{root_id}/...`).
|
||||||
|
- Metadata cache assets use logical root paths (`/api/roots/{root_id}/assets/...`) rather than exposing `.mediahive` in URLs.
|
||||||
|
|||||||
+31
-22
@@ -89,6 +89,30 @@ export function getVideoSourceAttributes(path: string | null | undefined): Video
|
|||||||
return { type: "video/mp4", codecs: "hvc1" }
|
return { type: "video/mp4", codecs: "hvc1" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encodePathSegments(path: string): string {
|
||||||
|
return path
|
||||||
|
.split("/")
|
||||||
|
.filter((segment) => segment.length > 0)
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCoverPath(path: string): string {
|
||||||
|
return path
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.replace(/^[A-Za-z]:\//, "")
|
||||||
|
.replace(/^\/+/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRootAssetPath(path: string): string | null {
|
||||||
|
const normalized = normalizeCoverPath(path)
|
||||||
|
const marker = ".mediahive/"
|
||||||
|
const idx = normalized.toLowerCase().indexOf(marker)
|
||||||
|
if (idx < 0) return null
|
||||||
|
const logical = normalized.slice(idx + marker.length)
|
||||||
|
return logical.length > 0 ? logical : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch active roots and their statuses
|
* Fetch active roots and their statuses
|
||||||
*/
|
*/
|
||||||
@@ -252,29 +276,14 @@ export function getCoverUrl(coverPath: string | null, rootId?: string | null): s
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert relative path to URL path for FastAPI server
|
|
||||||
// .mediahive/covers/Movies/... -> /api/media/{root_id}/.mediahive/covers/Movies/...
|
|
||||||
let urlPath = coverPath
|
|
||||||
|
|
||||||
// Remove drive letter (Z:) and convert backslashes to forward slashes
|
|
||||||
if (urlPath.match(/^[A-Za-z]:/)) {
|
|
||||||
urlPath = urlPath.substring(2)
|
|
||||||
}
|
|
||||||
urlPath = urlPath.replace(/\\/g, "/")
|
|
||||||
|
|
||||||
// Ensure path starts with /
|
|
||||||
if (!urlPath.startsWith("/")) {
|
|
||||||
urlPath = "/" + urlPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode URI components but preserve slashes
|
|
||||||
const encodedPath = urlPath
|
|
||||||
.split("/")
|
|
||||||
.map((segment) => encodeURIComponent(segment))
|
|
||||||
.join("/")
|
|
||||||
|
|
||||||
const rid = rootId || "unknown"
|
const rid = rootId || "unknown"
|
||||||
return `/api/media/${encodeURIComponent(rid)}${encodedPath}`
|
const assetPath = toRootAssetPath(coverPath)
|
||||||
|
if (assetPath) {
|
||||||
|
return `/api/roots/${encodeURIComponent(rid)}/assets/${encodePathSegments(assetPath)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaPath = normalizeCoverPath(coverPath)
|
||||||
|
return `/api/media/${encodeURIComponent(rid)}/${encodePathSegments(mediaPath)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+25
-8
@@ -616,18 +616,18 @@ def _mpcbe_request(path: str, timeout: float = 0.75, port: int | None = None) ->
|
|||||||
# --- Media file serving ---
|
# --- Media file serving ---
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/media/{root_id}/{file_path:path}")
|
def _resolve_root_scoped_path(base: Path, raw_path: str) -> Path:
|
||||||
async def serve_media_file(root_id: str, file_path: str, request: Request):
|
"""Resolve a user path under a fixed base directory and block traversal."""
|
||||||
"""Serve a media file asynchronously, scoped to a root."""
|
candidate = base / raw_path.lstrip("/")
|
||||||
ctx = _get_context(root_id)
|
|
||||||
full_path = ctx.root_path / file_path.lstrip("/")
|
|
||||||
|
|
||||||
# Security: ensure path doesn't escape base
|
|
||||||
try:
|
try:
|
||||||
full_path.resolve().relative_to(ctx.root_path.resolve())
|
candidate.resolve().relative_to(base.resolve())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _serve_file_response(full_path: Path, file_path: str, request: Request):
|
||||||
|
"""Serve a file with range + cache support."""
|
||||||
if not full_path.exists():
|
if not full_path.exists():
|
||||||
raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
|
raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
|
||||||
|
|
||||||
@@ -696,5 +696,22 @@ async def serve_media_file(root_id: str, file_path: str, request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/media/{root_id}/{file_path:path}")
|
||||||
|
async def serve_media_file(root_id: str, file_path: str, request: Request):
|
||||||
|
"""Serve a media file asynchronously, scoped to a root."""
|
||||||
|
ctx = _get_context(root_id)
|
||||||
|
full_path = _resolve_root_scoped_path(ctx.root_path, file_path)
|
||||||
|
return _serve_file_response(full_path, file_path, request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/roots/{root_id}/assets/{asset_path:path}")
|
||||||
|
async def serve_root_asset_file(root_id: str, asset_path: str, request: Request):
|
||||||
|
"""Serve files from a root's .mediahive cache using logical asset paths."""
|
||||||
|
ctx = _get_context(root_id)
|
||||||
|
base = ctx.root_path / ".mediahive"
|
||||||
|
full_path = _resolve_root_scoped_path(base, asset_path)
|
||||||
|
return _serve_file_response(full_path, asset_path, request)
|
||||||
|
|
||||||
|
|
||||||
# Serve the Vue frontend (needs to be last if SPA catch-all is used)
|
# Serve the Vue frontend (needs to be last if SPA catch-all is used)
|
||||||
frontend.route(app, "/")
|
frontend.route(app, "/")
|
||||||
|
|||||||
Reference in New Issue
Block a user