feat(knowledge): 知识源维护与 TXT/Markdown/PDF 导入
支持文本、Markdown 与非扫描 PDF 上传解析,展示提取文本与切块结果。
This commit is contained in:
1 parent
4388e1d203
commit
53825b0cfb
23 files changed
+7545
No files matched your search
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, Depends, status, Header, Request, Query
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from typing import Optional, Tuple
|
||||
import uuid
|
||||
import os
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.config import settings
|
||||
from app.core.s3 import upload_file_to_s3, get_s3_signed_url
|
||||
from app.core.local_file_urls import verify_local_signature
|
||||
from app.core.logger import get_logger
|
||||
from app.core.security import verify_conversation_token
|
||||
from app.models import User
|
||||
from app.core.auth import get_current_user
|
||||
from app.database import get_db
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Allowed file types and size limits
|
||||
ALLOWED_IMAGE_TYPES = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'}
|
||||
ALLOWED_DOCUMENT_TYPES = {'application/pdf', 'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain', 'text/csv', 'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}
|
||||
ALLOWED_FILE_TYPES = ALLOWED_IMAGE_TYPES | ALLOWED_DOCUMENT_TYPES
|
||||
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
|
||||
|
||||
async def get_current_user_or_widget(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
authorization: Optional[str] = Header(None),
|
||||
x_conversation_token: Optional[str] = Header(None)
|
||||
) -> dict:
|
||||
"""
|
||||
Get current user from JWT token, cookies, or widget conversation token
|
||||
Returns dict with user info or widget info
|
||||
"""
|
||||
# Try to get user from cookies or Authorization header (admin/agent users)
|
||||
try:
|
||||
# Call get_current_user with the request - it handles both cookies and headers
|
||||
user = await get_current_user(request, None, db)
|
||||
if user:
|
||||
return {
|
||||
"type": "user",
|
||||
"user_id": str(user.id),
|
||||
"org_id": str(user.organization_id)
|
||||
}
|
||||
except HTTPException:
|
||||
# User auth failed, continue to try other methods
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error authenticating user: {str(e)}")
|
||||
|
||||
# Try to get widget info from conversation token
|
||||
if x_conversation_token:
|
||||
try:
|
||||
payload = verify_conversation_token(x_conversation_token)
|
||||
if payload:
|
||||
return {
|
||||
"type": "widget",
|
||||
"widget_id": payload.get("widget_id"),
|
||||
"org_id": payload.get("org_id"),
|
||||
"customer_id": payload.get("customer_id")
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying conversation token: {str(e)}")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required"
|
||||
)
|
||||
|
||||
|
||||
def _not_found() -> HTTPException:
|
||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
|
||||
def attachment_key(file_path: str) -> Tuple[str, str]:
|
||||
"""Parse a download path into (org_id, storage key), or 404.
|
||||
|
||||
Uploads only ever land at `chat_attachments/<org_id>/<uuid>.<ext>` (see
|
||||
FileUploadService.upload_file), so anything else is not a servable
|
||||
attachment. Matching that shape exactly rules out traversal: no `..`
|
||||
segment can survive a three-part match on a UUID directory.
|
||||
"""
|
||||
# Accept the stored form ("/uploads/chat_attachments/...") and the bare key.
|
||||
clean_path = file_path.lstrip('/')
|
||||
if clean_path.startswith('uploads/'):
|
||||
clean_path = clean_path[len('uploads/'):]
|
||||
|
||||
parts = clean_path.split('/')
|
||||
if len(parts) != 3 or parts[0] != 'chat_attachments' or not parts[2] or parts[2] in ('.', '..'):
|
||||
raise _not_found()
|
||||
try:
|
||||
path_org_id = str(uuid.UUID(parts[1]))
|
||||
except ValueError:
|
||||
raise _not_found()
|
||||
return path_org_id, '/'.join(('chat_attachments', path_org_id, parts[2]))
|
||||
|
||||
|
||||
def authorized_attachment_key(file_path: str, org_id: Optional[str]) -> str:
|
||||
"""Resolve a download path to a storage key the caller's org may read."""
|
||||
if not org_id:
|
||||
raise _not_found()
|
||||
path_org_id, key = attachment_key(file_path)
|
||||
# 404 rather than 403: whether another org's attachment exists is itself
|
||||
# something the caller shouldn't learn.
|
||||
if path_org_id != str(org_id):
|
||||
raise _not_found()
|
||||
return key
|
||||
|
||||
|
||||
# Visitor-uploaded files (SVG included) are served from the API origin, so one
|
||||
# opened directly must not run script there. Only scripts are blocked: a
|
||||
# sandbox CSP would also stop the browser's PDF viewer.
|
||||
ATTACHMENT_HEADERS = {
|
||||
"Content-Security-Policy": "script-src 'none'",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, max-age=300",
|
||||
}
|
||||
|
||||
|
||||
def _inline_attachment(path: str, filename: str) -> FileResponse:
|
||||
return FileResponse(path, content_disposition_type="inline", filename=filename,
|
||||
headers=ATTACHMENT_HEADERS)
|
||||
|
||||
|
||||
@router.get("/local/{file_path:path}")
|
||||
async def serve_signed_local_file(
|
||||
file_path: str,
|
||||
exp: Optional[int] = Query(None),
|
||||
sig: Optional[str] = Query(None),
|
||||
):
|
||||
"""Serve a local-storage attachment through a link from sign_local_key.
|
||||
|
||||
The signature is the authorization, as with an S3 presigned URL, because
|
||||
the widget loads attachments with plain <img>/<a> tags.
|
||||
"""
|
||||
_, key = attachment_key(file_path)
|
||||
if not verify_local_signature(key, exp, sig):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or expired link")
|
||||
path = os.path.join("uploads", key)
|
||||
if not os.path.isfile(path):
|
||||
raise _not_found()
|
||||
return _inline_attachment(path, os.path.basename(key))
|
||||
|
||||
|
||||
@router.get("/download/{file_path:path}")
|
||||
async def download_file(
|
||||
file_path: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
authorization: Optional[str] = Header(None),
|
||||
x_conversation_token: Optional[str] = Header(None)
|
||||
):
|
||||
"""
|
||||
Download/serve a file from AWS S3 or local storage
|
||||
|
||||
Usage: GET /api/v1/files/download/chat_attachments/org-id/filename.jpg
|
||||
"""
|
||||
try:
|
||||
from fastapi.responses import StreamingResponse
|
||||
from app.core.s3 import get_s3_client
|
||||
|
||||
# Authentication is required, and the result is what authorizes the
|
||||
# read below. This used to swallow the failure and serve the file
|
||||
# anyway, making every org's attachments world-readable by path.
|
||||
auth_info = await get_current_user_or_widget(
|
||||
request, db, authorization, x_conversation_token)
|
||||
storage_key = authorized_attachment_key(file_path, auth_info.get("org_id"))
|
||||
|
||||
if not settings.S3_FILE_STORAGE:
|
||||
local_file_path = os.path.join("uploads", storage_key)
|
||||
if not os.path.isfile(local_file_path):
|
||||
raise _not_found()
|
||||
return _inline_attachment(local_file_path, os.path.basename(storage_key))
|
||||
|
||||
# For AWS S3 - stream file from S3
|
||||
try:
|
||||
s3_client = get_s3_client()
|
||||
except Exception as client_err:
|
||||
logger.error(f"Failed to create S3 client: {str(client_err)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create S3 client"
|
||||
)
|
||||
|
||||
# S3 keys don't include the /uploads/ prefix
|
||||
s3_key = storage_key
|
||||
bucket = settings.S3_BUCKET
|
||||
|
||||
# Check if the file exists
|
||||
try:
|
||||
s3_client.head_object(Bucket=bucket, Key=s3_key)
|
||||
except s3_client.exceptions.NoSuchKey:
|
||||
logger.warning(f"File not found in S3: {s3_key}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
except Exception as head_err:
|
||||
logger.error(f"Error checking file in S3: {str(head_err)}", exc_info=True)
|
||||
|
||||
# Get the object from S3
|
||||
try:
|
||||
response = s3_client.get_object(Bucket=bucket, Key=s3_key)
|
||||
except s3_client.exceptions.NoSuchKey:
|
||||
logger.warning(f"File not found in S3: {s3_key}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="File not found"
|
||||
)
|
||||
except Exception as s3_err:
|
||||
logger.error(f"S3 error: {str(s3_err)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve file from S3"
|
||||
)
|
||||
|
||||
# Stream the file back to client
|
||||
def generate():
|
||||
while True:
|
||||
chunk = response['Body'].read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
# Determine content type
|
||||
content_type = response.get('ContentType', 'application/octet-stream')
|
||||
|
||||
# Extract filename from the validated key
|
||||
filename = storage_key.split('/')[-1]
|
||||
|
||||
logger.info(f"Streaming file from S3: {filename}, content-type: {content_type}")
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type=content_type,
|
||||
headers={
|
||||
**ATTACHMENT_HEADERS,
|
||||
"Content-Disposition": f"inline; filename*=utf-8''{quote(filename)}",
|
||||
}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading file {file_path}: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to download file"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user