feat: add secure HTTP streaming upload gateway and address code review findings
Build and Push Outline MCP Docker Image / build (push) Successful in 16s
Build and Push Outline MCP Docker Image / build (push) Successful in 16s
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import contextlib
|
||||
from typing import Any
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Route, Mount
|
||||
from dotenv import load_dotenv
|
||||
@@ -491,6 +494,18 @@ Delete attachment. Params: id (required)
|
||||
### attachments.redirect
|
||||
Get attachment URL. Params: id (required)
|
||||
|
||||
### Custom HTTP Gateway Upload
|
||||
Rather than manually performing the multi-step attachments.create + PUT process, you can stream files directly through the MCP server's built-in HTTP streaming gateway:
|
||||
- **Endpoint**: `POST /upload`
|
||||
- **Query Parameters**:
|
||||
- `documentId` (required): The UUID of the destination Outline document.
|
||||
- `name` (required): The filename of the attachment.
|
||||
- **Headers**:
|
||||
- `Content-Length` (required): Total size of the file in bytes.
|
||||
- `Content-Type` (optional): The MIME type of the file (auto-detected if omitted).
|
||||
- **Body**: Raw binary stream of the file content.
|
||||
- **Response**: JSON containing the created attachment's details, including its public access `url` on success.
|
||||
|
||||
---
|
||||
|
||||
## Auth
|
||||
@@ -532,6 +547,167 @@ def get_api_reference() -> str:
|
||||
return API_REFERENCE
|
||||
|
||||
|
||||
# --- Upload Endpoint ---
|
||||
async def upload_endpoint(request: Request) -> JSONResponse:
|
||||
"""HTTP streaming endpoint to upload file attachments to Outline.
|
||||
|
||||
This endpoint acts as a non-buffering gateway/proxy. It calls Outline's
|
||||
attachments.create to register the metadata and obtain an upload URL,
|
||||
then streams the incoming request body directly to S3.
|
||||
"""
|
||||
# 1. Authorization validation
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if not auth_header or auth_header != f"Bearer {OUTLINE_API_TOKEN}":
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Unauthorized"},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
document_id = request.query_params.get("documentId")
|
||||
if not document_id:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Missing 'documentId' query parameter"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
filename = request.query_params.get("name")
|
||||
if not filename:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Missing 'name' query parameter"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Size is required by attachments.create
|
||||
content_length_str = request.headers.get("content-length")
|
||||
if not content_length_str:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Missing 'Content-Length' header"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
size = int(content_length_str)
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Invalid 'Content-Length' header"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate file size limits (abuses and negative boundary checks)
|
||||
MAX_UPLOAD_SIZE = int(os.getenv("MAX_UPLOAD_SIZE", "104857600")) # Default 100MB
|
||||
if size < 0:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Invalid file size (must be non-negative)"},
|
||||
status_code=400,
|
||||
)
|
||||
if size > MAX_UPLOAD_SIZE:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": f"File too large (exceeds limit of {MAX_UPLOAD_SIZE} bytes)"},
|
||||
status_code=413,
|
||||
)
|
||||
|
||||
# Detect content type
|
||||
content_type = (
|
||||
request.headers.get("content-type")
|
||||
or mimetypes.guess_type(filename)[0]
|
||||
or "application/octet-stream"
|
||||
)
|
||||
|
||||
# 2. Initialize upload session in Outline
|
||||
create_params = {
|
||||
"name": filename,
|
||||
"documentId": document_id,
|
||||
"size": size,
|
||||
"contentType": content_type,
|
||||
}
|
||||
|
||||
logger.info(f"Initiating attachment creation in Outline: {create_params}")
|
||||
create_response = await outline_client.call("attachments.create", create_params)
|
||||
|
||||
if not create_response.get("ok"):
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"Failed to initialize attachment in Outline: {create_response.get('error')}",
|
||||
},
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
attachment_data = create_response.get("data", {})
|
||||
upload_url = attachment_data.get("uploadUrl")
|
||||
if not upload_url:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Outline API did not return an uploadUrl"},
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
# 3. Stream the request body directly to S3 upload_url
|
||||
try:
|
||||
# Pre-signed S3 PUT URLs usually enforce exact headers
|
||||
headers = {
|
||||
"Content-Type": content_type,
|
||||
"Content-Length": str(size),
|
||||
}
|
||||
|
||||
logger.info(f"Streaming file content to storage (size={size} bytes)")
|
||||
async with httpx.AsyncClient() as client:
|
||||
put_response = await client.put(
|
||||
upload_url,
|
||||
content=request.stream(),
|
||||
headers=headers,
|
||||
timeout=600.0,
|
||||
)
|
||||
put_response.raise_for_status()
|
||||
|
||||
logger.info("Upload to storage completed successfully")
|
||||
|
||||
# 4. Return success and attachment metadata
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"data": {
|
||||
"id": attachment_data.get("id"),
|
||||
"name": attachment_data.get("name"),
|
||||
"size": attachment_data.get("size"),
|
||||
"contentType": attachment_data.get("contentType"),
|
||||
"url": attachment_data.get("url"),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Clean up orphaned attachment record in Outline to maintain consistency
|
||||
attachment_id = attachment_data.get("id")
|
||||
if attachment_id:
|
||||
logger.warning(
|
||||
f"Cleaning up orphaned attachment {attachment_id} in Outline due to S3 upload failure"
|
||||
)
|
||||
try:
|
||||
await outline_client.call("attachments.delete", {"id": attachment_id})
|
||||
except Exception as cleanup_err:
|
||||
logger.error(
|
||||
f"Failed to delete orphaned attachment {attachment_id}: {str(cleanup_err)}"
|
||||
)
|
||||
|
||||
if isinstance(e, httpx.HTTPStatusError):
|
||||
logger.error(
|
||||
f"Storage upload HTTP error: {e.response.status_code} - {e.response.text}"
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": f"Failed to upload attachment to storage: {str(e)}",
|
||||
},
|
||||
status_code=502,
|
||||
)
|
||||
else:
|
||||
logger.error(f"Exception during attachment upload: {str(e)}")
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": f"Upload stream failed: {str(e)}"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
# --- Health Check Endpoint ---
|
||||
async def health(request):
|
||||
"""Health check endpoint for Docker/load balancer.
|
||||
@@ -554,16 +730,30 @@ async def health(request):
|
||||
|
||||
|
||||
# --- ASGI Application ---
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(app: Starlette):
|
||||
"""Custom ASGI lifespan manager that integrates with FastMCP's lifespan
|
||||
and ensures the Outline HTTP client's connections are gracefully closed
|
||||
on server shutdown.
|
||||
"""
|
||||
mcp_app = mcp.http_app()
|
||||
async with mcp_app.lifespan(app):
|
||||
yield
|
||||
logger.info("Closing Outline client connections on server shutdown")
|
||||
await outline_client.close()
|
||||
|
||||
|
||||
def create_app():
|
||||
"""Create the ASGI application with health check and MCP routes."""
|
||||
mcp_app = mcp.http_app()
|
||||
|
||||
routes = [
|
||||
Route("/health", health, methods=["GET"]),
|
||||
Route("/upload", upload_endpoint, methods=["POST"]),
|
||||
Mount("/", app=mcp_app),
|
||||
]
|
||||
|
||||
return Starlette(routes=routes, lifespan=mcp_app.lifespan)
|
||||
return Starlette(routes=routes, lifespan=lifespan)
|
||||
|
||||
|
||||
# Create the app instance for uvicorn
|
||||
|
||||
Reference in New Issue
Block a user