| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124 |
- """PDF 处理服务 —— MuPDF 文本提取、元数据解析与文件管理."""
- from __future__ import annotations
- import email.utils
- import hashlib
- import os
- import re
- from typing import Any
- from fastapi import HTTPException
- from starlette.requests import Request
- from starlette.responses import Response, StreamingResponse
- from ...settings import get_settings
- def _iter_file(path: str):
- with open(path, "rb") as f:
- while True:
- chunk = f.read(1024 * 256)
- if not chunk:
- break
- yield chunk
- def build_library_pdf_response(*, paper_id: int, request: Request, db_path: str, logger: Any) -> Response:
- from ...core.storage import PaperDatabase
- path = PaperDatabase(db_path).get_library_pdf_abspath(paper_id)
- if not path or not os.path.isfile(path):
- raise HTTPException(status_code=404, detail="本地 PDF 不存在")
- data_root = os.path.realpath(os.path.abspath(get_settings().data_dir))
- real_path = os.path.realpath(os.path.abspath(path))
- if real_path != data_root and not real_path.startswith(data_root + os.sep):
- logger.warning(
- "PDF 路径安全检查失败: paper_id=%d, path=%s, data_root=%s",
- int(paper_id),
- path,
- data_root,
- )
- raise HTTPException(status_code=403, detail="非法文件路径")
- st = os.stat(path)
- file_size = int(st.st_size)
- mtime = int(st.st_mtime)
- range_header = request.headers.get("range") or request.headers.get("Range")
- if_none_match = (request.headers.get("if-none-match") or request.headers.get("If-None-Match") or "").strip()
- if_modified_since = (
- request.headers.get("if-modified-since") or request.headers.get("If-Modified-Since") or ""
- ).strip()
- if_range = (request.headers.get("if-range") or request.headers.get("If-Range") or "").strip()
- etag_raw = f"{path}|{mtime}|{file_size}".encode("utf-8", "ignore")
- etag = 'W/"' + hashlib.sha1(etag_raw).hexdigest() + '"'
- last_modified = email.utils.formatdate(mtime, usegmt=True)
- common_headers: dict[str, str] = {
- "Content-Disposition": f"inline; filename=paper-{int(paper_id)}.pdf",
- "Accept-Ranges": "bytes",
- "Access-Control-Allow-Origin": "*",
- "Access-Control-Allow-Methods": "GET, OPTIONS",
- "Access-Control-Allow-Headers": "*",
- "Access-Control-Expose-Headers": "Accept-Ranges, Content-Range, Content-Length, ETag, Last-Modified",
- "Cache-Control": "public, max-age=3600",
- "ETag": etag,
- "Last-Modified": last_modified,
- }
- if not range_header:
- try:
- if if_none_match and if_none_match == etag:
- return Response(status_code=304, headers=common_headers)
- if if_modified_since:
- ims_ts = email.utils.parsedate_to_datetime(if_modified_since).timestamp()
- if int(ims_ts) >= mtime:
- return Response(status_code=304, headers=common_headers)
- except Exception:
- pass
- if not range_header:
- return StreamingResponse(
- _iter_file(path),
- media_type="application/pdf",
- headers={**common_headers, "Content-Length": str(file_size)},
- )
- if if_range:
- ok = if_range in (etag, last_modified)
- if not ok:
- return StreamingResponse(
- _iter_file(path),
- media_type="application/pdf",
- headers={**common_headers, "Content-Length": str(file_size)},
- )
- m = re.match(r"bytes=(\d+)-(\d*)", range_header.strip())
- if not m:
- return Response(status_code=416, headers={**common_headers, "Content-Range": f"bytes */{file_size}"})
- start = int(m.group(1))
- end = int(m.group(2)) if m.group(2) else file_size - 1
- if start >= file_size:
- return Response(status_code=416, headers={**common_headers, "Content-Range": f"bytes */{file_size}"})
- end = min(end, file_size - 1)
- if end < start:
- return Response(status_code=416, headers={**common_headers, "Content-Range": f"bytes */{file_size}"})
- length = end - start + 1
- def iter_range():
- with open(path, "rb") as f:
- f.seek(start)
- remaining = length
- while remaining > 0:
- chunk = f.read(min(1024 * 256, remaining))
- if not chunk:
- break
- remaining -= len(chunk)
- yield chunk
- headers = {
- **common_headers,
- "Content-Range": f"bytes {start}-{end}/{file_size}",
- "Content-Length": str(length),
- }
- return StreamingResponse(iter_range(), status_code=206, media_type="application/pdf", headers=headers)
|