From 6cccdc1f34c38887609f31b50c2fd21b82821c53 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 26 Sep 2026 14:06:47 -0400 Subject: [PATCH] refactor: #85 T6c extrait media-pdf-export vers backend/routers (comportement inchange) --- CHANGELOG.md | 12 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/main.py | 629 +-------------------------- backend/routers/files_media.py | 569 ++++++++++++++++++++++++ backend/routers/helpers.py | 78 ++++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 6 +- package.json | 2 +- tests/frontend/image-viewer.test.mjs | 6 +- tests/frontend/media-viewer.test.mjs | 11 +- 14 files changed, 699 insertions(+), 634 deletions(-) create mode 100644 backend/routers/files_media.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8636d..c24b4e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.27.8**. +> [Unreleased](#unreleased). La dernière version livrée est **2.27.9**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.27.9] — 2026-09-26 + +--- + ## [2.27.8] — 2026-09-26 --- @@ -42,6 +46,12 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Modifié +- **#85 (T6c) — extraction media/pdf/export/guide hors du monolithe `backend/main.py`.** + file/pdf, exports (html/md-bundle/epub), guide/download, pdf/stream|info, + image, media+thumb, attachments (rescan/stats), vault settings (get/post/all) + et vault files sont servis par `backend/routers/files_media.py` ; le helper + Range partagé vit dans `backend/routers/helpers.py` (tags OpenAPI inchangés, + tests statiques frontend `media-viewer`/`image-viewer` réalignés). - **#85 (T6b) — extraction mutations fichiers/dossiers hors du monolithe `backend/main.py`.** `PUT .../save|xlsx/save`, `DELETE/POST/PATCH /api/file`, `POST/PATCH/DELETE /api/directory`, `POST /api/move`, `POST .../batch-upload` sont servis par diff --git a/README.fr.md b/README.fr.md index 06514c5..3da52bb 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.27.8-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.27.9-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.8). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.9). --- -*Projet : ObsiGate | Version : 2.27.8 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.27.9 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 7cd4569..69cf6db 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.27.8-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.27.9-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.8). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.9). --- -*Project: ObsiGate | Version: 2.27.8 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.27.9 | Last updated: September 2026* diff --git a/VERSION b/VERSION index a85fcf2..f89d8bb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.27.8 +2.27.9 diff --git a/backend/main.py b/backend/main.py index b307975..38ea194 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,16 +14,14 @@ from pathlib import Path import frontmatter import mistune from fastapi import Body, Depends, FastAPI, HTTPException, Query, Request, WebSocket -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response, StreamingResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from starlette.middleware.base import BaseHTTPMiddleware -from backend.attachment_indexer import get_attachment_stats, rescan_vault_attachments from backend.collab import authenticate_websocket, collab_manager from backend.history import ( get_bookmarks, - record_open, toggle_bookmark, ) from backend.image_processor import preprocess_images @@ -35,13 +33,11 @@ from backend.indexer import ( get_vault_data, handle_file_move, index, - parse_markdown_file, remove_single_file, remove_vault_from_index, update_single_file, ) -from backend.media_thumbs import generate_thumbnail, is_decodable -from backend.media_types import IMAGE_EXTENSIONS, is_audio, is_image, is_video, media_mime_type +from backend.media_types import IMAGE_EXTENSIONS from backend.openapi_docs import ( API_DESCRIPTION, TAGS_METADATA, @@ -53,23 +49,17 @@ from backend.schemas import ( AIKeysResponse, AIModelsResponse, AITestResponse, - AllVaultSettingsResponse, AppConfigResponse, - AttachmentRescanResponse, - AttachmentStatsResponse, BookmarksResponse, BookmarkToggleResponse, ConflictResolveResponse, ConflictsResponse, DashboardResponse, DiagnosticsResponse, - PdfInfoResponse, RecentResponse, SavedSearch, StatusResponse, VaultActionResponse, - VaultFilesResponse, - VaultSettingsResponse, VaultsStatusResponse, VaultStatsResponse, ) @@ -83,11 +73,6 @@ from backend.services.recent import humanize_mtime, list_recent from backend.services.sanitizer import sanitize_html from backend.services.vaults import ( list_accessible_vaults, - list_all_files, -) -from backend.vault_settings import ( - get_vault_setting, - update_vault_setting, ) logging.basicConfig( @@ -486,6 +471,8 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000) app.add_middleware(SecurityHeadersMiddleware) # Auth router +# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c). +from backend.ai_routes import router as ai_router from backend.audit import log_file_delete from backend.auth.middleware import ( check_vault_access, @@ -493,22 +480,9 @@ from backend.auth.middleware import ( require_auth, ) from backend.auth.router import router as auth_router -from backend.secret_redactor import redact_file_content - -# Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere) -try: - from backend.pdf_export import build_pdf_html, generate_pdf -except Exception: # pragma: no cover - WeasyPrint/GTK missing - generate_pdf = None # type: ignore[assignment] - build_pdf_html = None # type: ignore[assignment] - - logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)") - -# Multi-format export (HTML / MD bundle / ePub) — pure Python, no heavy deps. -from backend.ai_routes import router as ai_router from backend.bookslm_routes import router as bookslm_router -from backend.export import ExportError, export_epub, export_html, export_md_bundle from backend.routers.backups import router as backups_router +from backend.routers.files_media import router as files_media_router from backend.routers.files_read import router as files_read_router from backend.routers.files_write import router as files_write_router from backend.routers.health import router as health_router @@ -516,6 +490,7 @@ from backend.routers.search import router as search_router from backend.routers.sharing import router as sharing_router from backend.routers.webhooks import router as webhooks_router from backend.saved_searches import delete_saved, get_saved, save_search +from backend.secret_redactor import redact_file_content from backend.skills_routes import router as skills_router app.include_router(auth_router) @@ -526,6 +501,7 @@ app.include_router(health_router) # ROADMAP #85 T1 — System / health app.include_router(search_router) # ROADMAP #85 T5 — Search app.include_router(backups_router) # ROADMAP #85 T4 — Backups app.include_router(files_read_router) # ROADMAP #85 T6a — Files read +app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export app.include_router(files_write_router) # ROADMAP #85 T6b — Files write app.include_router(webhooks_router) # ROADMAP #85 T2 — Webhooks app.include_router(sharing_router) # ROADMAP #85 T3 — Sharing @@ -578,12 +554,9 @@ async def api_docs_landing(): # --------------------------------------------------------------------------- -# Path safety helper : voir backend.routers.helpers (#85 T6a) +# Path safety helper : voir backend.routers.helpers (#85 T6a, T6c) # --------------------------------------------------------------------------- -from backend.routers.helpers import content_disposition as _content_disposition -from backend.routers.helpers import media_max_inline_bytes as _media_max_inline_bytes - def _resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path: """Resolve a relative path safely within the vault root. @@ -964,168 +937,10 @@ async def api_delete_saved_search(search_id: str, current_user=Depends(require_a # File browse & read endpoints : voir backend.routers.files_read (#85 T6a) # --------------------------------------------------------------------------- -@app.get( - "/api/file/{vault_name}/pdf", - response_class=Response, - responses={200: {"content": {"application/pdf": {}}, "description": "PDF document"}}, -) -async def api_file_pdf(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)): - """Download a markdown file as PDF.""" - if generate_pdf is None: - raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)") - if not check_vault_access(vault_name, current_user): - raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'") - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(404, f"Vault '{vault_name}' not found") - vault_root = Path(vault_data["path"]) - file_path = _resolve_safe_path(vault_root, path) - if not file_path.exists(): - raise HTTPException(404, f"File not found: {path}") - try: - raw = file_path.read_text(encoding="utf-8", errors="replace") - except Exception: - raise HTTPException(500, "Cannot read file") - record_open(current_user.get("username"), vault_name, path) - raw = redact_file_content(raw, str(file_path)) - post = parse_markdown_file(raw) - html = _render_markdown(post.content, vault_name, file_path) - title = post.metadata.get("title", file_path.stem) - pdf_html = build_pdf_html(html, str(title)) - pdf_bytes = generate_pdf(pdf_html, str(title)) - safe_name = "".join(c for c in str(title) if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "document" - return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'}) - - # --------------------------------------------------------------------------- -# Multi-format export endpoints (HTML / Markdown bundle / ePub) +# File PDF / export / guide / media endpoints : voir backend.routers.files_media (#85 T6c) # --------------------------------------------------------------------------- -def _resolve_export_target(vault_name: str, path: str, current_user: dict) -> tuple[Path, Path]: - """Resolve a vault + relative path into (vault_root, absolute file path). - - Enforces auth (vault access) and path traversal protection. - """ - if not check_vault_access(vault_name, current_user): - raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - vault_root = Path(vault_data["path"]) - target = _resolve_safe_path(vault_root, path) - return vault_root, target - - -@app.get( - "/api/export/html", - response_class=Response, - responses={200: {"content": {"text/html": {}}, "description": "Standalone HTML file"}}, -) -async def api_export_html( - vault: str = Query(..., description="Vault name"), - path: str = Query(..., description="Relative path to file"), - current_user=Depends(require_auth), -): - """Export a markdown note as a standalone HTML file.""" - try: - vault_root, target = _resolve_export_target(vault, path, current_user) - html_bytes = export_html(vault_root, target) - except ExportError as e: - raise HTTPException(status_code=400, detail=str(e)) - record_open(current_user.get("username"), vault, path) - safe_name = _safe_export_name(target.stem) - return Response( - content=html_bytes, - media_type="text/html; charset=utf-8", - headers={"Content-Disposition": f'attachment; filename="{safe_name}.html"'}, - ) - - -@app.get( - "/api/export/md-bundle", - response_class=Response, - responses={200: {"content": {"application/zip": {}}, "description": "Markdown ZIP bundle"}}, -) -async def api_export_md_bundle( - vault: str = Query(..., description="Vault name"), - path: str = Query(..., description="Relative path to directory or file"), - current_user=Depends(require_auth), -): - """Export a directory (or single file) of markdown as a ZIP bundle.""" - try: - vault_root, target = _resolve_export_target(vault, path, current_user) - zip_bytes = export_md_bundle(vault_root, target) - except ExportError as e: - raise HTTPException(status_code=400, detail=str(e)) - safe_name = _safe_export_name(target.name) - return Response( - content=zip_bytes, - media_type="application/zip", - headers={"Content-Disposition": f'attachment; filename="{safe_name}.zip"'}, - ) - - -@app.get( - "/api/export/epub", - response_class=Response, - responses={200: {"content": {"application/epub+zip": {}}, "description": "ePub document"}}, -) -async def api_export_epub( - vault: str = Query(..., description="Vault name"), - path: str = Query(..., description="Relative path to file"), - current_user=Depends(require_auth), -): - """Export a markdown note as an ePub document.""" - try: - vault_root, target = _resolve_export_target(vault, path, current_user) - epub_bytes = export_epub(vault_root, target) - except ExportError as e: - raise HTTPException(status_code=400, detail=str(e)) - record_open(current_user.get("username"), vault, path) - safe_name = _safe_export_name(target.stem) - return Response( - content=epub_bytes, - media_type="application/epub+zip", - headers={"Content-Disposition": f'attachment; filename="{safe_name}.epub"'}, - ) - - -def _safe_export_name(name: str) -> str: - """ASCII-safe, filename-safe download name (falls back to 'document').""" - cleaned = "".join(c for c in name if c.isascii() and (c.isalnum() or c in " _-.")).strip() - return cleaned or "document" - - -@app.get( - "/api/guide/download", - response_class=Response, - responses={200: {"content": {"application/pdf": {}, "text/markdown": {}}}}, -) -async def api_guide_download( - format: str = Query("md", description="Download format: 'md' or 'pdf'"), - lang: str = Query("fr", description="Guide language: 'fr' or 'en'"), - current_user=Depends(require_auth), -): - """Download the in-app user guide as Markdown or PDF (#105). - - The document is generated from the live help modal in index.html resolved - through the locale files, so it always mirrors exactly what the user sees. - """ - from backend.guide_export import get_guide_document - - if format not in ("md", "pdf"): - raise HTTPException(status_code=400, detail="format doit être 'md' ou 'pdf'") - try: - payload, media, fname = get_guide_document(format, lang) - except Exception as e: # weasyprint/reportlab unavailable - logger.exception("guide export failed") - raise HTTPException(status_code=500, detail=f"Export impossible: {e}") from e - return Response( - content=payload, - media_type=media, - headers={"Content-Disposition": f'attachment; filename="{fname}"'}, - ) - # --------------------------------------------------------------------------- # File & directory mutations : voir backend.routers.files_write (#85 T6b) @@ -1160,138 +975,9 @@ def _get_backup_dir(vault_name: str, relative_path: str) -> Path: # File backlinks + view endpoints : voir backend.routers.files_read (#85 T6a) -def _stream_file_with_range(file_path: Path, request: Request, media_type: str): - """Return a file response honouring the HTTP ``Range`` header (roadmap #109). +# Range helper : voir backend.routers.helpers.stream_file_with_range (#85 T6c) - Shared by ``pdf/stream`` and ``/api/media``: a plain :class:`FileResponse` - with ``Accept-Ranges: bytes`` when no range is requested, or a - :class:`StreamingResponse` (206 Partial Content, 64 KiB chunks) for a valid - single range. An unsatisfiable range yields ``416`` with a - ``Content-Range: bytes */size`` header. - - Reads are offloaded to threads so the event loop is never blocked - (ASYNC230), matching the previous inline implementation. - """ - file_size = file_path.stat().st_size - range_header = request.headers.get("range") - disposition = _content_disposition("inline", file_path.name) - - if range_header: - # Parse "bytes=start-end" (single range only; multi-range is not used by viewers) - m = re.match(r"bytes=(\d*)-(\d*)", range_header) - if not m: - raise HTTPException(status_code=416, - headers={"Content-Range": f"bytes */{file_size}"}) - start_s, end_s = m.group(1), m.group(2) - if start_s == "" and end_s == "": - raise HTTPException(status_code=416, - headers={"Content-Range": f"bytes */{file_size}"}) - if start_s == "": - # suffix range: last N bytes - length = min(int(end_s), file_size) - start = file_size - length - end = file_size - 1 - else: - start = int(start_s) - end = int(end_s) if end_s else file_size - 1 - end = min(end, file_size - 1) - if start > end or start >= file_size: - raise HTTPException(status_code=416, - headers={"Content-Range": f"bytes */{file_size}"}) - - chunk_size = end - start + 1 - - async def _partial(): - f = await asyncio.to_thread(open, str(file_path), "rb") - try: - await asyncio.to_thread(f.seek, start) - remaining = chunk_size - while remaining > 0: - data = await asyncio.to_thread(f.read, min(64 * 1024, remaining)) - if not data: - break - remaining -= len(data) - yield data - finally: - await asyncio.to_thread(f.close) - - return StreamingResponse( - _partial(), - status_code=206, - media_type=media_type, - headers={ - "Content-Range": f"bytes {start}-{end}/{file_size}", - "Accept-Ranges": "bytes", - "Content-Length": str(chunk_size), - "Content-Disposition": disposition, - }, - ) - - return FileResponse(str(file_path), media_type=media_type, headers={ - "Accept-Ranges": "bytes", - "Content-Disposition": disposition}) - - -@app.get("/api/file/{vault_name}/pdf/stream", response_class=FileResponse) -async def api_pdf_stream( - request: Request, - vault_name: str, - path: str = Query(...), - current_user=Depends(require_auth), -): - """Stream a PDF file with Content-Type: application/pdf for inline browser viewing. - - Supports HTTP Range requests (206 Partial Content) so browsers can - progressively render large PDFs in the native viewer. - """ - if not check_vault_access(vault_name, current_user): - raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - vault_root = Path(vault_data["path"]) - file_path = _resolve_safe_path(vault_root, path) - if not file_path.exists() or not file_path.is_file(): - raise HTTPException(status_code=404, detail=f"File not found: {path}") - if file_path.suffix.lower() != ".pdf": - raise HTTPException(status_code=400, detail="Not a PDF file") - - return _stream_file_with_range(file_path, request, "application/pdf") - - -@app.get("/api/file/{vault_name}/pdf/info", response_model=PdfInfoResponse) -async def api_pdf_info( - vault_name: str, - path: str = Query(..., description="Relative path to PDF file"), - current_user=Depends(require_auth), -): - """Return PDF metadata (pages, title, author, size) without the document content. - - Lets the UI display file info before loading a heavy PDF into the viewer. - """ - if not check_vault_access(vault_name, current_user): - raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - vault_root = Path(vault_data["path"]) - file_path = _resolve_safe_path(vault_root, path) - if not file_path.exists() or not file_path.is_file(): - raise HTTPException(status_code=404, detail=f"File not found: {path}") - if file_path.suffix.lower() != ".pdf": - raise HTTPException(status_code=400, detail="Not a PDF file") - - from backend.pdf_reader import extract_pdf_metadata - meta = extract_pdf_metadata(file_path) - stat = file_path.stat() - return { - "vault": vault_name, - "path": path, - "pages": meta.get("pages", 0), - "title": meta.get("title") or file_path.name, - "author": meta.get("author", ""), - "size_bytes": stat.st_size, - } +# PDF stream/info endpoints : voir backend.routers.files_media (#85 T6c) # --------------------------------------------------------------------------- @@ -1421,298 +1107,13 @@ async def api_vaults_status(current_user=Depends(require_auth)): } -@app.get( - "/api/image/{vault_name}", - response_class=Response, - responses={200: {"content": {"application/octet-stream": {}}, "description": "Image bytes"}}, -) -async def api_image(vault_name: str, path: str = Query(..., description="Relative path to image"), current_user=Depends(require_auth)): - """Serve an image file with proper MIME type. - - Args: - vault_name: Name of the vault. - path: Relative file path within the vault. - - Returns: - Image file with appropriate content-type header. - """ - if not check_vault_access(vault_name, current_user): - raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") - vault_data = get_vault_data(vault_name) - if not vault_data: - raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found") - - vault_root = Path(vault_data["path"]) - file_path = _resolve_safe_path(vault_root, path) - - if not file_path.exists() or not file_path.is_file(): - raise HTTPException(status_code=404, detail=f"Image not found: {path}") - - mime_type = media_mime_type(str(file_path)) - - # #108-B3 — a standalone SVG opened in a tab executes its embedded JS - # (same-origin XSS). ``sandbox`` forces a unique opaque origin with no - # script execution; inside an tag the header is irrelevant. - headers = {"X-Content-Type-Options": "nosniff"} - if file_path.suffix.lower() == ".svg": - headers["Content-Security-Policy"] = "sandbox" - - try: - # Read and return the image file - content = file_path.read_bytes() - return Response(content=content, media_type=mime_type, headers=headers) - except PermissionError: - raise HTTPException(status_code=403, detail="Permission denied") - except Exception as e: - logger.error(f"Error serving image {vault_name}/{path}: {e}") - raise HTTPException(status_code=500, detail=f"Error serving image: {e!s}") - - -@app.get("/api/media/{vault_name}", response_class=FileResponse) -async def api_media_stream( - request: Request, - vault_name: str, - path: str = Query(..., description="Relative path to audio/video file"), - current_user=Depends(require_auth), -): - """Stream an audio/video file with HTTP Range support (roadmap #109-A2). - - Serves the bytes with the correct MIME type and honours ``Range`` requests - (``206 Partial Content`` + ``Content-Range``/``Accept-Ranges``), which is - what enables scrubbing in ``