diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9db6eef..f5a16bd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,7 +45,7 @@ repos: additional_dependencies: ['flake8-bugbear'] - repo: https://github.com/agritheory/test_utils - rev: v1.19.0 + rev: v1.20.3 hooks: - id: update_pre_commit_config - id: validate_frappe_project diff --git a/cloud_storage/cloud_storage/overrides/file.py b/cloud_storage/cloud_storage/overrides/file.py index 065e6ad..d7d1da1 100644 --- a/cloud_storage/cloud_storage/overrides/file.py +++ b/cloud_storage/cloud_storage/overrides/file.py @@ -751,3 +751,54 @@ def add_child_file_association(attached_to_doctype, attached_to_name): "user": frappe.session.user, "timestamp": get_datetime(), } + + +@frappe.whitelist() +def proxy_file(key: str): + """ + Fetch file from S3 server-side and stream back to browser. + Used for 3D preview to avoid CORS issues with direct S3 URLs. + """ + import requests # type: ignore[import-untyped] + + if not key: + frappe.throw(_("Key not found")) + + # Check file permissions + file = frappe.get_value("File", {"s3_key": key}, ["name", "is_private"], as_dict=True) + if not file: + frappe.throw(_("File not found")) + + if file.is_private: + file_doc = frappe.get_doc("File", file.name) + frappe.has_permission( + doctype="File", ptype="read", doc=file_doc, user=frappe.session.user, throw=True + ) + + # Get presigned URL and fetch content server-side + client = get_cloud_storage_client() + signed_url = client.generate_presigned_url( + ClientMethod="get_object", + Params={"Bucket": client.bucket, "Key": key}, + ExpiresIn=60, + ) + + response = requests.get(signed_url) + response.raise_for_status() + + # Return file content directly to browser + ext = key.split(".")[-1].lower() + content_types = { + "obj": "text/plain", + "glb": "model/gltf-binary", + "gltf": "model/gltf+json", + "stl": "model/stl", + "ply": "application/octet-stream", + "fbx": "application/octet-stream", + "dae": "model/vnd.collada+xml", + } + + frappe.local.response.filename = key.split("/")[-1] + frappe.local.response.filecontent = response.content + frappe.local.response.type = "download" + frappe.local.response["Content-Type"] = content_types.get(ext, "application/octet-stream") diff --git a/cloud_storage/public/js/components/FilePreview.vue b/cloud_storage/public/js/components/FilePreview.vue index 4e79db3..9338d98 100644 --- a/cloud_storage/public/js/components/FilePreview.vue +++ b/cloud_storage/public/js/components/FilePreview.vue @@ -1,183 +1,150 @@ diff --git a/cloud_storage/public/js/file.js b/cloud_storage/public/js/file.js index 236168f..1e12c1c 100644 --- a/cloud_storage/public/js/file.js +++ b/cloud_storage/public/js/file.js @@ -1,10 +1,17 @@ // Copyright (c) 2025, AgriTheory and contributors // For license information, please see license.txt +const THREE_D_EXTENSIONS = ['obj', 'glb', 'gltf', 'fbx', 'dae', 'ply', 'stl'] + +function is_3d_file(frm) { + const file_string = (frm.doc.file_type || frm.doc.file_name || '').toLowerCase() + return THREE_D_EXTENSIONS.some(ext => file_string.endsWith(ext)) +} + frappe.ui.form.on('File', { refresh: frm => { if (!frm.doc.is_folder) { - // add download button + // Share buttons frm.add_custom_button(__('Get Sharing Link', 'Share'), () => get_sharing_link(frm, false)) if (frm.doc.sharing_link) { frm.add_custom_button(__('Reset Sharing Link', 'Share'), () => get_sharing_link(frm, true)) @@ -12,20 +19,30 @@ frappe.ui.form.on('File', { } let file_string = frm.doc.file_type || frm.doc.file_name - file_string = file_string.toLowerCase() - if (['doc', 'docx'].some(extension => file_string.includes(extension))) { + + if (['doc', 'docx'].some(ext => file_string.includes(ext))) { frm.trigger('preview_doc_content') - } else if (['ppt', 'pptx', 'odp', 'key'].some(extension => file_string.includes(extension))) { + } else if (['ppt', 'pptx', 'odp', 'key'].some(ext => file_string.includes(ext))) { frm.trigger('preview_file_as_pdf') + } else if (is_3d_file(frm)) { + frm.trigger('preview_3d') } }, + preview_3d: async function (frm) { + const field = frm.get_field('preview_html') + const container = document.createElement('div') + container.style.cssText = 'width:100%;height:500px;background:#1a1a2e;border-radius:6px;position:relative;' + field.$wrapper.html(container) + frm.toggle_display('preview', true) + await render_3d_in_container(container, frm.doc.file_url, frm.doc.file_name) + }, + preview_file_as_pdf: async function (frm) { const response = await frm.call('get_pdf_preview') let pdf_content = response.message if (pdf_content) { - // Always treat as base64 string from backend const byteCharacters = atob(pdf_content) const byteNumbers = new Array(byteCharacters.length) for (let i = 0; i < byteCharacters.length; i++) { @@ -69,16 +86,12 @@ frappe.ui.form.on('File', { preview_file: function (frm) { let $preview = '' const file_extension = frm.doc.file_type.toLowerCase() - // Cloud Storage: replace # with %23 in PDFs const file_url = frm.doc.file_url.replace(/#/g, '%23') if (frappe.utils.is_image_file(file_url)) { $preview = $(`
- +
`) } else if (frappe.utils.is_video_file(file_url)) { $preview = $(`
@@ -90,12 +103,8 @@ frappe.ui.form.on('File', { } else if (file_extension === 'pdf') { $preview = $(`
- +
`) } else if (file_extension === 'mp3') { @@ -103,7 +112,7 @@ frappe.ui.form.on('File', { +
`) } @@ -121,3 +130,177 @@ function get_sharing_link(frm, reset) { frappe.msgprint(r, __('Sharing Link')) }) } + +const THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@0.158.0/build/three.module.js' +const CDN = 'https://cdn.jsdelivr.net/npm/three@0.158.0/examples/jsm' + +function inject_importmap() { + if (document.querySelector('script[type="importmap"]')) return + const map = document.createElement('script') + map.type = 'importmap' + map.textContent = JSON.stringify({ + imports: { + three: THREE_CDN, + 'three/addons/': `${CDN}/`, + }, + }) + document.head.prepend(map) +} + +async function render_3d_in_container(container, file_url, filename) { + inject_importmap() + const $loading = $( + '
Loading 3D model...
' + ) + $(container).append($loading) + + try { + const key = new URLSearchParams(file_url.split('?')[1]).get('key') + const proxy_url = `/api/method/cloud_storage.cloud_storage.overrides.file.proxy_file?key=${encodeURIComponent(key)}` + + const response = await fetch(proxy_url, { + credentials: 'same-origin', + headers: { 'X-Frappe-CSRF-Token': frappe.csrf_token }, + }) + if (!response.ok) throw new Error(`Failed to fetch file: ${response.status}`) + const blob = await response.blob() + const blob_url = URL.createObjectURL(blob) + + const THREE = await import(THREE_CDN) + const { OrbitControls } = await import(`${CDN}/controls/OrbitControls.js`) + + const w = container.clientWidth + const h = container.clientHeight + + const scene = new THREE.Scene() + scene.background = new THREE.Color(0x1a1a2e) + scene.add(new THREE.GridHelper(10, 20, 0x444444, 0x333333)) + + const camera = new THREE.PerspectiveCamera(60, w / h, 0.01, 10000) + camera.position.set(3, 3, 3) + + const renderer = new THREE.WebGLRenderer({ antialias: true }) + renderer.setSize(w, h) + renderer.setPixelRatio(window.devicePixelRatio) + renderer.shadowMap.enabled = true + container.appendChild(renderer.domElement) + + const controls = new OrbitControls(camera, renderer.domElement) + controls.enableDamping = true + controls.dampingFactor = 0.05 + + scene.add(new THREE.AmbientLight(0xffffff, 0.6)) + const dir = new THREE.DirectionalLight(0xffffff, 1) + dir.position.set(5, 10, 5) + dir.castShadow = true + scene.add(dir) + scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.4)) + + const ext = filename.split('.').pop().toLowerCase() + const object = await load_3d_model(THREE, ext, blob_url) + + const box = new THREE.Box3().setFromObject(object) + const size = box.getSize(new THREE.Vector3()).length() + const center = box.getCenter(new THREE.Vector3()) + object.position.sub(center) + camera.near = size / 100 + camera.far = size * 100 + camera.position.set(size, size, size) + camera.lookAt(0, 0, 0) + camera.updateProjectionMatrix() + controls.maxDistance = size * 10 + controls.update() + + scene.add(object) + $loading.remove() + + let running = true + function animate() { + if (!running) return + requestAnimationFrame(animate) + controls.update() + renderer.render(scene, camera) + } + animate() + + const on_resize = () => { + const w = container.clientWidth + const h = container.clientHeight + camera.aspect = w / h + camera.updateProjectionMatrix() + renderer.setSize(w, h) + } + window.addEventListener('resize', on_resize) + + dialog.$wrapper.on('hidden.bs.modal', () => { + running = false + renderer.dispose() + URL.revokeObjectURL(blob_url) + window.removeEventListener('resize', on_resize) + }) + } catch (e) { + $loading.text('Failed to load 3D model: ' + e.message).css('color', '#ff6b6b') + console.error('3D Preview error:', e) + } +} + +async function load_3d_model(THREE, ext, file_url) { + switch (ext) { + case 'glb': + case 'gltf': { + const { GLTFLoader } = await import(`${CDN}/loaders/GLTFLoader.js`) + const { DRACOLoader } = await import(`${CDN}/loaders/DRACOLoader.js`) + const draco = new DRACOLoader() + draco.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.6/') + const loader = new GLTFLoader() + loader.setDRACOLoader(draco) + return new Promise((res, rej) => loader.load(file_url, g => res(g.scene), undefined, rej)) + } + case 'obj': { + const { OBJLoader } = await import(`${CDN}/loaders/OBJLoader.js`) + return new Promise((res, rej) => new OBJLoader().load(file_url, res, undefined, rej)) + } + case 'stl': { + const { STLLoader } = await import(`${CDN}/loaders/STLLoader.js`) + return new Promise((res, rej) => + new STLLoader().load( + file_url, + geometry => { + const mat = new THREE.MeshStandardMaterial({ color: 0x888888, metalness: 0.3, roughness: 0.6 }) + res(new THREE.Mesh(geometry, mat)) + }, + undefined, + rej + ) + ) + } + case 'ply': { + const { PLYLoader } = await import(`${CDN}/loaders/PLYLoader.js`) + return new Promise((res, rej) => + new PLYLoader().load( + file_url, + geometry => { + geometry.computeVertexNormals() + const mat = new THREE.MeshStandardMaterial({ + color: 0x888888, + vertexColors: geometry.hasAttribute('color'), + }) + res(new THREE.Mesh(geometry, mat)) + }, + undefined, + rej + ) + ) + } + case 'fbx': { + const { FBXLoader } = await import(`${CDN}/loaders/FBXLoader.js`) + return new Promise((res, rej) => new FBXLoader().load(file_url, res, undefined, rej)) + } + case 'dae': { + const { ColladaLoader } = await import(`${CDN}/loaders/ColladaLoader.js`) + return new Promise((res, rej) => new ColladaLoader().load(file_url, c => res(c.scene), undefined, rej)) + } + default: + throw new Error(`Unsupported format: .${ext}`) + } +} diff --git a/cloud_storage/public/js/overrides.js b/cloud_storage/public/js/overrides.js index 5016c0c..d99ea30 100644 --- a/cloud_storage/public/js/overrides.js +++ b/cloud_storage/public/js/overrides.js @@ -31,139 +31,134 @@ function disallow_attachment_delete(frm) { } } -// TODO: full class override from Frappe's file_uploader.bundle.js file; keep in sync -frappe.provide('frappe.ui') -frappe.ui.FileUploader = class CloudStorageFileUploader { - constructor({ - wrapper, - method, - on_success, - doctype, - docname, - fieldname, - files, - folder, - restrictions = {}, - upload_notes, - allow_multiple, - as_dataurl, - disable_file_browser, - dialog_title, - attach_doc_image, - frm, - make_attachments_public, - } = {}) { - frm && frm.attachments.max_reached(true) - - if (!wrapper) { - this.make_dialog(dialog_title) - } else { - this.wrapper = wrapper.get ? wrapper.get(0) : wrapper - } - - if (restrictions && !restrictions.allowed_file_types) { - // apply global allow list if present - let allowed_extensions = frappe.sys_defaults?.allowed_file_extensions - if (allowed_extensions) { - restrictions.allowed_file_types = allowed_extensions.split('\n').map(ext => `.${ext}`) - } - } - - let app = createApp(FileUploaderComponent, { - show_upload_button: !Boolean(this.dialog), - doctype, - docname, - fieldname, - method, - folder, - on_success, - restrictions, - upload_notes, - allow_multiple, - as_dataurl, - disable_file_browser, - attach_doc_image, - make_attachments_public, - }) - SetVueGlobals(app) - this.uploader = app.mount(this.wrapper) - - if (!this.dialog) { - this.uploader.wrapper_ready = true - } +// Wait for ALL Frappe bundles to finish loading, then override +document.addEventListener('DOMContentLoaded', () => { + setTimeout(() => { + frappe.ui.FileUploader = class CloudStorageFileUploader { + constructor({ + wrapper, + method, + on_success, + doctype, + docname, + fieldname, + files, + folder, + restrictions = {}, + upload_notes, + allow_multiple, + as_dataurl, + disable_file_browser, + dialog_title, + attach_doc_image, + frm, + make_attachments_public, + } = {}) { + frm && frm.attachments.max_reached(true) + + if (!wrapper) { + this.make_dialog(dialog_title) + } else { + this.wrapper = wrapper.get ? wrapper.get(0) : wrapper + } - watch( - () => this.uploader.files, - files => { - let all_private = files.every(file => file.private) - if (this.dialog) { - this.dialog.set_secondary_action_label(all_private ? __('Set all public') : __('Set all private')) + if (restrictions && !restrictions.allowed_file_types) { + let allowed_extensions = frappe.sys_defaults?.allowed_file_extensions + if (allowed_extensions) { + restrictions.allowed_file_types = allowed_extensions.split('\n').map(ext => `.${ext}`) + } } - }, - { deep: true } - ) - - watch( - () => this.uploader.trigger_upload, - trigger_upload => { - if (trigger_upload) { - this.upload_files() + + let app = createApp(FileUploaderComponent, { + show_upload_button: !Boolean(this.dialog), + doctype, + docname, + fieldname, + method, + folder, + on_success, + restrictions, + upload_notes, + allow_multiple, + as_dataurl, + disable_file_browser, + attach_doc_image, + make_attachments_public, + }) + SetVueGlobals(app) + this.uploader = app.mount(this.wrapper) + + if (!this.dialog) { + this.uploader.wrapper_ready = true } - } - ) - watch( - () => this.uploader.close_dialog, - close_dialog => { - if (close_dialog) { - this.dialog && this.dialog.hide() + watch( + () => this.uploader.files, + files => { + let all_private = files.every(file => file.private) + if (this.dialog) { + this.dialog.set_secondary_action_label(all_private ? __('Set all public') : __('Set all private')) + } + }, + { deep: true } + ) + + watch( + () => this.uploader.trigger_upload, + trigger_upload => { + if (trigger_upload) this.upload_files() + } + ) + + watch( + () => this.uploader.close_dialog, + close_dialog => { + if (close_dialog) this.dialog && this.dialog.hide() + } + ) + + watch( + () => this.uploader.hide_dialog_footer, + hide_dialog_footer => { + if (hide_dialog_footer) { + this.dialog && this.dialog.footer.addClass('hide') + this.dialog.$wrapper.data('bs.modal')._config.backdrop = 'static' + } else { + this.dialog && this.dialog.footer.removeClass('hide') + this.dialog.$wrapper.data('bs.modal')._config.backdrop = true + } + } + ) + + if (files && files.length) { + this.uploader.add_files(files) } } - ) - - watch( - () => this.uploader.hide_dialog_footer, - hide_dialog_footer => { - if (hide_dialog_footer) { - this.dialog && this.dialog.footer.addClass('hide') - this.dialog.$wrapper.data('bs.modal')._config.backdrop = 'static' - } else { - this.dialog && this.dialog.footer.removeClass('hide') - this.dialog.$wrapper.data('bs.modal')._config.backdrop = true - } + + upload_files() { + this.dialog && this.dialog.get_primary_btn().prop('disabled', true) + this.dialog && this.dialog.get_secondary_btn().prop('disabled', true) + return this.uploader.upload_files() } - ) - if (files && files.length) { - this.uploader.add_files(files) + make_dialog(title) { + this.dialog = new frappe.ui.Dialog({ + title: title || __('Upload'), + primary_action_label: __('Upload'), + primary_action: () => this.upload_files(), + secondary_action_label: __('Set all private'), + secondary_action: () => this.uploader.toggle_all_private(), + on_page_show: () => { + this.uploader.wrapper_ready = true + }, + }) + this.wrapper = this.dialog.body + this.dialog.show() + this.dialog.$wrapper.on('hidden.bs.modal', function () { + $(this).data('bs.modal', null) + $(this).remove() + }) + } } - } - - upload_files() { - this.dialog && this.dialog.get_primary_btn().prop('disabled', true) - this.dialog && this.dialog.get_secondary_btn().prop('disabled', true) - return this.uploader.upload_files() - } - - make_dialog(title) { - this.dialog = new frappe.ui.Dialog({ - title: title || __('Upload'), - primary_action_label: __('Upload'), - primary_action: () => this.upload_files(), - secondary_action_label: __('Set all private'), - secondary_action: () => { - this.uploader.toggle_all_private() - }, - on_page_show: () => { - this.uploader.wrapper_ready = true - }, - }) - - this.wrapper = this.dialog.body - this.dialog.show() - this.dialog.$wrapper.on('hidden.bs.modal', function () { - $(this).data('bs.modal', null) - $(this).remove() - }) - } -} + }, 500) +})