-
Notifications
You must be signed in to change notification settings - Fork 11
feat: integrate office document conversion into normal upload flow #245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,22 +1,23 @@ | ||
| import { dirname } from "path"; | ||
| import { fileURLToPath } from "url"; | ||
| import { FlatCompat } from "@eslint/eslintrc"; | ||
| import { defineConfig, globalIgnores } from 'eslint/config'; | ||
| import nextVitals from 'eslint-config-next/core-web-vitals'; | ||
|
|
||
| const __filename = fileURLToPath(import.meta.url); | ||
| const __dirname = dirname(__filename); | ||
|
|
||
| const compat = new FlatCompat({ | ||
| baseDirectory: __dirname, | ||
| }); | ||
|
|
||
| const eslintConfig = [ | ||
| { ignores: ["assistant-ui-main/**"] }, | ||
| ...compat.extends("next/core-web-vitals", "next/typescript"), | ||
| const eslintConfig = defineConfig([ | ||
| ...nextVitals, | ||
| globalIgnores([ | ||
| '.next/**', | ||
| 'out/**', | ||
| 'build/**', | ||
| 'next-env.d.ts', | ||
| 'assistant-ui-main/**', | ||
| 'node_modules/**', | ||
| '.pnpm-store/**', | ||
| 'tmp/**', | ||
| ]), | ||
| { | ||
| rules: { | ||
| "max-lines": ["error", { max: 300, skipBlankLines: true, skipComments: true }], | ||
| }, | ||
| }, | ||
| ]; | ||
| ]); | ||
|
|
||
| export default eslintConfig; | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,125 @@ | ||||||||
| import { headers } from "next/headers"; | ||||||||
| import { auth } from "@/lib/auth"; | ||||||||
| import { NextRequest, NextResponse } from "next/server"; | ||||||||
| import { getFastAPIClient } from "@/lib/fastapi-client"; | ||||||||
|
|
||||||||
| /** Matches paths from upload-url / upload-file (optional `uploads/` for office docs). */ | ||||||||
| const STORAGE_PATH_PATTERN = /^(uploads\/)?\d+-[a-z0-9]+-[A-Za-z0-9._-]+$/; | ||||||||
| const MAX_FILE_PATH_LEN = 512; | ||||||||
| const MAX_FILE_URL_LEN = 4096; | ||||||||
|
|
||||||||
| function isValidStoragePath(filePath: string): boolean { | ||||||||
| return STORAGE_PATH_PATTERN.test(filePath); | ||||||||
| } | ||||||||
|
|
||||||||
| function isValidLocalFileUrl(fileUrl: string, filePath: string, requestOrigin: string): boolean { | ||||||||
| const expectedUrl = new URL(`/api/files/${filePath}`, requestOrigin); | ||||||||
| return fileUrl === expectedUrl.toString(); | ||||||||
| } | ||||||||
|
|
||||||||
| function isValidSupabaseFileUrl(fileUrl: string, filePath: string): boolean { | ||||||||
| const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; | ||||||||
|
|
||||||||
| if (!supabaseUrl) { | ||||||||
| return false; | ||||||||
| } | ||||||||
|
|
||||||||
| const expectedUrl = new URL( | ||||||||
| `/storage/v1/object/public/file-upload/${filePath}`, | ||||||||
| supabaseUrl | ||||||||
| ); | ||||||||
| return fileUrl === expectedUrl.toString(); | ||||||||
| } | ||||||||
|
|
||||||||
| function isValidConversionRequest( | ||||||||
| filePath: string, | ||||||||
| fileUrl: string, | ||||||||
| requestOrigin: string | ||||||||
| ): boolean { | ||||||||
| if (!isValidStoragePath(filePath)) { | ||||||||
| return false; | ||||||||
| } | ||||||||
|
|
||||||||
| return ( | ||||||||
| isValidLocalFileUrl(fileUrl, filePath, requestOrigin) || | ||||||||
| isValidSupabaseFileUrl(fileUrl, filePath) | ||||||||
| ); | ||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
| * Proxies document-to-PDF conversion to the FastAPI backend. | ||||||||
| * Payload: { file_path: "uploads/...", file_url: publicUrl } — no ?download=, no bucket in path. | ||||||||
| */ | ||||||||
| export async function POST(request: NextRequest) { | ||||||||
| try { | ||||||||
| const session = await auth.api.getSession({ | ||||||||
| headers: await headers(), | ||||||||
| }); | ||||||||
|
|
||||||||
| if (!session) { | ||||||||
| return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||||||||
| } | ||||||||
|
|
||||||||
| const body = await request.json(); | ||||||||
| const { file_path, file_url } = body as { file_path?: string; file_url?: string }; | ||||||||
|
|
||||||||
| if (!file_path || typeof file_path !== "string") { | ||||||||
| return NextResponse.json( | ||||||||
| { error: "file_path is required" }, | ||||||||
| { status: 400 } | ||||||||
| ); | ||||||||
| } | ||||||||
|
|
||||||||
| if (!file_url || typeof file_url !== "string") { | ||||||||
| return NextResponse.json( | ||||||||
| { error: "file_url is required" }, | ||||||||
| { status: 400 } | ||||||||
| ); | ||||||||
| } | ||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||
|
|
||||||||
| if ( | ||||||||
| file_path.length > MAX_FILE_PATH_LEN || | ||||||||
| file_url.length > MAX_FILE_URL_LEN | ||||||||
| ) { | ||||||||
| return NextResponse.json( | ||||||||
| { error: "Invalid conversion source" }, | ||||||||
| { status: 400 } | ||||||||
| ); | ||||||||
| } | ||||||||
|
|
||||||||
| // SSRF: only our public file URLs (same origin or Supabase bucket) with a strict path shape | ||||||||
| if (!isValidConversionRequest(file_path, file_url, request.nextUrl.origin)) { | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: SSRF bypass via Host header: Prompt for AI agents
Suggested change
|
||||||||
| return NextResponse.json( | ||||||||
| { error: "Invalid conversion source" }, | ||||||||
| { status: 400 } | ||||||||
| ); | ||||||||
| } | ||||||||
|
|
||||||||
| // Pass URL as-is; ?download= can cause Supabase to return invalid Content-Disposition | ||||||||
| const fastapi = getFastAPIClient(); | ||||||||
| const { data, error } = await fastapi.post<{ | ||||||||
| pdf_url?: string; | ||||||||
| pdf_path?: string; | ||||||||
| }>("api/v1/conversions/document-to-pdf", { | ||||||||
| file_path, | ||||||||
| file_url, | ||||||||
| }); | ||||||||
|
|
||||||||
| if (error) { | ||||||||
| return NextResponse.json( | ||||||||
| { error }, | ||||||||
| { status: 502 } | ||||||||
| ); | ||||||||
| } | ||||||||
|
|
||||||||
| return NextResponse.json(data ?? {}); | ||||||||
| } catch (err) { | ||||||||
| console.error("[convert-to-pdf] Error:", err); | ||||||||
| return NextResponse.json( | ||||||||
| { | ||||||||
| error: err instanceof Error ? err.message : "Conversion failed", | ||||||||
| }, | ||||||||
| { status: 500 } | ||||||||
| ); | ||||||||
| } | ||||||||
| } | ||||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: TypeScript-specific lint rules are no longer applied. This repo has many .ts/.tsx files, and Next’s docs recommend adding
eslint-config-next/typescriptfor TypeScript projects, otherwise TS-specific linting is skipped.Prompt for AI agents