Skip to content

Commit e1cfab4

Browse files
committed
Bugfix(modeling-commons): use public ACL image previews for uploads
1 parent ad88d7f commit e1cfab4

6 files changed

Lines changed: 309 additions & 29 deletions

File tree

apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.spec.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,75 @@ describe('modelDraftService', () => {
255255
expect(next.attachments).toHaveLength(1);
256256
expect(next.attachments![0]!.s3Key).toBe('staging/user-1/draft-1/att.csv');
257257
});
258+
259+
it('stages a preview image as a public object and persists it', async () => {
260+
const { service, modelDraftRepository, modelDraftStorage } = buildService();
261+
modelDraftStorage.putStaged.mockResolvedValue(
262+
'files/public/staging/user-1/draft-1/abc-preview.png',
263+
);
264+
const draft = makeDraft();
265+
266+
const result = await service.addFile(draft, 'preview', {
267+
buffer: Buffer.from('img') as Buffer<ArrayBuffer>,
268+
filename: 'preview.png',
269+
contentType: 'image/png',
270+
});
271+
272+
expect(result.role).toBe('preview');
273+
expect(modelDraftStorage.putStaged).toHaveBeenCalledWith(
274+
expect.objectContaining({ public: true }),
275+
);
276+
const next = modelDraftRepository.updateDataTx.mock.calls[0]![3] as DraftDataV1;
277+
expect(next.previewImage?.s3Key).toBe('files/public/staging/user-1/draft-1/abc-preview.png');
278+
});
279+
280+
it('stages non-preview files as private objects', async () => {
281+
const { service, modelDraftStorage } = buildService();
282+
modelDraftStorage.putStaged.mockResolvedValue('staging/user-1/draft-1/abc-new.nlogo');
283+
284+
await service.addFile(makeDraft(), 'primary', {
285+
buffer: Buffer.from('x') as Buffer<ArrayBuffer>,
286+
filename: 'new.nlogo',
287+
contentType: 'text/plain',
288+
});
289+
290+
expect(modelDraftStorage.putStaged).toHaveBeenCalledWith(
291+
expect.objectContaining({ public: false }),
292+
);
293+
});
294+
});
295+
296+
describe('generatePreviewImage', () => {
297+
function buildWithPreview() {
298+
const previewImageService = {
299+
generatePreviewFromNetlogoFile: vi
300+
.fn()
301+
.mockResolvedValue({ buffer: new Uint8Array([1, 2, 3]).buffer, contentType: 'image/png' }),
302+
};
303+
return { previewImageService, ...buildService({ previewImageService }) };
304+
}
305+
306+
it('stages the generated preview as a public object', async () => {
307+
const { service, modelDraftStorage } = buildWithPreview();
308+
modelDraftStorage.putStaged.mockResolvedValue(
309+
'files/public/staging/user-1/draft-1/uuid-preview.png',
310+
);
311+
const draft = makeDraft({ primaryFile: validPrimary });
312+
313+
const result = await service.generatePreviewImage(draft);
314+
315+
expect(result.s3Key).toBe('files/public/staging/user-1/draft-1/uuid-preview.png');
316+
expect(modelDraftStorage.putStaged).toHaveBeenCalledWith(
317+
expect.objectContaining({ public: true, filename: 'preview.png', contentType: 'image/png' }),
318+
);
319+
});
320+
321+
it('throws when the draft has no primary file', async () => {
322+
const { service } = buildWithPreview();
323+
await expect(service.generatePreviewImage(makeDraft({}))).rejects.toThrow(
324+
ModelDraftFileNotFoundError,
325+
);
326+
});
258327
});
259328

260329
describe('abandon', () => {

apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
type DraftPreviewImageV1,
2424
type DraftPrimaryFileV1,
2525
} from '#src/modules/model-draft/schemas/index.ts';
26+
import { PUBLIC_PREFIX } from '#src/modules/file/domain/file.types.ts';
2627
import { UnauthorizedException } from '#src/shared/exceptions/exceptions.ts';
2728
import { canWrite } from '#src/shared/permissions/model-access.policy.ts';
2829
import { loadModelAccessContext } from '#src/shared/permissions/model-access.viewer.ts';
@@ -79,8 +80,16 @@ export default function makeModelDraftService({
7980
});
8081
}
8182

