diff --git a/packages/base/src/formbuilder/creationform.tsx b/packages/base/src/formbuilder/creationform.tsx index 959a79b44..3cd696ed5 100644 --- a/packages/base/src/formbuilder/creationform.tsx +++ b/packages/base/src/formbuilder/creationform.tsx @@ -213,6 +213,7 @@ export class CreationForm extends React.Component { formChangedSignal={this.sourceFormChangedSignal} formErrorSignal={this.props.formErrorSignal} dialogOptions={this.props.dialogOptions} + sourceType={this.props.sourceType} /> )} diff --git a/packages/base/src/formbuilder/editform.tsx b/packages/base/src/formbuilder/editform.tsx index 6cc2c9947..ba3dd9598 100644 --- a/packages/base/src/formbuilder/editform.tsx +++ b/packages/base/src/formbuilder/editform.tsx @@ -120,6 +120,7 @@ export class EditForm extends React.Component { this.syncObjectProperties(this.props.source, properties); }} formChangedSignal={this.sourceFormChangedSignal} + sourceType={source?.type || 'RasterSource'} /> )} diff --git a/packages/base/src/formbuilder/formselectors.ts b/packages/base/src/formbuilder/formselectors.ts index a6d9ee378..27dec5956 100644 --- a/packages/base/src/formbuilder/formselectors.ts +++ b/packages/base/src/formbuilder/formselectors.ts @@ -7,6 +7,7 @@ import { TileSourcePropertiesForm } from './objectform/tilesourceform'; import { VectorLayerPropertiesForm } from './objectform/vectorlayerform'; import { WebGlLayerPropertiesForm } from './objectform/webGlLayerForm'; import { GeoTiffSourcePropertiesForm } from './objectform/geotiffsource'; +import { PathBasedSourcePropertiesForm } from './objectform/pathbasedsource'; export function getLayerTypeForm( layerType: LayerType @@ -36,6 +37,12 @@ export function getSourceTypeForm(sourceType: SourceType): typeof BaseForm { case 'GeoJSONSource': SourceForm = GeoJSONSourcePropertiesForm; break; + case 'ImageSource': + SourceForm = PathBasedSourcePropertiesForm; + break; + case 'ShapefileSource': + SourceForm = PathBasedSourcePropertiesForm; + break; case 'GeoTiffSource': SourceForm = GeoTiffSourcePropertiesForm; break; @@ -45,6 +52,5 @@ export function getSourceTypeForm(sourceType: SourceType): typeof BaseForm { break; // ADD MORE FORM TYPES HERE } - return SourceForm; } diff --git a/packages/base/src/formbuilder/objectform/baseform.tsx b/packages/base/src/formbuilder/objectform/baseform.tsx index fbd24e623..149737c7e 100644 --- a/packages/base/src/formbuilder/objectform/baseform.tsx +++ b/packages/base/src/formbuilder/objectform/baseform.tsx @@ -8,7 +8,7 @@ import { Signal } from '@lumino/signaling'; import { deepCopy } from '../../tools'; import { IDict } from '../../types'; import { Slider, SliderLabel } from '@jupyter/react-components'; -import { FileSelectorWidget } from './fileselectorwidget'; +import { SourceType } from '@jupytergis/schema'; export interface IBaseFormStates { schema?: IDict; @@ -73,6 +73,11 @@ export interface IBaseFormProps { * and other form-related parameters. */ dialogOptions?: any; + + /** + * Source type property + */ + sourceType: SourceType; } const WrappedFormComponent = (props: any): JSX.Element => { @@ -202,20 +207,6 @@ export class BaseForm extends React.Component { this.removeFormEntry(k, data, schema, uiSchema); } } - - // Customize the widget for path field - if (schema.properties && schema.properties.path) { - const docManager = - this.props.formChangedSignal?.sender.props.formSchemaRegistry.getDocManager(); - - uiSchema.path = { - 'ui:widget': FileSelectorWidget, - 'ui:options': { - docManager, - formOptions: this.props - } - }; - } }); } diff --git a/packages/base/src/formbuilder/objectform/fileselectorwidget.tsx b/packages/base/src/formbuilder/objectform/fileselectorwidget.tsx index d152027ac..46c03a35f 100644 --- a/packages/base/src/formbuilder/objectform/fileselectorwidget.tsx +++ b/packages/base/src/formbuilder/objectform/fileselectorwidget.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { FileDialog } from '@jupyterlab/filebrowser'; import { Dialog } from '@jupyterlab/apputils'; import { CreationFormDialog } from '../../dialogs/formdialog'; @@ -10,9 +10,10 @@ export const FileSelectorWidget = (props: any) => { const [serverFilePath, setServerFilePath] = useState(''); const [urlPath, setUrlPath] = useState(''); + const isTypingURL = useRef(false); // Tracks whether the user is manually typing a URL useEffect(() => { - if (props.value) { + if (!isTypingURL.current && props.value) { if ( props.value.startsWith('http://') || props.value.startsWith('https://') @@ -85,12 +86,17 @@ export const FileSelectorWidget = (props: any) => { }; const handleURLChange = (event: React.ChangeEvent) => { - const url = event.target.value; - setServerFilePath(''); + const url = event.target.value.trim(); + isTypingURL.current = true; setUrlPath(url); + setServerFilePath(''); props.onChange(url); }; + const handleURLBlur = () => { + isTypingURL.current = false; + }; + return (
@@ -114,6 +120,7 @@ export const FileSelectorWidget = (props: any) => { id="root_path" className="jp-mod-styled" onChange={handleURLChange} + onBlur={handleURLBlur} value={urlPath || ''} style={{ width: '100%' }} /> diff --git a/packages/base/src/formbuilder/objectform/geojsonsource.ts b/packages/base/src/formbuilder/objectform/geojsonsource.ts index 7526190e2..81b3c66a4 100644 --- a/packages/base/src/formbuilder/objectform/geojsonsource.ts +++ b/packages/base/src/formbuilder/objectform/geojsonsource.ts @@ -1,16 +1,17 @@ import { IDict } from '@jupytergis/schema'; -import { showErrorMessage } from '@jupyterlab/apputils'; -import { IChangeEvent, ISubmitEvent } from '@rjsf/core'; import { Ajv, ValidateFunction } from 'ajv'; import * as geojson from '@jupytergis/schema/src/schema/geojson.json'; -import { BaseForm, IBaseFormProps } from './baseform'; +import { IBaseFormProps } from './baseform'; +import { PathBasedSourcePropertiesForm } from './pathbasedsource'; import { loadFile } from '../../tools'; /** * The form to modify a GeoJSON source. */ -export class GeoJSONSourcePropertiesForm extends BaseForm { +export class GeoJSONSourcePropertiesForm extends PathBasedSourcePropertiesForm { + private _validate: ValidateFunction; + constructor(props: IBaseFormProps) { super(props); const ajv = new Ajv(); @@ -28,40 +29,6 @@ export class GeoJSONSourcePropertiesForm extends BaseForm { } super.processSchema(data, schema, uiSchema); - if (!schema.properties || !data) { - return; - } - - // This is not user-editable - delete schema.properties.valid; - } - - protected onFormBlur(id: string, value: any) { - // Is there a better way to spot the path text entry? - if (!id.endsWith('_path')) { - return; - } - - this._validatePath(value); - } - - // we need to use `onFormChange` instead of `onFormBlur` because it's no longer a text field - protected onFormChange(e: IChangeEvent): void { - super.onFormChange(e); - if (e.formData?.path) { - this._validatePath(e.formData.path); - } - } - - protected onFormSubmit(e: ISubmitEvent) { - if (this.state.extraErrors?.path?.__errors?.length >= 1) { - showErrorMessage( - 'Invalid JSON file', - this.state.extraErrors.path.__errors[0] - ); - return; - } - super.onFormSubmit(e); } /** @@ -69,7 +36,7 @@ export class GeoJSONSourcePropertiesForm extends BaseForm { * * @param path - the path to validate. */ - private async _validatePath(path: string) { + protected async _validatePath(path: string) { const extraErrors: IDict = this.state.extraErrors; let error = ''; @@ -78,7 +45,7 @@ export class GeoJSONSourcePropertiesForm extends BaseForm { try { const geoJSONData = await loadFile({ filepath: path, - type: 'GeoJSONSource', + type: this.props.sourceType, model: this.props.model }); valid = this._validate(geoJSONData); @@ -112,6 +79,4 @@ export class GeoJSONSourcePropertiesForm extends BaseForm { this.props.formErrorSignal.emit(!valid); } } - - private _validate: ValidateFunction; } diff --git a/packages/base/src/formbuilder/objectform/pathbasedsource.ts b/packages/base/src/formbuilder/objectform/pathbasedsource.ts new file mode 100644 index 000000000..6d22013b8 --- /dev/null +++ b/packages/base/src/formbuilder/objectform/pathbasedsource.ts @@ -0,0 +1,115 @@ +import { IDict } from '@jupytergis/schema'; +import { showErrorMessage } from '@jupyterlab/apputils'; +import { IChangeEvent, ISubmitEvent } from '@rjsf/core'; + +import { BaseForm, IBaseFormProps } from './baseform'; +import { loadFile } from '../../tools'; +import { FileSelectorWidget } from './fileselectorwidget'; + +/** + * The form to modify a PathBasedSource source. + */ +export class PathBasedSourcePropertiesForm extends BaseForm { + constructor(props: IBaseFormProps) { + super(props); + + if (this.props.sourceType !== 'GeoJSONSource') { + this._validatePath(props.sourceData?.path ?? ''); + } + } + + protected processSchema( + data: IDict | undefined, + schema: IDict, + uiSchema: IDict + ) { + super.processSchema(data, schema, uiSchema); + if (!schema.properties || !data) { + return; + } + + // Customize the widget for path field + if (schema.properties && schema.properties.path) { + const docManager = + this.props.formChangedSignal?.sender.props.formSchemaRegistry.getDocManager(); + + uiSchema.path = { + 'ui:widget': FileSelectorWidget, + 'ui:options': { + docManager, + formOptions: this.props + } + }; + } + // This is not user-editable + delete schema.properties.valid; + } + + protected onFormBlur(id: string, value: any) { + // Is there a better way to spot the path text entry? + if (!id.endsWith('_path')) { + return; + } + this._validatePath(value); + } + + // we need to use `onFormChange` instead of `onFormBlur` because it's no longer a text field + protected onFormChange(e: IChangeEvent): void { + super.onFormChange(e); + if (e.formData?.path !== undefined) { + this._validatePath(e.formData.path); + } + } + + protected onFormSubmit(e: ISubmitEvent) { + if (this.state.extraErrors?.path?.__errors?.length >= 1) { + showErrorMessage('Invalid file', this.state.extraErrors.path.__errors[0]); + return; + } + super.onFormSubmit(e); + } + + /** + * Validate the path, to avoid invalid path. + * + * @param path - the path to validate. + */ + protected async _validatePath(path: string) { + const extraErrors: IDict = this.state.extraErrors; + + let error = ''; + let valid = true; + if (!path) { + valid = false; + error = 'Path is required'; + } else { + try { + await loadFile({ + filepath: path, + type: this.props.sourceType, + model: this.props.model + }); + } catch (e) { + valid = false; + error = `"${path}" is not a valid ${this.props.sourceType} file.`; + } + } + + if (!valid) { + extraErrors.path = { + __errors: [error] + }; + + this.setState(old => ({ ...old, extraErrors })); + } else { + this.setState(old => ({ + ...old, + extraErrors: { ...extraErrors, path: { __errors: [] } } + })); + } + + if (this.props.formErrorSignal) { + this.props.formErrorSignal.emit(!valid); + } + } +} diff --git a/packages/base/src/tools.ts b/packages/base/src/tools.ts index d2f847949..6f98f0c46 100644 --- a/packages/base/src/tools.ts +++ b/packages/base/src/tools.ts @@ -473,7 +473,24 @@ export const loadFile = async (fileInfo: { if (filepath.startsWith('http://') || filepath.startsWith('https://')) { switch (type) { case 'ImageSource': { - return filepath; // Return the URL directly + try { + const response = await fetch(filepath); + if (!response.ok) { + throw new Error(`Failed to fetch image from URL: ${filepath}`); + } + + const contentType = response.headers.get('Content-Type'); + if (!contentType || !contentType.startsWith('image/')) { + throw new Error(`Invalid image URL. Content-Type: ${contentType}`); + } + + // load the image to verify it's not corrupted + await validateImage(await response.blob()); + return filepath; + } catch (error) { + console.error('Error validating remote image:', error); + throw error; + } } case 'ShapefileSource': { @@ -546,7 +563,18 @@ export const loadFile = async (fileInfo: { case 'ImageSource': { if (typeof file.content === 'string') { const mimeType = getMimeType(filepath); - return `data:${mimeType};base64,${file.content}`; + if (!mimeType.startsWith('image/')) { + throw new Error(`Invalid image file. MIME type: ${mimeType}`); + } + + // Attempt to decode the base64 data + try { + await validateImage(await base64ToBlob(file.content, mimeType)); + return `data:${mimeType};base64,${file.content}`; + } catch (error) { + console.error('Error image content failed to decode.:', error); + throw error; + } } else { throw new Error('Invalid file format for image content.'); } @@ -562,6 +590,36 @@ export const loadFile = async (fileInfo: { } }; +/** + * Validates whether a given Blob represents a valid image. + * + * @param blob - The Blob to validate. + * @returns A promise that resolves if the Blob is a valid image, or rejects with an error otherwise. + */ +const validateImage = async (blob: Blob): Promise => { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(); // Valid image + img.onerror = () => reject(new Error('Invalid image content.')); + img.src = URL.createObjectURL(blob); + }); +}; + +/** + * Converts a base64-encoded string to a Blob. + * + * @param base64 - The base64-encoded string representing the file data. + * @param mimeType - The MIME type of the data. + * @returns A promise that resolves to a Blob representing the decoded data. + */ +export const base64ToBlob = async ( + base64: string, + mimeType: string +): Promise => { + const response = await fetch(`data:${mimeType};base64,${base64}`); + return await response.blob(); +}; + /** * A mapping of file extensions to their corresponding MIME types. */