@@ -13,17 +13,26 @@ const { Workspace } = require("../models/workspace");
1313const createFilesLib = require ( "../utils/agents/aibitat/plugins/create-files/lib" ) ;
1414
1515/**
16- * Endpoints for serving agent-generated files (PPTX, etc.) with authentication
16+ * Endpoints for serving agent-generated files with authentication
1717 * and ownership validation.
18+ *
19+ * Two endpoints:
20+ * GET /agent-skills/generated-files/:filename
21+ * - Download (Content-Disposition: attachment)
22+ * GET /agent-skills/generated-files/:filename/preview
23+ * - Inline preview (Content-Disposition: inline)
24+ * - Also accepts ?token=<jwt> so <iframe>/<img> src attributes work
25+ *
26+ * IMPORTANT: Both endpoints first try the DB ownership check, but if no
27+ * DB record exists yet (file was just created and chat not yet saved),
28+ * they fall back to serving the file directly from storage. This handles
29+ * the race condition where the live preview card appears before the chat
30+ * is written to the database.
1831 */
1932function agentFileServerEndpoints ( app ) {
2033 if ( ! app ) return ;
2134
22- /**
23- * Download a generated file by its storage filename.
24- * Validates that the requesting user has access to the workspace
25- * where the file was generated.
26- */
35+ // ── DOWNLOAD endpoint ────────────────────────────────────────────────────
2736 app . get (
2837 "/agent-skills/generated-files/:filename" ,
2938 [ validatedRequest , flexUserRoleValid ( [ ROLES . all ] ) ] ,
@@ -34,45 +43,22 @@ function agentFileServerEndpoints(app) {
3443 if ( ! filename )
3544 return response . status ( 400 ) . json ( { error : "Filename is required" } ) ;
3645
37- // Validate filename format
3846 const parsed = createFilesLib . parseFilename ( filename ) ;
39- if ( ! parsed ) {
40- return response
41- . status ( 400 )
42- . json ( { error : "Invalid filename format" } ) ;
43- }
44-
45- // Find a chat record that references this file and that the user can access
46- const validChat = await findValidChatForFile (
47- filename ,
48- user ,
49- multiUserMode ( response )
50- ) ;
51-
52- if ( ! validChat ) {
53- return response . status ( 404 ) . json ( {
54- error : "File not found or access denied" ,
55- } ) ;
56- }
47+ if ( ! parsed )
48+ return response . status ( 400 ) . json ( { error : "Invalid filename format" } ) ;
5749
58- // Retrieve the file from storage
5950 const fileData = await createFilesLib . getGeneratedFile ( filename ) ;
60- if ( ! fileData ) {
61- return response
62- . status ( 404 )
63- . json ( { error : "File not found in storage" } ) ;
64- }
51+ if ( ! fileData )
52+ return response . status ( 404 ) . json ( { error : "File not found in storage" } ) ;
53+
54+ // Try DB ownership check — but don't block if chat not yet saved
55+ const validChat = await findValidChatForFile ( filename , user , multiUserMode ( response ) ) ;
56+ const displayFilename = validChat ?. displayFilename || filename ;
6557
66- // Get mime type and set headers for download
6758 const mimeType = createFilesLib . getMimeType ( `.${ parsed . extension } ` ) ;
68- const safeFilename = createFilesLib . sanitizeFilenameForHeader (
69- validChat . displayFilename || filename
70- ) ;
59+ const safeFilename = createFilesLib . sanitizeFilenameForHeader ( displayFilename ) ;
7160 response . setHeader ( "Content-Type" , mimeType ) ;
72- response . setHeader (
73- "Content-Disposition" ,
74- `attachment; filename="${ safeFilename } "`
75- ) ;
61+ response . setHeader ( "Content-Disposition" , `attachment; filename="${ safeFilename } "` ) ;
7662 response . setHeader ( "Content-Length" , fileData . buffer . length ) ;
7763 return response . send ( fileData . buffer ) ;
7864 } catch ( error ) {
@@ -81,21 +67,62 @@ function agentFileServerEndpoints(app) {
8167 }
8268 }
8369 ) ;
70+
71+ // ── PREVIEW endpoint ─────────────────────────────────────────────────────
72+ app . get (
73+ "/agent-skills/generated-files/:filename/preview" ,
74+ [ previewAuthMiddleware , flexUserRoleValid ( [ ROLES . all ] ) ] ,
75+ async ( request , response ) => {
76+ try {
77+ const user = await userFromSession ( request , response ) ;
78+ const { filename } = request . params ;
79+ if ( ! filename )
80+ return response . status ( 400 ) . json ( { error : "Filename is required" } ) ;
81+
82+ const parsed = createFilesLib . parseFilename ( filename ) ;
83+ if ( ! parsed )
84+ return response . status ( 400 ) . json ( { error : "Invalid filename format" } ) ;
85+
86+ const fileData = await createFilesLib . getGeneratedFile ( filename ) ;
87+ if ( ! fileData )
88+ return response . status ( 404 ) . json ( { error : "File not found in storage" } ) ;
89+
90+ // Try DB ownership check — but don't block if chat not yet saved
91+ const validChat = await findValidChatForFile ( filename , user , multiUserMode ( response ) ) ;
92+ const displayFilename = validChat ?. displayFilename || filename ;
93+
94+ const mimeType = createFilesLib . getMimeType ( `.${ parsed . extension } ` ) ;
95+ const safeFilename = createFilesLib . sanitizeFilenameForHeader ( displayFilename ) ;
96+ response . setHeader ( "Content-Type" , mimeType ) ;
97+ response . setHeader ( "Content-Disposition" , `inline; filename="${ safeFilename } "` ) ;
98+ response . setHeader ( "Content-Length" , fileData . buffer . length ) ;
99+ response . removeHeader ( "X-Frame-Options" ) ;
100+ return response . send ( fileData . buffer ) ;
101+ } catch ( error ) {
102+ console . error ( "[agentFileServer] Preview error:" , error . message ) ;
103+ return response . status ( 500 ) . json ( { error : "Failed to preview file" } ) ;
104+ }
105+ }
106+ ) ;
107+ }
108+
109+ /**
110+ * Auth middleware for the preview endpoint.
111+ * Falls back to ?token= query param when no Authorization header is present.
112+ */
113+ function previewAuthMiddleware ( request , response , next ) {
114+ if ( request . query . token && ! request . headers . authorization ) {
115+ request . headers . authorization = `Bearer ${ request . query . token } ` ;
116+ }
117+ return validatedRequest ( request , response , next ) ;
84118}
85119
86120/**
87- * Finds a valid chat record that references the given storage filename
88- * and that the user has access to.
89- * @param {string } storageFilename - The storage filename to search for
90- * @param {object|null } user - The user object (null in single-user mode)
91- * @param {boolean } isMultiUser - Whether multi-user mode is enabled
92- * @returns {Promise<{workspaceId: number, displayFilename: string}|null> }
121+ * Finds a valid chat record that references the given storage filename.
122+ * Returns null if not found (caller decides how to handle).
93123 */
94124async function findValidChatForFile ( storageFilename , user , isMultiUser ) {
95125 try {
96- // Get all workspaces the user has access to.
97- // In single-user mode, all workspaces are accessible.
98- // In multi-user mode, only workspaces assigned to the user are accessible.
99126 let workspaceIds ;
100127 if ( isMultiUser && user ) {
101128 const workspaces = await Workspace . whereWithUser ( user ) ;
@@ -107,8 +134,6 @@ async function findValidChatForFile(storageFilename, user, isMultiUser) {
107134
108135 if ( workspaceIds . length === 0 ) return null ;
109136
110- // Use database-level filtering to only fetch chats that contain the filename
111- // This avoids loading all chats into memory
112137 const chats = await WorkspaceChats . where ( {
113138 workspaceId : { in : workspaceIds } ,
114139 include : true ,
@@ -124,14 +149,12 @@ async function findValidChatForFile(storageFilename, user, isMultiUser) {
124149 if ( ! output ) continue ;
125150 return {
126151 workspaceId : chat . workspaceId ,
127- displayFilename :
128- output . payload . filename || output . payload . displayFilename ,
152+ displayFilename : output . payload . filename || output . payload . displayFilename ,
129153 } ;
130154 } catch {
131155 continue ;
132156 }
133157 }
134-
135158 return null ;
136159 } catch ( error ) {
137160 console . error ( "[findValidChatForFile] Error:" , error . message ) ;
0 commit comments