Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ FIRECRAWL_API_KEY=fc_...
# - direct-only (Force Direct Fetch only)
SCRAPING_MODE=hybrid

# FastAPI Service (optional - file conversion, doc-to-markdown, audio/video analysis)
# FASTAPI_BASE_URL=https://your-fastapi-service.com
# FASTAPI_API_KEY=your-service-api-key

# Azure Document AI (Mistral OCR) - for PDF upload OCR and scripts/ocr-pdf-from-url.sh
AZURE_DOCUMENT_AI_API_KEY=your-api-key-from-azure-deployment
# Required: AZURE_DOCUMENT_AI_ENDPOINT=your-ocr-endpoint-url
Expand Down
29 changes: 15 additions & 14 deletions eslint.config.mjs
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,

@cubic-dev-ai cubic-dev-ai Bot Mar 20, 2026

Copy link
Copy Markdown
Contributor

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/typescript for TypeScript projects, otherwise TS-specific linting is skipped.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At eslint.config.mjs, line 5:

<comment>TypeScript-specific lint rules are no longer applied. This repo has many .ts/.tsx files, and Next’s docs recommend adding `eslint-config-next/typescript` for TypeScript projects, otherwise TS-specific linting is skipped.</comment>

<file context>
@@ -1,22 +1,23 @@
-  { ignores: ["assistant-ui-main/**"] },
-  ...compat.extends("next/core-web-vitals", "next/typescript"),
+const eslintConfig = defineConfig([
+  ...nextVitals,
+  globalIgnores([
+    '.next/**',
</file context>
Fix with Cubic

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;
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
"dev": "concurrently \"next dev\" \"npx @ai-sdk/devtools\"",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"tc": "tsc --noEmit",
"clean": "rm -rf .next out dist",
"clean:all": "rm -rf .next out dist node_modules .pnpm-store && pnpm install",
Expand Down Expand Up @@ -124,7 +124,7 @@
"mathlive": "^0.108.2",
"mermaid": "^11.12.3",
"motion": "^12.34.3",
"next": "16.1.6",
"next": "16.2.0",
"next-themes": "^0.4.6",
"parse-diff": "^0.11.1",
"pdf-lib": "^1.17.1",
Expand Down Expand Up @@ -177,7 +177,7 @@
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"eslint": "^9.39.3",
"eslint-config-next": "16.1.6",
"eslint-config-next": "16.2.0",
"geist": "^1.4.2",
"knip": "^5.85.0",
"postinstall-postinstall": "^2.1.0",
Expand Down
125 changes: 125 additions & 0 deletions src/app/api/office-conversion/convert-to-pdf/route.ts
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 }
);
}
Comment thread
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)) {

@cubic-dev-ai cubic-dev-ai Bot Mar 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: SSRF bypass via Host header: request.nextUrl.origin is attacker-controlled (derived from the Host header), so the local-file-URL allowlist can be pointed at an arbitrary domain. Use the server-configured process.env.NEXT_PUBLIC_APP_URL instead, which the project already defines and uses elsewhere.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/office-conversion/convert-to-pdf/route.ts, line 91:

<comment>SSRF bypass via Host header: `request.nextUrl.origin` is attacker-controlled (derived from the `Host` header), so the local-file-URL allowlist can be pointed at an arbitrary domain. Use the server-configured `process.env.NEXT_PUBLIC_APP_URL` instead, which the project already defines and uses elsewhere.</comment>

<file context>
@@ -34,6 +77,24 @@ export async function POST(request: NextRequest) {
+    }
+
+    // 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)) {
+      return NextResponse.json(
+        { error: "Invalid conversion source" },
</file context>
Suggested change
if (!isValidConversionRequest(file_path, file_url, request.nextUrl.origin)) {
const appOrigin = process.env.NEXT_PUBLIC_APP_URL;
if (!appOrigin || !isValidConversionRequest(file_path, file_url, appOrigin)) {
Fix with Cubic

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 }
);
}
}
15 changes: 4 additions & 11 deletions src/app/api/upload-file/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,7 @@ export async function POST(request: NextRequest) {

// Reject Office documents — convert to PDF at ilovepdf.com
const convertUrl = getOfficeDocumentConvertUrl(file);
if (convertUrl) {
return NextResponse.json(
{
error: "Word, Excel, and PowerPoint files are not supported. Convert to PDF first.",
convertUrl,
},
{ status: 400 }
);
}
const isOfficeUpload = convertUrl !== null;

// Validate file size (50MB limit)
const maxSize = 50 * 1024 * 1024; // 50MB
Expand All @@ -127,9 +119,10 @@ export async function POST(request: NextRequest) {
const originalName = file.name;
// Sanitize filename: remove spaces and special chars, keep only alphanumeric, dots, hyphens, underscores
const sanitizedName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
const filename = `${timestamp}-${random}-${sanitizedName}`;

const storageType = getStorageType();
const filename = storageType === 'supabase' && isOfficeUpload
? `uploads/${timestamp}-${random}-${sanitizedName}`
: `${timestamp}-${random}-${sanitizedName}`;
let publicUrl: string;

if (storageType === 'local') {
Expand Down
16 changes: 5 additions & 11 deletions src/app/api/upload-url/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,8 @@ async function handlePOST(request: NextRequest) {
);
}

// Reject Office documents — convert to PDF at ilovepdf.com
const convertUrl = getOfficeDocumentConvertUrlFromMeta(filename, contentType);
if (convertUrl) {
return NextResponse.json(
{
error: "Word, Excel, and PowerPoint files are not supported. Convert to PDF first.",
convertUrl,
},
{ status: 400 }
);
}
const isOfficeUpload = convertUrl !== null;

const storageType = process.env.STORAGE_TYPE || 'supabase';

Expand Down Expand Up @@ -91,7 +82,10 @@ async function handlePOST(request: NextRequest) {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 15);
const sanitizedName = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
const storagePath = `${timestamp}-${random}-${sanitizedName}`;
// FastAPI convert expects file_path under "uploads/" prefix
const storagePath = isOfficeUpload
? `uploads/${timestamp}-${random}-${sanitizedName}`
: `${timestamp}-${random}-${sanitizedName}`;

// Create a signed upload URL (valid for 5 minutes)
const { data, error } = await supabase.storage
Expand Down
37 changes: 29 additions & 8 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { toast } from "sonner";
import { InviteGuard } from "@/components/workspace/InviteGuard";
import { useReactiveNavigation } from "@/hooks/ui/use-reactive-navigation";
import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation";
import { uploadFileDirect } from "@/lib/uploads/client-upload";
import { uploadPdfToStorage } from "@/lib/uploads/pdf-upload-with-ocr";
import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog";
import { useFolderUrl } from "@/hooks/ui/use-folder-url";
Expand Down Expand Up @@ -322,24 +323,44 @@ function DashboardContent({
throw new Error("Workspace not available");
}

const pdfFiles = files.filter(
(file) => file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf")
);
const officeFiles = files.filter(
(file) => file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")
);

// Reject password-protected PDFs
const { valid: unprotectedFiles, rejected: protectedNames } = await filterPasswordProtectedPdfs(files);
const { valid: unprotectedFiles, rejected: protectedNames } = await filterPasswordProtectedPdfs(pdfFiles);
if (protectedNames.length > 0) {
emitPasswordProtectedPdf(protectedNames);
}
if (unprotectedFiles.length === 0) {
const filesToUpload = [...unprotectedFiles, ...officeFiles];
if (filesToUpload.length === 0) {
return;
}

const uploadToastId = toast.loading(
`Uploading ${unprotectedFiles.length} PDF${unprotectedFiles.length > 1 ? "s" : ""}...`
`Uploading ${filesToUpload.length} document${filesToUpload.length > 1 ? "s" : ""}...`
);

const uploadResults = await Promise.all(
unprotectedFiles.map(async (file) => {
filesToUpload.map(async (file) => {
try {
const { url, filename, fileSize } = await uploadPdfToStorage(file);
return { file, fileUrl: url, filename, fileSize };
const isPdfFile = file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");
if (isPdfFile) {
const { url, filename, fileSize } = await uploadPdfToStorage(file);
return { file, fileUrl: url, filename, displayName: file.name, fileSize };
}

const result = await uploadFileDirect(file);
return {
file,
fileUrl: result.url,
filename: result.filename,
displayName: result.displayName,
fileSize: file.size,
};
} catch (err) {
toast.error(`Failed to upload ${file.name}: ${err instanceof Error ? err.message : "Unknown error"}`);
return null;
Expand All @@ -352,9 +373,9 @@ function DashboardContent({
const validUploads = uploadResults.filter((r): r is NonNullable<typeof r> => r !== null);
if (validUploads.length === 0) return;

const pdfCardDefinitions = validUploads.map(({ file, fileUrl, filename, fileSize }) => ({
const pdfCardDefinitions = validUploads.map(({ fileUrl, filename, displayName, fileSize }) => ({
type: "pdf" as const,
name: file.name.replace(/\.pdf$/i, ""),
name: displayName.replace(/\.pdf$/i, ""),
initialData: {
fileUrl,
filename,
Expand Down
4 changes: 2 additions & 2 deletions src/components/ai-elements/code-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ import { createHighlighter } from "shiki";

// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
// eslint-disable-next-line no-bitwise -- shiki bitflag check
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
// eslint-disable-next-line no-bitwise -- shiki bitflag check
// oxlint-disable-next-line eslint(no-bitwise)
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
const isUnderline = (fontStyle: number | undefined) =>
Expand Down
24 changes: 6 additions & 18 deletions src/components/assistant-ui/AssistantDropzone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { Upload } from "lucide-react";
import { useCallback, useState, useRef } from "react";
import { toast } from "sonner";
import { emitOfficeDocumentRejected } from "@/components/modals/OfficeDocumentRejectedDialog";
import { emitPasswordProtectedPdf } from "@/components/modals/PasswordProtectedPdfDialog";
import { filterPasswordProtectedPdfs } from "@/lib/uploads/pdf-validation";
import { isWordFile, isExcelFile, isPptxFile } from "@/lib/uploads/office-document-validation";
import { OFFICE_DOCUMENT_ACCEPT } from "@/lib/uploads/office-document-validation";

interface AssistantDropzoneProps {
children: React.ReactNode;
Expand Down Expand Up @@ -163,6 +162,7 @@ export function AssistantDropzone({ children }: AssistantDropzoneProps) {
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.heic', '.heif', '.avif', '.tiff', '.tif'],
'video/*': ['.mp4', '.webm', '.avi', '.mov', '.mkv'],
'application/pdf': ['.pdf'],
...OFFICE_DOCUMENT_ACCEPT,
'text/plain': ['.txt'],
'text/markdown': ['.md'],
'text/csv': ['.csv'],
Expand All @@ -181,22 +181,10 @@ export function AssistantDropzone({ children }: AssistantDropzoneProps) {
handleDragEnd();

if (fileRejections.length > 0) {
const wordFiles = fileRejections.filter((r) => isWordFile(r.file));
const excelFiles = fileRejections.filter((r) => isExcelFile(r.file));
const pptxFiles = fileRejections.filter((r) => isPptxFile(r.file));
const hasOffice = wordFiles.length > 0 || excelFiles.length > 0 || pptxFiles.length > 0;
if (hasOffice) {
emitOfficeDocumentRejected({
word: wordFiles.length ? wordFiles.map((r) => r.file.name) : undefined,
excel: excelFiles.length ? excelFiles.map((r) => r.file.name) : undefined,
powerpoint: pptxFiles.length ? pptxFiles.map((r) => r.file.name) : undefined,
});
} else {
const rejectedFileNames = fileRejections.map((r) => r.file.name);
toast.error(
`The following file${rejectedFileNames.length > 1 ? "s are" : " is"} not supported:\n${rejectedFileNames.join("\n")}\n\nSupported: Images, Videos, PDFs, Text files`
);
}
const rejectedFileNames = fileRejections.map((r) => r.file.name);
toast.error(
`The following file${rejectedFileNames.length > 1 ? "s are" : " is"} not supported:\n${rejectedFileNames.join("\n")}\n\nSupported: Images, Videos, PDFs, Office docs, Text files`
);
}
},
});
Expand Down
Loading
Loading