Skip to content
Closed
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
15 changes: 13 additions & 2 deletions core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import cartesBancairesIcon from '../assets/carter-banceris.svg';

export const DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE = 'This file type is not supported for preview.';
export const ZIP_EMPTY_ARCHIVE_MESSAGE = 'No files found in the ZIP archive.';
export const ZIP_PREVIEW_LOADING_MESSAGE = 'Loading preview...';
export const ZIP_PREVIEW_LOADING_MESSAGE = '...loading';

// MIME types recognised as a zip archive for `zipRender`. Deliberately exact:
// 'application/gzip' also contains "zip" but is not a zip.
Expand Down Expand Up @@ -544,6 +544,9 @@ export const ZIP_CONTAINER_STYLES = {
'flex-direction': 'row',
height: '100%',
width: '100%',
overflow: 'auto',
border: '1px solid #a5a2a2',
borderRadius: '4px',
},
};

Expand All @@ -567,6 +570,10 @@ export const ZIP_NAV_LIST_ITEM_STYLES = {
borderRadius: '4px',
boxSizing: 'border-box' as const,
backgroundColor: '#f9f9f9',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
wordBreak: 'break-all',
},
[STYLE_TYPE.FOCUS]: {
alignItems: 'center',
Expand All @@ -576,7 +583,11 @@ export const ZIP_NAV_LIST_ITEM_STYLES = {
borderRadius: '4px',
boxSizing: 'border-box' as const,
backgroundColor: '#f9f9f9',
border: '2px solid #b0b3b8ff',
border: '2px solid rgb(155, 158, 164)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
wordBreak: 'break-all',
},
};

Expand Down
7 changes: 6 additions & 1 deletion core/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export interface IRevealResponseType {
}
export interface IUnzippedFileMetadata {
name: string;
fileSize: number;
size: number;
type: string;
}

