diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index 73d83a3c90..2b1517a678 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -248,6 +248,13 @@ def getBlankThumbnail(): return getStaticUrl('img/blank_image.thumbnail.png') +def checkStaticFile(*args) -> bool: + """Check if a file exists in the static storage.""" + static_storage = StaticFilesStorage() + fn = os.path.join(*args) + return static_storage.exists(fn) + + def getLogoImage(as_file=False, custom=True): """Return the InvenTree logo image, or a custom logo if available.""" if custom and settings.CUSTOM_LOGO: diff --git a/src/backend/InvenTree/plugin/plugin.py b/src/backend/InvenTree/plugin/plugin.py index d5c58a0c86..2b3f5396af 100644 --- a/src/backend/InvenTree/plugin/plugin.py +++ b/src/backend/InvenTree/plugin/plugin.py @@ -1,6 +1,8 @@ """Base Class for InvenTree plugins.""" import inspect +import json +import re import warnings from datetime import datetime from distutils.sysconfig import get_python_lib # type: ignore[unresolved-import] @@ -9,6 +11,7 @@ from pathlib import Path from typing import Optional from django.conf import settings +from django.contrib.staticfiles.storage import StaticFilesStorage from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ @@ -578,35 +581,119 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase): # endregion + def get_static_path(self) -> list[str]: + """Return the path components to the plugin's static files.""" + return ['plugins', self.slug] + + def hashed_file_lookup(self, *args) -> str | None: + """Find a hashed version of the given file, if it exists. + + This is used to support cache busting for static files. + + Arguments: + *args: Path components to the static file (e.g. 'js', 'admin.js') + + Returns: + str: Path to the hashed version of the file if it exists, else None + """ + storage = StaticFilesStorage() + + # First, try to find a manifest file which maps original filenames to hashed filenames + # We only support vite manifest files - as generated by the plugin creator framework + manifest_file = str(Path(*self.get_static_path(), '.vite', 'manifest.json')) + + if not storage.exists(manifest_file): + return None + + # Read the contents of the manifest file + try: + with storage.open(manifest_file) as f: + manifest = json.load(f) + except json.JSONDecodeError: + logger.error(f"Failed to parse manifest file for plugin '{self.SLUG}'") + return None + + # Find the entry associated with the requested file + # Remove the file extension, as the manifest may contain hashed files with different extensions (e.g. .js, .css) + filename = str(args[-1] or '').split('.')[0] + pattern = re.compile(rf'{re.escape(filename)}\.(js|jsx|tsx)') + + for key, value in manifest.items(): + if re.search(pattern, key): + return value.get('file') + + return None + @mark_final - def plugin_static_file(self, *args) -> str: + def plugin_static_file( + self, *args, check_exists: bool = True, check_hash: bool = True + ) -> str: """Construct a path to a static file within the plugin directory. + Arguments: + *args: Path components to the static file (e.g. 'js', 'admin.js') + check_exists: If True, will check if the file actually exists on disk + check_hash: If True, will fallback to checking if the file has a hash in its name (for cache busting) + - This will return a URL can be used to access the static file - The path is constructed using the STATIC_URL setting and the plugin slug - Note: If the plugin is selected for "development" mode, the path will point to a vite server URL + Hash Checking: + + - Plugins may distribute static files with a hash in the filename for cache busting purposes (e.g. 'file-abc123.js'). + - If available, this file is priorities, and the non-hashed version is ignored. + - If no hashed file is available, the non-hashed version will be used (if it exists). + - If check_hash is False, the non-hashed version of the file will be used (even if a hashed version exists). + """ - import os - from django.conf import settings + from django.contrib.staticfiles.storage import StaticFilesStorage + # If the plugin is selected for development mode, use the development host + # This allows the plugin developer to run a local vite server and have the plugin load files directly from that server if ( settings.DEBUG and settings.PLUGIN_DEV_HOST and settings.PLUGIN_DEV_SLUG and self.SLUG == settings.PLUGIN_DEV_SLUG ): - # If the plugin is selected for development mode, use the development host pathname = '/'.join(list(args)) url = f'{settings.PLUGIN_DEV_HOST}/src/{pathname}' url = url.replace('.js', '.tsx') - else: - # Otherwise, construct the URL using the STATIC_URL setting - url = os.path.join(settings.STATIC_URL, 'plugins', self.SLUG, *args) + return url - if not url.startswith('/'): - url = '/' + url + storage = StaticFilesStorage() + + file_name = args[-1] or '' + + # The file may be specified with a function, e.g. 'file.js:renderFunction' + if ':' in file_name: + file_name, function_name = file_name.split(':')[:2] + else: + function_name = '' + + # Determine the preceding path to the file (if any) + file_path = args[:-1] + + # If enabled, check for a hashed version of the file + if check_hash: + file_name = self.hashed_file_lookup(*file_path, file_name) or file_name + + full_path = str(Path(*self.get_static_path(), *file_path, file_name)) + + if check_exists: + if not storage.exists(full_path): + logger.error( + f"Static file not found for plugin '{self.SLUG}': {full_path}" + ) + + # Resolve the URL to the static file + url = storage.url(full_path) + + # Re-append the function name (if provided) + if function_name: + url += f':{function_name}' return url diff --git a/src/backend/InvenTree/plugin/test_plugin.py b/src/backend/InvenTree/plugin/test_plugin.py index 3f57cf06ac..2d7c3eba04 100644 --- a/src/backend/InvenTree/plugin/test_plugin.py +++ b/src/backend/InvenTree/plugin/test_plugin.py @@ -209,6 +209,54 @@ class InvenTreePluginTests(TestCase): if plug: self.assertEqual(plug.is_active(), False) + def test_plugin_static_file_lookup(self): + """Test that the plugin static file lookup works as expected.""" + from django.contrib.staticfiles.storage import StaticFilesStorage + from django.core.files.base import ContentFile + + # Create a sample plugin with a known static file + class StaticFilePlugin(InvenTreePlugin): + NAME = 'StaticFilePlugin' + SLUG = 'static-file-test' + + def get_static_file_url(self, file_name): + return self.get_plugin_static_file(file_name) + + plugin = StaticFilePlugin() + storage = StaticFilesStorage() + + # A simple test to ensure the path is correctly resolved + self.assertEqual( + plugin.plugin_static_file( + 'sample.js', check_exists=False, check_hash=False + ), + storage.url('plugins/static-file-test/sample.js'), + ) + + manifest_path = 'plugins/static-file-test/.vite/manifest.json' + + manifest_data = textwrap.dedent("""{ + "src/sample.js": { + "file": "sample.123456.js", + "name": "sample", + "src": "src/sample.js", + "isEntry": true + } + }""") + + # A more comprehensive test - to find a hashed version of the file + # Note: This requires a manifest file to be present - let's create one + if not storage.exists(manifest_path): + storage.save(manifest_path, content=ContentFile(manifest_data)) + + lookup = plugin.plugin_static_file( + 'sample.js', check_exists=False, check_hash=True + ) + + self.assertEqual( + lookup, storage.url('plugins/static-file-test/sample.123456.js') + ) + class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase): """Tests for registry loading methods."""