Skip to content

Commit 5e8c661

Browse files
ai image video fs output (#3079)
* Add puter_output_path * Fix error reporting in puterjs. Add precheck for puterOutputPath * add tests * add tests * frontend puterjs tests
1 parent f80016e commit 5e8c661

14 files changed

Lines changed: 1341 additions & 13 deletions

File tree

src/backend/drivers/ai-image/ImageGenerationDriver.test.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,210 @@ describe('ImageGenerationDriver.generate audit log', () => {
410410
});
411411
});
412412

413+
// ── puter_output_path ─────────────────────────────────────────────
414+
415+
describe('ImageGenerationDriver.generate puter_output_path', () => {
416+
const TEST_ACTOR: import('../../core/actor.js').Actor = {
417+
user: { uuid: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', id: 42, username: 'testuser' },
418+
};
419+
420+
const withTestUser = <T>(fn: () => T | Promise<T>): Promise<T> =>
421+
Promise.resolve(runWithContext({ actor: TEST_ACTOR }, fn));
422+
423+
it('throws 400 when puter_output_path resolves to root', async () => {
424+
await expect(
425+
withTestUser(() =>
426+
driver.generate({
427+
model: 'dall-e-2',
428+
prompt: 'hi',
429+
puter_output_path: '/',
430+
} as never),
431+
),
432+
).rejects.toMatchObject({ statusCode: 400 });
433+
434+
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
435+
});
436+
437+
it('throws 400 when puter_output_path parent is root (e.g. /image.png)', async () => {
438+
await expect(
439+
withTestUser(() =>
440+
driver.generate({
441+
model: 'dall-e-2',
442+
prompt: 'hi',
443+
puter_output_path: '/image.png',
444+
} as never),
445+
),
446+
).rejects.toMatchObject({ statusCode: 400 });
447+
448+
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
449+
});
450+
451+
it('throws 403 when ACL denies write access to the destination', async () => {
452+
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
453+
aclCheckSpy.mockResolvedValueOnce(false);
454+
455+
await expect(
456+
withTestUser(() =>
457+
driver.generate({
458+
model: 'dall-e-2',
459+
prompt: 'hi',
460+
puter_output_path: '/testuser/somedir/image.png',
461+
} as never),
462+
),
463+
).rejects.toMatchObject({ statusCode: 403 });
464+
465+
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
466+
});
467+
468+
it('ACL check runs BEFORE provider.generate so credits are not wasted on a denied path', async () => {
469+
const callOrder: string[] = [];
470+
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
471+
aclCheckSpy.mockImplementation(async () => {
472+
callOrder.push('acl');
473+
return false;
474+
});
475+
openaiImagesGenerateMock.mockImplementation(async () => {
476+
callOrder.push('provider');
477+
return { data: [{ url: 'https://oai/img.png' }] };
478+
});
479+
480+
await expect(
481+
withTestUser(() =>
482+
driver.generate({
483+
model: 'dall-e-2',
484+
prompt: 'hi',
485+
puter_output_path: '/testuser/dir/img.png',
486+
} as never),
487+
),
488+
).rejects.toMatchObject({ statusCode: 403 });
489+
490+
expect(callOrder).toEqual(['acl']);
491+
});
492+
493+
it('resolves ~ in puter_output_path to /<username>/', async () => {
494+
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
495+
aclCheckSpy.mockResolvedValueOnce(true);
496+
497+
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
498+
fsWriteSpy.mockResolvedValueOnce(undefined as never);
499+
500+
openaiImagesGenerateMock.mockResolvedValueOnce({
501+
data: [{ url: 'https://oai/img.png' }],
502+
});
503+
fetchSpy.mockResolvedValueOnce(
504+
new Response(Buffer.from('fake-png'), {
505+
status: 200,
506+
headers: { 'content-type': 'image/png' },
507+
}),
508+
);
509+
510+
await withTestUser(() =>
511+
driver.generate({
512+
model: 'dall-e-2',
513+
prompt: 'hi',
514+
puter_output_path: '~/images/out.png',
515+
} as never),
516+
);
517+
518+
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
519+
const [, writeArg] = fsWriteSpy.mock.calls[0]!;
520+
expect(
521+
(writeArg as { fileMetadata: { path: string } }).fileMetadata.path,
522+
).toBe('/testuser/images/out.png');
523+
});
524+
525+
it('writes the generated image to FS and still returns the result URL', async () => {
526+
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
527+
aclCheckSpy.mockResolvedValueOnce(true);
528+
529+
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
530+
fsWriteSpy.mockResolvedValueOnce(undefined as never);
531+
532+
openaiImagesGenerateMock.mockResolvedValueOnce({
533+
data: [{ url: 'https://oai/img.png' }],
534+
});
535+
fetchSpy.mockResolvedValueOnce(
536+
new Response(Buffer.from('fake-png'), {
537+
status: 200,
538+
headers: { 'content-type': 'image/png' },
539+
}),
540+
);
541+
542+
const result = await withTestUser(() =>
543+
driver.generate({
544+
model: 'dall-e-2',
545+
prompt: 'hi',
546+
puter_output_path: '/testuser/photos/out.png',
547+
} as never),
548+
);
549+
550+
expect(result).toBe('https://oai/img.png');
551+
expect(fsWriteSpy).toHaveBeenCalledTimes(1);
552+
const [userId, writeArg] = fsWriteSpy.mock.calls[0]!;
553+
expect(userId).toBe(42);
554+
const meta = (
555+
writeArg as {
556+
fileMetadata: {
557+
path: string;
558+
contentType: string;
559+
overwrite: boolean;
560+
};
561+
}
562+
).fileMetadata;
563+
expect(meta.path).toBe('/testuser/photos/out.png');
564+
expect(meta.contentType).toBe('image/png');
565+
expect(meta.overwrite).toBe(true);
566+
});
567+
568+
it('does not forward puter_output_path to the upstream provider call', async () => {
569+
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
570+
aclCheckSpy.mockResolvedValueOnce(true);
571+
572+
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
573+
fsWriteSpy.mockResolvedValueOnce(undefined as never);
574+
575+
openaiImagesGenerateMock.mockResolvedValueOnce({
576+
data: [{ url: 'https://oai/img.png' }],
577+
});
578+
fetchSpy.mockResolvedValueOnce(
579+
new Response(Buffer.from('fake-png'), {
580+
status: 200,
581+
headers: { 'content-type': 'image/png' },
582+
}),
583+
);
584+
585+
await withTestUser(() =>
586+
driver.generate({
587+
model: 'dall-e-2',
588+
prompt: 'hi',
589+
puter_output_path: '/testuser/dir/img.png',
590+
} as never),
591+
);
592+
593+
const sent = openaiImagesGenerateMock.mock.calls[0]![0];
594+
expect(sent.puter_output_path).toBeUndefined();
595+
});
596+
597+
it('throws 400 when actor has no user ID but puter_output_path is set', async () => {
598+
const noIdActor: import('../../core/actor.js').Actor = {
599+
user: { uuid: 'f0e1d2c3-b4a5-4968-8777-0a1b2c3d4e5f', username: 'noone' },
600+
};
601+
await expect(
602+
Promise.resolve(
603+
runWithContext({ actor: noIdActor }, () =>
604+
driver.generate({
605+
model: 'dall-e-2',
606+
prompt: 'hi',
607+
puter_output_path: '/noone/dir/img.png',
608+
} as never),
609+
),
610+
),
611+
).rejects.toMatchObject({ statusCode: 400 });
612+
613+
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
614+
});
615+
});
616+
413617
// Avoid coupling the 'unused' XAI export to lint. The catalog reference
414618
// is also used implicitly by the routing tests above.
415619
void XAI_IMAGE_GENERATION_MODELS;

src/backend/drivers/ai-image/ImageGenerationDriver.ts

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@
1818
*/
1919

2020
import crypto from 'node:crypto';
21+
import { posix as pathPosix } from 'node:path';
22+
import { Readable } from 'node:stream';
2123
import { Context } from '../../core/context.js';
2224
import { HttpError } from '../../core/http/HttpError.js';
25+
import type { Actor } from '../../core/actor.js';
2326
import { PuterDriver } from '../types.js';
2427
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
2528
import { CloudflareImageProvider } from './providers/cloudflare/CloudflareImageProvider.js';
@@ -114,12 +117,34 @@ export class ImageGenerationDriver extends PuterDriver {
114117
}
115118

116119
async generate(args: IGenerateParams): Promise<string> {
117-
const actor = Context.get('actor');
120+
const actor = Context.get('actor') as Actor | undefined;
118121
if (!actor)
119122
throw new HttpError(401, 'Authentication required', {
120123
legacyCode: 'unauthorized',
121124
});
122125

126+
const puterOutputPath = args.puter_output_path;
127+
delete args.puter_output_path;
128+
129+
// Validate the output path early — before spending credits.
130+
let resolvedOutputPath: string | undefined;
131+
if (puterOutputPath) {
132+
const username = actor.user?.username;
133+
const userId = actor.user?.id;
134+
if (!userId || !username) {
135+
throw new HttpError(
136+
400,
137+
'User ID required for puter_output_path',
138+
{ legacyCode: 'bad_request' },
139+
);
140+
}
141+
resolvedOutputPath = this.#resolveOutputPath(
142+
puterOutputPath,
143+
username,
144+
);
145+
await this.#assertWriteAccess(actor, resolvedOutputPath);
146+
}
147+
123148
let modelId = args.model?.trim().toLowerCase();
124149
let intendedProvider =
125150
args.provider ?? (Context.get('driverName') as string | undefined);
@@ -172,11 +197,17 @@ export class ImageGenerationDriver extends PuterDriver {
172197
{},
173198
);
174199

175-
return provider.generate({
200+
const result = await provider.generate({
176201
...args,
177202
model: model.id,
178203
provider: model.provider,
179204
});
205+
206+
if (resolvedOutputPath) {
207+
await this.#saveToFS(actor, result, resolvedOutputPath);
208+
}
209+
210+
return result;
180211
}
181212

182213
#normalizeRatio(parameters: IGenerateParams) {
@@ -340,6 +371,106 @@ export class ImageGenerationDriver extends PuterDriver {
340371
}
341372
}
342373

374+
async #saveToFS(
375+
actor: Actor,
376+
result: string,
377+
resolvedPath: string,
378+
): Promise<void> {
379+
const userId = actor.user!.id!;
380+
381+
let buffer: Buffer;
382+
let contentType: string;
383+
384+
if (result.startsWith('data:')) {
385+
const commaIdx = result.indexOf(',');
386+
const header = result.substring(0, commaIdx);
387+
contentType =
388+
header.match(/data:(.*?);/)?.[1] ?? 'application/octet-stream';
389+
buffer = Buffer.from(result.substring(commaIdx + 1), 'base64');
390+
} else {
391+
const response = await fetch(result);
392+
if (!response.ok) {
393+
throw new HttpError(
394+
502,
395+
`Failed to fetch generated image for FS write: ${response.status}`,
396+
{ legacyCode: 'internal_error' },
397+
);
398+
}
399+
contentType =
400+
response.headers.get('content-type') ??
401+
'application/octet-stream';
402+
buffer = Buffer.from(await response.arrayBuffer());
403+
}
404+
405+
await this.services.fs.write(userId, {
406+
fileMetadata: {
407+
path: resolvedPath,
408+
size: buffer.length,
409+
contentType,
410+
overwrite: true,
411+
createMissingParents: true,
412+
},
413+
fileContent: Readable.from(buffer),
414+
});
415+
}
416+
417+
#resolveOutputPath(outputPath: string, username: string): string {
418+
let resolved = outputPath.trim();
419+
if (resolved === '~' || resolved.startsWith('~/')) {
420+
resolved = `/${username}${resolved.slice(1)}`;
421+
}
422+
resolved = pathPosix.normalize(resolved);
423+
if (!resolved.startsWith('/')) {
424+
resolved = `/${resolved}`;
425+
}
426+
if (resolved.length > 1 && resolved.endsWith('/')) {
427+
resolved = resolved.slice(0, -1);
428+
}
429+
return resolved;
430+
}
431+
432+
async #assertWriteAccess(
433+
actor: Actor,
434+
resolvedPath: string,
435+
): Promise<void> {
436+
if (resolvedPath === '/') {
437+
throw new HttpError(400, 'Cannot write to root path', {
438+
legacyCode: 'cannot_write_to_root',
439+
});
440+
}
441+
const parentPath = pathPosix.dirname(resolvedPath);
442+
if (parentPath === '/') {
443+
throw new HttpError(400, 'Cannot write to root path', {
444+
legacyCode: 'cannot_write_to_root',
445+
});
446+
}
447+
448+
const pathToCheck = parentPath;
449+
const fsService = this.services.fs;
450+
let ancestorsCache: Promise<
451+
Array<{ uid: string; path: string }>
452+
> | null = null;
453+
const canWrite = await this.services.acl.check(
454+
actor,
455+
{
456+
path: pathToCheck,
457+
resolveAncestors() {
458+
if (!ancestorsCache) {
459+
ancestorsCache =
460+
fsService.getAncestorChain(pathToCheck);
461+
}
462+
return ancestorsCache;
463+
},
464+
},
465+
'write',
466+
);
467+
if (!canWrite) {
468+
throw new HttpError(403, 'Write access denied for destination', {
469+
legacyCode: 'access_denied',
470+
});
471+
}
472+
}
473+
343474
#resolveModel(modelId: string, provider?: string): IImageModel | null {
344475
const models = this.#modelIdMap[modelId];
345476
if (!models || models.length === 0) return null;

src/backend/drivers/ai-image/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export interface IGenerateParams {
6363
input_image?: string;
6464
input_image_mime_type?: string;
6565
input_images?: string[];
66+
puter_output_path?: string;
6667
[key: string]: unknown;
6768
}
6869

0 commit comments

Comments
 (0)