From cd4ffb2c90e331cdfe527ca3d63ce2741da048f7 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Sat, 30 May 2026 11:15:50 +0200 Subject: [PATCH] added 360 panoramas supporting --- backend/src/db/api/assets.js | 12 +- backend/src/db/api/element_type_defaults.js | 10 +- .../20260529000001-add-embed-asset-support.js | 49 ++++ backend/src/db/models/assets.js | 18 +- backend/src/services/assets.js | 94 +++++- .../notifications/errors/validation.js | 15 +- .../components/Assets/AssetSectionCard.tsx | 17 +- .../components/Assets/EmbedAssetSection.tsx | 273 ++++++++++++++++++ .../InfoPanelSettingsSectionCompact.tsx | 48 +-- .../src/components/RuntimePresentation.tsx | 4 +- .../UiElements/ImageDetailPanel.tsx | 239 +++++++++++---- .../UiElements/InfoPanelOverlay.tsx | 45 +-- .../shared/useElementWrapperStyle.ts | 6 +- frontend/src/hooks/useAssetOptions.ts | 7 +- frontend/src/lib/elementDefaults.ts | 3 +- frontend/src/pages/assets/assets-list.tsx | 61 +++- frontend/src/pages/constructor.tsx | 4 +- frontend/src/schemas/assetSchema.ts | 9 +- frontend/src/types/constructor.ts | 10 +- frontend/src/types/entities.ts | 9 +- frontend/tsconfig.json | 4 +- 21 files changed, 774 insertions(+), 163 deletions(-) create mode 100644 backend/src/db/migrations/20260529000001-add-embed-asset-support.js create mode 100644 frontend/src/components/Assets/EmbedAssetSection.tsx diff --git a/backend/src/db/api/assets.js b/backend/src/db/api/assets.js index c347d4e..54da223 100644 --- a/backend/src/db/api/assets.js +++ b/backend/src/db/api/assets.js @@ -11,7 +11,14 @@ class AssetsDBApi extends GenericDBApi { } static get SEARCHABLE_FIELDS() { - return ['name', 'cdn_url', 'storage_key', 'mime_type', 'checksum']; + return [ + 'name', + 'cdn_url', + 'storage_key', + 'mime_type', + 'checksum', + 'embed_provider', + ]; } static get RANGE_FIELDS() { @@ -83,8 +90,11 @@ class AssetsDBApi extends GenericDBApi { width_px: data.width_px || null, height_px: data.height_px || null, duration_sec: data.duration_sec || null, + embed_code: data.embed_code || null, + embed_provider: data.embed_provider || null, checksum: data.checksum || null, is_public: data.is_public || false, + projectId: data.projectId || data.project || null, }; } } diff --git a/backend/src/db/api/element_type_defaults.js b/backend/src/db/api/element_type_defaults.js index 8bf27c6..4991701 100644 --- a/backend/src/db/api/element_type_defaults.js +++ b/backend/src/db/api/element_type_defaults.js @@ -220,7 +220,7 @@ class Element_type_defaultsDBApi extends GenericDBApi { sort_order: 12, default_settings_json: { label: 'Info Panel', - // Trigger position + // Trigger position (width/height intentionally not set - sizes based on content) xPercent: 5, yPercent: 90, infoPanelTriggerFontFamily: '', @@ -309,7 +309,13 @@ class Element_type_defaultsDBApi extends GenericDBApi { { id: 'default-header', type: 'header' }, { id: 'default-title', type: 'title' }, { id: 'default-text', type: 'text' }, - { id: 'default-spans', type: 'spans', columns: 3, gap: '8', spans: [] }, + { + id: 'default-spans', + type: 'spans', + columns: 3, + gap: '8', + spans: [], + }, { id: 'default-images', type: 'images', images: [] }, ], // Images section settings diff --git a/backend/src/db/migrations/20260529000001-add-embed-asset-support.js b/backend/src/db/migrations/20260529000001-add-embed-asset-support.js new file mode 100644 index 0000000..b6ed063 --- /dev/null +++ b/backend/src/db/migrations/20260529000001-add-embed-asset-support.js @@ -0,0 +1,49 @@ +'use strict'; + +/** + * Migration: Add embed asset support + * + * Adds support for storing embed assets (360 panoramas, 3D models, iframes): + * - Add 'embed' to asset_type ENUM + * - Add embed types to type ENUM (embed_360, embed_3d, embed_iframe) + * - Add embed_code column (stores full iframe HTML or direct URL) + * - Add embed_provider column (extracted provider name: matterport, kuula, etc.) + */ + +module.exports = { + async up(queryInterface, Sequelize) { + // Add 'embed' to asset_type ENUM + await queryInterface.sequelize.query(` + ALTER TYPE "enum_assets_asset_type" ADD VALUE IF NOT EXISTS 'embed'; + `); + + // Add embed types to type ENUM (categories) + await queryInterface.sequelize.query(` + ALTER TYPE "enum_assets_type" ADD VALUE IF NOT EXISTS 'embed_360'; + `); + await queryInterface.sequelize.query(` + ALTER TYPE "enum_assets_type" ADD VALUE IF NOT EXISTS 'embed_3d'; + `); + await queryInterface.sequelize.query(` + ALTER TYPE "enum_assets_type" ADD VALUE IF NOT EXISTS 'embed_iframe'; + `); + + // Add embed_code column (stores full iframe HTML or direct URL) + await queryInterface.addColumn('assets', 'embed_code', { + type: Sequelize.TEXT, + allowNull: true, + }); + + // Add embed_provider column (extracted: 'matterport', 'kuula', etc.) + await queryInterface.addColumn('assets', 'embed_provider', { + type: Sequelize.TEXT, + allowNull: true, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('assets', 'embed_code'); + await queryInterface.removeColumn('assets', 'embed_provider'); + // Note: PostgreSQL doesn't support removing ENUM values + }, +}; diff --git a/backend/src/db/models/assets.js b/backend/src/db/models/assets.js index 176a46f..6ec0c44 100644 --- a/backend/src/db/models/assets.js +++ b/backend/src/db/models/assets.js @@ -22,7 +22,7 @@ module.exports = function (sequelize, DataTypes) { type: DataTypes.ENUM, allowNull: false, - values: ['image', 'video', 'audio', 'file'], + values: ['image', 'video', 'audio', 'file', 'embed'], }, type: { @@ -48,6 +48,12 @@ module.exports = function (sequelize, DataTypes) { 'document', 'general', + + 'embed_360', + + 'embed_3d', + + 'embed_iframe', ], }, @@ -85,6 +91,16 @@ module.exports = function (sequelize, DataTypes) { type: DataTypes.DECIMAL, }, + embed_code: { + type: DataTypes.TEXT, + allowNull: true, + }, + + embed_provider: { + type: DataTypes.TEXT, + allowNull: true, + }, + checksum: { type: DataTypes.TEXT, }, diff --git a/backend/src/services/assets.js b/backend/src/services/assets.js index d49079a..8d8cfae 100644 --- a/backend/src/services/assets.js +++ b/backend/src/services/assets.js @@ -21,13 +21,80 @@ const VALID_MIME_PATTERNS = { prefixes: ['audio/'], description: 'audio (mp3, wav, ogg, etc.)', }, + embed: { + // Embeds don't have MIME types - skip validation + prefixes: [], + description: 'embed (360/3D iframe)', + skipValidation: true, + }, }; +/** + * Allowed domains for embed URLs (security: only trusted providers) + */ +const ALLOWED_EMBED_DOMAINS = [ + 'matterport.com', + 'my.matterport.com', + 'kuula.co', + 'roundme.com', + 'sketchfab.com', + 'youtube.com', + 'www.youtube.com', + 'vimeo.com', + 'player.vimeo.com', + 'google.com', + 'maps.google.com', + '360stories.com', +]; + +/** + * Extract and validate embed URL from iframe code or direct URL + * @param {string} embedCode - Full iframe HTML or direct URL + * @returns {{ url: string, provider: string }} + * @throws {ValidationError} If URL is invalid or domain not allowed + */ +function extractEmbedUrl(embedCode) { + if (!embedCode?.trim()) { + throw new ValidationError('Embed code is required'); + } + + // Extract src from iframe HTML + const srcMatch = embedCode.match(/src=["']([^"']+)["']/i); + const urlString = srcMatch ? srcMatch[1] : embedCode.trim(); + + let url; + try { + url = new URL(urlString); + } catch { + throw new ValidationError('Invalid embed URL format'); + } + + if (url.protocol !== 'https:') { + throw new ValidationError('Embed URL must use HTTPS'); + } + + const hostname = url.hostname.replace(/^www\./, ''); + const isAllowed = ALLOWED_EMBED_DOMAINS.some( + (domain) => hostname === domain || hostname.endsWith('.' + domain), + ); + + if (!isAllowed) { + throw new ValidationError( + `Untrusted domain: ${hostname}. Allowed: ${ALLOWED_EMBED_DOMAINS.join(', ')}`, + ); + } + + // Extract provider name (e.g., 'matterport', 'kuula') + const provider = hostname.split('.').slice(-2, -1)[0]; + + return { url: urlString, provider }; +} + /** * Validate that mime_type matches asset_type - * @param {string} assetType - Expected asset type (image, video, audio) + * @param {string} assetType - Expected asset type (image, video, audio, embed) * @param {string} mimeType - Actual MIME type of the file - * @returns {{ valid: boolean, error?: string }} + * @returns {{ valid: boolean, error?: string, skipValidation?: boolean }} */ function validateAssetMimeType(assetType, mimeType) { // If no asset_type specified, skip validation @@ -42,6 +109,11 @@ function validateAssetMimeType(assetType, mimeType) { return { valid: true }; } + // If patterns has skipValidation flag (e.g., embeds), skip MIME validation + if (patterns.skipValidation) { + return { valid: true, skipValidation: true }; + } + // If no mime_type provided, we can't validate but allow it // (browser may not always send mime type) if (!mimeType) { @@ -75,7 +147,7 @@ const BaseService = createEntityService(AssetsDBApi, { */ class AssetsService extends BaseService { /** - * Create asset with MIME type validation and video pre-processing + * Create asset with MIME type validation, embed URL validation, and video pre-processing */ static async create(data, currentUser) { // Validate asset_type and mime_type match @@ -87,6 +159,13 @@ class AssetsService extends BaseService { throw new ValidationError(validation.error); } + // Handle embed assets: extract and validate URL, set provider + if (assetType === 'embed') { + const { url, provider } = extractEmbedUrl(data.embed_code); + data.cdn_url = url; // Store extracted URL for easy access + data.embed_provider = provider; + } + // Call parent create const asset = await super.create(data, currentUser); @@ -98,7 +177,7 @@ class AssetsService extends BaseService { } /** - * Update asset with MIME type validation + * Update asset with MIME type validation and embed URL validation */ static async update(data, id, currentUser) { // If updating asset_type or mime_type, validate they match @@ -115,6 +194,13 @@ class AssetsService extends BaseService { } } + // Handle embed assets: if embed_code is being updated, re-extract URL and provider + if (data.embed_code) { + const { url, provider } = extractEmbedUrl(data.embed_code); + data.cdn_url = url; + data.embed_provider = provider; + } + // Call parent update return super.update(data, id, currentUser); } diff --git a/backend/src/services/notifications/errors/validation.js b/backend/src/services/notifications/errors/validation.js index 464550c..fc65457 100644 --- a/backend/src/services/notifications/errors/validation.js +++ b/backend/src/services/notifications/errors/validation.js @@ -1,14 +1,13 @@ -const { getNotification, isNotification } = require('../helpers'); +const { getNotification } = require('../helpers'); module.exports = class ValidationError extends Error { constructor(messageCode) { - let message; - - if (messageCode && isNotification(messageCode)) { - message = getNotification(messageCode); - } - - message = message || getNotification('errors.validation.message'); + // getNotification returns the translated message if key exists, + // or the key itself as the message if not found + // This allows both notification keys and plain string messages + const message = messageCode + ? getNotification(messageCode) + : getNotification('errors.validation.message'); super(message); this.code = 400; diff --git a/frontend/src/components/Assets/AssetSectionCard.tsx b/frontend/src/components/Assets/AssetSectionCard.tsx index bec0254..53ecca4 100644 --- a/frontend/src/components/Assets/AssetSectionCard.tsx +++ b/frontend/src/components/Assets/AssetSectionCard.tsx @@ -6,7 +6,7 @@ import UploadProgressList, { UploadQueueItem } from './UploadProgressList'; export type Asset = { id: string; name: string; - asset_type: 'image' | 'video' | 'audio' | 'file'; + asset_type: 'image' | 'video' | 'audio' | 'file' | 'embed'; type?: | 'icon' | 'background_image' @@ -16,9 +16,14 @@ export type Asset = { | 'logo' | 'favicon' | 'document' - | 'general'; + | 'general' + | 'embed_360' + | 'embed_3d' + | 'embed_iframe'; cdn_url?: string | null; mime_type?: string | null; + embed_code?: string | null; + embed_provider?: string | null; }; export type AssetSection = { @@ -28,12 +33,14 @@ export type AssetSection = { | 'audio' | 'video' | 'transitions' - | 'logo'; + | 'logo' + | 'embeds'; label: string; - accept: string; - assetFormat: 'image' | 'video' | 'audio'; + accept: string | null; // null for sections without file upload (embeds) + assetFormat: 'image' | 'video' | 'audio' | 'embed'; assetCategory: NonNullable; legacyTag: string; + isEmbed?: boolean; // Flag to render EmbedAssetSection instead of file input }; type AssetSectionCardProps = { diff --git a/frontend/src/components/Assets/EmbedAssetSection.tsx b/frontend/src/components/Assets/EmbedAssetSection.tsx new file mode 100644 index 0000000..22c3c7e --- /dev/null +++ b/frontend/src/components/Assets/EmbedAssetSection.tsx @@ -0,0 +1,273 @@ +/** + * EmbedAssetSection Component + * + * Form for adding 360/3D embed assets (Matterport, Kuula, Sketchfab, etc.) + * Validates embed URLs against a trusted domain allowlist on the backend. + */ + +import React, { useState, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { create, deleteItem } from '../../stores/assets/assetsSlice'; +import { queryKeys } from '../../lib/queryClient'; +import CardBox from '../CardBox'; +import BaseButton from '../BaseButton'; +import type { Asset } from './AssetSectionCard'; + +interface EmbedAssetSectionProps { + projectId: string; + onAssetCreated?: () => void; + hasCreatePermission: boolean; + hasDeletePermission?: boolean; + disabled: boolean; +} + +type EmbedType = 'embed_360' | 'embed_3d' | 'embed_iframe'; + +const EMBED_TYPE_OPTIONS: { value: EmbedType; label: string }[] = [ + { value: 'embed_360', label: '360 Panorama' }, + { value: 'embed_3d', label: '3D Model' }, + { value: 'embed_iframe', label: 'Other Embed' }, +]; + +const EmbedAssetSection: React.FC = ({ + projectId, + onAssetCreated, + hasCreatePermission, + hasDeletePermission = false, + disabled, +}) => { + const dispatch = useAppDispatch(); + const queryClient = useQueryClient(); + const assets = useAppSelector((state) => state.assets.data) as Asset[]; + const isLoadingAssets = useAppSelector((state) => state.assets.loading); + + const [name, setName] = useState(''); + const [embedCode, setEmbedCode] = useState(''); + const [embedType, setEmbedType] = useState('embed_360'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(''); + const [isExpanded, setIsExpanded] = useState(false); + const [deletingAssetId, setDeletingAssetId] = useState(''); + + // Filter embed assets + const embedAssets = assets.filter( + (asset) => + asset.type === 'embed_360' || + asset.type === 'embed_3d' || + asset.type === 'embed_iframe', + ); + + const handleSubmit = useCallback(async () => { + if (!embedCode.trim()) { + setError('Please enter embed code or URL'); + return; + } + + setIsSubmitting(true); + setError(''); + + try { + await dispatch( + create({ + name: name.trim() || 'Embed', + asset_type: 'embed', + type: embedType, + embed_code: embedCode.trim(), + projectId, + } as Parameters[0]), + ).unwrap(); + + // Reset form + setName(''); + setEmbedCode(''); + + // Invalidate React Query assets cache so constructor sees the new embed + await queryClient.invalidateQueries({ queryKey: queryKeys.assets.all }); + + onAssetCreated?.(); + } catch (err: unknown) { + const errorMessage = + err instanceof Error + ? err.message + : (err as { message?: string })?.message || 'Failed to save embed'; + setError(errorMessage); + } finally { + setIsSubmitting(false); + } + }, [ + dispatch, + queryClient, + name, + embedCode, + embedType, + projectId, + onAssetCreated, + ]); + + const handleDeleteAsset = useCallback( + async (assetId: string) => { + setDeletingAssetId(assetId); + try { + await dispatch(deleteItem(assetId)).unwrap(); + + // Invalidate React Query assets cache so constructor sees the deletion + await queryClient.invalidateQueries({ queryKey: queryKeys.assets.all }); + + onAssetCreated?.(); // Refresh assets list + } catch { + // Error handling is done globally + } finally { + setDeletingAssetId(''); + } + }, + [dispatch, queryClient, onAssetCreated], + ); + + return ( + +
+

360/3D Embeds

+ {!hasCreatePermission && ( +

+ You do not have upload permission +

+ )} +
+ +
+
+ + setName(e.target.value)} + placeholder='My 360 Tour' + disabled={disabled || !hasCreatePermission || isSubmitting} + className='w-full border border-gray-300 dark:border-dark-700 rounded px-3 py-2 text-sm bg-white dark:bg-dark-800' + /> +
+ +
+ + +
+ +
+ +