Expand Down Expand Up @@ -565,6 +565,11 @@ export interface RenderFileResponse {
success?: {
skyflow_id: string,
column: string,
// Metadata of the rendered file record itself (name, size, content type).
fileMetadata?: Record<string, any>,
// Present only when a zip archive was rendered with `zipRender: true`:
// one entry per extracted file.
unZippedFilesMetadata?: IUnzippedFileMetadata[],
},
errors?: {
skyflowId: string,
Expand Down
6 changes: 4 additions & 2 deletions packages/skyflow-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4376,13 +4376,15 @@ For zip files, the `renderFile()` success response also includes `unZippedFilesM
column: 'string',
fileMetadata: { ... },
unZippedFilesMetadata: [
{ name: 'photo.png', fileSize: 20480, type: 'image/png' },
{ name: 'doc.pdf', fileSize: 102400, type: 'application/pdf' },
{ name: 'photo.png', size: 20480, type: 'image/png' },
{ name: 'doc.pdf', size: 102400, type: 'application/pdf' },
],
},
}
```

For TypeScript users, the npm package exports `RenderFileResponse` for this response, `UnzippedFileMetadata` for each entry of `unZippedFilesMetadata`, and `RenderOptions` for the `renderFile()` options object.

#### Download the currently previewed file
For composable reveal elements rendering a zip file with `allowDownload: true`, call `downloadCurrentFile()` to download the file currently selected in the preview. Download is off by default; without `allowDownload: true` the call is ignored and an error is logged.

Expand Down
1 change: 1 addition & 0 deletions packages/skyflow-js/src/index-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type {
RevealResponse,
RenderFileResponse,
IRenderOptions as RenderOptions,
IUnzippedFileMetadata as UnzippedFileMetadata,
IValidationRule as ValidationRule,
ElementState,
ErrorMessages,
Expand Down
8 changes: 4 additions & 4 deletions packages/skyflow-js/src/internal/reveal/reveal-frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,15 +239,15 @@ class RevealFrame extends CoreRevealFrame {
if (resolvedResult.fields && data.column) {
url = resolvedResult.fields[data.column];
}
const fileType = this.getExtension(url);
const fileType = this.getExtension(url) || resolvedResult?.fileMetadata?.file?.fileType;
const isZip = RevealFrame.isZipMimeType(fileType);
if (isZip && this.#renderOptions.zipRender) {
this.dataElememt.innerText = '...loading';
this.dataElememt.innerText = ZIP_PREVIEW_LOADING_MESSAGE;
this.unZipFiles(url).then((files) => {
printLog(parameterizedString(logs.infoLogs.FILES_UNZIPPED_SUCCESSFULLY,
CLASS_NAME, this.record?.skyflowID), MessageType.LOG, this.context?.logLevel);
const metadata = files.map((f) => ({
name: f.name, fileSize: f.size, type: f.type,
name: f.name, size: f.size, type: f.type,
}));
if (renderId !== this.#renderSeq) {
// A newer renderFile() call owns the DOM now; just answer this one.
Expand Down Expand Up @@ -457,7 +457,7 @@ class RevealFrame extends CoreRevealFrame {

private async downloadCurrentFile(): Promise<void> {
if (!this.#renderOptions.allowDownload) {
printLog(logs.errorLogs.DOWNLOAD_NOT_ALLOWED, MessageType.ERROR, this.context?.logLevel);
printLog(logs.errorLogs.DOWNLOAD_NOT_ALLOWED, MessageType.WARN, this.context?.logLevel);
return;
}
const currentFile = this.#currentFile;
Expand Down
4 changes: 3 additions & 1 deletion packages/skyflow-js/src/utils/validators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,9 @@ const DANGEROUS_FILE_EXTENSIONS = [
'.ova', '.ovf', '.vdi', '.vbox',
];

export const isDangerousFileType = (file: { name?: string; type?: string }): boolean => {
export const isDangerousFileType = (
file?: { name?: string; type?: string } | null,
): boolean => {
const lowerName = (file?.name || '').toLowerCase();
const lowerType = (file?.type || '').toLowerCase();
if (DANGEROUS_FILE_EXTENSIONS.some((ext) => lowerName.endsWith(ext))) {
Expand Down
2 changes: 1 addition & 1 deletion packages/skyflow-js/tests/api-utils/reveal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ describe("formatForRenderClient fn test",()=>{
}});
});
test("with unZippedFilesMetadata should include it in success",()=>{
const unZippedFilesMetadata = [{ name: 'a.png', fileSize: 10, type: 'image/png' }];
const unZippedFilesMetadata = [{ name: 'a.png', size: 10, type: 'image/png' }];
const testInput = {"fields": { "col" : "http://dummy.com", "skyflow_id": "id" },
"fileMetadata": { contentType: 'application/zip' }, unZippedFilesMetadata };
const fnResponse = formatForRenderClient(testInput, 'col');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ describe("Reveal Frame - zip file render", () => {
expect(success.unZippedFilesMetadata.map((f) => f.name).sort()).toEqual(['doc.pdf', 'photo.png']);
// size comes from the archive's central directory, before any extraction
expect(success.unZippedFilesMetadata.find((f) => f.name === 'photo.png')).toEqual({
name: 'photo.png', type: 'image/png', fileSize: 'png-bytes'.length,
name: 'photo.png', type: 'image/png', size: 'png-bytes'.length,
});

const items = navItems();
Expand Down Expand Up @@ -562,13 +562,26 @@ describe("Reveal Frame - zip file render", () => {
});

test("download is blocked by default (allowDownload false)", async () => {
await renderZip({ 'a.png': 'a' });
// A download attempt without allowDownload is a misuse, not a failure, so the SDK
// reports it at WARN level. Run this frame at LogLevel.WARN so the message is emitted.
defineUrl('http://localhost/?' + btoa(JSON.stringify({
...frameData, context: { logLevel: LogLevel.WARN, env: Env.PROD },
})));
mockFetch(await buildZip({ 'a.png': 'a' }));
RevealFrame.init();
dispatchRenderRequest();
await waitForResponse();
await waitFor(() => document.querySelector('#zip-panel img'));

const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
dispatchDownload();
await new Promise((r) => setTimeout(r, 10));

expect(clickSpy).not.toHaveBeenCalled();
expect(errorSpy.mock.calls.flat().join(' ')).toContain(logs.errorLogs.DOWNLOAD_NOT_ALLOWED);
expect(warnSpy.mock.calls.flat().join(' ')).toContain(logs.errorLogs.DOWNLOAD_NOT_ALLOWED);
// and it is a warning, not an error
expect(errorSpy.mock.calls.flat().join(' ')).not.toContain(logs.errorLogs.DOWNLOAD_NOT_ALLOWED);
});

test("download is blocked for dangerous file types even when allowed", async () => {
Expand Down Expand Up @@ -766,11 +779,75 @@ describe("Reveal Frame - zip file render", () => {
for (let i = 0; i < 300; i += 1) entries[`dir${i % 7}/f${i}.txt`] = 'x'.repeat(50);
const response = await renderZip(entries, { zipRender: true, autoSelectFirst: false });
expect(response.data.result.success.unZippedFilesMetadata).toHaveLength(300);
expect(response.data.result.success.unZippedFilesMetadata[0].fileSize).toBe(50);
expect(response.data.result.success.unZippedFilesMetadata[0].size).toBe(50);
expect(navItems()).toHaveLength(300);
expect(createObjectURL).not.toHaveBeenCalled();
});

// ---------------------------------------------------------- nav chips
test("long unbroken file names: nav chips stay single-line, truncate with an ellipsis, and keep the full path on hover", async () => {
const longName = 'zipfilesfordemo/zipfile_flower_summer_flowers_yellow_flower_with_a_very_long_name_that_cannot_wrap.jpg';
await renderZip({ [longName]: 'jpg', 'short.pdf': 'p' }, { zipRender: true, autoSelectFirst: false });

const items = Array.from(navItems());
const chip = items.find((li) => li.title === longName);
expect(chip).toBeTruthy();
// basename label (default labelMode) but the full archive path stays on hover
expect(chip.textContent).toBe('zipfile_flower_summer_flowers_yellow_flower_with_a_very_long_name_that_cannot_wrap.jpg');
expect(chip.title).toBe(longName);

// both the resting and the selected chip styles carry the truncation rules
[ZIP_NAV_LIST_ITEM_STYLES.base, ZIP_NAV_LIST_ITEM_STYLES.focus].forEach((style) => {
expect(style).toEqual(expect.objectContaining({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}));
});
const index = items.indexOf(chip);
const applied = jssCalls.filter((c) => c.name === `zip-nav-item${index}`).pop()?.styles;
expect(applied.base).toEqual(expect.objectContaining({
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}));
});

// ------------------------------------------------------- metadata shape
test("unZippedFilesMetadata: every entry has exactly name, size and type, with size from the archive", async () => {
const entries = {
'img.png': 'png-bytes',
'docs/2024/report.pdf': 'hello pdf',
'docs/notes.txt': 'n',
};
const response = await renderZip(entries, { zipRender: true, autoSelectFirst: false });
const manifest = response.data.result.success.unZippedFilesMetadata;

expect(manifest).toHaveLength(3);
manifest.forEach((entry) => {
expect(Object.keys(entry).sort()).toEqual(['name', 'size', 'type']);
expect(typeof entry.name).toBe('string');
expect(typeof entry.size).toBe('number');
expect(typeof entry.type).toBe('string');
// size is the uncompressed byte length reported by the archive; nothing was inflated yet
expect(entry.size).toBe(entries[entry.name].length);
expect(entry).not.toHaveProperty('fileSize');
});
expect(manifest.map((f) => f.name).sort()).toEqual(['docs/2024/report.pdf', 'docs/notes.txt', 'img.png']);
expect(manifest.find((f) => f.name === 'img.png').type).toBe('image/png');
expect(manifest.find((f) => f.name === 'docs/2024/report.pdf').type).toBe('application/pdf');
expect(createObjectURL).not.toHaveBeenCalled();
});

test("the archive's own fileMetadata is preserved alongside unZippedFilesMetadata", async () => {
const response = await renderZip({ 'a.png': 'a', 'b.pdf': 'bb' }, { zipRender: true, autoSelectFirst: false });
const { success } = response.data.result;
// fileMetadata describes the zip record itself and must survive the manifest being added
expect(success.fileMetadata).toEqual({ contentType: 'application/zip' });
expect(success.unZippedFilesMetadata).toHaveLength(2);
expect(success.unZippedFilesMetadata.map((f) => f.size)).toEqual(expect.arrayContaining([1, 2]));
expect(success.skyflow_id).toBe('abc123');
expect(success.column).toBe('primary_card_file');
});

// ---------------------------------------------------------------- re-render / races
test("re-rendering revokes previous blob URLs and rebuilds the layout", async () => {
await renderZip({ 'a.png': 'a' });
Expand Down
15 changes: 15 additions & 0 deletions packages/skyflow-js/tests/utils/validators.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,21 @@ describe('isDangerousFileType', () => {
expect(isDangerousFileType({ name: 'clip.mp4', type: 'video/mp4' })).toBe(false);
expect(isDangerousFileType({})).toBe(false);
expect(isDangerousFileType(undefined)).toBe(false);
expect(isDangerousFileType(null)).toBe(false);
expect(isDangerousFileType()).toBe(false);
});
test('handles an undefined type: decides by extension alone', () => {
// Extracted zip entries often have no MIME type; the extension check must carry the decision.
expect(isDangerousFileType({ name: 'run.exe' })).toBe(true);
expect(isDangerousFileType({ name: 'run.exe', type: undefined })).toBe(true);
expect(isDangerousFileType({ name: 'photo.png' })).toBe(false);
expect(isDangerousFileType({ name: 'photo.png', type: undefined })).toBe(false);
});
test('handles an undefined name: decides by mime type alone', () => {
expect(isDangerousFileType({ type: 'text/html' })).toBe(true);
expect(isDangerousFileType({ name: undefined, type: 'application/x-msdownload' })).toBe(true);
expect(isDangerousFileType({ type: 'image/png' })).toBe(false);
expect(isDangerousFileType({ name: undefined, type: 'application/pdf' })).toBe(false);
});
});

Expand Down
Loading