82-
function stagingKey(userId: string, draftId: string, filename: string): string {
83-
return `staging/${userId}/${draftId}/${randomUUID()}-${sanitizeFilename(filename)}`;
83+
function stagingKey(
84+
userId: string,
85+
draftId: string,
86+
filename: string,
87+
isPublic = false,
88+
): string {
89+
const prefix = isPublic
90+
? `${PUBLIC_PREFIX}/staging/${userId}/${draftId}/`
91+
: `staging/${userId}/${draftId}/`;
92+
return `${prefix}${randomUUID()}-${sanitizeFilename(filename)}`;
8493
}
8594

8695
function filenameFromKey(key: string): string {
@@ -134,13 +143,15 @@ export default function makeModelDraftService({
134143
userId: string;
135144
draftId: string;
136145
filename: string;
146+
public?: boolean;
137147
}): Promise<{ s3Key: string; sizeBytes: number; mimeType: string }> {
138-
const destKey = stagingKey(params.userId, params.draftId, params.filename);
148+
const destKey = stagingKey(params.userId, params.draftId, params.filename, params.public);
139149
await storage.send(
140150
new CopyObjectCommand({
141151
Bucket: bucket.Name,
142152
Key: destKey,
143153
CopySource: `${bucket.Name}/${params.sourceKey}`,
154+
...(params.public ? { ACL: 'public-read' } : {}),
144155
}),
145156
);
146157
const head = await storage.send(new HeadObjectCommand({ Bucket: bucket.Name, Key: destKey }));
@@ -219,6 +230,7 @@ export default function makeModelDraftService({
219230
userId,
220231
draftId,
221232
filename: filenameFromKey(version.previewImageFileKey),
233+
public: true,
222234
})
223235
: undefined;
224236

@@ -287,6 +299,7 @@ export default function makeModelDraftService({
287299
buffer: file.buffer,
288300
filename: file.filename,
289301
contentType: file.contentType,
302+
public: role === 'preview',
290303
});
291304

292305
const data = currentData(draft);
@@ -344,6 +357,7 @@ export default function makeModelDraftService({
344357
buffer: previewBuffer,
345358
filename: 'preview.png',
346359
contentType: 'image/png',
360+
public: true,
347361
});
348362

349363
if (data.previewImage) {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import makeModelDraftStorage from '#src/modules/model-draft/model-draft.storage.ts';
3+
import { PUBLIC_PREFIX } from '#src/modules/file/domain/file.types.ts';
4+
5+
describe('modelDraftStorage', () => {
6+
const storage = { send: vi.fn() };
7+
const bucket = { Name: 'test-bucket' };
8+
const fileDomain = { createFile: vi.fn() };
9+
10+
const draftStorage = makeModelDraftStorage({ storage, bucket, fileDomain } as never);
11+
12+
beforeEach(() => {
13+
vi.clearAllMocks();
14+
});
15+
16+
describe('putStaged', () => {
17+
it('stages private files under the staging prefix without an ACL', async () => {
18+
storage.send.mockResolvedValue({});
19+
20+
const key = await draftStorage.putStaged({
21+
userId: 'u1',
22+
draftId: 'd1',
23+
buffer: Buffer.from('model') as Buffer<ArrayBuffer>,
24+
filename: 'model.nlogox',
25+
contentType: 'text/plain',
26+
});
27+
28+
expect(key.startsWith('staging/u1/d1/')).toBe(true);
29+
expect(key.startsWith(`${PUBLIC_PREFIX}/`)).toBe(false);
30+
const cmd = storage.send.mock.calls[0]![0];
31+
expect(cmd.input.Key).toBe(key);
32+
expect(cmd.input.ACL).toBeUndefined();
33+
});
34+
35+
it('stages public files under the public prefix with a public-read ACL', async () => {
36+
storage.send.mockResolvedValue({});
37+
38+
const key = await draftStorage.putStaged({
39+
userId: 'u1',
40+
draftId: 'd1',
41+
buffer: Buffer.from('img') as Buffer<ArrayBuffer>,
42+
filename: 'preview.png',
43+
contentType: 'image/png',
44+
public: true,
45+
});
46+
47+
expect(key.startsWith(`${PUBLIC_PREFIX}/staging/u1/d1/`)).toBe(true);
48+
const cmd = storage.send.mock.calls[0]![0];
49+
expect(cmd.input.Key).toBe(key);
50+
expect(cmd.input.ACL).toBe('public-read');
51+
});
52+
});
53+
54+
describe('deleteStagingPrefix', () => {
55+
it('sweeps both the private and public staging prefixes', async () => {
56+
storage.send.mockImplementation(async (cmd: { constructor: { name: string }; input: { Prefix?: string } }) => {
57+
if (cmd.constructor.name === 'ListObjectsV2Command') {
58+
return { Contents: [{ Key: `${cmd.input.Prefix}leftover.bin` }], IsTruncated: false };
59+
}
60+
return {};
61+
});
62+
63+
await draftStorage.deleteStagingPrefix('u1', 'd1');
64+
65+
const sent = storage.send.mock.calls.map((c) => c[0]);
66+
const listedPrefixes = sent
67+
.filter((cmd) => cmd.constructor.name === 'ListObjectsV2Command')
68+
.map((cmd) => cmd.input.Prefix);
69+
expect(listedPrefixes).toContain('staging/u1/d1/');
70+
expect(listedPrefixes).toContain(`${PUBLIC_PREFIX}/staging/u1/d1/`);
71+
72+
const deletedKeys = sent
73+
.filter((cmd) => cmd.constructor.name === 'DeleteObjectsCommand')
74+
.flatMap((cmd) => cmd.input.Delete.Objects.map((o: { Key: string }) => o.Key));
75+
expect(deletedKeys).toContain('staging/u1/d1/leftover.bin');
76+
expect(deletedKeys).toContain(`${PUBLIC_PREFIX}/staging/u1/d1/leftover.bin`);
77+
});
78+
79+
it('skips deletion when a prefix is empty', async () => {
80+
storage.send.mockResolvedValue({ Contents: [], IsTruncated: false });
81+
82+
await draftStorage.deleteStagingPrefix('u1', 'd1');
83+
84+
const sentNames = storage.send.mock.calls.map((c) => c[0].constructor.name);
85+
expect(sentNames).not.toContain('DeleteObjectsCommand');
86+
});
87+
});
88+
});

apps/modeling-commons-backend/src/modules/model-draft/model-draft.storage.ts

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { PUBLIC_PREFIX } from '#src/modules/file/domain/file.types.ts';
12
import {
23
CopyObjectCommand,
34
DeleteObjectCommand,
@@ -13,8 +14,45 @@ export default function makeModelDraftStorage({ storage, bucket, fileDomain }: D
1314
return `staging/${userId}/${draftId}/`;
1415
}
1516

16-
function stagingKey(userId: string, draftId: string, filename: string): string {
17-
return `${stagingPrefix(userId, draftId)}${randomUUID()}-${sanitizeFilename(filename)}`;
17+
function publicStagingPrefix(userId: string, draftId: string): string {
18+
return `${PUBLIC_PREFIX}/staging/${userId}/${draftId}/`;
19+
}
20+
21+
function stagingKey(
22+
userId: string,
23+
draftId: string,
24+
filename: string,
25+
isPublic = false,
26+
): string {
27+
const prefix = isPublic
28+
? publicStagingPrefix(userId, draftId)
29+
: stagingPrefix(userId, draftId);
30+
return `${prefix}${randomUUID()}-${sanitizeFilename(filename)}`;
31+
}
32+
33+
async function deletePrefix(prefix: string): Promise<void> {
34+
let continuationToken: string | undefined;
35+
do {
36+
const listed = await storage.send(
37+
new ListObjectsV2Command({
38+
Bucket: bucket.Name,
39+
Prefix: prefix,
40+
ContinuationToken: continuationToken,
41+
}),
42+
);
43+
const keys = (listed.Contents ?? [])
44+
.map((o) => o.Key)
45+
.filter((k): k is string => Boolean(k));
46+
if (keys.length > 0) {
47+
await storage.send(
48+
new DeleteObjectsCommand({
49+
Bucket: bucket.Name,
50+
Delete: { Objects: keys.map((Key) => ({ Key })) },
51+
}),
52+
);
53+
}
54+
continuationToken = listed.IsTruncated ? listed.NextContinuationToken : undefined;
55+
} while (continuationToken);
1856
}
1957

2058
return {
@@ -24,14 +62,16 @@ export default function makeModelDraftStorage({ storage, bucket, fileDomain }: D
2462
buffer: Buffer<ArrayBuffer>;
2563
filename: string;
2664
contentType: string;
65+
public?: boolean;
2766
}): Promise<string> {
28-
const key = stagingKey(params.userId, params.draftId, params.filename);
67+
const key = stagingKey(params.userId, params.draftId, params.filename, params.public);
2968
await storage.send(
3069
new PutObjectCommand({
3170
Bucket: bucket.Name,
3271
Key: key,
3372
Body: params.buffer,
3473
ContentType: params.contentType,
74+
...(params.public ? { ACL: 'public-read' } : {}),
3575
Metadata: {
3676
filename: sanitizeFilename(params.filename),
3777
createdat: new Date().toISOString(),
@@ -77,29 +117,8 @@ export default function makeModelDraftStorage({ storage, bucket, fileDomain }: D
77117
},
78118

79119
async deleteStagingPrefix(userId: string, draftId: string): Promise<void> {
80-
const prefix = stagingPrefix(userId, draftId);
81-
let continuationToken: string | undefined;
82-
do {
83-
const listed = await storage.send(
84-
new ListObjectsV2Command({
85-
Bucket: bucket.Name,
86-
Prefix: prefix,
87-
ContinuationToken: continuationToken,
88-
}),
89-
);
90-
const keys = (listed.Contents ?? [])
91-
.map((o) => o.Key)
92-
.filter((k): k is string => Boolean(k));
93-
if (keys.length > 0) {
94-
await storage.send(
95-
new DeleteObjectsCommand({
96-
Bucket: bucket.Name,
97-
Delete: { Objects: keys.map((Key) => ({ Key })) },
98-
}),
99-
);
100-
}
101-
continuationToken = listed.IsTruncated ? listed.NextContinuationToken : undefined;
102-
} while (continuationToken);
120+
await deletePrefix(stagingPrefix(userId, draftId));
121+
await deletePrefix(publicStagingPrefix(userId, draftId));
103122
},
104123
};
105124
}

apps/modeling-commons-backend/tests/api/model-draft.feature

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,29 @@ Feature: Model Drafts
6969
And the response body should have property "modelId"
7070
And the response body should have property "versionNumber"
7171

72+
Scenario: Uploading a preview image returns a public unsigned URL
73+
Given an authenticated user
74+
And an empty draft
75+
When I upload a preview image to the draft
76+
Then the response status should be 201
77+
And the response body should have property "previewImageUrl"
78+
And the response body property "previewImageUrl" should contain "files/public/"
79+
And the response body property "previewImageUrl" should not contain "X-Amz-Signature"
80+
81+
Scenario: A draft seeded from a model with a preview keeps it public and unsigned
82+
Given an authenticated user
83+
And an empty draft
84+
When I patch the draft with title "Seeded Preview Model" and visibility "public"
85+
And I upload a primary file to the draft
86+
And I upload a preview image to the draft
87+
And I publish the draft
88+
Then the response status should be 201
89+
When I seed a new draft from the published model
90+
And I get the draft
91+
Then the response status should be 200
92+
And the response body property "previewImageUrl" should contain "files/public/"
93+
And the response body property "previewImageUrl" should not contain "X-Amz-Signature"
94+
7295
@smoke
7396
Scenario: Publishing a draft seeded from an existing model creates a new version
7497
Given an authenticated user

0 commit comments

Comments
 (0)